feat: revamp analytics dashboards and harden database migrations

Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend.

Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users.

Include the current documentation cleanup and regression coverage.
This commit is contained in:
elky
2026-10-01 11:48:17 +08:00
parent 60b89cc840
commit 066ea87d72
327 changed files with 31728 additions and 20645 deletions
@@ -43,70 +43,26 @@
</Button>
</div>
<div
v-if="loadingEntitlements"
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
<UserPlanList
:entitlements="entitlements"
:loading="loadingEntitlements"
:format-date-time="formatDateTime"
:entitlement-labels="entitlementLabels"
:empty-text="legacyT('当前没有有效套餐')"
>
{{ legacyT('正在加载用户套餐...') }}
</div>
<div
v-else-if="entitlements.length === 0"
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
>
{{ legacyT('当前没有有效套餐') }}
</div>
<div
v-else
class="space-y-2.5"
>
<div
v-for="item in entitlements"
:key="item.id"
class="rounded-lg border border-border bg-card/80 p-3"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-foreground">
{{ item.plan_title || item.plan?.title || item.plan_id }}
</span>
<Badge
:variant="item.active ? 'success' : 'secondary'"
class="h-5 px-1.5 py-0 text-[10px]"
>
{{ item.active ? legacyT('生效中') : item.status }}
</Badge>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<Badge
v-for="(label, index) in entitlementLabels(item.entitlements)"
:key="`${label}-${index}`"
variant="outline"
class="h-5 px-1.5 py-0 text-[10px]"
>
{{ label }}
</Badge>
</div>
</div>
<div class="flex shrink-0 items-start gap-2">
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
<div>{{ legacyT('开始:') }}{{ formatDateTime(item.starts_at) }}</div>
<div>{{ legacyT('到期:') }}{{ formatDateTime(item.expires_at) }}</div>
</div>
<Button
v-if="item.active"
variant="destructive"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="revokingEntitlementId === item.id"
@click="$emit('revoke', item)"
>
{{ revokingEntitlementId === item.id ? legacyT('撤销中...') : legacyT('撤销套餐') }}
</Button>
</div>
</div>
</div>
</div>
<template #actions="{ item }">
<Button
v-if="item.active"
variant="destructive"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="revokingEntitlementId === item.id"
@click="$emit('revoke', item)"
>
{{ revokingEntitlementId === item.id ? legacyT('撤销中...') : legacyT('撤销套餐') }}
</Button>
</template>
</UserPlanList>
</section>
<section class="space-y-3 rounded-lg border border-border bg-card/70 p-4">
@@ -185,8 +141,8 @@
<script setup lang="ts">
import { PackageCheck } from 'lucide-vue-next'
import UserPlanList from './UserPlanList.vue'
import {
Badge,
Button,
Dialog,
Select,
@@ -0,0 +1,97 @@
<template>
<div
v-if="loading"
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
role="status"
>
{{ legacyT('正在加载用户套餐...') }}
</div>
<div
v-else-if="!entitlements.length"
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
>
{{ emptyText || text('暂无套餐记录', 'No plan records') }}
</div>
<div
v-else
class="space-y-2.5"
>
<div
v-for="item in entitlements"
:key="item.id"
class="rounded-lg border border-border bg-card/80 p-3"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="break-words font-medium text-foreground">{{ item.plan_title || item.plan?.title || item.plan_id }}</span>
<Badge
:variant="item.active ? 'success' : 'secondary'"
class="h-5 px-1.5 py-0 text-[10px]"
>
{{ statusLabel(item) }}
</Badge>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<Badge
v-for="(label, index) in labels(item.entitlements)"
:key="`${label}-${index}`"
variant="outline"
class="h-5 px-1.5 py-0 text-[10px]"
>
{{ label }}
</Badge>
</div>
</div>
<div class="flex shrink-0 items-start gap-2">
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
<div>{{ legacyT('开始:') }}{{ dateTime(item.starts_at) }}</div>
<div>{{ legacyT('到期:') }}{{ dateTime(item.expires_at) }}</div>
<div v-if="showUpdatedAt">
{{ text('更新:', 'Updated: ') }}{{ dateTime(item.updated_at || item.created_at) }}
</div>
</div>
<slot
name="actions"
:item="item"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Badge } from '@/components/ui'
import { useI18n } from '@/i18n'
import type { AdminUserPlanEntitlement } from '@/api/users'
import type { BillingEntitlement } from '@/api/billing'
import { billingEntitlementLabels } from '@/utils/billingEntitlements'
const props = defineProps<{
entitlements: AdminUserPlanEntitlement[]
loading?: boolean
emptyText?: string
showUpdatedAt?: boolean
timezone?: string
formatDateTime?: (value?: string | null) => string
entitlementLabels?: (items: BillingEntitlement[] | undefined) => string[]
}>()
const { locale, legacyT } = useI18n()
const text = (zh: string, en: string) => locale.value === 'zh-CN' ? zh : en
const labels = (items: BillingEntitlement[]) => props.entitlementLabels?.(items) ?? billingEntitlementLabels(items, legacyT)
function dateTime(value?: string | null) {
if (props.formatDateTime) return props.formatDateTime(value)
return value ? new Date(value).toLocaleString(locale.value, {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: props.timezone,
}) : '-'
}
function statusLabel(item: AdminUserPlanEntitlement) {
if (item.active) return text('生效中', 'Active')
if (item.status === 'revoked') return text('已撤销', 'Revoked')
if (item.status === 'replaced') return text('已替换', 'Replaced')
if (item.status === 'expired' || (item.status === 'active' && item.expires_at && Date.parse(item.expires_at) <= Date.now())) return text('已到期', 'Expired')
if (item.status === 'active' && item.starts_at && Date.parse(item.starts_at) > Date.now()) return text('待生效', 'Scheduled')
return item.status
}
</script>