Files
Novel-Map/frontend/App.vue

1058 lines
28 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="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="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 id="map"></div>
<!-- 势力图例 -->
<div class="legend">
<div class="legend-block legend-factions">
<h3>势力图例</h3>
<div class="legend-list legend-faction-list">
<div
v-for="faction in mergedFactions"
: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>
</div>
<div class="legend-block legend-routes">
<h3>人物路线</h3>
<div class="legend-list legend-route-list">
<div
v-for="route in visibleRouteDefs"
:key="route.character"
class="legend-item"
@click="toggleRoute(route)"
>
<div
class="legend-line"
:style="{
background: route.color,
opacity: selectedRoutes.includes(route.character) ? 1 : 0.4
}"
></div>
<span :style="{ opacity: selectedRoutes.includes(route.character) ? 1 : 0.5 }">{{ route.character }}</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 }} &nbsp;|&nbsp; 已加载 {{ loadedCount }} / {{ TOTAL_VOLS }} {{ currentNovel.volsLabel }}
</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 }}/63 </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-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([]) // 原始卷数据index 0 = vol01
const currentVol = ref(1) // 1-63/50
const selectedRoutes = ref([])
const selectedFaction = ref(null)
const isLoading = ref(true)
const loadedCount = ref(0)
// ── 问答系统状态 ────────────────────────────────────────────
const isChatOpen = ref(false)
const chatInput = ref('')
const isChatLoading = ref(false)
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 = []
selectedFaction.value = null
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
}
// ── 滑块刻度每约10卷一个─────────────────────────────────
// 滑块范围 1-63共 62 步
// pct = (vol - 1) / (63 - 1) * 100
const volTicks = [
{ vol: 1, label: '卷一', pct: 0 },
{ vol: 10, label: '卷十', pct: (9 / 62 * 100).toFixed(3) * 1 },
{ vol: 20, label: '卷二十', pct: (19 / 62 * 100).toFixed(3) * 1 },
{ vol: 30, label: '卷三十', pct: (29 / 62 * 100).toFixed(3) * 1 },
{ vol: 40, label: '卷四十', pct: (39 / 62 * 100).toFixed(3) * 1 },
{ vol: 50, label: '卷五十', pct: (49 / 62 * 100).toFixed(3) * 1 },
{ vol: 63, label: '卷六十三', pct: 100 }
]
// ── 计算属性:当前卷标签 ────────────────────────────────────
const currentVolLabel = computed(() => {
const d = allData.value[currentVol.value - 1]
return (d && d.volume) ? d.volume : `${currentVol.value}${currentNovel.value.volsLabel}`
})
// ── 跨卷合并数据(截至 currentVol──────────────────────────
// 地点:按 id 去重,取最新卷定义
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())
})
// 势力:按 id 去重,取最新卷定义
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())
})
/**
* 合并人物路线(全卷 1-63寇仲和徐子陵全程分开显示
*/
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) {
if (d.character_routes) {
let combinedRoute = []
for (const r of d.character_routes) {
if (r.character === KOUZHONG_NAME || r.character === XUZILING_NAME) {
combinedRoute = combinedRoute.concat(r.route || [])
} else {
const name = r.character
if (!charMap.has(name)) charMap.set(name, { character: name, color: r.color, route: [] })
const entry = charMap.get(name)
entry.color = r.color
for (const pt of (r.route || [])) entry.route.push({ ...pt, vol })
}
}
if (combinedRoute.length > 0) {
const comboName = `${KOUZHONG_NAME} & ${XUZILING_NAME}`
if (!charMap.has(comboName)) {
charMap.set(comboName, { character: comboName, color: KOUZHONG_COLOR, route: [] })
}
const entry = charMap.get(comboName)
for (const pt of combinedRoute) entry.route.push({ ...pt, vol })
}
}
} else {
if (d.character_routes) {
for (const r of d.character_routes) {
const name = r.character
if (!charMap.has(name)) {
charMap.set(name, { character: name, color: r.color, route: [] })
}
const entry = charMap.get(name)
entry.color = r.color
for (const pt of (r.route || [])) {
entry.route.push({ ...pt, vol })
}
}
}
}
}
return Array.from(charMap.values())
})
const visibleRouteDefs = computed(() =>
mergedRoutes.value.map(r => ({ character: r.character, color: r.color }))
)
// 当前卷关键事件
const currentKeyEvents = computed(() => {
const d = allData.value[currentVol.value - 1]
return d ? (d.key_events || []) : []
})
// ── 生命周期 ────────────────────────────────────────────────
onMounted(async () => {
initMap()
await loadAllData()
drawAll()
})
// 监听 currentVol 变化重绘
watch(currentVol, () => {
drawAll()
})
// ── 数据加载 ────────────────────────────────────────────────
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)
}
// ── 全量重绘 ────────────────────────────────────────────────
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.12,
weight: 2,
opacity: 0.5,
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,-1px -1px 0 #fff,1px -1px 0 #fff,-1px 1px 0 #fff,1px 1px 0 #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 = '#aaa'
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 6px ${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 || ''}`)
layerGroups.locations.addLayer(marker)
})
}
// ── 绘制人物路线 ────────────────────────────────────────────
function drawRoutes() {
const locs = mergedLocations.value
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)
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: 3,
opacity: 0.8
})
layerGroups.routes.addLayer(line)
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:12px;font-weight:bold;transform:rotate(${angle}deg);transform-origin:center;text-shadow:0 0 3px rgba(0,0,0,0.8)">→</div>`,
iconAnchor: [6, 6]
})
})
layerGroups.routes.addLayer(arrow)
}
resolved.forEach((p, idx) => {
const isEnd = idx === resolved.length - 1
const isStart = idx === 0
const size = (isStart || isEnd) ? 7 : 5
const dot = L.circleMarker([p.lat, p.lng], {
radius: size,
color: route.color,
fillColor: isEnd ? '#fff' : route.color,
fillOpacity: 0.9,
weight: 2
}).bindPopup(`<b>${route.character}</b><br>卷${p.vol || '?'}${p.chapter}章<br>${p.event || ''}`)
layerGroups.routes.addLayer(dot)
})
})
}
// ── 工具函数 ────────────────────────────────────────────────
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)
} else {
selectedRoutes.value.push(route.character)
}
layerGroups.routes.clearLayers()
drawRoutes()
}
function showFactionInfo(faction) {
selectedFaction.value = faction
}
function getTerritoryNames(territoryIds) {
return territoryIds.map(tid => {
const loc = mergedLocations.value.find(l => l.id === tid)
return loc ? loc.name : tid
})
}
function onVolSliderChange() {
// currentVol 已由 v-model 更新watch 会触发 drawAll
}
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) {
map.flyTo(coords, 8, { duration: 1.2 })
}
}
// ── 智能问答逻辑 ────────────────────────────────────────────
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()
try {
const res = await fetch('http://StoryMapAI.api.digitalmars.com.cn/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: text })
})
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>
.map-container {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
}
#map {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.map-title {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: rgba(20, 20, 40, 0.9);
padding: 8px 24px;
border-radius: 8px;
border: 1px solid #c9a96e;
font-size: 20px;
font-weight: bold;
color: #c9a96e;
letter-spacing: 4px;
text-shadow: 0 0 10px rgba(201, 169, 110, 0.5);
pointer-events: none;
white-space: nowrap;
}
/* ====== 势力图例 ====== */
.legend {
position: absolute;
bottom: 20px;
left: 10px;
z-index: 1000;
background: rgba(20, 20, 40, 0.92);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #555;
max-width: 220px;
height: calc(100vh - 60px);
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
line-height: 1.6;
color: #e0e0e0;
}
.legend-block {
min-height: 0;
display: flex;
flex-direction: column;
}
.legend-factions {
flex: 1 1 auto;
}
.legend-routes {
flex: 0 0 32%;
}
.legend-list {
min-height: 0;
overflow-y: auto;
}
.legend h3 {
color: #c9a96e;
margin-bottom: 8px;
font-size: 14px;
border-bottom: 1px solid #444;
padding-bottom: 4px;
}
.legend-item {
display: flex;
align-items: center;
margin: 3px 0;
cursor: pointer;
}
.legend-item:hover {
opacity: 0.7;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.legend-line {
width: 20px;
height: 3px;
margin-right: 8px;
flex-shrink: 0;
border-radius: 2px;
}
.legend-section {
margin-top: 8px;
}
/* ====== 卷册面板 ====== */
.chapter-panel {
position: absolute;
bottom: 20px;
right: 10px;
z-index: 1000;
background: rgba(20, 20, 40, 0.92);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #555;
width: 340px;
max-height: calc(100vh - 60px);
overflow-y: auto;
color: #e0e0e0;
}
.chapter-panel h3 {
color: #c9a96e;
margin-bottom: 8px;
font-size: 14px;
border-bottom: 1px solid #444;
padding-bottom: 4px;
}
.vol-slider-wrap {
position: relative;
padding-bottom: 24px;
margin-bottom: 4px;
}
.chapter-slider {
width: 100%;
margin: 8px 0 0;
accent-color: #c9a96e;
}
.vol-ticks {
position: relative;
height: 20px;
margin-top: 2px;
}
.tick-label {
position: absolute;
transform: translateX(-50%);
font-size: 11px;
color: #888;
white-space: nowrap;
}
.vol-info {
font-size: 12px;
color: #aaa;
text-align: center;
margin-bottom: 8px;
}
/* ====== 关键事件 ====== */
.events-section {
border-top: 1px solid #444;
padding-top: 8px;
margin-top: 4px;
}
.events-title {
font-size: 12px;
color: #c9a96e;
margin-bottom: 6px;
font-weight: bold;
}
.event-item {
display: flex;
gap: 6px;
margin: 4px 0;
cursor: pointer;
padding: 3px 4px;
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
}
.event-item:hover {
background: rgba(201, 169, 110, 0.12);
}
.event-ch {
color: #c9a96e;
white-space: nowrap;
flex-shrink: 0;
}
.event-text {
color: #ccc;
}
/* ====== 势力详情面板 ====== */
.info-panel {
position: absolute;
top: 60px;
right: 10px;
z-index: 1000;
background: rgba(20, 20, 40, 0.92);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #555;
width: 340px;
max-height: 50vh;
overflow-y: auto;
color: #e0e0e0;
}
.info-panel h3 {
color: #c9a96e;
margin-bottom: 6px;
font-size: 15px;
}
.info-panel .close-btn {
position: absolute;
top: 8px;
right: 12px;
cursor: pointer;
color: #aaa;
font-size: 16px;
}
.info-panel .close-btn:hover {
color: #fff;
}
.info-panel p {
font-size: 13px;
line-height: 1.6;
margin: 4px 0;
}
/* ====== 加载进度条 ====== */
.loading-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: rgba(20, 20, 40, 0.6);
z-index: 2000;
overflow: hidden;
}
.loading-inner {
height: 100%;
background: #c9a96e;
transition: width 0.3s ease;
}
.loading-text {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #c9a96e;
background: rgba(20, 20, 40, 0.85);
padding: 2px 10px;
border-radius: 4px;
white-space: nowrap;
}
/* ====== 智能助手面板 ====== */
.chat-toggle-btn {
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
background: rgba(20, 20, 40, 0.92);
color: #c9a96e;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid #c9a96e;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
transition: background 0.3s, transform 0.2s;
user-select: none;
}
.chat-toggle-btn:hover {
background: rgba(201, 169, 110, 0.2);
transform: scale(1.05);
}
.chat-panel {
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
background: rgba(20, 20, 40, 0.95);
border: 1px solid #c9a96e;
border-radius: 8px;
width: 320px;
height: 480px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
border-bottom: 1px solid #444;
}
.chat-header h3 {
margin: 0;
color: #c9a96e;
font-size: 15px;
}
.chat-header .close-btn {
color: #aaa;
cursor: pointer;
font-size: 20px;
}
.chat-header .close-btn:hover {
color: #fff;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-bubble {
max-width: 85%;
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
word-wrap: break-word;
}
.chat-bubble.user {
align-self: flex-end;
background: #c9a96e;
color: #111;
border-bottom-right-radius: 2px;
}
.chat-bubble.assistant {
align-self: flex-start;
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
border-bottom-left-radius: 2px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.chat-bubble.loading {
font-style: italic;
color: #aaa;
}
.chat-input-area {
display: flex;
padding: 10px;
border-top: 1px solid #444;
gap: 8px;
}
.chat-input-area input {
flex: 1;
background: rgba(0, 0, 0, 0.3);
border: 1px solid #555;
color: #fff;
border-radius: 4px;
padding: 6px 10px;
outline: none;
}
.chat-input-area input:focus {
border-color: #c9a96e;
}
.chat-input-area button {
background: #c9a96e;
color: #111;
border: none;
border-radius: 4px;
padding: 0 12px;
cursor: pointer;
font-weight: bold;
}
.chat-input-area button:disabled {
background: #555;
color: #888;
cursor: not-allowed;
}
</style>