feat: open performance conversion details separately
This commit is contained in:
parent
6dbafbd5ed
commit
35a301a45e
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
329
components/department-dashboard/PerformanceConversionDetail.tsx
Normal file
329
components/department-dashboard/PerformanceConversionDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user