三面拆分⑩:工作组管理落员工区 —— ADR-011 欠账清账

建组/改组:逐岗位选人,留空即回落四策略。绑定先于策略,起单时快照进工作流;
实测改绑定后新起单的 work_group 快照即含新成员表,在途不受影响。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 21:28:30 +08:00
co-authored by Claude Fable 5
parent a93f03f464
commit 6f36f8340a
8 changed files with 350 additions and 12 deletions
+39
View File
@@ -1493,6 +1493,44 @@ export declare const upsertTriggerRoute: {
}, z.core.$strip>; }, z.core.$strip>;
}, 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<z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
members: z.ZodRecord<z.ZodString, z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
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<z.ZodString, "StaffId", "out">;
name: z.ZodString;
members: z.ZodRecord<z.ZodString, z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
group: z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
members: z.ZodRecord<z.ZodString, z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
}, z.core.$strip>;
};
export declare const listActivitiesRoute: { export declare const listActivitiesRoute: {
readonly method: "GET"; readonly method: "GET";
readonly path: "/api/gov/activities"; readonly path: "/api/gov/activities";
@@ -1905,6 +1943,7 @@ export declare const govMetricsRoute: {
}, z.core.$strip>; }, z.core.$strip>;
}; };
export type GovTasksOutput = z.infer<typeof listGovTasksRoute.output>; export type GovTasksOutput = z.infer<typeof listGovTasksRoute.output>;
export type GovWorkGroupsOutput = z.infer<typeof listWorkGroupsRoute.output>;
export type GovApprovalsOutput = z.infer<typeof listApprovalsRoute.output>; export type GovApprovalsOutput = z.infer<typeof listApprovalsRoute.output>;
export type GovStaffOutput = z.infer<typeof listStaffRoute.output>; export type GovStaffOutput = z.infer<typeof listStaffRoute.output>;
export type GovGrantsOutput = z.infer<typeof listGrantsRoute.output>; export type GovGrantsOutput = z.infer<typeof listGrantsRoute.output>;
+44
View File
@@ -11,8 +11,10 @@ import {
listGrantsRoute, listGrantsRoute,
listLlmInstancesRoute, listLlmInstancesRoute,
listStaffRoute, listStaffRoute,
listWorkGroupsRoute,
positionIdSchema, positionIdSchema,
probeLlmInstanceRoute, probeLlmInstanceRoute,
saveWorkGroupRoute,
setGrantsRoute, setGrantsRoute,
staffIdSchema, staffIdSchema,
upsertLlmInstanceRoute, upsertLlmInstanceRoute,
@@ -97,6 +99,48 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
return c.json({ grants: await container.org.setGrants(positionId, parsed.data.grants) }); 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 实例 ──────────────────────────────────────────── */ /* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
app.get(listLlmInstancesRoute.path, async (c) => { app.get(listLlmInstancesRoute.path, async (c) => {
+4
View File
@@ -45,3 +45,7 @@
影响半径: contract 增 `login` / `logout` / `resolveSession` / `setPassword` 与 `IssuedSession`; 影响半径: contract 增 `login` / `logout` / `resolveSession` / `setPassword` 与 `IssuedSession`;
port 增 `SessionRepository` / `PasswordHasher`,`StaffRepository` 增两个口令读写方法; port 增 `SessionRepository` / `PasswordHasher`,`StaffRepository` 增两个口令读写方法;
下游只有 app(装配 + 鉴权中间件),depcruise 已反查确认 下游只有 app(装配 + 鉴权中间件),depcruise 已反查确认
近变(2026-07-27):模块面**未变**。app 层新增工作组读写路由
(`GET|PUT /api/gov/work-groups`),组装既有的 `workGroupOf` / `saveWorkGroup`
—— ADR-011 落地时欠的那个治理界面就此清账,一人一组的口径由 app 层按 ownerId 保证。
@@ -5,21 +5,37 @@
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 * 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 { import {
GOVERNANCE_FUNCTION_LABEL, Acts,
GRANT_ACTION_LABEL, Card,
} from '@contracts'; Chip,
import { Card, Chip, Clear, Entry, NList, NRow, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx'; 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 type { Nav } from '@web-shared/gov/gov-route.ts';
import { import { useGovGrants, useGovStaff, useGovTasks, useGovWorkGroups } from '@web-shared/gov/gov-api.ts';
useGovGrants,
useGovStaff,
useGovTasks,
} from '@web-shared/gov/gov-api.ts';
export type HomeProps = { tab: string; nav: Nav }; export type HomeProps = { tab: string; nav: Nav };
export function PeopleHome({ tab, nav }: HomeProps) { 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 q = useGovStaff();
const tasks = useGovTasks(); const tasks = useGovTasks();
const staff = q.data?.staff ?? []; 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<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: save.isPending || draftName.trim() === '',
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>
);
}
+7
View File
@@ -21,6 +21,7 @@ import type {
GovTasksOutput, GovTasksOutput,
GovToolsOutput, GovToolsOutput,
GovTriggersOutput, GovTriggersOutput,
GovWorkGroupsOutput,
GovWorkflowOutput, GovWorkflowOutput,
ProcessPackage, ProcessPackage,
} from '@contracts'; } from '@contracts';
@@ -41,6 +42,7 @@ import {
listStaffRoute, listStaffRoute,
listToolsRoute, listToolsRoute,
listTriggersRoute, listTriggersRoute,
listWorkGroupsRoute,
validateProcessRoute, validateProcessRoute,
} from '@contracts'; } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts'; import { call } from '@web-shared/typed-fetch.ts';
@@ -105,6 +107,11 @@ export const useGovPkgReports = (
}, },
}); });
export type GovWorkGroups = GovWorkGroupsOutput;
export const useGovWorkGroups = (): UseQueryResult<GovWorkGroups> =>
useQuery({ queryKey: ['gov', 'work-groups'], queryFn: () => call<GovWorkGroups>(listWorkGroupsRoute) });
export const useGovTriggers = (): UseQueryResult<GovTriggersOutput> => export const useGovTriggers = (): UseQueryResult<GovTriggersOutput> =>
useQuery({ queryKey: ['gov', 'triggers'], queryFn: () => call<GovTriggersOutput>(listTriggersRoute) }); useQuery({ queryKey: ['gov', 'triggers'], queryFn: () => call<GovTriggersOutput>(listTriggersRoute) });
+1
View File
@@ -156,6 +156,7 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
people: [ people: [
{ key: 'emp', label: '员工', ico: 'users' }, { key: 'emp', label: '员工', ico: 'users' },
{ key: 'roles', label: '岗位授权', ico: 'key' }, { key: 'roles', label: '岗位授权', ico: 'key' },
{ key: 'groups', label: '工作组', ico: 'users' },
], ],
ledgers: [], ledgers: [],
knowledge: [{ key: 'layers', label: '活动记忆', ico: 'layers' }], knowledge: [{ key: 'layers', label: '活动记忆', ico: 'layers' }],
+2 -2
View File
@@ -27,8 +27,8 @@
## 工作组(ADR-011 落地后的已知欠账) ## 工作组(ADR-011 落地后的已知欠账)
- **没有治理台管理界面**:建组 / 改组 / 指派默认组只能经播种或直接调用例。 - ~~**没有治理台管理界面**~~ —— 2026-07-27 清账:运营中心 · 员工 · 工作组页签可建组 / 改组
`saveWorkGroup` 已在 org 出口就位,缺的是「员工」面下的一个子页。 (`GET|PUT /api/gov/work-groups`);实测改绑定后新起工作流的 `work_group` 快照即含新成员表。
- **一人一组**:`workGroupOf` 取该人名下稳定的第一条;起单时选哪个组这一步没做。 - **一人一组**:`workGroupOf` 取该人名下稳定的第一条;起单时选哪个组这一步没做。
- **组成员离岗无提醒**:绑定的人离岗时静默退回四策略,不告诉任何人。 - **组成员离岗无提醒**:绑定的人离岗时静默退回四策略,不告诉任何人。
+30 -1
View File
@@ -4,7 +4,7 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import type { RouteContract } from './http.ts'; import type { RouteContract } from './http.ts';
import { disableCheckSchema, grantSchema, positionSchema, staffSchema } from '../org.ts'; import { disableCheckSchema, grantSchema, positionSchema, staffSchema, workGroupSchema } from '../org.ts';
import { positionIdSchema, staffIdSchema, taskIdSchema } from '../id.ts'; import { positionIdSchema, staffIdSchema, taskIdSchema } from '../id.ts';
import { closureReportSchema, processPackageSchema, triggerConfigSchema } from '../process-package.ts'; import { closureReportSchema, processPackageSchema, triggerConfigSchema } from '../process-package.ts';
import { controlSemanticSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts'; import { controlSemanticSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts';
@@ -250,6 +250,34 @@ export const upsertTriggerRoute = {
output: z.object({ trigger: triggerConfigSchema }), output: z.object({ trigger: triggerConfigSchema }),
} as const satisfies RouteContract; } as const satisfies RouteContract;
/* ── 员工管理 · 工作组(ADR-011) ───────────────────────────────────── */
/**
* 工作组 = 一组「岗位 → 员工」的绑定,**起单时装载、随工作流走**。
*
* 它是**绑定先于策略**,不是第五种负责人策略:起单人有组,整条流的岗位就按组指人;
* 没有组才回落四策略。装进工作流的是**快照** —— 改组不改已起的单。
*/
export const listWorkGroupsRoute = {
method: 'GET',
path: '/api/gov/work-groups',
/** `ownerName` 是组长姓名的呈现补充,读表时一并带出,免得前端再对一次 id */
output: z.object({ groups: z.array(workGroupSchema.extend({ ownerName: z.string() })) }),
} as const satisfies RouteContract;
/** 一人一组:按 `ownerId` 落,已有即更新、没有即新建 */
export const saveWorkGroupRoute = {
method: 'PUT',
path: '/api/gov/work-groups',
body: z.object({
ownerId: staffIdSchema,
name: z.string().min(1),
/** 岗位 code → 员工 id;留空的岗位不绑定,那一位仍走四策略 */
members: z.record(z.string().min(1), staffIdSchema),
}),
output: z.object({ group: workGroupSchema }),
} as const satisfies RouteContract;
/* ── 活动库管理:两维分类 × 包版本 × 资产版本 ─────────────────────────── */ /* ── 活动库管理:两维分类 × 包版本 × 资产版本 ─────────────────────────── */
export const listActivitiesRoute = { export const listActivitiesRoute = {
@@ -427,6 +455,7 @@ export const govMetricsRoute = {
/* ── 出参类型:契约自己命名,消费方不再 `z.infer` ──────────────────────── */ /* ── 出参类型:契约自己命名,消费方不再 `z.infer` ──────────────────────── */
export type GovTasksOutput = z.infer<typeof listGovTasksRoute.output>; export type GovTasksOutput = z.infer<typeof listGovTasksRoute.output>;
export type GovWorkGroupsOutput = z.infer<typeof listWorkGroupsRoute.output>;
export type GovApprovalsOutput = z.infer<typeof listApprovalsRoute.output>; export type GovApprovalsOutput = z.infer<typeof listApprovalsRoute.output>;
export type GovStaffOutput = z.infer<typeof listStaffRoute.output>; export type GovStaffOutput = z.infer<typeof listStaffRoute.output>;
export type GovGrantsOutput = z.infer<typeof listGrantsRoute.output>; export type GovGrantsOutput = z.infer<typeof listGrantsRoute.output>;