gongxiangp2/components/department-dashboard/PerformanceConversionDetail.tsx

363 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import Select from '@/components/ui/Select'
import Link from 'next/link'
import { useCallback, useEffect, useState } from 'react'
import {
AlertTriangle,
ArrowLeft,
Database,
RefreshCw,
} from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import {
PERFORMANCE_CONVERSION_METRICS,
buildIntentRateWindowSummary,
type PerformanceConversionDailyMetric,
type PerformanceConversionMetricKey,
type PerformanceConversionTimeseries,
} from './performance-conversion'
type DetailState = {
data: PerformanceConversionTimeseries | null
loading: boolean
error: string | null
}
function formatDate(value: string | null, includeTime = false) {
if (!value) return '—'
const date = /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00+08:00`) : new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
...(includeTime ? { hour: '2-digit', minute: '2-digit', hour12: false } : {}),
}).format(date)
}
function formatMetric(value: number | null, format: string) {
if (value === null || !Number.isFinite(value)) return '—'
if (format === 'percent') return `${(value * 100).toFixed(2)}%`
if (format === 'currency') {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
maximumFractionDigits: 2,
}).format(value)
}
if (format === 'decimal') return value.toFixed(2)
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value)
}
function metricDefinition(key: PerformanceConversionMetricKey) {
return PERFORMANCE_CONVERSION_METRICS.find((metric) => metric.key === key) || PERFORMANCE_CONVERSION_METRICS[0]
}
function TrendChart({ daily, metricKey }: {
daily: PerformanceConversionDailyMetric[]
metricKey: PerformanceConversionMetricKey
}) {
const definition = metricDefinition(metricKey)
const width = 900
const height = 240
const plot = { left: 24, right: 876, top: 18, bottom: 194 }
const values = daily.map((day) => day[metricKey])
const present = values.filter((value): value is number => value !== null && Number.isFinite(value))
const min = present.length ? Math.min(...present) : 0
const max = present.length ? Math.max(...present) : 0
const range = max - min || Math.max(Math.abs(max), 1)
const x = (index: number) => plot.left + (daily.length <= 1 ? 0 : index / (daily.length - 1)) * (plot.right - plot.left)
const y = (value: number) => plot.bottom - ((value - min) / range) * (plot.bottom - plot.top)
const segments: string[] = []
let current: string[] = []
values.forEach((value, index) => {
if (value === null || !Number.isFinite(value)) {
if (current.length) segments.push(current.join(' '))
current = []
return
}
current.push(`${current.length ? 'L' : 'M'} ${x(index).toFixed(2)} ${y(value).toFixed(2)}`)
})
if (current.length) segments.push(current.join(' '))
const middleIndex = Math.max(0, Math.floor((daily.length - 1) / 2))
if (!present.length) {
return (
<div className="flex aspect-[3/1] min-h-[220px] items-center justify-center border-y border-slate-200 bg-slate-50 px-4 text-center text-sm font-semibold text-slate-500">
监控区间内暂无{definition.label}数据
</div>
)
}
return (
<figure aria-label={`${definition.label}每日趋势`} className="border-y border-slate-200 bg-white px-4 py-4 sm:px-6">
<svg
role="img"
aria-label={`${definition.label}从 ${formatDate(daily[0]?.date || null)} 到 ${formatDate(daily[daily.length - 1]?.date || null)} 的每日变化`}
viewBox={`0 0 ${width} ${height}`}
className="block aspect-[3/1] min-h-[220px] w-full"
preserveAspectRatio="none"
>
{[0, 0.5, 1].map((position) => {
const lineY = plot.top + position * (plot.bottom - plot.top)
return <line key={position} x1={plot.left} x2={plot.right} y1={lineY} y2={lineY} stroke="#e2e8f0" strokeWidth="1" vectorEffect="non-scaling-stroke" />
})}
{segments.map((path, index) => (
<path key={index} d={path} fill="none" stroke="#1677ff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
))}
<text x={plot.left} y="224" fill="#66758b" fontSize="12">{daily[0]?.date || ''}</text>
<text x={(plot.left + plot.right) / 2} y="224" fill="#66758b" fontSize="12" textAnchor="middle">{daily[middleIndex]?.date || ''}</text>
<text x={plot.right} y="224" fill="#66758b" fontSize="12" textAnchor="end">{daily[daily.length - 1]?.date || ''}</text>
</svg>
<figcaption className="sr-only">缺失日期保持断点,准确数值见下方每日明细表。</figcaption>
</figure>
)
}
function MetricSummary({ data }: { data: PerformanceConversionTimeseries }) {
const groups = ['流量', '兴趣', '成交'] as const
return (
<div className="grid gap-px border-y border-slate-200 bg-slate-200 lg:grid-cols-3">
{groups.map((group) => (
<section key={group} className="bg-white px-5 py-4 sm:px-7">
<h2 className="text-xs font-bold text-slate-500">{group}</h2>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-4">
{PERFORMANCE_CONVERSION_METRICS.filter((metric) => metric.group === group).map((metric) => (
<div key={metric.key}>
<dt className="text-xs text-slate-500">{metric.label}</dt>
<dd className="mt-1 text-lg font-bold tabular-nums text-slate-950">
{formatMetric(data.latest?.[metric.key] ?? null, metric.format)}
</dd>
</div>
))}
</dl>
</section>
))}
</div>
)
}
function IntentRateSummary({ daily }: { daily: PerformanceConversionDailyMetric[] }) {
const summary = buildIntentRateWindowSummary(daily)
const formatWindowValue = (value: number | null) => value === null ? '——' : `${(value * 100).toFixed(2)}%`
return (
<section className="border-t border-slate-200 bg-white" aria-labelledby="intent-rate-summary-title">
<header className="px-5 py-4 sm:px-7">
<h2 id="intent-rate-summary-title" className="font-bold text-slate-950">流程完结后 7日 / 15日平均值</h2>
<p className="mt-1 text-xs text-slate-500">从流程完结日开始计算,窗口内每天数据完整时显示,缺少天数或字段显示“——”</p>
</header>
<dl className="grid gap-px border-y border-slate-200 bg-slate-200 sm:grid-cols-3">
<div className="bg-white px-5 py-4 sm:px-7">
<dt className="text-xs font-semibold text-slate-500">完结后7日意向率平均</dt>
<dd className="mt-1 text-lg font-bold tabular-nums text-slate-950">{formatWindowValue(summary.sevenDayIntentRate)}</dd>
</div>
<div className="bg-white px-5 py-4 sm:px-7">
<dt className="text-xs font-semibold text-slate-500">完结后15日意向率平均</dt>
<dd className="mt-1 text-lg font-bold tabular-nums text-slate-950">{formatWindowValue(summary.fifteenDayIntentRate)}</dd>
</div>
<div className="bg-white px-5 py-4 sm:px-7">
<dt className="text-xs font-semibold text-slate-500">意向率环比(15日 / 7日)</dt>
<dd className="mt-1 text-lg font-bold tabular-nums text-slate-950">{formatWindowValue(summary.intentRateChange)}</dd>
</div>
</dl>
<p className="px-5 py-3 text-xs leading-5 text-slate-500 sm:px-7">意向率 = (收藏人数 + 加购人数)÷ 商品访客数 × 100% + 支付转化率</p>
</section>
)
}
function DetailContent({ data }: { data: PerformanceConversionTimeseries }) {
const [metricKey, setMetricKey] = useState<PerformanceConversionMetricKey>('paymentAmount')
const [detailGroup, setDetailGroup] = useState<'流量' | '兴趣' | '成交'>('流量')
const visibleMetrics = PERFORMANCE_CONVERSION_METRICS.filter((metric) => metric.group === detailGroup)
const hasDailyData = data.daily.some((day) => PERFORMANCE_CONVERSION_METRICS.some((metric) => day[metric.key] !== null))
return (
<section className="overflow-hidden rounded-lg border border-slate-200 bg-white" aria-labelledby="conversion-detail-title">
<header className="px-5 py-5 sm:px-7">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<p className="break-all font-mono text-xs font-bold text-[#0050cb]">商品 ID {data.product.id}</p>
<h1 id="conversion-detail-title" className="mt-1 break-words text-2xl font-bold text-slate-950 sm:text-3xl">
{data.product.name || '未匹配到天猫经营数据'}
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
{data.product.category || '暂无类目信息'}
{data.product.sourceBrand ? ` · 数据品牌 ${data.product.sourceBrand}` : ''}
</p>
</div>
<dl className="grid shrink-0 gap-x-5 gap-y-2 text-left text-xs leading-5 text-slate-500 sm:grid-cols-3 lg:block lg:text-right">
<div><dt className="sr-only">审批编号</dt><dd>审批编号 {data.workflow.approvalNumber || '—'}</dd></div>
<div><dt className="sr-only">流程完结</dt><dd>流程完结 {formatDate(data.workflow.completedAt, true)}</dd></div>
<div><dt className="sr-only">经营数据更新</dt><dd>经营数据更新至 {formatDate(data.coverage.sourceLatestDate)}</dd></div>
</dl>
</div>
{data.product.brandMatchesWorkflow === false && (
<p role="status" className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold text-amber-800">
流程品牌与经营数据品牌不一致,请核对商品 ID。
</p>
)}
</header>
<MetricSummary data={data} />
<IntentRateSummary daily={data.daily} />
{!hasDailyData ? (
<div className="px-5 py-12 text-center">
<Database className="mx-auto h-7 w-7 text-slate-400" aria-hidden="true" />
<p className="mt-3 text-sm font-bold text-slate-700">该商品在流程完结后的监控区间内暂无经营数据</p>
<p className="mt-1 text-xs text-slate-500">监控区间 {data.coverage.monitorStartDate} 至 {data.coverage.monitorEndDate}</p>
</div>
) : (
<>
<div className="flex flex-col gap-4 px-5 py-4 sm:flex-row sm:items-end sm:justify-between sm:px-7">
<div>
<h2 className="font-bold text-slate-950">每日趋势</h2>
<p className="mt-1 text-xs text-slate-500">已有 {data.coverage.coveredDays} 天 / 应有 {data.coverage.expectedDaysToDate} 天,缺失日期保留断点</p>
</div>
<label className="flex flex-col gap-1 text-xs font-bold text-slate-600 sm:flex-row sm:items-center sm:gap-2">
趋势指标
<Select
value={metricKey}
onChange={(event) => setMetricKey(event.target.value as PerformanceConversionMetricKey)}
className="h-10 rounded-lg border border-slate-300 bg-white px-3 text-sm font-semibold text-slate-800 outline-none focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"
>
{PERFORMANCE_CONVERSION_METRICS.map((metric) => <option key={metric.key} value={metric.key}>{metric.group} · {metric.label}</option>)}
</Select>
</label>
</div>
<TrendChart daily={data.daily} metricKey={metricKey} />
<div className="flex flex-col gap-3 border-b border-slate-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-7">
<div>
<h2 className="font-bold text-slate-950">每日明细</h2>
<p className="mt-1 text-xs text-slate-500">0 为真实数值,— 表示当天无数据</p>
</div>
<div className="inline-flex self-start rounded-lg border border-slate-200 bg-slate-50 p-1" aria-label="选择每日明细指标组">
{(['流量', '兴趣', '成交'] as const).map((group) => (
<button
key={group}
type="button"
aria-pressed={detailGroup === group}
onClick={() => setDetailGroup(group)}
className={`min-h-9 rounded-md px-3 text-xs font-bold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] ${detailGroup === group ? 'bg-white text-[#0050cb] shadow-sm' : 'text-slate-600 hover:text-slate-950'}`}
>
{group}
</button>
))}
</div>
</div>
<div className="max-h-[520px] overflow-auto" tabIndex={0}>
<table className="w-full min-w-[620px] text-left text-sm">
<thead className="sticky top-0 bg-slate-50 text-xs font-bold text-slate-500">
<tr>
<th className="px-5 py-3 sm:px-7">日期</th>
{visibleMetrics.map((metric) => <th key={metric.key} className="px-4 py-3 text-right">{metric.label}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{[...data.daily].reverse().map((day) => (
<tr key={day.date} className="bg-white hover:bg-slate-50">
<td className="px-5 py-3 font-semibold tabular-nums text-slate-700 sm:px-7">{day.date}</td>
{visibleMetrics.map((metric) => (
<td key={metric.key} className="px-4 py-3 text-right font-semibold tabular-nums text-slate-800">
{formatMetric(day[metric.key], metric.format)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</section>
)
}
export default function PerformanceConversionDetail({
workflowId,
productId,
returnHref,
}: {
workflowId: string
productId: string
returnHref: string
}) {
const [state, setState] = useState<DetailState>({ data: null, loading: true, error: null })
const loadDetail = useCallback(async (signal?: AbortSignal) => {
if (!workflowId || !productId) {
setState({ data: null, loading: false, error: '详情链接缺少流程实例或商品 ID' })
return
}
setState({ data: null, loading: true, error: null })
try {
const params = new URLSearchParams({ workflowId, productId })
const response = await fetch(`/api/department-dashboard/performance-conversion/timeseries?${params}`, {
cache: 'no-store',
signal,
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success || !payload.data) {
throw new Error(payload?.error || '商品经营数据读取失败')
}
setState({ data: payload.data, loading: false, error: null })
} catch (error) {
if ((error as Error).name === 'AbortError') return
setState({ data: null, loading: false, error: (error as Error).message || '商品经营数据读取失败' })
}
}, [productId, workflowId])
useEffect(() => {
const controller = new AbortController()
void loadDetail(controller.signal)
return () => controller.abort()
}, [loadDetail])
return (
<DepartmentShell>
<main className="min-h-[calc(100dvh-72px)] bg-[#f2f4f7] text-[#101828]">
<div className="mx-auto max-w-[1360px] px-4 pb-14 pt-5 sm:px-6 sm:pt-7 lg:px-8">
<div className="mb-5 border-b border-[#dfe4ea] pb-5">
<Link
href={returnHref}
className="inline-flex min-h-10 items-center gap-2 rounded-lg px-2 text-sm font-bold text-slate-700 hover:bg-white hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
返回业绩转化
</Link>
<p className="mt-2 break-all font-mono text-xs font-semibold text-slate-500">流程实例 {workflowId || '—'}</p>
</div>
{state.loading ? (
<section aria-busy="true" className="rounded-lg border border-slate-200 bg-white px-5 py-24 text-center">
<RefreshCw className="mx-auto h-7 w-7 animate-spin text-[#1677ff]" aria-hidden="true" />
<p className="mt-4 text-sm font-semibold text-slate-700">正在读取商品每日经营数据</p>
</section>
) : state.error || !state.data ? (
<section role="alert" className="rounded-lg border border-red-200 bg-red-50 px-5 py-16 text-center">
<AlertTriangle className="mx-auto h-7 w-7 text-red-600" aria-hidden="true" />
<h1 className="mt-4 text-xl font-bold text-red-900">无法打开商品数据</h1>
<p className="mt-2 text-sm font-semibold text-red-800">{state.error || '商品经营数据读取失败'}</p>
<button
type="button"
onClick={() => void loadDetail()}
className="mt-5 inline-flex min-h-10 items-center gap-2 rounded-lg border border-red-200 bg-white px-4 text-sm font-bold text-red-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
重试
</button>
</section>
) : (
<DetailContent data={state.data} />
)}
</div>
</main>
</DepartmentShell>
)
}