|
|
|
@@ -0,0 +1,426 @@
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { ref, computed, watch } from "vue";
|
|
|
|
|
import { useQuery } from "@tanstack/vue-query";
|
|
|
|
|
import { useI18n } from "vue-i18n";
|
|
|
|
|
import {
|
|
|
|
|
TeamOutlined,
|
|
|
|
|
UserAddOutlined,
|
|
|
|
|
FileDoneOutlined,
|
|
|
|
|
CloseCircleOutlined,
|
|
|
|
|
ReloadOutlined
|
|
|
|
|
} from "@ant-design/icons-vue";
|
|
|
|
|
import type { TableColumnsType } from "ant-design-vue";
|
|
|
|
|
import type { KolDashboardPackageStat, KolDashboardTrendPoint } from "@geo/shared-types";
|
|
|
|
|
import { kolDashboardApi } from "@/lib/api";
|
|
|
|
|
|
|
|
|
|
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 chartViewBox = "0 0 1000 300";
|
|
|
|
|
const chartPadding = { top: 20, right: 40, bottom: 40, left: 60 };
|
|
|
|
|
const chartWidth = 1000 - chartPadding.left - chartPadding.right;
|
|
|
|
|
const chartHeight = 300 - chartPadding.top - chartPadding.bottom;
|
|
|
|
|
|
|
|
|
|
const chartPaths = computed(() => {
|
|
|
|
|
const points = trendQuery.data.value || [];
|
|
|
|
|
if (points.length < 2) return { subscriptions: "", usages: "" };
|
|
|
|
|
|
|
|
|
|
const maxVal = Math.max(
|
|
|
|
|
...points.map(p => Math.max(p.subscriptions, p.usages)),
|
|
|
|
|
1 // avoid division by zero
|
|
|
|
|
) * 1.1; // 10% headroom
|
|
|
|
|
|
|
|
|
|
const getX = (index: number) => chartPadding.left + (index / (points.length - 1)) * chartWidth;
|
|
|
|
|
const getY = (val: number) => chartPadding.top + chartHeight - (val / maxVal) * chartHeight;
|
|
|
|
|
|
|
|
|
|
let subPath = `M ${getX(0)} ${getY(points[0].subscriptions)}`;
|
|
|
|
|
let usagePath = `M ${getX(0)} ${getY(points[0].usages)}`;
|
|
|
|
|
|
|
|
|
|
for (let i = 1; i < points.length; i++) {
|
|
|
|
|
subPath += ` L ${getX(i)} ${getY(points[i].subscriptions)}`;
|
|
|
|
|
usagePath += ` L ${getX(i)} ${getY(points[i].usages)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { subscriptions: subPath, usages: usagePath };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const chartAxis = computed(() => {
|
|
|
|
|
const points = trendQuery.data.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: chartPadding.left + (i / (points.length - 1)) * chartWidth,
|
|
|
|
|
label: points[i].date.split("-").slice(1).join("/"), // MM/DD
|
|
|
|
|
});
|
|
|
|
|
if (i + step >= points.length && i !== points.length - 1) {
|
|
|
|
|
labels.push({
|
|
|
|
|
x: chartPadding.left + chartWidth,
|
|
|
|
|
label: points[points.length - 1].date.split("-").slice(1).join("/"),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return labels;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function refresh() {
|
|
|
|
|
void overviewQuery.refetch();
|
|
|
|
|
void packagesQuery.refetch();
|
|
|
|
|
void trendQuery.refetch();
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<div class="kol-dashboard-view">
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
<div class="header-info">
|
|
|
|
|
<h2 class="page-title">{{ t("kol.dashboard.title") }}</h2>
|
|
|
|
|
<p class="page-desc">实时查看订阅情况与生成数据统计</p>
|
|
|
|
|
</div>
|
|
|
|
|
<a-button @click="refresh">
|
|
|
|
|
<template #icon><ReloadOutlined /></template>
|
|
|
|
|
刷新
|
|
|
|
|
</a-button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 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 class="chart-container" v-if="!trendQuery.isPending.value">
|
|
|
|
|
<div v-if="trendQuery.data.value?.length" class="svg-chart-wrapper">
|
|
|
|
|
<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">
|
|
|
|
|
<!-- Grid lines -->
|
|
|
|
|
<line
|
|
|
|
|
v-for="i in 5" :key="i"
|
|
|
|
|
:x1="chartPadding.left"
|
|
|
|
|
:y1="chartPadding.top + (chartHeight * (i-1) / 4)"
|
|
|
|
|
:x2="chartPadding.left + chartWidth"
|
|
|
|
|
:y2="chartPadding.top + (chartHeight * (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 + chartHeight + 20"
|
|
|
|
|
text-anchor="middle"
|
|
|
|
|
font-size="12"
|
|
|
|
|
fill="#8c8c8c"
|
|
|
|
|
>
|
|
|
|
|
{{ tick.label }}
|
|
|
|
|
</text>
|
|
|
|
|
</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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.page-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
}
|
|
|
|
|
.page-title {
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
margin: 0 0 4px 0;
|
|
|
|
|
color: #1a1a1a;
|
|
|
|
|
}
|
|
|
|
|
.page-desc {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
color: #8c8c8c;
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 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);
|
|
|
|
|
}
|
|
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
.svg-chart-wrapper {
|
|
|
|
|
flex: 1;
|
|
|
|
|
position: relative;
|
|
|
|
|
}
|
|
|
|
|
.chart-legend {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
}
|
|
|
|
|
.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 {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
overflow: visible;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chart-skeleton {
|
|
|
|
|
padding: 20px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Shadows */
|
|
|
|
|
.shadow-sm {
|
|
|
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
|
|
|
|
}
|
|
|
|
|
</style>
|