2026-08-31 16:40:07 +08:00

364 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Link from 'next/link'
import {
ArrowRight,
CalendarDays,
CheckCircle2,
Clock3,
Database,
ExternalLink,
RotateCcw,
Search,
} from 'lucide-react'
import type {
FirstPassReviewProjectListItem,
FirstPassReviewProjectPage,
} from '@/lib/work-review-service'
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 = {
metric: CoreMetric
query?: string
projectInsights?: OngoingDingWorkflowInsights
monthlyLaunches?: MonthlyLaunchInsights
onTimeDelivery?: OnTimeDeliveryInsights
firstPassPage?: FirstPassReviewProjectPage
teamWorkload?: TeamWorkloadInsights
}
function formatDate(value: string | null, includeTime = false) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
...(includeTime ? { hour: '2-digit', minute: '2-digit', hour12: false } : {}),
timeZone: 'Asia/Shanghai',
}).format(new Date(value))
}
function buildPageHref(metricKey: string, page: number, query?: string) {
const search = new URLSearchParams()
if (page > 1) search.set('page', String(page))
if (query) search.set('q', query)
const suffix = search.toString()
return `/workspaces/dashboard/core-metrics/${metricKey}${suffix ? `?${suffix}` : ''}`
}
function formatRate(rate: number | null) {
if (rate === null) return '—'
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
}
const CURRENT_REVIEW_STATUS_LABELS: Record<string, string> = {
approved: '当前已通过',
pending_review: '当前评审中',
revision_required: '当前待修改',
}
const REVIEW_ROLE_LABELS: Record<string, string> = {
manager: '主管',
final_reviewer: '终审',
}
const REVIEW_DECISION_LABELS: Record<string, string> = {
approved: '通过',
revision_required: '退修',
}
function getFirstVersionReviewerSummary(project: FirstPassReviewProjectListItem) {
if (!project.firstVersionReviews?.length) return '待评审'
return project.firstVersionReviews.map((review) => (
`${review.reviewerName}(${REVIEW_ROLE_LABELS[review.reviewerRole] || '评审'} · ${REVIEW_DECISION_LABELS[review.decision] || '待核对'})`
)).join('、')
}
function FirstPassStatusBadge({ status }: { status: FirstPassReviewProjectListItem['firstVersionStatus'] }) {
if (status === 'approved') {
return (
<span className="inline-flex min-h-7 items-center gap-1.5 rounded-lg bg-emerald-50 px-2.5 text-xs font-bold text-emerald-800">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />首轮通过
</span>
)
}
if (status === 'revision_required') {
return (
<span className="inline-flex min-h-7 items-center gap-1.5 rounded-lg bg-amber-50 px-2.5 text-xs font-bold text-amber-800">
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />首轮未通过
</span>
)
}
if (status === 'pending_review') {
return (
<span className="inline-flex min-h-7 items-center gap-1.5 rounded-lg bg-blue-50 px-2.5 text-xs font-bold text-blue-700">
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />首轮评审中
</span>
)
}
return (
<span className="inline-flex min-h-7 items-center rounded-lg bg-slate-100 px-2.5 text-xs font-bold text-slate-600">
状态待核对
</span>
)
}
function FirstPassProjectList({ metric, query, firstPassPage }: Required<Pick<CoreMetricDetailProps, 'metric' | 'firstPassPage'>> & Pick<CoreMetricDetailProps, 'query'>) {
const { items, pagination, summary } = firstPassPage
return (
<section aria-labelledby="first-pass-list-title" className="mt-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 id="first-pass-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">所有作品明细</h2>
<p className="mt-1 text-sm leading-6 text-slate-600">
展示作品过稿后台全部已上传且包含 V1 的作品;每个作品只取 V1 审核结果,评审中、首轮未通过和待核对状态均会列出。
</p>
</div>
<a
href="https://sc.jiansun.vip/work-review"
target="_blank"
rel="noreferrer"
className="inline-flex min-h-10 items-center gap-2 self-start rounded-xl bg-white px-3 text-sm font-bold text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 lg:self-auto"
>
打开作品过稿数据源<ExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
</div>
<div className="mt-5 rounded-lg border border-[#e2e8f0] bg-white px-5 py-4">
<p className="text-xs font-bold text-slate-500">统计公式</p>
<p className="mt-1 text-sm font-bold text-slate-900">
首轮通过率 = V1 审核通过作品数 ÷ 所有已通过作品数 × 100%
</p>
<p className="mt-2 text-xs tabular-nums text-slate-500">
当前口径:{summary.firstPassApproved} ÷ {summary.totalApproved} = {formatRate(summary.rate)};共读取 {summary.totalUploads} 个作品
</p>
</div>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl flex-1">
<form className="flex gap-2" action={`/workspaces/dashboard/core-metrics/${metric.key}`} method="get" role="search">
<label htmlFor="first-pass-query" className="sr-only">搜索作品、提交人或负责人</label>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" aria-hidden="true" />
<input
id="first-pass-query"
name="q"
type="search"
defaultValue={query}
aria-describedby="first-pass-search-note"
placeholder="搜索作品、提交人或负责人"
className="min-h-11 w-full rounded-xl border border-slate-300 bg-white py-2 pl-9 pr-3 text-sm text-slate-950 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-500 focus:ring-2 focus:ring-slate-300"
/>
</div>
<button type="submit" className="min-h-11 shrink-0 rounded-xl bg-[#176fc1] px-4 text-sm font-bold text-white transition-colors hover:bg-[#125b9d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2">
查询
</button>
</form>
<p id="first-pass-search-note" className="mt-2 text-xs text-slate-500">搜索只筛选下方明细,不改变上方完整统计口径。</p>
</div>
<p className="text-sm font-semibold tabular-nums text-slate-600">共 {pagination.total} 个作品</p>
</div>
{items.length === 0 ? (
<div role="status" className="mt-6 rounded-lg border border-[#e2e8f0] bg-white px-5 py-14 text-center">
<Database className="mx-auto h-8 w-8 text-slate-400" aria-hidden="true" />
<h3 className="mt-4 text-base font-bold text-slate-900">
{query ? '没有匹配的作品' : '当前没有可统计的 V1 作品'}
</h3>
<p className="mx-auto mt-2 max-w-xl text-sm leading-6 text-slate-600">
{query ? '请调整关键词后重新查询。' : '作品上传至作品过稿后,本页会读取 V1 审核状态并自动纳入统计。'}
</p>
</div>
) : (
<>
<div className="mt-6 hidden overflow-x-auto rounded-lg border border-[#e2e8f0] bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2 md:block" tabIndex={0} aria-label="所有作品明细横向滚动表格">
<table className="min-w-[1400px] w-full table-fixed text-left text-sm">
<caption className="sr-only">所有作品的 V1 首轮审核数据表</caption>
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
<tr>
<th scope="col" className="w-[260px] px-5 py-3.5">作品</th>
<th scope="col" className="w-[170px] px-4 py-3.5">首轮结果</th>
<th scope="col" className="w-[120px] px-4 py-3.5">当前状态</th>
<th scope="col" className="w-[100px] px-4 py-3.5">提交人</th>
<th scope="col" className="w-[110px] px-4 py-3.5">主管</th>
<th scope="col" className="w-[110px] px-4 py-3.5">终审</th>
<th scope="col" className="w-[230px] px-4 py-3.5">V1 评审</th>
<th scope="col" className="w-[150px] px-4 py-3.5">V1 上传</th>
<th scope="col" className="w-[150px] px-4 py-3.5">V1 完成</th>
<th scope="col" className="w-[130px] px-5 py-3.5">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{items.map((project) => (
<tr key={project.id} className="align-top transition-colors hover:bg-slate-50">
<td className="px-5 py-4">
<p className="truncate font-bold text-slate-950">{project.title}</p>
{project.description && <p className="mt-1 truncate text-xs text-slate-600">{project.description}</p>}
<p className="mt-1 text-xs text-slate-500">作品日期 {formatDate(project.workDate)} · 当前第 {project.latestVersion} 版</p>
</td>
<td className="px-4 py-4"><FirstPassStatusBadge status={project.firstVersionStatus} /></td>
<td className="px-4 py-4 font-medium text-slate-700">{CURRENT_REVIEW_STATUS_LABELS[project.currentStatus] || '当前状态待核对'}</td>
<td className="truncate px-4 py-4 font-medium text-slate-700">{project.submitterName}</td>
<td className="truncate px-4 py-4 font-medium text-slate-700">{project.managerName}</td>
<td className="truncate px-4 py-4 font-medium text-slate-700">{project.finalReviewerName}</td>
<td className="px-4 py-4 text-xs leading-5 text-slate-600">{getFirstVersionReviewerSummary(project)}</td>
<td className="whitespace-nowrap px-4 py-4 tabular-nums text-slate-600">{formatDate(project.firstVersionSubmittedAt, true)}</td>
<td className="whitespace-nowrap px-4 py-4 tabular-nums text-slate-600">{formatDate(project.firstVersionCompletedAt, true)}</td>
<td className="px-5 py-4">
<Link href={`/work-review?project=${encodeURIComponent(project.id)}`} aria-label={`查看${project.title}过稿记录`} className="inline-flex min-h-9 items-center gap-1 rounded-lg px-2 font-bold text-[#b4231c] hover:bg-[#fff0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d92d20]">
查看过稿记录<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="mt-6 divide-y divide-slate-200 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white md:hidden" aria-label="所有作品的 V1 首轮审核列表">
{items.map((project) => (
<li key={project.id} className="p-5">
<h3 className="break-words font-bold text-slate-950">{project.title}</h3>
{project.description && <p className="mt-1 text-sm leading-6 text-slate-600">{project.description}</p>}
<div className="mt-3"><FirstPassStatusBadge status={project.firstVersionStatus} /></div>
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div><dt className="text-xs text-slate-500">当前状态</dt><dd className="mt-1 font-semibold text-slate-800">{CURRENT_REVIEW_STATUS_LABELS[project.currentStatus] || '待核对'}</dd></div>
<div><dt className="text-xs text-slate-500">当前版本</dt><dd className="mt-1 font-semibold tabular-nums text-slate-800">V{project.latestVersion}</dd></div>
<div><dt className="text-xs text-slate-500">提交人</dt><dd className="mt-1 font-semibold text-slate-800">{project.submitterName}</dd></div>
<div><dt className="text-xs text-slate-500">作品日期</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(project.workDate)}</dd></div>
<div><dt className="text-xs text-slate-500">主管</dt><dd className="mt-1 font-semibold text-slate-800">{project.managerName}</dd></div>
<div><dt className="text-xs text-slate-500">终审</dt><dd className="mt-1 font-semibold text-slate-800">{project.finalReviewerName}</dd></div>
<div><dt className="text-xs text-slate-500">V1 上传</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(project.firstVersionSubmittedAt, true)}</dd></div>
<div><dt className="text-xs text-slate-500">V1 完成</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(project.firstVersionCompletedAt, true)}</dd></div>
</dl>
<p className="mt-4 text-xs leading-5 text-slate-600">V1 评审:{getFirstVersionReviewerSummary(project)}</p>
<Link href={`/work-review?project=${encodeURIComponent(project.id)}`} aria-label={`查看${project.title}过稿记录`} className="mt-4 inline-flex min-h-10 items-center gap-1 rounded-xl bg-slate-100 px-3 text-sm font-bold text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400">
查看过稿记录<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</li>
))}
</ul>
</>
)}
{pagination.totalPages > 1 && (
<nav aria-label="所有作品的 V1 首轮审核分页" className="mt-5 flex items-center justify-between gap-4 text-sm">
<p className="text-slate-500">第 {pagination.page} / {pagination.totalPages} 页</p>
<div className="flex gap-2">
{pagination.page > 1 && <Link className="inline-flex min-h-10 items-center rounded-xl bg-white px-3 font-bold text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400" href={buildPageHref(metric.key, pagination.page - 1, query)}>上一页</Link>}
{pagination.page < pagination.totalPages && <Link className="inline-flex min-h-10 items-center rounded-xl bg-white px-3 font-bold text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400" href={buildPageHref(metric.key, pagination.page + 1, query)}>下一页</Link>}
</div>
</nav>
)}
</section>
)
}
function PendingDataSource({ metric }: { metric: CoreMetric }) {
return (
<section aria-labelledby="detail-fields-title" className="mt-10">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 id="detail-fields-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">明细框架</h2>
<p className="mt-1 text-sm leading-6 text-slate-600">底层业务表与公式尚未接入,当前不展示虚构记录。</p>
</div>
<span className="rounded-lg bg-amber-50 px-3 py-2 text-xs font-bold text-amber-800">数据源待接入</span>
</div>
<div role="status" className="mt-6 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white">
<div className="border-b border-slate-200 px-5 py-4">
<h3 className="font-bold text-slate-950">计划展示字段</h3>
</div>
<div className="flex flex-wrap gap-2 p-5">
{metric.plannedFields.map((field) => <span key={field} className="rounded-lg bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-700">{field}</span>)}
</div>
<p className="border-t border-slate-200 px-5 py-8 text-center text-sm leading-6 text-slate-500">
数据源、字段映射和统计公式确认后,本页将自动列出对应业务明细。
</p>
</div>
</section>
)
}
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>
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
<header className="border-b border-[#dfe4ea] pb-6">
<h1 className="text-balance text-3xl font-bold tracking-[-0.03em] sm:text-4xl">{metric.label}</h1>
<p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b] sm:text-lg">{metric.detailDescription}</p>
</header>
<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 ? '本月按时交付率' : 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 ? '—' : 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}` : hasTeamWorkloadSource ? `${teamWorkload.summary.busyPersonnel} 人有进行中流程 / 共 ${teamWorkload.summary.personnelCount} 人` : '等待业务数据源'}</p>
</div>
<div className="p-5">
<p className="text-xs font-bold text-slate-500">同步状态</p>
<p className="mt-2 inline-flex items-center gap-2 text-base font-bold">
<CalendarDays className="h-4 w-4 text-slate-500" aria-hidden="true" />
{hasRealProjectSource
? 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' ? '首轮同步失败' : '等待首轮同步'
: hasOnTimeDeliverySource
? onTimeDelivery.summary.lastSyncedAt
? `每小时增量同步 · ${formatDate(onTimeDelivery.summary.lastSyncedAt, true)}`
: onTimeDelivery.summary.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步'
: hasFirstPassSource
? 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>
</section>
{hasRealProjectSource
? <OngoingProjectsInsights insights={projectInsights} />
: hasMonthlyLaunchSource
? <MonthlyLaunchesInsights insights={monthlyLaunches} />
: hasOnTimeDeliverySource
? <OnTimeDeliveryInsightsView insights={onTimeDelivery} />
: hasFirstPassSource
? <FirstPassProjectList metric={metric} query={query} firstPassPage={firstPassPage} />
: hasTeamWorkloadSource
? <TeamWorkloadInsightsView insights={teamWorkload} />
: <PendingDataSource metric={metric} />}
</main>
</DepartmentShell>
)
}