feat:数据分析页面展示数据报表内容

This commit is contained in:
shijing 2026-07-15 13:16:28 +08:00
parent 4a7eb906d1
commit 2c9da6bf6d
6 changed files with 1002 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -42,7 +42,7 @@ const routes = [
{ {
path: '/analytics', path: '/analytics',
name: 'analytics', name: 'analytics',
component: () => import('@/views/PlaceholderView.vue'), component: () => import('@/views/AnalyticsView.vue'),
meta: { meta: {
title: '数据分析', title: '数据分析',
subtitle: '使用量、热门问题、命中率等数据看板。', subtitle: '使用量、热门问题、命中率等数据看板。',

View File

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