Files
Novel-Map/frontend/App.vue

1568 lines
40 KiB
Vue
Raw Normal View History

2026-03-30 17:26:44 +08:00
<template>
<div class="map-container">
2026-04-03 09:33:26 +08:00
<div class="atmo-bg"></div>
<div class="novel-selector">
2026-04-03 09:33:26 +08:00
<button
v-for="(novel, key) in NOVELS"
:key="key"
:class="['novel-btn', { active: activeNovelId === key }]"
@click="switchNovel(key)"
>
{{ novel.name }}
</button>
</div>
2026-03-30 17:26:44 +08:00
2026-04-03 09:33:26 +08:00
<div class="map-state-pill">
{{ currentNovel.name }} · {{ currentVolLabel }} · 缩放 {{ mapViewState.zoom.toFixed(1) }}
</div>
<div id="map"></div>
2026-03-30 17:26:44 +08:00
<div class="legend">
<div class="legend-block legend-factions">
<h3>势力图例</h3>
2026-04-03 09:33:26 +08:00
<input
v-model.trim="factionQuery"
class="legend-search"
type="text"
placeholder="搜索势力/首领/人物"
>
<div class="legend-list legend-faction-list">
<div
2026-04-03 09:33:26 +08:00
v-for="faction in filteredFactions"
:key="faction.id"
class="legend-item"
@click="showFactionInfo(faction)"
>
<div class="legend-dot" :style="{ background: faction.color }"></div>
<span>{{ faction.name }}{{ faction.leader }}</span>
</div>
2026-04-03 09:33:26 +08:00
<div v-if="!filteredFactions.length" class="empty-tip">未匹配到势力</div>
</div>
2026-03-30 17:26:44 +08:00
</div>
<div class="legend-block legend-routes">
2026-03-30 17:26:44 +08:00
<h3>人物路线</h3>
2026-04-03 09:33:26 +08:00
<input
v-model.trim="routeQuery"
class="legend-search"
type="text"
placeholder="搜索人物路线"
>
<div class="route-toolbar">
<button class="mini-btn" @click="selectTopRoutes">显示高频路线</button>
<button class="mini-btn" @click="clearAllRoutes">清空</button>
<button class="mini-btn" :class="{ active: showRouteOrder }" @click="showRouteOrder = !showRouteOrder">
{{ showRouteOrder ? '顺序标记:开' : '顺序标记:关' }}
</button>
</div>
<div class="legend-list legend-route-list">
2026-03-30 17:26:44 +08:00
<div
2026-04-03 09:33:26 +08:00
v-for="route in filteredRouteDefs"
:key="route.character"
2026-04-03 09:33:26 +08:00
class="legend-item route-item"
@click="toggleRoute(route)"
>
<div
class="legend-line"
:style="{
background: route.color,
2026-04-03 09:33:26 +08:00
opacity: selectedRoutes.includes(route.character) ? 1 : 0.35
}"
></div>
2026-04-03 09:33:26 +08:00
<span :style="{ opacity: selectedRoutes.includes(route.character) ? 1 : 0.55 }">{{ route.character }}</span>
<button
v-if="selectedRoutes.includes(route.character)"
class="focus-btn"
:class="{ active: focusedRouteName === route.character }"
@click.stop="focusRoute(route.character)"
>
{{ focusedRouteName === route.character ? '聚焦中' : '聚焦' }}
</button>
</div>
<div v-if="!filteredRouteDefs.length" class="empty-tip">未匹配到路线</div>
</div>
</div>
<div class="route-insight" v-if="focusedRouteSteps.length">
<div class="insight-header">
<span>路线细读{{ focusedRouteName }}</span>
<button class="mini-btn" @click="clearRouteFocus">退出聚焦</button>
</div>
<div class="insight-list">
<div
v-for="(step, idx) in focusedRouteSteps"
:key="`${focusedRouteName}-${idx}`"
class="insight-item"
@click="flyToRouteStep(step)"
>
<span class="step-idx">{{ idx + 1 }}</span>
<span class="step-main">{{ step.locationName }}</span>
<span class="step-sub">{{ step.vol || '?' }} {{ step.chapter || '?' }}{{ currentNovel.volsLabel }}</span>
</div>
2026-03-30 17:26:44 +08:00
</div>
</div>
</div>
<div class="chapter-panel">
2026-03-30 19:15:41 +08:00
<h3>卷册进度</h3>
<div class="vol-slider-wrap">
<input
type="range"
class="chapter-slider"
v-model.number="currentVol"
:min="1"
:max="TOTAL_VOLS"
2026-03-30 19:15:41 +08:00
@input="onVolSliderChange"
>
<div class="vol-ticks">
<span
v-for="tick in volTicks"
:key="tick.vol"
class="tick-label"
:style="{ left: tick.pct + '%' }"
>{{ tick.label }}</span>
</div>
</div>
<div class="vol-info">
2026-04-03 09:33:26 +08:00
当前{{ currentVolLabel }} | 已加载 {{ loadedCount }} / {{ TOTAL_VOLS }} {{ currentNovel.volsLabel }}
</div>
<div class="search-panel">
<div class="events-title">地图搜索</div>
<input
v-model.trim="mapSearchQuery"
class="map-search-input"
type="text"
placeholder="搜地点 / 势力 / 人物"
>
<div class="search-results" v-if="mapSearchQuery">
<div
v-for="result in globalSearchResults"
:key="result.key"
class="search-result-item"
@click="applySearchResult(result)"
>
<span class="search-type">{{ result.type }}</span>
<span class="search-name">{{ result.name }}</span>
</div>
<div v-if="!globalSearchResults.length" class="empty-tip">暂无匹配</div>
</div>
2026-03-30 19:15:41 +08:00
</div>
<div class="events-section" v-if="currentKeyEvents.length">
<div class="events-title">本卷关键事件</div>
<div
v-for="(ev, idx) in currentKeyEvents"
:key="idx"
class="event-item"
@click="flyToEvent(ev)"
>
2026-04-03 09:33:26 +08:00
<span class="event-ch">{{ ev.chapter }}{{ currentNovel.volsLabel }}</span>
2026-03-30 19:15:41 +08:00
<span class="event-text">{{ ev.event }}</span>
</div>
</div>
2026-03-30 17:26:44 +08:00
</div>
<div class="info-panel" v-if="selectedFaction">
<span class="close-btn" @click="selectedFaction = null">&times;</span>
<h3 :style="{ color: selectedFaction.color }">{{ selectedFaction.name }}</h3>
<p><b>首领</b>{{ selectedFaction.leader }}</p>
<p>{{ selectedFaction.description }}</p>
<p v-if="selectedFaction.territory && selectedFaction.territory.length">
<b>据点</b>{{ getTerritoryNames(selectedFaction.territory).join('、') }}
</p>
<p v-if="selectedFaction.key_figures && selectedFaction.key_figures.length">
<b>关键人物</b>{{ selectedFaction.key_figures.join('、') }}
</p>
</div>
2026-03-30 19:15:41 +08:00
<div class="loading-bar" v-if="isLoading">
<div class="loading-inner" :style="{ width: (loadedCount / TOTAL_VOLS * 100) + '%' }"></div>
2026-04-03 09:33:26 +08:00
<span class="loading-text">加载数据中 {{ loadedCount }}/{{ TOTAL_VOLS }} {{ currentNovel.volsLabel }}</span>
2026-03-30 19:15:41 +08:00
</div>
<div class="chat-toggle-btn" @click="toggleChat" v-if="!isChatOpen" title="打开智能助手" aria-label="打开智能助手">
2026-04-03 09:33:26 +08:00
智能问答
</div>
<div class="chat-panel" v-show="isChatOpen">
<div class="chat-header">
2026-04-03 09:33:26 +08:00
<h3>知识问答</h3>
<span class="close-btn" @click="toggleChat">&times;</span>
</div>
2026-04-03 09:33:26 +08:00
<div class="chat-context-panel">
<label>
<input type="checkbox" v-model="includeMapContext">
把当前地图上下文一起发给助手
</label>
<div class="context-preview" v-if="includeMapContext">
{{ mapContextSummary }}
</div>
</div>
<div class="chat-messages" ref="chatMessagesRef">
<div v-for="(msg, idx) in chatMessages" :key="idx" :class="['chat-bubble', msg.role]">
{{ msg.content }}
</div>
<div v-if="isChatLoading" class="chat-bubble assistant loading">思索中...</div>
</div>
<div class="chat-input-area">
2026-04-03 09:33:26 +08:00
<input
type="text"
v-model="chatInput"
@keyup.enter="sendChatMessage"
:placeholder="currentNovel.chatPlaceholder"
2026-04-03 09:33:26 +08:00
>
<button @click="sendChatMessage" :disabled="isChatLoading">发送</button>
</div>
</div>
2026-03-30 17:26:44 +08:00
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
2026-03-30 17:26:44 +08:00
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { NOVELS } from './novel-config.js'
2026-03-30 17:26:44 +08:00
2026-03-30 21:14:10 +08:00
// ── 常量 ────────────────────────────────────────────────────
2026-04-03 09:33:26 +08:00
const KOUZHONG_NAME = '寇仲'
const KOUZHONG_COLOR = '#ff4500'
const XUZILING_NAME = '徐子陵'
const XUZILING_COLOR = '#ffa07a'
2026-03-30 21:14:10 +08:00
2026-03-30 19:15:41 +08:00
// ── 响应式状态 ──────────────────────────────────────────────
const activeNovelId = ref('dtslz')
const currentNovel = computed(() => NOVELS[activeNovelId.value])
const TOTAL_VOLS = computed(() => currentNovel.value.totalVols)
2026-04-03 09:33:26 +08:00
const allData = ref([])
const currentVol = ref(1)
2026-03-30 17:26:44 +08:00
const selectedRoutes = ref([])
2026-04-03 09:33:26 +08:00
const focusedRouteName = ref('')
const showRouteOrder = ref(true)
2026-03-30 17:26:44 +08:00
const selectedFaction = ref(null)
2026-04-03 09:33:26 +08:00
const isLoading = ref(true)
const loadedCount = ref(0)
const mapViewState = ref({ center: [33.0, 113.0], zoom: 6 })
const lastInteraction = ref('')
const factionQuery = ref('')
const routeQuery = ref('')
const mapSearchQuery = ref('')
2026-03-30 17:26:44 +08:00
// ── 问答系统状态 ────────────────────────────────────────────
2026-04-03 09:33:26 +08:00
const isChatOpen = ref(false)
const chatInput = ref('')
const isChatLoading = ref(false)
2026-04-03 09:33:26 +08:00
const includeMapContext = ref(true)
const chatMessagesRef = ref(null)
2026-04-03 09:33:26 +08:00
const chatMessages = ref([
{ role: 'assistant', content: '您好!我是知识助理。我可以结合你当前浏览的地图状态回答问题。' }
])
// ── 小说切换逻辑 ──────────────────────────────────────────
async function switchNovel(novelId) {
if (activeNovelId.value === novelId) return
2026-04-03 09:33:26 +08:00
activeNovelId.value = novelId
currentVol.value = 1
selectedRoutes.value = []
2026-04-03 09:33:26 +08:00
focusedRouteName.value = ''
selectedFaction.value = null
2026-04-03 09:33:26 +08:00
factionQuery.value = ''
routeQuery.value = ''
mapSearchQuery.value = ''
chatMessages.value = [
2026-04-03 09:33:26 +08:00
{ role: 'assistant', content: `您好!我是${currentNovel.value.name}知识助理。你可以问人物行踪、势力分布、章节事件。` }
]
2026-04-03 09:33:26 +08:00
await loadAllData()
2026-04-03 09:33:26 +08:00
if (map) {
if (novelId === 'ldj') {
2026-04-03 09:33:26 +08:00
map.setView([33.0, 113.0], 5)
} else if (novelId === 'tlbb') {
2026-04-03 09:33:26 +08:00
map.setView([33.0, 105.0], 5)
} else {
2026-04-03 09:33:26 +08:00
map.setView([33.0, 113.0], 6)
}
}
2026-04-03 09:33:26 +08:00
drawAll()
}
2026-03-30 19:15:41 +08:00
// ── Leaflet 图层管理 ────────────────────────────────────────
2026-03-30 17:26:44 +08:00
let map = null
2026-03-30 19:15:41 +08:00
let layerGroups = {
territories: null,
2026-04-03 09:33:26 +08:00
locations: null,
routes: null,
events: null
}
// ── 计算属性 ───────────────────────────────────────────────
2026-03-30 19:15:41 +08:00
const currentVolLabel = computed(() => {
const d = allData.value[currentVol.value - 1]
return (d && d.volume) ? d.volume : `${currentVol.value}${currentNovel.value.volsLabel}`
2026-03-30 19:15:41 +08:00
})
2026-03-30 17:26:44 +08:00
2026-04-03 09:33:26 +08:00
const volTicks = computed(() => {
const maxVol = TOTAL_VOLS.value
const points = new Set([1, maxVol])
for (let i = 1; i <= 4; i++) {
points.add(Math.max(1, Math.min(maxVol, Math.round((maxVol - 1) * (i / 5)) + 1)))
}
return [...points].sort((a, b) => a - b).map((vol) => ({
vol,
label: `${vol}${currentNovel.value.volsLabel}`,
pct: maxVol === 1 ? 0 : ((vol - 1) / (maxVol - 1) * 100).toFixed(2)
}))
})
2026-03-30 19:15:41 +08:00
const mergedLocations = computed(() => {
2026-03-30 21:14:10 +08:00
const locMap = new Map()
2026-03-30 19:15:41 +08:00
for (let i = 0; i < currentVol.value; i++) {
const d = allData.value[i]
if (!d) continue
for (const loc of (d.locations || [])) {
2026-03-30 21:14:10 +08:00
locMap.set(loc.id, loc)
2026-03-30 19:15:41 +08:00
}
}
2026-03-30 21:14:10 +08:00
return Array.from(locMap.values())
2026-03-30 19:15:41 +08:00
})
2026-03-30 17:26:44 +08:00
2026-03-30 19:15:41 +08:00
const mergedFactions = computed(() => {
2026-03-30 21:14:10 +08:00
const facMap = new Map()
2026-03-30 19:15:41 +08:00
for (let i = 0; i < currentVol.value; i++) {
const d = allData.value[i]
if (!d) continue
for (const f of (d.factions || [])) {
2026-03-30 21:14:10 +08:00
facMap.set(f.id, f)
2026-03-30 19:15:41 +08:00
}
}
2026-03-30 21:14:10 +08:00
return Array.from(facMap.values())
2026-03-30 19:15:41 +08:00
})
const mergedRoutes = computed(() => {
2026-03-31 14:19:06 +08:00
const charMap = new Map()
2026-03-31 14:19:06 +08:00
for (let i = 0; i < currentVol.value; i++) {
const d = allData.value[i]
if (!d) continue
const vol = i + 1
if (activeNovelId.value === 'dtslz' && vol <= 20) {
2026-04-03 09:33:26 +08:00
let combinedRoute = []
for (const r of (d.character_routes || [])) {
if (r.character === KOUZHONG_NAME || r.character === XUZILING_NAME) {
combinedRoute = combinedRoute.concat(r.route || [])
continue
}
if (!charMap.has(r.character)) {
charMap.set(r.character, { character: r.character, color: r.color, route: [] })
}
2026-04-03 09:33:26 +08:00
const entry = charMap.get(r.character)
entry.color = r.color
for (const pt of (r.route || [])) entry.route.push({ ...pt, vol })
}
if (combinedRoute.length) {
const comboName = `${KOUZHONG_NAME} & ${XUZILING_NAME}`
if (!charMap.has(comboName)) {
charMap.set(comboName, { character: comboName, color: KOUZHONG_COLOR, route: [] })
}
2026-04-03 09:33:26 +08:00
const combo = charMap.get(comboName)
combo.color = combo.color || XUZILING_COLOR
for (const pt of combinedRoute) combo.route.push({ ...pt, vol })
2026-03-30 19:15:41 +08:00
}
} else {
2026-04-03 09:33:26 +08:00
for (const r of (d.character_routes || [])) {
if (!charMap.has(r.character)) {
charMap.set(r.character, { character: r.character, color: r.color, route: [] })
}
2026-04-03 09:33:26 +08:00
const entry = charMap.get(r.character)
entry.color = r.color
for (const pt of (r.route || [])) entry.route.push({ ...pt, vol })
2026-03-30 21:14:10 +08:00
}
}
2026-03-30 19:15:41 +08:00
}
2026-04-03 09:33:26 +08:00
return Array.from(charMap.values()).sort((a, b) => b.route.length - a.route.length)
2026-03-30 19:15:41 +08:00
})
2026-04-03 09:33:26 +08:00
const visibleRouteDefs = computed(() => mergedRoutes.value.map(r => ({
character: r.character,
color: r.color,
points: r.route.length
})))
const filteredFactions = computed(() => {
const q = factionQuery.value.toLowerCase()
if (!q) return mergedFactions.value
return mergedFactions.value.filter((f) => {
const figures = Array.isArray(f.key_figures) ? f.key_figures.join(' ') : ''
return (`${f.name} ${f.leader || ''} ${figures}`).toLowerCase().includes(q)
})
})
const filteredRouteDefs = computed(() => {
const q = routeQuery.value.toLowerCase()
if (!q) return visibleRouteDefs.value
return visibleRouteDefs.value.filter((r) => r.character.toLowerCase().includes(q))
})
2026-03-30 21:14:10 +08:00
2026-03-30 19:15:41 +08:00
const currentKeyEvents = computed(() => {
const d = allData.value[currentVol.value - 1]
return d ? (d.key_events || []) : []
})
2026-04-03 09:33:26 +08:00
const focusedRouteData = computed(() =>
mergedRoutes.value.find((r) => r.character === focusedRouteName.value) || null
)
const focusedRouteSteps = computed(() => {
if (!focusedRouteData.value) return []
const locMap = new Map(mergedLocations.value.map((l) => [l.id, l]))
return (focusedRouteData.value.route || [])
.map((pt, idx) => {
const coords = getPointCoords(pt, mergedLocations.value)
if (!coords) return null
const loc = pt.location ? locMap.get(pt.location) : null
return {
idx,
lat: coords.lat,
lng: coords.lng,
chapter: pt.chapter,
vol: pt.vol,
event: pt.event,
locationName: loc?.name || pt.location || `坐标点 ${idx + 1}`
}
})
.filter(Boolean)
})
const globalSearchResults = computed(() => {
const q = mapSearchQuery.value.trim().toLowerCase()
if (!q) return []
const locationHits = mergedLocations.value
.filter((l) => `${l.name} ${l.id}`.toLowerCase().includes(q))
.slice(0, 6)
.map((l) => ({ key: `loc-${l.id}`, type: '地点', name: l.name, payload: l }))
const factionHits = mergedFactions.value
.filter((f) => `${f.name} ${f.leader || ''}`.toLowerCase().includes(q))
.slice(0, 5)
.map((f) => ({ key: `fac-${f.id}`, type: '势力', name: f.name, payload: f }))
const routeHits = visibleRouteDefs.value
.filter((r) => r.character.toLowerCase().includes(q))
.slice(0, 5)
.map((r) => ({ key: `route-${r.character}`, type: '人物', name: r.character, payload: r }))
return [...locationHits, ...factionHits, ...routeHits].slice(0, 12)
})
const mapContextSummary = computed(() => {
const center = mapViewState.value.center
const selected = selectedRoutes.value.length ? selectedRoutes.value.slice(0, 5).join('、') : '无'
const focused = focusedRouteName.value || '无'
const faction = selectedFaction.value?.name || '无'
const eventTitles = currentKeyEvents.value.slice(0, 3).map((e) => e.event).join('') || '无'
return [
`小说=${currentNovel.value.name}`,
`进度=${currentVolLabel.value}`,
`地图中心=(${center[0].toFixed(2)}, ${center[1].toFixed(2)})`,
`缩放=${mapViewState.value.zoom.toFixed(1)}`,
`当前选中路线=${selected}`,
`当前聚焦人物=${focused}`,
`当前势力面板=${faction}`,
`本卷关键事件=${eventTitles}`,
`最近地图操作=${lastInteraction.value || '无'}`
].join(' | ')
})
2026-03-30 19:15:41 +08:00
// ── 生命周期 ────────────────────────────────────────────────
2026-03-30 17:26:44 +08:00
onMounted(async () => {
initMap()
2026-03-30 19:15:41 +08:00
await loadAllData()
2026-04-03 09:33:26 +08:00
selectTopRoutes()
2026-03-30 19:15:41 +08:00
drawAll()
})
watch(currentVol, () => {
2026-04-03 09:33:26 +08:00
if (focusedRouteName.value && !visibleRouteDefs.value.find((r) => r.character === focusedRouteName.value)) {
focusedRouteName.value = ''
}
2026-03-30 19:15:41 +08:00
drawAll()
2026-03-30 17:26:44 +08:00
})
2026-04-03 09:33:26 +08:00
watch(activeNovelId, () => {
focusedRouteName.value = ''
})
2026-03-30 19:15:41 +08:00
// ── 数据加载 ────────────────────────────────────────────────
2026-03-30 17:26:44 +08:00
async function loadAllData() {
2026-03-30 19:15:41 +08:00
isLoading.value = true
2026-03-30 21:14:10 +08:00
loadedCount.value = 0
allData.value = new Array(TOTAL_VOLS.value).fill(null)
2026-03-30 21:14:10 +08:00
2026-03-30 19:15:41 +08:00
const promises = []
for (let i = 1; i <= TOTAL_VOLS.value; i++) {
2026-03-30 19:15:41 +08:00
const volStr = String(i).padStart(2, '0')
promises.push(
fetch(`/novel-data/${activeNovelId.value}/data/vol${volStr}.json`)
2026-04-03 09:33:26 +08:00
.then((r) => {
2026-03-30 21:14:10 +08:00
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
})
2026-04-03 09:33:26 +08:00
.then((data) => {
2026-03-30 19:15:41 +08:00
allData.value[i - 1] = data
loadedCount.value++
})
2026-04-03 09:33:26 +08:00
.catch((err) => {
2026-03-30 19:15:41 +08:00
console.warn(`vol${volStr}.json 加载失败`, err)
loadedCount.value++
})
)
2026-03-30 17:26:44 +08:00
}
2026-04-03 09:33:26 +08:00
2026-03-30 19:15:41 +08:00
await Promise.all(promises)
isLoading.value = false
allData.value = [...allData.value]
2026-03-30 17:26:44 +08:00
}
2026-03-30 19:15:41 +08:00
// ── 地图初始化 ──────────────────────────────────────────────
2026-03-30 17:26:44 +08:00
function initMap() {
map = L.map('map', {
2026-03-30 19:15:41 +08:00
center: [33.0, 113.0],
2026-03-30 17:26:44 +08:00
zoom: 6,
zoomControl: true,
attributionControl: false
})
2026-03-31 16:55:26 +08:00
const TIANDITU_KEY = '5316253e2d2d21b707996f1f5748839d'
L.tileLayer(
`https://t{s}.tianditu.gov.cn/DataServer?T=vec_w&X={x}&Y={y}&L={z}&tk=${TIANDITU_KEY}`,
{ subdomains: '01234567', maxZoom: 18 }
).addTo(map)
2026-04-03 09:33:26 +08:00
2026-03-31 16:55:26 +08:00
L.tileLayer(
`https://t{s}.tianditu.gov.cn/DataServer?T=cva_w&X={x}&Y={y}&L={z}&tk=${TIANDITU_KEY}`,
{ subdomains: '01234567', maxZoom: 18 }
).addTo(map)
2026-03-31 16:26:15 +08:00
2026-03-30 19:15:41 +08:00
layerGroups.territories = L.layerGroup().addTo(map)
2026-04-03 09:33:26 +08:00
layerGroups.locations = L.layerGroup().addTo(map)
layerGroups.routes = L.layerGroup().addTo(map)
layerGroups.events = L.layerGroup().addTo(map)
map.on('moveend zoomend', () => {
const center = map.getCenter()
mapViewState.value = {
center: [center.lat, center.lng],
zoom: map.getZoom()
}
})
2026-03-30 17:26:44 +08:00
}
2026-03-30 19:15:41 +08:00
// ── 全量重绘 ────────────────────────────────────────────────
function drawAll() {
2026-03-30 17:26:44 +08:00
if (!map) return
2026-03-30 19:15:41 +08:00
clearLayerGroups()
2026-03-30 17:26:44 +08:00
drawTerritories()
drawLocations()
drawRoutes()
}
2026-03-30 19:15:41 +08:00
function clearLayerGroups() {
2026-04-03 09:33:26 +08:00
Object.values(layerGroups).forEach((lg) => lg && lg.clearLayers())
2026-03-30 17:26:44 +08:00
}
2026-03-30 19:15:41 +08:00
// ── 绘制势力领地 ────────────────────────────────────────────
2026-03-30 17:26:44 +08:00
function drawTerritories() {
2026-03-30 19:15:41 +08:00
const locs = mergedLocations.value
2026-04-03 09:33:26 +08:00
mergedFactions.value.forEach((f) => {
2026-03-30 17:26:44 +08:00
if (!f.territory || !f.territory.length) return
2026-04-03 09:33:26 +08:00
f.territory.forEach((tid) => {
const loc = locs.find((l) => l.id === tid)
2026-03-30 17:26:44 +08:00
if (!loc) return
const circle = L.circle([loc.lat, loc.lng], {
radius: 50000,
color: f.color,
fillColor: f.color,
2026-04-03 09:33:26 +08:00
fillOpacity: 0.1,
2026-03-30 17:26:44 +08:00
weight: 2,
2026-04-03 09:33:26 +08:00
opacity: 0.45,
2026-03-30 17:26:44 +08:00
dashArray: '6 4'
2026-03-30 19:15:41 +08:00
})
layerGroups.territories.addLayer(circle)
2026-03-30 17:26:44 +08:00
const label = L.marker([loc.lat + 0.35, loc.lng], {
icon: L.divIcon({
className: '',
2026-04-03 09:33:26 +08:00
html: `<div style="color:${f.color};font-size:11px;font-weight:bold;text-shadow:0 0 3px #fff,0 0 6px #fff;white-space:nowrap;text-align:center">${f.name}</div>`,
2026-03-30 17:26:44 +08:00
iconAnchor: [30, 8]
})
2026-03-30 19:15:41 +08:00
})
layerGroups.territories.addLayer(label)
2026-03-30 17:26:44 +08:00
})
})
}
2026-03-30 19:15:41 +08:00
// ── 绘制地点标记 ────────────────────────────────────────────
2026-03-30 17:26:44 +08:00
function drawLocations() {
2026-03-30 19:15:41 +08:00
const locs = mergedLocations.value
const facs = mergedFactions.value
2026-04-03 09:33:26 +08:00
locs.forEach((loc) => {
let markerColor = '#919191'
2026-03-30 19:15:41 +08:00
for (const f of facs) {
2026-03-30 17:26:44 +08:00
if (f.territory && f.territory.includes(loc.id)) {
markerColor = f.color
break
}
}
const size = loc.type === 'city' ? 10 : loc.type === 'landmark' ? 8 : 6
const icon = L.divIcon({
className: '',
2026-04-03 09:33:26 +08:00
html: `<div style="width:${size}px;height:${size}px;background:${markerColor};border:2px solid #fff;border-radius:50%;box-shadow:0 0 8px ${markerColor}"></div>`,
2026-03-30 17:26:44 +08:00
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
})
const marker = L.marker([loc.lat, loc.lng], { icon })
.bindPopup(`<b>${loc.name}</b><br>${loc.description || ''}`)
2026-04-03 09:33:26 +08:00
marker.on('click', () => {
lastInteraction.value = `查看地点 ${loc.name}`
})
2026-03-30 19:15:41 +08:00
layerGroups.locations.addLayer(marker)
2026-03-30 17:26:44 +08:00
})
}
2026-03-30 19:15:41 +08:00
// ── 绘制人物路线 ────────────────────────────────────────────
2026-03-30 17:26:44 +08:00
function drawRoutes() {
2026-03-30 19:15:41 +08:00
const locs = mergedLocations.value
2026-04-03 09:33:26 +08:00
const focus = focusedRouteName.value
const hasFocus = Boolean(focus)
mergedRoutes.value.forEach((route) => {
2026-03-30 17:26:44 +08:00
if (!selectedRoutes.value.includes(route.character)) return
const points = route.route || []
2026-04-03 09:33:26 +08:00
const resolved = points
.map((p) => {
const coords = getPointCoords(p, locs)
return coords ? { ...coords, chapter: p.chapter, vol: p.vol, event: p.event } : null
})
.filter(Boolean)
if (resolved.length < 2) return
const isFocused = focus === route.character
const lineOpacity = hasFocus ? (isFocused ? 0.95 : 0.15) : 0.8
const lineWeight = hasFocus ? (isFocused ? 4 : 2) : 3
2026-03-30 19:15:41 +08:00
for (let i = 0; i < resolved.length - 1; i++) {
const p1 = resolved[i]
const p2 = resolved[i + 1]
2026-03-30 17:26:44 +08:00
const line = L.polyline([[p1.lat, p1.lng], [p2.lat, p2.lng]], {
color: route.color,
2026-04-03 09:33:26 +08:00
weight: lineWeight,
opacity: lineOpacity
2026-03-30 19:15:41 +08:00
})
layerGroups.routes.addLayer(line)
2026-03-30 17:26:44 +08:00
2026-04-03 09:33:26 +08:00
if (!hasFocus || isFocused) {
const midLat = (p1.lat + p2.lat) / 2
const midLng = (p1.lng + p2.lng) / 2
const angle = Math.atan2(p2.lat - p1.lat, p2.lng - p1.lng) * (180 / Math.PI)
const arrow = L.marker([midLat, midLng], {
icon: L.divIcon({
className: '',
html: `<div style="color:${route.color};font-size:13px;font-weight:bold;transform:rotate(${angle}deg);text-shadow:0 0 3px rgba(0,0,0,0.8)">➤</div>`,
iconAnchor: [6, 6]
})
2026-03-30 19:15:41 +08:00
})
2026-04-03 09:33:26 +08:00
layerGroups.routes.addLayer(arrow)
}
2026-03-30 19:15:41 +08:00
}
2026-03-30 17:26:44 +08:00
2026-03-30 19:15:41 +08:00
resolved.forEach((p, idx) => {
2026-03-30 17:26:44 +08:00
const isStart = idx === 0
2026-04-03 09:33:26 +08:00
const isEnd = idx === resolved.length - 1
2026-03-30 17:26:44 +08:00
2026-03-30 19:15:41 +08:00
const dot = L.circleMarker([p.lat, p.lng], {
2026-04-03 09:33:26 +08:00
radius: isFocused ? (isStart || isEnd ? 7 : 6) : (isStart || isEnd ? 6 : 4),
2026-03-30 17:26:44 +08:00
color: route.color,
fillColor: isEnd ? '#fff' : route.color,
2026-04-03 09:33:26 +08:00
fillOpacity: hasFocus ? (isFocused ? 0.95 : 0.2) : 0.9,
weight: isFocused ? 2 : 1
}).bindPopup(`<b>${route.character}</b><br>卷${p.vol || '?'}${p.chapter || '?'}${currentNovel.value.volsLabel}<br>${p.event || ''}`)
2026-03-30 19:15:41 +08:00
layerGroups.routes.addLayer(dot)
2026-04-03 09:33:26 +08:00
if (isFocused && showRouteOrder.value) {
const tag = isStart ? '起' : (isEnd ? '终' : `${idx + 1}`)
const tagMarker = L.marker([p.lat, p.lng], {
icon: L.divIcon({
className: 'route-order-tag',
html: `<div class="order-badge" style="border-color:${route.color};color:${route.color}">${tag}</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
})
})
layerGroups.routes.addLayer(tagMarker)
}
2026-03-30 17:26:44 +08:00
})
})
}
2026-03-30 19:15:41 +08:00
// ── 工具函数 ────────────────────────────────────────────────
function getPointCoords(point, locs) {
2026-03-30 17:26:44 +08:00
if (point.lat && point.lng) {
return { lat: point.lat, lng: point.lng }
}
if (point.location) {
2026-04-03 09:33:26 +08:00
const loc = locs.find((l) => l.id === point.location)
2026-03-30 19:15:41 +08:00
if (loc) return { lat: loc.lat, lng: loc.lng }
2026-03-30 17:26:44 +08:00
}
return null
}
function toggleRoute(route) {
const idx = selectedRoutes.value.indexOf(route.character)
if (idx > -1) {
selectedRoutes.value.splice(idx, 1)
2026-04-03 09:33:26 +08:00
if (focusedRouteName.value === route.character) {
focusedRouteName.value = ''
}
2026-03-30 17:26:44 +08:00
} else {
selectedRoutes.value.push(route.character)
2026-04-03 09:33:26 +08:00
if (!focusedRouteName.value) {
focusedRouteName.value = route.character
}
2026-03-30 17:26:44 +08:00
}
2026-04-03 09:33:26 +08:00
drawRoutesOnly()
}
function focusRoute(character) {
focusedRouteName.value = focusedRouteName.value === character ? '' : character
if (!selectedRoutes.value.includes(character)) {
selectedRoutes.value.push(character)
}
drawRoutesOnly()
}
function clearRouteFocus() {
focusedRouteName.value = ''
drawRoutesOnly()
}
function clearAllRoutes() {
selectedRoutes.value = []
focusedRouteName.value = ''
drawRoutesOnly()
}
function selectTopRoutes() {
const top = mergedRoutes.value.slice(0, 4).map((r) => r.character)
selectedRoutes.value = top
focusedRouteName.value = top[0] || ''
drawRoutesOnly()
}
function drawRoutesOnly() {
if (!layerGroups.routes) return
2026-03-30 19:15:41 +08:00
layerGroups.routes.clearLayers()
2026-03-30 17:26:44 +08:00
drawRoutes()
}
function showFactionInfo(faction) {
selectedFaction.value = faction
2026-04-03 09:33:26 +08:00
if (Array.isArray(faction.territory) && faction.territory.length) {
const loc = mergedLocations.value.find((l) => l.id === faction.territory[0])
if (loc && map) {
map.flyTo([loc.lat, loc.lng], 7, { duration: 1.1 })
}
}
2026-03-30 17:26:44 +08:00
}
function getTerritoryNames(territoryIds) {
2026-04-03 09:33:26 +08:00
return territoryIds.map((tid) => {
const loc = mergedLocations.value.find((l) => l.id === tid)
2026-03-30 17:26:44 +08:00
return loc ? loc.name : tid
})
}
2026-03-30 19:15:41 +08:00
function onVolSliderChange() {
2026-04-03 09:33:26 +08:00
lastInteraction.value = `切换到 ${currentVolLabel.value}`
2026-03-30 19:15:41 +08:00
}
function flyToEvent(ev) {
const locs = mergedLocations.value
let coords = null
if (ev.lat && ev.lng) {
coords = [ev.lat, ev.lng]
} else if (ev.location) {
2026-04-03 09:33:26 +08:00
const loc = locs.find((l) => l.id === ev.location)
2026-03-30 19:15:41 +08:00
if (loc) coords = [loc.lat, loc.lng]
}
if (coords && map) {
2026-04-03 09:33:26 +08:00
lastInteraction.value = `查看事件 ${ev.event}`
2026-03-30 19:15:41 +08:00
map.flyTo(coords, 8, { duration: 1.2 })
}
}
2026-04-03 09:33:26 +08:00
function flyToRouteStep(step) {
if (!map) return
lastInteraction.value = `查看路线节点 ${step.locationName}`
map.flyTo([step.lat, step.lng], 8, { duration: 1 })
}
function applySearchResult(result) {
if (result.type === '地点' && result.payload) {
const loc = result.payload
if (map) map.flyTo([loc.lat, loc.lng], 8, { duration: 1 })
lastInteraction.value = `搜索到地点 ${loc.name}`
return
}
if (result.type === '势力' && result.payload) {
showFactionInfo(result.payload)
lastInteraction.value = `搜索到势力 ${result.payload.name}`
return
}
if (result.type === '人物' && result.payload) {
const route = result.payload
if (!selectedRoutes.value.includes(route.character)) {
selectedRoutes.value.push(route.character)
}
focusedRouteName.value = route.character
drawRoutesOnly()
lastInteraction.value = `搜索到人物 ${route.character}`
}
}
// ── 智能问答逻辑 ────────────────────────────────────────────
function toggleChat() {
isChatOpen.value = !isChatOpen.value
if (isChatOpen.value) {
scrollToChatBottom()
}
}
async function sendChatMessage() {
const text = chatInput.value.trim()
if (!text || isChatLoading.value) return
2026-04-03 09:33:26 +08:00
chatMessages.value.push({ role: 'user', content: text })
chatInput.value = ''
isChatLoading.value = true
scrollToChatBottom()
2026-04-03 09:33:26 +08:00
const question = includeMapContext.value
? `【地图上下文】\n${mapContextSummary.value}\n\n【用户问题】\n${text}`
: text
try {
2026-04-04 10:52:34 +08:00
const res = await fetch('http://StoryMapAI.api.digitalmars.com.cn/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
2026-04-03 09:33:26 +08:00
body: JSON.stringify({ question })
})
2026-04-03 09:33:26 +08:00
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
2026-04-03 09:33:26 +08:00
chatMessages.value.push({ role: 'assistant', content: data.answer || '助手暂时没有返回结果。' })
} catch (err) {
console.error('Chat error:', err)
chatMessages.value.push({ role: 'assistant', content: '抱歉,连接知识库失败,请稍后再试。' })
} finally {
isChatLoading.value = false
scrollToChatBottom()
}
}
function scrollToChatBottom() {
nextTick(() => {
if (chatMessagesRef.value) {
chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight
}
})
}
2026-03-30 17:26:44 +08:00
</script>
<style scoped>
2026-04-03 09:33:26 +08:00
:root {
font-family: 'Noto Serif SC', 'Source Han Serif SC', serif;
}
2026-03-30 17:26:44 +08:00
.map-container {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
2026-04-03 09:33:26 +08:00
color: #f0ead9;
}
.atmo-bg {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 12% 15%, rgba(220, 188, 121, 0.25), transparent 38%),
radial-gradient(circle at 88% 72%, rgba(121, 173, 220, 0.15), transparent 36%),
linear-gradient(120deg, rgba(18, 23, 34, 0.12), rgba(38, 25, 16, 0.1));
z-index: 0;
pointer-events: none;
2026-03-30 17:26:44 +08:00
}
#map {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
2026-04-03 09:33:26 +08:00
.novel-selector,
.legend,
.chapter-panel,
.info-panel,
.chat-panel,
.map-state-pill,
.chat-toggle-btn {
2026-03-30 17:26:44 +08:00
position: absolute;
z-index: 1000;
2026-04-03 09:33:26 +08:00
background: linear-gradient(160deg, rgba(14, 19, 30, 0.92), rgba(36, 27, 21, 0.88));
border: 1px solid rgba(201, 169, 110, 0.55);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
}
.novel-selector {
top: 16px;
left: 16px;
display: flex;
gap: 8px;
padding: 8px;
border-radius: 10px;
}
.novel-btn {
background: transparent;
color: #d6d0bf;
border: 1px solid rgba(201, 169, 110, 0.35);
padding: 8px 14px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.novel-btn:hover {
border-color: rgba(201, 169, 110, 0.75);
transform: translateY(-1px);
}
.novel-btn.active {
background: #c9a96e;
color: #1b140d;
border-color: #c9a96e;
font-weight: 700;
}
.map-state-pill {
top: 72px;
left: 16px;
padding: 8px 12px;
border-radius: 999px;
font-size: 12px;
2026-03-30 17:26:44 +08:00
}
.legend {
2026-04-03 09:33:26 +08:00
bottom: 14px;
left: 12px;
width: 320px;
max-height: calc(100vh - 160px);
border-radius: 12px;
padding: 12px;
display: flex;
flex-direction: column;
2026-04-03 09:33:26 +08:00
gap: 10px;
overflow: hidden;
2026-03-30 17:26:44 +08:00
}
.legend-block {
min-height: 0;
display: flex;
flex-direction: column;
}
.legend-factions {
flex: 1 1 auto;
}
.legend-routes {
2026-04-03 09:33:26 +08:00
flex: 1 1 auto;
}
2026-04-03 09:33:26 +08:00
.legend h3 {
color: #e6c27a;
margin: 0 0 8px;
font-size: 14px;
letter-spacing: 0.5px;
}
2026-04-03 09:33:26 +08:00
.legend-search {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(8, 11, 18, 0.55);
color: #f6f2e7;
border-radius: 6px;
padding: 6px 8px;
2026-03-30 17:26:44 +08:00
margin-bottom: 8px;
2026-04-03 09:33:26 +08:00
}
.legend-list {
min-height: 0;
overflow-y: auto;
2026-03-30 17:26:44 +08:00
}
.legend-item {
display: flex;
align-items: center;
2026-04-03 09:33:26 +08:00
margin: 4px 0;
2026-03-30 17:26:44 +08:00
cursor: pointer;
2026-04-03 09:33:26 +08:00
gap: 8px;
padding: 2px 2px;
2026-03-30 17:26:44 +08:00
}
.legend-item:hover {
2026-04-03 09:33:26 +08:00
opacity: 0.8;
2026-03-30 17:26:44 +08:00
}
.legend-dot {
2026-04-03 09:33:26 +08:00
width: 11px;
height: 11px;
2026-03-30 17:26:44 +08:00
border-radius: 50%;
2026-04-03 09:33:26 +08:00
border: 1px solid rgba(255, 255, 255, 0.35);
2026-03-30 17:26:44 +08:00
flex-shrink: 0;
}
.legend-line {
2026-04-03 09:33:26 +08:00
width: 22px;
2026-03-30 17:26:44 +08:00
height: 3px;
border-radius: 2px;
2026-04-03 09:33:26 +08:00
flex-shrink: 0;
}
.route-toolbar {
display: flex;
gap: 6px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.mini-btn {
border: 1px solid rgba(201, 169, 110, 0.55);
background: rgba(13, 17, 28, 0.75);
color: #e2d4b3;
border-radius: 6px;
padding: 4px 6px;
font-size: 11px;
cursor: pointer;
}
.mini-btn.active {
background: rgba(201, 169, 110, 0.25);
}
.route-item {
justify-content: space-between;
}
.focus-btn {
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: #d8d2c4;
border-radius: 999px;
font-size: 11px;
padding: 1px 8px;
cursor: pointer;
}
.focus-btn.active {
border-color: #e5c279;
color: #e5c279;
}
.route-insight {
border-top: 1px solid rgba(201, 169, 110, 0.3);
padding-top: 8px;
}
.insight-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #e8cb8f;
margin-bottom: 6px;
}
.insight-list {
max-height: 140px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.insight-item {
display: grid;
grid-template-columns: 22px 1fr;
grid-template-rows: auto auto;
gap: 1px 6px;
border: 1px solid rgba(201, 169, 110, 0.2);
background: rgba(9, 12, 20, 0.55);
border-radius: 6px;
padding: 4px;
cursor: pointer;
2026-03-30 17:26:44 +08:00
}
2026-04-03 09:33:26 +08:00
.insight-item:hover {
border-color: rgba(201, 169, 110, 0.6);
}
.step-idx {
grid-row: 1 / span 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-top: 2px;
border-radius: 50%;
background: rgba(201, 169, 110, 0.2);
color: #eecf91;
font-size: 11px;
}
.step-main {
font-size: 12px;
}
.step-sub {
font-size: 11px;
color: #a8a093;
2026-03-30 17:26:44 +08:00
}
.chapter-panel {
2026-04-03 09:33:26 +08:00
right: 12px;
bottom: 14px;
width: 360px;
max-height: calc(100vh - 140px);
2026-03-30 19:15:41 +08:00
overflow-y: auto;
2026-04-03 09:33:26 +08:00
border-radius: 12px;
padding: 12px;
2026-03-30 17:26:44 +08:00
}
.chapter-panel h3 {
2026-04-03 09:33:26 +08:00
margin: 0 0 8px;
color: #e6c27a;
2026-03-30 17:26:44 +08:00
}
2026-03-30 19:15:41 +08:00
.vol-slider-wrap {
position: relative;
2026-04-03 09:33:26 +08:00
padding-bottom: 22px;
2026-03-30 17:26:44 +08:00
}
.chapter-slider {
width: 100%;
2026-03-30 19:15:41 +08:00
margin: 8px 0 0;
2026-03-30 17:26:44 +08:00
accent-color: #c9a96e;
}
2026-03-30 19:15:41 +08:00
.vol-ticks {
position: relative;
2026-04-03 09:33:26 +08:00
height: 18px;
2026-03-30 19:15:41 +08:00
margin-top: 2px;
}
.tick-label {
position: absolute;
transform: translateX(-50%);
font-size: 11px;
2026-04-03 09:33:26 +08:00
color: #8f8a7e;
2026-03-30 19:15:41 +08:00
white-space: nowrap;
}
.vol-info {
2026-03-30 17:26:44 +08:00
font-size: 12px;
2026-04-03 09:33:26 +08:00
color: #c8bfad;
2026-03-30 19:15:41 +08:00
margin-bottom: 8px;
2026-03-30 17:26:44 +08:00
}
2026-04-03 09:33:26 +08:00
.search-panel {
border-top: 1px solid rgba(201, 169, 110, 0.2);
border-bottom: 1px solid rgba(201, 169, 110, 0.2);
padding: 8px 0;
}
.map-search-input {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(8, 11, 18, 0.55);
color: #f6f2e7;
border-radius: 6px;
padding: 6px 8px;
}
.search-results {
margin-top: 6px;
max-height: 145px;
overflow-y: auto;
}
.search-result-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 3px;
border-radius: 5px;
cursor: pointer;
}
.search-result-item:hover {
background: rgba(201, 169, 110, 0.13);
}
.search-type {
font-size: 10px;
color: #e8cb8f;
border: 1px solid rgba(201, 169, 110, 0.4);
border-radius: 999px;
padding: 1px 6px;
}
.search-name {
font-size: 12px;
}
2026-03-30 19:15:41 +08:00
.events-section {
padding-top: 8px;
2026-03-30 17:26:44 +08:00
}
2026-03-30 19:15:41 +08:00
.events-title {
font-size: 12px;
2026-04-03 09:33:26 +08:00
color: #e8cb8f;
margin-bottom: 5px;
2026-03-30 19:15:41 +08:00
}
.event-item {
display: flex;
gap: 6px;
2026-04-03 09:33:26 +08:00
margin: 3px 0;
2026-03-30 19:15:41 +08:00
cursor: pointer;
padding: 3px 4px;
border-radius: 4px;
font-size: 12px;
}
.event-item:hover {
2026-04-03 09:33:26 +08:00
background: rgba(201, 169, 110, 0.13);
2026-03-30 19:15:41 +08:00
}
.event-ch {
2026-04-03 09:33:26 +08:00
color: #d9ba7a;
2026-03-30 19:15:41 +08:00
flex-shrink: 0;
}
.event-text {
2026-04-03 09:33:26 +08:00
color: #d7d3cc;
2026-03-30 19:15:41 +08:00
}
2026-03-30 17:26:44 +08:00
.info-panel {
2026-04-03 09:33:26 +08:00
top: 124px;
right: 12px;
width: 320px;
max-height: 40vh;
2026-03-30 17:26:44 +08:00
overflow-y: auto;
2026-04-03 09:33:26 +08:00
border-radius: 10px;
padding: 10px 12px;
2026-03-30 17:26:44 +08:00
}
.info-panel h3 {
2026-04-03 09:33:26 +08:00
margin: 2px 0 6px;
2026-03-30 17:26:44 +08:00
}
2026-04-03 09:33:26 +08:00
.info-panel p {
margin: 4px 0;
font-size: 13px;
line-height: 1.5;
}
.close-btn {
2026-03-30 17:26:44 +08:00
position: absolute;
top: 8px;
2026-04-03 09:33:26 +08:00
right: 10px;
2026-03-30 17:26:44 +08:00
cursor: pointer;
2026-04-03 09:33:26 +08:00
font-size: 18px;
color: #b7b1a4;
2026-03-30 17:26:44 +08:00
}
2026-04-03 09:33:26 +08:00
.close-btn:hover {
2026-03-30 17:26:44 +08:00
color: #fff;
}
2026-03-30 19:15:41 +08:00
.loading-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
z-index: 2000;
overflow: hidden;
2026-04-03 09:33:26 +08:00
background: rgba(11, 11, 11, 0.8);
2026-03-30 19:15:41 +08:00
}
.loading-inner {
height: 100%;
2026-04-03 09:33:26 +08:00
background: linear-gradient(90deg, #8f6f3f, #e7ca8e);
2026-03-30 19:15:41 +08:00
transition: width 0.3s ease;
}
.loading-text {
position: absolute;
2026-04-03 09:33:26 +08:00
top: 8px;
2026-03-30 19:15:41 +08:00
left: 50%;
transform: translateX(-50%);
font-size: 12px;
2026-04-03 09:33:26 +08:00
color: #f0d8a7;
background: rgba(13, 17, 28, 0.85);
padding: 3px 10px;
2026-03-30 19:15:41 +08:00
border-radius: 4px;
}
.chat-toggle-btn {
2026-04-03 09:33:26 +08:00
top: 16px;
right: 16px;
2026-04-04 10:52:34 +08:00
bottom: auto;
border-radius: 8px;
padding: 8px 7px;
2026-04-03 09:33:26 +08:00
color: #f2e3be;
cursor: pointer;
2026-04-04 10:52:34 +08:00
font-size: 12px;
writing-mode: vertical-rl;
line-height: 1.15;
}
.chat-toggle-btn:hover {
2026-04-03 09:33:26 +08:00
transform: translateY(-1px);
}
.chat-panel {
2026-04-03 09:33:26 +08:00
top: 16px;
right: 16px;
width: 350px;
2026-04-04 10:52:34 +08:00
height: 380px;
2026-04-03 09:33:26 +08:00
border-radius: 12px;
display: flex;
flex-direction: column;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
2026-04-03 09:33:26 +08:00
padding: 10px 12px;
border-bottom: 1px solid rgba(201, 169, 110, 0.3);
}
.chat-header h3 {
margin: 0;
2026-04-03 09:33:26 +08:00
color: #f2d192;
font-size: 15px;
}
2026-04-03 09:33:26 +08:00
.chat-context-panel {
border-bottom: 1px solid rgba(201, 169, 110, 0.2);
padding: 8px 10px;
font-size: 12px;
color: #d9d3c5;
}
2026-04-03 09:33:26 +08:00
.context-preview {
margin-top: 6px;
max-height: 70px;
overflow-y: auto;
background: rgba(9, 12, 18, 0.55);
border: 1px solid rgba(201, 169, 110, 0.22);
border-radius: 6px;
padding: 6px;
font-size: 11px;
line-height: 1.4;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
2026-04-03 09:33:26 +08:00
gap: 8px;
}
.chat-bubble {
max-width: 85%;
2026-04-03 09:33:26 +08:00
padding: 8px 10px;
border-radius: 8px;
font-size: 13px;
2026-04-03 09:33:26 +08:00
line-height: 1.45;
}
.chat-bubble.user {
align-self: flex-end;
2026-04-03 09:33:26 +08:00
background: #cfb276;
color: #17120c;
}
.chat-bubble.assistant {
align-self: flex-start;
2026-04-03 09:33:26 +08:00
background: rgba(236, 229, 213, 0.08);
border: 1px solid rgba(236, 229, 213, 0.15);
color: #ece8de;
}
.chat-bubble.loading {
2026-04-03 09:33:26 +08:00
color: #aaa08d;
font-style: italic;
}
.chat-input-area {
display: flex;
gap: 8px;
2026-04-03 09:33:26 +08:00
padding: 10px;
border-top: 1px solid rgba(201, 169, 110, 0.2);
}
.chat-input-area input {
flex: 1;
2026-04-03 09:33:26 +08:00
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(201, 169, 110, 0.35);
color: #fff;
2026-04-03 09:33:26 +08:00
border-radius: 5px;
padding: 7px 9px;
outline: none;
}
.chat-input-area button {
border: none;
2026-04-03 09:33:26 +08:00
background: #cfb276;
color: #1a140f;
border-radius: 5px;
padding: 0 12px;
cursor: pointer;
2026-04-03 09:33:26 +08:00
font-weight: 700;
}
.chat-input-area button:disabled {
2026-04-03 09:33:26 +08:00
background: #5a5550;
color: #9e978e;
cursor: not-allowed;
}
2026-04-03 09:33:26 +08:00
.empty-tip {
font-size: 12px;
color: #9c9588;
padding: 3px 2px;
}
:deep(.order-badge) {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid;
background: rgba(255, 255, 255, 0.9);
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.35);
}
@media (max-width: 1024px) {
.legend {
width: 290px;
}
.chapter-panel {
width: 320px;
}
}
@media (max-width: 768px) {
.map-state-pill {
display: none;
}
.novel-selector {
top: 10px;
left: 10px;
right: 74px;
flex-wrap: wrap;
}
.legend {
left: 8px;
bottom: 8px;
width: calc(100vw - 16px);
max-height: 36vh;
}
.chapter-panel {
right: 8px;
bottom: calc(36vh + 16px);
width: calc(100vw - 16px);
max-height: 30vh;
}
.info-panel {
top: 130px;
right: 8px;
width: calc(100vw - 16px);
}
.chat-panel {
top: 64px;
right: 8px;
width: calc(100vw - 16px);
height: 58vh;
}
.chat-toggle-btn {
top: 10px;
right: 10px;
}
}
2026-03-30 17:26:44 +08:00
</style>