update web, add clickable parts
This commit is contained in:
@@ -44,6 +44,16 @@ class Animal:
|
||||
|
||||
return " - ".join(info_parts)
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
items_info = [item.get_structured_info() for item in self.items]
|
||||
return {
|
||||
"name": self.name,
|
||||
"desc": self.desc,
|
||||
"grade": self.realm.value,
|
||||
"drops": items_info,
|
||||
"type": "animal"
|
||||
}
|
||||
|
||||
def _load_animals() -> tuple[dict[int, Animal], dict[str, Animal]]:
|
||||
"""从配表加载animal数据"""
|
||||
animals_by_id: dict[int, Animal] = {}
|
||||
|
||||
@@ -41,6 +41,16 @@ class Auxiliary:
|
||||
r, g, b = self.grade.color_rgb
|
||||
return f"<color:{r},{g},{b}>{self.get_info()}</color>"
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
from src.utils.effect_desc import format_effects_to_text
|
||||
return {
|
||||
"name": self.name,
|
||||
"desc": self.desc,
|
||||
"grade": self.grade.value,
|
||||
"color": self.grade.color_rgb,
|
||||
"effect_desc": format_effects_to_text(self.effects),
|
||||
}
|
||||
|
||||
|
||||
def _load_auxiliaries() -> tuple[Dict[int, Auxiliary], Dict[str, Auxiliary], Dict[int, Auxiliary]]:
|
||||
"""从配表加载 auxiliary 数据。
|
||||
|
||||
@@ -278,6 +278,119 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin):
|
||||
info_dict["短期目标"] = self.short_term_objective
|
||||
return info_dict
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
"""
|
||||
获取结构化的角色信息,用于前端展示和交互。
|
||||
"""
|
||||
# 基础信息
|
||||
info = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"gender": str(self.gender),
|
||||
"age": self.age.age,
|
||||
"lifespan": self.age.max_lifespan,
|
||||
"realm": self.cultivation_progress.realm.value,
|
||||
"level": self.cultivation_progress.level,
|
||||
"hp": {"cur": self.hp.cur, "max": self.hp.max},
|
||||
"mp": {"cur": self.mp.cur, "max": self.mp.max},
|
||||
"alignment": str(self.alignment) if self.alignment else "未知",
|
||||
"magic_stone": self.magic_stone.value,
|
||||
"thinking": self.thinking,
|
||||
"short_term_objective": self.short_term_objective,
|
||||
"long_term_objective": self.long_term_objective.content if self.long_term_objective else "",
|
||||
"nickname": self.nickname,
|
||||
}
|
||||
|
||||
# 复杂对象结构化
|
||||
|
||||
# 1. 特质 (Personas)
|
||||
info["personas"] = [p.get_structured_info() for p in self.personas]
|
||||
|
||||
# 2. 功法 (Technique)
|
||||
if self.technique:
|
||||
info["technique"] = self.technique.get_structured_info()
|
||||
else:
|
||||
info["technique"] = None
|
||||
|
||||
# 3. 宗门 (Sect)
|
||||
if self.sect:
|
||||
sect_info = self.sect.get_structured_info()
|
||||
# 补充职位信息
|
||||
if self.sect_rank:
|
||||
from src.classes.sect_ranks import get_rank_display_name
|
||||
sect_info["rank"] = get_rank_display_name(self.sect_rank, self.sect)
|
||||
else:
|
||||
sect_info["rank"] = "弟子"
|
||||
info["sect"] = sect_info
|
||||
else:
|
||||
info["sect"] = None
|
||||
|
||||
# 补充:阵营详情
|
||||
from src.classes.alignment import alignment_infos, alignment_strs
|
||||
# 保持 alignment 字段为 string (value) 兼容现有逻辑
|
||||
info["alignment"] = str(self.alignment) if self.alignment else "未知"
|
||||
if self.alignment:
|
||||
cn_name = alignment_strs.get(self.alignment, self.alignment.value)
|
||||
desc = alignment_infos.get(self.alignment, "")
|
||||
info["alignment_detail"] = {
|
||||
"name": cn_name,
|
||||
"desc": desc,
|
||||
}
|
||||
|
||||
# 4. 装备 (Weapon & Auxiliary)
|
||||
if self.weapon:
|
||||
w_info = self.weapon.get_structured_info()
|
||||
w_info["proficiency"] = f"{self.weapon_proficiency:.1f}%"
|
||||
info["weapon"] = w_info
|
||||
else:
|
||||
info["weapon"] = None
|
||||
|
||||
if self.auxiliary:
|
||||
info["auxiliary"] = self.auxiliary.get_structured_info()
|
||||
else:
|
||||
info["auxiliary"] = None
|
||||
|
||||
# 5. 物品 (Items)
|
||||
items_list = []
|
||||
for item, count in self.items.items():
|
||||
i_info = item.get_structured_info()
|
||||
i_info["count"] = count
|
||||
items_list.append(i_info)
|
||||
info["items"] = items_list
|
||||
|
||||
# 6. 关系 (Relations)
|
||||
relations_list = []
|
||||
for other, relation in self.relations.items():
|
||||
relations_list.append({
|
||||
"target_id": other.id,
|
||||
"name": other.name,
|
||||
"relation": str(relation),
|
||||
# 可以加更多 info,比如境界,用于列表中展示
|
||||
"realm": other.cultivation_progress.realm.value,
|
||||
"sect": other.sect.name if other.sect else "散修"
|
||||
})
|
||||
info["relations"] = relations_list
|
||||
|
||||
# 7. 外貌
|
||||
info["appearance"] = self.appearance.get_info()
|
||||
|
||||
# 8. 灵根
|
||||
from src.classes.root import format_root_cn
|
||||
from src.utils.effect_desc import format_effects_to_text
|
||||
root_str = format_root_cn(self.root)
|
||||
info["root"] = root_str
|
||||
info["root_detail"] = {
|
||||
"name": root_str,
|
||||
"desc": f"包含元素:{'、'.join(str(e) for e in self.root.elements)}",
|
||||
"effect_desc": format_effects_to_text(self.root.effects)
|
||||
}
|
||||
|
||||
# 9. 灵兽
|
||||
if self.spirit_animal:
|
||||
info["spirit_animal"] = self.spirit_animal.get_structured_info()
|
||||
|
||||
return info
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.get_info(detailed=False))
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ class Item:
|
||||
def get_detailed_info(self) -> str:
|
||||
return f"{self.name} - {self.desc}({self.realm.value})"
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"desc": self.desc,
|
||||
"grade": self.realm.value,
|
||||
"effect_desc": "" # 物品暂时没有效果字段
|
||||
}
|
||||
|
||||
def _load_items() -> tuple[dict[int, Item], dict[str, Item]]:
|
||||
"""从配表加载item数据"""
|
||||
items_by_id: dict[int, Item] = {}
|
||||
|
||||
@@ -41,6 +41,16 @@ class Persona:
|
||||
r, g, b = self.rarity.color_rgb
|
||||
return f"<color:{r},{g},{b}>{self.name}</color>"
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
from src.utils.effect_desc import format_effects_to_text
|
||||
return {
|
||||
"name": self.name,
|
||||
"desc": self.desc,
|
||||
"rarity": self.rarity.level.value,
|
||||
"color": self.rarity.color_rgb,
|
||||
"effect_desc": format_effects_to_text(self.effects),
|
||||
}
|
||||
|
||||
def _load_personas() -> tuple[dict[int, Persona], dict[str, Persona]]:
|
||||
"""从配表加载persona数据"""
|
||||
personas_by_id: dict[int, Persona] = {}
|
||||
|
||||
@@ -44,6 +44,16 @@ class Plant:
|
||||
|
||||
return " - ".join(info_parts)
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
items_info = [item.get_structured_info() for item in self.items]
|
||||
return {
|
||||
"name": self.name,
|
||||
"desc": self.desc,
|
||||
"grade": self.realm.value,
|
||||
"drops": items_info,
|
||||
"type": "plant"
|
||||
}
|
||||
|
||||
def _load_plants() -> tuple[dict[int, Plant], dict[str, Plant]]:
|
||||
"""从配表加载plant数据"""
|
||||
plants_by_id: dict[int, Plant] = {}
|
||||
|
||||
@@ -177,6 +177,15 @@ class Region(ABC):
|
||||
# 基类暂无更多结构化信息,详细版返回名称+描述
|
||||
return f"{self.name} - {self.desc}"
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"desc": self.desc,
|
||||
"type": self.get_region_type(),
|
||||
"type_name": "区域"
|
||||
}
|
||||
|
||||
|
||||
class Shape(Enum):
|
||||
"""
|
||||
@@ -301,6 +310,13 @@ class NormalRegion(Region):
|
||||
# 如果该区域有植物,则可以采集
|
||||
return len(self.plants) > 0
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
info = super().get_structured_info()
|
||||
info["type_name"] = "普通区域"
|
||||
info["animals"] = [a.get_structured_info() for a in self.animals]
|
||||
info["plants"] = [p.get_structured_info() for p in self.plants]
|
||||
return info
|
||||
|
||||
|
||||
@dataclass
|
||||
class CultivateRegion(Region):
|
||||
@@ -338,6 +354,15 @@ class CultivateRegion(Region):
|
||||
lines.append(f"主要灵气: {self.essence_type} {stars}")
|
||||
return lines
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
info = super().get_structured_info()
|
||||
info["type_name"] = "修炼区域"
|
||||
info["essence"] = {
|
||||
"type": self.essence_type.value,
|
||||
"density": self.essence_density
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
@dataclass
|
||||
class CityRegion(Region):
|
||||
@@ -361,6 +386,11 @@ class CityRegion(Region):
|
||||
def get_detailed_info(self) -> str:
|
||||
return f"{self.name} - {self.desc}"
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
info = super().get_structured_info()
|
||||
info["type_name"] = "城市区域"
|
||||
return info
|
||||
|
||||
|
||||
def _normalize_region_name(name: str) -> str:
|
||||
"""
|
||||
|
||||
@@ -69,6 +69,19 @@ class Sect:
|
||||
# 优先使用自定义名称,否则使用默认名称
|
||||
return self.rank_names.get(rank.value, DEFAULT_RANK_NAMES.get(rank, "弟子"))
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
from src.utils.effect_desc import format_effects_to_text
|
||||
hq = self.headquarter
|
||||
return {
|
||||
"name": self.name,
|
||||
"desc": self.desc,
|
||||
"alignment": self.alignment.value,
|
||||
"style": self.member_act_style,
|
||||
"hq_name": hq.name,
|
||||
"hq_desc": hq.desc,
|
||||
"effect_desc": format_effects_to_text(self.effects),
|
||||
}
|
||||
|
||||
def _split_names(value: object) -> list[str]:
|
||||
raw = "" if value is None or str(value) == "nan" else str(value)
|
||||
sep = CONFIG.df.ids_separator
|
||||
|
||||
@@ -24,4 +24,8 @@ class SectRegion(Region):
|
||||
f"描述: {self.desc}",
|
||||
]
|
||||
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
info = super().get_structured_info()
|
||||
info["type_name"] = "宗门驻地"
|
||||
info["sect_name"] = self.sect_name
|
||||
return info
|
||||
|
||||
@@ -39,4 +39,13 @@ class SpiritAnimal:
|
||||
pts = self.get_extra_strength_points()
|
||||
return {"extra_battle_strength_points": pts} if pts else {}
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
from src.utils.effect_desc import format_effects_to_text
|
||||
return {
|
||||
"name": self.name,
|
||||
"desc": f"境界:{self.realm.value}",
|
||||
"grade": self.realm.value,
|
||||
"effect_desc": format_effects_to_text(self.effects),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -83,6 +83,17 @@ class Technique:
|
||||
r, g, b = self.grade.color_rgb
|
||||
return f"<color:{r},{g},{b}>{self.name}({self.attribute}·{self.grade.value})</color>"
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
from src.utils.effect_desc import format_effects_to_text
|
||||
return {
|
||||
"name": self.name,
|
||||
"desc": self.desc,
|
||||
"grade": self.grade.value,
|
||||
"color": self.grade.color_rgb,
|
||||
"attribute": self.attribute.value,
|
||||
"effect_desc": format_effects_to_text(self.effects),
|
||||
}
|
||||
|
||||
# 五行与扩展属性的克制关系
|
||||
# - 五行:金克木,木克土,土克水,水克火,火克金
|
||||
# - 雷克邪;邪、冰、风、暗不克任何人
|
||||
|
||||
@@ -51,6 +51,28 @@ class Weapon:
|
||||
r, g, b = self.grade.color_rgb
|
||||
return f"<color:{r},{g},{b}>{self.get_info()}</color>"
|
||||
|
||||
def get_structured_info(self) -> dict:
|
||||
from src.utils.effect_desc import format_effects_to_text
|
||||
|
||||
# 基础描述
|
||||
full_desc = self.desc
|
||||
|
||||
# 特殊数据处理
|
||||
souls = 0
|
||||
if self.name == "万魂幡":
|
||||
souls = self.special_data.get("devoured_souls", 0)
|
||||
if souls > 0:
|
||||
full_desc = f"{full_desc} (已吞噬魂魄:{souls})"
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"desc": full_desc,
|
||||
"grade": self.grade.value,
|
||||
"color": self.grade.color_rgb,
|
||||
"type": self.weapon_type.value,
|
||||
"effect_desc": format_effects_to_text(self.effects),
|
||||
}
|
||||
|
||||
|
||||
def _load_weapons() -> tuple[Dict[int, Weapon], Dict[str, Weapon], Dict[int, Weapon]]:
|
||||
"""从配表加载 weapon 数据。
|
||||
|
||||
@@ -475,6 +475,45 @@ def get_hover_info(
|
||||
"lines": serialize_hover_lines([str(line) for line in lines]),
|
||||
}
|
||||
|
||||
@app.get("/api/detail")
|
||||
def get_detail_info(
|
||||
target_type: str = Query(alias="type"),
|
||||
target_id: str = Query(alias="id")
|
||||
):
|
||||
"""获取结构化详情信息,替代/增强 hover info"""
|
||||
world = game_instance.get("world")
|
||||
if world is None:
|
||||
raise HTTPException(status_code=503, detail="World not initialized")
|
||||
|
||||
target = None
|
||||
if target_type == "avatar":
|
||||
target = world.avatar_manager.avatars.get(target_id)
|
||||
elif target_type == "region":
|
||||
if world.map and hasattr(world.map, "regions"):
|
||||
regions = world.map.regions
|
||||
target = regions.get(target_id)
|
||||
if target is None:
|
||||
try:
|
||||
target = regions.get(int(target_id))
|
||||
except (ValueError, TypeError):
|
||||
target = None
|
||||
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
|
||||
if hasattr(target, "get_structured_info"):
|
||||
return target.get_structured_info()
|
||||
else:
|
||||
# 回退到 hover info 如果没有结构化信息
|
||||
if hasattr(target, "get_hover_info"):
|
||||
lines = target.get_hover_info() or []
|
||||
return {
|
||||
"fallback": True,
|
||||
"name": getattr(target, "name", target_id),
|
||||
"lines": serialize_hover_lines([str(line) for line in lines])
|
||||
}
|
||||
return {"error": "No info available"}
|
||||
|
||||
class SetObjectiveRequest(BaseModel):
|
||||
avatar_id: str
|
||||
content: str
|
||||
|
||||
82
src/utils/effect_desc.py
Normal file
82
src/utils/effect_desc.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from typing import Any
|
||||
|
||||
EFFECT_DESC_MAP = {
|
||||
"extra_hp_recovery_rate": "生命恢复速率",
|
||||
"extra_max_hp": "最大生命值",
|
||||
"extra_max_mp": "最大灵力值",
|
||||
"extra_max_lifespan": "最大寿元",
|
||||
"extra_weapon_proficiency_gain": "兵器熟练度获取",
|
||||
"extra_dual_cultivation_exp": "双修经验",
|
||||
"extra_breakthrough_success_rate": "突破成功率",
|
||||
"extra_fortune_probability": "奇遇概率",
|
||||
"extra_harvest_items": "采集获取物品",
|
||||
"extra_hunt_items": "狩猎获取物品",
|
||||
"extra_item_sell_price_multiplier": "物品出售价格",
|
||||
"extra_weapon_upgrade_chance": "兵器升级概率",
|
||||
"extra_plunder_multiplier": "掠夺收益",
|
||||
"extra_catch_success_rate": "捕捉灵兽成功率",
|
||||
"extra_cultivate_exp": "修炼经验",
|
||||
"extra_battle_strength_points": "战力点数",
|
||||
"extra_escape_success_rate": "逃跑成功率",
|
||||
"extra_observation_radius": "感知范围",
|
||||
"extra_move_step": "移动步长",
|
||||
}
|
||||
|
||||
def format_value(key: str, value: Any) -> str:
|
||||
"""
|
||||
格式化效果数值
|
||||
"""
|
||||
if isinstance(value, (int, float)):
|
||||
# 处理百分比类型的字段
|
||||
if "rate" in key or "probability" in key or "chance" in key or "multiplier" in key or "gain" in key:
|
||||
# 如果是小数,转为百分比。通常 0.1 表示 +10%
|
||||
# 但有些可能是直接的倍率?代码里 1.0 + value,所以 value 是增量
|
||||
if isinstance(value, float):
|
||||
percent = value * 100
|
||||
sign = "+" if percent > 0 else ""
|
||||
return f"{sign}{percent:.1f}%"
|
||||
|
||||
# 处理数值类型的字段
|
||||
sign = "+" if value > 0 else ""
|
||||
return f"{sign}{value}"
|
||||
|
||||
return str(value)
|
||||
|
||||
def format_effects_to_text(effects: dict[str, Any] | list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
将 effects 字典转换为易读的文本描述。
|
||||
例如:{"extra_max_hp": 100} -> "最大生命值 +100"
|
||||
"""
|
||||
if not effects:
|
||||
return ""
|
||||
|
||||
if isinstance(effects, list):
|
||||
parts = []
|
||||
for eff in effects:
|
||||
text = format_effects_to_text(eff)
|
||||
if text:
|
||||
if eff.get("when"):
|
||||
parts.append(f"[条件触发] {text}")
|
||||
else:
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
|
||||
desc_list = []
|
||||
for k, v in effects.items():
|
||||
if k == "when":
|
||||
continue
|
||||
|
||||
# 跳过 eval 表达式或者无法解析的 key,或者直接显示 key
|
||||
name = EFFECT_DESC_MAP.get(k, k)
|
||||
|
||||
# 如果是 eval 表达式(字符串形式)
|
||||
if isinstance(v, str) and v.startswith("eval("):
|
||||
# 尝试提取简单的描述,或者显示"特殊效果"
|
||||
val_str = "特殊效果"
|
||||
else:
|
||||
val_str = format_value(k, v)
|
||||
|
||||
desc_list.append(f"{name} {val_str}")
|
||||
|
||||
return ";".join(desc_list)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useGameStore } from '../stores/game'
|
||||
import type { IEffectEntity } from '../types/game'
|
||||
|
||||
const store = useGameStore()
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
@@ -9,13 +10,22 @@ let lastOpenAt = 0
|
||||
const showObjectiveModal = ref(false)
|
||||
const objectiveContent = ref('')
|
||||
|
||||
const title = computed(() => store.selectedTarget?.name ?? '')
|
||||
// Secondary info state
|
||||
const secondaryItem = ref<IEffectEntity | null>(null)
|
||||
|
||||
const title = computed(() => {
|
||||
if (store.detailInfo && !store.detailInfo.fallback) {
|
||||
return store.detailInfo.name
|
||||
}
|
||||
return store.selectedTarget?.name ?? ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.selectedTarget,
|
||||
(target) => {
|
||||
showObjectiveModal.value = false
|
||||
objectiveContent.value = ''
|
||||
secondaryItem.value = null
|
||||
if (target) {
|
||||
lastOpenAt = performance.now()
|
||||
}
|
||||
@@ -48,6 +58,32 @@ async function handleClearObjective() {
|
||||
}
|
||||
}
|
||||
|
||||
function showSecondary(item: IEffectEntity | undefined) {
|
||||
if (item) {
|
||||
secondaryItem.value = item
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToAvatar(id: string) {
|
||||
store.openInfoPanel({ type: 'avatar', id })
|
||||
}
|
||||
|
||||
// Helper for colors
|
||||
function getRarityColor(item: any) {
|
||||
if (!item) return undefined
|
||||
if (item.color && Array.isArray(item.color) && item.color.length === 3) {
|
||||
const [r, g, b] = item.color
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
// Map grade string to color if no color prop
|
||||
const grade = item.grade || item.rarity
|
||||
if (!grade) return undefined
|
||||
if (['上品', '宝物', 'SR', 'Upper'].some(s => grade.includes(s))) return '#c488fd' // Purple
|
||||
if (['中品', 'R', 'Middle'].some(s => grade.includes(s))) return '#88fdc4' // Green? Usually blue
|
||||
if (['法宝', 'SSR', 'Artifact'].some(s => grade.includes(s))) return '#fddc88' // Gold
|
||||
return undefined
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||
})
|
||||
@@ -63,20 +99,252 @@ onUnmounted(() => {
|
||||
class="info-panel"
|
||||
ref="panelRef"
|
||||
>
|
||||
<!-- Secondary Panel (Popup) -->
|
||||
<div v-if="secondaryItem" class="secondary-panel">
|
||||
<div class="sec-header">
|
||||
<span class="sec-title" :style="{ color: getRarityColor(secondaryItem) }">{{ secondaryItem.name }}</span>
|
||||
<button class="close-btn-small" @click="secondaryItem = null">×</button>
|
||||
</div>
|
||||
<div class="sec-body">
|
||||
<div class="sec-row" v-if="secondaryItem.grade || secondaryItem.rarity">
|
||||
<span class="tag">{{ secondaryItem.grade || secondaryItem.rarity }}</span>
|
||||
</div>
|
||||
<div class="sec-desc">{{ secondaryItem.desc }}</div>
|
||||
<div v-if="secondaryItem.effect_desc" class="sec-effect-box">
|
||||
<div class="sec-label">效果:</div>
|
||||
<div class="sec-effect-text">{{ secondaryItem.effect_desc }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 特殊字段展示 -->
|
||||
<div v-if="secondaryItem.hq_name" class="sec-extra">
|
||||
<div><strong>驻地:</strong> {{ secondaryItem.hq_name }}</div>
|
||||
<div class="sub-desc">{{ secondaryItem.hq_desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions" v-if="store.selectedTarget.type === 'avatar'">
|
||||
<button class="action-btn primary" @click="showObjectiveModal = true">设定长期目标</button>
|
||||
<button class="action-btn secondary" @click="handleClearObjective">清空长期目标</button>
|
||||
</div>
|
||||
|
||||
<button class="close-btn-absolute" type="button" @click="store.closeInfoPanel()">×</button>
|
||||
|
||||
<div class="info-header">
|
||||
<div class="info-title">{{ title || '详情' }}</div>
|
||||
<button class="close-btn" type="button" @click="store.closeInfoPanel()">×</button>
|
||||
<div class="header-right">
|
||||
<span v-if="store.detailInfo && !store.detailInfo.fallback" class="header-subtitle">{{ store.detailInfo.nickname || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-body">
|
||||
<div v-if="store.infoLoading" class="placeholder">加载中...</div>
|
||||
<div v-else-if="store.infoError" class="placeholder error">
|
||||
{{ store.infoError }}
|
||||
</div>
|
||||
|
||||
<!-- 结构化数据展示 -->
|
||||
<div v-else-if="store.detailInfo && !store.detailInfo.fallback && store.selectedTarget.type === 'avatar'" class="structured-content">
|
||||
<!-- 基础属性 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<label>境界</label>
|
||||
<span>{{ store.detailInfo.realm }} ({{ store.detailInfo.level }})</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<label>年龄</label>
|
||||
<span>{{ store.detailInfo.age }} / {{ store.detailInfo.lifespan }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<label>HP</label>
|
||||
<span>{{ Math.floor(store.detailInfo.hp.cur) }}/{{ store.detailInfo.hp.max }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<label>灵石</label>
|
||||
<span>{{ store.detailInfo.magic_stone }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-item full clickable"
|
||||
@click="showSecondary(store.detailInfo.alignment_detail)"
|
||||
v-if="store.detailInfo.alignment_detail"
|
||||
>
|
||||
<label>阵营</label>
|
||||
<span>{{ store.detailInfo.alignment }}</span>
|
||||
</div>
|
||||
<div class="stat-item full" v-else>
|
||||
<label>阵营</label>
|
||||
<span>{{ store.detailInfo.alignment }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="stat-item full clickable"
|
||||
@click="showSecondary(store.detailInfo.root_detail)"
|
||||
v-if="store.detailInfo.root_detail"
|
||||
>
|
||||
<label>灵根</label>
|
||||
<span>{{ store.detailInfo.root }}</span>
|
||||
</div>
|
||||
<div class="stat-item full" v-else>
|
||||
<label>灵根</label>
|
||||
<span>{{ store.detailInfo.root }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态 -->
|
||||
<div class="section" v-if="store.detailInfo.thinking">
|
||||
<div class="section-title">当前思考</div>
|
||||
<div class="text-content">{{ store.detailInfo.thinking }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 特质 -->
|
||||
<div class="section" v-if="store.detailInfo.personas?.length">
|
||||
<div class="section-title">特质</div>
|
||||
<div class="tags-container">
|
||||
<span
|
||||
v-for="p in store.detailInfo.personas"
|
||||
:key="p.name"
|
||||
class="clickable-tag"
|
||||
:style="{ borderColor: getRarityColor(p) }"
|
||||
@click="showSecondary(p)"
|
||||
>
|
||||
{{ p.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 功法 -->
|
||||
<div class="section" v-if="store.detailInfo.technique">
|
||||
<div class="section-title">功法</div>
|
||||
<div class="clickable-item" @click="showSecondary(store.detailInfo.technique)">
|
||||
<span :style="{ color: getRarityColor(store.detailInfo.technique) }">{{ store.detailInfo.technique.name }}</span>
|
||||
<span class="item-meta">{{ store.detailInfo.technique.grade }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 宗门 -->
|
||||
<div class="section" v-if="store.detailInfo.sect">
|
||||
<div class="section-title">宗门</div>
|
||||
<div class="clickable-item" @click="showSecondary(store.detailInfo.sect)">
|
||||
<span>{{ store.detailInfo.sect.name }}</span>
|
||||
<span class="item-meta">{{ store.detailInfo.sect.rank }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装备 -->
|
||||
<div class="section" v-if="store.detailInfo.weapon || store.detailInfo.auxiliary">
|
||||
<div class="section-title">装备</div>
|
||||
<div v-if="store.detailInfo.weapon" class="clickable-item" @click="showSecondary(store.detailInfo.weapon)">
|
||||
<span :style="{ color: getRarityColor(store.detailInfo.weapon) }">{{ store.detailInfo.weapon.name }}</span>
|
||||
<span class="item-meta">熟练度 {{ store.detailInfo.weapon.proficiency }}</span>
|
||||
</div>
|
||||
<div v-if="store.detailInfo.auxiliary" class="clickable-item" @click="showSecondary(store.detailInfo.auxiliary)">
|
||||
<span :style="{ color: getRarityColor(store.detailInfo.auxiliary) }">{{ store.detailInfo.auxiliary.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 灵兽 -->
|
||||
<div class="section" v-if="store.detailInfo.spirit_animal">
|
||||
<div class="section-title">灵兽</div>
|
||||
<div class="clickable-item" @click="showSecondary(store.detailInfo.spirit_animal)">
|
||||
<span :style="{ color: getRarityColor(store.detailInfo.spirit_animal) }">{{ store.detailInfo.spirit_animal.name }}</span>
|
||||
<span class="item-meta">{{ store.detailInfo.spirit_animal.grade }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 物品 -->
|
||||
<div class="section" v-if="store.detailInfo.items?.length">
|
||||
<div class="section-title">物品</div>
|
||||
<div class="items-list">
|
||||
<div
|
||||
v-for="item in store.detailInfo.items"
|
||||
:key="item.name"
|
||||
class="clickable-item small"
|
||||
@click="showSecondary(item)"
|
||||
>
|
||||
{{ item.name }} x{{ item.count }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关系 -->
|
||||
<div class="section" v-if="store.detailInfo.relations?.length">
|
||||
<div class="section-title">关系</div>
|
||||
<div class="relations-list">
|
||||
<div
|
||||
v-for="rel in store.detailInfo.relations"
|
||||
:key="rel.target_id"
|
||||
class="relation-item"
|
||||
@click="jumpToAvatar(rel.target_id)"
|
||||
>
|
||||
<div class="rel-name">{{ rel.name }}</div>
|
||||
<div class="rel-desc">{{ rel.relation }}</div>
|
||||
<div class="rel-meta">{{ rel.sect }} · {{ rel.realm }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 目标 -->
|
||||
<div class="section">
|
||||
<div class="section-title">长期目标</div>
|
||||
<div class="text-content">{{ store.detailInfo.long_term_objective || '无' }}</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">短期目标</div>
|
||||
<div class="text-content">{{ store.detailInfo.short_term_objective || '无' }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 结构化数据展示 (Region) -->
|
||||
<div v-else-if="store.detailInfo && !store.detailInfo.fallback && store.selectedTarget.type === 'region'" class="structured-content">
|
||||
<!-- Type & Desc -->
|
||||
<div class="section">
|
||||
<div class="section-title">{{ store.detailInfo.type_name }}</div>
|
||||
<div class="text-content">{{ store.detailInfo.desc }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Cultivate Region: Essence -->
|
||||
<div class="section" v-if="store.detailInfo.essence">
|
||||
<div class="section-title">灵气环境</div>
|
||||
<div class="text-content">
|
||||
{{ store.detailInfo.essence.type }}行灵气 · 浓度 {{ store.detailInfo.essence.density }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Normal Region: Animals -->
|
||||
<div class="section" v-if="store.detailInfo.animals?.length">
|
||||
<div class="section-title">动物分布</div>
|
||||
<div class="items-list">
|
||||
<div
|
||||
v-for="animal in store.detailInfo.animals"
|
||||
:key="animal.name"
|
||||
class="clickable-item small"
|
||||
@click="showSecondary(animal)"
|
||||
>
|
||||
<span :style="{ color: getRarityColor(animal) }">{{ animal.name }}</span>
|
||||
<span class="item-meta">{{ animal.grade }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Normal Region: Plants -->
|
||||
<div class="section" v-if="store.detailInfo.plants?.length">
|
||||
<div class="section-title">植物分布</div>
|
||||
<div class="items-list">
|
||||
<div
|
||||
v-for="plant in store.detailInfo.plants"
|
||||
:key="plant.name"
|
||||
class="clickable-item small"
|
||||
@click="showSecondary(plant)"
|
||||
>
|
||||
<span :style="{ color: getRarityColor(plant) }">{{ plant.name }}</span>
|
||||
<span class="item-meta">{{ plant.grade }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legacy / Fallback 展示 -->
|
||||
<ul v-else-if="store.hoverInfo.length" class="info-list">
|
||||
<li v-for="(line, index) in store.hoverInfo" :key="index">
|
||||
<template v-if="line.length">
|
||||
@@ -114,7 +382,7 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.info-panel {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
top: 60px;
|
||||
right: 20px;
|
||||
width: 320px;
|
||||
max-height: calc(100vh - 40px);
|
||||
@@ -126,7 +394,7 @@ onUnmounted(() => {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible; /* Allow modal to show outside */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@@ -187,6 +455,17 @@ onUnmounted(() => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -201,15 +480,41 @@ onUnmounted(() => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.close-btn-absolute {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #ccc;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20; /* Higher than panel-actions */
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn-absolute:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.info-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
/* 确保body内容滚动时圆角 */
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
/* Legacy List Styles */
|
||||
.info-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -226,6 +531,259 @@ onUnmounted(() => {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Structured Content Styles */
|
||||
.structured-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-item.full {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.stat-item.clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.stat-item.clickable:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.stat-item label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.stat-item span {
|
||||
font-size: 13px;
|
||||
color: #ddd;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.clickable-tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid #444;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clickable-tag:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.clickable-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.clickable-item:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.clickable-item.small {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.relations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.relation-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-left: 2px solid #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.relation-item:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-left-color: #666;
|
||||
}
|
||||
|
||||
.rel-name {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.rel-desc {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.rel-meta {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Secondary Panel Styles */
|
||||
.secondary-panel {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 100%;
|
||||
margin-right: 12px;
|
||||
width: 260px;
|
||||
background: rgba(32, 32, 32, 0.98);
|
||||
border: 1px solid #555;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.8);
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sec-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #444;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.sec-title {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.close-btn-small {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-btn-small:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sec-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
background: #444;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sec-desc {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sec-effect-box {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.sec-label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sec-effect-text {
|
||||
color: #ffd700; /* Gold for effects */
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sec-extra {
|
||||
font-size: 12px;
|
||||
border-top: 1px solid #444;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.sub-desc {
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
|
||||
.placeholder {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
@@ -239,7 +797,7 @@ onUnmounted(() => {
|
||||
.objective-modal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 100%; /* Position to the left of the panel */
|
||||
right: 100%;
|
||||
margin-right: 12px;
|
||||
width: 280px;
|
||||
background: rgba(32, 32, 32, 0.98);
|
||||
|
||||
@@ -2,7 +2,9 @@ import type {
|
||||
HoverResponse,
|
||||
HoverTarget,
|
||||
InitialStateResponse,
|
||||
MapResponse
|
||||
MapResponse,
|
||||
IAvatarDetail,
|
||||
DetailInfo
|
||||
} from '../types/game'
|
||||
import { apiGet, apiPost } from './apiClient'
|
||||
|
||||
@@ -14,6 +16,14 @@ function buildHoverQuery(target: HoverTarget) {
|
||||
return `/api/hover?${query.toString()}`
|
||||
}
|
||||
|
||||
function buildDetailQuery(target: HoverTarget) {
|
||||
const query = new URLSearchParams({
|
||||
type: target.type,
|
||||
id: target.id
|
||||
})
|
||||
return `/api/detail?${query.toString()}`
|
||||
}
|
||||
|
||||
export interface SaveFile {
|
||||
filename: string
|
||||
save_time: string
|
||||
@@ -30,6 +40,10 @@ export const gameApi = {
|
||||
return apiGet<HoverResponse>(buildHoverQuery(target))
|
||||
},
|
||||
|
||||
getDetailInfo(target: HoverTarget) {
|
||||
return apiGet<DetailInfo>(buildDetailQuery(target))
|
||||
},
|
||||
|
||||
getMap() {
|
||||
return apiGet<MapResponse>('/api/map')
|
||||
},
|
||||
|
||||
@@ -5,7 +5,9 @@ import type {
|
||||
GameEvent,
|
||||
HoverLine,
|
||||
HoverTarget,
|
||||
TickPayload
|
||||
TickPayload,
|
||||
IAvatarDetail,
|
||||
DetailInfo
|
||||
} from '../types/game'
|
||||
import { gameApi } from '../services/gameApi'
|
||||
import { createGameGateway } from '../services/gameGateway'
|
||||
@@ -41,6 +43,7 @@ export const useGameStore = defineStore('game', () => {
|
||||
const events = ref<GameEvent[]>([])
|
||||
const selectedTarget = ref<HoverTarget | null>(null)
|
||||
const hoverInfo = ref<HoverLine[]>([])
|
||||
const detailInfo = ref<DetailInfo | null>(null)
|
||||
const infoLoading = ref(false)
|
||||
const infoError = ref<string | null>(null)
|
||||
const hoverCache = new Map<string, HoverLine[]>()
|
||||
@@ -74,7 +77,11 @@ export const useGameStore = defineStore('game', () => {
|
||||
|
||||
// If panel is open, silently refresh content to show latest status
|
||||
if (selectedTarget.value) {
|
||||
fetchHoverInfo(selectedTarget.value, { force: true, silent: true })
|
||||
if (selectedTarget.value.type === 'avatar') {
|
||||
fetchDetailInfo(selectedTarget.value, { silent: true })
|
||||
} else {
|
||||
fetchHoverInfo(selectedTarget.value, { force: true, silent: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +132,38 @@ export const useGameStore = defineStore('game', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetailInfo(target: HoverTarget, options: { silent?: boolean } = {}) {
|
||||
const { silent = false } = options
|
||||
if (!silent) {
|
||||
infoLoading.value = true
|
||||
infoError.value = null
|
||||
detailInfo.value = null
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await gameApi.getDetailInfo(target)
|
||||
// Check if result matches current selection (race condition)
|
||||
if (selectedTarget.value && selectedTarget.value.id === target.id) {
|
||||
detailInfo.value = data
|
||||
// If fallback is true, we might also want to populate hoverInfo for legacy support,
|
||||
// but InfoPanel should handle detailInfo with lines fallback.
|
||||
}
|
||||
} catch (error) {
|
||||
if (selectedTarget.value && selectedTarget.value.id === target.id) {
|
||||
if (!silent) {
|
||||
infoError.value = error instanceof Error ? error.message : String(error)
|
||||
detailInfo.value = null
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (selectedTarget.value && selectedTarget.value.id === target.id) {
|
||||
if (!silent) {
|
||||
infoLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHoverInfo(target: HoverTarget, options: { force?: boolean, silent?: boolean } = {}) {
|
||||
const { force = false, silent = false } = options
|
||||
const key = cacheKey(target)
|
||||
@@ -176,7 +215,7 @@ export const useGameStore = defineStore('game', () => {
|
||||
await gameApi.setLongTermObjective(avatarId, content)
|
||||
// 成功后刷新 info panel
|
||||
if (selectedTarget.value && selectedTarget.value.id === avatarId && selectedTarget.value.type === 'avatar') {
|
||||
await fetchHoverInfo(selectedTarget.value, { force: true })
|
||||
await fetchDetailInfo(selectedTarget.value, { silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +223,7 @@ export const useGameStore = defineStore('game', () => {
|
||||
await gameApi.clearLongTermObjective(avatarId)
|
||||
// 成功后刷新 info panel
|
||||
if (selectedTarget.value && selectedTarget.value.id === avatarId && selectedTarget.value.type === 'avatar') {
|
||||
await fetchHoverInfo(selectedTarget.value, { force: true })
|
||||
await fetchDetailInfo(selectedTarget.value, { silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,13 +257,18 @@ export const useGameStore = defineStore('game', () => {
|
||||
|
||||
function openInfoPanel(target: HoverTarget) {
|
||||
selectedTarget.value = target
|
||||
fetchHoverInfo(target)
|
||||
if (target.type === 'avatar' || target.type === 'region') {
|
||||
fetchDetailInfo(target)
|
||||
} else {
|
||||
fetchHoverInfo(target)
|
||||
}
|
||||
}
|
||||
|
||||
function closeInfoPanel() {
|
||||
selectedTarget.value = null
|
||||
infoError.value = null
|
||||
hoverInfo.value = []
|
||||
detailInfo.value = null
|
||||
infoLoading.value = false
|
||||
}
|
||||
|
||||
@@ -237,6 +281,7 @@ export const useGameStore = defineStore('game', () => {
|
||||
events,
|
||||
selectedTarget,
|
||||
hoverInfo,
|
||||
detailInfo,
|
||||
infoLoading,
|
||||
infoError,
|
||||
worldVersion, // 导出
|
||||
|
||||
@@ -69,3 +69,87 @@ export interface MapResponse {
|
||||
regions: Region[]
|
||||
}
|
||||
|
||||
// --- Structured Info Types ---
|
||||
|
||||
export interface IEffectEntity {
|
||||
name: string;
|
||||
desc?: string;
|
||||
effect_desc?: string;
|
||||
grade?: string;
|
||||
rarity?: string;
|
||||
type?: string;
|
||||
// some entities might have custom fields
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ISectInfo extends IEffectEntity {
|
||||
alignment: string;
|
||||
style: string;
|
||||
hq_name: string;
|
||||
hq_desc: string;
|
||||
rank: string;
|
||||
}
|
||||
|
||||
export interface IItemInfo extends IEffectEntity {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface IRelationInfo {
|
||||
target_id: string;
|
||||
name: string;
|
||||
relation: string;
|
||||
realm: string;
|
||||
sect: string;
|
||||
}
|
||||
|
||||
export interface IAvatarDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number;
|
||||
lifespan: number;
|
||||
realm: string;
|
||||
level: number;
|
||||
hp: { cur: number; max: number };
|
||||
mp: { cur: number; max: number };
|
||||
alignment: string;
|
||||
alignment_detail?: IEffectEntity;
|
||||
magic_stone: number;
|
||||
thinking: string;
|
||||
short_term_objective: string;
|
||||
long_term_objective: string;
|
||||
nickname?: string;
|
||||
|
||||
personas: IEffectEntity[];
|
||||
technique?: IEffectEntity;
|
||||
sect?: ISectInfo;
|
||||
weapon?: IEffectEntity & { proficiency: string };
|
||||
auxiliary?: IEffectEntity;
|
||||
items: IItemInfo[];
|
||||
relations: IRelationInfo[];
|
||||
appearance: string;
|
||||
root: string;
|
||||
root_detail?: IEffectEntity;
|
||||
spirit_animal?: IEffectEntity;
|
||||
|
||||
// Fallback for non-avatar targets or legacy
|
||||
fallback?: boolean;
|
||||
lines?: unknown; // HoverLines
|
||||
}
|
||||
|
||||
export interface IRegionDetail {
|
||||
id: string | number;
|
||||
name: string;
|
||||
desc: string;
|
||||
type: string;
|
||||
type_name: string;
|
||||
|
||||
animals?: IEffectEntity[];
|
||||
plants?: IEffectEntity[];
|
||||
essence?: { type: string; density: number };
|
||||
|
||||
fallback?: boolean;
|
||||
lines?: unknown;
|
||||
}
|
||||
|
||||
export type DetailInfo = IAvatarDetail | IRegionDetail;
|
||||
|
||||
Reference in New Issue
Block a user