feat(dashboard): archive daily report from page data
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>
This commit is contained in:
215
dashboard-frontend/src/features/boss-dashboard/archive.ts
Normal file
215
dashboard-frontend/src/features/boss-dashboard/archive.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { formatMetricValue, formatMoney, formatNumber } from '../../utils/format'
|
||||
import type { DashboardOverview, MetricStatus, RankItem, RiskAlert, RiskLevel, SnapshotSlot, TodaySnapshot } from './types'
|
||||
|
||||
const snapshotTitle: Record<SnapshotSlot, string> = {
|
||||
'1015': '上午抢购快报',
|
||||
'1455': '下午寄卖/转卖快报',
|
||||
}
|
||||
|
||||
const snapshotDescription: Record<SnapshotSlot, string> = {
|
||||
'1015': '用户集中抢购上一天用户寄卖的商品,重点看成交、付款和采购用户是否达标。',
|
||||
'1455': '用户把上午抢到的商品继续寄卖或转卖,重点看新增寄售供给和奖金变化是否正常。',
|
||||
}
|
||||
|
||||
const metricStatusText: Record<MetricStatus, string> = {
|
||||
normal: '正常',
|
||||
success: '达标',
|
||||
warning: '关注',
|
||||
danger: '异常',
|
||||
}
|
||||
|
||||
const riskLevelText: Record<RiskLevel, string> = {
|
||||
red: '红色',
|
||||
yellow: '黄色',
|
||||
gray: '灰色',
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function serializeStaticData(data: DashboardOverview): string {
|
||||
return JSON.stringify(data, null, 2).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e')
|
||||
}
|
||||
|
||||
function renderMetricGrid(metrics: DashboardOverview['kpis']): string {
|
||||
return metrics
|
||||
.map(
|
||||
(metric) => `
|
||||
<article class="card metric-card">
|
||||
<span>${escapeHtml(metricStatusText[metric.status])}</span>
|
||||
<h3>${escapeHtml(metric.title)}</h3>
|
||||
<strong>${escapeHtml(formatMetricValue(metric.value, metric.unit))}</strong>
|
||||
${metric.trendLabel ? `<p>${escapeHtml(metric.trendLabel)} ${escapeHtml(metric.trendValue ?? '')}%</p>` : ''}
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function renderSnapshots(snapshots: TodaySnapshot[]): string {
|
||||
return snapshots
|
||||
.map((snapshot) => {
|
||||
const bonusChange = Number(snapshot.selfBonusChange) + Number(snapshot.shareBonusChange)
|
||||
return `
|
||||
<article class="card snapshot-card">
|
||||
<div class="card-title-row">
|
||||
<div>
|
||||
<span>${escapeHtml(snapshot.slot)}</span>
|
||||
<h3>${escapeHtml(snapshotTitle[snapshot.slot])}</h3>
|
||||
</div>
|
||||
<mark>${escapeHtml(snapshot.status)}</mark>
|
||||
</div>
|
||||
<p>${escapeHtml(snapshotDescription[snapshot.slot])}</p>
|
||||
<p class="message">${escapeHtml(snapshot.message)}</p>
|
||||
${snapshot.generatedAt ? `<small>生成时间:${escapeHtml(snapshot.generatedAt)}</small>` : ''}
|
||||
<div class="snapshot-grid">
|
||||
<span>用户<strong>${escapeHtml(formatNumber(snapshot.purchaseUsers))}人</strong></span>
|
||||
<span>订单<strong>${escapeHtml(formatNumber(snapshot.orderCount))}单</strong></span>
|
||||
<span>成交额<strong>${escapeHtml(formatMoney(snapshot.dealAmount))}</strong></span>
|
||||
<span>已支付<strong>${escapeHtml(formatMoney(snapshot.paidAmount))}</strong></span>
|
||||
<span>商品<strong>${escapeHtml(formatNumber(snapshot.newMerchandiseCount))}件</strong></span>
|
||||
<span>奖金<strong>${escapeHtml(formatMoney(bonusChange))}</strong></span>
|
||||
</div>
|
||||
</article>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function renderRanks(title: string, ranks: RankItem[]): string {
|
||||
return `
|
||||
<section class="section">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<div class="rank-list">
|
||||
${ranks
|
||||
.map(
|
||||
(rank, index) => `
|
||||
<div class="rank-item">
|
||||
<b>${index + 1}</b>
|
||||
<span>
|
||||
<strong>${escapeHtml(rank.name)}</strong>
|
||||
<small>${escapeHtml(rank.description)}</small>
|
||||
</span>
|
||||
<em>${escapeHtml(formatMoney(rank.value))}</em>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>`
|
||||
}
|
||||
|
||||
function renderRisks(risks: RiskAlert[]): string {
|
||||
return risks
|
||||
.map(
|
||||
(risk) => `
|
||||
<article class="risk risk--${risk.level}">
|
||||
<div>
|
||||
<mark>${escapeHtml(riskLevelText[risk.level])}</mark>
|
||||
<span>${escapeHtml(risk.type)}</span>
|
||||
<time>${escapeHtml(risk.discoveredAt)}</time>
|
||||
</div>
|
||||
<strong>${escapeHtml(risk.title)}</strong>
|
||||
<p>${escapeHtml(risk.description)}</p>
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function buildDailyReportArchiveHtml(data: DashboardOverview): string {
|
||||
const generatedAt = new Date().toLocaleString('zh-CN', { hour12: false })
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>经营日报归档 - ${escapeHtml(data.businessDate)}</title>
|
||||
<style>
|
||||
:root { --bg: #fff6f1; --surface: #fff; --surface-soft: #f6f9fb; --text: #132033; --muted: #6b7a90; --border: rgba(19, 32, 51, .08); --primary: #ff5b36; --warning: #ffb000; --danger: #dc2626; --shadow: 0 16px 40px rgba(255, 91, 54, .14); --radius-xl: 28px; --radius-lg: 20px; --radius-md: 14px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { min-width: 320px; margin: 0; color: var(--text); background: radial-gradient(circle at top left, rgba(255, 91, 54, .2), transparent 28rem), var(--bg); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
main { width: min(100%, 430px); min-height: 100svh; margin: 0 auto; padding: 14px 14px 24px; background: var(--bg); box-shadow: 0 0 0 1px rgba(19, 32, 51, .04); }
|
||||
.hero { position: relative; overflow: hidden; padding: 20px; color: #fff; background: linear-gradient(145deg, rgba(255, 91, 54, .98), rgba(255, 139, 82, .92)), radial-gradient(circle at 90% 10%, rgba(255, 176, 0, .42), transparent 18rem); border-radius: 0 0 var(--radius-xl) var(--radius-xl); box-shadow: var(--shadow); }
|
||||
.hero p { margin: 0; color: rgba(255, 255, 255, .76); line-height: 1.6; }
|
||||
.eyebrow, .card-title-row span, .metric-card > span { margin: 0; color: rgba(255, 255, 255, .68); font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
h1, h2, h3 { margin: 0; }
|
||||
h1 { margin: 12px 0 8px; font-size: 28px; line-height: 1.12; }
|
||||
h2 { font-size: 18px; margin-bottom: 12px; }
|
||||
h3 { font-size: 16px; }
|
||||
.meta { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.meta span { display: inline-flex; width: max-content; padding: 7px 12px; color: rgba(255, 255, 255, .86); font-size: 12px; font-weight: 700; background: rgba(255, 255, 255, .14); border: 1px solid rgba(255, 255, 255, .18); border-radius: 999px; }
|
||||
.section { margin-top: 14px; padding: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-xl); box-shadow: 0 10px 28px rgba(22, 47, 80, .08); }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.card { padding: 14px; background: var(--surface-soft); border: 0; border-radius: var(--radius-lg); }
|
||||
.metric-card { min-height: 112px; background: var(--surface); border: 1px solid var(--border); box-shadow: 0 10px 28px rgba(22, 47, 80, .08); }
|
||||
.metric-card > span { color: var(--muted); }
|
||||
.metric-card strong { display: block; margin-top: 8px; color: var(--text); font-size: 22px; line-height: 1.08; word-break: break-all; }
|
||||
.metric-card p { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.snapshot-stack, .trend-list, .rank-list, .risk-list { display: grid; gap: 10px; margin-top: 14px; }
|
||||
.card-title-row, .rank-item, .risk div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.card-title-row span { color: var(--muted); }
|
||||
mark { padding: 3px 8px; color: var(--primary); font-size: 12px; font-weight: 700; background: #fff0eb; border: 0; border-radius: 999px; }
|
||||
.snapshot-card p { margin: 12px 0 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.snapshot-card .message { margin: 10px 0 8px; color: var(--text); font-weight: 700; line-height: 1.55; }
|
||||
.snapshot-card small { color: var(--muted); }
|
||||
.snapshot-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
|
||||
.snapshot-grid span, .trend-item, .rank-item, .risk { padding: 12px; background: var(--surface-soft); border: 0; border-radius: var(--radius-md); }
|
||||
.snapshot-grid span { color: var(--muted); font-size: 12px; }
|
||||
.snapshot-grid strong { display: block; margin-top: 4px; color: var(--text); font-size: 15px; }
|
||||
.trend-item { display: grid; gap: 4px; }
|
||||
.trend-item span { color: var(--muted); font-size: 13px; }
|
||||
.rank-item b { width: 28px; height: 28px; display: inline-grid; place-items: center; border-radius: 10px; color: #fff; background: var(--primary); }
|
||||
.rank-item span { flex: 1; }
|
||||
.rank-item small { display: block; margin-top: 3px; color: var(--muted); line-height: 1.45; }
|
||||
.rank-item em { color: var(--primary); font-size: 13px; font-style: normal; font-weight: 800; }
|
||||
.risk strong { display: block; margin-top: 10px; }
|
||||
.risk p { margin: 6px 0 0; color: var(--muted); line-height: 1.5; }
|
||||
.risk--red mark { color: #991b1b; background: #fee2e2; }
|
||||
.risk--yellow mark { color: #92400e; background: #fef3c7; }
|
||||
.risk--gray mark { color: #475569; background: #e2e8f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Daily Report Archive</p>
|
||||
<h1>经营日报归档 ${escapeHtml(data.businessDate)}</h1>
|
||||
<p>${escapeHtml(data.summary)}</p>
|
||||
<div class="meta">
|
||||
<span>业务日期:${escapeHtml(data.businessDate)}</span>
|
||||
<span>数据生成:${escapeHtml(data.generatedAt)}</span>
|
||||
<span>归档生成:${escapeHtml(generatedAt)}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section"><h2>核心指标</h2><div class="grid">${renderMetricGrid(data.kpis)}</div></section>
|
||||
<section class="section"><h2>资金池摘要</h2><div class="grid">${renderMetricGrid(data.fundPool)}</div></section>
|
||||
<section class="section"><h2>今日快报</h2><div class="snapshot-stack">${renderSnapshots(data.snapshots)}</div></section>
|
||||
<section class="section">
|
||||
<h2>最近趋势</h2>
|
||||
<div class="trend-list">
|
||||
${data.trends
|
||||
.map(
|
||||
(trend) => `
|
||||
<div class="trend-item">
|
||||
<strong>${escapeHtml(trend.date)}</strong>
|
||||
<span>成交 ${escapeHtml(formatMoney(trend.amount))}</span>
|
||||
<span>订单 ${escapeHtml(formatNumber(trend.orders))} 单</span>
|
||||
<span>奖金 ${escapeHtml(formatMoney(trend.bonus))}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
${renderRanks('高价值用户', data.userRanks)}
|
||||
${renderRanks('团队贡献排行', data.teamRanks)}
|
||||
${renderRanks('高货值未成交商品', data.productRanks)}
|
||||
<section class="section"><h2>风险预警</h2><div class="risk-list">${renderRisks(data.risks)}</div></section>
|
||||
<script id="dashboard-static-data" type="application/json">${serializeStaticData(data)}</script>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -3,7 +3,8 @@ 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 { useDashboardOverview } from '../api'
|
||||
import { buildDailyReportArchiveHtml } from '../archive'
|
||||
import type { DashboardOverview, RiskLevel, SnapshotSlot, TodaySnapshot } from '../types'
|
||||
|
||||
const snapshotStatusMeta = {
|
||||
@@ -187,7 +188,8 @@ export function DailyReportPage() {
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
setIsArchiving(true)
|
||||
const blob = await downloadDailyReportArchive(data.businessDate)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user