Generate the standalone daily report HTML from the dashboard data already loaded in the H5 page, keeping the archived page visually aligned with the mobile dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
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 { useDashboardOverview } from '../api'
|
||
import { buildDailyReportArchiveHtml } from '../archive'
|
||
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 html = buildDailyReportArchiveHtml(data)
|
||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||
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>
|
||
)
|
||
}
|