1568 lines
40 KiB
Vue
1568 lines
40 KiB
Vue
<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">×</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">×</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>
|