feat: add team workload core metric

This commit is contained in:
江林铖 2026-08-31 16:40:07 +08:00
parent d2ca6a1bd8
commit 87919a3943
11 changed files with 763 additions and 19 deletions

View File

@ -0,0 +1,36 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
DingWorkflowMetricApiError,
getTeamWorkloadInsights,
} 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 getTeamWorkloadInsights(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

@ -0,0 +1,49 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { DingWorkflowMetricApiError, getTeamWorkloadSummary } from '@/lib/ding-workflow-sync'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('mssql', () => ({ __esModule: true, default: {} }))
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/brand-center-access', () => ({ resolveBrandCenterAccessForUser: jest.fn() }))
jest.mock('@/lib/ding-workflow-sync', () => {
const actual = jest.requireActual('@/lib/ding-workflow-sync')
return { ...actual, getTeamWorkloadSummary: jest.fn() }
})
const { GET } = require('@/app/api/department-dashboard/core-metrics/team-workload/route') as typeof import('@/app/api/department-dashboard/core-metrics/team-workload/route')
describe('GET /api/department-dashboard/core-metrics/team-workload', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'manager-1' } })
})
it('返回团队负荷摘要并禁止缓存', async () => {
const data = { averageLoadPercent: 30.7, personnelCount: 44, busyPersonnel: 17 }
;(getTeamWorkloadSummary as jest.Mock).mockResolvedValue(data)
const response = await GET()
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0')
await expect(response.json()).resolves.toEqual({ success: true, data })
})
it('按业务权限错误返回对应状态', async () => {
;(getTeamWorkloadSummary as jest.Mock).mockRejectedValue(new DingWorkflowMetricApiError('无权查看品牌中心流程指标', 403))
const response = await GET()
expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({ success: false, error: '无权查看品牌中心流程指标' })
})
it('隐藏未知异常细节', async () => {
;(getTeamWorkloadSummary as jest.Mock).mockRejectedValue(new Error('database password leaked'))
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
const response = await GET()
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({ success: false, error: '团队负荷指标读取失败,请稍后重试' })
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
})

View File

@ -0,0 +1,36 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
DingWorkflowMetricApiError,
getTeamWorkloadSummary,
} 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 getTeamWorkloadSummary(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

@ -8,6 +8,7 @@ import {
getMonthlyLaunchInsights,
getOnTimeDeliveryInsights,
getOngoingDingWorkflowInsights,
getTeamWorkloadInsights,
} from '@/lib/ding-workflow-sync'
import {
getFirstPassReviewProjects,
@ -60,5 +61,11 @@ export default async function CoreMetricPage({ params, searchParams }: CoreMetri
return <CoreMetricDetail metric={metric} query={query} firstPassPage={firstPassPage} />
}
if (metric.key === 'team-workload') {
const session = await getServerSession(authOptions)
const teamWorkload = await getTeamWorkloadInsights(session)
return <CoreMetricDetail metric={metric} teamWorkload={teamWorkload} />
}
return <CoreMetricDetail metric={metric} />
}

View File

@ -17,12 +17,14 @@ import type {
MonthlyLaunchInsights,
OnTimeDeliveryInsights,
OngoingDingWorkflowInsights,
TeamWorkloadInsights,
} from '@/lib/ding-workflow-sync'
import DepartmentShell from './DepartmentShell'
import MonthlyLaunchesInsights from './MonthlyLaunchesInsights'
import OngoingProjectsInsights from './OngoingProjectsInsights'
import OnTimeDeliveryInsightsView from './OnTimeDeliveryInsights'
import OnTimeDeliveryRate from './OnTimeDeliveryRate'
import TeamWorkloadInsightsView from './TeamWorkloadInsights'
import type { CoreMetric } from './core-metrics'
type CoreMetricDetailProps = {
@ -32,6 +34,7 @@ type CoreMetricDetailProps = {
monthlyLaunches?: MonthlyLaunchInsights
onTimeDelivery?: OnTimeDeliveryInsights
firstPassPage?: FirstPassReviewProjectPage
teamWorkload?: TeamWorkloadInsights
}
function formatDate(value: string | null, includeTime = false) {
@ -288,11 +291,12 @@ function PendingDataSource({ metric }: { metric: CoreMetric }) {
)
}
export default function CoreMetricDetail({ metric, query, projectInsights, monthlyLaunches, onTimeDelivery, firstPassPage }: CoreMetricDetailProps) {
export default function CoreMetricDetail({ metric, query, projectInsights, monthlyLaunches, onTimeDelivery, firstPassPage, teamWorkload }: CoreMetricDetailProps) {
const hasRealProjectSource = metric.key === 'ongoing-projects' && projectInsights
const hasMonthlyLaunchSource = metric.key === 'monthly-launches' && monthlyLaunches
const hasFirstPassSource = metric.key === 'first-pass-rate' && firstPassPage
const hasOnTimeDeliverySource = metric.key === 'on-time-delivery' && onTimeDelivery
const hasTeamWorkloadSource = metric.key === 'team-workload' && teamWorkload
return (
<DepartmentShell>
@ -304,14 +308,14 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
<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 ? '当前未完结流程' : hasMonthlyLaunchSource ? '本月已完结 / 新品流程总数' : hasOnTimeDeliverySource ? '本月按时交付率' : '总览示例值'}</p>
<p className="text-xs font-bold text-slate-500">{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : hasMonthlyLaunchSource ? '本月已完结 / 新品流程总数' : hasOnTimeDeliverySource ? '本月按时交付率' : hasTeamWorkloadSource ? '品牌中心平均负荷' : '总览示例值'}</p>
{hasOnTimeDeliverySource && onTimeDelivery.summary.rate !== null
? <OnTimeDeliveryRate rate={onTimeDelivery.summary.rate} contextLabel="按时交付当前值" size="summary" />
: <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}` : hasOnTimeDeliverySource ? '—' : 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}` : hasOnTimeDeliverySource ? '—' : hasTeamWorkloadSource ? formatRate(teamWorkload.summary.averageLoadPercent) : 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.totalApproved}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `本月已完结 ${monthlyLaunches.summary.completed} / 当前进行中 ${monthlyLaunches.summary.ongoing}` : hasOnTimeDeliverySource ? `未超时审核节点完成 ${onTimeDelivery.summary.onTime} / 审核节点完成 ${onTimeDelivery.summary.total}` : '等待业务数据源'}</p>
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 已通过 ${firstPassPage.summary.totalApproved}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `本月已完结 ${monthlyLaunches.summary.completed} / 当前进行中 ${monthlyLaunches.summary.ongoing}` : hasOnTimeDeliverySource ? `未超时审核节点完成 ${onTimeDelivery.summary.onTime} / 审核节点完成 ${onTimeDelivery.summary.total}` : hasTeamWorkloadSource ? `${teamWorkload.summary.busyPersonnel} 人有进行中流程 / 共 ${teamWorkload.summary.personnelCount} 人` : '等待业务数据源'}</p>
</div>
<div className="p-5">
<p className="text-xs font-bold text-slate-500">同步状态</p>
@ -333,6 +337,10 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
? firstPassPage.summary.lastSyncedAt
? `每小时增量同步 · ${formatDate(firstPassPage.summary.lastSyncedAt, true)}`
: firstPassPage.summary.syncStatus === 'error' ? '增量同步失败' : '等待首轮同步'
: hasTeamWorkloadSource
? teamWorkload.summary.lastSyncedAt
? `每小时增量同步 · ${formatDate(teamWorkload.summary.lastSyncedAt, true)}`
: teamWorkload.summary.syncStatus === 'error' ? '同步失败' : '等待首轮同步'
: '每日同步待接入'}
</p>
</div>
@ -346,6 +354,8 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
? <OnTimeDeliveryInsightsView insights={onTimeDelivery} />
: hasFirstPassSource
? <FirstPassProjectList metric={metric} query={query} firstPassPage={firstPassPage} />
: hasTeamWorkloadSource
? <TeamWorkloadInsightsView insights={teamWorkload} />
: <PendingDataSource metric={metric} />}
</main>
</DepartmentShell>

View File

@ -47,6 +47,21 @@ describe('CoreMetricsDashboard', () => {
syncStatus: 'ready',
},
}
: url.includes('team-workload')
? {
success: true,
data: {
averageLoadPercent: 30.7,
personnelCount: 44,
busyPersonnel: 17,
idlePersonnel: 27,
activeProjectAssignments: 27,
sourceOngoingProjects: 27,
excludedNames: ['元泽', '江陵', '无邪', '清云', '伯言', '希文', '文轩'],
lastSyncedAt: '2026-08-14T02:00:00.000Z',
syncStatus: 'ready',
},
}
: {
success: true,
data: {
@ -67,7 +82,7 @@ describe('CoreMetricsDashboard', () => {
jest.restoreAllMocks()
})
it('展示真实流程指标、首轮通过率与其余两项诚实的示例状态', async () => {
it('展示真实流程、首轮通过率与团队负荷指标', async () => {
render(<CoreMetricsDashboard />)
expect(screen.getByRole('heading', { level: 1, name: '核心指标' })).toBeInTheDocument()
@ -76,19 +91,19 @@ describe('CoreMetricsDashboard', () => {
expect(screen.getByText('V1通过 11 / 已通过 15')).toBeInTheDocument()
expect(screen.getByText('作品过稿后台')).toBeInTheDocument()
expect(screen.getAllByText('每小时同步').length).toBeGreaterThan(0)
expect(screen.getAllByText('示例值')).toHaveLength(2)
expect(screen.getAllByText('示例值')).toHaveLength(1)
for (const label of ['进行中项目', '本月上新', '按时交付', '首轮通过', '团队负荷', '风险']) {
expect(screen.getByRole('heading', { level: 3, name: label })).toBeInTheDocument()
}
for (const value of ['2338', '8/13', '92.3%', '73.3%', '87%', '4项']) {
for (const value of ['2338', '8/13', '92.3%', '73.3%', '30.7%', '4项']) {
expect(screen.getByText(value)).toBeInTheDocument()
}
expect(screen.getByText('本月已完结 8 · 当前进行中 5')).toBeInTheDocument()
expect(screen.getAllByText('公式待接入')).toHaveLength(2)
expect(screen.getAllByText('公式待接入')).toHaveLength(1)
expect(screen.getAllByText('钉钉流程快照').length).toBeGreaterThan(0)
expect(screen.getByText('未超时 12 / 本月完结 13')).toBeInTheDocument()
expect(screen.getAllByText('查看明细')).toHaveLength(5)
@ -115,8 +130,8 @@ describe('CoreMetricsDashboard', () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({ success: false }) }) as jest.Mock
render(<CoreMetricsDashboard />)
expect(await screen.findAllByText('读取失败')).toHaveLength(4)
expect(await screen.findAllByText('读取失败')).toHaveLength(5)
expect(screen.queryByText('82.3%')).not.toBeInTheDocument()
expect(screen.getAllByText('数据未加载')).toHaveLength(4)
expect(screen.getAllByText('数据未加载')).toHaveLength(5)
})
})

View File

@ -11,6 +11,7 @@ import type {
MonthlyLaunchSummary,
OnTimeDeliverySummary,
OngoingDingWorkflowSummary,
TeamWorkloadSummary,
} from '@/lib/ding-workflow-sync'
import DepartmentShell from './DepartmentShell'
import { CORE_METRICS, getCoreMetricHref } from './core-metrics'
@ -36,6 +37,11 @@ type OnTimeDeliveryState =
| { status: 'success'; data: OnTimeDeliverySummary }
| { status: 'error'; data: null }
type TeamWorkloadState =
| { status: 'loading'; data: null }
| { status: 'success'; data: TeamWorkloadSummary }
| { status: 'error'; data: null }
function formatRate(rate: number | null) {
if (rate === null) return '—'
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
@ -58,6 +64,7 @@ export default function CoreMetricsDashboard() {
const [ongoingWorkflows, setOngoingWorkflows] = useState<OngoingWorkflowState>({ status: 'loading', data: null })
const [monthlyLaunches, setMonthlyLaunches] = useState<MonthlyLaunchState>({ status: 'loading', data: null })
const [onTimeDelivery, setOnTimeDelivery] = useState<OnTimeDeliveryState>({ status: 'loading', data: null })
const [teamWorkload, setTeamWorkload] = useState<TeamWorkloadState>({ status: 'loading', data: null })
useEffect(() => {
const controller = new AbortController()
@ -118,10 +125,25 @@ export default function CoreMetricsDashboard() {
}
}
async function loadTeamWorkloadSummary() {
try {
const response = await fetch('/api/department-dashboard/core-metrics/team-workload', {
cache: 'no-store',
signal: controller.signal,
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success || !payload.data) throw new Error('指标读取失败')
setTeamWorkload({ status: 'success', data: payload.data })
} catch (error) {
if ((error as Error).name !== 'AbortError') setTeamWorkload({ status: 'error', data: null })
}
}
void loadFirstPassSummary()
void loadOngoingWorkflowSummary()
void loadMonthlyLaunchSummary()
void loadOnTimeDeliverySummary()
void loadTeamWorkloadSummary()
return () => controller.abort()
}, [])
@ -157,6 +179,7 @@ export default function CoreMetricsDashboard() {
const isMonthlyLaunch = metric.key === 'monthly-launches'
const isFirstPass = metric.key === 'first-pass-rate'
const isOnTimeDelivery = metric.key === 'on-time-delivery'
const isTeamWorkload = metric.key === 'team-workload'
const badge = isOngoingWorkflow
? ongoingWorkflows.status === 'success'
? ongoingWorkflows.data.syncStatus === 'ready'
@ -191,6 +214,12 @@ export default function CoreMetricsDashboard() {
? firstPass.status === 'success'
? firstPass.data.syncStatus === 'ready' ? '每小时同步' : firstPass.data.syncStatus === 'error' ? '同步异常' : '正在同步'
: firstPass.status === 'error' ? '读取失败' : '正在读取'
: isTeamWorkload
? teamWorkload.status === 'success'
? teamWorkload.data.syncStatus === 'ready'
? '每小时同步'
: teamWorkload.data.syncStatus === 'error' ? '同步异常' : '正在同步'
: teamWorkload.status === 'error' ? '读取失败' : '正在读取'
: '示例值'
const value = isOngoingWorkflow
? ongoingWorkflows.status === 'success' ? String(ongoingWorkflows.data.total) : '—'
@ -200,6 +229,10 @@ export default function CoreMetricsDashboard() {
? onTimeDelivery.status === 'success' ? formatRate(onTimeDelivery.data.rate) : '—'
: isFirstPass
? firstPass.status === 'success' ? formatRate(firstPass.data.rate) : '—'
: isTeamWorkload
? teamWorkload.status === 'success' && teamWorkload.data.averageLoadPercent !== null
? `${teamWorkload.data.averageLoadPercent}%`
: '—'
: metric.exampleValue
return (
@ -219,11 +252,11 @@ export default function CoreMetricsDashboard() {
</div>
<h3 className="mt-6 text-base font-bold text-[#4f6077]">{metric.label}</h3>
<div aria-live={isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery ? 'polite' : undefined} aria-atomic={isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery ? 'true' : undefined}>
<div aria-live={isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery || isTeamWorkload ? 'polite' : undefined} aria-atomic={isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery || isTeamWorkload ? 'true' : undefined}>
{isOnTimeDelivery && onTimeDelivery.status === 'success' && onTimeDelivery.data.rate !== null
? <OnTimeDeliveryRate rate={onTimeDelivery.data.rate} 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 || isMonthlyLaunch || isOnTimeDelivery ? '当前值' : '示例值'}${value}`}>
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery || isTeamWorkload ? '当前值' : '示例值'}${value}`}>
{value}
</p>
)}
@ -264,6 +297,19 @@ export default function CoreMetricsDashboard() {
{isOnTimeDelivery && onTimeDelivery.status === 'loading' && (
<p className="mt-2 text-xs font-semibold text-slate-500">正在计算本月交付时效</p>
)}
{isTeamWorkload && teamWorkload.status === 'success' && (
<p className="mt-2 text-xs font-semibold tabular-nums text-slate-500">
{teamWorkload.data.busyPersonnel} 人有进行中流程 / 共 {teamWorkload.data.personnelCount} 人
</p>
)}
{isTeamWorkload && teamWorkload.status === 'success' && teamWorkload.data.lastSyncedAt && (
<p className="mt-2 text-xs font-semibold tabular-nums text-slate-500">
最近同步 {formatSyncTime(teamWorkload.data.lastSyncedAt)}
</p>
)}
{isTeamWorkload && teamWorkload.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>
@ -271,14 +317,21 @@ 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 || isMonthlyLaunch || isOnTimeDelivery
? (isOngoingWorkflow ? ongoingWorkflows.status : isMonthlyLaunch ? monthlyLaunches.status : onTimeDelivery.status) === 'error' ? '刷新页面重试' : '每小时增量同步'
{isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery || isTeamWorkload
? (isOngoingWorkflow
? ongoingWorkflows.status
: isMonthlyLaunch
? monthlyLaunches.status
: isOnTimeDelivery
? onTimeDelivery.status
: teamWorkload.status) === 'error' ? '刷新页面重试' : '每小时增量同步'
: isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '每小时增量同步') : '每日同步'}
</span>
<span aria-hidden="true">·</span>
<span>{isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery
? (isOngoingWorkflow ? ongoingWorkflows.status : isMonthlyLaunch ? monthlyLaunches.status : onTimeDelivery.status) === 'error' ? '数据未加载' : '钉钉流程快照'
: isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '作品过稿后台') : '公式待接入'}</span>
: isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '作品过稿后台')
: isTeamWorkload ? (teamWorkload.status === 'error' ? '数据未加载' : '钉钉流程快照') : '公式待接入'}</span>
</span>
<span className="inline-flex items-center gap-1 text-[#176fc1]">
{isFirstPass ? '查看所有作品明细' : '查看明细'}

View File

@ -0,0 +1,120 @@
'use client'
import { useId, useState } from 'react'
import { ChevronDown, Clock3 } from 'lucide-react'
import type {
TeamWorkloadDepartment,
TeamWorkloadInsights as TeamWorkloadInsightsData,
TeamWorkloadPerson,
} from '@/lib/ding-workflow-sync'
function formatPercent(value: number | null) {
if (value === null) return '—'
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}%`
}
function personStatusLabel(person: TeamWorkloadPerson) {
if (person.status === 'idle') return '空闲'
if (person.status === 'available') return '1 个进行中 · 负荷 50%'
if (person.status === 'full') return '2 个进行中 · 满负荷 100%'
return `${person.activeProjectCount} 个进行中 · 负荷 ${person.loadPercent}%`
}
function personLoadBadgeClass(status: TeamWorkloadPerson['status']) {
if (status === 'idle') return 'bg-slate-200 text-slate-700'
if (status === 'overloaded') return 'bg-red-100 text-red-800'
return 'bg-blue-100 text-blue-800'
}
function DepartmentRow({ department }: { department: TeamWorkloadDepartment }) {
const [expanded, setExpanded] = useState(false)
const detailsId = useId()
return (
<div className="rounded-xl border border-slate-200 bg-white">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls={detailsId}
className="flex w-full items-center justify-between gap-4 p-5 text-left hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#5a9ce6]"
>
<span className="min-w-0">
<span className="block break-words text-base font-bold text-slate-950" title={department.name}>{department.name}</span>
<span className="mt-1 block text-xs text-slate-500">
{department.personnelCount} 人 · {department.busyPersonnel} 人有进行中 · {department.activeProjectAssignments} 个流程归属
</span>
</span>
<span className="flex shrink-0 items-center gap-3">
<span className="text-lg font-bold tabular-nums text-slate-950">{formatPercent(department.averageLoadPercent)}</span>
<ChevronDown className={`h-5 w-5 text-slate-500 transition-transform ${expanded ? 'rotate-180' : ''}`} aria-hidden="true" />
</span>
</button>
{expanded && (
<div id={detailsId} role="region" aria-label={`${department.name}人员与流程明细`} className="border-t border-slate-200 p-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{department.people.map((person) => (
<article key={person.id} className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="break-words font-bold text-slate-950" title={person.name}>{person.name}</p>
<p className="mt-1 text-xs text-slate-500">{person.activeProjectCount} 个进行中流程</p>
</div>
<span className={`shrink-0 rounded-md px-2 py-1 text-xs font-bold ${personLoadBadgeClass(person.status)}`}>
{formatPercent(person.loadPercent)}
</span>
</div>
<p className="mt-3 text-xs font-semibold text-slate-600">{personStatusLabel(person)}</p>
{person.projects.length > 0 && (
<ul className="mt-3 space-y-2 border-t border-slate-200 pt-3">
{person.projects.map((project) => (
<li key={project.id} className="break-words text-xs leading-5 text-slate-700">
<span className="font-semibold text-slate-900" title={project.title}>{project.title}</span>
<span className="block text-slate-500">{project.projectType} · {project.nodeNames.join(' / ') || '当前处理节点'}</span>
</li>
))}
</ul>
)}
</article>
))}
</div>
</div>
)}
</div>
)
}
export default function TeamWorkloadInsights({ insights }: { insights: TeamWorkloadInsightsData }) {
const { summary, departments } = insights
return (
<section aria-labelledby="team-workload-detail-title" className="mt-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 id="team-workload-detail-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">
统计品牌中心在职人员当前手里的进行中流程;0 个为空闲,1 个为 50%,2 个为 100%,超过 2 个后每个流程继续增加 50%。点击部门可展开人员与流程明细。
</p>
</div>
<span className="inline-flex items-center gap-2 rounded-xl bg-white px-3 py-2 text-xs font-bold text-slate-600">
<Clock3 className="h-4 w-4" aria-hidden="true" />每小时增量同步
</span>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">品牌中心平均负荷</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{formatPercent(summary.averageLoadPercent)}</p></div>
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">统计人员</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{summary.personnelCount} 人</p></div>
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">有进行中流程</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{summary.busyPersonnel} 人</p></div>
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">进行中流程归属</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{summary.activeProjectAssignments} 个</p></div>
</div>
<div className="mt-5 rounded-xl border border-blue-100 bg-blue-50 px-4 py-3 text-xs leading-5 text-blue-900">
已排除:{summary.excludedNames.join('、')}。进行中流程与“进行中项目”板块使用同一份钉钉流程快照;当前快照共 {summary.sourceOngoingProjects} 个流程。
</div>
<div className="mt-5 space-y-3">
{departments.length === 0 ? (
<div role="status" className="rounded-xl border border-slate-200 bg-white px-5 py-12 text-center text-sm text-slate-600">暂无匹配的品牌中心在职人员</div>
) : departments.map((department) => <DepartmentRow key={department.name} department={department} />)}
</div>
</section>
)
}

View File

@ -68,11 +68,11 @@ export const CORE_METRICS: CoreMetric[] = [
{
key: 'team-workload',
label: '团队负荷',
exampleValue: '87%',
description: '当前任务负荷相对可用产能的占比。',
exampleValue: '—',
description: '品牌中心在职人员(排除指定管理人员)的进行中流程负荷平均值;每个进行中流程计 50%,空闲人员按 0% 计入。',
icon: UsersRound,
detailDescription: '用于对照团队可用产能、已分配任务与负荷风险。',
plannedFields: ['团队或人员', '可用产能', '已分配', '负荷率', '风险状态'],
detailDescription: '按部门展开品牌中心人员的进行中流程数量、当前节点与负荷;同一人员同一流程只计一次,数据每小时增量同步。',
plannedFields: ['部门', '人员', '进行中流程数', '当前节点', '负荷率', '状态'],
},
{
key: 'risks',

View File

@ -0,0 +1,113 @@
import { buildTeamWorkloadInsights } from '@/lib/ding-workflow-sync'
jest.mock('mssql', () => ({
__esModule: true,
default: {},
}))
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/brand-center-access', () => ({ resolveBrandCenterAccessForUser: jest.fn() }))
function rosterRow(overrides: Partial<{
id: string
dingtalkUserId: string | null
displayName: string
flowerName: string | null
realName: string | null
departmentName: string | null
departmentPath: string | null
employmentState: string | null
departmentMemberships: Array<{ departmentName: string | null; departmentPath: string | null; isPrimary: boolean }>
}> = {}) {
return {
id: 'person-1',
dingtalkUserId: 'ding-1',
displayName: '一号员工',
flowerName: null,
realName: null,
departmentName: '设计部',
departmentPath: '品牌中心/设计部',
employmentState: 'ACTIVE',
departmentMemberships: [],
...overrides,
}
}
function snapshotRow(id: string, assignees: string[], title = id) {
return {
processInstanceId: id,
businessId: `business-${id}`,
title,
processCode: 'PROC-ART',
projectType: '美术设计流程',
activeTasks: assignees.map((assigneeUserId, index) => ({
taskId: `${id}-task-${index}`,
activityId: `activity-${index}`,
nodeName: `节点${index + 1}`,
assigneeUserId,
assigneeName: assigneeUserId,
assigneeDepartment: '品牌中心/设计部',
isBrandCenter: true,
isLeadership: false,
isActive: true,
reviewPassed: false,
result: null,
createdAt: null,
finishedAt: null,
})),
}
}
describe('buildTeamWorkloadInsights', () => {
it('排除指定人员、把空闲人员计入平均值,并按流程去重', () => {
const insights = buildTeamWorkloadInsights(
[
rosterRow({ id: 'busy', dingtalkUserId: 'busy', displayName: '忙碌员工' }),
rosterRow({ id: 'idle', dingtalkUserId: 'idle', displayName: '空闲员工', departmentName: '内容部', departmentPath: '品牌中心/内容部' }),
rosterRow({ id: 'excluded', dingtalkUserId: 'excluded', displayName: '元泽' }),
rosterRow({ id: 'resigned', dingtalkUserId: 'resigned', displayName: '离职员工', employmentState: 'RESIGNED' }),
],
[
snapshotRow('project-1', ['busy', 'busy']),
snapshotRow('project-2', ['busy']),
snapshotRow('project-3', ['busy']),
snapshotRow('project-excluded', ['excluded']),
],
{ lastSyncedAt: '2026-08-31T02:00:00.000Z', syncStatus: 'ready' },
)
expect(insights.summary).toMatchObject({
averageLoadPercent: 75,
personnelCount: 2,
busyPersonnel: 1,
idlePersonnel: 1,
activeProjectAssignments: 3,
sourceOngoingProjects: 4,
})
expect(insights.departments).toEqual(expect.arrayContaining([
expect.objectContaining({ name: '设计部', personnelCount: 1, averageLoadPercent: 150 }),
expect.objectContaining({ name: '内容部', personnelCount: 1, averageLoadPercent: 0 }),
]))
const busy = insights.departments.find((department) => department.name === '设计部')!.people[0]
expect(busy.activeProjectCount).toBe(3)
expect(busy.loadPercent).toBe(150)
expect(busy.status).toBe('overloaded')
expect(busy.projects).toHaveLength(3)
})
it('只统计当前活跃节点,并以当前花名册组织归属为准', () => {
const insights = buildTeamWorkloadInsights(
[rosterRow()],
[{
...snapshotRow('project-1', ['ding-1']),
activeTasks: [
{ ...snapshotRow('project-1', ['ding-1']).activeTasks[0], isActive: false },
{ ...snapshotRow('project-1', ['ding-1']).activeTasks[0], taskId: 'other', isBrandCenter: false, isActive: true },
],
}],
{ lastSyncedAt: null, syncStatus: 'pending' },
)
expect(insights.summary.activeProjectAssignments).toBe(1)
expect(insights.summary.averageLoadPercent).toBe(50)
})
})

View File

@ -38,6 +38,7 @@ export const DING_WORKFLOW_ALLOWED_PROCESS_CODES = [
] as const
export const DING_WORKFLOW_BRANDS = ['亿家达', '卓禾', '欧意朗', '顾家', '索菲亚', '朴美'] as const
export const DING_WORKFLOW_NEW_PRODUCT_DEMAND_TYPE = '新品'
export const TEAM_WORKLOAD_EXCLUDED_NAMES = ['元泽', '江陵', '无邪', '清云', '伯言', '希文', '文轩'] as const
const ALLOWED_PROCESS_CODE_VALUES: string[] = [...DING_WORKFLOW_ALLOWED_PROCESS_CODES]
const ALLOWED_PROCESS_CODE_SET = new Set<string>(DING_WORKFLOW_ALLOWED_PROCESS_CODES)
const BRAND_CENTER_ACTIVE_TASK_JSON = [{ isBrandCenter: true, isActive: true }]
@ -362,6 +363,55 @@ export type OngoingDingWorkflowInsights = {
sync: OngoingDingWorkflowSummary
}
export type TeamWorkloadStatus = 'idle' | 'available' | 'full' | 'overloaded'
export type TeamWorkloadProject = {
id: string
businessId: string | null
title: string
projectType: string
nodeNames: string[]
}
export type TeamWorkloadPerson = {
id: string
dingtalkUserId: string | null
name: string
departmentName: string
departmentPath: string
activeProjectCount: number
loadPercent: number
status: TeamWorkloadStatus
projects: TeamWorkloadProject[]
}
export type TeamWorkloadDepartment = {
name: string
personnelCount: number
busyPersonnel: number
idlePersonnel: number
activeProjectAssignments: number
averageLoadPercent: number
people: TeamWorkloadPerson[]
}
export type TeamWorkloadSummary = {
averageLoadPercent: number | null
personnelCount: number
busyPersonnel: number
idlePersonnel: number
activeProjectAssignments: number
sourceOngoingProjects: number
excludedNames: readonly string[]
lastSyncedAt: string | null
syncStatus: DingWorkflowSyncStatus
}
export type TeamWorkloadInsights = {
summary: TeamWorkloadSummary
departments: TeamWorkloadDepartment[]
}
export type MonthlyLaunchWorkflowStatus = 'completed' | 'ongoing'
export type MonthlyLaunchWorkflowItem = OngoingDingWorkflowListItem & {
@ -628,6 +678,200 @@ function normalizeWorkflowTasks(value: unknown): OngoingDingWorkflowActiveTask[]
})
}
type TeamWorkloadRosterRow = {
id: string
dingtalkUserId: string | null
displayName: string
flowerName: string | null
realName: string | null
departmentName: string | null
departmentPath: string | null
employmentState: string | null
departmentMemberships: Array<{
departmentName: string | null
departmentPath: string | null
isPrimary: boolean
}>
}
type TeamWorkloadSnapshotRow = {
processInstanceId: string
businessId: string | null
title: string | null
processCode: string | null
projectType: string | null
activeTasks: unknown
}
function cleanTeamWorkloadName(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized || null
}
function profileHasBrandCenterPath(profile: TeamWorkloadRosterRow) {
return [
profile.departmentPath,
...profile.departmentMemberships.map((membership) => membership.departmentPath),
].some(isBrandCenterOrganizationPath)
}
function profileIsExcludedFromTeamWorkload(profile: TeamWorkloadRosterRow) {
const names = [profile.displayName, profile.flowerName, profile.realName]
.map(cleanTeamWorkloadName)
.filter((name): name is string => Boolean(name))
return names.some((name) => TEAM_WORKLOAD_EXCLUDED_NAMES.includes(name as typeof TEAM_WORKLOAD_EXCLUDED_NAMES[number]))
}
function profileDepartmentForTeamWorkload(profile: TeamWorkloadRosterRow) {
const matchingPaths = [
{ departmentPath: profile.departmentPath, departmentName: profile.departmentName, isPrimary: true },
...profile.departmentMemberships,
].filter((entry) => isBrandCenterOrganizationPath(entry.departmentPath))
const selected = matchingPaths.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))[0]
const parts = String(selected?.departmentPath || profile.departmentPath || '')
.split('/')
.map((part) => part.trim())
.filter(Boolean)
const brandCenterIndex = parts.indexOf('品牌中心')
const departmentName = brandCenterIndex >= 0
? parts[brandCenterIndex + 1] || selected?.departmentName || '品牌中心'
: selected?.departmentName || profile.departmentName || '品牌中心'
const departmentPath = brandCenterIndex >= 0
? parts.slice(brandCenterIndex).join('/')
: String(selected?.departmentPath || profile.departmentPath || '品牌中心')
return { departmentName, departmentPath }
}
function workloadStatus(activeProjectCount: number): TeamWorkloadStatus {
if (activeProjectCount <= 0) return 'idle'
if (activeProjectCount === 1) return 'available'
if (activeProjectCount === 2) return 'full'
return 'overloaded'
}
function roundWorkloadPercent(value: number) {
return Math.round(value * 10) / 10
}
/**
* 品牌中心团队负荷的纯计算部分,供同步读取和单元测试复用。
* 每个当前节点处理人按流程去重后,每条进行中流程计 50% 负荷;平均值的
* 分母是排除指定管理人员后的全部在职品牌中心人员,因此没有进行中流程的人
* 会以 0%(空闲)计入平均值。
*/
export function buildTeamWorkloadInsights(
rosterRows: TeamWorkloadRosterRow[],
snapshotRows: TeamWorkloadSnapshotRow[],
sync: Pick<OngoingDingWorkflowSummary, 'lastSyncedAt' | 'syncStatus'>,
): TeamWorkloadInsights {
const roster = rosterRows
.filter((profile) => profile.employmentState !== 'RESIGNED')
.filter(profileHasBrandCenterPath)
.filter((profile) => !profileIsExcludedFromTeamWorkload(profile))
.map((profile) => {
const department = profileDepartmentForTeamWorkload(profile)
return {
profile,
name: cleanTeamWorkloadName(profile.flowerName)
|| cleanTeamWorkloadName(profile.displayName)
|| cleanTeamWorkloadName(profile.realName)
|| '未命名人员',
...department,
}
})
const peopleByDingtalkUserId = new Map(
roster
.filter((person) => person.profile.dingtalkUserId)
.map((person) => [person.profile.dingtalkUserId as string, person]),
)
const projectByPerson = new Map<string, Map<string, TeamWorkloadProject>>()
snapshotRows.forEach((snapshot) => {
const projectId = snapshot.processInstanceId
// 当前 HR 花名册负责判定人员是否属于品牌中心;不再依赖快照同步时物化的
// isBrandCenter 标记,避免人员调入品牌中心后、流程未更新时发生漏算。
const tasks = normalizeWorkflowTasks(snapshot.activeTasks)
.filter((task) => task.isActive && task.assigneeUserId)
const tasksByPerson = new Map<string, OngoingDingWorkflowActiveTask[]>()
tasks.forEach((task) => {
const person = peopleByDingtalkUserId.get(task.assigneeUserId as string)
if (!person) return
const current = tasksByPerson.get(person.profile.id) || []
current.push(task)
tasksByPerson.set(person.profile.id, current)
})
tasksByPerson.forEach((personTasks, personId) => {
const projects = projectByPerson.get(personId) || new Map<string, TeamWorkloadProject>()
const existing = projects.get(projectId)
const nodeNames = Array.from(new Set(personTasks.map((task) => task.nodeName).filter(Boolean)))
projects.set(projectId, {
id: projectId,
businessId: snapshot.businessId,
title: snapshot.title || '未命名流程',
projectType: snapshot.projectType || getDingWorkflowProjectType(snapshot.processCode),
nodeNames: existing ? Array.from(new Set([...existing.nodeNames, ...nodeNames])) : nodeNames,
})
projectByPerson.set(personId, projects)
})
})
const people = roster.map((entry): TeamWorkloadPerson => {
const projects = Array.from(projectByPerson.get(entry.profile.id)?.values() || [])
.sort((a, b) => a.title.localeCompare(b.title, 'zh-CN') || a.id.localeCompare(b.id))
const activeProjectCount = projects.length
return {
id: entry.profile.id,
dingtalkUserId: entry.profile.dingtalkUserId,
name: entry.name,
departmentName: entry.departmentName,
departmentPath: entry.departmentPath,
activeProjectCount,
loadPercent: activeProjectCount * 50,
status: workloadStatus(activeProjectCount),
projects,
}
}).sort((a, b) => b.loadPercent - a.loadPercent || a.name.localeCompare(b.name, 'zh-CN'))
const departments = Array.from(new Set(people.map((person) => person.departmentName)))
.map((name): TeamWorkloadDepartment => {
const departmentPeople = people
.filter((person) => person.departmentName === name)
.sort((a, b) => b.loadPercent - a.loadPercent || a.name.localeCompare(b.name, 'zh-CN'))
const activeProjectAssignments = departmentPeople.reduce((total, person) => total + person.activeProjectCount, 0)
return {
name,
personnelCount: departmentPeople.length,
busyPersonnel: departmentPeople.filter((person) => person.activeProjectCount > 0).length,
idlePersonnel: departmentPeople.filter((person) => person.activeProjectCount === 0).length,
activeProjectAssignments,
averageLoadPercent: departmentPeople.length > 0
? roundWorkloadPercent(activeProjectAssignments * 50 / departmentPeople.length)
: 0,
people: departmentPeople,
}
})
.sort((a, b) => b.averageLoadPercent - a.averageLoadPercent || a.name.localeCompare(b.name, 'zh-CN'))
const activeProjectAssignments = people.reduce((total, person) => total + person.activeProjectCount, 0)
return {
summary: {
averageLoadPercent: people.length > 0
? roundWorkloadPercent(activeProjectAssignments * 50 / people.length)
: null,
personnelCount: people.length,
busyPersonnel: people.filter((person) => person.activeProjectCount > 0).length,
idlePersonnel: people.filter((person) => person.activeProjectCount === 0).length,
activeProjectAssignments,
sourceOngoingProjects: snapshotRows.length,
excludedNames: TEAM_WORKLOAD_EXCLUDED_NAMES,
lastSyncedAt: sync.lastSyncedAt,
syncStatus: sync.syncStatus,
},
departments,
}
}
function normalizeDetailFormFields(value: unknown): DingWorkflowDetailFormFields {
const record = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
@ -1629,6 +1873,67 @@ async function readSummaryWithoutAccessCheck(): Promise<OngoingDingWorkflowSumma
}
}
const TEAM_WORKLOAD_ROSTER_SELECT = {
id: true,
dingtalkUserId: true,
displayName: true,
flowerName: true,
realName: true,
departmentName: true,
departmentPath: true,
employmentState: true,
departmentMemberships: {
select: {
departmentName: true,
departmentPath: true,
isPrimary: true,
},
},
} as const
const TEAM_WORKLOAD_SNAPSHOT_SELECT = {
processInstanceId: true,
businessId: true,
title: true,
processCode: true,
projectType: true,
activeTasks: true,
} as const
async function readTeamWorkloadInsightsWithoutAccessCheck(): Promise<TeamWorkloadInsights> {
const [rosterCandidates, snapshots, sync] = await Promise.all([
prisma.performanceSurveyEmployeeProfile.findMany({
where: {
OR: [
{ departmentPath: { contains: '品牌中心' } },
{ departmentMemberships: { some: { departmentPath: { contains: '品牌中心' } } } },
],
},
select: TEAM_WORKLOAD_ROSTER_SELECT,
}),
prisma.dingWorkflowSnapshot.findMany({
where: buildBrandCenterOngoingWorkflowWhere(),
select: TEAM_WORKLOAD_SNAPSHOT_SELECT,
orderBy: [
{ sourceUpdatedAtRaw: { sort: 'desc', nulls: 'last' } },
{ processInstanceId: 'desc' },
],
}),
readSummaryWithoutAccessCheck(),
])
return buildTeamWorkloadInsights(rosterCandidates, snapshots, sync)
}
export async function getTeamWorkloadSummary(session: Session | null) {
await assertDashboardAccess(session)
return (await readTeamWorkloadInsightsWithoutAccessCheck()).summary
}
export async function getTeamWorkloadInsights(session: Session | null) {
await assertDashboardAccess(session)
return readTeamWorkloadInsightsWithoutAccessCheck()
}
export async function getOngoingDingWorkflowSummary(session: Session | null) {
await assertDashboardAccess(session)
return readSummaryWithoutAccessCheck()