feat: open performance conversion details separately

This commit is contained in:
江林铖 2026-09-17 15:29:01 +08:00
parent 6dbafbd5ed
commit 35a301a45e
5 changed files with 614 additions and 173 deletions

View File

@ -0,0 +1,42 @@
import type { Metadata } from 'next'
import PerformanceConversionDetail from '@/components/department-dashboard/PerformanceConversionDetail'
import { BUSINESS_TREND_BRANDS, isValidDateKey } from '@/components/department-dashboard/performance-conversion'
export const metadata: Metadata = {
title: '商品经营详情 | 业绩转化 | 品牌中心部门看板',
description: '查看美术申请流程商品 ID 在流程完结后六个月的每日经营变化。',
}
type SearchParams = Record<string, string | string[] | undefined>
function firstValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] || '' : value || ''
}
function buildReturnHref(searchParams: SearchParams) {
const params = new URLSearchParams()
const brand = firstValue(searchParams.brand)
const query = firstValue(searchParams.q)
const completedFrom = firstValue(searchParams.completedFrom)
const completedTo = firstValue(searchParams.completedTo)
const page = Number.parseInt(firstValue(searchParams.page), 10)
if (BUSINESS_TREND_BRANDS.includes(brand as (typeof BUSINESS_TREND_BRANDS)[number])) params.set('brand', brand)
if (query) params.set('q', query)
if (isValidDateKey(completedFrom)) params.set('completedFrom', completedFrom)
if (isValidDateKey(completedTo)) params.set('completedTo', completedTo)
if (Number.isSafeInteger(page) && page > 1) params.set('page', String(page))
const queryString = params.toString()
return `/workspaces/business/performance-conversion${queryString ? `?${queryString}` : ''}`
}
export default function PerformanceConversionDetailPage({ searchParams }: { searchParams: SearchParams }) {
return (
<PerformanceConversionDetail
workflowId={firstValue(searchParams.workflowId)}
productId={firstValue(searchParams.productId)}
returnHref={buildReturnHref(searchParams)}
/>
)
}

View File

@ -19,6 +19,21 @@ const emptyList = {
lastWorkflowSyncedAt: '2026-09-17T00:00:00.000Z',
}
const workflowItem = {
key: 'flow-1:1041432226288',
workflowId: 'flow-1',
approvalNumber: '202609170001',
processCode: 'art-design',
title: '商品主图美术设计申请',
brand: '亿家达' as const,
productId: '1041432226288',
spu: 'SPU-001',
completedAt: '2026-07-12T08:00:00.000Z',
monitorStartDate: '2026-07-12',
monitorEndDate: '2027-01-12',
monitoringStatus: 'monitoring' as const,
}
describe('PerformanceConversionDashboard filters', () => {
const originalFetch = global.fetch
@ -51,4 +66,43 @@ describe('PerformanceConversionDashboard filters', () => {
expect(window.location.search).toContain('completedFrom=2026-07-01')
expect(window.location.search).toContain('completedTo=2026-07-31')
})
it('opens each product ID on a separate detail page and preserves the list context', async () => {
window.history.replaceState(null, '', '/workspaces/business/performance-conversion?brand=%E4%BA%BF%E5%AE%B6%E8%BE%BE&q=%E4%B8%BB%E5%9B%BE&completedFrom=2026-07-01&completedTo=2026-07-31&page=3')
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
data: {
...emptyList,
brands: BUSINESS_TREND_BRANDS.map((brand) => ({ brand, count: brand === '亿家达' ? 1 : 0 })),
items: [workflowItem],
total: 41,
page: 3,
},
}),
} as Response)
global.fetch = fetchMock
render(<PerformanceConversionDashboard />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
expect(String(fetchMock.mock.calls[0][0])).toContain('page=3')
expect(String(fetchMock.mock.calls[0][0])).toContain('q=%E4%B8%BB%E5%9B%BE')
const links = await screen.findAllByRole('link', { name: new RegExp(workflowItem.productId) })
expect(links[0]).toHaveAttribute(
'href',
expect.stringContaining('/workspaces/business/performance-conversion/detail?'),
)
const detailUrl = new URL(links[0].getAttribute('href') || '', 'http://localhost')
expect(detailUrl.searchParams.get('workflowId')).toBe(workflowItem.workflowId)
expect(detailUrl.searchParams.get('productId')).toBe(workflowItem.productId)
expect(detailUrl.searchParams.get('brand')).toBe('亿家达')
expect(detailUrl.searchParams.get('q')).toBe('主图')
expect(detailUrl.searchParams.get('completedFrom')).toBe('2026-07-01')
expect(detailUrl.searchParams.get('completedTo')).toBe('2026-07-31')
expect(detailUrl.searchParams.get('page')).toBe('3')
expect(fetchMock).not.toHaveBeenCalledWith(expect.stringContaining('/timeseries'), expect.anything())
})
})

View File

@ -1,24 +1,18 @@
'use client'
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'
import {
Activity,
AlertTriangle,
CalendarDays,
ChevronLeft,
ChevronRight,
Database,
LineChart,
RefreshCw,
Search,
} from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import {
BUSINESS_TREND_BRANDS,
PERFORMANCE_CONVERSION_METRICS,
type PerformanceConversionDailyMetric,
type PerformanceConversionMetricKey,
type PerformanceConversionTimeseries,
type PerformanceConversionWorkflowItem,
type PerformanceConversionWorkflowList,
isValidDateKey,
@ -30,12 +24,6 @@ type ListState = {
error: string | null
}
type DetailState = {
data: PerformanceConversionTimeseries | null
loading: boolean
error: string | null
}
type WorkflowFilters = {
query: string
completedFrom: string
@ -55,68 +43,9 @@ function formatDate(value: string | null, includeTime = 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 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 WorkflowTable({ items, selectedKey, onSelect }: {
function WorkflowTable({ items, detailHref }: {
items: PerformanceConversionWorkflowItem[]
selectedKey: string | null
onSelect: (item: PerformanceConversionWorkflowItem) => void
detailHref: (item: PerformanceConversionWorkflowItem) => string
}) {
return (
<>
@ -125,54 +54,17 @@ function WorkflowTable({ items, selectedKey, onSelect }: {
<caption className="sr-only">2026 年 4 月 1 日以后已完结美术流程关联的商品 ID</caption>
<thead className="bg-slate-50 text-xs font-bold text-slate-500"><tr><th className="w-[18%] px-5 py-3">商品 ID</th><th className="w-[24%] px-4 py-3">审批编号</th><th className="w-[18%] px-4 py-3">完结时间</th><th className="w-[25%] px-4 py-3">监控区间</th><th className="w-[15%] px-4 py-3">状态</th></tr></thead>
<tbody className="divide-y divide-slate-200">
{items.map((item) => {
const selected = selectedKey === item.key
return <tr key={item.key} className={selected ? 'bg-[#e6f4ff]' : 'bg-white hover:bg-slate-50'}><td className="px-5 py-3"><button type="button" aria-expanded={selected} aria-controls="performance-conversion-detail" onClick={() => onSelect(item)} className="break-all text-left font-mono text-sm font-bold text-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">{item.productId}</button>{item.spu && <p className="mt-1 truncate text-xs text-slate-500">SPU {item.spu}</p>}</td><td className="px-4 py-3"><p className="break-all font-mono text-xs text-slate-700">{item.approvalNumber || '—'}</p><p className="mt-1 truncate text-xs text-slate-500" title={item.workflowId}>实例 {item.workflowId}</p></td><td className="px-4 py-3 tabular-nums text-slate-700">{formatDate(item.completedAt, true)}</td><td className="px-4 py-3 tabular-nums text-slate-700">{item.monitorStartDate} 至 {item.monitorEndDate}</td><td className="px-4 py-3"><span className={`inline-flex min-h-7 items-center rounded-md px-2 text-xs font-bold ${item.monitoringStatus === 'monitoring' ? 'bg-amber-50 text-amber-800 ring-1 ring-inset ring-amber-200' : 'bg-emerald-50 text-emerald-800 ring-1 ring-inset ring-emerald-200'}`}>{item.monitoringStatus === 'monitoring' ? '监控中' : '已满 6 个月'}</span></td></tr>
})}
{items.map((item) => <tr key={item.key} className="bg-white hover:bg-slate-50"><td className="px-5 py-3"><Link href={detailHref(item)} className="break-all font-mono text-sm font-bold text-[#0050cb] underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">{item.productId}</Link>{item.spu && <p className="mt-1 truncate text-xs text-slate-500">SPU {item.spu}</p>}</td><td className="px-4 py-3"><p className="break-all font-mono text-xs text-slate-700">{item.approvalNumber || '—'}</p><p className="mt-1 truncate text-xs text-slate-500" title={item.workflowId}>实例 {item.workflowId}</p></td><td className="px-4 py-3 tabular-nums text-slate-700">{formatDate(item.completedAt, true)}</td><td className="px-4 py-3 tabular-nums text-slate-700">{item.monitorStartDate} 至 {item.monitorEndDate}</td><td className="px-4 py-3"><span className={`inline-flex min-h-7 items-center rounded-md px-2 text-xs font-bold ${item.monitoringStatus === 'monitoring' ? 'bg-amber-50 text-amber-800 ring-1 ring-inset ring-amber-200' : 'bg-emerald-50 text-emerald-800 ring-1 ring-inset ring-emerald-200'}`}>{item.monitoringStatus === 'monitoring' ? '监控中' : '已满 6 个月'}</span></td></tr>)}
</tbody>
</table>
</div>
<ul className="divide-y divide-slate-200 md:hidden" aria-label="美术流程商品 ID 列表">
{items.map((item) => <li key={item.key} className={selectedKey === item.key ? 'bg-[#e6f4ff] p-4' : 'bg-white p-4'}><button type="button" aria-expanded={selectedKey === item.key} aria-controls="performance-conversion-detail" onClick={() => onSelect(item)} className="w-full text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><span className="font-mono text-sm font-bold text-[#0050cb]">{item.productId}</span><span className="mt-1 block text-xs text-slate-500">审批编号 {item.approvalNumber || '—'}</span><dl className="mt-3 grid grid-cols-2 gap-3 text-xs"><div><dt className="text-slate-500">完结时间</dt><dd className="mt-1 font-semibold text-slate-800">{formatDate(item.completedAt)}</dd></div><div><dt className="text-slate-500">状态</dt><dd className="mt-1 font-semibold text-slate-800">{item.monitoringStatus === 'monitoring' ? '监控中' : '已满 6 个月'}</dd></div><div className="col-span-2"><dt className="text-slate-500">监控区间</dt><dd className="mt-1 font-semibold text-slate-800">{item.monitorStartDate} 至 {item.monitorEndDate}</dd></div></dl></button></li>)}
{items.map((item) => <li key={item.key} className="bg-white p-4"><Link href={detailHref(item)} className="block w-full text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><span className="font-mono text-sm font-bold text-[#0050cb]">{item.productId}</span><span className="mt-1 block text-xs text-slate-500">审批编号 {item.approvalNumber || '—'}</span><dl className="mt-3 grid grid-cols-2 gap-3 text-xs"><div><dt className="text-slate-500">完结时间</dt><dd className="mt-1 font-semibold text-slate-800">{formatDate(item.completedAt)}</dd></div><div><dt className="text-slate-500">状态</dt><dd className="mt-1 font-semibold text-slate-800">{item.monitoringStatus === 'monitoring' ? '监控中' : '已满 6 个月'}</dd></div><div className="col-span-2"><dt className="text-slate-500">监控区间</dt><dd className="mt-1 font-semibold text-slate-800">{item.monitorStartDate} 至 {item.monitorEndDate}</dd></div></dl></Link></li>)}
</ul>
</>
)
}
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"><h4 className="text-xs font-bold text-slate-500">{group}</h4><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 DetailPanel({ state, selected, metricKey, setMetricKey, detailGroup, setDetailGroup, retry }: {
state: DetailState
selected: PerformanceConversionWorkflowItem | null
metricKey: PerformanceConversionMetricKey
setMetricKey: (key: PerformanceConversionMetricKey) => void
detailGroup: '流量' | '兴趣' | '成交'
setDetailGroup: (group: '流量' | '兴趣' | '成交') => void
retry: () => void
}) {
if (!selected) return <section id="performance-conversion-detail" className="border-t border-slate-200 bg-white px-5 py-14 text-center"><LineChart className="mx-auto h-7 w-7 text-slate-400" aria-hidden="true" /><p className="mt-3 text-sm font-semibold text-slate-700">选择一个商品 ID 查看六个月每日变化</p></section>
if (state.loading && !state.data) return <section id="performance-conversion-detail" aria-busy="true" className="border-t border-slate-200 bg-white px-5 py-14 text-center"><RefreshCw className="mx-auto h-6 w-6 animate-spin text-[#1677ff]" aria-hidden="true" /><p className="mt-3 text-sm font-semibold text-slate-700">正在读取商品每日经营数据</p></section>
if (state.error) return <section id="performance-conversion-detail" role="alert" className="border-t border-red-200 bg-red-50 px-5 py-8 text-center"><AlertTriangle className="mx-auto h-6 w-6 text-red-600" aria-hidden="true" /><p className="mt-3 text-sm font-bold text-red-800">{state.error}</p><button type="button" onClick={retry} className="mt-4 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>
if (!state.data) return null
const data = state.data
const visibleMetrics = PERFORMANCE_CONVERSION_METRICS.filter((metric) => metric.group === detailGroup)
return (
<section id="performance-conversion-detail" className="border-t 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="text-xs font-bold text-[#0050cb]">商品 ID {data.product.id}</p><h3 id="conversion-detail-title" className="mt-1 break-words text-xl font-bold text-slate-950">{data.product.name || '未匹配到天猫经营数据'}</h3><p className="mt-2 text-sm leading-6 text-slate-600">{data.product.category || '暂无类目信息'}{data.product.sourceBrand ? ` · 数据品牌 ${data.product.sourceBrand}` : ''}</p></div><div className="shrink-0 text-left text-xs leading-6 text-slate-500 lg:text-right"><p>审批编号 {data.workflow.approvalNumber || '—'}</p><p>流程完结 {formatDate(data.workflow.completedAt, true)}</p><p>数据更新至 {formatDate(data.coverage.sourceLatestDate)}</p></div></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} />
{!data.daily.some((day) => PERFORMANCE_CONVERSION_METRICS.some((metric) => day[metric.key] !== null)) ? <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><h4 className="font-bold text-slate-950">每日趋势</h4><p className="mt-1 text-xs text-slate-500">已有 {data.coverage.coveredDays} 天 / 应有 {data.coverage.expectedDaysToDate} 天,缺失日期保留断点</p></div><label className="text-xs font-bold text-slate-600">趋势指标<select value={metricKey} onChange={(event) => setMetricKey(event.target.value as PerformanceConversionMetricKey)} className="ml-2 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><h4 className="font-bold text-slate-950">每日明细</h4><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 PerformanceConversionDashboard() {
const [selectedBrand, setSelectedBrand] = useState<(typeof BUSINESS_TREND_BRANDS)[number]>(BUSINESS_TREND_BRANDS[0])
const [query, setQuery] = useState('')
@ -182,65 +74,33 @@ export default function PerformanceConversionDashboard() {
const [filterError, setFilterError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [listState, setListState] = useState<ListState>({ data: null, loading: true, error: null })
const [selected, setSelected] = useState<PerformanceConversionWorkflowItem | null>(null)
const [detailState, setDetailState] = useState<DetailState>({ data: null, loading: false, error: null })
const [metricKey, setMetricKey] = useState<PerformanceConversionMetricKey>('paymentAmount')
const [detailGroup, setDetailGroup] = useState<'流量' | '兴趣' | '成交'>('流量')
const listAbort = useRef<AbortController | null>(null)
const detailAbort = useRef<AbortController | null>(null)
const updateUrl = useCallback((brand: string, item?: PerformanceConversionWorkflowItem | null, filters?: WorkflowFilters) => {
const updateUrl = useCallback((brand: string, targetPage: number, filters: WorkflowFilters) => {
const url = new URL(window.location.href)
url.searchParams.set('brand', brand)
if (filters) {
const filterParams = {
q: filters.query,
completedFrom: filters.completedFrom,
completedTo: filters.completedTo,
}
Object.entries(filterParams).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value)
else url.searchParams.delete(key)
})
}
if (item) {
url.searchParams.set('workflowId', item.workflowId)
url.searchParams.set('productId', item.productId)
} else {
url.searchParams.delete('workflowId')
url.searchParams.delete('productId')
const filterParams = {
q: filters.query,
completedFrom: filters.completedFrom,
completedTo: filters.completedTo,
}
Object.entries(filterParams).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value)
else url.searchParams.delete(key)
})
if (targetPage > 1) url.searchParams.set('page', String(targetPage))
else url.searchParams.delete('page')
url.searchParams.delete('workflowId')
url.searchParams.delete('productId')
window.history.replaceState(null, '', url)
}, [])
const loadDetail = useCallback(async (item: PerformanceConversionWorkflowItem) => {
detailAbort.current?.abort()
const controller = new AbortController()
detailAbort.current = controller
setSelected(item)
setDetailState({ data: null, loading: true, error: null })
updateUrl(item.brand, item)
try {
const params = new URLSearchParams({ workflowId: item.workflowId, productId: item.productId })
const response = await fetch(`/api/department-dashboard/performance-conversion/timeseries?${params}`, { cache: 'no-store', signal: controller.signal })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success || !payload.data) throw new Error(payload?.error || '商品经营数据读取失败')
setDetailState({ data: payload.data, loading: false, error: null })
} catch (error) {
if ((error as Error).name === 'AbortError') return
setDetailState({ data: null, loading: false, error: (error as Error).message || '商品经营数据读取失败' })
}
}, [updateUrl])
const loadList = useCallback(async (brand: typeof selectedBrand, targetPage: number, filters: WorkflowFilters, preferred?: { workflowId: string; productId: string } | null) => {
const loadList = useCallback(async (brand: typeof selectedBrand, targetPage: number, filters: WorkflowFilters) => {
listAbort.current?.abort()
detailAbort.current?.abort()
const controller = new AbortController()
listAbort.current = controller
setSelected(null)
setDetailState({ data: null, loading: false, error: null })
setListState({ data: null, loading: true, error: null })
updateUrl(brand, null, filters)
updateUrl(brand, targetPage, filters)
try {
const params = new URLSearchParams({ brand, page: String(targetPage), pageSize: String(PAGE_SIZE) })
if (filters.query) params.set('q', filters.query)
@ -251,15 +111,11 @@ export default function PerformanceConversionDashboard() {
if (!response.ok || !payload?.success || !payload.data) throw new Error(payload?.error || '业绩转化流程读取失败')
const data = payload.data as PerformanceConversionWorkflowList
setListState({ data, loading: false, error: null })
const first = preferred
? data.items.find((item) => item.workflowId === preferred.workflowId && item.productId === preferred.productId)
: data.items[0]
if (first) void loadDetail(first)
} catch (error) {
if ((error as Error).name === 'AbortError') return
setListState({ data: null, loading: false, error: (error as Error).message || '业绩转化流程读取失败' })
}
}, [loadDetail, updateUrl])
}, [updateUrl])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
@ -267,9 +123,8 @@ export default function PerformanceConversionDashboard() {
const initialBrand = BUSINESS_TREND_BRANDS.includes(brandParam as typeof selectedBrand)
? brandParam as typeof selectedBrand
: BUSINESS_TREND_BRANDS[0]
const preferred = params.get('workflowId') && params.get('productId')
? { workflowId: params.get('workflowId') as string, productId: params.get('productId') as string }
: null
const pageParam = Number.parseInt(params.get('page') || '', 10)
const initialPage = Number.isSafeInteger(pageParam) && pageParam > 0 ? pageParam : 1
const initialFilters = {
query: params.get('q') || '',
completedFrom: isValidDateKey(params.get('completedFrom')) ? params.get('completedFrom') as string : '',
@ -280,8 +135,9 @@ export default function PerformanceConversionDashboard() {
setCompletedFrom(initialFilters.completedFrom)
setCompletedTo(initialFilters.completedTo)
setAppliedFilters(initialFilters)
void loadList(initialBrand, 1, initialFilters, preferred)
return () => { listAbort.current?.abort(); detailAbort.current?.abort() }
setPage(initialPage)
void loadList(initialBrand, initialPage, initialFilters)
return () => listAbort.current?.abort()
}, [loadList])
const submitSearch = (event: FormEvent) => {
@ -298,6 +154,18 @@ export default function PerformanceConversionDashboard() {
}
const selectedBrandCount = listState.data?.brands.find((item) => item.brand === selectedBrand)?.count || 0
const detailHref = useCallback((item: PerformanceConversionWorkflowItem) => {
const params = new URLSearchParams({
workflowId: item.workflowId,
productId: item.productId,
brand: selectedBrand,
page: String(page),
})
if (appliedFilters.query) params.set('q', appliedFilters.query)
if (appliedFilters.completedFrom) params.set('completedFrom', appliedFilters.completedFrom)
if (appliedFilters.completedTo) params.set('completedTo', appliedFilters.completedTo)
return `/workspaces/business/performance-conversion/detail?${params}`
}, [appliedFilters, page, selectedBrand])
return (
<DepartmentShell>
@ -325,8 +193,7 @@ export default function PerformanceConversionDashboard() {
{filterError && <p role="alert" className="text-xs font-semibold text-red-700 md:col-span-3">{filterError}</p>}
</form>
{listState.loading && !listState.data ? <div role="status" className="px-5 py-16 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">正在读取美术流程商品 ID</p></div> : listState.data?.items.length ? <><WorkflowTable items={listState.data.items} selectedKey={selected?.key || null} onSelect={(item) => void loadDetail(item)} /><div className="flex items-center justify-between border-t border-slate-200 bg-slate-50 px-5 py-3 text-xs text-slate-500 sm:px-7"><span>第 {page} 页 · 共 {listState.data.total} 项</span><div className="flex gap-2"><button type="button" aria-label="上一页" disabled={page <= 1 || listState.loading} onClick={() => { const next = page - 1; setPage(next); void loadList(selectedBrand, next, appliedFilters) }} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-300 bg-white text-slate-700 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><ChevronLeft className="h-4 w-4" aria-hidden="true" /></button><button type="button" aria-label="下一页" disabled={!listState.data.hasMore || listState.loading} onClick={() => { const next = page + 1; setPage(next); void loadList(selectedBrand, next, appliedFilters) }} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-300 bg-white text-slate-700 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><ChevronRight className="h-4 w-4" aria-hidden="true" /></button></div></div></> : <div className="px-5 py-16 text-center"><Activity className="mx-auto h-7 w-7 text-slate-400" aria-hidden="true" /><p className="mt-3 text-sm font-bold text-slate-700">{appliedFilters.query || appliedFilters.completedFrom || appliedFilters.completedTo ? '该筛选条件下没有匹配的流程商品 ID' : '该品牌暂无已完结且填写有效商品 ID 的美术流程'}</p>{Boolean(listState.data?.invalidProductIdCount) && <p className="mt-1 text-xs text-slate-500">有 {listState.data?.invalidProductIdCount} 条流程填写了空值或无效占位内容</p>}</div>}
<DetailPanel state={detailState} selected={selected} metricKey={metricKey} setMetricKey={setMetricKey} detailGroup={detailGroup} setDetailGroup={setDetailGroup} retry={() => selected && void loadDetail(selected)} />
{listState.loading && !listState.data ? <div role="status" className="px-5 py-16 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">正在读取美术流程商品 ID</p></div> : listState.data?.items.length ? <><WorkflowTable items={listState.data.items} detailHref={detailHref} /><div className="flex items-center justify-between border-t border-slate-200 bg-slate-50 px-5 py-3 text-xs text-slate-500 sm:px-7"><span>第 {page} 页 · 共 {listState.data.total} 项</span><div className="flex gap-2"><button type="button" aria-label="上一页" disabled={page <= 1 || listState.loading} onClick={() => { const next = page - 1; setPage(next); void loadList(selectedBrand, next, appliedFilters) }} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-300 bg-white text-slate-700 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><ChevronLeft className="h-4 w-4" aria-hidden="true" /></button><button type="button" aria-label="下一页" disabled={!listState.data.hasMore || listState.loading} onClick={() => { const next = page + 1; setPage(next); void loadList(selectedBrand, next, appliedFilters) }} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-300 bg-white text-slate-700 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><ChevronRight className="h-4 w-4" aria-hidden="true" /></button></div></div></> : <div className="px-5 py-16 text-center"><Activity className="mx-auto h-7 w-7 text-slate-400" aria-hidden="true" /><p className="mt-3 text-sm font-bold text-slate-700">{appliedFilters.query || appliedFilters.completedFrom || appliedFilters.completedTo ? '该筛选条件下没有匹配的流程商品 ID' : '该品牌暂无已完结且填写有效商品 ID 的美术流程'}</p>{Boolean(listState.data?.invalidProductIdCount) && <p className="mt-1 text-xs text-slate-500">有 {listState.data?.invalidProductIdCount} 条流程填写了空值或无效占位内容</p>}</div>}
</section>
</div>
</main>

View File

@ -0,0 +1,149 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import PerformanceConversionDetail from './PerformanceConversionDetail'
jest.mock('./DepartmentShell', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <>{children}</>,
}))
const detailData = {
workflow: {
key: 'flow-1:1041432226288',
workflowId: 'flow-1',
approvalNumber: '202609170001',
processCode: 'art-design',
title: '商品主图美术设计申请',
brand: '亿家达' as const,
productId: '1041432226288',
spu: 'SPU-001',
completedAt: '2026-07-12T08:00:00.000Z',
monitorStartDate: '2026-07-12',
monitorEndDate: '2027-01-12',
monitoringStatus: 'monitoring' as const,
},
product: {
id: '1041432226288',
name: '亿家达折叠书桌',
sourceBrand: '亿家达',
category: '住宅家具',
brandMatchesWorkflow: true,
},
latest: {
date: '2026-07-13',
impressions: 1200,
visitors: 200,
clickRate: 0.125,
favorites: 18,
cartUsers: 12,
paymentConversionRate: 0.035,
paymentAmount: 1299.5,
roi: 2.4,
},
daily: [
{
date: '2026-07-12',
impressions: 1000,
visitors: 160,
clickRate: 0.1,
favorites: 12,
cartUsers: 9,
paymentConversionRate: 0.03,
paymentAmount: 899,
roi: 2.1,
},
{
date: '2026-07-13',
impressions: 1200,
visitors: 200,
clickRate: 0.125,
favorites: 18,
cartUsers: 12,
paymentConversionRate: 0.035,
paymentAmount: 1299.5,
roi: 2.4,
},
],
coverage: {
sourceFirstDate: '2026-07-12',
sourceLatestDate: '2026-07-13',
coveredDays: 2,
expectedDaysToDate: 2,
monitorStartDate: '2026-07-12',
monitorEndDate: '2027-01-12',
},
}
describe('PerformanceConversionDetail', () => {
const originalFetch = global.fetch
afterEach(() => {
global.fetch = originalFetch
jest.restoreAllMocks()
})
it('loads the selected product on its own page and links back to the filtered list', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, data: detailData }),
} as Response)
global.fetch = fetchMock
const returnHref = '/workspaces/business/performance-conversion?brand=%E4%BA%BF%E5%AE%B6%E8%BE%BE&page=3'
render(
<PerformanceConversionDetail
workflowId="flow-1"
productId="1041432226288"
returnHref={returnHref}
/>,
)
expect(await screen.findByRole('heading', { level: 1, name: '亿家达折叠书桌' })).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith(
'/api/department-dashboard/performance-conversion/timeseries?workflowId=flow-1&productId=1041432226288',
expect.objectContaining({ cache: 'no-store' }),
)
expect(screen.getByRole('link', { name: '返回业绩转化' })).toHaveAttribute('href', returnHref)
expect(screen.getAllByText('12.50%').length).toBeGreaterThan(0)
expect(screen.getAllByText('¥1,299.50').length).toBeGreaterThan(0)
expect(screen.getByRole('img', { name: /支付金额从/ })).toBeInTheDocument()
expect(screen.getByRole('table')).toHaveTextContent('2026-07-13')
})
it('shows the no-data state without hiding the product and workflow metadata', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
data: { ...detailData, latest: null, daily: [] },
}),
} as Response)
render(<PerformanceConversionDetail workflowId="flow-1" productId="1041432226288" returnHref="/workspaces/business/performance-conversion" />)
expect(await screen.findByRole('heading', { name: '亿家达折叠书桌' })).toBeInTheDocument()
expect(screen.getByText('该商品在流程完结后的监控区间内暂无经营数据')).toBeInTheDocument()
expect(screen.getByText(/审批编号 202609170001/)).toBeInTheDocument()
})
it('shows the API error and allows the user to retry', async () => {
const fetchMock = jest.fn()
.mockResolvedValueOnce({
ok: false,
json: async () => ({ success: false, error: '商品 ID 不属于该流程' }),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, data: detailData }),
} as Response)
global.fetch = fetchMock
render(<PerformanceConversionDetail workflowId="flow-1" productId="1041432226288" returnHref="/workspaces/business/performance-conversion" />)
expect(await screen.findByRole('heading', { name: '无法打开商品数据' })).toBeInTheDocument()
expect(screen.getByText('商品 ID 不属于该流程')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => expect(screen.getByRole('heading', { name: '亿家达折叠书桌' })).toBeInTheDocument())
expect(fetchMock).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,329 @@
'use client'
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,
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 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} />
{!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>
)
}