You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
153 lines
9.2 KiB
153 lines
9.2 KiB
<template>
|
|
<div class="dashboard-panel">
|
|
<t-card title="" :bordered="false" header-bordered>
|
|
<template #title>
|
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
|
|
<h2 style="margin:0;">📈 运营数据看板</h2>
|
|
<t-space size="small">
|
|
<t-button :variant="rangeDays === 7 ? 'base' : 'outline'" theme="primary" size="small" @click="setRange(7)">近7天</t-button>
|
|
<t-button :variant="rangeDays === 30 ? 'base' : 'outline'" theme="primary" size="small" @click="setRange(30)">近30天</t-button>
|
|
<t-divider layout="vertical" />
|
|
<t-date-picker v-model="customStart" size="small" style="width:140px;" />
|
|
<span style="color:var(--td-text-color-placeholder);">至</span>
|
|
<t-date-picker v-model="customEnd" size="small" style="width:140px;" />
|
|
<t-button variant="outline" size="small" @click="loadCustomRange" :disabled="!customStart || !customEnd">查询</t-button>
|
|
</t-space>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 4 指标卡片 -->
|
|
<t-row :gutter="16" style="margin-bottom:16px;">
|
|
<t-col :span="6"><t-card :bordered="true"><t-statistic title="今日对话数" :value="overview.conversationCount ?? 0" /></t-card></t-col>
|
|
<t-col :span="6"><t-card :bordered="true"><t-statistic title="满意率" :value="formatPercent(overview.satisfactionRate)" unit="%" :color="satisfactionColor" /></t-card></t-col>
|
|
<t-col :span="6"><t-card :bordered="true"><t-statistic title="知识库命中率" :value="formatPercent(overview.ragHitRate)" unit="%" color="green" /></t-card></t-col>
|
|
<t-col :span="6"><t-card :bordered="true"><t-statistic title="平均响应时间" :value="overview.avgResponseTime ?? 0" unit="ms" /></t-card></t-col>
|
|
</t-row>
|
|
|
|
<!-- 图表 -->
|
|
<t-row :gutter="16" style="margin-bottom:16px;">
|
|
<t-col :span="6"><t-card title="📊 对话量趋势" :bordered="true"><div style="height:260px;"><canvas ref="conversationChartRef"></canvas></div></t-card></t-col>
|
|
<t-col :span="6"><t-card title="📊 满意度趋势" :bordered="true"><div style="height:260px;"><canvas ref="satisfactionChartRef"></canvas></div></t-card></t-col>
|
|
</t-row>
|
|
|
|
<!-- 知识库分析 -->
|
|
<t-row :gutter="16">
|
|
<t-col :span="6">
|
|
<t-card title="📚 文档命中排行 TOP-10" :bordered="true">
|
|
<t-table :data="topHitDocuments" :columns="hitColumns" row-key="idx" v-if="topHitDocuments.length" />
|
|
<t-empty v-else description="暂无数据" />
|
|
</t-card>
|
|
</t-col>
|
|
<t-col :span="6">
|
|
<t-card title="❓ 未命中问题收集" :bordered="true">
|
|
<div v-if="missQuestions.length" style="max-height:400px;overflow-y:auto;">
|
|
<div v-for="(item, idx) in missQuestions" :key="idx" style="padding:8px 12px;border-bottom:1px solid var(--td-border-level-1-color);font-size:13px;display:flex;align-items:center;gap:8px;">
|
|
<span style="color:var(--td-text-color-placeholder);flex:none;">{{ idx + 1 }}.</span>
|
|
<span style="flex:1;word-break:break-all;">{{ item.user_query }}</span>
|
|
<span v-if="item.last_time" style="color:var(--td-text-color-placeholder);font-size:11px;flex:none;">{{ formatDateShort(item.last_time) }}</span>
|
|
</div>
|
|
</div>
|
|
<t-empty v-else description="暂无数据" />
|
|
</t-card>
|
|
</t-col>
|
|
</t-row>
|
|
|
|
<t-loading v-if="loading" text="加载中..." style="text-align:center;padding:20px;" />
|
|
</t-card>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import { getDashboardOverview, getDashboardTrend, getDashboardKnowledge, getDashboardCustom } from '@/api/dashboard'
|
|
import { palette } from '@/utils/palette'
|
|
|
|
const loading = ref(false)
|
|
const rangeDays = ref(7)
|
|
const customStart = ref('')
|
|
const customEnd = ref('')
|
|
const overview = ref<any>({})
|
|
const trendData = ref<any[]>([])
|
|
const topHitDocuments = ref<any[]>([])
|
|
const missQuestions = ref<any[]>([])
|
|
|
|
let conversationChart: any = null
|
|
let satisfactionChart: any = null
|
|
const conversationChartRef = ref<HTMLCanvasElement | null>(null)
|
|
const satisfactionChartRef = ref<HTMLCanvasElement | null>(null)
|
|
|
|
const hitColumns = [
|
|
{ colKey: 'idx', title: '排名', width: 50, cell: (_h: any, { rowIndex }: any) => rowIndex + 1 },
|
|
{ colKey: 'document_title', title: '文档标题', ellipsis: true },
|
|
{ colKey: 'hit_count', title: '命中次数', width: 80, cell: (_h: any, { row }: any) => row.hit_count ?? row.cnt ?? 0 },
|
|
{ colKey: 'avg_score', title: '平均得分', width: 80, cell: (_h: any, { row }: any) => row.avg_score != null ? Number(row.avg_score).toFixed(3) : '-' },
|
|
]
|
|
|
|
const satisfactionColor = computed(() => {
|
|
const rate = overview.value.satisfactionRate || 0
|
|
if (rate > 0 && rate < 0.5) return 'red'
|
|
if (rate >= 0.5 && rate < 0.8) return 'orange'
|
|
return 'green'
|
|
})
|
|
|
|
function formatPercent(v: any) { if (v == null) return '0.0'; const n = Number(v); return (n * 100).toFixed(1) }
|
|
function formatDateShort(d: any) { if (!d) return ''; const dt = new Date(d); if (isNaN(dt.getTime())) return ''; return `${dt.getMonth() + 1}/${dt.getDate()}` }
|
|
|
|
async function setRange(days: number) { rangeDays.value = days; await loadTrend(days) }
|
|
async function loadOverview() {
|
|
try { const r = await getDashboardOverview(); if (r.success) overview.value = r.data } catch { /* silent */ }
|
|
// API 降级逻辑(后端已降级但前端不感知)—— 如果今日无数据,错误态 toast 提示
|
|
if (overview.value?.conversationCount === 0 && overview.value?.messageCount === 0) {
|
|
import('tdesign-vue-next').then(({ MessagePlugin }) => {
|
|
MessagePlugin.warning('今日暂无数据,概览指标展示近 7 天汇总')
|
|
}).catch(() => {})
|
|
}
|
|
}
|
|
|
|
async function loadTrend(days: number) {
|
|
try { const r = await getDashboardTrend(days); if (r.success) { trendData.value = r.data || []; await nextTick(); renderCharts() } } catch { /* */ }
|
|
}
|
|
|
|
async function loadCustomRange() {
|
|
if (!customStart.value || !customEnd.value) return; loading.value = true
|
|
try { const r = await getDashboardCustom(customStart.value, customEnd.value); if (r.success) { trendData.value = r.data || []; rangeDays.value = 0; await nextTick(); renderCharts() } } catch { /* */ }
|
|
finally { loading.value = false }
|
|
}
|
|
|
|
async function loadKnowledge() {
|
|
try { const r = await getDashboardKnowledge(); if (r.success) { topHitDocuments.value = r.data?.topHitDocuments || []; missQuestions.value = r.data?.missQuestions || [] } } catch { /* */ }
|
|
}
|
|
|
|
// Chart.js 渲染
|
|
async function renderCharts() {
|
|
const { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler } = await import('chart.js')
|
|
Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler)
|
|
|
|
const labels = trendData.value.map((s: any) => { const d = new Date(s.snapshotDate || s.snapshot_date); return `${d.getMonth() + 1}/${d.getDate()}` })
|
|
|
|
if (conversationChart) conversationChart.destroy()
|
|
if (conversationChartRef.value) {
|
|
conversationChart = new Chart(conversationChartRef.value, {
|
|
type: 'line', data: { labels, datasets: [
|
|
{ label: '对话数', data: trendData.value.map((s: any) => s.conversationCount ?? s.conversation_count ?? 0), borderColor: palette.blue, backgroundColor: `rgba(${palette.blueRgb},0.1)`, fill: true, tension: 0.3, pointRadius: 4 },
|
|
{ label: '消息数', data: trendData.value.map((s: any) => s.messageCount ?? s.message_count ?? 0), borderColor: palette.purple, backgroundColor: `rgba(${palette.purpleRgb},0.1)`, fill: true, tension: 0.3, pointRadius: 4 }] },
|
|
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 12 } } } }, scales: { y: { beginAtZero: true }, x: { grid: { display: false } } } },
|
|
})
|
|
}
|
|
|
|
if (satisfactionChart) satisfactionChart.destroy()
|
|
if (satisfactionChartRef.value) {
|
|
satisfactionChart = new Chart(satisfactionChartRef.value, {
|
|
type: 'line', data: { labels, datasets: [
|
|
{ label: '👍 有帮助', data: trendData.value.map((s: any) => s.thumbsUpCount ?? s.thumbs_up_count ?? 0), borderColor: palette.green, backgroundColor: `rgba(${palette.greenRgb},0.1)`, fill: true, tension: 0.3, pointRadius: 4 },
|
|
{ label: '👎 没帮助', data: trendData.value.map((s: any) => s.thumbsDownCount ?? s.thumbs_down_count ?? 0), borderColor: palette.red, backgroundColor: `rgba(${palette.redRgb},0.1)`, fill: true, tension: 0.3, pointRadius: 4 }] },
|
|
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 12 } } } }, scales: { y: { beginAtZero: true }, x: { grid: { display: false } } } },
|
|
})
|
|
}
|
|
}
|
|
|
|
function destroyCharts() { if (conversationChart) { conversationChart.destroy(); conversationChart = null }; if (satisfactionChart) { satisfactionChart.destroy(); satisfactionChart = null } }
|
|
|
|
onMounted(async () => { loading.value = true; await Promise.all([loadOverview(), loadTrend(7), loadKnowledge()]); loading.value = false })
|
|
onBeforeUnmount(() => destroyCharts())
|
|
</script>
|