From fbafe693b62289b9ea072530f58d67877fce83c3 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 21:44:58 +0800 Subject: [PATCH] feat: add admin dashboard data adapter --- admin-web/src/services/dashboard.ts | 169 ++++++++++++++++++++ admin-web/src/types/dashboard.ts | 61 +++++++ ui-tests/admin-dashboard-data-contracts.mjs | 26 +++ 3 files changed, 256 insertions(+) create mode 100644 admin-web/src/services/dashboard.ts create mode 100644 admin-web/src/types/dashboard.ts create mode 100644 ui-tests/admin-dashboard-data-contracts.mjs diff --git a/admin-web/src/services/dashboard.ts b/admin-web/src/services/dashboard.ts new file mode 100644 index 0000000..bd639ba --- /dev/null +++ b/admin-web/src/services/dashboard.ts @@ -0,0 +1,169 @@ +import client from '@/api/client' +import type { + DashboardActivity, + DashboardEffectivenessItem, + DashboardMetric, + DashboardModel, + DashboardOverviewItem, + DashboardTodo, +} from '@/types/dashboard' + +type JsonRecord = Record + +interface ApiEnvelope { + success: boolean + data: T + message?: string +} + +const REQUEST_TIMEOUT = 8000 + +const numberOf = (source: JsonRecord | undefined, key: string) => { + const value = source?.[key] + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +const recordOf = (source: JsonRecord | undefined, key: string): JsonRecord => { + const value = source?.[key] + return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {} +} + +const dataOf = (response: unknown): T | null => { + const envelope = response as ApiEnvelope | null + return envelope?.success && envelope.data != null ? envelope.data : null +} + +const fulfilledData = (result: PromiseSettledResult): T | null => + result.status === 'fulfilled' && result.value ? dataOf(result.value) : null + +const emptyMetric = ( + key: string, + label: string, + tone: DashboardMetric['tone'], + route: string, +): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, route }) + +export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel { + return { + metrics: [ + emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'), + emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/referral'), + emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'), + emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'), + emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'), + emptyMetric('activity_registrations', '活动报名', 'red', '/activities/interests'), + ], + todos: [], + activities: [], + serviceOverview: [], + effectiveness: [], + activitiesAvailable: false, + notice, + } +} + +export async function loadDashboard(permissions: string[]): Promise { + const canViewAnalytics = permissions.includes('analytics.view') + const canViewActivities = permissions.includes('activity.view') + + const [coreResult, serviceResult, activityResult] = await Promise.allSettled([ + canViewAnalytics + ? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT }) + : Promise.resolve(null), + canViewAnalytics + ? client.get('/operator/analytics/v11-dashboard', { timeout: REQUEST_TIMEOUT }) + : Promise.resolve(null), + canViewActivities + ? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT }) + : Promise.resolve(null), + ]) + + const core = fulfilledData(coreResult) + const service = fulfilledData(serviceResult) + const activityRows = fulfilledData(activityResult) + const analyticsAvailable = Boolean(core || service) + + const eligibility = recordOf(service || undefined, 'localEligibility') + const counseling = recordOf(service || undefined, 'counseling') + const appointments = recordOf(service || undefined, 'serviceAppointments') + const effectiveConnections = recordOf(service || undefined, 'effectiveConnections') + const registrationSources = recordOf(service || undefined, 'registrationSources') + const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers') + + const metrics: DashboardMetric[] = [ + { + key: 'pending_reviews', label: '待审核资料', value: numberOf(eligibility, 'pending'), + trendText: service ? '本地资格待核验' : '暂无授权数据', tone: 'rose', available: Boolean(service), route: '/review', + }, + { + key: 'matchmaker_todos', label: '红娘服务待办', value: 0, + trendText: '当前接口未提供待办数', tone: 'amber', available: false, route: '/referral', + }, + { + key: 'counseling_todos', label: '咨询服务待办', + value: numberOf(counseling, 'pending') + numberOf(counseling, 'assigned'), + trendText: service ? '待受理与已分配' : '暂无授权数据', tone: 'blue', available: Boolean(service), route: '/counseling', + }, + { + key: 'station_appointments', label: '服务站预约', + value: numberOf(appointments, 'submitted') + numberOf(appointments, 'confirmed'), + trendText: service ? '待确认与已确认' : '暂无授权数据', tone: 'green', available: Boolean(service), route: '/service-appointment', + }, + { + key: 'effective_matches', label: '有效匹配', value: numberOf(effectiveConnections, 'activeMatches'), + trendText: service ? '当前活跃匹配' : '暂无授权数据', tone: 'purple', available: Boolean(service), route: '/analytics', + }, + { + key: 'activity_registrations', label: '活动报名', value: numberOf(registrationSources, 'totalRegistrations'), + trendText: service ? '累计有效报名' : '暂无授权数据', tone: 'red', available: Boolean(service), route: '/activities/interests', + }, + ] + + const todos = ([ + { key: 'review', title: '资料审核', description: '本地资格材料等待核验', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available }, + { key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metrics[2].value, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metrics[2].available }, + { key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metrics[3].value, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metrics[3].available }, + { key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) }, + ] as DashboardTodo[]).filter((item) => item.available) + + const activities: DashboardActivity[] = (activityRows || []).slice(0, 4).map((item) => ({ + id: String(item.id || ''), + title: String(item.title || '未命名活动'), + coverUrl: String(item.coverUrl || ''), + startAt: String(item.startAt || ''), + locationName: String(item.locationName || '地点待确认'), + registered: typeof item.registered === 'number' ? item.registered : null, + capacity: typeof item.totalLimit === 'number' ? item.totalLimit : null, + statusText: String(item.status || '状态待确认'), + })) + + const serviceOverview = ([ + { key: 'registered', label: '平台注册用户', value: numberOf(core || undefined, 'totalUsers'), compareText: '累计注册', tone: 'rose', available: Boolean(core) }, + { key: 'approved', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), compareText: '已通过资料', tone: 'blue', available: Boolean(core) }, + { key: 'served', label: '公益服务用户', value: numberOf(uniqueUsers, 'totalUniqueServiceUsers'), compareText: '去重服务人数', tone: 'green', available: Boolean(service) }, + { key: 'success', label: '成功牵手', value: numberOf(core || undefined, 'successPairs'), compareText: '双方确认成功', tone: 'amber', available: Boolean(core) }, + ] as DashboardOverviewItem[]).filter((item) => item.available) + + const effectiveness = ([ + { key: 'profile_review', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), max: numberOf(core || undefined, 'totalUsers'), unit: '人', available: Boolean(core) }, + { key: 'successful_match', label: '成功匹配用户', value: numberOf(core || undefined, 'totalMatches'), max: numberOf(core || undefined, 'totalLikes'), unit: '对', available: Boolean(core) }, + { key: 'counseling_completed', label: '咨询服务完成', value: numberOf(counseling, 'completed'), max: numberOf(counseling, 'total'), unit: '人次', available: Boolean(service) }, + { key: 'activity_participation', label: '活动参与人数', value: numberOf(registrationSources, 'totalRegistrations'), max: 0, unit: '人次', available: Boolean(service) }, + { key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) }, + ] as DashboardEffectivenessItem[]).filter((item) => item.available) + + const rejectedCount = [coreResult, serviceResult, activityResult].filter((item) => item.status === 'rejected').length + const notice = !analyticsAvailable && !activityRows + ? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限') + : (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '') + + return { + metrics, + todos, + activities, + serviceOverview, + effectiveness, + activitiesAvailable: Boolean(activityRows), + notice, + } +} diff --git a/admin-web/src/types/dashboard.ts b/admin-web/src/types/dashboard.ts new file mode 100644 index 0000000..d632b60 --- /dev/null +++ b/admin-web/src/types/dashboard.ts @@ -0,0 +1,61 @@ +export type DashboardTone = 'rose' | 'amber' | 'blue' | 'green' | 'purple' | 'red' + +export interface DashboardMetric { + key: string + label: string + value: number + trendText: string + tone: DashboardTone + available: boolean + route: string +} + +export interface DashboardTodo { + key: string + title: string + description: string + count: number + actionText: string + route: string + tone: DashboardTone + available: boolean +} + +export interface DashboardActivity { + id: string + title: string + coverUrl: string + startAt: string + locationName: string + registered: number | null + capacity: number | null + statusText: string +} + +export interface DashboardOverviewItem { + key: string + label: string + value: number + compareText: string + tone: DashboardTone + available: boolean +} + +export interface DashboardEffectivenessItem { + key: string + label: string + value: number + max: number + unit: string + available: boolean +} + +export interface DashboardModel { + metrics: DashboardMetric[] + todos: DashboardTodo[] + activities: DashboardActivity[] + serviceOverview: DashboardOverviewItem[] + effectiveness: DashboardEffectivenessItem[] + activitiesAvailable: boolean + notice: string +} diff --git a/ui-tests/admin-dashboard-data-contracts.mjs b/ui-tests/admin-dashboard-data-contracts.mjs new file mode 100644 index 0000000..acf9ff3 --- /dev/null +++ b/ui-tests/admin-dashboard-data-contracts.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const servicePath = resolve(root, 'admin-web/src/services/dashboard.ts') +const typePath = resolve(root, 'admin-web/src/types/dashboard.ts') + +assert.ok(existsSync(servicePath), '缺少工作台数据适配层') +assert.ok(existsSync(typePath), '缺少工作台数据模型') + +const service = readFileSync(servicePath, 'utf8') +const types = readFileSync(typePath, 'utf8') + +assert.match(service, /export async function loadDashboard/, '必须导出 loadDashboard') +assert.match(service, /const REQUEST_TIMEOUT = \d+/, '工作台必须定义明确的请求超时') +assert.match(service, /timeout:\s*REQUEST_TIMEOUT/, '工作台请求必须使用超时配置') +assert.match(service, /Promise\.allSettled/, '多来源工作台数据必须隔离局部接口失败') +assert.doesNotMatch(service, /\|\|\s*(3|6|8|9|12|18|23|28|86)\b/, '不得用参考图演示数字兜底') + +for (const field of ['metrics', 'todos', 'activities', 'serviceOverview', 'effectiveness']) { + assert.ok(types.includes(field), `DashboardModel 缺少 ${field}`) +} +assert.match(types, /available:\s*boolean/, '工作台模块必须显式标识数据是否可用') + +console.log('Admin dashboard data contracts passed')