From 6f36f8340ae8e564aefa0e002c6611ee58b9e752 Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Mon, 27 Jul 2026 21:28:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E9=9D=A2=E6=8B=86=E5=88=86=E2=91=A9:?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E7=BB=84=E7=AE=A1=E7=90=86=E8=90=BD=E5=91=98?= =?UTF-8?q?=E5=B7=A5=E5=8C=BA=20=E2=80=94=E2=80=94=20ADR-011=20=E6=AC=A0?= =?UTF-8?q?=E8=B4=A6=E6=B8=85=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建组/改组:逐岗位选人,留空即回落四策略。绑定先于策略,起单时快照进工作流; 实测改绑定后新起单的 work_group 快照即含新成员表,在途不受影响。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../contracts/src/api/governance.d.ts | 39 +++ apps/api/src/app/routes/governance.ts | 44 ++++ apps/api/src/modules/org/CONTRACT.md | 4 + .../src/features/gov-staff/internal/panel.tsx | 232 +++++++++++++++++- apps/web/src/shared/gov/gov-api.ts | 7 + apps/web/src/shared/gov/gov-route.ts | 1 + docs/debt.md | 4 +- packages/contracts/src/api/governance.ts | 31 ++- 8 files changed, 350 insertions(+), 12 deletions(-) diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index eae1450..7c9cff9 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -1493,6 +1493,44 @@ export declare const upsertTriggerRoute: { }, z.core.$strip>; }, z.core.$strip>; }; +/** + * 工作组 = 一组「岗位 → 员工」的绑定,**起单时装载、随工作流走**。 + * + * 它是**绑定先于策略**,不是第五种负责人策略:起单人有组,整条流的岗位就按组指人; + * 没有组才回落四策略。装进工作流的是**快照** —— 改组不改已起的单。 + */ +export declare const listWorkGroupsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/work-groups"; + /** `ownerName` 是组长姓名的呈现补充,读表时一并带出,免得前端再对一次 id */ + readonly output: z.ZodObject<{ + groups: z.ZodArray; + members: z.ZodRecord>; + ownerName: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 一人一组:按 `ownerId` 落,已有即更新、没有即新建 */ +export declare const saveWorkGroupRoute: { + readonly method: "PUT"; + readonly path: "/api/gov/work-groups"; + readonly body: z.ZodObject<{ + ownerId: z.core.$ZodBranded; + name: z.ZodString; + members: z.ZodRecord>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + group: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + ownerId: z.core.$ZodBranded; + members: z.ZodRecord>; + }, z.core.$strip>; + }, z.core.$strip>; +}; export declare const listActivitiesRoute: { readonly method: "GET"; readonly path: "/api/gov/activities"; @@ -1905,6 +1943,7 @@ export declare const govMetricsRoute: { }, z.core.$strip>; }; export type GovTasksOutput = z.infer; +export type GovWorkGroupsOutput = z.infer; export type GovApprovalsOutput = z.infer; export type GovStaffOutput = z.infer; export type GovGrantsOutput = z.infer; diff --git a/apps/api/src/app/routes/governance.ts b/apps/api/src/app/routes/governance.ts index 3566678..633191b 100644 --- a/apps/api/src/app/routes/governance.ts +++ b/apps/api/src/app/routes/governance.ts @@ -11,8 +11,10 @@ import { listGrantsRoute, listLlmInstancesRoute, listStaffRoute, + listWorkGroupsRoute, positionIdSchema, probeLlmInstanceRoute, + saveWorkGroupRoute, setGrantsRoute, staffIdSchema, upsertLlmInstanceRoute, @@ -97,6 +99,48 @@ export function registerGovernanceRoutes(app: Hono, container: Container return c.json({ grants: await container.org.setGrants(positionId, parsed.data.grants) }); }); + /* ── 员工管理 · 工作组(ADR-011) ──────────────────────────────────── */ + + /** + * 全部工作组 —— 一人至多一组,按员工遍历取。 + * `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。 + */ + app.get(listWorkGroupsRoute.path, async (c) => { + const denied = await guardStaff(c.get('account')); + if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); + const { staff } = await container.org.listStaff(); + const found = await Promise.all( + staff.map(async (s) => { + const group = await container.org.workGroupOf(s.id); + return group === undefined ? undefined : { ...group, ownerName: s.name }; + }), + ); + return c.json({ groups: found.filter((g) => g !== undefined) }); + }); + + /** + * 建组 / 改组 —— 按 `ownerId` 落:已有即更新(复用 id),没有即新建。 + * 与 `workGroupOf` 的取组口径一致(一人一组),不给同一个人建出两个组来。 + * 改组**不影响在途**:装进工作流的是起单时的快照。 + */ + app.put(saveWorkGroupRoute.path, async (c) => { + const denied = await guardStaff(c.get('account')); + if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); + const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues }; + return c.json(body, 400); + } + const existing = await container.org.workGroupOf(parsed.data.ownerId); + const group = await container.org.saveWorkGroup({ + ...(existing === undefined ? {} : { id: existing.id }), + name: parsed.data.name, + ownerId: parsed.data.ownerId, + members: parsed.data.members, + }); + return c.json({ group }); + }); + /* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */ app.get(listLlmInstancesRoute.path, async (c) => { diff --git a/apps/api/src/modules/org/CONTRACT.md b/apps/api/src/modules/org/CONTRACT.md index 65f4b96..05e7412 100644 --- a/apps/api/src/modules/org/CONTRACT.md +++ b/apps/api/src/modules/org/CONTRACT.md @@ -45,3 +45,7 @@ 影响半径: contract 增 `login` / `logout` / `resolveSession` / `setPassword` 与 `IssuedSession`; port 增 `SessionRepository` / `PasswordHasher`,`StaffRepository` 增两个口令读写方法; 下游只有 app(装配 + 鉴权中间件),depcruise 已反查确认 + +近变(2026-07-27):模块面**未变**。app 层新增工作组读写路由 + (`GET|PUT /api/gov/work-groups`),组装既有的 `workGroupOf` / `saveWorkGroup` + —— ADR-011 落地时欠的那个治理界面就此清账,一人一组的口径由 app 层按 ownerId 保证。 diff --git a/apps/web/src/features/gov-staff/internal/panel.tsx b/apps/web/src/features/gov-staff/internal/panel.tsx index 7110e6c..c815799 100644 --- a/apps/web/src/features/gov-staff/internal/panel.tsx +++ b/apps/web/src/features/gov-staff/internal/panel.tsx @@ -5,21 +5,37 @@ * 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 { - GOVERNANCE_FUNCTION_LABEL, - GRANT_ACTION_LABEL, -} from '@contracts'; -import { Card, Chip, Clear, Entry, NList, NRow, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx'; + 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, -} from '@web-shared/gov/gov-api.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 ; + return ; +} + +function StaffHome({ tab, nav }: HomeProps) { const q = useGovStaff(); const tasks = useGovTasks(); const staff = q.data?.staff ?? []; @@ -153,3 +169,201 @@ export function RolePage({ id }: { id: string }) { /* ---------------- 流程包 ---------------- */ + +/* ---------------- 工作组(ADR-011) ---------------- */ + +/** + * 工作组 = 一组「岗位 → 员工」的绑定,**起单时装载、随工作流走**。 + * + * 它是**绑定先于策略**,不是第五种负责人策略:起单人有组,整条流的岗位就按组指人; + * 没有组才回落四策略(独任 / 轮询 / 抢单 / 指派)。 + * 装进工作流的是**快照** —— 改组不改已起的单,留痕不被改写。 + */ +function WorkGroupsTab() { + const groups = useGovWorkGroups(); + const staffQ = useGovStaff(); + const [editing, setEditing] = useState(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 ( + + <> + + 工作组把岗位绑到人:谁起的单,整条流的岗位就按谁的组指人 —— 绑定先于策略, + 没绑定的岗位仍走四策略。绑定在起单那一刻快照进工作流,此后改组不影响已起的单。 + + {rows.length === 0 ? ( + 还没有工作组 —— 给谁建一个,他起的单整条流就能一次指到人。 + ) : ( + rows.map((g) => + editing === g.ownerId ? ( + setEditing(undefined)} + onCancel={() => setEditing(undefined)} + /> + ) : ( + setEditing(g.ownerId)}> + 改绑定 + + } + > + ({ + k: `${p.name} · ${p.code}`, + v: g.members[p.code] === undefined ? '未绑定 · 走四策略' : nameOf(g.members[p.code]!), + })), + ]} + /> + + ), + ) + )} + + + {staff + .filter((s) => !rows.some((g) => g.ownerId === s.id)) + .map((s) => + editing === s.id ? null : ( + 建组 →} + onClick={() => setEditing(s.id)} + /> + ), + )} + {staff.every((s) => rows.some((g) => g.ownerId === s.id)) && 每个人都有组了。} + + + {staff + .filter((s) => !rows.some((g) => g.ownerId === s.id) && editing === s.id) + .map((s) => ( + setEditing(undefined)} + onCancel={() => setEditing(undefined)} + /> + ))} + + + ); +} + +/** 建组 / 改组表单:逐岗位选人,留空即该岗位不绑定 */ +function WorkGroupForm({ + ownerId, + ownerName, + name, + members, + positions, + staff, + onDone, + onCancel, +}: { + ownerId: string; + ownerName: string; + name: string; + members: Readonly>; + 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>({ ...members }); + + const save = useMutation({ + mutationFn: (input: { name: string; members: Record }) => + 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 ( + + + setDraftName(e.target.value)} /> + + {positions.map((p) => ( + + + + ))} + 绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。, + impact: { + who: '仅此后新起的工作流;在途不受影响 —— 绑定是起单时的快照', + undo: '可随时改;解绑某岗位即回落四策略', + when: '确认后即时生效', + }, + okLabel: '确认保存', + onOk: () => { + const picked: Record = {}; + for (const [code, id] of Object.entries(draft)) { + if (id !== '') picked[code] = staffIdSchema.parse(id); + } + save.mutate({ name: draftName, members: picked }); + }, + }, + }, + ]} + /> + {save.isError && 保存失败,请重试。} + + ); +} diff --git a/apps/web/src/shared/gov/gov-api.ts b/apps/web/src/shared/gov/gov-api.ts index f50c5e8..0fb928a 100644 --- a/apps/web/src/shared/gov/gov-api.ts +++ b/apps/web/src/shared/gov/gov-api.ts @@ -21,6 +21,7 @@ import type { GovTasksOutput, GovToolsOutput, GovTriggersOutput, + GovWorkGroupsOutput, GovWorkflowOutput, ProcessPackage, } from '@contracts'; @@ -41,6 +42,7 @@ import { listStaffRoute, listToolsRoute, listTriggersRoute, + listWorkGroupsRoute, validateProcessRoute, } from '@contracts'; import { call } from '@web-shared/typed-fetch.ts'; @@ -105,6 +107,11 @@ export const useGovPkgReports = ( }, }); +export type GovWorkGroups = GovWorkGroupsOutput; + +export const useGovWorkGroups = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'work-groups'], queryFn: () => call(listWorkGroupsRoute) }); + export const useGovTriggers = (): UseQueryResult => useQuery({ queryKey: ['gov', 'triggers'], queryFn: () => call(listTriggersRoute) }); diff --git a/apps/web/src/shared/gov/gov-route.ts b/apps/web/src/shared/gov/gov-route.ts index c7f4e20..3c1898b 100644 --- a/apps/web/src/shared/gov/gov-route.ts +++ b/apps/web/src/shared/gov/gov-route.ts @@ -156,6 +156,7 @@ export const TABS: Readonly; +export type GovWorkGroupsOutput = z.infer; export type GovApprovalsOutput = z.infer; export type GovStaffOutput = z.infer; export type GovGrantsOutput = z.infer;