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

@@ -7,6 +7,7 @@ import time
import threading import threading
import signal import signal
import random import random
import re
from omegaconf import OmegaConf from omegaconf import OmegaConf
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -1684,8 +1685,17 @@ async def save_llm_config(req: LLMConfigDTO):
# --- 存档系统 API --- # --- 存档系统 API ---
def validate_save_name(name: str) -> bool:
"""验证存档名称是否合法。"""
if not name or len(name) > 50:
return False
# 只允许中文、字母、数字和下划线。
pattern = r'^[\w\u4e00-\u9fff]+$'
return bool(re.match(pattern, name))
class SaveGameRequest(BaseModel): class SaveGameRequest(BaseModel):
filename: Optional[str] = None custom_name: Optional[str] = None # 自定义存档名称
class LoadGameRequest(BaseModel): class LoadGameRequest(BaseModel):
filename: str filename: str
@@ -1694,14 +1704,22 @@ class LoadGameRequest(BaseModel):
def get_saves(): def get_saves():
"""获取存档列表""" """获取存档列表"""
saves_list = list_saves() saves_list = list_saves()
# 转换 Path 为 str并整理格式 # 转换 Path 为 str并整理格式
result = [] result = []
for path, meta in saves_list: for path, meta in saves_list:
result.append({ result.append({
"filename": path.name, "filename": path.name,
"save_time": meta.get("save_time", ""), "save_time": meta.get("save_time", ""),
"game_time": meta.get("game_time", ""), "game_time": meta.get("game_time", ""),
"version": meta.get("version", "") "version": meta.get("version", ""),
# 新增字段。
"language": meta.get("language", ""),
"avatar_count": meta.get("avatar_count", 0),
"alive_count": meta.get("alive_count", 0),
"dead_count": meta.get("dead_count", 0),
"protagonist_name": meta.get("protagonist_name"),
"custom_name": meta.get("custom_name"),
"event_count": meta.get("event_count", 0),
}) })
return {"saves": result} return {"saves": result}
@@ -1713,15 +1731,22 @@ def api_save_game(req: SaveGameRequest):
if not world or not sim: if not world or not sim:
raise HTTPException(status_code=503, detail="Game not initialized") raise HTTPException(status_code=503, detail="Game not initialized")
# 尝试从 world 属性获取(如果以后添加了) # 尝试从 world 属性获取(如果以后添加了)
existed_sects = getattr(world, "existed_sects", []) existed_sects = getattr(world, "existed_sects", [])
if not existed_sects: if not existed_sects:
# fallback: 所有 sects # fallback: 所有 sects.
existed_sects = list(sects_by_id.values()) existed_sects = list(sects_by_id.values())
# 使用当前存档路径(保持 SQLite 数据库关联) # 名称验证。
current_save_path = game_instance.get("current_save_path") custom_name = req.custom_name
success, filename = save_game(world, sim, existed_sects, save_path=current_save_path) if custom_name and not validate_save_name(custom_name):
raise HTTPException(
status_code=400,
detail="Invalid save name"
)
# 新存档(不使用 current_save_path每次创建新文件
success, filename = save_game(world, sim, existed_sects, custom_name=custom_name)
if success: if success:
return {"status": "ok", "filename": filename} return {"status": "ok", "filename": filename}
else: else:

View File

@@ -25,6 +25,7 @@
- 事件实时写入SQLiteJSON中的events字段仅用于旧存档迁移 - 事件实时写入SQLiteJSON中的events字段仅用于旧存档迁移
""" """
import json import json
import re
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import List, Optional, TYPE_CHECKING from typing import List, Optional, TYPE_CHECKING
@@ -38,22 +39,45 @@ from src.utils.config import CONFIG
from src.classes.language import language_manager from src.classes.language import language_manager
from src.sim.load.load_game import get_events_db_path from src.sim.load.load_game import get_events_db_path
# 主角特质 ID: 穿越者=30, 气运之子=31.
PROTAGONIST_PERSONA_IDS = {30, 31}
def sanitize_save_name(name: str) -> str:
"""清理存档名称,只保留安全字符。"""
# 移除文件系统不允许的字符。
safe_name = re.sub(r'[\\/:*?"<>|]', '', name)
# 只保留中文、字母、数字和下划线。
safe_name = re.sub(r'[^\w\u4e00-\u9fff]', '_', safe_name)
return safe_name[:50] if safe_name else "save"
def find_protagonist_name(world: "World") -> Optional[str]:
"""查找主角名字(具有气运之子或穿越者特质的存活角色)。"""
for avatar in world.avatar_manager.avatars.values():
persona_ids = [p.id for p in avatar.personas] if avatar.personas else []
if any(pid in PROTAGONIST_PERSONA_IDS for pid in persona_ids):
return avatar.name
return None
def save_game( def save_game(
world: "World", world: "World",
simulator: "Simulator", simulator: "Simulator",
existed_sects: List["Sect"], existed_sects: List["Sect"],
save_path: Optional[Path] = None save_path: Optional[Path] = None,
custom_name: Optional[str] = None
) -> tuple[bool, Optional[str]]: ) -> tuple[bool, Optional[str]]:
""" """
保存游戏状态到文件 保存游戏状态到文件
Args: Args:
world: 世界对象 world: 世界对象
simulator: 模拟器对象 simulator: 模拟器对象
existed_sects: 本局启用的宗门列表 existed_sects: 本局启用的宗门列表
save_path: 保存路径默认为saves/时间戳_游戏时间.json save_path: 保存路径默认为saves/时间戳_游戏时间.json
custom_name: 用户自定义的存档名称
Returns: Returns:
(保存是否成功, 保存的文件名) (保存是否成功, 保存的文件名)
""" """
@@ -62,15 +86,21 @@ def save_game(
if save_path is None: if save_path is None:
saves_dir = CONFIG.paths.saves saves_dir = CONFIG.paths.saves
saves_dir.mkdir(parents=True, exist_ok=True) saves_dir.mkdir(parents=True, exist_ok=True)
# 生成友好的文件名20251111_193000_Y100M1.json # 生成友好的文件名
now = datetime.now() now = datetime.now()
time_str = now.strftime("%Y%m%d_%H%M%S") time_str = now.strftime("%Y%m%d_%H%M%S")
year = world.month_stamp.get_year() year = world.month_stamp.get_year()
month = world.month_stamp.get_month().value month = world.month_stamp.get_month().value
game_time_str = f"Y{year}M{month}" game_time_str = f"Y{year}M{month}"
filename = f"{time_str}_{game_time_str}.json" # 处理自定义名称。
if custom_name:
safe_name = sanitize_save_name(custom_name)
filename = f"{safe_name}_{time_str}.json"
else:
filename = f"{time_str}_{game_time_str}.json"
save_path = saves_dir / filename save_path = saves_dir / filename
else: else:
save_path = Path(save_path) save_path = Path(save_path)
@@ -95,6 +125,12 @@ def save_game(
else: else:
print(f"警告: 当前事件数据库不存在: {current_db_path}") print(f"警告: 当前事件数据库不存在: {current_db_path}")
# 计算角色统计。
alive_count = len(world.avatar_manager.avatars)
dead_count = len(world.avatar_manager.dead_avatars)
total_count = alive_count + dead_count
protagonist_name = find_protagonist_name(world)
# 构建元信息 # 构建元信息
meta = { meta = {
"version": CONFIG.meta.version, "version": CONFIG.meta.version,
@@ -104,6 +140,12 @@ def save_game(
# SQLite 事件数据库信息。 # SQLite 事件数据库信息。
"events_db": str(events_db_path.name), "events_db": str(events_db_path.name),
"event_count": world.event_manager.count(), "event_count": world.event_manager.count(),
# 新增元数据。
"avatar_count": total_count,
"alive_count": alive_count,
"dead_count": dead_count,
"protagonist_name": protagonist_name,
"custom_name": custom_name,
} }
# 构建世界数据 # 构建世界数据

View File

@@ -0,0 +1,680 @@
"""
Tests for custom save name and enhanced metadata features (Issue #95).
"""
import pytest
import json
from pathlib import Path
from unittest.mock import patch, MagicMock
from src.classes.world import World
from src.classes.map import Map
from src.classes.tile import TileType
from src.classes.calendar import Month, Year, create_month_stamp
from src.classes.avatar import Avatar, Gender
from src.classes.age import Age
from src.classes.cultivation import Realm
from src.classes.persona import personas_by_id
from src.sim.simulator import Simulator
from src.sim.save.save_game import (
save_game,
sanitize_save_name,
find_protagonist_name,
PROTAGONIST_PERSONA_IDS,
)
from src.sim.load.load_game import load_game, get_events_db_path
from src.utils.id_generator import get_avatar_id
def create_test_map():
"""Create a simple test map."""
m = Map(width=10, height=10)
for x in range(10):
for y in range(10):
m.create_tile(x, y, TileType.PLAIN)
return m
@pytest.fixture
def temp_save_dir(tmp_path):
d = tmp_path / "saves"
d.mkdir()
return d
# =============================================================================
# Tests for sanitize_save_name function
# =============================================================================
class TestSanitizeSaveName:
"""Tests for the sanitize_save_name helper function."""
def test_chinese_characters_allowed(self):
"""Test that Chinese characters are preserved."""
result = sanitize_save_name("我的存档")
assert result == "我的存档"
def test_english_characters_allowed(self):
"""Test that English characters are preserved."""
result = sanitize_save_name("MyFirstSave")
assert result == "MyFirstSave"
def test_numbers_allowed(self):
"""Test that numbers are preserved."""
result = sanitize_save_name("Save123")
assert result == "Save123"
def test_underscores_allowed(self):
"""Test that underscores are preserved."""
result = sanitize_save_name("my_save_file")
assert result == "my_save_file"
def test_mixed_content(self):
"""Test mixed Chinese, English, and numbers."""
result = sanitize_save_name("我的Save存档_123")
assert result == "我的Save存档_123"
def test_special_characters_replaced(self):
"""Test that special characters are replaced with underscores."""
result = sanitize_save_name("Save!@#$%^&*()")
assert "!" not in result
assert "@" not in result
assert result.replace("_", "").isalnum() or result == "Save__________"
def test_path_separators_removed(self):
"""Test that path separators are removed."""
result = sanitize_save_name("path/to\\save")
assert "/" not in result
assert "\\" not in result
def test_dangerous_chars_removed(self):
"""Test that dangerous filesystem characters are removed."""
result = sanitize_save_name('save:*?"<>|name')
assert ":" not in result
assert "*" not in result
assert "?" not in result
assert '"' not in result
assert "<" not in result
assert ">" not in result
assert "|" not in result
def test_length_limit(self):
"""Test that names are truncated to 50 characters."""
long_name = "a" * 100
result = sanitize_save_name(long_name)
assert len(result) <= 50
def test_empty_string_returns_default(self):
"""Test that empty string returns 'save'."""
result = sanitize_save_name("")
assert result == "save"
def test_only_special_chars_returns_default(self):
"""Test that a name with only special chars returns 'save'."""
# After replacing all special chars with underscores, if nothing left, return 'save'.
# But underscores are kept, so "!!!" becomes "___" which is not empty.
result = sanitize_save_name("!!!")
# Should be "___" or similar, not "save".
assert len(result) > 0
def test_spaces_replaced(self):
"""Test that spaces are replaced with underscores."""
result = sanitize_save_name("my save file")
assert " " not in result
assert "_" in result
# =============================================================================
# Tests for find_protagonist_name function
# =============================================================================
class TestFindProtagonistName:
"""Tests for the find_protagonist_name helper function."""
def test_no_protagonists(self, temp_save_dir):
"""Test with no protagonist avatars."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
# Add regular avatar without protagonist traits.
avatar = Avatar(
world=world,
name="RegularAvatar",
id=get_avatar_id(),
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE,
)
avatar.personas = []
world.avatar_manager.avatars[avatar.id] = avatar
result = find_protagonist_name(world)
assert result is None
def test_finds_protagonist_with_trait_30(self, temp_save_dir):
"""Test finding protagonist with trait ID 30 (穿越者)."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
# Add protagonist avatar with trait 30.
avatar = Avatar(
world=world,
name="穿越者主角",
id=get_avatar_id(),
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE,
)
# Mock persona with ID 30.
mock_persona = MagicMock()
mock_persona.id = 30
avatar.personas = [mock_persona]
world.avatar_manager.avatars[avatar.id] = avatar
result = find_protagonist_name(world)
assert result == "穿越者主角"
def test_finds_protagonist_with_trait_31(self, temp_save_dir):
"""Test finding protagonist with trait ID 31 (气运之子)."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
# Add protagonist avatar with trait 31.
avatar = Avatar(
world=world,
name="气运之子",
id=get_avatar_id(),
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE,
)
mock_persona = MagicMock()
mock_persona.id = 31
avatar.personas = [mock_persona]
world.avatar_manager.avatars[avatar.id] = avatar
result = find_protagonist_name(world)
assert result == "气运之子"
def test_finds_first_protagonist_when_multiple(self, temp_save_dir):
"""Test that it returns one protagonist when multiple exist."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
# Add two protagonist avatars.
for i, name in enumerate(["主角一", "主角二"]):
avatar = Avatar(
world=world,
name=name,
id=get_avatar_id(),
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE,
)
mock_persona = MagicMock()
mock_persona.id = 30
avatar.personas = [mock_persona]
world.avatar_manager.avatars[avatar.id] = avatar
result = find_protagonist_name(world)
assert result in ["主角一", "主角二"]
# =============================================================================
# Tests for save_game with custom name
# =============================================================================
class TestSaveGameWithCustomName:
"""Tests for save_game function with custom_name parameter."""
def test_save_with_custom_name(self, temp_save_dir):
"""Test that custom name is used in filename."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
sim = Simulator(world)
success, filename = save_game(
world, sim, [],
save_path=None,
custom_name="我的测试存档"
)
# Check save succeeded.
assert success
# Filename should start with the sanitized custom name.
assert filename.startswith("我的测试存档_")
assert filename.endswith(".json")
def test_save_without_custom_name(self, temp_save_dir):
"""Test that default naming is used when no custom name provided."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
sim = Simulator(world)
# Patch CONFIG.paths.saves to use temp dir.
with patch.object(__import__('src.utils.config', fromlist=['CONFIG']).CONFIG.paths, 'saves', temp_save_dir):
success, filename = save_game(
world, sim, [],
save_path=None,
custom_name=None
)
assert success
# Default filename format: YYYYMMDD_HHMMSS_Y{year}M{month}.json.
assert "_Y100M1.json" in filename or filename.endswith(".json")
def test_custom_name_stored_in_meta(self, temp_save_dir):
"""Test that custom_name is stored in save metadata."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
sim = Simulator(world)
save_path = temp_save_dir / "test_custom_meta.json"
success, _ = save_game(
world, sim, [],
save_path=save_path,
custom_name="我的存档"
)
assert success
# Read and verify meta.
with open(save_path, "r", encoding="utf-8") as f:
data = json.load(f)
assert data["meta"]["custom_name"] == "我的存档"
def test_null_custom_name_in_meta(self, temp_save_dir):
"""Test that null custom_name is stored when not provided."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
sim = Simulator(world)
save_path = temp_save_dir / "test_null_meta.json"
success, _ = save_game(
world, sim, [],
save_path=save_path,
custom_name=None
)
assert success
with open(save_path, "r", encoding="utf-8") as f:
data = json.load(f)
assert data["meta"]["custom_name"] is None
# =============================================================================
# Tests for enhanced metadata
# =============================================================================
class TestEnhancedMetadata:
"""Tests for enhanced save metadata (avatar counts, protagonist)."""
def test_avatar_counts_in_meta(self, temp_save_dir):
"""Test that avatar counts are correctly stored."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
# Add living avatars.
for i in range(5):
avatar = Avatar(
world=world,
name=f"Avatar{i}",
id=get_avatar_id(),
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE,
)
world.avatar_manager.avatars[avatar.id] = avatar
# Add dead avatars.
for i in range(3):
avatar = Avatar(
world=world,
name=f"DeadAvatar{i}",
id=get_avatar_id(),
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE,
)
world.avatar_manager.dead_avatars[avatar.id] = avatar
sim = Simulator(world)
save_path = temp_save_dir / "test_counts.json"
success, _ = save_game(world, sim, [], save_path)
assert success
with open(save_path, "r", encoding="utf-8") as f:
data = json.load(f)
meta = data["meta"]
assert meta["alive_count"] == 5
assert meta["dead_count"] == 3
assert meta["avatar_count"] == 8 # total
def test_protagonist_name_in_meta(self, temp_save_dir):
"""Test that protagonist name is stored in metadata."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
# Add protagonist avatar.
avatar = Avatar(
world=world,
name="林动",
id=get_avatar_id(),
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE,
)
mock_persona = MagicMock()
mock_persona.id = 31 # 气运之子
avatar.personas = [mock_persona]
world.avatar_manager.avatars[avatar.id] = avatar
sim = Simulator(world)
save_path = temp_save_dir / "test_protagonist.json"
success, _ = save_game(world, sim, [], save_path)
assert success
with open(save_path, "r", encoding="utf-8") as f:
data = json.load(f)
assert data["meta"]["protagonist_name"] == "林动"
def test_no_protagonist_in_meta(self, temp_save_dir):
"""Test that protagonist_name is None when no protagonist exists."""
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
# Add regular avatar.
avatar = Avatar(
world=world,
name="普通人",
id=get_avatar_id(),
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE,
)
avatar.personas = []
world.avatar_manager.avatars[avatar.id] = avatar
sim = Simulator(world)
save_path = temp_save_dir / "test_no_prot.json"
success, _ = save_game(world, sim, [], save_path)
assert success
with open(save_path, "r", encoding="utf-8") as f:
data = json.load(f)
assert data["meta"]["protagonist_name"] is None
# =============================================================================
# Tests for API endpoints
# =============================================================================
class TestSaveApiWithCustomName:
"""Tests for /api/game/save endpoint with custom name."""
def test_api_save_with_custom_name(self, temp_save_dir):
"""Test API save endpoint with custom name."""
from fastapi.testclient import TestClient
from src.server import main
from src.utils.config import CONFIG
# Setup game instance.
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
sim = Simulator(world)
original_state = main.game_instance.copy()
main.game_instance["world"] = world
main.game_instance["sim"] = sim
with patch.object(CONFIG.paths, "saves", temp_save_dir):
client = TestClient(main.app)
response = client.post(
"/api/game/save",
json={"custom_name": "我的API存档"}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "我的API存档" in data["filename"]
# Cleanup.
main.game_instance.update(original_state)
def test_api_save_without_custom_name(self, temp_save_dir):
"""Test API save endpoint without custom name."""
from fastapi.testclient import TestClient
from src.server import main
from src.utils.config import CONFIG
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
sim = Simulator(world)
original_state = main.game_instance.copy()
main.game_instance["world"] = world
main.game_instance["sim"] = sim
with patch.object(CONFIG.paths, "saves", temp_save_dir):
client = TestClient(main.app)
response = client.post(
"/api/game/save",
json={}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["filename"].endswith(".json")
main.game_instance.update(original_state)
def test_api_save_invalid_name_rejected(self, temp_save_dir):
"""Test that invalid save names are rejected."""
from fastapi.testclient import TestClient
from src.server import main
from src.utils.config import CONFIG
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
sim = Simulator(world)
original_state = main.game_instance.copy()
main.game_instance["world"] = world
main.game_instance["sim"] = sim
with patch.object(CONFIG.paths, "saves", temp_save_dir):
client = TestClient(main.app)
# Name with only special characters - should be rejected.
response = client.post(
"/api/game/save",
json={"custom_name": "!!!@@@###"}
)
# Should be rejected with 400.
assert response.status_code == 400
main.game_instance.update(original_state)
def test_api_save_name_too_long_rejected(self, temp_save_dir):
"""Test that names over 50 chars are rejected."""
from fastapi.testclient import TestClient
from src.server import main
from src.utils.config import CONFIG
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
sim = Simulator(world)
original_state = main.game_instance.copy()
main.game_instance["world"] = world
main.game_instance["sim"] = sim
with patch.object(CONFIG.paths, "saves", temp_save_dir):
client = TestClient(main.app)
long_name = "a" * 51
response = client.post(
"/api/game/save",
json={"custom_name": long_name}
)
assert response.status_code == 400
main.game_instance.update(original_state)
class TestSavesListApiWithMetadata:
"""Tests for /api/saves endpoint returning enhanced metadata."""
def test_api_saves_returns_new_fields(self, temp_save_dir):
"""Test that /api/saves returns new metadata fields."""
from fastapi.testclient import TestClient
from src.server import main
from src.utils.config import CONFIG
# Create a save file with metadata.
game_map = create_test_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
# Add avatars.
for i in range(3):
avatar = Avatar(
world=world,
name=f"Avatar{i}",
id=get_avatar_id(),
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE,
)
world.avatar_manager.avatars[avatar.id] = avatar
sim = Simulator(world)
save_path = temp_save_dir / "test_list.json"
save_game(world, sim, [], save_path, custom_name="列表测试")
with patch.object(CONFIG.paths, "saves", temp_save_dir):
client = TestClient(main.app)
response = client.get("/api/saves")
assert response.status_code == 200
data = response.json()
assert len(data["saves"]) >= 1
save_item = data["saves"][0]
# Verify new fields are present.
assert "avatar_count" in save_item
assert "alive_count" in save_item
assert "dead_count" in save_item
assert "protagonist_name" in save_item
assert "custom_name" in save_item
assert "event_count" in save_item
assert "language" in save_item
# Verify values.
assert save_item["custom_name"] == "列表测试"
assert save_item["alive_count"] == 3
assert save_item["avatar_count"] == 3
def test_api_saves_old_save_compatibility(self, temp_save_dir):
"""Test that old saves without new fields return defaults."""
from fastapi.testclient import TestClient
from src.server import main
from src.utils.config import CONFIG
# Create a "legacy" save file without new metadata fields.
legacy_save = {
"meta": {
"version": "1.0",
"save_time": "2026-01-01T12:00:00",
"game_time": "100年1月",
},
"world": {"month_stamp": 1200},
"avatars": [],
"events": [],
"simulator": {},
}
save_path = temp_save_dir / "legacy.json"
with open(save_path, "w", encoding="utf-8") as f:
json.dump(legacy_save, f)
with patch.object(CONFIG.paths, "saves", temp_save_dir):
client = TestClient(main.app)
response = client.get("/api/saves")
assert response.status_code == 200
data = response.json()
# Find our legacy save.
legacy_item = None
for save in data["saves"]:
if save["filename"] == "legacy.json":
legacy_item = save
break
assert legacy_item is not None
# New fields should have default values.
assert legacy_item["avatar_count"] == 0
assert legacy_item["alive_count"] == 0
assert legacy_item["dead_count"] == 0
assert legacy_item["protagonist_name"] is None
assert legacy_item["custom_name"] is None
assert legacy_item["event_count"] == 0
# =============================================================================
# Tests for validate_save_name function
# =============================================================================
class TestValidateSaveName:
"""Tests for the validate_save_name function in main.py."""
def test_valid_chinese_name(self):
from src.server.main import validate_save_name
assert validate_save_name("我的存档") is True
def test_valid_english_name(self):
from src.server.main import validate_save_name
assert validate_save_name("MySave") is True
def test_valid_mixed_name(self):
from src.server.main import validate_save_name
assert validate_save_name("我的Save_123") is True
def test_empty_name_invalid(self):
from src.server.main import validate_save_name
assert validate_save_name("") is False
def test_too_long_name_invalid(self):
from src.server.main import validate_save_name
assert validate_save_name("a" * 51) is False
def test_special_chars_invalid(self):
from src.server.main import validate_save_name
assert validate_save_name("save!@#") is False
def test_space_invalid(self):
from src.server.main import validate_save_name
assert validate_save_name("my save") is False
def test_exactly_50_chars_valid(self):
from src.server.main import validate_save_name
assert validate_save_name("a" * 50) is True

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'); return httpClient.get<{ saves: SaveFileDTO[] }>('/api/saves');
}, },
saveGame(filename?: string) { saveGame(customName?: string) {
return httpClient.post<{ status: string; filename: string }>('/api/game/save', { filename }); return httpClient.post<{ status: string; filename: string }>(
'/api/game/save',
{ custom_name: customName }
);
}, },
loadGame(filename: string) { loadGame(filename: string) {

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <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 { systemApi } from '../../../../api'
import type { SaveFileDTO } from '../../../../types/api' import type { SaveFileDTO } from '../../../../types/api'
import { useWorldStore } from '../../../../stores/world' import { useWorldStore } from '../../../../stores/world'
@@ -23,6 +24,25 @@ const message = useMessage()
const loading = ref(false) const loading = ref(false)
const saves = ref<SaveFileDTO[]>([]) 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() { async function fetchSaves() {
loading.value = true loading.value = true
try { 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 { try {
const res = await systemApi.saveGame() const res = await systemApi.saveGame()
message.success(t('save_load.save_success', { filename: res.filename })) message.success(t('save_load.save_success', { filename: res.filename }))
@@ -44,7 +71,26 @@ async function handleSave() {
} catch (e) { } catch (e) {
message.error(t('save_load.save_failed')) message.error(t('save_load.save_failed'))
} finally { } 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, () => { watch(() => props.mode, () => {
fetchSaves() fetchSaves()
}) })
@@ -77,32 +143,103 @@ onMounted(() => {
<template> <template>
<div :class="mode === 'save' ? 'save-panel' : 'load-panel'"> <div :class="mode === 'save' ? 'save-panel' : 'load-panel'">
<div v-if="loading && saves.length === 0" class="loading">{{ t('save_load.loading') }}</div> <div v-if="loading && saves.length === 0" class="loading">
<NSpin size="medium" />
<!-- Save Mode: New Save Button --> <span>{{ t('save_load.loading') }}</span>
</div>
<!-- Save Mode: Action Buttons -->
<template v-if="mode === 'save'"> <template v-if="mode === 'save'">
<div class="new-save-card" @click="handleSave"> <div class="save-actions">
<div class="icon">+</div> <div class="new-save-card" @click="openSaveModal">
<div>{{ t('save_load.new_save') }}</div> <div class="icon">+</div>
<div class="sub">{{ t('save_load.new_save_desc') }}</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> </div>
</template> </template>
<!-- Save List --> <!-- Save List -->
<div v-if="!loading && saves.length === 0" class="empty">{{ t('save_load.empty') }}</div> <div v-if="!loading && saves.length === 0" class="empty">{{ t('save_load.empty') }}</div>
<div
v-for="save in saves" <div class="saves-list">
:key="save.filename" <div
class="save-item" v-for="save in saves"
@click="mode === 'load' ? handleLoad(save.filename) : null" :key="save.filename"
> class="save-item"
<div class="save-info"> @click="mode === 'load' ? handleLoad(save.filename) : null"
<div class="save-time">{{ save.save_time }}</div> >
<div class="game-time">{{ t('save_load.game_time', { time: save.game_time }) }}</div> <div class="save-info">
<div class="filename">{{ save.filename }}</div> <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>
<div v-if="mode === 'load'" class="load-btn">{{ t('save_load.load') }}</div>
</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> </div>
</template> </template>
@@ -115,12 +252,18 @@ onMounted(() => {
.save-panel { .save-panel {
align-items: center; align-items: center;
padding-top: 3em; padding-top: 2em;
} }
.new-save-card { .save-actions {
width: 15em; display: flex;
height: 11em; gap: 1.5em;
margin-bottom: 2em;
}
.new-save-card, .quick-save-card {
width: 12em;
height: 9em;
border: 2px dashed #444; border: 2px dashed #444;
border-radius: 0.5em; border-radius: 0.5em;
display: flex; display: flex;
@@ -130,44 +273,56 @@ onMounted(() => {
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
color: #888; color: #888;
margin-bottom: 3em;
} }
.new-save-card:hover { .new-save-card:hover, .quick-save-card:hover {
border-color: #666; border-color: #666;
background: #222; background: #222;
color: #fff; color: #fff;
} }
.new-save-card .icon { .quick-save-card {
font-size: 3em; 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; margin-bottom: 0.2em;
} }
.new-save-card .sub { .new-save-card .sub, .quick-save-card .sub {
font-size: 0.85em; font-size: 0.75em;
color: #666; color: #666;
margin-top: 0.4em; margin-top: 0.3em;
}
.saves-list {
width: 100%;
max-width: 50em;
overflow-y: auto;
flex: 1;
} }
.save-item { .save-item {
background: #222; background: #222;
border: 1px solid #333; border: 1px solid #333;
padding: 0.8em; padding: 0.8em 1em;
margin-bottom: 0.8em; margin-bottom: 0.6em;
border-radius: 0.3em; border-radius: 0.4em;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: all 0.2s;
width: 100%;
} }
.save-panel .save-item { .save-panel .save-item {
cursor: default; cursor: default;
width: 100%;
max-width: 45em;
} }
.save-item:hover { .save-item:hover {
@@ -175,21 +330,65 @@ onMounted(() => {
border-color: #444; 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; color: #fff;
font-weight: bold; font-weight: bold;
font-size: 1em; font-size: 1.05em;
} }
.save-info .game-time { .protagonist-badge {
color: #4a9eff; background: linear-gradient(135deg, #5a4a2a 0%, #3a2a1a 100%);
font-size: 0.9em; color: #ffd700;
margin: 0.3em 0; padding: 0.15em 0.5em;
border-radius: 0.3em;
font-size: 0.8em;
cursor: help;
} }
.save-info .filename { .save-meta {
color: #666; display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 0.3em;
font-size: 0.85em; 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; font-family: monospace;
} }
@@ -200,12 +399,59 @@ onMounted(() => {
padding: 0.4em 1em; padding: 0.4em 1em;
border-radius: 0.3em; border-radius: 0.3em;
font-size: 0.9em; 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; text-align: center;
color: #888; color: #888;
padding: 3em; padding: 3em;
width: 100%; 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> </style>

View File

@@ -28,16 +28,28 @@
"save_load": { "save_load": {
"loading": "Loading...", "loading": "Loading...",
"new_save": "New Save", "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", "empty": "No saves found",
"game_time": "Game Time: {time}", "game_time": "Game Time: {time}",
"avatar_count": "Characters: {alive}/{total}",
"event_count": "{count} events",
"protagonist_tooltip": "Protagonist (Child of Destiny/Transmigrator)",
"load": "Load", "load": "Load",
"save_success": "Saved successfully: {filename}", "save_success": "Saved successfully: {filename}",
"save_failed": "Failed to save", "save_failed": "Failed to save",
"load_confirm": "Are you sure you want to load save {filename}? Unsaved progress will be lost.", "load_confirm": "Are you sure you want to load save {filename}? Unsaved progress will be lost.",
"load_success": "Loaded successfully", "load_success": "Loaded successfully",
"load_failed": "Failed to load", "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": { "llm": {
"loading": "Loading...", "loading": "Loading...",

View File

@@ -28,16 +28,28 @@
"save_load": { "save_load": {
"loading": "加载中...", "loading": "加载中...",
"new_save": "新建存档", "new_save": "新建存档",
"new_save_desc": "点击创建一个新的存档文件", "new_save_desc": "输入自定义名称保存",
"quick_save": "快速保存",
"quick_save_desc": "使用自动生成的名称",
"empty": "暂无存档", "empty": "暂无存档",
"game_time": "游戏时间: {time}", "game_time": "游戏时间: {time}",
"avatar_count": "角色: {alive}/{total}",
"event_count": "{count} 条事件",
"protagonist_tooltip": "主角(气运之子/穿越者)",
"load": "加载", "load": "加载",
"save_success": "存档成功: {filename}", "save_success": "存档成功: {filename}",
"save_failed": "存档失败", "save_failed": "存档失败",
"load_confirm": "确定要加载存档 {filename} 吗?当前未保存的进度将丢失。", "load_confirm": "确定要加载存档 {filename} 吗?当前未保存的进度将丢失。",
"load_success": "读档成功", "load_success": "读档成功",
"load_failed": "读档失败", "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": { "llm": {
"loading": "加载中...", "loading": "加载中...",

View File

@@ -28,16 +28,28 @@
"save_load": { "save_load": {
"loading": "載入中...", "loading": "載入中...",
"new_save": "新增存檔", "new_save": "新增存檔",
"new_save_desc": "點擊創建一個新的存檔檔案", "new_save_desc": "輸入自訂名稱儲存",
"quick_save": "快速儲存",
"quick_save_desc": "使用自動生成的名稱",
"empty": "暫無存檔", "empty": "暫無存檔",
"game_time": "遊戲時間: {time}", "game_time": "遊戲時間: {time}",
"avatar_count": "角色: {alive}/{total}",
"event_count": "{count} 條事件",
"protagonist_tooltip": "主角(氣運之子/穿越者)",
"load": "載入", "load": "載入",
"save_success": "存檔成功: {filename}", "save_success": "存檔成功: {filename}",
"save_failed": "存檔失敗", "save_failed": "存檔失敗",
"load_confirm": "確定要載入存檔 {filename} 嗎?當前未儲存的進度將丟失。", "load_confirm": "確定要載入存檔 {filename} 嗎?當前未儲存的進度將丟失。",
"load_success": "讀檔成功", "load_success": "讀檔成功",
"load_failed": "讀檔失敗", "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": { "llm": {
"loading": "載入中...", "loading": "載入中...",

View File

@@ -66,6 +66,14 @@ export interface SaveFileDTO {
save_time: string; save_time: string;
game_time: string; game_time: string;
version: 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 --- // --- Game Data Metadata ---