Archived
三面拆分⑩:工作组管理落员工区 —— 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:
@@ -11,8 +11,10 @@ import {
|
||||
listGrantsRoute,
|
||||
listLlmInstancesRoute,
|
||||
listStaffRoute,
|
||||
listWorkGroupsRoute,
|
||||
positionIdSchema,
|
||||
probeLlmInstanceRoute,
|
||||
saveWorkGroupRoute,
|
||||
setGrantsRoute,
|
||||
staffIdSchema,
|
||||
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) });
|
||||
});
|
||||
|
||||
/* ── 员工管理 · 工作组(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) => {
|
||||
|
||||
@@ -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 保证。
|
||||
|
||||
@@ -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 <WorkGroupsTab />;
|
||||
return <StaffHome tab={tab} nav={nav} />;
|
||||
}
|
||||
|
||||
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<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<GovWorkGroups> =>
|
||||
useQuery({ queryKey: ['gov', 'work-groups'], queryFn: () => call<GovWorkGroups>(listWorkGroupsRoute) });
|
||||
|
||||
export const useGovTriggers = (): UseQueryResult<GovTriggersOutput> =>
|
||||
useQuery({ queryKey: ['gov', 'triggers'], queryFn: () => call<GovTriggersOutput>(listTriggersRoute) });
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
|
||||
people: [
|
||||
{ key: 'emp', label: '员工', ico: 'users' },
|
||||
{ key: 'roles', label: '岗位授权', ico: 'key' },
|
||||
{ key: 'groups', label: '工作组', ico: 'users' },
|
||||
],
|
||||
ledgers: [],
|
||||
knowledge: [{ key: 'layers', label: '活动记忆', ico: 'layers' }],
|
||||
|
||||
Reference in New Issue
Block a user