接入本月新品流程指标

This commit is contained in:
江林铖 2026-08-26 11:21:23 +08:00
parent 8bb0fc5c47
commit 8490b3b371
10 changed files with 643 additions and 39 deletions

View File

@ -0,0 +1,36 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
DingWorkflowMetricApiError,
getMonthlyLaunchSummary,
} from '@/lib/ding-workflow-sync'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
export async function GET() {
try {
const session = await getServerSession(authOptions)
const data = await getMonthlyLaunchSummary(session)
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof DingWorkflowMetricApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 本月上新指标读取失败:', error)
return NextResponse.json(
{ success: false, error: '本月上新指标读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -4,7 +4,10 @@ import { notFound } from 'next/navigation'
import CoreMetricDetail from '@/components/department-dashboard/CoreMetricDetail'
import { getCoreMetric } from '@/components/department-dashboard/core-metrics'
import { authOptions } from '@/lib/auth'
import { getOngoingDingWorkflowInsights } from '@/lib/ding-workflow-sync'
import {
getMonthlyLaunchInsights,
getOngoingDingWorkflowInsights,
} from '@/lib/ding-workflow-sync'
import {
getFirstPassReviewProjects,
} from '@/lib/work-review-service'
@ -38,6 +41,12 @@ export default async function CoreMetricPage({ params, searchParams }: CoreMetri
return <CoreMetricDetail metric={metric} projectInsights={projectInsights} />
}
if (metric.key === 'monthly-launches') {
const session = await getServerSession(authOptions)
const monthlyLaunches = await getMonthlyLaunchInsights(session)
return <CoreMetricDetail metric={metric} monthlyLaunches={monthlyLaunches} />
}
if (metric.key === 'first-pass-rate') {
const session = await getServerSession(authOptions)
const firstPassPage = await getFirstPassReviewProjects(session, { page, pageSize: 20, query })

View File

@ -80,6 +80,93 @@ describe('CoreMetricDetail', () => {
expect(screen.getByText('数据源待接入')).toBeInTheDocument()
})
it('按六品牌切换查看本月完成与当前进行中的美术设计流程', () => {
const baseProject = {
businessId: 'BUSINESS-1',
title: '新品主视觉申请',
status: 'COMPLETED',
processCode: 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838',
projectType: '美术设计流程',
brand: '亿家达',
requesterName: '林需求',
requesterDepartment: '品牌中心',
initiatorName: '荀瑶',
initiatorDepartment: '运营中心A-事业四部-品类九部',
originatorUserId: 'ding-1',
originatorDeptName: '运营中心A',
sourceCreatedAt: '2026-04-20T00:00:00.000Z',
sourceUpdatedAt: '2026-08-14T02:30:00.000Z',
activeTaskCount: 0,
taskCount: 0,
schemeFieldCount: 0,
latestTaskStatus: null,
activeTasks: [],
result: 'AGREE',
}
render(
<CoreMetricDetail
metric={getCoreMetric('monthly-launches')!}
monthlyLaunches={{
summary: {
completed: 1,
ongoing: 1,
total: 2,
monthStart: '2026-07-31T16:00:00.000Z',
monthEnd: '2026-08-31T16:00:00.000Z',
lastSyncedAt: '2026-08-14T02:30:00.000Z',
syncStatus: 'ready',
},
brands: [
{ brand: '亿家达', completed: 1, ongoing: 1, total: 2 },
{ brand: '卓禾', completed: 0, ongoing: 0, total: 0 },
{ brand: '欧意朗', completed: 0, ongoing: 0, total: 0 },
{ brand: '顾家', completed: 0, ongoing: 0, total: 0 },
{ brand: '索菲亚', completed: 0, ongoing: 0, total: 0 },
{ brand: '朴美', completed: 0, ongoing: 0, total: 0 },
],
items: [
{
...baseProject,
id: 'completed-1',
launchStatus: 'completed',
sourceFinishedAt: '2026-08-12T03:00:00.000Z',
},
{
...baseProject,
id: 'ongoing-1',
businessId: 'BUSINESS-2',
title: '新品场景设计申请',
status: 'RUNNING',
launchStatus: 'ongoing',
sourceFinishedAt: null,
activeTaskCount: 1,
activeTasks: [{
taskId: 'task-1',
activityId: 'activity-1',
nodeName: '品牌负责人审批',
assigneeUserId: 'ding-owner',
assigneeName: '陈负责人',
assigneeDepartment: '品牌中心',
isBrandCenter: true,
createdAt: '2026-08-14T02:00:00.000Z',
}],
},
],
}}
/>,
)
expect(screen.getByRole('heading', { level: 1, name: '本月上新' })).toBeInTheDocument()
expect(screen.getByText('1/2')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '亿家达 · 已完成新品' })).toBeInTheDocument()
expect(screen.getAllByText('新品主视觉申请').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: '查看亿家达进行中新品明细,共1条' }))
expect(screen.getByRole('heading', { name: '亿家达 · 进行中新品' })).toBeInTheDocument()
expect(screen.getAllByText('新品场景设计申请').length).toBeGreaterThan(0)
expect(screen.getAllByText(/品牌负责人审批/).length).toBeGreaterThan(0)
})
it('在按时交付明细中只用黄色数值呈现阈值状态', () => {
render(<CoreMetricDetail metric={getCoreMetric('on-time-delivery')!} />)

View File

@ -13,8 +13,12 @@ import type {
FirstPassReviewProjectListItem,
FirstPassReviewProjectPage,
} from '@/lib/work-review-service'
import type { OngoingDingWorkflowInsights } from '@/lib/ding-workflow-sync'
import type {
MonthlyLaunchInsights,
OngoingDingWorkflowInsights,
} from '@/lib/ding-workflow-sync'
import DepartmentShell from './DepartmentShell'
import MonthlyLaunchesInsights from './MonthlyLaunchesInsights'
import OngoingProjectsInsights from './OngoingProjectsInsights'
import OnTimeDeliveryRate from './OnTimeDeliveryRate'
import type { CoreMetric } from './core-metrics'
@ -23,6 +27,7 @@ type CoreMetricDetailProps = {
metric: CoreMetric
query?: string
projectInsights?: OngoingDingWorkflowInsights
monthlyLaunches?: MonthlyLaunchInsights
firstPassPage?: FirstPassReviewProjectPage
}
@ -244,8 +249,9 @@ function PendingDataSource({ metric }: { metric: CoreMetric }) {
)
}
export default function CoreMetricDetail({ metric, query, projectInsights, firstPassPage }: CoreMetricDetailProps) {
export default function CoreMetricDetail({ metric, query, projectInsights, monthlyLaunches, firstPassPage }: CoreMetricDetailProps) {
const hasRealProjectSource = metric.key === 'ongoing-projects' && projectInsights
const hasMonthlyLaunchSource = metric.key === 'monthly-launches' && monthlyLaunches
const hasFirstPassSource = metric.key === 'first-pass-rate' && firstPassPage
const isOnTimeDelivery = metric.key === 'on-time-delivery'
@ -259,14 +265,14 @@ export default function CoreMetricDetail({ metric, query, projectInsights, first
<section aria-label="指标口径" className="mt-6 grid overflow-hidden rounded-lg border border-[#e2e8f0] bg-white sm:grid-cols-3">
<div className="border-b border-slate-200 p-5 sm:border-b-0 sm:border-r">
<p className="text-xs font-bold text-slate-500">{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : '总览示例值'}</p>
<p className="text-xs font-bold text-slate-500">{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : hasMonthlyLaunchSource ? '本月完成 / 新品总数' : '总览示例值'}</p>
{isOnTimeDelivery && metric.exampleNumericValue !== undefined
? <OnTimeDeliveryRate rate={metric.exampleNumericValue} contextLabel="按时交付示例值" size="summary" />
: <p className="mt-2 text-2xl font-bold tabular-nums">{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : metric.exampleValue}</p>}
: <p className="mt-2 text-2xl font-bold tabular-nums">{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : hasMonthlyLaunchSource ? `${monthlyLaunches.summary.completed}/${monthlyLaunches.summary.total}` : metric.exampleValue}</p>}
</div>
<div className="border-b border-slate-200 p-5 sm:border-b-0 sm:border-r">
<p className="text-xs font-bold text-slate-500">明细数据</p>
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : '等待业务数据源'}</p>
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `已完成 ${monthlyLaunches.summary.completed} / 进行中 ${monthlyLaunches.summary.ongoing}` : '等待业务数据源'}</p>
</div>
<div className="p-5">
<p className="text-xs font-bold text-slate-500">同步状态</p>
@ -276,6 +282,10 @@ export default function CoreMetricDetail({ metric, query, projectInsights, first
? projectInsights.sync.lastSyncedAt
? `每小时增量同步 · ${formatDate(projectInsights.sync.lastSyncedAt, true)}`
: projectInsights.sync.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步'
: hasMonthlyLaunchSource
? monthlyLaunches.summary.lastSyncedAt
? `每小时增量同步 · ${formatDate(monthlyLaunches.summary.lastSyncedAt, true)}`
: monthlyLaunches.summary.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步'
: hasFirstPassSource ? '打开页面时读取' : '每日同步待接入'}
</p>
</div>
@ -283,6 +293,8 @@ export default function CoreMetricDetail({ metric, query, projectInsights, first
{hasRealProjectSource
? <OngoingProjectsInsights insights={projectInsights} />
: hasMonthlyLaunchSource
? <MonthlyLaunchesInsights insights={monthlyLaunches} />
: hasFirstPassSource
? <FirstPassProjectList metric={metric} query={query} firstPassPage={firstPassPage} />
: <PendingDataSource metric={metric} />}

View File

@ -19,16 +19,29 @@ describe('CoreMetricsDashboard', () => {
syncStatus: 'ready',
},
}
: {
success: true,
data: {
totalUploads: 30,
firstPassApproved: 11,
rate: 36.7,
statusBreakdown: { approved: 11, revisionRequired: 7, pendingReview: 10, unknown: 2 },
readAt: '2026-08-14T02:00:00.000Z',
: url.includes('monthly-launches')
? {
success: true,
data: {
completed: 8,
ongoing: 5,
total: 13,
monthStart: '2026-07-31T16:00:00.000Z',
monthEnd: '2026-08-31T16:00:00.000Z',
lastSyncedAt: '2026-08-14T02:00:00.000Z',
syncStatus: 'ready',
},
}
: {
success: true,
data: {
totalUploads: 30,
firstPassApproved: 11,
rate: 36.7,
statusBreakdown: { approved: 11, revisionRequired: 7, pendingReview: 10, unknown: 2 },
readAt: '2026-08-14T02:00:00.000Z',
},
},
},
})) as jest.Mock
})
@ -36,7 +49,7 @@ describe('CoreMetricsDashboard', () => {
jest.restoreAllMocks()
})
it('展示真实进行中流程、首轮通过率与其余四项诚实的示例状态', async () => {
it('展示真实流程指标、首轮通过率与其余三项诚实的示例状态', async () => {
render(<CoreMetricsDashboard />)
expect(screen.getByRole('heading', { level: 1, name: '核心指标' })).toBeInTheDocument()
@ -44,19 +57,21 @@ describe('CoreMetricsDashboard', () => {
expect(await screen.findByText('36.7%')).toBeInTheDocument()
expect(screen.getByText('V1通过 11 / 全部 30')).toBeInTheDocument()
expect(screen.getByText('实时读取')).toBeInTheDocument()
expect(screen.getByText('每小时同步')).toBeInTheDocument()
expect(screen.getAllByText('示例值')).toHaveLength(4)
expect(screen.getAllByText('每小时同步').length).toBeGreaterThan(0)
expect(screen.getAllByText('示例值')).toHaveLength(3)
for (const label of ['进行中项目', '本月上新', '按时交付', '首轮通过', '团队负荷', '风险']) {
expect(screen.getByRole('heading', { level: 3, name: label })).toBeInTheDocument()
}
for (const value of ['2338', '18/24', '94.6%', '36.7%', '87%', '4项']) {
for (const value of ['2338', '8/13', '94.6%', '36.7%', '87%', '4项']) {
expect(screen.getByText(value)).toBeInTheDocument()
}
expect(screen.getAllByText('公式待接入')).toHaveLength(4)
expect(screen.getByText('钉钉流程快照')).toBeInTheDocument()
expect(screen.getByText('已完成 8 · 进行中 5')).toBeInTheDocument()
expect(screen.getAllByText('公式待接入')).toHaveLength(3)
expect(screen.getAllByText('钉钉流程快照').length).toBeGreaterThan(0)
expect(screen.getByText('口径已定义')).toBeInTheDocument()
expect(screen.getAllByText('查看明细')).toHaveLength(6)
const onTimeDelivery = screen.getByRole('link', { name: '查看按时交付明细' })
@ -77,8 +92,8 @@ describe('CoreMetricsDashboard', () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({ success: false }) }) as jest.Mock
render(<CoreMetricsDashboard />)
expect(await screen.findAllByText('读取失败')).toHaveLength(2)
expect(await screen.findAllByText('读取失败')).toHaveLength(3)
expect(screen.queryByText('82.3%')).not.toBeInTheDocument()
expect(screen.getAllByText('数据未加载')).toHaveLength(2)
expect(screen.getAllByText('数据未加载')).toHaveLength(3)
})
})

View File

@ -7,7 +7,10 @@ import {
CalendarClock,
} from 'lucide-react'
import type { FirstPassReviewSummary } from '@/lib/work-review-metrics'
import type { OngoingDingWorkflowSummary } from '@/lib/ding-workflow-sync'
import type {
MonthlyLaunchSummary,
OngoingDingWorkflowSummary,
} from '@/lib/ding-workflow-sync'
import DepartmentShell from './DepartmentShell'
import { CORE_METRICS, getCoreMetricHref } from './core-metrics'
import OnTimeDeliveryRate from './OnTimeDeliveryRate'
@ -22,6 +25,11 @@ type OngoingWorkflowState =
| { status: 'success'; data: OngoingDingWorkflowSummary }
| { status: 'error'; data: null }
type MonthlyLaunchState =
| { status: 'loading'; data: null }
| { status: 'success'; data: MonthlyLaunchSummary }
| { status: 'error'; data: null }
function formatRate(rate: number | null) {
if (rate === null) return '—'
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
@ -42,6 +50,7 @@ function formatSyncTime(value: string | null) {
export default function CoreMetricsDashboard() {
const [firstPass, setFirstPass] = useState<FirstPassState>({ status: 'loading', data: null })
const [ongoingWorkflows, setOngoingWorkflows] = useState<OngoingWorkflowState>({ status: 'loading', data: null })
const [monthlyLaunches, setMonthlyLaunches] = useState<MonthlyLaunchState>({ status: 'loading', data: null })
useEffect(() => {
const controller = new AbortController()
@ -74,8 +83,23 @@ export default function CoreMetricsDashboard() {
}
}
async function loadMonthlyLaunchSummary() {
try {
const response = await fetch('/api/department-dashboard/core-metrics/monthly-launches', {
cache: 'no-store',
signal: controller.signal,
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success || !payload.data) throw new Error('指标读取失败')
setMonthlyLaunches({ status: 'success', data: payload.data })
} catch (error) {
if ((error as Error).name !== 'AbortError') setMonthlyLaunches({ status: 'error', data: null })
}
}
void loadFirstPassSummary()
void loadOngoingWorkflowSummary()
void loadMonthlyLaunchSummary()
return () => controller.abort()
}, [])
@ -108,6 +132,7 @@ export default function CoreMetricsDashboard() {
{CORE_METRICS.map((metric) => {
const Icon = metric.icon
const isOngoingWorkflow = metric.key === 'ongoing-projects'
const isMonthlyLaunch = metric.key === 'monthly-launches'
const isFirstPass = metric.key === 'first-pass-rate'
const isOnTimeDelivery = metric.key === 'on-time-delivery'
const badge = isOngoingWorkflow
@ -120,11 +145,23 @@ export default function CoreMetricsDashboard() {
? '同步异常'
: '等待首轮同步'
: ongoingWorkflows.status === 'error' ? '读取失败' : '正在读取'
: isMonthlyLaunch
? monthlyLaunches.status === 'success'
? monthlyLaunches.data.syncStatus === 'ready'
? '每小时同步'
: monthlyLaunches.data.syncStatus === 'syncing'
? '正在同步'
: monthlyLaunches.data.syncStatus === 'error'
? '同步异常'
: '等待首轮同步'
: monthlyLaunches.status === 'error' ? '读取失败' : '正在读取'
: isFirstPass
? firstPass.status === 'success' ? '实时读取' : firstPass.status === 'error' ? '读取失败' : '正在读取'
: '示例值'
const value = isOngoingWorkflow
? ongoingWorkflows.status === 'success' ? String(ongoingWorkflows.data.total) : '—'
: isMonthlyLaunch
? monthlyLaunches.status === 'success' ? `${monthlyLaunches.data.completed}/${monthlyLaunches.data.total}` : '—'
: isFirstPass
? firstPass.status === 'success' ? formatRate(firstPass.data.rate) : '—'
: metric.exampleValue
@ -146,11 +183,11 @@ export default function CoreMetricsDashboard() {
</div>
<h3 className="mt-6 text-base font-bold text-[#4f6077]">{metric.label}</h3>
<div aria-live={isFirstPass || isOngoingWorkflow ? 'polite' : undefined} aria-atomic={isFirstPass || isOngoingWorkflow ? 'true' : undefined}>
<div aria-live={isFirstPass || isOngoingWorkflow || isMonthlyLaunch ? 'polite' : undefined} aria-atomic={isFirstPass || isOngoingWorkflow || isMonthlyLaunch ? 'true' : undefined}>
{isOnTimeDelivery && metric.exampleNumericValue !== undefined
? <OnTimeDeliveryRate rate={metric.exampleNumericValue} contextLabel={`${metric.label}示例值`} />
: (
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass || isOngoingWorkflow ? '当前值' : '示例值'}${value}`}>
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass || isOngoingWorkflow || isMonthlyLaunch ? '当前值' : '示例值'}${value}`}>
{value}
</p>
)}
@ -170,6 +207,14 @@ export default function CoreMetricsDashboard() {
{isOngoingWorkflow && ongoingWorkflows.status === 'loading' && (
<p className="mt-2 text-xs font-semibold text-slate-500">正在读取本地流程快照</p>
)}
{isMonthlyLaunch && monthlyLaunches.status === 'success' && (
<p className="mt-2 text-xs font-semibold tabular-nums text-slate-500">
已完成 {monthlyLaunches.data.completed} · 进行中 {monthlyLaunches.data.ongoing}
</p>
)}
{isMonthlyLaunch && monthlyLaunches.status === 'loading' && (
<p className="mt-2 text-xs font-semibold text-slate-500">正在读取本地流程快照</p>
)}
</div>
<p className="mt-3 text-sm leading-6 text-[#66758b]">{metric.description}</p>
@ -177,13 +222,13 @@ export default function CoreMetricsDashboard() {
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-slate-500">
<span className="inline-flex items-center gap-1.5">
<CalendarClock className="h-3.5 w-3.5" aria-hidden="true" />
{isOngoingWorkflow
? ongoingWorkflows.status === 'error' ? '刷新页面重试' : '每小时增量同步'
{isOngoingWorkflow || isMonthlyLaunch
? (isOngoingWorkflow ? ongoingWorkflows.status : monthlyLaunches.status) === 'error' ? '刷新页面重试' : '每小时增量同步'
: isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '打开页面时读取') : '每日同步'}
</span>
<span aria-hidden="true">·</span>
<span>{isOngoingWorkflow
? ongoingWorkflows.status === 'error' ? '数据未加载' : '钉钉流程快照'
<span>{isOngoingWorkflow || isMonthlyLaunch
? (isOngoingWorkflow ? ongoingWorkflows.status : monthlyLaunches.status) === 'error' ? '数据未加载' : '钉钉流程快照'
: isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '口径已定义') : '公式待接入'}</span>
</span>
<span className="inline-flex items-center gap-1 text-[#176fc1]">
@ -196,7 +241,7 @@ export default function CoreMetricsDashboard() {
})}
</div>
<p id="metrics-disclaimer" className="mt-3 text-xs leading-5 text-[#66758b]">
“进行中项目”每小时读取钉钉审批流程快照,“首轮通过”读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。
“进行中项目”和“本月上新”每小时读取钉钉审批流程三表快照,“首轮通过”读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。
</p>
</section>

View File

@ -0,0 +1,213 @@
'use client'
import { useMemo, useState } from 'react'
import {
CheckCircle2,
ChevronDown,
Clock3,
PackageOpen,
} from 'lucide-react'
import type {
MonthlyLaunchBrandSummary,
MonthlyLaunchInsights,
MonthlyLaunchWorkflowItem,
MonthlyLaunchWorkflowStatus,
} from '@/lib/ding-workflow-sync'
function formatDate(value: string | null) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Asia/Shanghai',
}).format(new Date(value))
}
function statusLabel(status: MonthlyLaunchWorkflowStatus) {
return status === 'completed' ? '已完成新品' : '进行中新品'
}
function CurrentProgress({ item }: { item: MonthlyLaunchWorkflowItem }) {
if (item.launchStatus === 'completed') {
return (
<div>
<p className="font-bold text-emerald-700">流程已完成</p>
<p className="mt-1 text-xs tabular-nums text-slate-500">完成于 {formatDate(item.sourceFinishedAt)}</p>
</div>
)
}
if (item.activeTasks.length === 0) return <span className="text-slate-500">待进入处理节点</span>
return (
<div className="space-y-1.5">
{item.activeTasks.map((task) => (
<p key={task.taskId} className="leading-5 text-slate-700">
<span className="font-bold text-slate-950">{task.nodeName}</span>
<span className="mx-1.5 text-slate-300">·</span>
{task.assigneeName}
</p>
))}
</div>
)
}
export default function MonthlyLaunchesInsights({ insights }: { insights: MonthlyLaunchInsights }) {
const [selectedBrand, setSelectedBrand] = useState(insights.brands[0]?.brand || '亿家达')
const [selectedStatus, setSelectedStatus] = useState<MonthlyLaunchWorkflowStatus>('completed')
const [visibleCount, setVisibleCount] = useState(30)
const selectedItems = useMemo(() => insights.items.filter((item) => (
item.brand === selectedBrand && item.launchStatus === selectedStatus
)), [insights.items, selectedBrand, selectedStatus])
const selectGroup = (brand: MonthlyLaunchBrandSummary['brand'], status: MonthlyLaunchWorkflowStatus) => {
setSelectedBrand(brand)
setSelectedStatus(status)
setVisibleCount(30)
}
return (
<section aria-labelledby="monthly-launch-title" className="mt-10">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 id="monthly-launch-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">六品牌本月新品流程</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-600">
已完成按本月完成时间统计;进行中读取当前未完结流程,不限制发起月份。取消和终止流程不计入。
</p>
</div>
<p className="text-sm font-semibold tabular-nums text-slate-600">
已完成 {insights.summary.completed} · 进行中 {insights.summary.ongoing}
</p>
</div>
<div className="mt-5 grid gap-3 lg:grid-cols-2" aria-label="六品牌新品流程数量">
{insights.brands.map((brand) => (
<article key={brand.brand} className="min-w-0 overflow-hidden rounded-xl border border-slate-200 bg-white">
<div className="flex items-center justify-between gap-4 border-b border-slate-200 px-5 py-4">
<h3 className="min-w-0 truncate text-lg font-bold text-slate-950">{brand.brand}</h3>
<p className="shrink-0 text-sm font-semibold tabular-nums text-slate-500">合计 {brand.total}</p>
</div>
<div className="grid grid-cols-2 gap-px bg-slate-200">
{([
{ status: 'completed' as const, label: '已完成新品', count: brand.completed, icon: CheckCircle2 },
{ status: 'ongoing' as const, label: '进行中新品', count: brand.ongoing, icon: Clock3 },
]).map((group) => {
const Icon = group.icon
const selected = selectedBrand === brand.brand && selectedStatus === group.status
return (
<button
key={group.status}
type="button"
aria-pressed={selected}
aria-label={`查看${brand.brand}${group.label}明细,共${group.count}条`}
onClick={() => selectGroup(brand.brand, group.status)}
className={`group min-w-0 px-5 py-4 text-left transition-colors focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#5a9ce6] ${selected ? 'bg-[#e5f2ff] text-[#075fab]' : 'bg-white hover:bg-slate-50'}`}
>
<div className="flex items-start justify-between gap-3">
<span className="inline-flex min-w-0 items-center gap-2 text-sm font-bold">
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="truncate">{group.label}</span>
</span>
<span className="shrink-0 text-2xl font-bold tabular-nums">{group.count}</span>
</div>
</button>
)
})}
</div>
</article>
))}
</div>
<section aria-labelledby="monthly-launch-detail-title" className="mt-6 overflow-hidden rounded-xl border border-slate-200 bg-white">
<div className="flex flex-col gap-2 border-b border-slate-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 id="monthly-launch-detail-title" className="text-lg font-bold text-slate-950">
{selectedBrand} · {statusLabel(selectedStatus)}
</h3>
<p className="mt-1 text-sm text-slate-500">流程编码、发起人、所在部门、当前节点与业务时间</p>
</div>
<p className="text-sm font-semibold tabular-nums text-slate-600">共 {selectedItems.length} 条</p>
</div>
{selectedItems.length === 0 ? (
<div role="status" className="px-5 py-14 text-center">
<PackageOpen className="mx-auto h-8 w-8 text-slate-400" aria-hidden="true" />
<h4 className="mt-4 font-bold text-slate-950">当前分组暂无流程</h4>
<p className="mt-2 text-sm text-slate-500">切换品牌或流程状态查看其他明细。</p>
</div>
) : (
<>
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[980px] table-fixed text-left text-sm">
<caption className="sr-only">{selectedBrand}{statusLabel(selectedStatus)}流程明细</caption>
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
<tr>
<th scope="col" className="w-[25%] px-5 py-3.5">流程</th>
<th scope="col" className="w-[15%] px-4 py-3.5">任务类型</th>
<th scope="col" className="w-[18%] px-4 py-3.5">发起人 / 所在部门</th>
<th scope="col" className="w-[24%] px-4 py-3.5">当前进度</th>
<th scope="col" className="w-[18%] px-5 py-3.5">发起 / 最近更新</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{selectedItems.slice(0, visibleCount).map((item) => (
<tr key={item.id} className="align-top transition-colors hover:bg-slate-50">
<td className="px-5 py-4">
<p className="line-clamp-2 font-bold leading-5 text-slate-950">{item.title}</p>
<p className="mt-1 break-all text-xs text-slate-500">{item.businessId || item.id}</p>
</td>
<td className="px-4 py-4 font-semibold text-slate-700">{item.projectType}</td>
<td className="px-4 py-4">
<p className="font-bold text-slate-900">{item.initiatorName}</p>
<p className="mt-1 break-words text-xs leading-5 text-slate-500">{item.initiatorDepartment}</p>
</td>
<td className="px-4 py-4"><CurrentProgress item={item} /></td>
<td className="px-5 py-4 text-xs leading-5 tabular-nums text-slate-600">
<p>发起 {formatDate(item.sourceCreatedAt)}</p>
<p className="mt-1">更新 {formatDate(item.sourceUpdatedAt)}</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="divide-y divide-slate-200 md:hidden" aria-label={`${selectedBrand}${statusLabel(selectedStatus)}流程列表`}>
{selectedItems.slice(0, visibleCount).map((item) => (
<li key={item.id} className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h4 className="break-words font-bold text-slate-950">{item.title}</h4>
<p className="mt-1 break-all text-xs text-slate-500">{item.businessId || item.id}</p>
</div>
<span className="shrink-0 rounded-lg bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">{item.projectType}</span>
</div>
<dl className="mt-4 grid gap-4 text-sm">
<div><dt className="text-xs text-slate-500">发起人 / 所在部门</dt><dd className="mt-1 font-semibold text-slate-800">{item.initiatorName} · {item.initiatorDepartment}</dd></div>
<div><dt className="text-xs text-slate-500">当前进度</dt><dd className="mt-1"><CurrentProgress item={item} /></dd></div>
<div><dt className="text-xs text-slate-500">业务时间</dt><dd className="mt-1 tabular-nums text-slate-700">发起 {formatDate(item.sourceCreatedAt)} · 更新 {formatDate(item.sourceUpdatedAt)}</dd></div>
</dl>
</li>
))}
</ul>
</>
)}
{visibleCount < selectedItems.length && (
<div className="border-t border-slate-200 p-4 text-center">
<button
type="button"
onClick={() => setVisibleCount((count) => count + 30)}
className="inline-flex min-h-10 items-center gap-2 rounded-xl bg-slate-100 px-4 text-sm font-bold text-slate-700 transition-colors hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
>
展开更多<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
</div>
)}
</section>
</section>
)
}

View File

@ -41,10 +41,10 @@ export const CORE_METRICS: CoreMetric[] = [
key: 'monthly-launches',
label: '本月上新',
exampleValue: '18/24',
description: '本月已上新数量与计划目标的对照。',
description: '本月已完成新品流程与当前进行中新品流程的对照。',
icon: PackagePlus,
detailDescription: '用于查看本月上新计划、已完成项目与当前节点。',
plannedFields: ['商品或项目', '品牌', '计划日期', '当前节点', '负责人'],
detailDescription: '查看 2026 年 4 月 1 日后发起的美术设计流程;已完成按本月完成时间统计,进行中按当前未完结状态统计。',
plannedFields: ['流程编码', '品牌', '发起人', '所在部门', '当前节点', '业务时间'],
},
{
key: 'on-time-delivery',

View File

@ -1,11 +1,15 @@
import {
DING_WORKFLOW_ALLOWED_PROCESS_CODES,
DING_WORKFLOW_BRANDS,
DING_WORKFLOW_STARTED_AT_CUTOFF_ISO,
buildBrandCenterOngoingWorkflowWhere,
buildMonthlyLaunchWorkflowWhere,
getShanghaiMonthRange,
getDingWorkflowProjectType,
isAllowedDingWorkflowProcessCode,
isDingWorkflowStartedOnOrAfterCutoff,
isDingWorkflowOngoing,
isDingWorkflowCompletedInRange,
normalizeDingWorkflowBrand,
normalizeDingWorkflowDepartment,
normalizeDingWorkflowProcessCode,
@ -49,6 +53,16 @@ describe('ding workflow snapshot sync semantics', () => {
expect(isDingWorkflowStartedOnOrAfterCutoff('invalid')).toBe(false)
})
it('uses Shanghai calendar boundaries for monthly completion', () => {
const range = getShanghaiMonthRange(new Date('2026-08-26T03:00:00.000Z'))
expect(range.start.toISOString()).toBe('2026-07-31T16:00:00.000Z')
expect(range.end.toISOString()).toBe('2026-08-31T16:00:00.000Z')
expect(isDingWorkflowCompletedInRange('COMPLETED', '2026-08-01 00:00:00', range)).toBe(true)
expect(isDingWorkflowCompletedInRange('COMPLETED', '2026-09-01 00:00:00', range)).toBe(false)
expect(isDingWorkflowCompletedInRange('TERMINATED', '2026-08-12 12:00:00', range)).toBe(false)
expect(isDingWorkflowCompletedInRange('COMPLETED', null, range)).toBe(false)
})
it('only accepts the two approved process types', () => {
expect(DING_WORKFLOW_ALLOWED_PROCESS_CODES).toEqual([
'PROC-3571C915-0506-4490-A91A-8AA7EA33F838',
@ -69,6 +83,14 @@ describe('ding workflow snapshot sync semantics', () => {
})
})
it('only queries post-April art-design workflows for the six-brand monthly launch metric', () => {
expect(buildMonthlyLaunchWorkflowWhere()).toEqual({
processCode: 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838',
sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO },
brand: { in: [...DING_WORKFLOW_BRANDS] },
})
})
it('normalizes process codes before persistence and comparison', () => {
expect(normalizeDingWorkflowProcessCode(' proc-ae9ff3e1-a442-4e6a-85cf-0f2b86409673 '))
.toBe('PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673')

View File

@ -184,6 +184,37 @@ export type OngoingDingWorkflowInsights = {
sync: OngoingDingWorkflowSummary
}
export type MonthlyLaunchWorkflowStatus = 'completed' | 'ongoing'
export type MonthlyLaunchWorkflowItem = OngoingDingWorkflowListItem & {
launchStatus: MonthlyLaunchWorkflowStatus
result: string | null
sourceFinishedAt: string | null
}
export type MonthlyLaunchBrandSummary = {
brand: typeof DING_WORKFLOW_BRANDS[number]
completed: number
ongoing: number
total: number
}
export type MonthlyLaunchSummary = {
completed: number
ongoing: number
total: number
monthStart: string
monthEnd: string
lastSyncedAt: string | null
syncStatus: DingWorkflowSyncStatus
}
export type MonthlyLaunchInsights = {
items: MonthlyLaunchWorkflowItem[]
brands: MonthlyLaunchBrandSummary[]
summary: MonthlyLaunchSummary
}
export class DingWorkflowMetricApiError extends Error {
status: number
@ -365,6 +396,33 @@ export function sourceWorkflowTimestampToIso(value: unknown): string | null {
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
}
export function getShanghaiMonthRange(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
}).formatToParts(now)
const year = Number(parts.find((part) => part.type === 'year')?.value)
const month = Number(parts.find((part) => part.type === 'month')?.value)
const shanghaiOffsetMs = 8 * 60 * 60 * 1000
return {
start: new Date(Date.UTC(year, month - 1, 1) - shanghaiOffsetMs),
end: new Date(Date.UTC(year, month, 1) - shanghaiOffsetMs),
}
}
export function isDingWorkflowCompletedInRange(
status: unknown,
finishedAt: unknown,
range: { start: Date; end: Date },
) {
if (String(status || '').trim().toUpperCase() !== 'COMPLETED') return false
const finishedAtIso = sourceWorkflowTimestampToIso(finishedAt)
if (!finishedAtIso) return false
const finishedAtMs = new Date(finishedAtIso).getTime()
return finishedAtMs >= range.start.getTime() && finishedAtMs < range.end.getTime()
}
export function isDingWorkflowStartedOnOrAfterCutoff(value: unknown): boolean {
const startedAt = sourceWorkflowTimestampToIso(value)
return Boolean(startedAt && startedAt >= DING_WORKFLOW_STARTED_AT_CUTOFF_ISO)
@ -441,8 +499,20 @@ async function assertDashboardAccess(session: Session | null) {
async function fetchSourceWorkflows(pool: ConnectionPool, cursor: string | null) {
const request = pool.request()
const currentMonth = getShanghaiMonthRange()
const currentMonthStart = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(currentMonth.start)
request.input('cursor', mssql.NVarChar(64), cursor)
request.input('startedAtCutoff', mssql.NVarChar(64), DING_WORKFLOW_STARTED_AT_CUTOFF_LOCAL)
request.input('currentMonthStart', mssql.NVarChar(64), currentMonthStart)
DING_WORKFLOW_ALLOWED_PROCESS_CODES.forEach((processCode, index) => {
request.input(`allowedProcessCode${index}`, mssql.NVarChar(255), processCode)
})
@ -474,6 +544,11 @@ async function fetchSourceWorkflows(pool: ConnectionPool, cursor: string | null)
AND (
@cursor IS NULL
OR UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(100), status), '')))) NOT IN ('COMPLETED', 'TERMINATED', 'CANCELED')
OR (
UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(100), status), '')))) = 'COMPLETED'
AND TRY_CONVERT(datetime2, NULLIF(CONVERT(nvarchar(64), finishTime), '')) >=
TRY_CONVERT(datetime2, @currentMonthStart)
)
OR TRY_CONVERT(datetime2, NULLIF(CONVERT(nvarchar(64), updateAt), '')) >=
DATEADD(minute, -5, TRY_CONVERT(datetime2, @cursor))
)
@ -481,7 +556,7 @@ async function fetchSourceWorkflows(pool: ConnectionPool, cursor: string | null)
return result.recordset
}
async function fetchOngoingAggregates(pool: ConnectionPool, processInstanceIds: string[]) {
async function fetchWorkflowAggregates(pool: ConnectionPool, processInstanceIds: string[]) {
if (processInstanceIds.length === 0) {
return {
taskByWorkflow: new Map<string, SourceTaskAggregate>(),
@ -743,7 +818,7 @@ function buildSnapshotPayload(
joinedAtRaw: clean(row.joinedAtRaw),
sourceCreatedAtRaw,
sourceUpdatedAtRaw: clean(row.sourceUpdatedAtRaw),
sourceFinishedAtRaw: clean(row.sourceFinishedAtRaw),
sourceFinishedAtRaw: sourceWorkflowTimestampToIso(row.sourceFinishedAtRaw),
isOngoing: ongoing,
schemeFieldCount: ongoing ? nonNegativeInteger(scheme?.schemeFieldCount) : 0,
taskCount: ongoing ? nonNegativeInteger(task?.taskCount) : 0,
@ -860,16 +935,16 @@ export async function syncDingWorkflowSnapshots() {
try {
await pool.connect()
const sourceRows = await fetchSourceWorkflows(pool, state.sourceCursorRaw)
const ongoingIds = sourceRows.flatMap((row) => {
const workflowIds = sourceRows.flatMap((row) => {
const id = clean(row.processInstanceId)
return id && isDingWorkflowOngoing(row.status, row.sourceFinishedAtRaw) ? [id] : []
return id ? [id] : []
})
const {
taskByWorkflow,
taskRowsByWorkflow,
schemeByWorkflow,
schemeRowsByWorkflow,
} = await fetchOngoingAggregates(pool, ongoingIds)
} = await fetchWorkflowAggregates(pool, workflowIds)
const employeeDirectory = await readEmployeeDirectory(sourceRows, taskRowsByWorkflow)
const activityNodeNames = inferActivityNodeNames(sourceRows, taskRowsByWorkflow, schemeRowsByWorkflow)
const rows = buildSnapshotPayload(
@ -982,6 +1057,12 @@ const ONGOING_WORKFLOW_SELECT = {
latestTaskStatus: true,
} as const
const MONTHLY_LAUNCH_WORKFLOW_SELECT = {
...ONGOING_WORKFLOW_SELECT,
result: true,
sourceFinishedAtRaw: true,
} as const
type DingWorkflowInitiatorProfile = {
displayName: string | null
departmentName: string | null
@ -1160,6 +1241,90 @@ export async function getOngoingDingWorkflowProjects(
}
}
export function buildMonthlyLaunchWorkflowWhere() {
return {
processCode: DING_WORKFLOW_ALLOWED_PROCESS_CODES[0],
sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO },
brand: { in: [...DING_WORKFLOW_BRANDS] },
}
}
async function readMonthlyLaunchInsightsWithoutAccessCheck(now = new Date()): Promise<MonthlyLaunchInsights> {
const range = getShanghaiMonthRange(now)
const baseWhere = buildMonthlyLaunchWorkflowWhere()
const [completedCandidates, ongoingRows, state] = await Promise.all([
prisma.dingWorkflowSnapshot.findMany({
where: { ...baseWhere, status: 'COMPLETED' },
select: MONTHLY_LAUNCH_WORKFLOW_SELECT,
}),
prisma.dingWorkflowSnapshot.findMany({
where: { ...baseWhere, isOngoing: true },
select: MONTHLY_LAUNCH_WORKFLOW_SELECT,
}),
prisma.dingWorkflowSyncState.findUnique({
where: { id: SYNC_STATE_ID },
select: { status: true, lastCompletedAt: true },
}),
])
const completedRows = completedCandidates.filter((row) => (
isDingWorkflowCompletedInRange(row.status, row.sourceFinishedAtRaw, range)
))
const allRows = [...completedRows, ...ongoingRows]
const initiatorDirectory = await readInitiatorDirectory(allRows)
const mapMonthlyItem = (
row: typeof allRows[number],
launchStatus: MonthlyLaunchWorkflowStatus,
): MonthlyLaunchWorkflowItem => ({
...mapSnapshotListItem(
row,
row.originatorUserId ? initiatorDirectory.get(row.originatorUserId.trim()) : null,
),
launchStatus,
result: row.result,
sourceFinishedAt: sourceWorkflowTimestampToIso(row.sourceFinishedAtRaw),
})
const items = [
...completedRows.map((row) => mapMonthlyItem(row, 'completed')),
...ongoingRows.map((row) => mapMonthlyItem(row, 'ongoing')),
].sort((a, b) => {
const aTime = a.launchStatus === 'completed' ? a.sourceFinishedAt : a.sourceUpdatedAt
const bTime = b.launchStatus === 'completed' ? b.sourceFinishedAt : b.sourceUpdatedAt
return String(bTime || '').localeCompare(String(aTime || '')) || b.id.localeCompare(a.id)
})
const brands = DING_WORKFLOW_BRANDS.map((brand) => {
const brandItems = items.filter((item) => item.brand === brand)
const completed = brandItems.filter((item) => item.launchStatus === 'completed').length
const ongoing = brandItems.filter((item) => item.launchStatus === 'ongoing').length
return { brand, completed, ongoing, total: completed + ongoing }
})
const completed = completedRows.length
const ongoing = ongoingRows.length
return {
items,
brands,
summary: {
completed,
ongoing,
total: completed + ongoing,
monthStart: range.start.toISOString(),
monthEnd: range.end.toISOString(),
lastSyncedAt: state?.lastCompletedAt?.toISOString() || null,
syncStatus: normalizeSyncStatus(state),
},
}
}
export async function getMonthlyLaunchSummary(session: Session | null): Promise<MonthlyLaunchSummary> {
await assertDashboardAccess(session)
return (await readMonthlyLaunchInsightsWithoutAccessCheck()).summary
}
export async function getMonthlyLaunchInsights(session: Session | null): Promise<MonthlyLaunchInsights> {
await assertDashboardAccess(session)
return readMonthlyLaunchInsightsWithoutAccessCheck()
}
type DingWorkflowSchedulerGlobal = typeof globalThis & {
__dingWorkflowHourlySyncStarted?: boolean
}