Files
JX-BPOS/apps/web/src/features/desk-report/internal/ReportDesk.tsx
T
ChaiHaoandClaude Opus 5 3b9587563a M6(4/n): 工作台五要素齐活 —— 审批 / 消息 / 纪要 / 汇报
四个 desk-* feature 自 workbench 原型 ModuleWorkspace.tsx 平移(jc-* 类名原样),
共用件落 shared/desk/module-kit;mock 数据换成服务端投影:
  · desk-approval:审批点卡到审批人工位;依据快照、驳回走向(声明式)、驳回必附意见
  · desk-message:通知 + 跨任务征答**就地作答**;卡面表单由 card.ts schema 驱动,
    与后端回传校验同 schema;跨人卡不占任务清单,不开放任务对话流
  · desk-minute:纪要维护 + 决议登记起单;正文可续、决议只增
  · desk-report:四周期草案 + 度量八指标 + 确认即提交
左轨角标常驻(待批 / 未读),责任面要一眼看见什么在等我。

修一个真 bug:process.start 从不校验流程包的 callInterface,
先插工作流再由 task.start 撞活动契约 —— 失败就留下一条永远推不动的在途单。
改为**先校验后建单**(CallInterfaceViolationError),一条工作流都不留;
路由回 400 并带上缺哪个字段。已清掉这次触发出的两条孤儿单。

判据 ⑩ 纪要一环跑通:决议 → 登记触发 → 工作流 → A1 落到 BD 冯兵,
登记人即来源已记在单上;同一决议重复登记被 412 挡下。

gates: tsc / eslint / depcruise(316 模块)/ api-report / 380 测试 全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-27 03:53:36 +08:00

182 lines
7.8 KiB
TypeScript

/**
* 汇报要素 —— 自 `~/coding/JCAI_PRD/workbench/_components/ModuleWorkspace.tsx`
* 的 `ReportModule` 平移;mock 数据换成服务端投影。
*
* 「按日 / 周 / 月 / 年**自动**总结此员工的工作——由本人各任务的**任务日志**聚合生成,
* 确认即提交,**零手工汇报**。」结构化部分零 LLM(事件流骨架 + 归侧分栏)。
*/
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ReportPeriodName, ReportView } from '@contracts';
import { REPORT_PERIOD_LABEL, draftReportRoute, submitReportRoute } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
const PERIODS: ReadonlyArray<{ k: ReportPeriodName; ico: string }> = [
{ k: 'day', ico: 'calendar-days' },
{ k: 'week', ico: 'calendar-clock' },
{ k: 'month', ico: 'calendar-days' },
{ k: 'year', ico: 'history' },
];
const pct = (v: number): string => `${Math.round(v * 100)}%`;
export function ReportDesk() {
const queryClient = useQueryClient();
const [period, setPeriod] = useState<ReportPeriodName>('day');
const [note, setNote] = useState('');
const q = useQuery({
queryKey: ['report', period],
queryFn: () => call<ReportView>(draftReportRoute, { params: { period } }),
});
const report = q.data;
const submitted = report?.submittedAt !== undefined;
const submit = useMutation({
mutationFn: () =>
call<ReportView>(submitReportRoute, {
params: { reportId: report!.id },
body: note.trim() === '' ? {} : { note: note.trim() },
}),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['report'] }),
});
return (
<div className="workspace">
<aside className="task-sidebar">
<SidebarHead icon="bar-chart" title="工作汇报" sub="任务日志聚合 · 零手工汇报" />
<div className="jc-flt-wrap" aria-label="汇报周期">
<div className="jc-flt" role="tablist" aria-label="按周期">
{PERIODS.map((p) => (
<button key={p.k} className={period === p.k ? 'active' : ''} onClick={() => setPeriod(p.k)}>
<span>{REPORT_PERIOD_LABEL[p.k]}</span>
</button>
))}
</div>
</div>
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
{report !== undefined && (
<button className="jc-mcard selected">
<span className="jc-mcard-datebox">
<b>{report.window.from.slice(8, 10)}</b>
<i>{report.window.from.slice(5, 7)} 月</i>
</span>
<span className="jc-mcard-body">
<span className="jc-mcard-top">
<em>{report.title}</em>
<Chip tone={submitted ? 'ok' : 'todo'} label={submitted ? '已提交' : '待确认'} pulse={!submitted} />
</span>
<strong className="jc-mcard-title jc-wrap">
经手 {report.counts.touched} 单 · 放行 {report.counts.released} 单
</strong>
<span className="jc-mcard-foot">
<Ico name="git-branch" s={12} />
{report.counts.logLines} 条动作
<i>系统生成</i>
</span>
</span>
</button>
)}
</ScrollArea>
</aside>
{report === undefined ? (
<main className="execution-panel jc-panel-card">
<ListEmpty icon="bar-chart">正在聚合…</ListEmpty>
</main>
) : (
<DocPanel scrollKey={`${period}:${report.id}`}>
<Hero
grad={['#0aa294', '#0a6a64']}
icon="bar-chart"
kicker={`汇报 · ${REPORT_PERIOD_LABEL[period]}`}
title={report.title}
meta={
<>
<Ico name="bot" s={14} />
<b>系统自动生成</b>
<span className="jc-dim">由本人各任务的任务日志聚合</span>
</>
}
chip={<Chip tone={submitted ? 'ok' : 'todo'} label={submitted ? '已提交' : '待确认'} pulse={!submitted} />}
/>
<Section icon="bar-chart" title="度量八指标(零埋点 · 口径挂在归侧标签上)" extra={`共 ${report.counts.logLines} 条日志`}>
<div className="jc-stats">
{[
{ ico: 'bot', label: '自主率', v: pct(report.metrics.autonomy) },
{ ico: 'rotate-ccw', label: '纠正率', v: pct(report.metrics.correction) },
{ ico: 'gauge', label: '有效自主率', v: pct(report.metrics.effectiveAutonomy) },
{ ico: 'hand', label: '打扰率', v: report.metrics.interruption.toFixed(1) },
{ ico: 'git-commit', label: 'DMN 覆盖率', v: pct(report.metrics.dmnCoverage) },
{ ico: 'circle-dot', label: '断点率', v: pct(report.metrics.breakpoint) },
].map((s) => (
<div key={s.label} className="jc-stat">
<span className="jc-stat-ico">
<Ico name={s.ico} s={15} />
</span>
<span className="jc-stat-v">{s.v}</span>
<span className="jc-stat-k">{s.label}</span>
</div>
))}
</div>
<NoteBand>
自主率 = 机器产出 ÷(机器产出 + 人产出),裁决与等待不进分母;它与纠正率
<b>成对读</b>,复合为有效自主率 —— 单看任一都会被优化坏。
单位成本 {Math.round(report.metrics.unitCost.tokens)} token /
{report.metrics.unitCost.minutes.toFixed(1)} 分钟每放行单,分母只认放行通过。
</NoteBand>
</Section>
<Section icon="clock" title="动作明细(结构化日志 · 零 LLM)" extra={`${report.sections.length} 单任务`}>
{report.sections.map((s) => (
<div key={s.taskId} className="jc-axis">
<div className="jc-sec-head">
<Ico name="list-checks" s={13} />
<span>{s.title}</span>
{s.terminalLabel !== undefined && <span className="jc-sec-extra">{s.terminalLabel}</span>}
</div>
{s.lines.map((line, i) => (
<div key={i} className="jc-axis-row">
<span className="jc-axis-node" />
<p>{line}</p>
</div>
))}
</div>
))}
{report.sections.length === 0 && <span className="jc-dim">这个周期内没有动作</span>}
</Section>
<Section icon="pen-line" title="员工补充" extra="可选 · 系统不要求手工写汇报">
<textarea
className="jc-sup"
rows={3}
disabled={submitted}
placeholder="补充主观判断或下阶段计划(可留空)"
value={submitted ? (report.note ?? '') : note}
onChange={(e) => setNote(e.target.value)}
/>
</Section>
<div className="jc-mod-actions">
{submitted ? (
<span className="jc-mod-donehint">
<Ico name="circle-check" s={15} />
已确认提交 · 汇报进入组织视图
</span>
) : (
<button type="button" className="card-btn primary" disabled={submit.isPending} onClick={() => submit.mutate()}>
<Ico name="check" s={13} />
确认提交
</button>
)}
</div>
</DocPanel>
)}
</div>
);
}