feat(avatar): implement region ownership management in AvatarManager and Avatar classes

- Added `owned_regions` attribute to the Avatar class to track regions owned by avatars.
- Introduced `occupy_region` and `release_region` methods for managing region ownership and ensuring proper relationship handling.
- Updated AvatarManager to clear relationships when an avatar is released, ensuring no lingering references.
- Refactored region ownership logic in the Occupy action and Simulator to utilize the new methods for better clarity and maintainability.
- Enhanced game loading process to establish ownership relationships correctly during game state restoration.
This commit is contained in:
bridge
2026-02-02 21:34:02 +08:00
parent 7143b27a0a
commit 4f377551e8
8 changed files with 355 additions and 122 deletions

View File

@@ -11,6 +11,7 @@ from typing import Optional, List, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from src.classes.sect_ranks import SectRank from src.classes.sect_ranks import SectRank
from src.classes.region import CultivateRegion
from src.classes.calendar import MonthStamp from src.classes.calendar import MonthStamp
from src.classes.world import World from src.classes.world import World
@@ -133,6 +134,39 @@ class Avatar(
# 关系交互计数器: key=target_id, value={"count": 0, "checked_times": 0} # 关系交互计数器: key=target_id, value={"count": 0, "checked_times": 0}
relation_interaction_states: dict[str, dict[str, int]] = field(default_factory=lambda: defaultdict(lambda: {"count": 0, "checked_times": 0})) relation_interaction_states: dict[str, dict[str, int]] = field(default_factory=lambda: defaultdict(lambda: {"count": 0, "checked_times": 0}))
# 拥有的洞府列表(不参与序列化,通过 load_game 重建)
owned_regions: List["CultivateRegion"] = field(default_factory=list, init=False)
def occupy_region(self, region: "CultivateRegion") -> None:
"""
占据一个洞府,处理双向绑定和旧主清理。
"""
# 如果已经是我的,无需操作
if region.host_avatar == self:
if region not in self.owned_regions:
self.owned_regions.append(region)
return
# 如果有旧主,先让旧主释放
if region.host_avatar is not None:
region.host_avatar.release_region(region)
# 建立新关系
region.host_avatar = self
if region not in self.owned_regions:
self.owned_regions.append(region)
def release_region(self, region: "CultivateRegion") -> None:
"""
放弃一个洞府的所有权。
"""
if region in self.owned_regions:
self.owned_regions.remove(region)
# 只有当 region 的主人确实是自己时才置空(防止误伤新主人)
if region.host_avatar == self:
region.host_avatar = None
def add_breakthrough_rate(self, rate: float, duration: int = 1) -> None: def add_breakthrough_rate(self, rate: float, duration: int = 1) -> None:
""" """
增加突破成功率(临时效果) 增加突破成功率(临时效果)
@@ -259,6 +293,11 @@ class Avatar(
self.thinking = "" self.thinking = ""
self.short_term_objective = "" self.short_term_objective = ""
# 释放所有拥有的洞府
# 复制列表进行遍历,因为 release_region 会修改列表
for region in list(self.owned_regions):
self.release_region(region)
if self.sect: if self.sect:
self.sect.remove_member(self) self.sect.remove_member(self)

View File

@@ -114,10 +114,12 @@ class AvatarManager:
avatar.clear_relation(other) avatar.clear_relation(other)
# 2. 清理占据的洞府 # 2. 清理占据的洞府
if getattr(avatar, "world", None) and hasattr(avatar.world, "map"): if hasattr(avatar, "owned_regions") and avatar.owned_regions:
for region in avatar.world.map.regions.values(): for region in list(avatar.owned_regions):
if getattr(region, "host_avatar", None) == avatar: # 仅解除关系,不触发其他逻辑
if region.host_avatar == avatar:
region.host_avatar = None region.host_avatar = None
avatar.owned_regions.clear()
# 3. 扫一遍所有角色(含死者),确保清除反向引用 # 3. 扫一遍所有角色(含死者),确保清除反向引用
for other in self._iter_all_avatars(): for other in self._iter_all_avatars():

View File

@@ -106,7 +106,7 @@ class Occupy(MutualAction):
if feedback_name == "Yield": if feedback_name == "Yield":
# 对方让步:直接转移所有权 # 对方让步:直接转移所有权
if region: if region:
region.host_avatar = self.avatar self.avatar.occupy_region(region)
# 共用一个事件 # 共用一个事件
event_text = t("{initiator} forced {target} to yield {region}", event_text = t("{initiator} forced {target} to yield {region}",
@@ -131,7 +131,7 @@ class Occupy(MutualAction):
# 进攻方胜利则洞府易主 # 进攻方胜利则洞府易主
attacker_won = winner == self.avatar attacker_won = winner == self.avatar
if attacker_won and region: if attacker_won and region:
region.host_avatar = self.avatar self.avatar.occupy_region(region)
self._last_result = (winner, loser, loser_dmg, winner_dmg, region_name, attacker_won) self._last_result = (winner, loser, loser_dmg, winner_dmg, region_name, attacker_won)

View File

@@ -261,7 +261,9 @@ def load_game(save_path: Optional[Path] = None) -> Tuple["World", "Simulator", L
if rid in game_map.regions: if rid in game_map.regions:
region = game_map.regions[rid] region = game_map.regions[rid]
if isinstance(region, CultivateRegion) and avatar_id in all_avatars: if isinstance(region, CultivateRegion) and avatar_id in all_avatars:
region.host_avatar = all_avatars[avatar_id] avatar = all_avatars[avatar_id]
# 使用 occupy_region 建立双向绑定
avatar.occupy_region(region)
# 重建宗门成员关系与功法列表 # 重建宗门成员关系与功法列表
from src.classes.technique import techniques_by_name from src.classes.technique import techniques_by_name

View File

@@ -82,7 +82,7 @@ class Simulator:
if region.host_avatar is None: if region.host_avatar is None:
if avatar.id not in avatars_with_home: if avatar.id not in avatars_with_home:
# 占据 # 占据
region.host_avatar = avatar avatar.occupy_region(region)
avatars_with_home.add(avatar.id) avatars_with_home.add(avatar.id)
# 记录事件 # 记录事件
event = Event( event = Event(

View File

@@ -1,177 +1,240 @@
""" """
测试 CSV 数据加载的正确性。 测试 CSV 数据加载的正确性。
验证代码中使用的列名与 CSV 文件中的实际列名匹配。 验证代码中使用的列名与 CSV 文件中的实际列名匹配。
采用动态多语言测试方案,不再硬编码特定语言的预期字符串。
""" """
import pytest import pytest
from src.classes.sect import sects_by_id, sects_by_name, Sect import csv
from src.classes.technique import techniques_by_id, techniques_by_name, Technique from pathlib import Path
from src.classes.sect import sects_by_id, sects_by_name, Sect, reload as reload_sects
from src.classes.technique import techniques_by_id, techniques_by_name, Technique, reload as reload_techniques
from src.classes.elixir import elixirs_by_id
from src.utils.config import CONFIG
from src.i18n import t, reload_translations
from src.classes.language import language_manager
# --- Helpers ---
def read_raw_csv_as_dict(file_path):
"""读取原始 CSV 文件,跳过描述行"""
if not file_path.exists():
return []
with open(file_path, 'r', encoding='utf-8-sig') as f:
lines = list(csv.reader(f))
if len(lines) < 1:
return []
headers = lines[0]
data = []
# Start from index 2 if there's a description row
start_index = 2 if len(lines) > 1 else 1
for row_values in lines[start_index:]:
if not row_values: continue
row_dict = {}
for i, h in enumerate(headers):
if i < len(row_values):
row_dict[h] = row_values[i]
else:
row_dict[h] = None
data.append(row_dict)
return data
@pytest.fixture(params=["zh-CN", "zh-TW", "en-US"])
def game_lang(request):
"""
参数化 Fixture切换语言并重载游戏数据。
测试结束后自动恢复回 zh-CN 环境。
"""
lang = request.param
# 1. Switch Language
language_manager.set_language(lang)
reload_translations()
# 2. Force Reload Game Data
from src.utils.config import update_paths_for_language
update_paths_for_language(lang)
from src.utils.df import reload_game_configs
reload_game_configs()
reload_techniques()
reload_sects()
yield lang
# Teardown: Restore to zh-CN for other tests
language_manager.set_language("zh-CN")
reload_translations()
update_paths_for_language("zh-CN")
reload_game_configs()
reload_techniques()
reload_sects()
class TestSectLoading: class TestSectLoading:
"""测试宗门数据加载""" """测试宗门数据加载 (多语言动态验证)"""
def test_sect_headquarter_name_loaded(self): def test_sect_headquarter_name_loaded(self, game_lang):
"""测试宗门驻地名称正确加载(来自 sect_region.csv 的 name 列)""" """测试宗门驻地名称正确加载"""
# 不夜城 (sect_id=12) 的驻地应该是 "大千光极城" # Read RAW Sect CSV
sect = sects_by_id.get(12) sect_csv_path = CONFIG.paths.shared_game_configs / "sect.csv"
assert sect is not None, "宗门 ID=12 应该存在" raw_sects = read_raw_csv_as_dict(sect_csv_path)
# 兼容多语言环境:检查中文或英文名称 # Read RAW Sect Region CSV (Source of HQ names)
expected_names = {"Sleepless City", "不夜城"} region_csv_path = CONFIG.paths.shared_game_configs / "sect_region.csv"
assert sect.name in expected_names, f"宗门名称 '{sect.name}' 不在预期列表中: {expected_names}" raw_regions = read_raw_csv_as_dict(region_csv_path)
sect_region_map = {int(r['sect_id']): r for r in raw_regions if r.get('sect_id')}
expected_hqs = {"Daqian Aurora City", "大千光极城"} # Verify specific Sect (ID=12, 不夜城)
assert sect.headquarter.name in expected_hqs, ( target_id = 12
f"驻地名称 '{sect.headquarter.name}' 不在预期列表中: {expected_hqs}" sect = sects_by_id.get(target_id)
)
def test_sect_headquarter_desc_loaded(self):
"""测试宗门驻地描述正确加载(来自 sect_region.csv 的 desc 列)"""
sect = sects_by_id.get(12)
assert sect is not None assert sect is not None
# 验证描述不为空且包含关键词 (兼容中英文)
assert sect.headquarter.desc, "驻地描述不应为空"
desc = sect.headquarter.desc.lower() # 1. Verify Sect Name
sect_row = next((r for r in raw_sects if int(r['id']) == target_id), None)
assert sect_row
# 简单宽松的检查:只要包含任一语言的关键词即可, expected_sect_name = sect_row.get('name')
# 因为测试环境加载语言的顺序可能不确定pytest 并行或 fixture 顺序)。 if sect_row.get('name_id'):
# 这样无论当前加载的是哪种语言的数据,只要数据本身正确就能通过。 trans = t(sect_row['name_id'])
keywords = ["aurora", "极光", "不夜"] if trans and trans != sect_row['name_id']:
expected_sect_name = trans
found = any(k in desc for k in keywords) assert sect.name == expected_sect_name, f"Sect name mismatch in {game_lang}"
assert found, f"驻地描述 '{desc}' 应该包含以下关键词之一: {keywords}"
# 2. Verify HQ Name
region_row = sect_region_map.get(target_id)
assert region_row
expected_hq_name = region_row.get('name')
if region_row.get('name_id'):
trans = t(region_row['name_id'])
if trans and trans != region_row['name_id']:
expected_hq_name = trans
assert sect.headquarter.name == expected_hq_name, f"HQ name mismatch in {game_lang}"
def test_all_sects_have_headquarters(self): def test_sect_headquarter_desc_loaded(self, game_lang):
"""测试宗门驻地描述正确加载"""
target_id = 12
sect = sects_by_id.get(target_id)
assert sect is not None
# Read RAW Sect Region CSV
region_csv_path = CONFIG.paths.shared_game_configs / "sect_region.csv"
raw_regions = read_raw_csv_as_dict(region_csv_path)
region_row = next((r for r in raw_regions if int(r.get('sect_id', -1)) == target_id), None)
assert region_row
expected_desc = region_row.get('desc')
if region_row.get('desc_id'):
trans = t(region_row['desc_id'])
if trans and trans != region_row['desc_id']:
expected_desc = trans
# Normalize newlines/spaces for comparison if needed
assert sect.headquarter.desc == expected_desc, f"HQ desc mismatch in {game_lang}"
def test_all_sects_have_headquarters(self, game_lang):
"""测试所有宗门都有驻地信息""" """测试所有宗门都有驻地信息"""
for sect_id, sect in sects_by_id.items(): for sect_id, sect in sects_by_id.items():
assert sect.headquarter is not None, f"宗门 {sect.name} (ID={sect_id}) 应该有驻地" assert sect.headquarter is not None
assert sect.headquarter.name, f"宗门 {sect.name} 的驻地名称不应为空" assert sect.headquarter.name, f"宗门 {sect.name} 的驻地名称不应为空"
def test_sect_techniques_loaded(self): def test_sect_techniques_loaded(self, game_lang):
"""测试宗门功法列表正确加载""" """测试宗门功法列表正确加载"""
# 明心剑宗 (sect_id=1) 应该有功法 sect = sects_by_id.get(1) # 明心剑宗
sect = sects_by_id.get(1)
assert sect is not None, "宗门 ID=1 应该存在"
assert len(sect.technique_names) > 0, (
f"宗门 '{sect.name}' 应该有独门功法,但 technique_names 为空"
)
def test_sect_without_techniques(self):
"""测试没有配置功法的宗门(不夜城 sect_id=12"""
sect = sects_by_id.get(12)
assert sect is not None assert sect is not None
# 不夜城在 technique.csv 中没有配置功法,所以应该是空列表 assert len(sect.technique_names) > 0
assert sect.technique_names == [], (
f"宗门 '{sect.name}' 不应该有独门功法" def test_sect_without_techniques(self, game_lang):
) """测试没有配置功法的宗门"""
sect = sects_by_id.get(12) # 不夜城
assert sect is not None
assert sect.technique_names == []
class TestTechniqueLoading: class TestTechniqueLoading:
"""测试功法数据加载""" """测试功法数据加载"""
def test_technique_sect_id_loaded(self): def test_technique_sect_id_loaded(self, game_lang):
"""测试功法的 sect_id 正确加载(来自 technique.csv 的 sect_id 列)""" """测试功法的 sect_id 正确加载"""
# 草字剑诀 (id=30) 属于明心剑宗 (sect_id=1) tech_id = 30 # 草字剑诀
technique = techniques_by_id.get(30) technique = techniques_by_id.get(tech_id)
assert technique is not None, "功法 ID=30 应该存在" assert technique is not None
# 兼容多语言环境 # Verify Name using Dynamic Logic
expected_names = {"Grass Word Sword Formula", "草字剑诀"} tech_csv_path = CONFIG.paths.shared_game_configs / "technique.csv"
assert technique.name in expected_names, f"功法名称 '{technique.name}' 不在预期列表中: {expected_names}" raw_techs = read_raw_csv_as_dict(tech_csv_path)
row = next((r for r in raw_techs if int(r['id']) == tech_id), None)
assert technique.sect_id == 1, ( expected_name = row.get('name')
f"功法 '{technique.name}' 的 sect_id 应该是 1而不是 {technique.sect_id}" if row.get('name_id'):
) trans = t(row['name_id'])
if trans and trans != row['name_id']:
expected_name = trans
assert technique.name == expected_name, f"Technique name mismatch in {game_lang}"
assert technique.sect_id == 1
def test_technique_without_sect(self): def test_technique_without_sect(self, game_lang):
"""测试散修功法(没有宗门限制)的 sect_id 为 None""" """测试散修功法"""
# 金刚不坏体 (id=1) 是散修功法
technique = techniques_by_id.get(1) technique = techniques_by_id.get(1)
assert technique is not None, "功法 ID=1 应该存在" assert technique is not None
assert technique.sect_id is None, ( assert technique.sect_id is None
f"散修功法 '{technique.name}' 的 sect_id 应该是 None而不是 {technique.sect_id}"
)
def test_sect_techniques_match(self): def test_sect_techniques_match(self, game_lang):
"""测试宗门功法和功法的宗门ID相互匹配""" """测试宗门功法和功法的宗门ID相互匹配"""
for sect_id, sect in sects_by_id.items(): for sect_id, sect in sects_by_id.items():
for tech_name in sect.technique_names: for tech_name in sect.technique_names:
technique = techniques_by_name.get(tech_name) technique = techniques_by_name.get(tech_name)
assert technique is not None, f"功法 '{tech_name}' 应该存在" # 注意technique_names 是 string list如果 names 不匹配(翻译问题)这里会取不到
assert technique.sect_id == sect_id, ( # 但我们的系统设计是sect.technique_names 是直接从 technique.csv 加载的
f"功法 '{tech_name}' 的 sect_id ({technique.sect_id}) " # 所以只要 reload 顺序正确(先 technique 后 sect名字应该是一致的
f"应该匹配宗门 '{sect.name}' 的 ID ({sect_id})" assert technique is not None, f"功法 '{tech_name}' 应该存在 (Lang: {game_lang})"
) assert technique.sect_id == sect_id
class TestElixirLoading: class TestElixirLoading:
"""测试丹药数据加载""" """丹药加载测试 (ID check, less dependent on lang but good to verify integrity)"""
def test_elixir_loaded_with_item_id(self): def test_elixir_loaded_with_item_id(self):
"""测试丹药使用 item_id 列正确加载""" # 丹药目前没有专门的 reload 和 translation key 绑定逻辑验证需求
from src.classes.elixir import elixirs_by_id # 保持原样即可,不需要 parametrizing unless needed
assert len(elixirs_by_id) > 0
# 验证丹药已加载且 ID 不为 0如果用错误的列名会得到默认值 0
assert len(elixirs_by_id) > 0, "应该加载到丹药数据"
for elixir_id, elixir in elixirs_by_id.items(): for elixir_id, elixir in elixirs_by_id.items():
assert elixir_id > 0, f"丹药 '{elixir.name}' 的 ID 应该大于 0" assert elixir_id > 0
assert elixir.id == elixir_id, f"丹药 ID 不匹配: {elixir.id} != {elixir_id}" assert elixir.id == elixir_id
class TestGameDataAPI: class TestGameDataAPI:
"""测试 /api/meta/game_data API 返回正确的数据结构""" """测试 API (API 测试通常在固定环境下运行,这里不使用多语言参数化以免影响 Server 状态)"""
@pytest.fixture @pytest.fixture
def client(self): def client(self):
"""创建测试客户端"""
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from src.server.main import app from src.server.main import app
return TestClient(app) return TestClient(app)
def test_game_data_techniques_have_sect_id(self, client): def test_game_data_techniques_have_sect_id(self, client):
"""测试 /api/meta/game_data 返回的功法包含 sect_id 字段(而非 sect"""
response = client.get("/api/meta/game_data") response = client.get("/api/meta/game_data")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert "techniques" in data, "响应应该包含 techniques 字段" assert len(data["techniques"]) > 0
assert "sect_id" in data["techniques"][0]
techniques = data["techniques"]
assert len(techniques) > 0, "应该有功法数据"
for tech in techniques:
# 确保使用 sect_id 而非 sect
assert "sect_id" in tech, (
f"功法 '{tech.get('name', 'unknown')}' 应该有 sect_id 字段"
)
assert "sect" not in tech, (
f"功法 '{tech.get('name', 'unknown')}' 不应该有 sect 字段(应使用 sect_id"
)
# 验证 sect_id 的值类型正确
sect_id = tech["sect_id"]
assert sect_id is None or isinstance(sect_id, int), (
f"功法 '{tech.get('name')}' 的 sect_id 应该是 None 或 int而不是 {type(sect_id)}"
)
def test_game_data_sects_structure(self, client): def test_game_data_sects_structure(self, client):
"""测试 /api/meta/game_data 返回的宗门数据结构正确"""
response = client.get("/api/meta/game_data") response = client.get("/api/meta/game_data")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert "sects" in data, "响应应该包含 sects 字段" assert len(data["sects"]) > 0
assert "id" in data["sects"][0]
sects = data["sects"]
assert len(sects) > 0, "应该有宗门数据"
for sect in sects:
assert "id" in sect, "宗门应该有 id 字段"
assert "name" in sect, "宗门应该有 name 字段"
assert sect["id"] > 0, f"宗门 '{sect.get('name')}' 的 ID 应该大于 0"
if __name__ == "__main__": if __name__ == "__main__":
pytest.main([__file__, "-v"]) pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,107 @@
import pytest
from src.classes.death_reason import DeathReason, DeathType
from src.classes.death import handle_death
from src.classes.region import CultivateRegion, EssenceType
def test_death_releases_region(base_world, dummy_avatar):
"""测试死亡时释放占领的洞府"""
# 1. 创建一个修炼区域
region = CultivateRegion(
id=1001,
name="Test Cave",
desc="A test cave",
essence_type=EssenceType.GOLD,
essence_density=10
)
# 将区域添加到地图
base_world.map.regions[region.id] = region
# 2. 让角色占领该区域
dummy_avatar.occupy_region(region)
# 验证占领成功
assert region.host_avatar == dummy_avatar
assert region in dummy_avatar.owned_regions
# 3. 角色死亡
reason = DeathReason(DeathType.OLD_AGE)
handle_death(base_world, dummy_avatar, reason)
# 4. 验证洞府已被释放
assert region.host_avatar is None
assert len(dummy_avatar.owned_regions) == 0
assert dummy_avatar.is_dead is True
def test_occupy_region_logic(base_world, dummy_avatar):
"""测试占领逻辑的双向绑定和抢夺"""
from src.classes.avatar import Avatar, Gender
from src.classes.age import Age
from src.classes.cultivation import Realm
from src.utils.id_generator import get_avatar_id
from src.classes.root import Root
from src.classes.alignment import Alignment
from src.classes.calendar import create_month_stamp, Year, Month
# 创建第二个角色
other_avatar = Avatar(
world=base_world,
name="Other",
id=get_avatar_id(),
birth_month_stamp=create_month_stamp(Year(2000), Month.JANUARY),
age=Age(20, Realm.Qi_Refinement),
gender=Gender.MALE,
pos_x=0, pos_y=0,
root=Root.WOOD,
alignment=Alignment.RIGHTEOUS
)
region = CultivateRegion(
id=1002,
name="Test Cave 2",
desc="Another test cave",
essence_type=EssenceType.WOOD,
essence_density=10
)
# 1. dummy_avatar 占领
dummy_avatar.occupy_region(region)
assert region.host_avatar == dummy_avatar
assert region in dummy_avatar.owned_regions
# 2. other_avatar 抢夺
other_avatar.occupy_region(region)
# 验证所有权转移
assert region.host_avatar == other_avatar
assert region in other_avatar.owned_regions
# 验证旧主已释放
assert region not in dummy_avatar.owned_regions
def test_remove_avatar_releases_region(base_world, dummy_avatar):
"""测试彻底删除角色时释放占领的洞府"""
# 1. 创建一个修炼区域
region = CultivateRegion(
id=1003,
name="Test Cave 3",
desc="Yet another test cave",
essence_type=EssenceType.WATER,
essence_density=10
)
base_world.map.regions[region.id] = region
# 2. 注册并占领
base_world.avatar_manager.register_avatar(dummy_avatar)
dummy_avatar.occupy_region(region)
assert region.host_avatar == dummy_avatar
# 3. 彻底删除角色
base_world.avatar_manager.remove_avatar(dummy_avatar.id)
# 4. 验证洞府已被释放
assert region.host_avatar is None
# 注意:此时 dummy_avatar 对象可能还在内存中,但已经从管理器移除
# 它的 owned_regions 应该被清空了
assert len(dummy_avatar.owned_regions) == 0

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { NButton, NSelect } from 'naive-ui' import { NButton, NSelect, NIcon } from 'naive-ui'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useSettingStore } from '../stores/setting' import { useSettingStore } from '../stores/setting'
import SaveLoadPanel from './game/panels/system/SaveLoadPanel.vue' import SaveLoadPanel from './game/panels/system/SaveLoadPanel.vue'
@@ -149,7 +149,14 @@ watch(() => props.visible, (val) => {
<div class="settings-form"> <div class="settings-form">
<div class="setting-item"> <div class="setting-item">
<span class="setting-label">{{ t('ui.language') }}</span> <div class="setting-label-group">
<n-icon size="24" color="#eee" class="setting-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="M256 48C141.13 48 48 141.13 48 256s93.13 208 208 208s208-93.13 208-208S370.87 48 256 48m0 46.54c33.4 0 63.87 11.23 88.63 30.17c-22.56 31.84-51.48 59.7-88.63 80.64c-37.15-20.94-66.07-48.8-88.63-80.64C192.13 105.77 222.6 94.54 256 94.54m-80 32.14c22.76 27.65 49.77 52.37 80 71.95c-30.23 19.58-57.24 44.3-80 71.95c-20.78-22.38-38.35-46.73-52.09-72c13.73-25.26 31.31-49.61 52.09-71.9ZM256 417.46c-33.4 0-63.87-11.23-88.63-30.17c22.56-31.84 51.48-59.7 88.63-80.64c37.15 20.94 66.07 48.8 88.63 80.64c-24.76 18.94-55.23 30.17-88.63 30.17m80-32.14c-22.76-27.65-49.77-52.37-80-71.95c30.23-19.58 57.24-44.3 80-71.95c20.78 22.38 38.35 46.73 52.09 72c-13.74 25.26-31.31 49.61-52.09 71.95M256 244.64c-25.68-18.3-48.46-41.22-67.45-66.52c19.64-18.79 42.41-32.58 67.45-39.72c25.04 7.14 47.81 20.93 67.45 39.72c-18.99 25.3-41.77 48.22-67.45 66.52m0 109.24c-25.04-7.14-47.81-20.93-67.45-39.72c18.99-25.3 41.77-48.22 67.45-66.52c25.68 18.3 48.46 41.22 67.45 66.52c-19.64 18.79-42.41 32.58-67.45 39.72M81.56 238.15c13.29 27.23 30.76 52.92 51.64 76.54c-15.65-17.65-28.77-37.15-38.74-58.12c-5.18-10.9-9.17-22.03-11.96-33.37c3.15 5.06 6.13 10.05 9.06 14.95m24.16-52.53c9.97-20.97 23.09-40.47 38.74-58.12c-20.88 23.62-38.35 49.31-51.64 76.54c-2.93 4.9-5.91 9.89-9.06 14.95c2.79-11.34 6.78-22.47 11.96-33.37M406.28 273.85c-9.97 20.97-23.09 40.47-38.74 58.12c20.88-23.62 38.35-49.31 51.64-76.54c2.93-4.9 5.91-9.89 9.06-14.95c-2.79 11.34-6.78 22.47-11.96 33.37m-24.16 52.53c-13.29-27.23-30.76-52.92-51.64-76.54c15.65 17.65 28.77 37.15 38.74 58.12c5.18 10.9 9.17 22.03 11.96 33.37c-3.15-5.06-6.13-10.05-9.06-14.95"/>
</svg>
</n-icon>
<span class="setting-label">{{ t('ui.language') }}</span>
</div>
<n-select <n-select
v-model:value="settingStore.locale" v-model:value="settingStore.locale"
:options="languageOptions" :options="languageOptions"
@@ -212,6 +219,19 @@ watch(() => props.visible, (val) => {
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
} }
.setting-label-group {
display: flex;
align-items: center;
gap: 12px;
}
.setting-icon {
display: flex;
align-items: center;
justify-content: center;
opacity: 0.9;
}
.setting-label { .setting-label {
font-size: 1.1em; font-size: 1.1em;
color: #eee; color: #eee;