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:
Zihao Xu
2026-02-06 06:03:41 -08:00
committed by GitHub
parent ef926594c7
commit 67b559ac5a
10 changed files with 1607 additions and 73 deletions

View 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)
})
})
})

View File

@@ -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) {

View File

@@ -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>&#9889;</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">&#9733; {{ 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>

View File

@@ -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...",

View File

@@ -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": "加载中...",

View File

@@ -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": "載入中...",

View File

@@ -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 ---