diff --git a/Dockerfile b/Dockerfile index e45f675..580f621 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,7 +81,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260817_add_material_is_incomplete_shell/migration.sql" && \ test -f "/app/prisma/migrations/20260819_add_user_permission_inherit/migration.sql" && \ test -f "/app/prisma/migrations/20260820_add_permission_group_members/migration.sql" && \ - test -f "/app/prisma/migrations/20260820_unique_permission_group_department/migration.sql" + test -f "/app/prisma/migrations/20260820_unique_permission_group_department/migration.sql" && \ + test -f "/app/prisma/migrations/20260820_add_ding_workflow_snapshots/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -194,6 +195,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260817_add_materi COPY --from=builder --chown=root:root /app/prisma/migrations/20260819_add_user_permission_inherit ./prisma/migrations/20260819_add_user_permission_inherit COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_add_permission_group_members ./prisma/migrations/20260820_add_permission_group_members COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_unique_permission_group_department ./prisma/migrations/20260820_unique_permission_group_department +COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_add_ding_workflow_snapshots ./prisma/migrations/20260820_add_ding_workflow_snapshots COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs diff --git a/app/api/department-dashboard/core-metrics/ongoing-projects/route.ts b/app/api/department-dashboard/core-metrics/ongoing-projects/route.ts new file mode 100644 index 0000000..ad8d3b3 --- /dev/null +++ b/app/api/department-dashboard/core-metrics/ongoing-projects/route.ts @@ -0,0 +1,36 @@ +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' +import { authOptions } from '@/lib/auth' +import { + DingWorkflowMetricApiError, + getOngoingDingWorkflowSummary, +} 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 getOngoingDingWorkflowSummary(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 bdbab8c..19da000 100644 --- a/app/workspaces/[section]/[item]/[metric]/page.tsx +++ b/app/workspaces/[section]/[item]/[metric]/page.tsx @@ -4,9 +4,9 @@ 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 { getOngoingDingWorkflowProjects } from '@/lib/ding-workflow-sync' import { getFirstPassReviewProjects, - getOngoingWorkReviewProjects, } from '@/lib/work-review-service' type CoreMetricPageProps = { @@ -34,7 +34,7 @@ export default async function CoreMetricPage({ params, searchParams }: CoreMetri if (metric.key === 'ongoing-projects') { const session = await getServerSession(authOptions) - const projectPage = await getOngoingWorkReviewProjects(session, { page, pageSize: 20, query }) + const projectPage = await getOngoingDingWorkflowProjects(session, { page, pageSize: 20, query }) return } diff --git a/components/department-dashboard/CoreMetricDetail.test.tsx b/components/department-dashboard/CoreMetricDetail.test.tsx index 34a5b14..b393daf 100644 --- a/components/department-dashboard/CoreMetricDetail.test.tsx +++ b/components/department-dashboard/CoreMetricDetail.test.tsx @@ -16,25 +16,28 @@ describe('CoreMetricDetail', () => { projectPage={{ items: [{ id: 'project-1', + businessId: 'BUSINESS-1', title: '夏季主视觉过稿', - description: null, - status: 'pending_review', - workDate: '2026-08-10T00:00:00.000Z', - latestVersion: 2, - submitterName: '林一', - managerName: '陈主管', - finalReviewerName: '元泽', - updatedAt: '2026-08-14T02:30:00.000Z', + status: 'RUNNING', + originatorUserId: 'ding-1', + originatorDeptName: '品牌中心', + sourceCreatedAt: '2026-08-10T00:00:00.000Z', + sourceUpdatedAt: '2026-08-14T02:30:00.000Z', + activeTaskCount: 1, + taskCount: 3, + schemeFieldCount: 12, + latestTaskStatus: 'RUNNING', }], pagination: { page: 1, pageSize: 20, total: 1, totalPages: 1 }, + sync: { total: 1, lastSyncedAt: '2026-08-14T02:30:00.000Z', syncStatus: 'ready' }, }} />, ) expect(screen.getByRole('heading', { level: 1, name: '进行中项目' })).toBeInTheDocument() expect(screen.getAllByText('夏季主视觉过稿')).toHaveLength(2) - expect(screen.getAllByText('评审中').length).toBeGreaterThan(0) - expect(screen.getByText(/数据来自作品过稿项目库/)).toBeInTheDocument() + expect(screen.getAllByText('流转中').length).toBeGreaterThan(0) + expect(screen.getByText(/数据来自钉钉审批流程本地快照/)).toBeInTheDocument() expect(screen.getByRole('link', { name: /返回核心指标/ })).toHaveAttribute('href', '/workspaces/dashboard/core-metrics') }) diff --git a/components/department-dashboard/CoreMetricDetail.tsx b/components/department-dashboard/CoreMetricDetail.tsx index 3aa888c..357941e 100644 --- a/components/department-dashboard/CoreMetricDetail.tsx +++ b/components/department-dashboard/CoreMetricDetail.tsx @@ -13,9 +13,11 @@ import { import type { FirstPassReviewProjectListItem, FirstPassReviewProjectPage, - OngoingWorkReviewProjectListItem, - OngoingWorkReviewProjectPage, } from '@/lib/work-review-service' +import type { + OngoingDingWorkflowListItem, + OngoingDingWorkflowPage, +} from '@/lib/ding-workflow-sync' import DepartmentShell from './DepartmentShell' import OnTimeDeliveryRate from './OnTimeDeliveryRate' import type { CoreMetric } from './core-metrics' @@ -23,16 +25,19 @@ import type { CoreMetric } from './core-metrics' type CoreMetricDetailProps = { metric: CoreMetric query?: string - projectPage?: OngoingWorkReviewProjectPage + projectPage?: OngoingDingWorkflowPage firstPassPage?: FirstPassReviewProjectPage } -const STATUS_LABELS: Record = { - pending_review: '评审中', - revision_required: '待修改', +const WORKFLOW_STATUS_LABELS: Record = { + RUNNING: '流转中', + NEW: '待处理', + PENDING: '待处理', + PROCESSING: '处理中', } -function formatDate(value: string, includeTime = false) { +function formatDate(value: string | null, includeTime = false) { + if (!value) return '—' return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', @@ -55,11 +60,10 @@ function formatRate(rate: number | null) { return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%` } -function StatusBadge({ status }: { status: OngoingWorkReviewProjectListItem['status'] }) { - const isRevision = status === 'revision_required' +function StatusBadge({ status }: { status: OngoingDingWorkflowListItem['status'] }) { return ( - - {STATUS_LABELS[status]} + + {WORKFLOW_STATUS_LABELS[status] || '未完结'} ) } @@ -73,14 +77,14 @@ function ProjectList({ metric, query, projectPage }: Required

进行中项目明细

- 数据来自作品过稿项目库;“评审中”和“待修改”均计入进行中。 + 数据来自钉钉审批流程本地快照;所有未完结流程均计入进行中,每小时增量同步一次。

共 {pagination.total} 个项目

- +
@@ -106,7 +110,9 @@ function ProjectList({ metric, query, projectPage }: Required {query ? '请调整关键词后重新查询。' - : '数据库中暂时没有“评审中”或“待修改”的作品过稿项目;有项目进入这些状态后会自动显示在这里。'} + : projectPage.sync.syncStatus === 'pending' + ? '本地快照正在建立首轮同步基线,完成后会自动显示未完结流程。' + : '当前本地快照中没有未完结的钉钉流程。'}

) : ( @@ -116,12 +122,12 @@ function ProjectList({ metric, query, projectPage }: Required进行中项目数据表 - 项目 - 状态 - 提交人 - 负责人 - 作品日期 - 最近更新 + 流程 + 状态 + 发起部门 + 待办任务 + 发起时间 + 最近更新 @@ -129,13 +135,13 @@ function ProjectList({ metric, query, projectPage }: Required

{project.title}

-

第 {project.latestVersion} 版 · 终审 {project.finalReviewerName}

+

流程编号 {project.businessId || project.id}

- {project.submitterName} - {project.managerName} - {formatDate(project.workDate)} - {formatDate(project.updatedAt, true)} + {project.originatorDeptName || '未记录'} + {project.activeTaskCount} / {project.taskCount} + {formatDate(project.sourceCreatedAt, true)} + {formatDate(project.sourceUpdatedAt, true)} ))} @@ -148,15 +154,15 @@ function ProjectList({ metric, query, projectPage }: Required

{project.title}

-

第 {project.latestVersion} 版 · 终审 {project.finalReviewerName}

+

流程编号 {project.businessId || project.id}

-
提交人
{project.submitterName}
-
负责人
{project.managerName}
-
作品日期
{formatDate(project.workDate)}
-
最近更新
{formatDate(project.updatedAt, true)}
+
发起部门
{project.originatorDeptName || '未记录'}
+
待办 / 任务
{project.activeTaskCount} / {project.taskCount}
+
发起时间
{formatDate(project.sourceCreatedAt, true)}
+
最近更新
{formatDate(project.sourceUpdatedAt, true)}
))} @@ -371,33 +377,40 @@ function PendingDataSource({ metric }: { metric: CoreMetric }) { } export default function CoreMetricDetail({ metric, query, projectPage, firstPassPage }: CoreMetricDetailProps) { - const Icon = metric.icon const hasRealProjectSource = metric.key === 'ongoing-projects' && projectPage const hasFirstPassSource = metric.key === 'first-pass-rate' && firstPassPage const isOnTimeDelivery = metric.key === 'on-time-delivery' - const hasRealSource = hasRealProjectSource || hasFirstPassSource return (
-
-

{metric.label}

+

{metric.label}

{metric.detailDescription}

-

{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前项目数' : '总览示例值'}

+

{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : '总览示例值'}

{isOnTimeDelivery && metric.exampleNumericValue !== undefined ? :

{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectPage.pagination.total : metric.exampleValue}

}

明细数据

-

{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectPage.pagination.total} 条真实记录` : '等待业务数据源'}

+

{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectPage.pagination.total} 条流程快照` : '等待业务数据源'}

+
+
+

同步状态

+

+

-

同步状态

{hasRealProjectSource @@ -408,7 +421,6 @@ export default function CoreMetricDetail({ metric, query, projectPage, firstPass
diff --git a/components/department-dashboard/CoreMetricsDashboard.test.tsx b/components/department-dashboard/CoreMetricsDashboard.test.tsx index 36a1f2b..223bb29 100644 --- a/components/department-dashboard/CoreMetricsDashboard.test.tsx +++ b/components/department-dashboard/CoreMetricsDashboard.test.tsx @@ -8,26 +8,35 @@ jest.mock('next-auth/react', () => ({ describe('CoreMetricsDashboard', () => { beforeEach(() => { - global.fetch = jest.fn().mockResolvedValue({ + global.fetch = jest.fn().mockImplementation(async (url: string) => ({ ok: true, - json: async () => ({ - 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 + json: async () => url.includes('ongoing-projects') + ? { + success: true, + data: { + total: 2338, + 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 }) afterEach(() => { jest.restoreAllMocks() }) - it('展示真实首轮通过率与其余五项诚实的示例状态', async () => { + it('展示真实进行中流程、首轮通过率与其余四项诚实的示例状态', async () => { render() expect(screen.getByRole('heading', { level: 1, name: '核心指标' })).toBeInTheDocument() @@ -35,17 +44,19 @@ describe('CoreMetricsDashboard', () => { expect(await screen.findByText('36.7%')).toBeInTheDocument() expect(screen.getByText('V1通过 11 / 全部 30')).toBeInTheDocument() expect(screen.getByText('实时读取')).toBeInTheDocument() - expect(screen.getAllByText('示例值')).toHaveLength(5) + expect(screen.getByText('每小时同步')).toBeInTheDocument() + expect(screen.getAllByText('示例值')).toHaveLength(4) for (const label of ['进行中项目', '本月上新', '按时交付', '首轮通过', '团队负荷', '风险']) { expect(screen.getByRole('heading', { level: 3, name: label })).toBeInTheDocument() } - for (const value of ['42', '18/24', '94.6%', '36.7%', '87%', '4项']) { + for (const value of ['2338', '18/24', '94.6%', '36.7%', '87%', '4项']) { expect(screen.getByText(value)).toBeInTheDocument() } - expect(screen.getAllByText('公式待接入')).toHaveLength(5) + expect(screen.getAllByText('公式待接入')).toHaveLength(4) + expect(screen.getByText('钉钉流程快照')).toBeInTheDocument() expect(screen.getByText('口径已定义')).toBeInTheDocument() expect(screen.getAllByText('查看明细')).toHaveLength(6) const onTimeDelivery = screen.getByRole('link', { name: '查看按时交付明细' }) @@ -66,8 +77,8 @@ describe('CoreMetricsDashboard', () => { global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({ success: false }) }) as jest.Mock render() - expect(await screen.findByText('读取失败')).toBeInTheDocument() + expect(await screen.findAllByText('读取失败')).toHaveLength(2) expect(screen.queryByText('82.3%')).not.toBeInTheDocument() - expect(screen.getByText('数据未加载')).toBeInTheDocument() + expect(screen.getAllByText('数据未加载')).toHaveLength(2) }) }) diff --git a/components/department-dashboard/CoreMetricsDashboard.tsx b/components/department-dashboard/CoreMetricsDashboard.tsx index 672dad3..1146d77 100644 --- a/components/department-dashboard/CoreMetricsDashboard.tsx +++ b/components/department-dashboard/CoreMetricsDashboard.tsx @@ -7,6 +7,7 @@ import { CalendarClock, } from 'lucide-react' import type { FirstPassReviewSummary } from '@/lib/work-review-metrics' +import type { OngoingDingWorkflowSummary } from '@/lib/ding-workflow-sync' import DepartmentShell from './DepartmentShell' import { CORE_METRICS, getCoreMetricHref } from './core-metrics' import OnTimeDeliveryRate from './OnTimeDeliveryRate' @@ -16,13 +17,31 @@ type FirstPassState = | { status: 'success'; data: FirstPassReviewSummary } | { status: 'error'; data: null } +type OngoingWorkflowState = + | { status: 'loading'; data: null } + | { status: 'success'; data: OngoingDingWorkflowSummary } + | { status: 'error'; data: null } + function formatRate(rate: number | null) { if (rate === null) return '—' return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%` } +function formatSyncTime(value: string | null) { + if (!value) return '等待首轮同步' + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'Asia/Shanghai', + }).format(new Date(value)) +} + export default function CoreMetricsDashboard() { const [firstPass, setFirstPass] = useState({ status: 'loading', data: null }) + const [ongoingWorkflows, setOngoingWorkflows] = useState({ status: 'loading', data: null }) useEffect(() => { const controller = new AbortController() @@ -41,7 +60,22 @@ export default function CoreMetricsDashboard() { } } + async function loadOngoingWorkflowSummary() { + try { + const response = await fetch('/api/department-dashboard/core-metrics/ongoing-projects', { + cache: 'no-store', + signal: controller.signal, + }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success || !payload.data) throw new Error('指标读取失败') + setOngoingWorkflows({ status: 'success', data: payload.data }) + } catch (error) { + if ((error as Error).name !== 'AbortError') setOngoingWorkflows({ status: 'error', data: null }) + } + } + void loadFirstPassSummary() + void loadOngoingWorkflowSummary() return () => controller.abort() }, []) @@ -73,12 +107,25 @@ export default function CoreMetricsDashboard() {