本地 RAG 知识库
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.
 
 
 
 
 

340 lines
13 KiB

/**
* 运营看板组件
* 指标卡片 + Chart.js 趋势图表 + 知识库文档命中排行 + 未命中问题收集
*/
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { getDashboardOverview, getDashboardTrend, getDashboardKnowledge, getDashboardCustom } from '../js/api.js'
export default {
template: `
<div class="dashboard-panel">
<!-- 时间范围选择器 -->
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;">
<h2 style="margin:0;">📈 运营数据看板</h2>
<div style="display:flex; align-items:center; gap:8px;">
<button class="btn btn-sm" :class="rangeDays === 7 ? 'btn-primary' : 'btn-outline'" @click="setRange(7)">近7天</button>
<button class="btn btn-sm" :class="rangeDays === 30 ? 'btn-primary' : 'btn-outline'" @click="setRange(30)">近30天</button>
<span style="color:var(--muted); font-size:13px;">|</span>
<input type="date" class="input" style="width:140px; flex:none; padding:6px 10px; font-size:13px;" v-model="customStart" />
<span style="color:var(--muted); font-size:13px;">至</span>
<input type="date" class="input" style="width:140px; flex:none; padding:6px 10px; font-size:13px;" v-model="customEnd" />
<button class="btn btn-sm btn-purple" @click="loadCustomRange" :disabled="!customStart || !customEnd">查询</button>
</div>
</div>
</div>
<!-- 4个指标卡片 -->
<div class="stat-grid">
<div class="stat-card">
<div class="number">{{ overview.conversationCount ?? 0 }}</div>
<div class="label">今日对话数</div>
</div>
<div class="stat-card">
<div class="number" :style="{ color: satisfactionColor }">{{ formatPercent(overview.satisfactionRate) }}%</div>
<div class="label">满意率</div>
</div>
<div class="stat-card">
<div class="number" style="color: var(--success);">{{ formatPercent(overview.ragHitRate) }}%</div>
<div class="label">知识库命中率</div>
</div>
<div class="stat-card">
<div class="number">{{ overview.avgResponseTime ?? 0 }}<span style="font-size:14px; color:var(--muted);">ms</span></div>
<div class="label">平均响应时间</div>
</div>
</div>
<!-- 图表区域 -->
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px;">
<!-- 对话量趋势折线图 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📊 对话量趋势</h3>
<div style="position:relative; height:260px;">
<canvas ref="conversationChartRef"></canvas>
</div>
</div>
<!-- 满意度趋势折线图 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📊 满意度趋势</h3>
<div style="position:relative; height:260px;">
<canvas ref="satisfactionChartRef"></canvas>
</div>
</div>
</div>
<!-- 知识库分析 -->
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<!-- 文档命中排行 TOP-10 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📚 文档命中排行 TOP-10</h3>
<div v-if="topHitDocuments.length === 0" class="empty" style="padding:30px;">暂无数据</div>
<table v-else style="width:100%; font-size:13px; border-collapse:collapse;">
<thead>
<tr style="border-bottom:1px solid var(--border);">
<th style="text-align:left; padding:8px 4px; color:var(--sub); font-weight:600;">排名</th>
<th style="text-align:left; padding:8px 4px; color:var(--sub); font-weight:600;">文档标题</th>
<th style="text-align:right; padding:8px 4px; color:var(--sub); font-weight:600;">命中次数</th>
<th style="text-align:right; padding:8px 4px; color:var(--sub); font-weight:600;">平均得分</th>
</tr>
</thead>
<tbody>
<tr v-for="(doc, idx) in topHitDocuments" :key="idx" style="border-bottom:1px solid var(--border);">
<td style="padding:8px 4px; color:var(--muted);">{{ idx + 1 }}</td>
<td style="padding:8px 4px; max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" :title="doc.document_title">{{ doc.document_title || '-' }}</td>
<td style="text-align:right; padding:8px 4px; font-weight:600; color:var(--primary);">{{ doc.hit_count ?? doc.cnt ?? 0 }}</td>
<td style="text-align:right; padding:8px 4px; color:var(--sub);">{{ doc.avg_score != null ? Number(doc.avg_score).toFixed(3) : '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 未命中问题收集 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">❓ 未命中问题收集</h3>
<div v-if="missQuestions.length === 0" class="empty" style="padding:30px;">暂无数据</div>
<div v-else 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(--border); font-size:13px; display:flex; align-items:center; gap:8px;">
<span style="color:var(--muted); flex:none; min-width:20px;">{{ idx + 1 }}.</span>
<span style="flex:1; word-break:break-all;">{{ item.user_query }}</span>
<span v-if="item.last_time" style="color:var(--muted); font-size:11px; flex:none;">{{ formatDateShort(item.last_time) }}</span>
</div>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" style="text-align:center; padding:20px; color:var(--muted);">加载中...</div>
</div>
`,
setup() {
const loading = ref(false)
const rangeDays = ref(7)
const customStart = ref('')
const customEnd = ref('')
const overview = ref({})
const trendData = ref([])
const topHitDocuments = ref([])
const missQuestions = ref([])
// Chart.js 实例引用(用于销毁重建)
let conversationChart = null
let satisfactionChart = null
const conversationChartRef = ref(null)
const satisfactionChartRef = ref(null)
// 满意率颜色:>=80% 绿色,>=60% 橙色,<60% 红色
const satisfactionColor = computed(() => {
const rate = overview.value.satisfactionRate || 0
if (rate >= 80) return 'var(--success)'
if (rate >= 60) return 'var(--warn)'
return 'var(--danger)'
})
// 格式化百分比(保留一位小数)
function formatPercent(val) {
if (val == null) return '0.0'
return Number(val).toFixed(1)
}
// 格式化日期简写(仅月-日)
function formatDateShort(dateVal) {
if (!dateVal) return ''
const d = new Date(dateVal)
return `${d.getMonth() + 1}/${d.getDate()}`
}
// 设置时间范围并加载趋势
async function setRange(days) {
rangeDays.value = days
await loadTrend(days)
}
// 加载今日概览
async function loadOverview() {
try {
const res = await getDashboardOverview()
if (res.success) overview.value = res.data
} catch (e) {
console.error('加载概览数据失败', e)
}
}
// 加载趋势数据
async function loadTrend(days) {
try {
const res = await getDashboardTrend(days)
if (res.success) {
trendData.value = res.data || []
await nextTick()
renderCharts()
}
} catch (e) {
console.error('加载趋势数据失败', e)
}
}
// 加载自定义范围数据
async function loadCustomRange() {
if (!customStart.value || !customEnd.value) return
loading.value = true
try {
const res = await getDashboardCustom(customStart.value, customEnd.value)
if (res.success) {
trendData.value = res.data || []
rangeDays.value = 0
await nextTick()
renderCharts()
}
} catch (e) {
console.error('加载自定义范围数据失败', e)
} finally {
loading.value = false
}
}
// 加载知识库分析
async function loadKnowledgeAnalysis() {
try {
const res = await getDashboardKnowledge()
if (res.success) {
topHitDocuments.value = res.data?.topHitDocuments || []
missQuestions.value = res.data?.missQuestions || []
}
} catch (e) {
console.error('加载知识库分析数据失败', e)
}
}
// 渲染 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 => {
const d = new Date(s.snapshotDate || s.snapshot_date)
return `${d.getMonth() + 1}/${d.getDate()}`
})
// === 对话量趋势图 ===
if (conversationChart) conversationChart.destroy()
if (conversationChartRef.value) {
const convData = trendData.value.map(s => s.conversationCount ?? s.conversation_count ?? 0)
const msgData = trendData.value.map(s => s.messageCount ?? s.message_count ?? 0)
conversationChart = new Chart(conversationChartRef.value, {
type: 'line',
data: {
labels,
datasets: [
{
label: '对话数',
data: convData,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6
},
{
label: '消息数',
data: msgData,
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139,92,246,0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16, font: { size: 12 } } } },
scales: {
y: { beginAtZero: true, ticks: { font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.05)' } },
x: { ticks: { font: { size: 11 } }, grid: { display: false } }
}
}
})
}
// === 满意度趋势图(👍/👎 双线) ===
if (satisfactionChart) satisfactionChart.destroy()
if (satisfactionChartRef.value) {
const upData = trendData.value.map(s => s.thumbsUpCount ?? s.thumbs_up_count ?? 0)
const downData = trendData.value.map(s => s.thumbsDownCount ?? s.thumbs_down_count ?? 0)
satisfactionChart = new Chart(satisfactionChartRef.value, {
type: 'line',
data: {
labels,
datasets: [
{
label: '👍 有帮助',
data: upData,
borderColor: '#10b981',
backgroundColor: 'rgba(16,185,129,0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6
},
{
label: '👎 没帮助',
data: downData,
borderColor: '#ef4444',
backgroundColor: 'rgba(239,68,68,0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16, font: { size: 12 } } } },
scales: {
y: { beginAtZero: true, ticks: { font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.05)' } },
x: { ticks: { font: { size: 11 } }, 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),
loadKnowledgeAnalysis()
])
loading.value = false
})
onBeforeUnmount(() => {
destroyCharts()
})
return {
loading, rangeDays, customStart, customEnd,
overview, trendData, topHitDocuments, missQuestions,
conversationChartRef, satisfactionChartRef,
satisfactionColor, formatPercent, formatDateShort,
setRange, loadCustomRange
}
}
}