Files
geo/apps/admin-web/src/views/KolDashboardView.vue
T
root 162abdc97c
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Failing after 1m39s
chore(frontend): introduce prettier + eslint and prune unused code
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports)
- Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier
- Add root scripts: format, format:check, lint, lint:fix
- Reformat 257 files across admin-web, ops-web, desktop-client, packages
- Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters
- Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex
- Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:39:09 +08:00

602 lines
16 KiB
Vue

<script setup lang="ts">
import { kolDashboardApi } from '@/lib/api'
import {
CloseCircleOutlined,
FileDoneOutlined,
ReloadOutlined,
TeamOutlined,
UserAddOutlined,
} from '@ant-design/icons-vue'
import type { KolDashboardPackageStat } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import { useElementSize } from '@vueuse/core'
import type { TableColumnsType } from 'ant-design-vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// Filter for trend chart
const periodDays = ref(7)
// Queries
const overviewQuery = useQuery({
queryKey: ['kol', 'dashboard', 'overview'],
queryFn: () => kolDashboardApi.overview(),
})
const packagesQuery = useQuery({
queryKey: ['kol', 'dashboard', 'packages'],
queryFn: () => kolDashboardApi.packages(),
})
const trendQuery = useQuery({
queryKey: ['kol', 'dashboard', 'trend', periodDays],
queryFn: () => kolDashboardApi.trend(periodDays.value),
})
// Stats formatting
const stats = computed(() => {
const data = overviewQuery.data.value
return [
{
title: t('kol.dashboard.totalSubs'),
value: data?.total_subscriber_tenants ?? 0,
icon: TeamOutlined,
color: '#1890ff',
},
{
title: t('kol.dashboard.monthSubs'),
value: data?.month_new_subscriptions ?? 0,
icon: UserAddOutlined,
color: '#52c41a',
},
{
title: t('kol.dashboard.totalUsage'),
value: data?.total_usage ?? 0,
icon: FileDoneOutlined,
color: '#722ed1',
},
{
title: t('kol.dashboard.failRate'),
value: data ? `${(data.failure_rate * 100).toFixed(2)}%` : '0%',
icon: CloseCircleOutlined,
color: '#f5222d',
},
]
})
// Table columns
const packageColumns: TableColumnsType<KolDashboardPackageStat> = [
{
title: t('common.name'),
dataIndex: 'package_name',
key: 'package_name',
},
{
title: t('kol.dashboard.totalSubs'),
dataIndex: 'subscriber_tenants',
key: 'subscriber_tenants',
align: 'right',
},
{
title: t('kol.dashboard.totalUsage'),
dataIndex: 'usage_count',
key: 'usage_count',
align: 'right',
},
{
title: t('kol.dashboard.failRate'),
dataIndex: 'failure_rate',
key: 'failure_rate',
align: 'right',
},
]
// SVG Chart logic
const chartWrapperRef = ref<HTMLElement | null>(null)
const { width: wrapperWidth, height: wrapperHeight } = useElementSize(chartWrapperRef, {
width: 1000,
height: 300,
})
const chartPadding = { top: 20, right: 40, bottom: 40, left: 60 }
const chartViewBox = computed(() => {
const w = Math.max(wrapperWidth.value, 1)
const h = Math.max(wrapperHeight.value, 1)
return `0 0 ${w} ${h}`
})
const chartPoints = computed(() => {
const points = trendQuery.data.value || []
if (points.length < 2) return []
const maxVal =
Math.max(
...points.map((p) => Math.max(p.subscriptions, p.usages)),
1, // avoid division by zero
) * 1.1 // 10% headroom
const w = Math.max(wrapperWidth.value, 1)
const h = Math.max(wrapperHeight.value, 1)
const cWidth = Math.max(w - chartPadding.left - chartPadding.right, 1)
const cHeight = Math.max(h - chartPadding.top - chartPadding.bottom, 1)
const getX = (index: number) => chartPadding.left + (index / (points.length - 1)) * cWidth
const getY = (val: number) => chartPadding.top + cHeight - (val / maxVal) * cHeight
return points.map((p, i) => {
const x = getX(i)
let hoverX = 0
let hoverWidth = 0
if (i === 0) {
hoverX = chartPadding.left
hoverWidth = (getX(1) - x) / 2
} else if (i === points.length - 1) {
const prevX = getX(i - 1)
hoverX = prevX + (x - prevX) / 2
hoverWidth = (x - prevX) / 2
} else {
const prevX = getX(i - 1)
const nextX = getX(i + 1)
hoverX = prevX + (x - prevX) / 2
hoverWidth = (x - prevX) / 2 + (nextX - x) / 2
}
return {
...p,
x,
subY: getY(p.subscriptions),
useY: getY(p.usages),
hoverX,
hoverWidth,
}
})
})
const chartPaths = computed(() => {
const points = chartPoints.value
if (points.length < 2) return { subscriptions: '', usages: '' }
let subPath = `M ${points[0].x} ${points[0].subY}`
let usagePath = `M ${points[0].x} ${points[0].useY}`
for (let i = 1; i < points.length; i++) {
subPath += ` L ${points[i].x} ${points[i].subY}`
usagePath += ` L ${points[i].x} ${points[i].useY}`
}
return { subscriptions: subPath, usages: usagePath }
})
const chartAxis = computed(() => {
const points = chartPoints.value
if (points.length === 0) return []
// Show at most 7 date labels
const step = Math.max(1, Math.floor(points.length / 6))
const labels = []
for (let i = 0; i < points.length; i += step) {
labels.push({
x: points[i].x,
label: points[i].date.split('-').slice(1).join('/'), // MM/DD
})
if (i + step >= points.length && i !== points.length - 1) {
labels.push({
x: points[points.length - 1].x,
label: points[points.length - 1].date.split('-').slice(1).join('/'),
})
}
}
return labels
})
const activePoint = ref<any>(null)
function showTooltip(point: any) {
activePoint.value = point
}
function hideTooltip() {
activePoint.value = null
}
function refresh() {
void overviewQuery.refetch()
void packagesQuery.refetch()
void trendQuery.refetch()
}
</script>
<template>
<div class="kol-dashboard-view">
<section class="page-title-card">
<div class="page-title-card__header">
<div class="page-title-card__copy">
<h2>{{ t('kol.dashboard.title') }}</h2>
<p>{{ t('kol.dashboard.subtitle') }}</p>
</div>
<div class="page-title-card__actions">
<a-button @click="refresh">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
</div>
</section>
<!-- Stats Row -->
<div class="stats-row">
<template v-if="overviewQuery.isPending.value">
<a-card v-for="i in 4" :key="i" class="stat-card shadow-sm">
<a-skeleton active :paragraph="{ rows: 1 }" />
</a-card>
</template>
<template v-else>
<a-card v-for="stat in stats" :key="stat.title" class="stat-card shadow-sm">
<div class="stat-content">
<div class="stat-icon" :style="{ background: stat.color + '15', color: stat.color }">
<component :is="stat.icon" />
</div>
<div class="stat-info">
<span class="stat-label">{{ stat.title }}</span>
<strong class="stat-value">{{ stat.value }}</strong>
</div>
</div>
</a-card>
</template>
</div>
<div class="dashboard-grid">
<!-- Package Table -->
<section class="panel panel-packages">
<h3 class="panel-title">{{ t('kol.dashboard.byPackage') }}</h3>
<a-table
:columns="packageColumns"
:data-source="packagesQuery.data.value || []"
:loading="packagesQuery.isPending.value"
:pagination="false"
row-key="package_id"
size="middle"
>
<template #emptyText>
<a-empty :description="t('kol.marketplace.empty')" />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'failure_rate'">
<span :style="{ color: record.failure_rate > 0.1 ? '#f5222d' : 'inherit' }">
{{ (record.failure_rate * 100).toFixed(2) }}%
</span>
</template>
</template>
</a-table>
</section>
<!-- Trend Chart -->
<section class="panel panel-trend">
<div class="panel-header">
<h3 class="panel-title">{{ t('kol.dashboard.trend') }}</h3>
<a-radio-group v-model:value="periodDays" size="small" button-style="solid">
<a-radio-button :value="7">7</a-radio-button>
<a-radio-button :value="30">30</a-radio-button>
<a-radio-button :value="90">90</a-radio-button>
</a-radio-group>
</div>
<div v-if="!trendQuery.isPending.value" class="chart-container">
<div
v-if="chartPoints.length > 0"
ref="chartWrapperRef"
class="svg-chart-wrapper"
@mouseleave="hideTooltip"
>
<div class="chart-legend">
<div class="legend-item">
<span class="dot sub"></span>
订阅
</div>
<div class="legend-item">
<span class="dot usage"></span>
使用
</div>
</div>
<svg :viewBox="chartViewBox" class="svg-chart" preserveAspectRatio="none">
<!-- Grid lines -->
<line
v-for="i in 5"
:key="i"
:x1="chartPadding.left"
:y1="
chartPadding.top +
(Math.max(wrapperHeight - chartPadding.top - chartPadding.bottom, 1) * (i - 1)) /
4
"
:x2="
chartPadding.left +
Math.max(wrapperWidth - chartPadding.left - chartPadding.right, 1)
"
:y2="
chartPadding.top +
(Math.max(wrapperHeight - chartPadding.top - chartPadding.bottom, 1) * (i - 1)) /
4
"
stroke="#f0f0f0"
stroke-width="1"
/>
<!-- Paths -->
<path
:d="chartPaths.subscriptions"
fill="none"
stroke="#1890ff"
stroke-width="2"
stroke-linejoin="round"
/>
<path
:d="chartPaths.usages"
fill="none"
stroke="#52c41a"
stroke-width="2"
stroke-linejoin="round"
/>
<!-- X Axis Labels -->
<text
v-for="tick in chartAxis"
:key="tick.x"
:x="tick.x"
:y="
chartPadding.top +
Math.max(wrapperHeight - chartPadding.top - chartPadding.bottom, 1) +
20
"
text-anchor="middle"
font-size="12"
fill="#8c8c8c"
>
{{ tick.label }}
</text>
<!-- Hover Interaction Areas -->
<rect
v-for="(point, i) in chartPoints"
:key="'hover-' + i"
:x="point.hoverX"
:y="chartPadding.top"
:width="point.hoverWidth"
:height="Math.max(wrapperHeight - chartPadding.top - chartPadding.bottom, 1)"
fill="transparent"
style="cursor: pointer"
@mouseenter="showTooltip(point)"
/>
<!-- Tooltip Overlay -->
<g v-if="activePoint">
<!-- Vertical dashed line -->
<line
:x1="activePoint.x"
:y1="chartPadding.top"
:x2="activePoint.x"
:y2="
chartPadding.top +
Math.max(wrapperHeight - chartPadding.top - chartPadding.bottom, 1)
"
stroke="#bfbfbf"
stroke-width="1"
stroke-dasharray="4 4"
/>
<!-- Dots on the line -->
<circle
:cx="activePoint.x"
:cy="activePoint.subY"
r="4"
fill="#1890ff"
stroke="#fff"
stroke-width="2"
/>
<circle
:cx="activePoint.x"
:cy="activePoint.useY"
r="4"
fill="#52c41a"
stroke="#fff"
stroke-width="2"
/>
<!-- Tooltip Panel -->
<g
:transform="`translate(${activePoint.x > wrapperWidth - 160 ? activePoint.x - 140 : activePoint.x + 15}, ${chartPadding.top + 20})`"
>
<rect
x="0"
y="0"
width="125"
height="75"
fill="#ffffff"
stroke="#e8e8e8"
rx="4"
style="filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.1))"
/>
<text x="12" y="22" font-size="12" font-weight="bold" fill="#8c8c8c">
{{ activePoint.date }}
</text>
<circle cx="16" cy="42" r="4" fill="#1890ff" />
<text x="28" y="46" font-size="12" fill="#1a1a1a">
订阅: {{ activePoint.subscriptions }}
</text>
<circle cx="16" cy="62" r="4" fill="#52c41a" />
<text x="28" y="66" font-size="12" fill="#1a1a1a">
使用: {{ activePoint.usages }}
</text>
</g>
</g>
</svg>
</div>
<a-empty v-else description="暂无趋势数据" />
</div>
<div v-else class="chart-skeleton">
<a-skeleton active :paragraph="{ rows: 6 }" />
</div>
</section>
</div>
</div>
</template>
<style scoped>
.kol-dashboard-view {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Stats Row */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.stat-card {
border-radius: 12px;
border: none;
}
.stat-content {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 14px;
color: #8c8c8c;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
line-height: 1;
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 1400px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
/* Panel */
.panel {
background: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
min-width: 0; /* Add to prevent grid/flex blowout */
}
.panel-title {
margin: 0 0 20px 0;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
display: flex;
align-items: center;
}
.panel-title::before {
content: '';
display: inline-block;
width: 4px;
height: 14px;
background: #1f5cff;
border-radius: 2px;
margin-right: 8px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.panel-header .panel-title {
margin-bottom: 0;
}
/* Chart */
.chart-container {
height: 320px;
display: flex;
flex-direction: column;
min-width: 0;
}
.svg-chart-wrapper {
flex: 1;
position: relative;
min-height: 0; /* Fix flex item height blowout */
}
.chart-legend {
display: flex;
justify-content: flex-end;
gap: 16px;
margin-bottom: 12px;
position: relative;
z-index: 10;
}
.legend-item {
font-size: 12px;
color: #8c8c8c;
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot.sub {
background: #1890ff;
}
.dot.usage {
background: #52c41a;
}
.svg-chart {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: visible;
z-index: 1;
}
.chart-skeleton {
padding: 20px;
}
/* Shadows */
.shadow-sm {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
</style>