Files
Novel-Map/frontend/App.vue
2026-04-04 10:52:34 +08:00

1568 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="map-container">
<div class="atmo-bg"></div>
<div class="novel-selector">
<button
v-for="(novel, key) in NOVELS"
:key="key"
:class="['novel-btn', { active: activeNovelId === key }]"
@click="switchNovel(key)"
>
{{ novel.name }}
</button>
</div>
<div class="map-state-pill">
{{ currentNovel.name }} · {{ currentVolLabel }} · 缩放 {{ mapViewState.zoom.toFixed(1) }}
</div>
<div id="map"></div>
<div class="legend">
<div class="legend-block legend-factions">
<h3>势力图例</h3>
<input
v-model.trim="factionQuery"
class="legend-search"
type="text"
placeholder="搜索势力/首领/人物"
>
<div class="legend-list legend-faction-list">
<div
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>
<div v-if="!filteredFactions.length" class="empty-tip">未匹配到势力</div>
</div>
</div>
<div class="legend-block legend-routes">
<h3>人物路线</h3>
<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">
<div
v-for="route in filteredRouteDefs"
:key="route.character"
class="legend-item route-item"
@click="toggleRoute(route)"
>
<div
class="legend-line"
:style="{
background: route.color,
opacity: selectedRoutes.includes(route.character) ? 1 : 0.35
}"
></div>
<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>
</div>
</div>
</div>
<div class="chapter-panel">
<h3>卷册进度</h3>
<div class="vol-slider-wrap">
<input
type="range"
class="chapter-slider"
v-model.number="currentVol"
:min="1"
:max="TOTAL_VOLS"
@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">
当前{{ 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>
</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)"
>
<span class="event-ch">{{ ev.chapter }}{{ currentNovel.volsLabel }}</span>
<span class="event-text">{{ ev.event }}</span>
</div>
</div>
</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>
<div class="loading-bar" v-if="isLoading">
<div class="loading-inner" :style="{ width: (loadedCount / TOTAL_VOLS * 100) + '%' }"></div>
<span class="loading-text">加载数据中 {{ loadedCount }}/{{ TOTAL_VOLS }} {{ currentNovel.volsLabel }}</span>
</div>
<div class="chat-toggle-btn" @click="toggleChat" v-if="!isChatOpen" title="打开智能助手" aria-label="打开智能助手">
智能问答
</div>
<div class="chat-panel" v-show="isChatOpen">
<div class="chat-header">
<h3>知识问答</h3>
<span class="close-btn" @click="toggleChat">&times;</span>
</div>
<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">
<input
type="text"
v-model="chatInput"
@keyup.enter="sendChatMessage"
:placeholder="currentNovel.chatPlaceholder"
>
<button @click="sendChatMessage" :disabled="isChatLoading">发送</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { NOVELS } from './novel-config.js'
// ── 常量 ────────────────────────────────────────────────────
const KOUZHONG_NAME = '寇仲'
const KOUZHONG_COLOR = '#ff4500'
const XUZILING_NAME = '徐子陵'
const XUZILING_COLOR = '#ffa07a'
// ── 响应式状态 ──────────────────────────────────────────────
const activeNovelId = ref('dtslz')
const currentNovel = computed(() => NOVELS[activeNovelId.value])
const TOTAL_VOLS = computed(() => currentNovel.value.totalVols)
const allData = ref([])
const currentVol = ref(1)
const selectedRoutes = ref([])
const focusedRouteName = ref('')
const showRouteOrder = ref(true)
const selectedFaction = ref(null)
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('')
// ── 问答系统状态 ────────────────────────────────────────────
const isChatOpen = ref(false)
const chatInput = ref('')
const isChatLoading = ref(false)
const includeMapContext = ref(true)
const chatMessagesRef = ref(null)
const chatMessages = ref([
{ role: 'assistant', content: '您好!我是知识助理。我可以结合你当前浏览的地图状态回答问题。' }
])
// ── 小说切换逻辑 ──────────────────────────────────────────
async function switchNovel(novelId) {
if (activeNovelId.value === novelId) return
activeNovelId.value = novelId
currentVol.value = 1
selectedRoutes.value = []
focusedRouteName.value = ''
selectedFaction.value = null
factionQuery.value = ''
routeQuery.value = ''
mapSearchQuery.value = ''
chatMessages.value = [
{ role: 'assistant', content: `您好!我是${currentNovel.value.name}知识助理。你可以问人物行踪、势力分布、章节事件。` }
]
await loadAllData()
if (map) {
if (novelId === 'ldj') {
map.setView([33.0, 113.0], 5)
} else if (novelId === 'tlbb') {
map.setView([33.0, 105.0], 5)
} else {
map.setView([33.0, 113.0], 6)
}
}
drawAll()
}
// ── Leaflet 图层管理 ────────────────────────────────────────
let map = null
let layerGroups = {
territories: null,
locations: null,
routes: null,
events: null
}
// ── 计算属性 ───────────────────────────────────────────────
const currentVolLabel = computed(() => {
const d = allData.value[currentVol.value - 1]
return (d && d.volume) ? d.volume : `${currentVol.value}${currentNovel.value.volsLabel}`
})
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)
}))
})
const mergedLocations = computed(() => {
const locMap = new Map()
for (let i = 0; i < currentVol.value; i++) {
const d = allData.value[i]
if (!d) continue
for (const loc of (d.locations || [])) {
locMap.set(loc.id, loc)
}
}
return Array.from(locMap.values())
})
const mergedFactions = computed(() => {
const facMap = new Map()
for (let i = 0; i < currentVol.value; i++) {
const d = allData.value[i]
if (!d) continue
for (const f of (d.factions || [])) {
facMap.set(f.id, f)
}
}
return Array.from(facMap.values())
})
const mergedRoutes = computed(() => {
const charMap = new Map()
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) {
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: [] })
}
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: [] })
}
const combo = charMap.get(comboName)
combo.color = combo.color || XUZILING_COLOR
for (const pt of combinedRoute) combo.route.push({ ...pt, vol })
}
} else {
for (const r of (d.character_routes || [])) {
if (!charMap.has(r.character)) {
charMap.set(r.character, { character: r.character, color: r.color, route: [] })
}
const entry = charMap.get(r.character)
entry.color = r.color
for (const pt of (r.route || [])) entry.route.push({ ...pt, vol })
}
}
}
return Array.from(charMap.values()).sort((a, b) => b.route.length - a.route.length)
})
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))
})
const currentKeyEvents = computed(() => {
const d = allData.value[currentVol.value - 1]
return d ? (d.key_events || []) : []
})
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(' | ')
})
// ── 生命周期 ────────────────────────────────────────────────
onMounted(async () => {
initMap()
await loadAllData()
selectTopRoutes()
drawAll()
})
watch(currentVol, () => {
if (focusedRouteName.value && !visibleRouteDefs.value.find((r) => r.character === focusedRouteName.value)) {
focusedRouteName.value = ''
}
drawAll()
})
watch(activeNovelId, () => {
focusedRouteName.value = ''
})
// ── 数据加载 ────────────────────────────────────────────────
async function loadAllData() {
isLoading.value = true
loadedCount.value = 0
allData.value = new Array(TOTAL_VOLS.value).fill(null)
const promises = []
for (let i = 1; i <= TOTAL_VOLS.value; i++) {
const volStr = String(i).padStart(2, '0')
promises.push(
fetch(`/novel-data/${activeNovelId.value}/data/vol${volStr}.json`)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
})
.then((data) => {
allData.value[i - 1] = data
loadedCount.value++
})
.catch((err) => {
console.warn(`vol${volStr}.json 加载失败`, err)
loadedCount.value++
})
)
}
await Promise.all(promises)
isLoading.value = false
allData.value = [...allData.value]
}
// ── 地图初始化 ──────────────────────────────────────────────
function initMap() {
map = L.map('map', {
center: [33.0, 113.0],
zoom: 6,
zoomControl: true,
attributionControl: false
})
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)
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)
layerGroups.territories = L.layerGroup().addTo(map)
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()
}
})
}
// ── 全量重绘 ────────────────────────────────────────────────
function drawAll() {
if (!map) return
clearLayerGroups()
drawTerritories()
drawLocations()
drawRoutes()
}
function clearLayerGroups() {
Object.values(layerGroups).forEach((lg) => lg && lg.clearLayers())
}
// ── 绘制势力领地 ────────────────────────────────────────────
function drawTerritories() {
const locs = mergedLocations.value
mergedFactions.value.forEach((f) => {
if (!f.territory || !f.territory.length) return
f.territory.forEach((tid) => {
const loc = locs.find((l) => l.id === tid)
if (!loc) return
const circle = L.circle([loc.lat, loc.lng], {
radius: 50000,
color: f.color,
fillColor: f.color,
fillOpacity: 0.1,
weight: 2,
opacity: 0.45,
dashArray: '6 4'
})
layerGroups.territories.addLayer(circle)
const label = L.marker([loc.lat + 0.35, loc.lng], {
icon: L.divIcon({
className: '',
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>`,
iconAnchor: [30, 8]
})
})
layerGroups.territories.addLayer(label)
})
})
}
// ── 绘制地点标记 ────────────────────────────────────────────
function drawLocations() {
const locs = mergedLocations.value
const facs = mergedFactions.value
locs.forEach((loc) => {
let markerColor = '#919191'
for (const f of facs) {
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: '',
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>`,
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 || ''}`)
marker.on('click', () => {
lastInteraction.value = `查看地点 ${loc.name}`
})
layerGroups.locations.addLayer(marker)
})
}
// ── 绘制人物路线 ────────────────────────────────────────────
function drawRoutes() {
const locs = mergedLocations.value
const focus = focusedRouteName.value
const hasFocus = Boolean(focus)
mergedRoutes.value.forEach((route) => {
if (!selectedRoutes.value.includes(route.character)) return
const points = route.route || []
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
for (let i = 0; i < resolved.length - 1; i++) {
const p1 = resolved[i]
const p2 = resolved[i + 1]
const line = L.polyline([[p1.lat, p1.lng], [p2.lat, p2.lng]], {
color: route.color,
weight: lineWeight,
opacity: lineOpacity
})
layerGroups.routes.addLayer(line)
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]
})
})
layerGroups.routes.addLayer(arrow)
}
}
resolved.forEach((p, idx) => {
const isStart = idx === 0
const isEnd = idx === resolved.length - 1
const dot = L.circleMarker([p.lat, p.lng], {
radius: isFocused ? (isStart || isEnd ? 7 : 6) : (isStart || isEnd ? 6 : 4),
color: route.color,
fillColor: isEnd ? '#fff' : route.color,
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 || ''}`)
layerGroups.routes.addLayer(dot)
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)
}
})
})
}
// ── 工具函数 ────────────────────────────────────────────────
function getPointCoords(point, locs) {
if (point.lat && point.lng) {
return { lat: point.lat, lng: point.lng }
}
if (point.location) {
const loc = locs.find((l) => l.id === point.location)
if (loc) return { lat: loc.lat, lng: loc.lng }
}
return null
}
function toggleRoute(route) {
const idx = selectedRoutes.value.indexOf(route.character)
if (idx > -1) {
selectedRoutes.value.splice(idx, 1)
if (focusedRouteName.value === route.character) {
focusedRouteName.value = ''
}
} else {
selectedRoutes.value.push(route.character)
if (!focusedRouteName.value) {
focusedRouteName.value = route.character
}
}
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
layerGroups.routes.clearLayers()
drawRoutes()
}
function showFactionInfo(faction) {
selectedFaction.value = faction
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 })
}
}
}
function getTerritoryNames(territoryIds) {
return territoryIds.map((tid) => {
const loc = mergedLocations.value.find((l) => l.id === tid)
return loc ? loc.name : tid
})
}
function onVolSliderChange() {
lastInteraction.value = `切换到 ${currentVolLabel.value}`
}
function flyToEvent(ev) {
const locs = mergedLocations.value
let coords = null
if (ev.lat && ev.lng) {
coords = [ev.lat, ev.lng]
} else if (ev.location) {
const loc = locs.find((l) => l.id === ev.location)
if (loc) coords = [loc.lat, loc.lng]
}
if (coords && map) {
lastInteraction.value = `查看事件 ${ev.event}`
map.flyTo(coords, 8, { duration: 1.2 })
}
}
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
chatMessages.value.push({ role: 'user', content: text })
chatInput.value = ''
isChatLoading.value = true
scrollToChatBottom()
const question = includeMapContext.value
? `【地图上下文】\n${mapContextSummary.value}\n\n【用户问题】\n${text}`
: text
try {
const res = await fetch('http://StoryMapAI.api.digitalmars.com.cn/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question })
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
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
}
})
}
</script>
<style scoped>
:root {
font-family: 'Noto Serif SC', 'Source Han Serif SC', serif;
}
.map-container {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
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;
}
#map {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.novel-selector,
.legend,
.chapter-panel,
.info-panel,
.chat-panel,
.map-state-pill,
.chat-toggle-btn {
position: absolute;
z-index: 1000;
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;
}
.legend {
bottom: 14px;
left: 12px;
width: 320px;
max-height: calc(100vh - 160px);
border-radius: 12px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.legend-block {
min-height: 0;
display: flex;
flex-direction: column;
}
.legend-factions {
flex: 1 1 auto;
}
.legend-routes {
flex: 1 1 auto;
}
.legend h3 {
color: #e6c27a;
margin: 0 0 8px;
font-size: 14px;
letter-spacing: 0.5px;
}
.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;
margin-bottom: 8px;
}
.legend-list {
min-height: 0;
overflow-y: auto;
}
.legend-item {
display: flex;
align-items: center;
margin: 4px 0;
cursor: pointer;
gap: 8px;
padding: 2px 2px;
}
.legend-item:hover {
opacity: 0.8;
}
.legend-dot {
width: 11px;
height: 11px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.35);
flex-shrink: 0;
}
.legend-line {
width: 22px;
height: 3px;
border-radius: 2px;
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;
}
.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;
}
.chapter-panel {
right: 12px;
bottom: 14px;
width: 360px;
max-height: calc(100vh - 140px);
overflow-y: auto;
border-radius: 12px;
padding: 12px;
}
.chapter-panel h3 {
margin: 0 0 8px;
color: #e6c27a;
}
.vol-slider-wrap {
position: relative;
padding-bottom: 22px;
}
.chapter-slider {
width: 100%;
margin: 8px 0 0;
accent-color: #c9a96e;
}
.vol-ticks {
position: relative;
height: 18px;
margin-top: 2px;
}
.tick-label {
position: absolute;
transform: translateX(-50%);
font-size: 11px;
color: #8f8a7e;
white-space: nowrap;
}
.vol-info {
font-size: 12px;
color: #c8bfad;
margin-bottom: 8px;
}
.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;
}
.events-section {
padding-top: 8px;
}
.events-title {
font-size: 12px;
color: #e8cb8f;
margin-bottom: 5px;
}
.event-item {
display: flex;
gap: 6px;
margin: 3px 0;
cursor: pointer;
padding: 3px 4px;
border-radius: 4px;
font-size: 12px;
}
.event-item:hover {
background: rgba(201, 169, 110, 0.13);
}
.event-ch {
color: #d9ba7a;
flex-shrink: 0;
}
.event-text {
color: #d7d3cc;
}
.info-panel {
top: 124px;
right: 12px;
width: 320px;
max-height: 40vh;
overflow-y: auto;
border-radius: 10px;
padding: 10px 12px;
}
.info-panel h3 {
margin: 2px 0 6px;
}
.info-panel p {
margin: 4px 0;
font-size: 13px;
line-height: 1.5;
}
.close-btn {
position: absolute;
top: 8px;
right: 10px;
cursor: pointer;
font-size: 18px;
color: #b7b1a4;
}
.close-btn:hover {
color: #fff;
}
.loading-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
z-index: 2000;
overflow: hidden;
background: rgba(11, 11, 11, 0.8);
}
.loading-inner {
height: 100%;
background: linear-gradient(90deg, #8f6f3f, #e7ca8e);
transition: width 0.3s ease;
}
.loading-text {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #f0d8a7;
background: rgba(13, 17, 28, 0.85);
padding: 3px 10px;
border-radius: 4px;
}
.chat-toggle-btn {
top: 16px;
right: 16px;
bottom: auto;
border-radius: 8px;
padding: 8px 7px;
color: #f2e3be;
cursor: pointer;
font-size: 12px;
writing-mode: vertical-rl;
line-height: 1.15;
}
.chat-toggle-btn:hover {
transform: translateY(-1px);
}
.chat-panel {
top: 16px;
right: 16px;
width: 350px;
height: 380px;
border-radius: 12px;
display: flex;
flex-direction: column;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid rgba(201, 169, 110, 0.3);
}
.chat-header h3 {
margin: 0;
color: #f2d192;
font-size: 15px;
}
.chat-context-panel {
border-bottom: 1px solid rgba(201, 169, 110, 0.2);
padding: 8px 10px;
font-size: 12px;
color: #d9d3c5;
}
.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;
gap: 8px;
}
.chat-bubble {
max-width: 85%;
padding: 8px 10px;
border-radius: 8px;
font-size: 13px;
line-height: 1.45;
}
.chat-bubble.user {
align-self: flex-end;
background: #cfb276;
color: #17120c;
}
.chat-bubble.assistant {
align-self: flex-start;
background: rgba(236, 229, 213, 0.08);
border: 1px solid rgba(236, 229, 213, 0.15);
color: #ece8de;
}
.chat-bubble.loading {
color: #aaa08d;
font-style: italic;
}
.chat-input-area {
display: flex;
gap: 8px;
padding: 10px;
border-top: 1px solid rgba(201, 169, 110, 0.2);
}
.chat-input-area input {
flex: 1;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(201, 169, 110, 0.35);
color: #fff;
border-radius: 5px;
padding: 7px 9px;
outline: none;
}
.chat-input-area button {
border: none;
background: #cfb276;
color: #1a140f;
border-radius: 5px;
padding: 0 12px;
cursor: pointer;
font-weight: 700;
}
.chat-input-area button:disabled {
background: #5a5550;
color: #9e978e;
cursor: not-allowed;
}
.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;
}
}
</style>