feat:数据分析页面展示数据报表内容
This commit is contained in:
parent
4a7eb906d1
commit
2c9da6bf6d
|
|
@ -0,0 +1,114 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true },
|
||||
labelKey: { type: String, default: 'label' },
|
||||
valueKey: { type: String, default: 'count' },
|
||||
color: { type: String, default: '#126854' },
|
||||
emptyText: { type: String, default: '暂无数据' },
|
||||
})
|
||||
|
||||
const max = computed(() => {
|
||||
let m = 0
|
||||
for (const it of props.items) {
|
||||
const v = Number(it[props.valueKey] || 0)
|
||||
if (v > m) m = v
|
||||
}
|
||||
return Math.max(m, 1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rank-list">
|
||||
<div v-if="!items.length" class="rank-empty">{{ emptyText }}</div>
|
||||
<div v-else class="rank-rows">
|
||||
<div v-for="(it, idx) in items" :key="idx" class="rank-row">
|
||||
<div class="rank-index" :class="{ top3: idx < 3 }">{{ idx + 1 }}</div>
|
||||
<div class="rank-body">
|
||||
<div class="rank-label" :title="it[labelKey]">{{ it[labelKey] }}</div>
|
||||
<div class="rank-bar-track">
|
||||
<div
|
||||
class="rank-bar-fill"
|
||||
:style="{
|
||||
width: ((Number(it[valueKey] || 0) / max) * 100).toFixed(1) + '%',
|
||||
background: color,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-value">{{ it[valueKey] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.rank-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.rank-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.rank-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr 60px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.rank-index {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: #eef2f0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.rank-index.top3 {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
}
|
||||
.rank-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rank-label {
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rank-bar-track {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #eef2f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rank-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.rank-value {
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<script setup>
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
value: { type: [String, Number], required: true },
|
||||
unit: { type: String, default: '' },
|
||||
hint: { type: String, default: '' },
|
||||
trend: { type: Number, default: null },
|
||||
subs: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
function trendClass(v) {
|
||||
if (v == null) return 'trend flat'
|
||||
if (v > 0) return 'trend up'
|
||||
if (v < 0) return 'trend down'
|
||||
return 'trend flat'
|
||||
}
|
||||
|
||||
function trendArrow(v) {
|
||||
if (v == null) return '—'
|
||||
if (v > 0) return '↑'
|
||||
if (v < 0) return '↓'
|
||||
return '→'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="hint" class="hint" :title="hint">?</span>
|
||||
</div>
|
||||
<div class="stat-value-row">
|
||||
<div class="stat-value">
|
||||
{{ value }}
|
||||
<span v-if="unit" class="unit">{{ unit }}</span>
|
||||
</div>
|
||||
<div v-if="trend != null" :class="trendClass(trend)">
|
||||
<span class="arrow">{{ trendArrow(trend) }}</span>
|
||||
<span>环比 {{ Math.abs(trend).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="subs.length" class="stat-subs">
|
||||
<div v-for="s in subs" :key="s.label" class="stat-sub">
|
||||
<span class="sub-label">{{ s.label }}</span>
|
||||
<span class="sub-value">{{ s.value }}<span v-if="s.unit" class="sub-unit">{{ s.unit }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
padding: 18px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(20, 33, 29, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 140px;
|
||||
}
|
||||
.stat-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.hint {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: help;
|
||||
}
|
||||
.stat-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stat-value .unit {
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
.trend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: #f4f6f5;
|
||||
color: var(--muted);
|
||||
}
|
||||
.trend.up { color: #b64a45; background: rgba(182, 74, 69, 0.08); }
|
||||
.trend.down { color: #126854; background: rgba(18, 104, 84, 0.08); }
|
||||
.trend .arrow { font-weight: 800; }
|
||||
.stat-subs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
margin-top: auto;
|
||||
padding-top: 4px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.stat-sub {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sub-label { color: var(--muted); }
|
||||
.sub-value { color: var(--ink); font-weight: 600; }
|
||||
.sub-unit { margin-left: 2px; color: var(--muted); font-weight: 400; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
buckets: { type: Array, required: true },
|
||||
series: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
height: { type: Number, default: 260 },
|
||||
yTicks: { type: Number, default: 4 },
|
||||
})
|
||||
|
||||
const width = 900
|
||||
const marginTop = 12
|
||||
const marginRight = 12
|
||||
const marginBottom = 28
|
||||
const marginLeft = 40
|
||||
|
||||
const plotHeight = computed(() => props.height - marginTop - marginBottom)
|
||||
const plotWidth = width - marginLeft - marginRight
|
||||
|
||||
const maxValue = computed(() => {
|
||||
let m = 0
|
||||
for (const s of props.series) {
|
||||
for (const label of s.field ? [s.field] : []) {
|
||||
for (const b of props.buckets) {
|
||||
const v = Number(b[label] || 0)
|
||||
if (v > m) m = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.max(m, 1)
|
||||
})
|
||||
|
||||
const niceMax = computed(() => {
|
||||
const m = maxValue.value
|
||||
if (m <= 1) return 1
|
||||
const pow = Math.pow(10, Math.floor(Math.log10(m)))
|
||||
const scaled = m / pow
|
||||
const nice = scaled <= 1 ? 1 : scaled <= 2 ? 2 : scaled <= 5 ? 5 : 10
|
||||
return nice * pow
|
||||
})
|
||||
|
||||
const yAxisTicks = computed(() => {
|
||||
const step = niceMax.value / props.yTicks
|
||||
return Array.from({ length: props.yTicks + 1 }, (_, i) => {
|
||||
const value = i * step
|
||||
const y = marginTop + plotHeight.value - (value / niceMax.value) * plotHeight.value
|
||||
return { value, y, label: formatShort(value) }
|
||||
})
|
||||
})
|
||||
|
||||
function formatShort(n) {
|
||||
if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, '') + 'w'
|
||||
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
|
||||
return String(Math.round(n))
|
||||
}
|
||||
|
||||
const xPositions = computed(() => {
|
||||
const n = props.buckets.length
|
||||
if (n === 0) return []
|
||||
if (n === 1) return [marginLeft + plotWidth / 2]
|
||||
const step = plotWidth / (n - 1)
|
||||
return props.buckets.map((_, i) => marginLeft + step * i)
|
||||
})
|
||||
|
||||
const xTickLabels = computed(() => {
|
||||
const n = props.buckets.length
|
||||
if (n === 0) return []
|
||||
const targetTicks = Math.min(n, 12)
|
||||
const stride = Math.max(1, Math.ceil(n / targetTicks))
|
||||
return props.buckets.map((b, i) => ({
|
||||
x: xPositions.value[i],
|
||||
label: b.bucket,
|
||||
show: i % stride === 0 || i === n - 1,
|
||||
}))
|
||||
})
|
||||
|
||||
function buildPath(field) {
|
||||
const parts = []
|
||||
props.buckets.forEach((b, i) => {
|
||||
const v = Number(b[field] || 0)
|
||||
const x = xPositions.value[i]
|
||||
const y = marginTop + plotHeight.value - (v / niceMax.value) * plotHeight.value
|
||||
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`)
|
||||
})
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function pointsForSeries(field) {
|
||||
return props.buckets.map((b, i) => {
|
||||
const v = Number(b[field] || 0)
|
||||
const x = xPositions.value[i]
|
||||
const y = marginTop + plotHeight.value - (v / niceMax.value) * plotHeight.value
|
||||
return { x, y, value: v, label: b.bucket, i }
|
||||
})
|
||||
}
|
||||
|
||||
const hoverIndex = ref(-1)
|
||||
|
||||
function onMove(evt) {
|
||||
const svg = evt.currentTarget
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const relX = ((evt.clientX - rect.left) / rect.width) * width
|
||||
if (xPositions.value.length === 0) {
|
||||
hoverIndex.value = -1
|
||||
return
|
||||
}
|
||||
let best = 0
|
||||
let bestDist = Infinity
|
||||
xPositions.value.forEach((x, i) => {
|
||||
const d = Math.abs(x - relX)
|
||||
if (d < bestDist) { bestDist = d; best = i }
|
||||
})
|
||||
hoverIndex.value = best
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
hoverIndex.value = -1
|
||||
}
|
||||
|
||||
const tooltip = computed(() => {
|
||||
if (hoverIndex.value < 0 || hoverIndex.value >= props.buckets.length) return null
|
||||
const b = props.buckets[hoverIndex.value]
|
||||
const x = xPositions.value[hoverIndex.value]
|
||||
return {
|
||||
x,
|
||||
label: b.bucket,
|
||||
items: props.series.map((s) => ({ name: s.name, color: s.color, value: b[s.field] || 0 })),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="trend-chart">
|
||||
<svg
|
||||
:viewBox="`0 0 ${width} ${height}`"
|
||||
preserveAspectRatio="none"
|
||||
class="chart-svg"
|
||||
@mousemove="onMove"
|
||||
@mouseleave="onLeave"
|
||||
>
|
||||
<g class="grid">
|
||||
<line
|
||||
v-for="t in yAxisTicks"
|
||||
:key="'g' + t.value"
|
||||
:x1="marginLeft"
|
||||
:x2="width - marginRight"
|
||||
:y1="t.y"
|
||||
:y2="t.y"
|
||||
stroke="#e6ebe9"
|
||||
stroke-dasharray="3 3"
|
||||
/>
|
||||
</g>
|
||||
<g class="y-axis" fill="#8a9691" font-size="11">
|
||||
<text
|
||||
v-for="t in yAxisTicks"
|
||||
:key="'y' + t.value"
|
||||
:x="marginLeft - 6"
|
||||
:y="t.y + 3"
|
||||
text-anchor="end"
|
||||
>{{ t.label }}</text>
|
||||
</g>
|
||||
<g class="x-axis" fill="#8a9691" font-size="11">
|
||||
<text
|
||||
v-for="t in xTickLabels"
|
||||
:key="'x' + t.x"
|
||||
:x="t.x"
|
||||
:y="height - 8"
|
||||
text-anchor="middle"
|
||||
v-show="t.show"
|
||||
>{{ t.label }}</text>
|
||||
</g>
|
||||
|
||||
<g v-for="s in series" :key="s.field">
|
||||
<path :d="buildPath(s.field)" :stroke="s.color" fill="none" stroke-width="1.8" />
|
||||
<g v-if="buckets.length <= 30">
|
||||
<circle
|
||||
v-for="p in pointsForSeries(s.field)"
|
||||
:key="s.field + p.i"
|
||||
:cx="p.x"
|
||||
:cy="p.y"
|
||||
r="2.5"
|
||||
:fill="s.color"
|
||||
:opacity="p.value > 0 ? 1 : 0.35"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g v-if="tooltip">
|
||||
<line
|
||||
:x1="tooltip.x"
|
||||
:x2="tooltip.x"
|
||||
:y1="marginTop"
|
||||
:y2="height - marginBottom"
|
||||
stroke="#c9d4d0"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div v-if="tooltip" class="chart-tooltip" :style="{ left: `${(tooltip.x / width) * 100}%` }">
|
||||
<div class="tt-title">{{ tooltip.label }}</div>
|
||||
<div v-for="it in tooltip.items" :key="it.name" class="tt-item">
|
||||
<span class="tt-dot" :style="{ background: it.color }"></span>
|
||||
<span class="tt-name">{{ it.name }}</span>
|
||||
<span class="tt-value">{{ it.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-legend">
|
||||
<span v-for="s in series" :key="s.field" class="legend-item">
|
||||
<span class="dot" :style="{ background: s.color }"></span>
|
||||
<span>{{ s.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trend-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.chart-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: v-bind(height + 'px');
|
||||
}
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.legend-item .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.chart-tooltip {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(20, 33, 29, 0.9);
|
||||
color: #fff;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
min-width: 120px;
|
||||
box-shadow: 0 6px 16px rgba(20, 33, 29, 0.18);
|
||||
}
|
||||
.tt-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tt-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
.tt-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tt-name { color: rgba(255,255,255,0.75); }
|
||||
.tt-value { font-weight: 700; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { ref } from 'vue'
|
||||
import { request } from '@/api/client'
|
||||
|
||||
export const RANGE_OPTIONS = [
|
||||
{ code: '1h', label: '近1小时' },
|
||||
{ code: '12h', label: '近12小时' },
|
||||
{ code: '24h', label: '近24小时' },
|
||||
{ code: '7d', label: '近7天' },
|
||||
]
|
||||
|
||||
function emptyBusiness() {
|
||||
return {
|
||||
range: null,
|
||||
kpis: {
|
||||
question_count: 0,
|
||||
active_user_count: 0,
|
||||
prev_active_user_count: 0,
|
||||
user_delta_pct: null,
|
||||
avg_questions_per_user: 0,
|
||||
avg_latency_ms: 0,
|
||||
success_count: 0,
|
||||
success_ratio: 0,
|
||||
fail_count: 0,
|
||||
fail_ratio: 0,
|
||||
},
|
||||
trend: [],
|
||||
top_questions: [],
|
||||
}
|
||||
}
|
||||
|
||||
function emptyResources() {
|
||||
return {
|
||||
range: null,
|
||||
kpis: {
|
||||
document_count: 0,
|
||||
total_tokens: 0,
|
||||
total_pu: 0,
|
||||
api_call_count: 0,
|
||||
api_success_ratio: 0,
|
||||
avg_api_latency_ms: 0,
|
||||
},
|
||||
token_trend: [],
|
||||
top_documents: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function useAnalytics() {
|
||||
const business = ref(emptyBusiness())
|
||||
const resources = ref(emptyResources())
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function loadBusiness(rangeCode) {
|
||||
business.value = await request(`/api/stats/dashboard/business?range=${encodeURIComponent(rangeCode)}`)
|
||||
}
|
||||
|
||||
async function loadResources(rangeCode) {
|
||||
resources.value = await request(`/api/stats/dashboard/resources?range=${encodeURIComponent(rangeCode)}`)
|
||||
}
|
||||
|
||||
async function loadAll(rangeCode) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await Promise.all([loadBusiness(rangeCode), loadResources(rangeCode)])
|
||||
} catch (err) {
|
||||
error.value = err?.message || String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { business, resources, loading, error, loadAll, loadBusiness, loadResources }
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ const routes = [
|
|||
{
|
||||
path: '/analytics',
|
||||
name: 'analytics',
|
||||
component: () => import('@/views/PlaceholderView.vue'),
|
||||
component: () => import('@/views/AnalyticsView.vue'),
|
||||
meta: {
|
||||
title: '数据分析',
|
||||
subtitle: '使用量、热门问题、命中率等数据看板。',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { RANGE_OPTIONS, useAnalytics } from '@/composables/useAnalytics'
|
||||
import StatCard from '@/components/analytics/StatCard.vue'
|
||||
import TrendChart from '@/components/analytics/TrendChart.vue'
|
||||
import RankBarList from '@/components/analytics/RankBarList.vue'
|
||||
|
||||
const store = useAppStore()
|
||||
const { business, resources, loading, error, loadAll } = useAnalytics()
|
||||
|
||||
const activeTab = ref('business')
|
||||
const activeRange = ref('24h')
|
||||
|
||||
const businessSeries = [
|
||||
{ field: 'asked', name: '问答次数', color: '#126854' },
|
||||
{ field: 'success', name: '成功次数', color: '#4c9f8a' },
|
||||
{ field: 'failed', name: '失败次数', color: '#b64a45' },
|
||||
]
|
||||
|
||||
const tokenSeries = [
|
||||
{ field: 'tokens', name: 'Tokens 消耗', color: '#1c7f9e' },
|
||||
{ field: 'pu', name: 'PU 消耗', color: '#b56a1a' },
|
||||
]
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
await loadAll(activeRange.value)
|
||||
} catch (err) {
|
||||
store.toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function pickRange(code) {
|
||||
if (activeRange.value === code) return
|
||||
activeRange.value = code
|
||||
reload()
|
||||
}
|
||||
|
||||
const businessKpis = computed(() => business.value.kpis)
|
||||
const resourceKpis = computed(() => resources.value.kpis)
|
||||
const rangeLabel = computed(() =>
|
||||
RANGE_OPTIONS.find((r) => r.code === activeRange.value)?.label || activeRange.value,
|
||||
)
|
||||
|
||||
const trendKpis = computed(() => ({
|
||||
asked: businessKpis.value.question_count,
|
||||
success: businessKpis.value.success_count,
|
||||
successPct: businessKpis.value.success_ratio,
|
||||
failed: businessKpis.value.fail_count,
|
||||
failedPct: businessKpis.value.fail_ratio,
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
if (store.token) reload()
|
||||
})
|
||||
|
||||
watch(() => store.token, (val) => {
|
||||
if (val) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="view is-visible analytics-view">
|
||||
<div class="analytics-header">
|
||||
<div>
|
||||
<h2>数据报表</h2>
|
||||
<p>基于问答日志和 ADP 调用留痕的实时业务与资源指标。</p>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'business' }"
|
||||
@click="activeTab = 'business'"
|
||||
>业务看板</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'resources' }"
|
||||
@click="activeTab = 'resources'"
|
||||
>资源看板</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="range-group">
|
||||
<button
|
||||
v-for="opt in RANGE_OPTIONS"
|
||||
:key="opt.code"
|
||||
type="button"
|
||||
class="range-btn"
|
||||
:class="{ active: activeRange === opt.code }"
|
||||
@click="pickRange(opt.code)"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span v-if="loading" class="loading-tag">加载中…</span>
|
||||
<span v-else-if="error" class="error-tag" :title="error">加载失败</span>
|
||||
<button type="button" class="icon-btn" title="刷新" @click="reload">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-body">
|
||||
<template v-if="activeTab === 'business'">
|
||||
<div class="kpi-row">
|
||||
<StatCard
|
||||
label="问答总次数"
|
||||
:value="businessKpis.question_count"
|
||||
hint="当前时间窗口内的用户提问总数"
|
||||
:subs="[
|
||||
{ label: '成功', value: businessKpis.success_count },
|
||||
{ label: '失败', value: businessKpis.fail_count },
|
||||
]"
|
||||
/>
|
||||
<StatCard
|
||||
label="活跃用户数"
|
||||
:value="businessKpis.active_user_count"
|
||||
:trend="businessKpis.user_delta_pct"
|
||||
hint="至少提出过一次问答的独立用户数"
|
||||
:subs="[
|
||||
{ label: '人均问答次数', value: businessKpis.avg_questions_per_user },
|
||||
{ label: '上一时段', value: businessKpis.prev_active_user_count },
|
||||
]"
|
||||
/>
|
||||
<StatCard
|
||||
label="平均响应时长"
|
||||
:value="businessKpis.avg_latency_ms"
|
||||
unit="ms"
|
||||
hint="流式回答从建立连接到收尾入库的总耗时均值"
|
||||
:subs="[
|
||||
{ label: '成功率', value: businessKpis.success_ratio, unit: '%' },
|
||||
{ label: '失败率', value: businessKpis.fail_ratio, unit: '%' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>问答趋势</h3>
|
||||
<div class="panel-subkpis">
|
||||
<div><span>问答次数</span><strong>{{ trendKpis.asked }}</strong></div>
|
||||
<div><span>成功次数/占比</span><strong>{{ trendKpis.success }}<em>/{{ trendKpis.successPct }}%</em></strong></div>
|
||||
<div><span>失败次数/占比</span><strong>{{ trendKpis.failed }}<em>/{{ trendKpis.failedPct }}%</em></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<TrendChart
|
||||
:buckets="business.trend"
|
||||
:series="businessSeries"
|
||||
:height="280"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>高频提问 Top10</h3>
|
||||
<span class="panel-sub">{{ rangeLabel }} · 按问题原文聚合</span>
|
||||
</div>
|
||||
<RankBarList
|
||||
:items="business.top_questions"
|
||||
label-key="question"
|
||||
value-key="count"
|
||||
color="#126854"
|
||||
empty-text="当前时段暂无问答记录"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="kpi-row kpi-row--four">
|
||||
<StatCard
|
||||
label="知识文档总数"
|
||||
:value="resourceKpis.document_count"
|
||||
hint="未删除的制度/手册/FAQ 文档"
|
||||
/>
|
||||
<StatCard
|
||||
label="累计 Tokens"
|
||||
:value="resourceKpis.total_tokens"
|
||||
hint="当前时间窗口内消耗的 tokens 总量"
|
||||
/>
|
||||
<StatCard
|
||||
label="累计 PU"
|
||||
:value="resourceKpis.total_pu"
|
||||
hint="当前时间窗口内消耗的 PU (Processing Unit)"
|
||||
/>
|
||||
<StatCard
|
||||
label="API 调用次数"
|
||||
:value="resourceKpis.api_call_count"
|
||||
hint="Tencent ADP 等外部接口调用总次数"
|
||||
:subs="[
|
||||
{ label: '成功率', value: resourceKpis.api_success_ratio, unit: '%' },
|
||||
{ label: '平均耗时', value: resourceKpis.avg_api_latency_ms, unit: 'ms' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Tokens / PU 消耗趋势</h3>
|
||||
<span class="panel-sub">{{ rangeLabel }}</span>
|
||||
</div>
|
||||
<TrendChart
|
||||
:buckets="resources.token_trend"
|
||||
:series="tokenSeries"
|
||||
:height="260"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>热门引用文档 Top10</h3>
|
||||
<span class="panel-sub">按被引用次数排序</span>
|
||||
</div>
|
||||
<RankBarList
|
||||
:items="resources.top_documents"
|
||||
label-key="title"
|
||||
value-key="count"
|
||||
color="#1c7f9e"
|
||||
empty-text="当前时段暂无引用记录"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.analytics-view {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.analytics-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.analytics-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.analytics-header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
.tab + .tab {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.tab.active + .tab,
|
||||
.tab:has(+ .tab.active) {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.range-group {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.range-btn {
|
||||
padding: 6px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.range-btn.active {
|
||||
color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
.toolbar-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.loading-tag,
|
||||
.error-tag {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.error-tag { color: var(--danger); }
|
||||
.icon-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.icon-btn:hover { color: var(--brand); border-color: var(--brand); }
|
||||
|
||||
.analytics-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.kpi-row--four {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.kpi-row,
|
||||
.kpi-row--four {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.kpi-row,
|
||||
.kpi-row--four {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 4px 16px rgba(20, 33, 29, 0.04);
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.panel-head h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.panel-sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.panel-subkpis {
|
||||
display: inline-flex;
|
||||
gap: 24px;
|
||||
}
|
||||
.panel-subkpis > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
.panel-subkpis span {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.panel-subkpis strong {
|
||||
font-size: 18px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.panel-subkpis em {
|
||||
margin-left: 2px;
|
||||
color: var(--muted);
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue