feat: improve save/load interface with custom names and metadata (#128)
* feat: improve save/load interface with custom names and metadata - Add custom save name support with input validation - Extend save metadata with avatar counts, protagonist info, and event count - Add quick save button alongside named save option - Enhance save list display with richer information - Add sanitize_save_name and find_protagonist_name helpers - Update API endpoints to support new features - Add i18n translations for new UI elements Closes #95 * test: add comprehensive tests for save custom name feature - Add 37 tests for sanitize_save_name, find_protagonist_name - Add tests for custom name API endpoints - Add tests for enhanced metadata - Fix unused NIcon import in SaveLoadPanel - Add zh-TW translations for new save_load keys * test(frontend): add SaveLoadPanel component tests - Add 21 tests for SaveLoadPanel component - Cover save mode, load mode, display, validation - Mock naive-ui components, stores, and API
This commit is contained in:
494
web/src/__tests__/components/panels/SaveLoadPanel.test.ts
Normal file
494
web/src/__tests__/components/panels/SaveLoadPanel.test.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import SaveLoadPanel from '@/components/game/panels/system/SaveLoadPanel.vue'
|
||||
import type { SaveFileDTO } from '@/types/api'
|
||||
|
||||
// Use real timers for this test file since we need async operations.
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
// Mock naive-ui components.
|
||||
vi.mock('naive-ui', () => ({
|
||||
NModal: {
|
||||
name: 'NModal',
|
||||
template: '<div class="n-modal" v-if="show"><slot /><slot name="footer" /></div>',
|
||||
props: ['show', 'title', 'preset', 'maskClosable', 'closable'],
|
||||
},
|
||||
NInput: {
|
||||
name: 'NInput',
|
||||
template: '<input class="n-input" :value="value" @input="$emit(\'update:value\', $event.target.value)" :placeholder="placeholder" :disabled="disabled" />',
|
||||
props: ['value', 'placeholder', 'status', 'disabled'],
|
||||
emits: ['update:value'],
|
||||
},
|
||||
NButton: {
|
||||
name: 'NButton',
|
||||
template: '<button class="n-button" :disabled="disabled || loading" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['type', 'loading', 'disabled'],
|
||||
emits: ['click'],
|
||||
},
|
||||
NSpin: {
|
||||
name: 'NSpin',
|
||||
template: '<div class="n-spin"></div>',
|
||||
props: ['size'],
|
||||
},
|
||||
NTooltip: {
|
||||
name: 'NTooltip',
|
||||
template: '<div class="n-tooltip"><slot name="trigger" /><slot /></div>',
|
||||
props: ['trigger'],
|
||||
},
|
||||
useMessage: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock stores.
|
||||
vi.mock('@/stores/world', () => ({
|
||||
useWorldStore: () => ({
|
||||
reset: vi.fn(),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/ui', () => ({
|
||||
useUiStore: () => ({
|
||||
clearSelection: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API.
|
||||
vi.mock('@/api', () => ({
|
||||
systemApi: {
|
||||
fetchSaves: vi.fn(),
|
||||
saveGame: vi.fn(),
|
||||
loadGame: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { systemApi } from '@/api'
|
||||
|
||||
// Create i18n instance for tests.
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en-US',
|
||||
messages: {
|
||||
'en-US': {
|
||||
save_load: {
|
||||
loading: 'Loading...',
|
||||
new_save: 'New Save',
|
||||
new_save_desc: 'Save with custom name',
|
||||
quick_save: 'Quick Save',
|
||||
quick_save_desc: 'Use auto-generated name',
|
||||
empty: 'No saves found',
|
||||
game_time: 'Game Time: {time}',
|
||||
avatar_count: 'Characters: {alive}/{total}',
|
||||
event_count: '{count} events',
|
||||
protagonist_tooltip: 'Protagonist',
|
||||
load: 'Load',
|
||||
save_success: 'Saved: {filename}',
|
||||
save_failed: 'Save failed',
|
||||
load_confirm: 'Load {filename}?',
|
||||
load_success: 'Loaded',
|
||||
load_failed: 'Load failed',
|
||||
fetch_failed: 'Fetch failed',
|
||||
save_modal_title: 'Save Game',
|
||||
save_confirm: 'Save',
|
||||
name_hint: 'Enter save name (optional)',
|
||||
name_placeholder: 'Enter name...',
|
||||
name_tip: 'Leave empty for auto name',
|
||||
name_too_long: 'Name too long',
|
||||
name_invalid_chars: 'Invalid characters',
|
||||
},
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const createMockSave = (overrides: Partial<SaveFileDTO> = {}): SaveFileDTO => ({
|
||||
filename: 'test_save.json',
|
||||
save_time: '2026-01-01T12:00:00',
|
||||
game_time: '100年1月',
|
||||
version: '1.0.0',
|
||||
language: 'zh-CN',
|
||||
avatar_count: 10,
|
||||
alive_count: 8,
|
||||
dead_count: 2,
|
||||
protagonist_name: null,
|
||||
custom_name: null,
|
||||
event_count: 50,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Helper to wait for promises.
|
||||
const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
describe('SaveLoadPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('Save Mode', () => {
|
||||
it('should render save actions in save mode', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'save' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.new-save-card').exists()).toBe(true)
|
||||
expect(wrapper.find('.quick-save-card').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('New Save')
|
||||
expect(wrapper.text()).toContain('Quick Save')
|
||||
})
|
||||
|
||||
it('should open save modal when clicking new save', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'save' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.find('.new-save-card').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.n-modal').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should call saveGame without name on quick save', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
vi.mocked(systemApi.saveGame).mockResolvedValue({ status: 'ok', filename: 'auto_save.json' })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'save' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.find('.quick-save-card').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(systemApi.saveGame).toHaveBeenCalled()
|
||||
expect(systemApi.saveGame).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load Mode', () => {
|
||||
it('should render save list in load mode', async () => {
|
||||
const mockSaves = [
|
||||
createMockSave({ filename: 'save1.json', custom_name: '我的存档' }),
|
||||
createMockSave({ filename: 'save2.json', game_time: '200年6月' }),
|
||||
]
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.save-item')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('我的存档')
|
||||
expect(wrapper.text()).toContain('Load')
|
||||
})
|
||||
|
||||
it('should not render save actions in load mode', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.new-save-card').exists()).toBe(false)
|
||||
expect(wrapper.find('.quick-save-card').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should call loadGame when clicking save item', async () => {
|
||||
const mockSaves = [createMockSave({ filename: 'test.json' })]
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves })
|
||||
vi.mocked(systemApi.loadGame).mockResolvedValue({ status: 'ok', message: 'loaded' })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.find('.save-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(systemApi.loadGame).toHaveBeenCalledWith('test.json')
|
||||
})
|
||||
|
||||
it('should not load if user cancels confirm', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const mockSaves = [createMockSave({ filename: 'test.json' })]
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.find('.save-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(systemApi.loadGame).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Save Display', () => {
|
||||
it('should display custom name when available', async () => {
|
||||
const mockSaves = [createMockSave({ custom_name: '自定义名称', filename: 'test.json' })]
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.save-name').text()).toBe('自定义名称')
|
||||
})
|
||||
|
||||
it('should display filename when no custom name', async () => {
|
||||
const mockSaves = [createMockSave({ custom_name: null, filename: '20260101_120000.json' })]
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.save-name').text()).toBe('20260101_120000')
|
||||
})
|
||||
|
||||
it('should display protagonist badge when protagonist exists', async () => {
|
||||
const mockSaves = [createMockSave({ protagonist_name: '林动' })]
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.protagonist-badge').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('林动')
|
||||
})
|
||||
|
||||
it('should not display protagonist badge when no protagonist', async () => {
|
||||
const mockSaves = [createMockSave({ protagonist_name: null })]
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.protagonist-badge').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should display avatar counts', async () => {
|
||||
const mockSaves = [createMockSave({ alive_count: 15, avatar_count: 20 })]
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.avatar-count').text()).toContain('15')
|
||||
expect(wrapper.find('.avatar-count').text()).toContain('20')
|
||||
})
|
||||
|
||||
it('should display event count', async () => {
|
||||
const mockSaves = [createMockSave({ event_count: 100 })]
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: mockSaves })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.event-count').text()).toContain('100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Name Validation', () => {
|
||||
it('should show error for name over 50 chars', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'save' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.find('.new-save-card').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const input = wrapper.find('.n-input')
|
||||
await input.setValue('a'.repeat(51))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error-text').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Name too long')
|
||||
})
|
||||
|
||||
it('should show error for invalid characters', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'save' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.find('.new-save-card').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const input = wrapper.find('.n-input')
|
||||
await input.setValue('name!@#$')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error-text').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Invalid characters')
|
||||
})
|
||||
|
||||
it('should allow valid Chinese name', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'save' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.find('.new-save-card').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const input = wrapper.find('.n-input')
|
||||
await input.setValue('我的存档')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error-text').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow empty name', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'save' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.find('.new-save-card').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const input = wrapper.find('.n-input')
|
||||
await input.setValue('')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error-text').exists()).toBe(false)
|
||||
expect(wrapper.find('.tip-text').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show empty message when no saves', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.empty').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('No saves found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle fetchSaves error gracefully', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'load' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// Should not crash, saves should be empty.
|
||||
expect(wrapper.findAll('.save-item')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle saveGame error gracefully', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
vi.mocked(systemApi.saveGame).mockRejectedValue(new Error('Save error'))
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'save' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.find('.quick-save-card').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Should not crash.
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mode Switching', () => {
|
||||
it('should refetch saves when mode changes', async () => {
|
||||
vi.mocked(systemApi.fetchSaves).mockResolvedValue({ saves: [] })
|
||||
|
||||
const wrapper = mount(SaveLoadPanel, {
|
||||
props: { mode: 'save' },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(systemApi.fetchSaves).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.setProps({ mode: 'load' })
|
||||
await flushPromises()
|
||||
|
||||
expect(systemApi.fetchSaves).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,8 +19,11 @@ export const systemApi = {
|
||||
return httpClient.get<{ saves: SaveFileDTO[] }>('/api/saves');
|
||||
},
|
||||
|
||||
saveGame(filename?: string) {
|
||||
return httpClient.post<{ status: string; filename: string }>('/api/game/save', { filename });
|
||||
saveGame(customName?: string) {
|
||||
return httpClient.post<{ status: string; filename: string }>(
|
||||
'/api/game/save',
|
||||
{ custom_name: customName }
|
||||
);
|
||||
},
|
||||
|
||||
loadGame(filename: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { NModal, NInput, NButton, NSpin, NTooltip } from 'naive-ui'
|
||||
import { systemApi } from '../../../../api'
|
||||
import type { SaveFileDTO } from '../../../../types/api'
|
||||
import { useWorldStore } from '../../../../stores/world'
|
||||
@@ -23,6 +24,25 @@ const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const saves = ref<SaveFileDTO[]>([])
|
||||
|
||||
// 保存对话框状态
|
||||
const showSaveModal = ref(false)
|
||||
const saveName = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
// 名称验证
|
||||
const nameError = computed(() => {
|
||||
if (!saveName.value) return ''
|
||||
if (saveName.value.length > 50) {
|
||||
return t('save_load.name_too_long')
|
||||
}
|
||||
// 只允许中文、字母、数字和下划线
|
||||
const pattern = /^[\w\u4e00-\u9fff]+$/
|
||||
if (!pattern.test(saveName.value)) {
|
||||
return t('save_load.name_invalid_chars')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
async function fetchSaves() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -35,8 +55,15 @@ async function fetchSaves() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
loading.value = true
|
||||
// 打开保存对话框
|
||||
function openSaveModal() {
|
||||
saveName.value = ''
|
||||
showSaveModal.value = true
|
||||
}
|
||||
|
||||
// 快速保存(不输入名称)
|
||||
async function handleQuickSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await systemApi.saveGame()
|
||||
message.success(t('save_load.save_success', { filename: res.filename }))
|
||||
@@ -44,7 +71,26 @@ async function handleSave() {
|
||||
} catch (e) {
|
||||
message.error(t('save_load.save_failed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 带名称保存
|
||||
async function handleSaveWithName() {
|
||||
if (nameError.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const customName = saveName.value.trim() || undefined
|
||||
const res = await systemApi.saveGame(customName)
|
||||
message.success(t('save_load.save_success', { filename: res.filename }))
|
||||
showSaveModal.value = false
|
||||
saveName.value = ''
|
||||
await fetchSaves()
|
||||
} catch (e) {
|
||||
message.error(t('save_load.save_failed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +112,26 @@ async function handleLoad(filename: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化保存时间
|
||||
function formatSaveTime(isoTime: string): string {
|
||||
if (!isoTime) return ''
|
||||
try {
|
||||
const date = new Date(isoTime)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return isoTime
|
||||
}
|
||||
}
|
||||
|
||||
// 获取存档显示名称
|
||||
function getSaveDisplayName(save: SaveFileDTO): string {
|
||||
if (save.custom_name) {
|
||||
return save.custom_name
|
||||
}
|
||||
// 从文件名提取时间部分
|
||||
return save.filename.replace('.json', '')
|
||||
}
|
||||
|
||||
watch(() => props.mode, () => {
|
||||
fetchSaves()
|
||||
})
|
||||
@@ -77,32 +143,103 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div :class="mode === 'save' ? 'save-panel' : 'load-panel'">
|
||||
<div v-if="loading && saves.length === 0" class="loading">{{ t('save_load.loading') }}</div>
|
||||
|
||||
<!-- Save Mode: New Save Button -->
|
||||
<div v-if="loading && saves.length === 0" class="loading">
|
||||
<NSpin size="medium" />
|
||||
<span>{{ t('save_load.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Save Mode: Action Buttons -->
|
||||
<template v-if="mode === 'save'">
|
||||
<div class="new-save-card" @click="handleSave">
|
||||
<div class="icon">+</div>
|
||||
<div>{{ t('save_load.new_save') }}</div>
|
||||
<div class="sub">{{ t('save_load.new_save_desc') }}</div>
|
||||
<div class="save-actions">
|
||||
<div class="new-save-card" @click="openSaveModal">
|
||||
<div class="icon">+</div>
|
||||
<div>{{ t('save_load.new_save') }}</div>
|
||||
<div class="sub">{{ t('save_load.new_save_desc') }}</div>
|
||||
</div>
|
||||
<div class="quick-save-card" @click="handleQuickSave">
|
||||
<div class="icon">
|
||||
<NSpin v-if="saving" size="small" />
|
||||
<span v-else>⚡</span>
|
||||
</div>
|
||||
<div>{{ t('save_load.quick_save') }}</div>
|
||||
<div class="sub">{{ t('save_load.quick_save_desc') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Save List -->
|
||||
<div v-if="!loading && saves.length === 0" class="empty">{{ t('save_load.empty') }}</div>
|
||||
<div
|
||||
v-for="save in saves"
|
||||
:key="save.filename"
|
||||
class="save-item"
|
||||
@click="mode === 'load' ? handleLoad(save.filename) : null"
|
||||
>
|
||||
<div class="save-info">
|
||||
<div class="save-time">{{ save.save_time }}</div>
|
||||
<div class="game-time">{{ t('save_load.game_time', { time: save.game_time }) }}</div>
|
||||
<div class="filename">{{ save.filename }}</div>
|
||||
|
||||
<div class="saves-list">
|
||||
<div
|
||||
v-for="save in saves"
|
||||
:key="save.filename"
|
||||
class="save-item"
|
||||
@click="mode === 'load' ? handleLoad(save.filename) : null"
|
||||
>
|
||||
<div class="save-info">
|
||||
<div class="save-header">
|
||||
<span class="save-name">{{ getSaveDisplayName(save) }}</span>
|
||||
<NTooltip v-if="save.protagonist_name" trigger="hover">
|
||||
<template #trigger>
|
||||
<span class="protagonist-badge">★ {{ save.protagonist_name }}</span>
|
||||
</template>
|
||||
{{ t('save_load.protagonist_tooltip') }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
<div class="save-meta">
|
||||
<span class="game-time">{{ t('save_load.game_time', { time: save.game_time }) }}</span>
|
||||
<span class="divider">|</span>
|
||||
<span class="avatar-count">{{ t('save_load.avatar_count', { alive: save.alive_count, total: save.avatar_count }) }}</span>
|
||||
<span class="divider">|</span>
|
||||
<span class="event-count">{{ t('save_load.event_count', { count: save.event_count }) }}</span>
|
||||
</div>
|
||||
<div class="save-footer">
|
||||
<span class="save-time">{{ formatSaveTime(save.save_time) }}</span>
|
||||
<span class="version">v{{ save.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mode === 'load'" class="load-btn">{{ t('save_load.load') }}</div>
|
||||
</div>
|
||||
<div v-if="mode === 'load'" class="load-btn">{{ t('save_load.load') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Modal -->
|
||||
<NModal
|
||||
v-model:show="showSaveModal"
|
||||
preset="card"
|
||||
:title="t('save_load.save_modal_title')"
|
||||
style="width: 400px;"
|
||||
:mask-closable="!saving"
|
||||
:closable="!saving"
|
||||
>
|
||||
<div class="save-modal-content">
|
||||
<p class="hint">{{ t('save_load.name_hint') }}</p>
|
||||
<NInput
|
||||
v-model:value="saveName"
|
||||
:placeholder="t('save_load.name_placeholder')"
|
||||
:status="nameError ? 'error' : undefined"
|
||||
:disabled="saving"
|
||||
@keyup.enter="handleSaveWithName"
|
||||
/>
|
||||
<p v-if="nameError" class="error-text">{{ nameError }}</p>
|
||||
<p v-else class="tip-text">{{ t('save_load.name_tip') }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton :disabled="saving" @click="showSaveModal = false">
|
||||
{{ t('common.cancel') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!!nameError"
|
||||
@click="handleSaveWithName"
|
||||
>
|
||||
{{ t('save_load.save_confirm') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -115,12 +252,18 @@ onMounted(() => {
|
||||
|
||||
.save-panel {
|
||||
align-items: center;
|
||||
padding-top: 3em;
|
||||
padding-top: 2em;
|
||||
}
|
||||
|
||||
.new-save-card {
|
||||
width: 15em;
|
||||
height: 11em;
|
||||
.save-actions {
|
||||
display: flex;
|
||||
gap: 1.5em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.new-save-card, .quick-save-card {
|
||||
width: 12em;
|
||||
height: 9em;
|
||||
border: 2px dashed #444;
|
||||
border-radius: 0.5em;
|
||||
display: flex;
|
||||
@@ -130,44 +273,56 @@ onMounted(() => {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #888;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
|
||||
.new-save-card:hover {
|
||||
.new-save-card:hover, .quick-save-card:hover {
|
||||
border-color: #666;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.new-save-card .icon {
|
||||
font-size: 3em;
|
||||
.quick-save-card {
|
||||
border-color: #3a5a3a;
|
||||
}
|
||||
|
||||
.quick-save-card:hover {
|
||||
border-color: #4a7a4a;
|
||||
background: #1a2a1a;
|
||||
}
|
||||
|
||||
.new-save-card .icon, .quick-save-card .icon {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.new-save-card .sub {
|
||||
font-size: 0.85em;
|
||||
.new-save-card .sub, .quick-save-card .sub {
|
||||
font-size: 0.75em;
|
||||
color: #666;
|
||||
margin-top: 0.4em;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.saves-list {
|
||||
width: 100%;
|
||||
max-width: 50em;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.save-item {
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
padding: 0.8em;
|
||||
margin-bottom: 0.8em;
|
||||
border-radius: 0.3em;
|
||||
padding: 0.8em 1em;
|
||||
margin-bottom: 0.6em;
|
||||
border-radius: 0.4em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.save-panel .save-item {
|
||||
cursor: default;
|
||||
width: 100%;
|
||||
max-width: 45em;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.save-item:hover {
|
||||
@@ -175,21 +330,65 @@ onMounted(() => {
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.save-info .save-time {
|
||||
.save-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.save-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
.save-name {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.save-info .game-time {
|
||||
color: #4a9eff;
|
||||
font-size: 0.9em;
|
||||
margin: 0.3em 0;
|
||||
.protagonist-badge {
|
||||
background: linear-gradient(135deg, #5a4a2a 0%, #3a2a1a 100%);
|
||||
color: #ffd700;
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 0.3em;
|
||||
font-size: 0.8em;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.save-info .filename {
|
||||
color: #666;
|
||||
.save-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.3em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.game-time {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.avatar-count {
|
||||
color: #7acc7a;
|
||||
}
|
||||
|
||||
.event-count {
|
||||
color: #cc9a7a;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.save-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@@ -200,12 +399,59 @@ onMounted(() => {
|
||||
padding: 0.4em 1em;
|
||||
border-radius: 0.3em;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
.load-btn:hover {
|
||||
background: #444;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8em;
|
||||
color: #888;
|
||||
padding: 3em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 3em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Save Modal */
|
||||
.save-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8em;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #aaa;
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e55;
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
color: #888;
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.8em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,16 +28,28 @@
|
||||
"save_load": {
|
||||
"loading": "Loading...",
|
||||
"new_save": "New Save",
|
||||
"new_save_desc": "Click to create a new save file",
|
||||
"new_save_desc": "Save with custom name",
|
||||
"quick_save": "Quick Save",
|
||||
"quick_save_desc": "Use auto-generated name",
|
||||
"empty": "No saves found",
|
||||
"game_time": "Game Time: {time}",
|
||||
"avatar_count": "Characters: {alive}/{total}",
|
||||
"event_count": "{count} events",
|
||||
"protagonist_tooltip": "Protagonist (Child of Destiny/Transmigrator)",
|
||||
"load": "Load",
|
||||
"save_success": "Saved successfully: {filename}",
|
||||
"save_failed": "Failed to save",
|
||||
"load_confirm": "Are you sure you want to load save {filename}? Unsaved progress will be lost.",
|
||||
"load_success": "Loaded successfully",
|
||||
"load_failed": "Failed to load",
|
||||
"fetch_failed": "Failed to fetch save list"
|
||||
"fetch_failed": "Failed to fetch save list",
|
||||
"save_modal_title": "Save Game",
|
||||
"save_confirm": "Save",
|
||||
"name_hint": "Enter a name for this save (optional)",
|
||||
"name_placeholder": "Enter save name...",
|
||||
"name_tip": "Leave empty to use auto-generated name",
|
||||
"name_too_long": "Name cannot exceed 50 characters",
|
||||
"name_invalid_chars": "Name can only contain letters, numbers, Chinese characters and underscores"
|
||||
},
|
||||
"llm": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -28,16 +28,28 @@
|
||||
"save_load": {
|
||||
"loading": "加载中...",
|
||||
"new_save": "新建存档",
|
||||
"new_save_desc": "点击创建一个新的存档文件",
|
||||
"new_save_desc": "输入自定义名称保存",
|
||||
"quick_save": "快速保存",
|
||||
"quick_save_desc": "使用自动生成的名称",
|
||||
"empty": "暂无存档",
|
||||
"game_time": "游戏时间: {time}",
|
||||
"avatar_count": "角色: {alive}/{total}",
|
||||
"event_count": "{count} 条事件",
|
||||
"protagonist_tooltip": "主角(气运之子/穿越者)",
|
||||
"load": "加载",
|
||||
"save_success": "存档成功: {filename}",
|
||||
"save_failed": "存档失败",
|
||||
"load_confirm": "确定要加载存档 {filename} 吗?当前未保存的进度将丢失。",
|
||||
"load_success": "读档成功",
|
||||
"load_failed": "读档失败",
|
||||
"fetch_failed": "获取存档列表失败"
|
||||
"fetch_failed": "获取存档列表失败",
|
||||
"save_modal_title": "保存游戏",
|
||||
"save_confirm": "保存",
|
||||
"name_hint": "为存档输入一个名称(可选)",
|
||||
"name_placeholder": "输入存档名称...",
|
||||
"name_tip": "留空将使用自动生成的名称",
|
||||
"name_too_long": "名称不能超过50个字符",
|
||||
"name_invalid_chars": "名称只能包含中文、字母、数字和下划线"
|
||||
},
|
||||
"llm": {
|
||||
"loading": "加载中...",
|
||||
|
||||
@@ -28,16 +28,28 @@
|
||||
"save_load": {
|
||||
"loading": "載入中...",
|
||||
"new_save": "新增存檔",
|
||||
"new_save_desc": "點擊創建一個新的存檔檔案",
|
||||
"new_save_desc": "輸入自訂名稱儲存",
|
||||
"quick_save": "快速儲存",
|
||||
"quick_save_desc": "使用自動生成的名稱",
|
||||
"empty": "暫無存檔",
|
||||
"game_time": "遊戲時間: {time}",
|
||||
"avatar_count": "角色: {alive}/{total}",
|
||||
"event_count": "{count} 條事件",
|
||||
"protagonist_tooltip": "主角(氣運之子/穿越者)",
|
||||
"load": "載入",
|
||||
"save_success": "存檔成功: {filename}",
|
||||
"save_failed": "存檔失敗",
|
||||
"load_confirm": "確定要載入存檔 {filename} 嗎?當前未儲存的進度將丟失。",
|
||||
"load_success": "讀檔成功",
|
||||
"load_failed": "讀檔失敗",
|
||||
"fetch_failed": "獲取存檔列表失敗"
|
||||
"fetch_failed": "獲取存檔列表失敗",
|
||||
"save_modal_title": "儲存遊戲",
|
||||
"save_confirm": "儲存",
|
||||
"name_hint": "為存檔輸入一個名稱(可選)",
|
||||
"name_placeholder": "輸入存檔名稱...",
|
||||
"name_tip": "留空將使用自動生成的名稱",
|
||||
"name_too_long": "名稱不能超過50個字元",
|
||||
"name_invalid_chars": "名稱只能包含中文、字母、數字和底線"
|
||||
},
|
||||
"llm": {
|
||||
"loading": "載入中...",
|
||||
|
||||
@@ -66,6 +66,14 @@ export interface SaveFileDTO {
|
||||
save_time: string;
|
||||
game_time: string;
|
||||
version: string;
|
||||
// 新增字段。
|
||||
language: string;
|
||||
avatar_count: number;
|
||||
alive_count: number;
|
||||
dead_count: number;
|
||||
protagonist_name: string | null;
|
||||
custom_name: string | null;
|
||||
event_count: number;
|
||||
}
|
||||
|
||||
// --- Game Data Metadata ---
|
||||
|
||||
Reference in New Issue
Block a user