363 lines
18 KiB
TypeScript
363 lines
18 KiB
TypeScript
'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>
|
||
)
|
||
}
|