diff --git a/app/api/department-dashboard/core-metrics/monthly-launches/route.ts b/app/api/department-dashboard/core-metrics/monthly-launches/route.ts
new file mode 100644
index 00000000..a2f1af9c
--- /dev/null
+++ b/app/api/department-dashboard/core-metrics/monthly-launches/route.ts
@@ -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 },
+ )
+ }
+}
diff --git a/app/workspaces/[section]/[item]/[metric]/page.tsx b/app/workspaces/[section]/[item]/[metric]/page.tsx
index a10fe6cb..0ce2b457 100644
--- a/app/workspaces/[section]/[item]/[metric]/page.tsx
+++ b/app/workspaces/[section]/[item]/[metric]/page.tsx
@@ -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
}
+ if (metric.key === 'monthly-launches') {
+ const session = await getServerSession(authOptions)
+ const monthlyLaunches = await getMonthlyLaunchInsights(session)
+ return
+ }
+
if (metric.key === 'first-pass-rate') {
const session = await getServerSession(authOptions)
const firstPassPage = await getFirstPassReviewProjects(session, { page, pageSize: 20, query })
diff --git a/components/department-dashboard/CoreMetricDetail.test.tsx b/components/department-dashboard/CoreMetricDetail.test.tsx
index b07fd970..c686afe8 100644
--- a/components/department-dashboard/CoreMetricDetail.test.tsx
+++ b/components/department-dashboard/CoreMetricDetail.test.tsx
@@ -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(
+ ,
+ )
+
+ 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()
diff --git a/components/department-dashboard/CoreMetricDetail.tsx b/components/department-dashboard/CoreMetricDetail.tsx
index f10bee43..957404a8 100644
--- a/components/department-dashboard/CoreMetricDetail.tsx
+++ b/components/department-dashboard/CoreMetricDetail.tsx
@@ -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
-
{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : '总览示例值'}
+
{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : hasMonthlyLaunchSource ? '本月完成 / 新品总数' : '总览示例值'}
{isOnTimeDelivery && metric.exampleNumericValue !== undefined
?
- :
{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : metric.exampleValue}
}
+ :
{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : hasMonthlyLaunchSource ? `${monthlyLaunches.summary.completed}/${monthlyLaunches.summary.total}` : metric.exampleValue}
}
明细数据
-
{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : '等待业务数据源'}
+
{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `已完成 ${monthlyLaunches.summary.completed} / 进行中 ${monthlyLaunches.summary.ongoing}` : '等待业务数据源'}
同步状态
@@ -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 ? '打开页面时读取' : '每日同步待接入'}
@@ -283,6 +293,8 @@ export default function CoreMetricDetail({ metric, query, projectInsights, first
{hasRealProjectSource
?
+ : hasMonthlyLaunchSource
+ ?
: hasFirstPassSource
?
: }
diff --git a/components/department-dashboard/CoreMetricsDashboard.test.tsx b/components/department-dashboard/CoreMetricsDashboard.test.tsx
index 223bb29e..4b036d42 100644
--- a/components/department-dashboard/CoreMetricsDashboard.test.tsx
+++ b/components/department-dashboard/CoreMetricsDashboard.test.tsx
@@ -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()
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()
- 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)
})
})
diff --git a/components/department-dashboard/CoreMetricsDashboard.tsx b/components/department-dashboard/CoreMetricsDashboard.tsx
index 1146d77e..a8606ddb 100644
--- a/components/department-dashboard/CoreMetricsDashboard.tsx
+++ b/components/department-dashboard/CoreMetricsDashboard.tsx
@@ -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({ status: 'loading', data: null })
const [ongoingWorkflows, setOngoingWorkflows] = useState({ status: 'loading', data: null })
+ const [monthlyLaunches, setMonthlyLaunches] = useState({ 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() {
{metric.label}
-
+
{isOnTimeDelivery && metric.exampleNumericValue !== undefined
?
: (
-
+
{value}
)}
@@ -170,6 +207,14 @@ export default function CoreMetricsDashboard() {
{isOngoingWorkflow && ongoingWorkflows.status === 'loading' && (
正在读取本地流程快照
)}
+ {isMonthlyLaunch && monthlyLaunches.status === 'success' && (
+
+ 已完成 {monthlyLaunches.data.completed} · 进行中 {monthlyLaunches.data.ongoing}
+
+ )}
+ {isMonthlyLaunch && monthlyLaunches.status === 'loading' && (
+
正在读取本地流程快照
+ )}
{metric.description}
@@ -177,13 +222,13 @@ export default function CoreMetricsDashboard() {
- {isOngoingWorkflow
- ? ongoingWorkflows.status === 'error' ? '刷新页面重试' : '每小时增量同步'
+ {isOngoingWorkflow || isMonthlyLaunch
+ ? (isOngoingWorkflow ? ongoingWorkflows.status : monthlyLaunches.status) === 'error' ? '刷新页面重试' : '每小时增量同步'
: isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '打开页面时读取') : '每日同步'}
·
- {isOngoingWorkflow
- ? ongoingWorkflows.status === 'error' ? '数据未加载' : '钉钉流程快照'
+ {isOngoingWorkflow || isMonthlyLaunch
+ ? (isOngoingWorkflow ? ongoingWorkflows.status : monthlyLaunches.status) === 'error' ? '数据未加载' : '钉钉流程快照'
: isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '口径已定义') : '公式待接入'}
@@ -196,7 +241,7 @@ export default function CoreMetricsDashboard() {
})}
- “进行中项目”每小时读取钉钉审批流程快照,“首轮通过”读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。
+ “进行中项目”和“本月上新”每小时读取钉钉审批流程三表快照,“首轮通过”读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。
diff --git a/components/department-dashboard/MonthlyLaunchesInsights.tsx b/components/department-dashboard/MonthlyLaunchesInsights.tsx
new file mode 100644
index 00000000..3c9072d3
--- /dev/null
+++ b/components/department-dashboard/MonthlyLaunchesInsights.tsx
@@ -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 (
+
+
流程已完成
+
完成于 {formatDate(item.sourceFinishedAt)}
+
+ )
+ }
+ if (item.activeTasks.length === 0) return 待进入处理节点
+ return (
+
+ {item.activeTasks.map((task) => (
+
+ {task.nodeName}
+ ·
+ {task.assigneeName}
+
+ ))}
+
+ )
+}
+
+export default function MonthlyLaunchesInsights({ insights }: { insights: MonthlyLaunchInsights }) {
+ const [selectedBrand, setSelectedBrand] = useState(insights.brands[0]?.brand || '亿家达')
+ const [selectedStatus, setSelectedStatus] = useState('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 (
+
+
+
+
六品牌本月新品流程
+
+ 已完成按本月完成时间统计;进行中读取当前未完结流程,不限制发起月份。取消和终止流程不计入。
+
+
+
+ 已完成 {insights.summary.completed} · 进行中 {insights.summary.ongoing}
+
+
+
+
+ {insights.brands.map((brand) => (
+
+
+
{brand.brand}
+
合计 {brand.total}
+
+
+ {([
+ { 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 (
+
+ )
+ })}
+
+
+ ))}
+
+
+
+
+
+
+ {selectedBrand} · {statusLabel(selectedStatus)}
+
+
流程编码、发起人、所在部门、当前节点与业务时间
+
+
共 {selectedItems.length} 条
+
+
+ {selectedItems.length === 0 ? (
+
+
+
当前分组暂无流程
+
切换品牌或流程状态查看其他明细。
+
+ ) : (
+ <>
+
+
+ {selectedBrand}{statusLabel(selectedStatus)}流程明细
+
+
+ | 流程 |
+ 任务类型 |
+ 发起人 / 所在部门 |
+ 当前进度 |
+ 发起 / 最近更新 |
+
+
+
+ {selectedItems.slice(0, visibleCount).map((item) => (
+
+ |
+ {item.title}
+ {item.businessId || item.id}
+ |
+ {item.projectType} |
+
+ {item.initiatorName}
+ {item.initiatorDepartment}
+ |
+ |
+
+ 发起 {formatDate(item.sourceCreatedAt)}
+ 更新 {formatDate(item.sourceUpdatedAt)}
+ |
+
+ ))}
+
+
+
+
+
+ {selectedItems.slice(0, visibleCount).map((item) => (
+ -
+
+
+
{item.title}
+
{item.businessId || item.id}
+
+
{item.projectType}
+
+
+ - 发起人 / 所在部门
- {item.initiatorName} · {item.initiatorDepartment}
+ - 当前进度
+ - 业务时间
- 发起 {formatDate(item.sourceCreatedAt)} · 更新 {formatDate(item.sourceUpdatedAt)}
+
+
+ ))}
+
+ >
+ )}
+
+ {visibleCount < selectedItems.length && (
+
+
+
+ )}
+
+
+ )
+}
diff --git a/components/department-dashboard/core-metrics.ts b/components/department-dashboard/core-metrics.ts
index 69b62ed9..93113aed 100644
--- a/components/department-dashboard/core-metrics.ts
+++ b/components/department-dashboard/core-metrics.ts
@@ -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',
diff --git a/lib/__tests__/ding-workflow-sync.test.ts b/lib/__tests__/ding-workflow-sync.test.ts
index ed8d2070..e1ee5ede 100644
--- a/lib/__tests__/ding-workflow-sync.test.ts
+++ b/lib/__tests__/ding-workflow-sync.test.ts
@@ -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')
diff --git a/lib/ding-workflow-sync.ts b/lib/ding-workflow-sync.ts
index 5907ab53..f070744a 100644
--- a/lib/ding-workflow-sync.ts
+++ b/lib/ding-workflow-sync.ts
@@ -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(),
@@ -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 {
+ 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 {
+ await assertDashboardAccess(session)
+ return (await readMonthlyLaunchInsightsWithoutAccessCheck()).summary
+}
+
+export async function getMonthlyLaunchInsights(session: Session | null): Promise {
+ await assertDashboardAccess(session)
+ return readMonthlyLaunchInsightsWithoutAccessCheck()
+}
+
type DingWorkflowSchedulerGlobal = typeof globalThis & {
__dingWorkflowHourlySyncStarted?: boolean
}