feat: add admin dashboard data adapter
This commit is contained in:
@@ -0,0 +1,169 @@
|
|||||||
|
import client from '@/api/client'
|
||||||
|
import type {
|
||||||
|
DashboardActivity,
|
||||||
|
DashboardEffectivenessItem,
|
||||||
|
DashboardMetric,
|
||||||
|
DashboardModel,
|
||||||
|
DashboardOverviewItem,
|
||||||
|
DashboardTodo,
|
||||||
|
} from '@/types/dashboard'
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
interface ApiEnvelope<T> {
|
||||||
|
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 = <T>(response: unknown): T | null => {
|
||||||
|
const envelope = response as ApiEnvelope<T> | null
|
||||||
|
return envelope?.success && envelope.data != null ? envelope.data : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fulfilledData = <T>(result: PromiseSettledResult<unknown>): T | null =>
|
||||||
|
result.status === 'fulfilled' && result.value ? dataOf<T>(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<DashboardModel> {
|
||||||
|
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<JsonRecord>(coreResult)
|
||||||
|
const service = fulfilledData<JsonRecord>(serviceResult)
|
||||||
|
const activityRows = fulfilledData<JsonRecord[]>(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user