feat(dashboard): add boss dashboard H5 and APIs
Implement the mobile dashboard frontend, admin overview APIs, report archive export, and local dev proxy so the boss dashboard can run against real backend data while preserving MSW demos. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
import { Button, CapsuleTabs, DotLoading, ErrorBlock, Tag, Toast } from 'antd-mobile'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { MiniTrendChart } from '../../../components/charts/MiniTrendChart'
|
||||
import { KpiCard } from '../../../components/kpi/KpiCard'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import { downloadDailyReportArchive, useDashboardOverview } from '../api'
|
||||
import type { DashboardOverview, RiskLevel, SnapshotSlot, TodaySnapshot } from '../types'
|
||||
|
||||
const snapshotStatusMeta = {
|
||||
pending: { color: 'default', label: '待生成' },
|
||||
success: { color: 'success', label: '已生成' },
|
||||
failed: { color: 'danger', label: '失败' },
|
||||
temporary: { color: 'warning', label: '临时' },
|
||||
} as const
|
||||
|
||||
const riskLevelMeta: Record<RiskLevel, { color: 'danger' | 'warning' | 'default'; label: string }> = {
|
||||
red: { color: 'danger', label: '红色' },
|
||||
yellow: { color: 'warning', label: '黄色' },
|
||||
gray: { color: 'default', label: '灰色' },
|
||||
}
|
||||
|
||||
const snapshotSlotMeta: Record<
|
||||
SnapshotSlot,
|
||||
{
|
||||
title: string
|
||||
subtitle: string
|
||||
metricLabels: {
|
||||
primaryUsers: string
|
||||
primaryOrders: string
|
||||
amount: string
|
||||
paidAmount: string
|
||||
merchandise: string
|
||||
bonus: string
|
||||
}
|
||||
checklist: string[]
|
||||
}
|
||||
> = {
|
||||
'1015': {
|
||||
title: '上午抢购快报',
|
||||
subtitle: '用户集中抢购上一天用户寄卖的商品,重点看成交、付款和采购用户是否达标。',
|
||||
metricLabels: {
|
||||
primaryUsers: '抢购用户',
|
||||
primaryOrders: '抢购订单',
|
||||
amount: '抢购成交额',
|
||||
paidAmount: '已支付金额',
|
||||
merchandise: '成交商品',
|
||||
bonus: '相关奖金',
|
||||
},
|
||||
checklist: ['抢购成交额是否低于昨日同节点', '采购用户是否异常回落', '付款金额与成交额是否明显偏离', '高货值寄卖商品是否完成消化'],
|
||||
},
|
||||
'1455': {
|
||||
title: '下午寄卖/转卖快报',
|
||||
subtitle: '用户把上午抢到的商品继续寄卖或转卖,重点看新增寄售供给和奖金变化是否正常。',
|
||||
metricLabels: {
|
||||
primaryUsers: '寄卖用户',
|
||||
primaryOrders: '转卖订单',
|
||||
amount: '转卖成交额',
|
||||
paidAmount: '回款金额',
|
||||
merchandise: '新增寄售',
|
||||
bonus: '奖金变化',
|
||||
},
|
||||
checklist: ['抢购商品是否按预期转入寄卖', '新增寄售商品是否满足下午供给', '个人奖金与推广奖金是否同步变化', '转卖回款是否出现异常延迟'],
|
||||
},
|
||||
}
|
||||
|
||||
function QueryState({
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
title,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
refetch: () => void
|
||||
title: string
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="loading-page">
|
||||
<DotLoading color="primary" />
|
||||
<p>正在加载{title}...</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section className="error-page">
|
||||
<ErrorBlock status="default" title={`${title}加载失败`} description="后端接口暂不可用,请确认服务、登录态或接口权限后重试。" />
|
||||
<Button color="primary" onClick={refetch}>
|
||||
重新加载
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function OperationsHeader({
|
||||
kicker,
|
||||
title,
|
||||
description,
|
||||
extra,
|
||||
}: {
|
||||
kicker: string
|
||||
title: string
|
||||
description: string
|
||||
extra?: string
|
||||
}) {
|
||||
return (
|
||||
<header className="operations-header">
|
||||
<p className="eyebrow">{kicker}</p>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
{extra && <span>{extra}</span>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotDetailCard({ snapshot }: { snapshot: TodaySnapshot }) {
|
||||
const status = snapshotStatusMeta[snapshot.status]
|
||||
const slotMeta = snapshotSlotMeta[snapshot.slot]
|
||||
|
||||
return (
|
||||
<article className="snapshot-detail-card">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">{snapshot.slot}</p>
|
||||
<h2>{slotMeta.title}</h2>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
</div>
|
||||
<p className="snapshot-detail-subtitle">{slotMeta.subtitle}</p>
|
||||
<p className="snapshot-detail-message">{snapshot.message}</p>
|
||||
{snapshot.generatedAt && <p className="snapshot-time">生成时间:{snapshot.generatedAt}</p>}
|
||||
<div className="snapshot-grid snapshot-grid--wide">
|
||||
<span>
|
||||
{slotMeta.metricLabels.primaryUsers}
|
||||
<strong>{formatNumber(snapshot.purchaseUsers)}人</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.primaryOrders}
|
||||
<strong>{formatNumber(snapshot.orderCount)}单</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.amount}
|
||||
<strong>{formatMoney(snapshot.dealAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.paidAmount}
|
||||
<strong>{formatMoney(snapshot.paidAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.merchandise}
|
||||
<strong>{formatNumber(snapshot.newMerchandiseCount)}件</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.bonus}
|
||||
<strong>{formatMoney(Number(snapshot.selfBonusChange) + Number(snapshot.shareBonusChange))}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function buildDailyReports(data: DashboardOverview) {
|
||||
return data.trends
|
||||
.slice(-4)
|
||||
.reverse()
|
||||
.map((trend, index) => ({
|
||||
...trend,
|
||||
status: index === 0 ? '已生成' : '历史快照',
|
||||
bonusRate: Number(trend.amount) > 0 ? (Number(trend.bonus) / Number(trend.amount)) * 100 : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export function DailyReportPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const [isArchiving, setIsArchiving] = useState(false)
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="经营日报" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
const reports = buildDailyReports(data)
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
setIsArchiving(true)
|
||||
const blob = await downloadDailyReportArchive(data.businessDate)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `dashboard-daily-report-${data.businessDate}.html`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
Toast.show({ icon: 'success', content: '归档 HTML 已生成' })
|
||||
} catch {
|
||||
Toast.show({ icon: 'fail', content: '归档生成失败,请稍后重试' })
|
||||
} finally {
|
||||
setIsArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Daily Report"
|
||||
title="经营日报"
|
||||
description="按日沉淀成交、订单、用户与奖金变化,方便老板回看最近经营节奏。"
|
||||
extra={`最新数据:${data.businessDate}`}
|
||||
/>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Workday</p>
|
||||
<h2>上个工作日重点</h2>
|
||||
</div>
|
||||
<Tag color="success">已归档</Tag>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{data.kpis.slice(0, 4).map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Trend</p>
|
||||
<h2>最近 7 天趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<MiniTrendChart data={data.trends} />
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Archive</p>
|
||||
<h2>日报归档</h2>
|
||||
</div>
|
||||
<button className="text-button" type="button" disabled={isArchiving} onClick={() => void handleArchive()}>
|
||||
{isArchiving ? '生成中...' : '生成归档'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="report-list">
|
||||
{reports.map((report) => (
|
||||
<button className="report-item" key={report.date} type="button">
|
||||
<span>
|
||||
<strong>{report.date}</strong>
|
||||
<small>{report.status}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{formatMoney(report.amount)}</strong>
|
||||
<small>
|
||||
{formatNumber(report.orders)} 单 / 奖金占比 {formatNumber(report.bonusRate, 1)}%
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function TodaySnapshotPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="今日快报" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Today Snapshot"
|
||||
title="今日快报"
|
||||
description="10:15 看上一日寄卖商品的抢购结果,14:55 看抢到商品的寄卖/转卖承接情况。"
|
||||
extra="节点状态随 Mock 场景切换"
|
||||
/>
|
||||
|
||||
<section className="section-block snapshot-page-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Timeline</p>
|
||||
<h2>节点快报</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="snapshot-stack">
|
||||
{data.snapshots.map((snapshot) => (
|
||||
<SnapshotDetailCard key={snapshot.slot} snapshot={snapshot} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Checklist</p>
|
||||
<h2>节点检查项</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="check-list">
|
||||
{data.snapshots.flatMap((snapshot) =>
|
||||
snapshotSlotMeta[snapshot.slot].checklist.map((item) => (
|
||||
<span key={`${snapshot.slot}-${item}`}>
|
||||
<strong>{snapshot.slot === '1015' ? '上午' : '下午'}</strong>
|
||||
{item}
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function RiskCenterPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const [activeLevel, setActiveLevel] = useState<RiskLevel | 'all'>('all')
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="风险中心" />
|
||||
|
||||
const filteredRisks = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (activeLevel === 'all') return data.risks
|
||||
return data.risks.filter((risk) => risk.level === activeLevel)
|
||||
}, [activeLevel, data])
|
||||
|
||||
if (!data) return state
|
||||
|
||||
const dangerousFunds = data.fundPool.filter((metric) => metric.status === 'warning' || metric.status === 'danger')
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Risk Center"
|
||||
title="风险中心"
|
||||
description="把资金、积分与数据一致性风险集中处理,优先看红色和黄色事项。"
|
||||
extra={`${data.risks.length} 条待关注`}
|
||||
/>
|
||||
|
||||
<section className="risk-summary-grid" aria-label="风险概览">
|
||||
{(['red', 'yellow', 'gray'] as const).map((level) => {
|
||||
const meta = riskLevelMeta[level]
|
||||
const count = data.risks.filter((risk) => risk.level === level).length
|
||||
return (
|
||||
<button className={`risk-summary-card risk-summary-card--${level}`} key={level} type="button" onClick={() => setActiveLevel(level)}>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<strong>{count}</strong>
|
||||
<span>条风险</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<CapsuleTabs activeKey={activeLevel} onChange={(key) => setActiveLevel(key as RiskLevel | 'all')}>
|
||||
<CapsuleTabs.Tab title="全部" key="all" />
|
||||
<CapsuleTabs.Tab title="红色" key="red" />
|
||||
<CapsuleTabs.Tab title="黄色" key="yellow" />
|
||||
<CapsuleTabs.Tab title="灰色" key="gray" />
|
||||
</CapsuleTabs>
|
||||
<div className="risk-list">
|
||||
{filteredRisks.map((risk) => {
|
||||
const meta = riskLevelMeta[risk.level]
|
||||
return (
|
||||
<button className="risk-item" key={risk.id} type="button">
|
||||
<div className="risk-header">
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<span>{risk.type}</span>
|
||||
<time>{risk.discoveredAt}</time>
|
||||
</div>
|
||||
<strong>{risk.title}</strong>
|
||||
<p>{risk.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Fund Watch</p>
|
||||
<h2>资金池关注项</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{dangerousFunds.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="我的" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Profile"
|
||||
title="我的"
|
||||
description="展示当前驾驶舱权限、数据环境与演示版本,方便联调时确认口径。"
|
||||
extra="老板驾驶舱 H5"
|
||||
/>
|
||||
|
||||
<section className="profile-card">
|
||||
<div className="profile-avatar" aria-hidden="true">
|
||||
老
|
||||
</div>
|
||||
<div>
|
||||
<h2>老板视角</h2>
|
||||
<p>可查看经营概览、今日快报、排行与风险预警。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Environment</p>
|
||||
<h2>数据环境</h2>
|
||||
</div>
|
||||
<Tag color="warning">Mock</Tag>
|
||||
</div>
|
||||
<div className="info-list">
|
||||
<span>
|
||||
<small>数据日期</small>
|
||||
<strong>{data.businessDate}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>生成时间</small>
|
||||
<strong>{data.generatedAt}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>API 模式</small>
|
||||
<strong>{import.meta.env.VITE_MOCK_ENABLED === 'false' ? '真实接口' : 'Mock 演示'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Permissions</p>
|
||||
<h2>权限模块</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="check-list">
|
||||
<span>经营概览:可见</span>
|
||||
<span>资金池摘要:可见</span>
|
||||
<span>风险预警:可见</span>
|
||||
<span>导出能力:待接入</span>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user