feat: 客户亮灯表 + AI 周报摘要 — v0.2

亮灯表:
- 四色覆盖矩阵: 绿亮灯/黄临期/红灭灯/灰未分配
- 按客户经理折叠卡片流, 覆盖率进度条, 团队总览统计
- 红灯客户显示连续未拜访月份, 未分配客户专区
- 后端: light_board.py service + GET /api/dashboard/light-board
- 前端: LightBoard.vue + 路由 /light-board + 汇总侧边栏

AI 周报摘要:
- 接入 OpenAI 兼容大模型, 注入拜访数据+亮灯表覆盖数据
- 四段式结构化输出: 概况/需求/覆盖分析/建议
- 一键生成+Markdown渲染+复制纯文本
- 支局长/分管领导专用, 支持配置内部模型
- 后端: ai_summary.py service + POST /api/ai/summary
- 前端: WeeklyReport 集成按钮+结果面板
- 新增配置: AI_API_URL / AI_API_KEY / AI_MODEL

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-24 12:03:00 +08:00
parent b79a5296c4
commit 305ee5df09
13 changed files with 1100 additions and 1 deletions
+473
View File
@@ -0,0 +1,473 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { dashboardApi } from '@/api/dashboard'
import { ElMessage } from 'element-plus'
const router = useRouter()
const loading = ref(false)
const board = ref<any>(null)
const expandedManagers = ref<Set<string>>(new Set())
const monthOffset = ref(0)
const referenceMonth = computed(() => {
const d = new Date()
d.setMonth(d.getMonth() + monthOffset.value)
return d.toISOString().slice(0, 7)
})
const isCurrentMonth = computed(() => monthOffset.value >= 0)
function changeMonth(delta: number) {
monthOffset.value += delta
loadData()
}
function goCurrentMonth() {
monthOffset.value = 0
loadData()
}
function toggleManager(id: string) {
if (expandedManagers.value.has(id)) {
expandedManagers.value.delete(id)
} else {
expandedManagers.value.add(id)
}
}
async function loadData() {
loading.value = true
try {
const refDate = new Date()
refDate.setMonth(refDate.getMonth() + monthOffset.value)
const res = await dashboardApi.getLightBoard({ reference_date: refDate.toISOString().slice(0, 10) })
board.value = res.data
} catch (e: any) {
ElMessage.error('加载亮灯表失败')
} finally {
loading.value = false
}
}
onMounted(loadData)
function statusLabel(status: string): string {
const map: Record<string, string> = { green: '亮灯', yellow: '临期', red: '灭灯', gray: '未分配' }
return map[status] || status
}
function statusGlyph(status: string): string {
const map: Record<string, string> = { green: '亮', yellow: '临', red: '灭', gray: '—' }
return map[status] || '?'
}
function goCustomerVisits(customerId: string) {
router.push({ path: '/weekly-report', query: { customer_id: customerId } })
}
function goManagerReport(managerId: string) {
router.push({ path: '/weekly-report', query: { manager_id: managerId } })
}
const coverageColor = (rate: number): string => {
if (rate >= 0.8) return 'var(--sage)'
if (rate >= 0.5) return 'var(--gold)'
return 'var(--vermilion)'
}
</script>
<template>
<div class="light-board" v-loading="loading">
<!-- Page Header -->
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="margin-right:8px; color: var(--gold); vertical-align: -4px;">
<path d="M12 2l2.4 7.2h7.6l-6 4.8 2.4 7.2-6.4-4.8-6.4 4.8 2.4-7.2-6-4.8h7.6z"/>
</svg>
客户拜访亮灯表
</h2>
<div class="month-nav">
<button class="month-nav-btn" @click="changeMonth(-1)"></button>
<span class="month-label">{{ referenceMonth }}</span>
<button class="month-nav-btn" @click="changeMonth(1)" :disabled="isCurrentMonth"></button>
<button v-if="!isCurrentMonth" class="month-nav-reset" @click="goCurrentMonth">回到本月</button>
</div>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- Team Summary Bar -->
<div v-if="board" class="team-bar">
<div class="team-stat">
<span class="team-stat-num">{{ board.team_summary.total_customers }}</span>
<span class="team-stat-label">总客户</span>
</div>
<div class="team-stat team-stat--green">
<span class="team-stat-num">{{ board.team_summary.visited_this_month }}</span>
<span class="team-stat-label">🟢 亮灯</span>
</div>
<div class="team-stat team-stat--yellow">
<span class="team-stat-num">{{ board.team_summary.visited_last_month_only }}</span>
<span class="team-stat-label">🟡 临期</span>
</div>
<div class="team-stat team-stat--red">
<span class="team-stat-num">{{ board.team_summary.not_visited_2months }}</span>
<span class="team-stat-label">🔴 灭灯</span>
</div>
<div class="team-stat" v-if="board.team_summary.unassigned">
<span class="team-stat-num">{{ board.team_summary.unassigned }}</span>
<span class="team-stat-label"> 未分配</span>
</div>
<div class="team-stat team-stat--coverage">
<span class="team-stat-num">{{ (board.team_summary.coverage_rate * 100).toFixed(0) }}%</span>
<span class="team-stat-label">覆盖率</span>
</div>
</div>
<!-- Manager Rows -->
<div v-if="board" class="manager-list">
<div
v-for="m in board.managers"
:key="m.manager_id"
class="manager-row"
:class="{ 'manager-row--expanded': expandedManagers.has(m.manager_id) }"
>
<!-- Summary Row (always visible) -->
<div class="manager-summary" @click="toggleManager(m.manager_id)">
<div class="manager-info">
<span class="manager-expand">{{ expandedManagers.has(m.manager_id) ? '▼' : '▶' }}</span>
<span class="manager-name" @click.stop="goManagerReport(m.manager_id)">{{ m.manager_name }}</span>
<span class="manager-count">{{ m.total_customers }} 个客户</span>
</div>
<div class="manager-lights">
<span class="light-dot light-dot--green" :title="`亮灯 ${m.visited_this_month}`">{{ m.visited_this_month }}</span>
<span class="light-dot light-dot--yellow" :title="`临期 ${m.visited_last_month_only}`">{{ m.visited_last_month_only }}</span>
<span class="light-dot light-dot--red" :title="`灭灯 ${m.not_visited_2months}`">{{ m.not_visited_2months }}</span>
</div>
<div class="manager-coverage">
<div class="coverage-bar">
<div class="coverage-fill" :style="{
width: (m.coverage_rate * 100) + '%',
background: coverageColor(m.coverage_rate),
}"></div>
</div>
<span class="coverage-pct" :style="{ color: coverageColor(m.coverage_rate) }">
{{ (m.coverage_rate * 100).toFixed(0) }}%
</span>
</div>
</div>
<!-- Customer Cards (expanded) -->
<div v-if="expandedManagers.has(m.manager_id)" class="customer-grid">
<div
v-for="cust in m.customers"
:key="cust.customer_id"
class="customer-card"
:class="`customer-card--${cust.status}`"
@click="goCustomerVisits(cust.customer_id)"
>
<div class="card-status-stripe"></div>
<div class="card-body">
<div class="card-name-row">
<span class="card-glyph" :class="`card-glyph--${cust.status}`">{{ statusGlyph(cust.status) }}</span>
<strong class="card-name">{{ cust.customer_name }}</strong>
</div>
<div class="card-meta">
<span v-if="cust.industry" class="card-industry">{{ cust.industry }}</span>
<span v-if="cust.in_use_services" class="card-services">{{ cust.in_use_services }}</span>
</div>
<div class="card-visit-info">
<span class="card-last-visit" v-if="cust.last_visit_date">
{{ { green: '最近', yellow: '上月', red: '上次' }[cust.status] }}{{ cust.last_visit_date }}
</span>
<span class="card-last-visit card-last-visit--never" v-else>从未拜访</span>
<span v-if="cust.last_visit_method" class="card-method">{{ cust.last_visit_method }}</span>
</div>
<div v-if="cust.status === 'red' && cust.consecutive_missed_months > 0" class="card-warning">
连续 {{ cust.consecutive_missed_months }} 个月未拜访
</div>
</div>
</div>
<div v-if="m.customers.length === 0" class="empty">暂无客户</div>
</div>
</div>
<!-- Unassigned Customers -->
<div v-if="board.unassigned_customers.length > 0" class="manager-row unassigned-section">
<div class="manager-summary unassigned-summary" @click="toggleManager('unassigned')">
<div class="manager-info">
<span class="manager-expand">{{ expandedManagers.has('unassigned') ? '▼' : '▶' }}</span>
<span class="manager-name" style="color: var(--warm-gray)">未分配客户</span>
<span class="manager-count">{{ board.unassigned_customers.length }} </span>
</div>
</div>
<div v-if="expandedManagers.has('unassigned')" class="customer-grid">
<div
v-for="cust in board.unassigned_customers"
:key="cust.customer_id"
class="customer-card customer-card--gray"
@click="router.push('/customers')"
>
<div class="card-status-stripe"></div>
<div class="card-body">
<div class="card-name-row">
<span class="card-glyph card-glyph--gray"></span>
<strong class="card-name">{{ cust.customer_name }}</strong>
</div>
<div class="card-meta">
<span v-if="cust.industry" class="card-industry">{{ cust.industry }}</span>
<span v-if="cust.in_use_services" class="card-services">{{ cust.in_use_services }}</span>
</div>
<div class="card-visit-info">
<span class="card-last-visit card-last-visit--never">未分配客户经理</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!loading && !board" class="empty-state">
<p class="empty-text">暂无数据</p>
</div>
</div>
</template>
<style scoped>
/* ═══ Page Header ═══ */
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.month-nav { display: flex; align-items: center; gap: 8px; margin: 4px 0 6px; }
.month-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 3px 8px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
.month-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
.month-nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.month-label { font-family: 'JetBrains Mono', 'SF Mono', monospace; font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
.month-nav-reset { background: none; border: 1px solid var(--gold); color: var(--gold); padding: 3px 8px; cursor: pointer; font-size: 12px; }
.month-nav-reset:hover { background: var(--gold); color: #fff; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
/* ═══ Team Summary Bar ═══ */
.team-bar {
display: flex; gap: 0;
background: var(--surface);
border: 1px solid var(--warm-border);
margin-bottom: 20px;
}
.team-stat {
flex: 1;
text-align: center;
padding: 16px 8px;
border-right: 1px solid var(--warm-border);
display: flex; flex-direction: column; gap: 4px;
}
.team-stat:last-child { border-right: none; }
.team-stat-num {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 26px; color: var(--ink); line-height: 1.1;
}
.team-stat-label {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 11px; color: var(--warm-gray); letter-spacing: 0.04em;
}
.team-stat--green .team-stat-num { color: var(--sage); }
.team-stat--yellow .team-stat-num { color: var(--gold); }
.team-stat--red .team-stat-num { color: var(--vermilion); }
.team-stat--coverage .team-stat-num { color: var(--ink); }
/* ═══ Manager Rows ═══ */
.manager-list {
display: flex; flex-direction: column; gap: 8px;
}
.manager-row {
background: var(--surface);
border: 1px solid var(--warm-border);
transition: border-color 0.2s;
}
.manager-row--expanded {
border-color: var(--ink);
}
/* ═══ Manager Summary ═══ */
.manager-summary {
display: flex; align-items: center; gap: 16px;
padding: 14px 18px;
cursor: pointer;
transition: background 0.2s;
user-select: none;
}
.manager-summary:hover {
background: rgba(196,147,74,0.03);
}
.manager-info {
display: flex; align-items: center; gap: 8px;
flex: 1;
min-width: 0;
}
.manager-expand {
font-size: 10px; color: var(--warm-gray);
width: 14px; flex-shrink: 0;
}
.manager-name {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px; color: var(--ink); letter-spacing: 0.04em;
cursor: pointer;
}
.manager-name:hover { color: var(--gold); }
.manager-count {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.03em;
}
.manager-lights {
display: flex; gap: 6px;
}
.light-dot {
display: inline-flex; align-items: center; justify-content: center;
min-width: 26px; height: 26px; border-radius: 4px;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 12px; font-weight: 600;
cursor: help;
}
.light-dot--green { background: #EDF2EC; color: var(--sage); }
.light-dot--yellow { background: #FBF6EE; color: var(--gold); }
.light-dot--red { background: #FBF1EE; color: var(--vermilion); }
.manager-coverage {
display: flex; align-items: center; gap: 10px;
width: 180px; flex-shrink: 0;
}
.coverage-bar {
flex: 1; height: 6px;
background: var(--paper-dark);
border-radius: 3px; overflow: hidden;
}
.coverage-fill {
height: 100%; border-radius: 3px;
transition: width 0.5s ease;
}
.coverage-pct {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 13px; font-weight: 600;
width: 40px; text-align: right;
}
/* ═══ Customer Grid ═══ */
.customer-grid {
display: flex; flex-wrap: wrap; gap: 10px;
padding: 0 18px 16px;
border-top: 1px solid var(--warm-border);
padding-top: 14px;
}
/* ═══ Customer Card ═══ */
.customer-card {
width: 200px;
background: var(--paper);
border: 1px solid var(--warm-border);
cursor: pointer;
transition: all 0.2s;
display: flex;
overflow: hidden;
position: relative;
}
.customer-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(28,55,56,0.06);
}
.customer-card:active { transform: scale(0.99); }
.card-status-stripe {
width: 4px; flex-shrink: 0;
}
.customer-card--green .card-status-stripe { background: var(--sage); }
.customer-card--yellow .card-status-stripe { background: var(--gold); }
.customer-card--red .card-status-stripe { background: var(--vermilion); }
.customer-card--gray .card-status-stripe { background: var(--warm-gray); }
.customer-card--red {
animation: pulse-warn 3s ease-in-out infinite;
}
@keyframes pulse-warn {
0%, 100% { border-color: var(--warm-border); }
50% { border-color: rgba(184,71,46,0.35); }
}
.card-body {
padding: 12px 14px;
flex: 1; min-width: 0;
}
.card-name-row {
display: flex; align-items: center; gap: 6px;
margin-bottom: 6px;
}
.card-glyph {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 2px;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 12px; color: #fff;
flex-shrink: 0;
}
.card-glyph--green { background: var(--sage); }
.card-glyph--yellow { background: var(--gold); }
.card-glyph--red { background: var(--vermilion); }
.card-glyph--gray { background: var(--warm-gray); }
.card-name {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px; color: var(--ink); letter-spacing: 0.03em;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-meta {
display: flex; gap: 6px; flex-wrap: wrap;
margin-bottom: 6px;
}
.card-industry, .card-services {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 10px; color: var(--warm-gray);
letter-spacing: 0.03em;
}
.card-visit-info {
display: flex; align-items: center; gap: 8px;
}
.card-last-visit {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 10px; color: var(--warm-gray);
}
.card-last-visit--never { color: var(--vermilion); }
.card-method {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 10px; padding: 1px 6px;
border: 1px solid var(--warm-border);
color: var(--warm-gray);
}
.card-warning {
margin-top: 6px;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 10px; color: var(--vermilion);
letter-spacing: 0.03em;
}
/* ═══ Unassigned Section ═══ */
.unassigned-section {
border-style: dashed;
border-color: var(--warm-gray);
}
.unassigned-summary {
cursor: pointer;
}
/* ═══ Empty ═══ */
.empty-state { text-align: center; padding: 48px 0; }
.empty-text { font-family: 'Noto Serif SC', STSong, serif; font-size: 15px; color: var(--warm-gray); }
.empty { text-align: center; padding: 20px; color: var(--c-text-muted); font-family: 'Noto Serif SC', STSong, serif; }
</style>
+160
View File
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
import { uploadApi } from '@/api/upload'
import { aiApi } from '@/api/ai'
import { ElMessage } from 'element-plus'
import ImagePreview from '@/components/ImagePreview.vue'
import api from '@/api/index'
@@ -11,6 +12,9 @@ import api from '@/api/index'
const auth = useAuthStore()
const route = useRoute()
const loading = ref(false)
const aiLoading = ref(false)
const aiSummary = ref('')
const aiError = ref('')
const activeTab = ref('visits')
const filterManagerId = ref('')
const filterCustomerId = ref('')
@@ -91,6 +95,54 @@ function viewPhoto(url: string) {
photoDialogVisible.value = true
}
async function generateAISummary() {
aiLoading.value = true
aiSummary.value = ''
aiError.value = ''
try {
const res = await aiApi.generateSummary({ reference_date: getRefDate(), period: 'week' })
aiSummary.value = res.data.summary
} catch (e: any) {
const detail = e.response?.data?.detail || 'AI 摘要生成失败,请检查 AI 服务配置或稍后重试'
aiError.value = detail
ElMessage.error(detail)
} finally {
aiLoading.value = false
}
}
async function copySummary() {
if (!aiSummary.value) return
try {
// Strip markdown markers for plain text
const plain = aiSummary.value.replace(/^#{1,4}\s+/gm, '').replace(/\*\*/g, '').replace(/\*/g, '')
await navigator.clipboard.writeText(plain)
ElMessage.success('摘要已复制到剪贴板')
} catch {
ElMessage.error('复制失败')
}
}
function renderMarkdown(md: string): string {
if (!md) return ''
let html = md
// Headers
.replace(/^#### (.+)$/gm, '<h4 class="ai-h4">$1</h4>')
.replace(/^### (.+)$/gm, '<h3 class="ai-h3">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="ai-h2">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="ai-h1">$1</h1>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Unordered lists
.replace(/^- (.+)$/gm, '<li>$1</li>')
// Wrap consecutive <li> in <ul>
.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
// Line breaks
.replace(/\n\n/g, '<br/><br/>')
.replace(/\n/g, '<br/>')
return html
}
const visitsByDate = computed(() => {
const grouped: Record<string, any[]> = {}
for (const v of report.value.visits) {
@@ -128,6 +180,12 @@ const notesByDate = computed(() => {
</div>
</div>
<div>
<el-button v-if="auth.isDirector || auth.isLeader" type="warning" :loading="aiLoading" @click="generateAISummary">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
AI 生成摘要
</el-button>
<el-button v-if="auth.isDirector" type="success" @click="handleExport">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
@@ -155,6 +213,43 @@ const notesByDate = computed(() => {
</el-row>
</el-card>
<!-- AI Summary Panel -->
<el-card v-if="aiSummary || aiLoading || aiError" class="ai-summary-card">
<template #header>
<div class="ai-header">
<div class="ai-header-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--gold); margin-right:6px">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
<span class="ai-header-title">AI 周报摘要</span>
<span class="ai-header-hint">基于本周拜访数据自动生成仅供参考</span>
</div>
<div class="ai-header-actions">
<el-button v-if="aiSummary" size="small" text @click="copySummary">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:3px">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
复制
</el-button>
<el-button size="small" text @click="aiSummary = ''; aiError = ''">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</el-button>
</div>
</div>
</template>
<div v-if="aiLoading" class="ai-loading">
<span class="ai-loading-text">🤖 AI 正在分析本周拜访数据...</span>
</div>
<div v-else-if="aiError" class="ai-error">
{{ aiError }}
</div>
<div v-else class="ai-content" v-html="renderMarkdown(aiSummary)"></div>
</el-card>
<!-- Tabs -->
<el-card>
<el-tabs v-model="activeTab">
@@ -262,4 +357,69 @@ const notesByDate = computed(() => {
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
/* ═══ AI Summary Panel ═══ */
.ai-summary-card {
margin-bottom: 16px;
border-color: var(--gold);
}
.ai-header {
display: flex; justify-content: space-between; align-items: center;
}
.ai-header-left {
display: flex; align-items: center;
}
.ai-header-title {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
}
.ai-header-hint {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 11px; color: var(--warm-gray); margin-left: 10px;
}
.ai-header-actions {
display: flex; gap: 4px;
}
.ai-loading {
text-align: center; padding: 32px 0;
}
.ai-loading-text {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px; color: var(--warm-gray);
animation: pulse-text 1.8s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.ai-error {
color: var(--vermilion);
font-family: 'Noto Serif SC', STSong, serif;
padding: 12px 0;
}
.ai-content {
font-family: 'Noto Serif SC', STSong, serif;
line-height: 1.85;
color: var(--c-text);
}
.ai-content :deep(.ai-h3) {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 16px; color: var(--ink);
margin: 16px 0 8px; letter-spacing: 0.04em;
border-left: 3px solid var(--gold); padding-left: 10px;
}
.ai-content :deep(.ai-h4) {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 14px; color: var(--ink);
margin: 12px 0 6px; letter-spacing: 0.03em;
}
.ai-content :deep(ul) {
margin: 6px 0; padding-left: 20px;
}
.ai-content :deep(li) {
margin: 3px 0; font-size: 14px;
}
.ai-content :deep(strong) {
color: var(--ink); font-weight: 600;
}
</style>