This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/web/src/features/gov-staff/internal/panel.tsx
T
xcsandClaude Fable 5 f3397a60ef E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2):
- 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进
  S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视
- 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo,
  计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标)
- 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源,
  正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填
- 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键;
  标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段
- 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数
- 阶段刻度对齐文档 7 步;起单样例预填满

基座侧:
- 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY
  广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻
- 多行 write 整批先验后写(dryRun),被拒 = 一行都没落
- 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors)
- 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行)
- 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端
- 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸
- 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组;
  JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖
- 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01

契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo,
objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。
1700 测 / typecheck / lint / depcruise / api:check 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-25 01:23:30 +08:00

368 lines
13 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.
/**
* feature `gov-staff` —— 员工管理:账号、岗位授权、停用前置检查
*
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
*/
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { GOVERNANCE_FUNCTION_LABEL, GRANT_ACTION_LABEL, saveWorkGroupRoute, staffIdSchema } from '@contracts';
import type { StaffId } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import {
Acts,
Card,
Chip,
Clear,
Entry,
Field,
NList,
NRow,
Note,
PageHead,
QueryGate,
Rows,
St,
} from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovGrants, useGovStaff, useGovTasks, useGovWorkGroups } from '@web-shared/gov/gov-api.ts';
export type HomeProps = { tab: string; nav: Nav };
export function PeopleHome({ tab, nav }: HomeProps) {
if (tab === 'groups') return <WorkGroupsTab />;
return <StaffHome tab={tab} nav={nav} />;
}
function StaffHome({ tab, nav }: HomeProps) {
const q = useGovStaff();
const tasks = useGovTasks();
const staff = q.data?.staff ?? [];
const positions = q.data?.positions ?? [];
if (tab === 'roles') {
return (
<>
<Note>
权限按<b>岗位</b>授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。
人换岗即换权,权限随岗位走。
</Note>
<NList>
{positions.map((p) => (
<NRow
key={p.id}
tone="brand"
ico="key"
title={p.name}
sub={`岗位编码 ${p.code}`}
cols={[{ k: '在岗', v: staff.filter((s) => s.position.id === p.id).length }]}
onClick={() => nav.push({ k: 'role', id: p.id })}
/>
))}
</NList>
</>
);
}
return (
<QueryGate query={q}>
<>
<NList>
{staff.map((s) => {
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined);
return (
<NRow
key={s.id}
tone={s.state === 'active' ? 'brand' : 'mute'}
ico="user-round"
title={s.name}
sub={`${s.account} · ${s.position.name}(${s.position.code})`}
cols={[{ k: '在途任务', v: mine.length }]}
right={
<St tone={s.state === 'active' ? 'ok' : 'mute'} ico={s.state === 'active' ? 'circle-check' : 'power'}>
{s.state === 'active' ? '在职' : '已停用'}
</St>
}
onClick={() => nav.push({ k: 'emp', id: s.id })}
/>
);
})}
</NList>
</>
</QueryGate>
);
}
/* ---------------- ③ 工作流管理 ---------------- */
export function EmpPage({ id, nav }: { id: string; nav: Nav }) {
const q = useGovStaff();
const tasks = useGovTasks();
const s = (q.data?.staff ?? []).find((x) => x.id === id);
if (s === undefined) return <Note>员工不存在。</Note>;
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name);
const live = mine.filter((t) => t.terminal === undefined);
return (
<>
<PageHead
ico="user-round"
kicker={`员工 · ${s.account}`}
title={s.name}
chips={
<>
<Chip ico="key">
{s.position.name} · {s.position.code}
</Chip>
<Chip tone={s.state === 'active' ? 'ok' : 'mute'}>{s.state === 'active' ? '在职' : '已停用'}</Chip>
</>
}
desc="停用前置检查:在途任务与待答卡须先改派完毕——停用不得让在途工作无人认领。"
/>
<Card ico="list-checks" title="在途任务" extra={<span className="g-mono">{live.length}</span>}>
<NList>
{live.map((t) => (
<NRow key={t.id} tone="brand" ico="list-checks" title={t.title} onClick={() => nav.push({ k: 'task', id: t.id })} />
))}
{live.length === 0 && <Clear>无在途任务——可安全停用。</Clear>}
</NList>
</Card>
<Entry ico="key" title="岗位授权" desc={`查看 ${s.position.name} 的授权清单`} onClick={() => nav.push({ k: 'role', id: s.position.id })} />
</>
);
}
export function RolePage({ id }: { id: string }) {
const staff = useGovStaff();
const grants = useGovGrants(id);
const position = (staff.data?.positions ?? []).find((p) => p.id === id);
return (
<>
<PageHead
ico="key"
kicker="岗位授权"
title={position?.name ?? id}
chips={<Chip ico="hash">{position?.code ?? '—'}</Chip>}
desc="权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。人换岗即换权。"
/>
<div className="g-rows">
{(grants.data?.grants ?? []).map((g, i) => (
<div key={i}>
<em>
{g.object.kind === 'data'
? `数据 · ${g.object.domain}.${g.object.object}`
: g.object.kind === 'governance'
? `治理功能 · ${GOVERNANCE_FUNCTION_LABEL[g.object.function]}`
: `任务 · ${g.object.activityId ?? '全部活动'}`}
</em>
<b className="mono">{GRANT_ACTION_LABEL[g.action]}</b>
</div>
))}
{(grants.data?.grants ?? []).length === 0 && <Clear>未授予任何权限。</Clear>}
</div>
</>
);
}
/* ---------------- 流程包 ---------------- */
/* ---------------- 工作组(ADR-011) ---------------- */
/**
* 工作组 = 一组「岗位 → 员工」的绑定,**起单时装载、随工作流走**。
*
* 它是**绑定先于策略**,不是第五种负责人策略:起单人有组,整条流的岗位就按组指人;
* 没有组才回落四策略(独任 / 轮询 / 抢单 / 指派)。
* 装进工作流的是**快照** —— 改组不改已起的单,留痕不被改写。
*/
function WorkGroupsTab() {
const groups = useGovWorkGroups();
const staffQ = useGovStaff();
const [editing, setEditing] = useState<string | undefined>(undefined);
const staff = staffQ.data?.staff ?? [];
const positions = staffQ.data?.positions ?? [];
const nameOf = (id: string) => staff.find((s) => s.id === id)?.name ?? id;
const rows = groups.data?.groups ?? [];
return (
<QueryGate query={groups}>
<>
<Note>
工作组把<b>岗位绑到人</b>:谁起的单,整条流的岗位就按谁的组指人 —— 绑定先于策略,
没绑定的岗位仍走四策略。绑定在起单那一刻<b>快照</b>进工作流,此后改组不影响已起的单。
</Note>
{rows.length === 0 ? (
<Clear>还没有工作组 —— 给谁建一个,他起的单整条流就能一次指到人。</Clear>
) : (
rows.map((g) =>
editing === g.ownerId ? (
<WorkGroupForm
key={g.id}
ownerId={g.ownerId}
ownerName={g.ownerName}
name={g.name}
members={g.members}
positions={positions}
staff={staff}
onDone={() => setEditing(undefined)}
onCancel={() => setEditing(undefined)}
/>
) : (
<Card
key={g.id}
ico="users"
title={g.name}
extra={
<button className="g-btn" onClick={() => setEditing(g.ownerId)}>
改绑定
</button>
}
>
<Rows
items={[
{ k: '组长', v: g.ownerName },
...positions.map((p) => ({
k: `${p.name} · ${p.code}`,
v: g.members[p.code] === undefined ? '未绑定 · 走四策略' : nameOf(g.members[p.code]!),
})),
]}
/>
</Card>
),
)
)}
<Card ico="user-round" title="给还没有组的人建一个">
<NList>
{staff
.filter((s) => !rows.some((g) => g.ownerId === s.id))
.map((s) =>
editing === s.id ? null : (
<NRow
key={s.id}
tone="mute"
ico="user-round"
title={s.name}
sub={`${s.position.name} · ${s.position.code}`}
right={<span className="g-mono">建组 →</span>}
onClick={() => setEditing(s.id)}
/>
),
)}
{staff.every((s) => rows.some((g) => g.ownerId === s.id)) && <Clear>每个人都有组了。</Clear>}
</NList>
</Card>
{staff
.filter((s) => !rows.some((g) => g.ownerId === s.id) && editing === s.id)
.map((s) => (
<WorkGroupForm
key={s.id}
ownerId={s.id}
ownerName={s.name}
name={`${s.name}的工作组`}
members={{}}
positions={positions}
staff={staff}
onDone={() => setEditing(undefined)}
onCancel={() => setEditing(undefined)}
/>
))}
</>
</QueryGate>
);
}
/** 建组 / 改组表单:逐岗位选人,留空即该岗位不绑定 */
function WorkGroupForm({
ownerId,
ownerName,
name,
members,
positions,
staff,
onDone,
onCancel,
}: {
ownerId: string;
ownerName: string;
name: string;
members: Readonly<Record<string, string>>;
positions: ReadonlyArray<{ id: string; code: string; name: string }>;
staff: ReadonlyArray<{ id: string; name: string; position: { code: string; name: string } }>;
onDone: () => void;
onCancel: () => void;
}) {
const queryClient = useQueryClient();
const [draftName, setDraftName] = useState(name);
const [draft, setDraft] = useState<Record<string, string>>({ ...members });
const save = useMutation({
mutationFn: (input: { name: string; members: Record<string, StaffId> }) =>
call(saveWorkGroupRoute, {
body: { ownerId: staffIdSchema.parse(ownerId), name: input.name, members: input.members },
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['gov', 'work-groups'] });
onDone();
},
});
const bound = Object.values(draft).filter((v) => v !== '').length;
return (
<Card ico="users" title={`工作组:${ownerName}`}>
<Field k="组名" hint="人读的一句话,如「柴浩的工作组」">
<input className="g-input" value={draftName} onChange={(e) => setDraftName(e.target.value)} />
</Field>
{positions.map((p) => (
<Field key={p.id} k={`${p.name} · ${p.code}`} hint="留空即不绑定,该岗位仍走四策略">
<select
className="g-input"
value={draft[p.code] ?? ''}
onChange={(e) => setDraft({ ...draft, [p.code]: e.target.value })}
>
<option value="">— 不绑定 —</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>
{s.name}({s.position.code})
</option>
))}
</select>
</Field>
))}
<Acts
align="end"
items={[
{ label: '取消', ico: 'x', onClick: onCancel },
{
label: save.isPending ? '保存中…' : `保存(已绑 ${bound} 个岗位)`,
ico: 'circle-check',
primary: true,
disabled: draftName.trim() === '',
busy: save.isPending,
ask: {
title: `保存工作组:${draftName}`,
note: <Note>绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。</Note>,
impact: {
who: '仅此后新起的工作流;在途不受影响 —— 绑定是起单时的快照',
undo: '可随时改;解绑某岗位即回落四策略',
when: '确认后即时生效',
},
okLabel: '确认保存',
onOk: () => {
const picked: Record<string, StaffId> = {};
for (const [code, id] of Object.entries(draft)) {
if (id !== '') picked[code] = staffIdSchema.parse(id);
}
save.mutate({ name: draftName, members: picked });
},
},
},
]}
/>
{save.isError && <Note tone="warn">保存失败,请重试。</Note>}
</Card>
);
}