update web, add clickable parts
This commit is contained in:
@@ -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