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
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
|
|
}
|
|
}
|
|
}
|