From 35a301a45eecb97343affb6032f0c818ff332cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E6=9E=97=E9=93=96?= Date: Thu, 17 Sep 2026 15:29:01 +0800 Subject: [PATCH] feat: open performance conversion details separately --- .../performance-conversion/detail/page.tsx | 42 +++ .../PerformanceConversionDashboard.test.tsx | 54 +++ .../PerformanceConversionDashboard.tsx | 213 +++--------- .../PerformanceConversionDetail.test.tsx | 149 ++++++++ .../PerformanceConversionDetail.tsx | 329 ++++++++++++++++++ 5 files changed, 614 insertions(+), 173 deletions(-) create mode 100644 app/workspaces/business/performance-conversion/detail/page.tsx create mode 100644 components/department-dashboard/PerformanceConversionDetail.test.tsx create mode 100644 components/department-dashboard/PerformanceConversionDetail.tsx diff --git a/app/workspaces/business/performance-conversion/detail/page.tsx b/app/workspaces/business/performance-conversion/detail/page.tsx new file mode 100644 index 0000000..1773ee6 --- /dev/null +++ b/app/workspaces/business/performance-conversion/detail/page.tsx @@ -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 + +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 ( + + ) +} diff --git a/components/department-dashboard/PerformanceConversionDashboard.test.tsx b/components/department-dashboard/PerformanceConversionDashboard.test.tsx index 84e0c81..3f0690e 100644 --- a/components/department-dashboard/PerformanceConversionDashboard.test.tsx +++ b/components/department-dashboard/PerformanceConversionDashboard.test.tsx @@ -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() + + 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()) + }) }) diff --git a/components/department-dashboard/PerformanceConversionDashboard.tsx b/components/department-dashboard/PerformanceConversionDashboard.tsx index 5bdaedf..4e8d9a6 100644 --- a/components/department-dashboard/PerformanceConversionDashboard.tsx +++ b/components/department-dashboard/PerformanceConversionDashboard.tsx @@ -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
监控区间内暂无{definition.label}数据
- } - - return ( -
- - {[0, 0.5, 1].map((position) => { - const lineY = plot.top + position * (plot.bottom - plot.top) - return - })} - {segments.map((path, index) => )} - {daily[0]?.date || ''} - {daily[middleIndex]?.date || ''} - {daily[daily.length - 1]?.date || ''} - -
缺失日期保持断点,准确数值见下方每日明细表。
-
- ) -} - -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 }: { 2026 年 4 月 1 日以后已完结美术流程关联的商品 ID 商品 ID审批编号完结时间监控区间状态 - {items.map((item) => { - const selected = selectedKey === item.key - return {item.spu &&

SPU {item.spu}

}

{item.approvalNumber || '—'}

实例 {item.workflowId}

{formatDate(item.completedAt, true)}{item.monitorStartDate} 至 {item.monitorEndDate}{item.monitoringStatus === 'monitoring' ? '监控中' : '已满 6 个月'} - })} + {items.map((item) => {item.productId}{item.spu &&

SPU {item.spu}

}

{item.approvalNumber || '—'}

实例 {item.workflowId}

{formatDate(item.completedAt, true)}{item.monitorStartDate} 至 {item.monitorEndDate}{item.monitoringStatus === 'monitoring' ? '监控中' : '已满 6 个月'})}
    - {items.map((item) =>
  • )} + {items.map((item) =>
  • {item.productId}审批编号 {item.approvalNumber || '—'}
    完结时间
    {formatDate(item.completedAt)}
    状态
    {item.monitoringStatus === 'monitoring' ? '监控中' : '已满 6 个月'}
    监控区间
    {item.monitorStartDate} 至 {item.monitorEndDate}
  • )}
) } -function MetricSummary({ data }: { data: PerformanceConversionTimeseries }) { - const groups = ['流量', '兴趣', '成交'] as const - return
{groups.map((group) =>

{group}

{PERFORMANCE_CONVERSION_METRICS.filter((metric) => metric.group === group).map((metric) =>
{metric.label}
{formatMetric(data.latest?.[metric.key] ?? null, metric.format)}
)}
)}
-} - -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
- if (state.loading && !state.data) return
- if (state.error) return - if (!state.data) return null - const data = state.data - const visibleMetrics = PERFORMANCE_CONVERSION_METRICS.filter((metric) => metric.group === detailGroup) - return ( -
-

商品 ID {data.product.id}

{data.product.name || '未匹配到天猫经营数据'}

{data.product.category || '暂无类目信息'}{data.product.sourceBrand ? ` · 数据品牌 ${data.product.sourceBrand}` : ''}

审批编号 {data.workflow.approvalNumber || '—'}

流程完结 {formatDate(data.workflow.completedAt, true)}

数据更新至 {formatDate(data.coverage.sourceLatestDate)}

{data.product.brandMatchesWorkflow === false &&

流程品牌与经营数据品牌不一致,请核对商品 ID。

}
- - {!data.daily.some((day) => PERFORMANCE_CONVERSION_METRICS.some((metric) => day[metric.key] !== null)) ?
: <> -

每日趋势

已有 {data.coverage.coveredDays} 天 / 应有 {data.coverage.expectedDaysToDate} 天,缺失日期保留断点

- -

每日明细

0 为真实数值,— 表示当天无数据

{(['流量', '兴趣', '成交'] as const).map((group) => )}
-
{visibleMetrics.map((metric) => )}{[...data.daily].reverse().map((day) => {visibleMetrics.map((metric) => )})}
日期{metric.label}
{day.date}{formatMetric(day[metric.key], metric.format)}
- } -
- ) -} - 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(null) const [page, setPage] = useState(1) const [listState, setListState] = useState({ data: null, loading: true, error: null }) - const [selected, setSelected] = useState(null) - const [detailState, setDetailState] = useState({ data: null, loading: false, error: null }) - const [metricKey, setMetricKey] = useState('paymentAmount') - const [detailGroup, setDetailGroup] = useState<'流量' | '兴趣' | '成交'>('流量') const listAbort = useRef(null) - const detailAbort = useRef(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 ( @@ -325,8 +193,7 @@ export default function PerformanceConversionDashboard() { {filterError &&

{filterError}

} - {listState.loading && !listState.data ?
: listState.data?.items.length ? <> void loadDetail(item)} />
第 {page} 页 · 共 {listState.data.total} 项
:
} - selected && void loadDetail(selected)} /> + {listState.loading && !listState.data ?
: listState.data?.items.length ? <>
第 {page} 页 · 共 {listState.data.total} 项
:
} diff --git a/components/department-dashboard/PerformanceConversionDetail.test.tsx b/components/department-dashboard/PerformanceConversionDetail.test.tsx new file mode 100644 index 0000000..0383512 --- /dev/null +++ b/components/department-dashboard/PerformanceConversionDetail.test.tsx @@ -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( + , + ) + + 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() + + 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() + + 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) + }) +}) diff --git a/components/department-dashboard/PerformanceConversionDetail.tsx b/components/department-dashboard/PerformanceConversionDetail.tsx new file mode 100644 index 0000000..8faba0d --- /dev/null +++ b/components/department-dashboard/PerformanceConversionDetail.tsx @@ -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 ( +
+ 监控区间内暂无{definition.label}数据 +
+ ) + } + + return ( +
+ + {[0, 0.5, 1].map((position) => { + const lineY = plot.top + position * (plot.bottom - plot.top) + return + })} + {segments.map((path, index) => ( + + ))} + {daily[0]?.date || ''} + {daily[middleIndex]?.date || ''} + {daily[daily.length - 1]?.date || ''} + +
缺失日期保持断点,准确数值见下方每日明细表。
+
+ ) +} + +function MetricSummary({ data }: { data: PerformanceConversionTimeseries }) { + const groups = ['流量', '兴趣', '成交'] as const + return ( +
+ {groups.map((group) => ( +
+

{group}

+
+ {PERFORMANCE_CONVERSION_METRICS.filter((metric) => metric.group === group).map((metric) => ( +
+
{metric.label}
+
+ {formatMetric(data.latest?.[metric.key] ?? null, metric.format)} +
+
+ ))} +
+
+ ))} +
+ ) +} + +function DetailContent({ data }: { data: PerformanceConversionTimeseries }) { + const [metricKey, setMetricKey] = useState('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 ( +
+
+
+
+

商品 ID {data.product.id}

+

+ {data.product.name || '未匹配到天猫经营数据'} +

+

+ {data.product.category || '暂无类目信息'} + {data.product.sourceBrand ? ` · 数据品牌 ${data.product.sourceBrand}` : ''} +

+
+
+
审批编号
审批编号 {data.workflow.approvalNumber || '—'}
+
流程完结
流程完结 {formatDate(data.workflow.completedAt, true)}
+
数据更新
数据更新至 {formatDate(data.coverage.sourceLatestDate)}
+
+
+ {data.product.brandMatchesWorkflow === false && ( +

+ 流程品牌与经营数据品牌不一致,请核对商品 ID。 +

+ )} +
+ + + + {!hasDailyData ? ( +
+
+ ) : ( + <> +
+
+

每日趋势

+

已有 {data.coverage.coveredDays} 天 / 应有 {data.coverage.expectedDaysToDate} 天,缺失日期保留断点

+
+ +
+ +
+
+

每日明细

+

0 为真实数值,— 表示当天无数据

+
+
+ {(['流量', '兴趣', '成交'] as const).map((group) => ( + + ))} +
+
+
+ + + + + {visibleMetrics.map((metric) => )} + + + + {[...data.daily].reverse().map((day) => ( + + + {visibleMetrics.map((metric) => ( + + ))} + + ))} + +
日期{metric.label}
{day.date} + {formatMetric(day[metric.key], metric.format)} +
+
+ + )} +
+ ) +} + +export default function PerformanceConversionDetail({ + workflowId, + productId, + returnHref, +}: { + workflowId: string + productId: string + returnHref: string +}) { + const [state, setState] = useState({ 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 ( + +
+
+
+ +
+ + {state.loading ? ( +
+
+ ) : state.error || !state.data ? ( +
+
+ ) : ( + + )} +
+
+
+ ) +}