合并 feat/org-directory:成员目录(Keycloak 读写面穿透)+ 治理台成员/组织/权限三页 + 广播桥回弹修复 + 稳定版脚本 + W05 目标件
CI / ci (push) Failing after 11s

分支四条(2026-08-26 做的,一直留在工作树没入库,今天补提交):
① feat(org) 成员目录 —— KeycloakDirectory 端口与适配器、org-directory 契约/路由、sync-roster CLI、
   personName 姓在前、Staff.kind 按账号名现判;前端员工屏重做成成员 / 组织 / 权限三页签;
② fix(app) 广播桥「转发进来的不再 NOTIFY 回去」—— 两台后端互桥回弹 19 万条广播那次的修复,代码此前没入库;
③ chore stable.sh + .stable/ 入 ignore + vite preview 宿主白名单;
④ docs(w05) 目标件五份 HTML 与三份说明、实施计划、三张图。

冲突六处,全在 org 后端「姓在前」上 —— 主支(0c029b6,shanqin)与分支同日各做了一版:
主支拼「姓 名」留空格,分支 personName 汉字直拼(「柴浩」)、非汉字留空格(「Admin Amy」),
且分支那版是超集(服务账号识别、名册同步走同一把尺)。取分支版:port.ts / claims.ts /
keycloak-token-verifier.ts / keycloak-admin-roster.ts 及其测试按分支;主支那条 claims 测试
(「亲 删」→ 姓在前)保留,期望从「删 亲」改为「删亲」。api-report 重生成。

五道门禁绿:2096 测试通过、23 跳过(无模型密钥 / 无真信箱的惯常项)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-27 11:49:05 +08:00
co-authored by Claude Fable 5
67 changed files with 10890 additions and 418 deletions
+6 -1
View File
@@ -135,6 +135,7 @@ export function GovShell({ surface }: { surface: Surface }) {
const domains = sortDomains([...new Set((objects.data?.objects ?? []).map((o) => o.domain))]);
// 进台账没带域(左轨点进来)时落到首域 —— 页签与内容同一判定
const effectiveTab = zone === 'ledgers' && tab === '' ? (domains[0] ?? '') : tab;
const tabHint = TABS[zone].find((t) => t.key === tab)?.hint;
/* 面包屑:台面 › 区 › 页签 › 下钻各级 */
const crumbs: Array<{ label: string; go?: () => void }> = [
@@ -249,7 +250,8 @@ export function GovShell({ surface }: { surface: Surface }) {
* 摆一个只有一颗按钮的导航条,是在给人做无谓的选择。
* 台账例外 —— 它的页签是随发布生长的数据域,数量本来就不定。
*/}
<div className={'g-tabs' + (zone !== 'ledgers' && TABS[zone].length <= 1 ? ' hidden' : '')}>
<div className={'g-tabsrow' + (zone !== 'ledgers' && TABS[zone].length <= 1 ? ' hidden' : '')}>
<div className="g-tabs">
{zone === 'ledgers'
? // 台账的页签是**数据域**(短名 2-3 字),随发布生长 —— 设计产生运营的形状,落在页签上
domains.map((d) => (
@@ -273,6 +275,9 @@ export function GovShell({ surface }: { surface: Surface }) {
{t.label}
</button>
))}
</div>
{/* 右端一句话:这一格管什么、写下去会发生什么 —— 界面自己说清楚,不靠人记 */}
{tabHint !== undefined && <span className="g-tabs-hint">{tabHint}</span>}
</div>
<ZoneHome zone={zone} tab={effectiveTab} nav={nav} />
</>
+17
View File
@@ -84,6 +84,10 @@
.g-head-t p { margin: 5px 0 0; font-size: 12.5px; line-height: 1.7; color: var(--ink-3); max-width: 108ch; }
/* 区内页签:一个页签 = 一个维度 */
/* 页签栏一行两件:左边那颗药丸是页签,右端一句话说清这一格管什么(可缺席) */
.g-tabsrow { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.g-tabsrow.hidden { display: none; }
.g-tabs-hint { font-size: 12px; color: var(--ink-4); text-align: right; }
.g-tabs.hidden { display: none; }
.g-tabs { display: flex; flex-wrap: wrap; gap: 3px; padding: 4px; border-radius: 12px; background: #e3e8ef; align-self: flex-start; }
.g-tab { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 14px; border: 0; border-radius: 9px; background: transparent; color: var(--ink-3); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: background var(--t-fast), color var(--t-fast); }
@@ -862,6 +866,19 @@
/* 供应商:一行小签 —— 挑一个即预填它的默认端点。
从前是 146px 宽的大牌子,一个人吃掉 118px 高,把下面的输入框全顶出屏幕。 */
.g-chiprow { display: flex; flex-wrap: wrap; gap: 6px; }
/* 组织那一屏:左树右详情;窄屏叠成一列(树在上,详情在下) */
.g-split { display: grid; grid-template-columns: minmax(240px, 340px) 1fr; gap: 14px; align-items: start; }
@media (max-width: 900px) { .g-split { grid-template-columns: 1fr; } }
.g-tree { display: flex; flex-direction: column; gap: 2px; }
.g-tree-r { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--ink-2); font: inherit; font-size: 12.5px; text-align: left; cursor: pointer; transition: background var(--t-fast), color var(--t-fast); }
.g-tree-r:hover { background: #f1f4f8; color: var(--ink); }
.g-tree-r.on { background: var(--brand-soft, #e8f0ff); color: var(--ink); font-weight: 700; }
.g-tree-r b { flex: 1; font-weight: inherit; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.g-tree-r em { font-style: normal; font-size: 11px; color: var(--ink-4); }
/* 一枚勾选 + 一句话 —— `.g-swrow` 会把行内**所有** input 压成 14px,
而这一行里还站着检索框,所以单起一个,只管勾选框自己 */
.g-tick { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--ink-2); cursor: pointer; white-space: nowrap; }
.g-tick input { width: 14px; height: 14px; accent-color: var(--brand); cursor: pointer; }
.g-chip { padding: 6px 11px; border: 1px solid var(--cline); border-radius: 15px; background: #fff; font: 500 12px/1.2 var(--sans); color: var(--ink-3); cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast), color var(--t-fast); }
.g-chip:hover { border-color: #aebdcd; color: var(--ink); }
.g-chip.on { border-color: var(--brand); background: var(--brand-wash); color: var(--brand-deep); font-weight: 600; }
@@ -0,0 +1,255 @@
/**
* 成员详情 —— 一个人的全部:身份、能进哪几个端、角色授收、所属组、启停与删除。
*
* 这一屏的每个动作都**穿透到 Keycloak**,没有撤销;所以一律走二次确认带。
* 两处顺手做的事要说出来,不然人不知道:
* 停用与收权都**当场踢掉他的全部会话** —— 否则他手上那枚 token 到期前照样通行,
* 而屏上已经写了「已停用」。界面不撒谎。
*/
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
deleteOrgUserRoute,
patchOrgUserRoute,
resetOrgUserPasswordRoute,
setOrgUserEnabledRoute,
setOrgUserRoleRoute,
} from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { Acts, Card, Chip, Clear, Field, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
import { Btn } from '@web-shared/Btn.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { ORG_KEYS, useOrgRoles, useOrgUser } from '@web-shared/gov/gov-api.ts';
import { EndChips } from './members.tsx';
export function MemberPage({ id, nav }: { id: string; nav: Nav }) {
const qc = useQueryClient();
const q = useOrgUser(id);
const roles = useOrgRoles();
const u = q.data;
const [err, setErr] = useState<string | undefined>(undefined);
const [pwd, setPwd] = useState('');
const [editing, setEditing] = useState<{ firstName: string; lastName: string; email: string } | undefined>(undefined);
/** 改一个人:他自己 + 名单 + 角色持有数;组的成员数没变(删人那一路另说) */
const bust = (): void => {
void qc.invalidateQueries({ queryKey: ORG_KEYS.user });
void qc.invalidateQueries({ queryKey: ORG_KEYS.users });
void qc.invalidateQueries({ queryKey: ORG_KEYS.roles });
};
const fail = (e: unknown): void => setErr(e instanceof Error ? e.message : String(e));
const setEnabled = useMutation({
mutationFn: (enabled: boolean) => call(setOrgUserEnabledRoute, { params: { sub: id }, body: { enabled } }),
onSuccess: () => { setErr(undefined); bust(); },
onError: fail,
});
const setRole = useMutation({
mutationFn: (v: { role: string; granted: boolean }) =>
call(setOrgUserRoleRoute, { params: { sub: id, role: v.role }, body: { granted: v.granted } }),
onSuccess: () => { setErr(undefined); bust(); },
onError: fail,
});
const patch = useMutation({
mutationFn: (v: { firstName: string; lastName: string; email: string }) =>
call(patchOrgUserRoute, { params: { sub: id }, body: v }),
onSuccess: () => { setEditing(undefined); setErr(undefined); bust(); },
onError: fail,
});
const reset = useMutation({
mutationFn: () => call(resetOrgUserPasswordRoute, { params: { sub: id }, body: { password: pwd } }),
onSuccess: () => { setPwd(''); setErr(undefined); },
onError: fail,
});
const drop = useMutation({
mutationFn: () => call(deleteOrgUserRoute, { params: { sub: id } }),
// 删人是唯一会动到组的那一路:他从他所在的每个组里没了
onSuccess: () => {
bust();
void qc.invalidateQueries({ queryKey: ORG_KEYS.groups });
nav.jump('people', 'members');
},
onError: fail,
});
return (
<QueryGate query={q}>
{u === undefined ? (
<Clear>{`Keycloak 里没有这个人(${id})—— 可能刚被删了。`}</Clear>
) : (
<>
<PageHead
ico="user-round"
kicker="成员"
title={u.displayName}
chips={
<>
<Chip ico="at-sign">{u.username}</Chip>
<St tone={u.enabled ? 'ok' : 'mute'} ico={u.enabled ? 'circle-check' : 'power'}>
{u.enabled ? '在职' : '已停用'}
</St>
</>
}
desc="身份与角色的真源是 Keycloak —— 这一屏的每个动作都直接改那边,没有撤销。"
/>
{err !== undefined && <Note tone="warn">{err}</Note>}
<Card ico="user-check" title="身份">
<Rows
items={[
{ k: '用户名', v: u.username, mono: true },
{ k: '姓名', v: u.displayName },
{ k: '邮箱', v: u.email ?? '—' },
{ k: '邮箱已验证', v: u.emailVerified ? '是' : '否' },
{ k: 'sub', v: u.sub, mono: true },
{ k: '本地岗位', v: u.positionCode ?? '未分配' },
{ k: '几时来过', v: u.lastSeenAt ?? '还没拿这枚身份访问过本系统' },
]}
/>
{editing === undefined ? (
<Acts
align="end"
items={[
{
label: '改资料',
ico: 'pencil-line',
onClick: () =>
setEditing({ firstName: u.firstName ?? '', lastName: u.lastName ?? '', email: u.email ?? '' }),
},
]}
/>
) : (
<div className="g-prop">
<div className="g-prop-h">改资料(用户名建后不可改)</div>
<div className="g-prop-b">
<Field k="姓">
<input className="g-input" value={editing.lastName} onChange={(e) => setEditing({ ...editing, lastName: e.target.value })} />
</Field>
<Field k="名">
<input className="g-input" value={editing.firstName} onChange={(e) => setEditing({ ...editing, firstName: e.target.value })} />
</Field>
<Field k="邮箱">
<input className="g-input" value={editing.email} onChange={(e) => setEditing({ ...editing, email: e.target.value })} />
</Field>
<Acts
align="end"
items={[
{ label: '取消', onClick: () => setEditing(undefined) },
{ label: '保存', ico: 'check', primary: true, busy: patch.isPending, onClick: () => patch.mutate(editing) },
]}
/>
</div>
</div>
)}
</Card>
<Card ico="shield-check" title="能进哪几个端" extra={<span className="x">由下面的角色 × 「权限」页签的配置算出来</span>}>
<div className="g-chiprow">
<EndChips ends={u.ends} />
</div>
</Card>
<Card ico="key" title="角色" extra={<span className="x">授收都穿透 Keycloak;收权当场踢掉他的会话</span>}>
<QueryGate query={roles}>
<div className="g-chiprow">
{(roles.data?.roles ?? []).map((r) => {
const has = u.roles.includes(r.name);
return (
<Btn
key={r.name}
className={'g-btn' + (has ? ' primary' : '')}
busy={setRole.isPending && setRole.variables?.role === r.name}
onClick={() => setRole.mutate({ role: r.name, granted: !has })}
title={
has
? `收掉「${r.name}」—— 他手上的会话会被当场踢掉`
: `授予「${r.name}」(可进:${r.ends.length === 0 ? '无端' : r.ends.join(' / ')})`
}
>
{(has ? '✓ ' : '+ ') + r.name}
</Btn>
);
})}
</div>
</QueryGate>
</Card>
<Card ico="network" title="所属组">
{u.groups.length === 0 ? (
<Clear>不属于任何组 —— 进出组在「组织」页签里做。</Clear>
) : (
<div className="g-chiprow">
{u.groups.map((g) => (
<Chip key={g.id} tone="brand" ico="network">
{g.path}
</Chip>
))}
</div>
)}
</Card>
<Card ico="lock" title="口令" extra={<span className="x">一律临时口令 —— 本人首登必须改</span>}>
<div className="g-chiprow" style={{ alignItems: 'center' }}>
<input
className="g-input"
style={{ flex: 1 }}
type="password"
placeholder="新的临时口令"
value={pwd}
onChange={(e) => setPwd(e.target.value)}
/>
<Btn className="g-btn" disabled={pwd === ''} busy={reset.isPending} onClick={() => reset.mutate()}>
重置口令
</Btn>
</div>
<Note>服务端不回显、不留痕 —— 这一下按完,这串字就只在你和他之间。</Note>
</Card>
<Card ico="power" title="启停与删除">
<Acts
items={[
{
label: u.enabled ? '停用' : '启用',
ico: 'power',
danger: u.enabled,
busy: setEnabled.isPending,
ask: {
title: `${u.enabled ? '停用' : '启用'}「${u.displayName}」`,
note: u.enabled
? '停用会当场踢掉他的全部会话 —— 手上那枚 token 立刻失效,不用等过期。'
: '启用之后他就能重新登录了。',
impact: {
who: u.displayName,
undo: '可以再改回来',
when: '立刻',
},
okLabel: u.enabled ? '确认停用' : '确认启用',
onOk: () => setEnabled.mutate(!u.enabled),
},
},
{
label: '删除',
ico: 'trash-2',
danger: true,
busy: drop.isPending,
ask: {
title: `把「${u.displayName}」从 Keycloak 删掉`,
note: '本地名册那一行会停用但不删 —— 留痕、在途任务、台账里都还指着这个 sub,删了那些地方就成了悬空的 id。',
impact: {
who: u.displayName,
undo: '不能撤销 —— 从 Keycloak 消失,再建一个是新的 sub',
when: '按下即生效',
},
okLabel: '确认删除',
onOk: () => drop.mutate(),
},
},
]}
/>
</Card>
</>
)}
</QueryGate>
);
}
@@ -0,0 +1,188 @@
/**
* 成员 —— Keycloak 目录的投影(ADR-012)。
*
* **这里的写是穿透的**:建的人立刻能登录、停的人立刻登不上,没有本地草稿,也没有撤销。
* 所以每个写动作都走 `Acts` 的二次确认带(三必答:谁受影响 / 能不能撤 / 几时生效)。
*
* 检索与「含停用」都交给服务端 —— 那边握着全量,前端只拿要摆的那些。
*/
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createOrgUserRoute, SURFACE_LABEL } from '@contracts';
import type { Surface } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { Acts, Card, Chip, Clear, Field, NList, NRow, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx';
import { Btn } from '@web-shared/Btn.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { ORG_KEYS, useOrgUsers } from '@web-shared/gov/gov-api.ts';
/** 能进的端 —— 摆人读名;一个都没有就直说,别留一片空白让人以为没加载出来 */
export function EndChips({ ends }: { ends: readonly Surface[] }) {
if (ends.length === 0) return <Chip tone="mute">无端可进</Chip>;
return (
<>
{ends.map((e) => (
<Chip key={e} tone="brand">
{SURFACE_LABEL[e]}
</Chip>
))}
</>
);
}
export function MembersTab({ nav }: { nav: Nav }) {
const [q, setQ] = useState('');
const [includeDisabled, setIncludeDisabled] = useState(true);
const [creating, setCreating] = useState(false);
const users = useOrgUsers(q, includeDisabled);
const rows = users.data?.users ?? [];
const active = rows.filter((u) => u.enabled).length;
/*
* 数还没取回来就别摆数 —— `?? []` 会让计数在载入那几秒显示「0 人 · 0 在职」,
* 而那是**一句假话**:不是没有人,是还不知道有几个人(铁规矩 8)。
*/
const counted = users.data !== undefined;
return (
<>
<Card
ico="users"
title="成员"
extra={
<span className="g-chiprow" style={{ alignItems: 'center' }}>
<input
className="g-input"
style={{ width: 240 }}
placeholder="按用户名 / 姓名 / 邮箱检索…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<label className="g-tick">
<input type="checkbox" checked={includeDisabled} onChange={(e) => setIncludeDisabled(e.target.checked)} />
含停用
</label>
<Chip tone={counted ? 'brand' : 'mute'}>{counted ? `${rows.length} 人 · ${active} 在职` : '数着…'}</Chip>
<Btn className="g-btn primary" onClick={() => setCreating((v) => !v)}>
{creating ? '收起' : '新建成员'}
</Btn>
</span>
}
>
<Note>
真源是 Keycloak · 停用的人<b>立刻</b>登不上;在途任务不受影响(需手动改派)。服务账号不在这份名单里 ——
它是身份不是人。
</Note>
{creating && <CreateMemberForm onDone={() => setCreating(false)} />}
<QueryGate query={users}>
{rows.length === 0 ? (
<Clear>{q === '' ? '一个人都没有 —— 去 Keycloak 建人,或用「新建成员」。' : `没有匹配「${q}」的成员。`}</Clear>
) : (
<NList>
{rows.map((u) => (
<NRow
key={u.sub}
tone={u.enabled ? 'brand' : 'mute'}
ico="user-round"
title={u.displayName}
sub={`${u.username}${u.email === undefined ? '' : ` · ${u.email}`}`}
cols={[
{ k: '角色', v: u.roles.length === 0 ? '—' : u.roles.join(', ') },
{ k: '可进', v: <EndChips ends={u.ends} /> },
]}
right={
<St tone={u.enabled ? 'ok' : 'mute'} ico={u.enabled ? 'circle-check' : 'power'}>
{u.enabled ? '在职' : '已停用'}
</St>
}
onClick={() => nav.push({ k: 'emp', id: u.sub })}
/>
))}
</NList>
)}
</QueryGate>
</Card>
</>
);
}
/**
* 新建成员 —— 口令一律**临时**:本人首登必须改。
* 我们不存它、不回显 —— 它经过这个表单就再也不出现了,所以建完那一句话得把它说清楚。
*/
function CreateMemberForm({ onDone }: { onDone: () => void }) {
const qc = useQueryClient();
const [username, setUsername] = useState('');
const [lastName, setLastName] = useState('');
const [firstName, setFirstName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | undefined>(undefined);
const create = useMutation({
mutationFn: () =>
call(createOrgUserRoute, {
body: {
username: username.trim(),
...(lastName.trim() === '' ? {} : { lastName: lastName.trim() }),
...(firstName.trim() === '' ? {} : { firstName: firstName.trim() }),
...(email.trim() === '' ? {} : { email: email.trim() }),
...(password === '' ? {} : { password }),
},
}),
onSuccess: () => {
// 多了一个人 → 名单变了,角色的持有数也跟着变;组没动
void qc.invalidateQueries({ queryKey: ORG_KEYS.users });
void qc.invalidateQueries({ queryKey: ORG_KEYS.roles });
onDone();
},
onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)),
});
return (
<div className="g-prop">
<div className="g-prop-h">新建成员</div>
<div className="g-prop-b">
<Field k="用户名" hint="登录名,建后不可改">
<input className="g-input" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="如 zhangsan" />
</Field>
<Field k="姓" hint="Keycloak 的 lastName">
<input className="g-input" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</Field>
<Field k="名" hint="Keycloak 的 firstName">
<input className="g-input" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</Field>
<Field k="邮箱" hint="选填">
<input className="g-input" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field>
<Field k="临时口令" hint="留空则建一个还登不进来的壳;填了则本人首登必须改">
<input className="g-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</Field>
{err !== undefined && <Note tone="warn">{err}</Note>}
<Acts
align="end"
items={[
{ label: '取消', onClick: onDone },
{
label: '建这个人',
ico: 'user-plus',
primary: true,
disabled: username.trim() === '',
busy: create.isPending,
ask: {
title: `在 Keycloak 里建「${username.trim()}」`,
note: '姓名照 Keycloak 的 lastName + firstName 拼;角色先不给 —— 建完去「权限」页签授。',
impact: {
who: '这个人立刻能登录(若给了临时口令)',
undo: '不能撤销 —— 建错了只能再删掉',
when: '按下即生效,Keycloak 那边当场就有',
},
okLabel: '确认新建',
onOk: () => create.mutate(),
},
},
]}
/>
</div>
</div>
);
}
@@ -0,0 +1,276 @@
/**
* 组织 —— Keycloak 的组(ADR-012)。左边一棵树,右边选中那个组的成员与操作。
*
* 树是**平表**渲染的:一行一个组,靠 `depth` 缩进(契约里就不放递归结构 ——
* 递归 schema 到了声明文件那一层只会变成一坨 any)。按 `path` 升序,父在子前。
*
* 删组连子组一起没(Keycloak 语义),人不会被删 —— 只是不再属于这个组。
*/
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
createOrgGroupRoute,
deleteOrgGroupRoute,
renameOrgGroupRoute,
setOrgUserGroupRoute,
} from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { Acts, Card, Chip, Clear, NList, NRow, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx';
import { Btn } from '@web-shared/Btn.tsx';
import { ORG_KEYS, useOrgGroupMembers, useOrgGroups, useOrgUsers } from '@web-shared/gov/gov-api.ts';
import type { GovOrgGroups } from '@web-shared/gov/gov-api.ts';
export function OrgsTab() {
const groups = useOrgGroups();
const rows = groups.data?.groups ?? [];
const [picked, setPicked] = useState<string | undefined>(undefined);
const current = rows.find((g) => g.id === picked);
const tops = rows.filter((g) => g.depth === 0).length;
/* 取回来之前一个数都别摆、一句「还没有组」也别说 —— 那时候我们并不知道有没有组 */
const loaded = groups.data !== undefined;
return (
<div className="g-split">
<Card ico="network" title="组织树" extra={<Chip tone={loaded ? 'brand' : 'mute'}>{loaded ? `${tops} 顶层组` : '数着…'}</Chip>}>
<Note>Keycloak 组 · 点一行看它的成员与操作</Note>
<QueryGate query={groups}>
{rows.length === 0 ? (
<Clear>还没有组 —— 先在下面「建顶层组」。</Clear>
) : (
<div className="g-tree">
{rows.map((g) => (
<button
key={g.id}
className={'g-tree-r' + (picked === g.id ? ' on' : '')}
style={{ paddingLeft: 10 + g.depth * 18 }}
onClick={() => setPicked(g.id)}
title={g.path}
>
<b>{g.name}</b>
<em>{`${g.memberCount} 人`}</em>
</button>
))}
</div>
)}
</QueryGate>
<GroupCreate label="建顶层组" hint="组名(同级不得重名)" />
</Card>
{current === undefined ? (
<Card ico="users" title="成员">
<Clear>
{!loaded
? '组织树还在取…'
: rows.length === 0
? '还没有组 —— 先在左侧「建顶层组」,这里看它的成员与操作。'
: '在左侧点一个组,这里看它的成员与操作。'}
</Clear>
</Card>
) : (
<GroupDetail
key={current.id}
group={current}
onGone={() => setPicked(undefined)}
/>
)}
</div>
);
}
/** 建组 —— `parentId` 给了就是子组,不给就是顶层组 */
function GroupCreate({ label, hint, parentId }: { label: string; hint: string; parentId?: string }) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [err, setErr] = useState<string | undefined>(undefined);
const create = useMutation({
mutationFn: () =>
call(createOrgGroupRoute, {
body: { name: name.trim(), ...(parentId === undefined ? {} : { parentId }) },
}),
onSuccess: () => {
setName('');
setErr(undefined);
// 只有组变了 —— 成员那一趟很贵,别拖它下水
void qc.invalidateQueries({ queryKey: ORG_KEYS.groups });
},
onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)),
});
return (
<div className="g-prop">
<div className="g-prop-h">{label}</div>
<div className="g-prop-b">
<div className="g-chiprow" style={{ alignItems: 'center' }}>
<input className="g-input" style={{ flex: 1 }} placeholder={hint} value={name} onChange={(e) => setName(e.target.value)} />
<Btn className="g-btn" disabled={name.trim() === ''} busy={create.isPending} onClick={() => create.mutate()}>
建组
</Btn>
</div>
{err !== undefined && <Note tone="warn">{err}</Note>}
</div>
</div>
);
}
function GroupDetail({
group,
onGone,
}: {
group: { id: string; name: string; path: string; depth: number; memberCount: number };
onGone: () => void;
}) {
const qc = useQueryClient();
const members = useOrgGroupMembers(group.id);
const all = useOrgUsers('', true);
const [renaming, setRenaming] = useState<string | undefined>(undefined);
const [err, setErr] = useState<string | undefined>(undefined);
/** 组这一屏的写只动组与组成员;人的角色与端一个字没变 */
const bust = (): void => {
void qc.invalidateQueries({ queryKey: ORG_KEYS.groups });
void qc.invalidateQueries({ queryKey: ORG_KEYS.groupMembers });
};
const fail = (e: unknown): void => setErr(e instanceof Error ? e.message : String(e));
const rename = useMutation({
mutationFn: (name: string) => call(renameOrgGroupRoute, { params: { id: group.id }, body: { name } }),
onSuccess: () => { setRenaming(undefined); setErr(undefined); bust(); },
onError: fail,
});
const drop = useMutation({
mutationFn: () => call(deleteOrgGroupRoute, { params: { id: group.id } }),
onSuccess: () => {
/*
* 删成功了 —— **「这个组没了」是已知事实**,不必等服务端再说一遍。
* 那一趟重取要好几秒(组树得逐组数成员),期间树上还钉着已经没了的组;
* 撞上 token 到期强刷的那一次更久,实测有一轮干脆没刷过来。
* 手上有的答案先落进缓存,重取照发 —— 回来时一比,一样。
* 子组跟着一起没(Keycloak 语义),按**路径前缀**连它们一起摘,不是只摘直属那一层。
*/
qc.setQueryData<GovOrgGroups>(ORG_KEYS.groups, (old) =>
old === undefined
? old
: { groups: old.groups.filter((g) => g.id !== group.id && !g.path.startsWith(`${group.path}/`)) },
);
onGone();
bust();
},
onError: fail,
});
const setMember = useMutation({
mutationFn: (v: { sub: string; member: boolean }) =>
call(setOrgUserGroupRoute, { params: { sub: v.sub, groupId: group.id }, body: { member: v.member } }),
onSuccess: () => { setErr(undefined); bust(); },
onError: fail,
});
const inGroup = members.data?.members ?? [];
const inIds = new Set(inGroup.map((m) => m.sub));
const outside = (all.data?.users ?? []).filter((u) => !inIds.has(u.sub));
return (
<Card
ico="users"
title={group.name}
extra={<St tone="ok" ico="users">{`${inGroup.length} 人`}</St>}
>
<Note>{`路径 ${group.path}`}</Note>
{renaming !== undefined && (
<div className="g-prop">
<div className="g-prop-h">改组名</div>
<div className="g-prop-b">
<div className="g-chiprow" style={{ alignItems: 'center' }}>
<input className="g-input" style={{ flex: 1 }} value={renaming} onChange={(e) => setRenaming(e.target.value)} />
<Btn className="g-btn" onClick={() => setRenaming(undefined)}>取消</Btn>
<Btn
className="g-btn primary"
disabled={renaming.trim() === '' || renaming.trim() === group.name}
busy={rename.isPending}
onClick={() => rename.mutate(renaming.trim())}
>
改名
</Btn>
</div>
</div>
</div>
)}
{err !== undefined && <Note tone="warn">{err}</Note>}
<QueryGate query={members}>
{inGroup.length === 0 ? (
<Clear>这个组还没有人 —— 在下面「把人加进来」。</Clear>
) : (
<NList>
{inGroup.map((m) => (
<NRow
key={m.sub}
tone={m.enabled ? 'brand' : 'mute'}
ico="user-round"
title={m.displayName}
sub={m.username}
right={
<Btn
className="g-btn danger"
busy={setMember.isPending && setMember.variables?.sub === m.sub}
onClick={() => setMember.mutate({ sub: m.sub, member: false })}
>
移出
</Btn>
}
onClick={() => undefined}
/>
))}
</NList>
)}
</QueryGate>
<div className="g-prop">
<div className="g-prop-h">把人加进来</div>
<div className="g-prop-b">
{outside.length === 0 ? (
<Clear>所有人都已经在这个组里了。</Clear>
) : (
<div className="g-chiprow">
{outside.map((u) => (
<Btn
key={u.sub}
className="g-btn"
busy={setMember.isPending && setMember.variables?.sub === u.sub}
onClick={() => setMember.mutate({ sub: u.sub, member: true })}
>
{`+ ${u.displayName}`}
</Btn>
))}
</div>
)}
</div>
</div>
<GroupCreate label="在这个组下建子组" hint="子组名(同级不得重名)" parentId={group.id} />
<Acts
align="end"
items={[
{ label: '改组名', ico: 'pencil-line', onClick: () => setRenaming(group.name) },
{
label: '删掉这个组',
ico: 'trash-2',
danger: true,
busy: drop.isPending,
ask: {
title: `删掉「${group.name}」`,
note: '子组会跟着一起没。人不会被删 —— 只是不再属于这个组。',
impact: {
who: `这个组里的 ${inGroup.length} 个人,以及它下面的所有子组`,
undo: '不能撤销 —— 组和它的层级关系直接从 Keycloak 消失',
when: '按下即生效',
},
okLabel: '确认删组',
onOk: () => drop.mutate(),
},
},
]}
/>
</Card>
);
}
@@ -1,367 +1,31 @@
/**
* feature `gov-staff` —— 员工管理:账号、岗位授权、停用前置检查
* feature `gov-staff` —— 成员与组织(ADR-012,2026-08-26 用户按目标件定的三页签)。
*
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
* 三个页签一一对着 Keycloak 的三样东西:
* **成员** = users(目录投影;新建 / 启停 / 删除都穿透过去)
* **权限** = realm roles × 本系统的「角色 → 端」表(每个请求都按它判)
* **组织** = groups(建组 / 改名 / 删组 / 成员进出组)
*
* 原「岗位授权」与「工作组」两个页签已拿掉(用户定)。**后端数据与接口原样留着** ——
* 岗位仍决定活动派单的候选集,工作组仍在起单那一刻快照进工作流;拿掉的只是入口。
*/
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 { MembersTab } from './members.tsx';
import { MemberPage } from './member-page.tsx';
import { OrgsTab } from './orgs.tsx';
import { PermsTab, RolePage } from './perms.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} />;
if (tab === 'perms') return <PermsTab />;
if (tab === 'orgs') return <OrgsTab />;
return <MembersTab 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>
);
}
/* ---------------- ③ 工作流管理 ---------------- */
/** 详情页 `emp` —— 路由名沿用(URL 里已有的书签不该失效),内容是成员详情 */
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 })} />
</>
);
return <MemberPage id={id} nav={nav} />;
}
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>
);
}
export { RolePage };
@@ -0,0 +1,204 @@
/**
* 权限 —— **两份事实拼在一起**(ADR-012):
* 角色本身是 Keycloak 的(realm roles,授收穿透过去);
* 「这个角色能进哪几个端」是**本系统自己的**(`sys.role_access`),Keycloak 不知道这回事。
*
* 权限只到端:设计中心 / 运营中心 / 工作台。一个请求进不进得来,鉴权中间件每次都按这张表判 ——
* 所以这一屏改的不是一句描述,是**下一个请求的成败**。
*
* 锁死不变量:改完之后至少还得有人能进运营中心 —— 否则这张表就再也没人改得了。
* 服务端算完了才落库,不过就 409;这边如实把那句话摆出来,不自己拦(拦了两处口径会分叉)。
*/
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient } from '@tanstack/react-query';
import { deleteRoleAccessRoute, setRoleAccessRoute, SURFACE_LABEL, SURFACES } from '@contracts';
import type { Surface } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { Acts, Card, Chip, Clear, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx';
import { ORG_KEYS, useOrgRoles, useRoleAccess } from '@web-shared/gov/gov-api.ts';
import { EndChips } from './members.tsx';
/**
* 改「角色 → 端」之后,**每个人的可进端都跟着变** —— 所以人那一份也得失效,
* 不然成员那一屏还摆着旧的端胶囊(同一件事两屏说两句话)。组没动。
*/
function bustAccess(qc: QueryClient): void {
void qc.invalidateQueries({ queryKey: ORG_KEYS.roleAccess });
void qc.invalidateQueries({ queryKey: ORG_KEYS.roles });
void qc.invalidateQueries({ queryKey: ORG_KEYS.users });
void qc.invalidateQueries({ queryKey: ORG_KEYS.user });
}
export function PermsTab() {
const roles = useOrgRoles();
const access = useRoleAccess();
const rows = roles.data?.roles ?? [];
return (
<Card
ico="shield-check"
title="角色 → 能进哪几个端"
// 取回来之前不摆数 —— 「0 个角色」在那一刻是假话
extra={<Chip tone={roles.data === undefined ? 'mute' : 'brand'}>{roles.data === undefined ? '数着…' : `${rows.length} 个角色`}</Chip>}
>
<Note>
角色来自 Keycloak(realm roles),<b>能进哪几个端由这里说了算</b> —— 每个请求都按它判,
不合就回 403。改完至少要有人还能进运营中心,否则这张表自己就锁死了。
</Note>
<QueryGate query={roles}>
{rows.length === 0 ? (
<Clear>realm 里还没有业务角色 —— 去 Keycloak 建(家具角色不摆在这儿)。</Clear>
) : (
<div className="g-rows">
{rows.map((r) => (
<RoleRow key={r.name} role={r} configured={(access.data?.roles ?? []).some((x) => x.role === r.name)} />
))}
</div>
)}
</QueryGate>
</Card>
);
}
function RoleRow({
role,
configured,
}: {
role: { name: string; description?: string | undefined; composite: boolean; ends: readonly Surface[]; holders: number };
configured: boolean;
}) {
const qc = useQueryClient();
const [draft, setDraft] = useState<readonly Surface[] | undefined>(undefined);
const [err, setErr] = useState<string | undefined>(undefined);
const editing = draft !== undefined;
const shown = draft ?? role.ends;
const save = useMutation({
mutationFn: (ends: readonly Surface[]) =>
// 一个端都不给 = 这个角色哪儿都进不去。删掉那一行与存一行空的是同一件事,存空的更好读
call(setRoleAccessRoute, { params: { role: role.name }, body: { ends: [...ends] } }),
onSuccess: () => {
setDraft(undefined);
setErr(undefined);
bustAccess(qc);
},
onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)),
});
const remove = useMutation({
mutationFn: () => call(deleteRoleAccessRoute, { params: { role: role.name } }),
onSuccess: () => {
setDraft(undefined);
setErr(undefined);
bustAccess(qc);
},
onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)),
});
return (
<div className="g-prop">
<div className="g-prop-h">
<span className="g-mono">{role.name}</span>
{role.composite && <Chip tone="warn">组合角色</Chip>}
<span className="x">{`${role.holders} 人持有`}</span>
</div>
<div className="g-prop-b">
{role.description !== undefined && <Note>{role.description}</Note>}
{editing ? (
<div className="g-swrow">
{SURFACES.map((s) => (
<label key={s}>
<input
type="checkbox"
checked={shown.includes(s)}
onChange={(e) =>
setDraft(e.target.checked ? [...shown, s] : shown.filter((x) => x !== s))
}
/>
{SURFACE_LABEL[s]}
</label>
))}
<small>勾上的端,持有这个角色的人就能进;一个都不勾 = 有这个角色也哪儿都进不去。</small>
</div>
) : (
<div className="g-chiprow">
<EndChips ends={role.ends} />
{!configured && <Chip tone="mute">还没配过</Chip>}
</div>
)}
{err !== undefined && <Note tone="warn">{err}</Note>}
<Acts
align="end"
items={
editing
? [
{ label: '取消', onClick: () => { setDraft(undefined); setErr(undefined); } },
{
label: '保存',
ico: 'check',
primary: true,
busy: save.isPending,
ask: {
title: `把「${role.name}」的可进端改成:${shown.length === 0 ? '无' : shown.map((s) => SURFACE_LABEL[s]).join(' / ')}`,
note: '这张表每个请求都在读 —— 改完下一个请求就按新的判。',
impact: {
who: `持有这个角色的 ${role.holders} 个人`,
undo: '可以改回来 —— 但改完得还有人进得了运营中心,否则服务端会拒',
when: '立刻;他们手上的 token 不用重新登录也按新表判',
},
okLabel: '确认改',
onOk: () => save.mutate(shown),
},
},
]
: [
{ label: '改可进端', ico: 'pencil-line', onClick: () => setDraft(role.ends) },
...(configured
? [
{
label: '删掉这一行',
ico: 'trash-2',
danger: true,
busy: remove.isPending,
ask: {
title: `删掉「${role.name}」的端配置`,
note: '删掉之后这个角色不再命中任何端 —— 与「一个端都不勾」是同一个效果。',
impact: {
who: `持有这个角色的 ${role.holders} 个人`,
undo: '可以再配回来',
when: '立刻',
},
okLabel: '确认删',
onOk: () => remove.mutate(),
},
},
]
: []),
]
}
/>
</div>
</div>
);
}
/** 角色详情:谁持有它 —— 从成员那一屏点进来,或从这一屏的角色名点进来 */
export function RolePage({ id }: { id: string }) {
const roles = useOrgRoles();
const role = (roles.data?.roles ?? []).find((r) => r.name === id);
return (
<QueryGate query={roles}>
{role === undefined ? (
<Clear>{`realm 里没有角色「${id}」—— 可能刚在 Keycloak 里删了。`}</Clear>
) : (
<Card ico="shield-check" title={role.name} extra={<St tone="ok" ico="users">{`${role.holders} 人持有`}</St>}>
<div className="g-chiprow">
<EndChips ends={role.ends} />
</div>
<Note>授收这个角色在「成员」那一屏的成员详情里做 —— 一次改一个人,收权当场踢掉他的会话。</Note>
</Card>
)}
</QueryGate>
);
}
+69
View File
@@ -23,6 +23,7 @@ import type {
GovTriggersOutput,
GovWorkGroupsOutput,
GovWorkflowOutput,
RouteOutput,
ProcessPackage,
GovLedgerByActivityOutput,
} from '@contracts';
@@ -44,6 +45,12 @@ import {
listLlmInstancesRoute,
listLlmProvidersRoute,
listProcessPackagesRoute,
getOrgUserRoute,
listOrgGroupMembersRoute,
listOrgGroupsRoute,
listOrgRolesRoute,
listOrgUsersRoute,
listRoleAccessRoute,
listStaffRoute,
listToolsRoute,
listTriggersRoute,
@@ -190,3 +197,65 @@ export const useGovWorkflow = (id: string): UseQueryResult<GovWorkflow> =>
export const useGovTaskLog = (taskId: string): UseQueryResult<GovTaskLog> =>
useQuery({ queryKey: ['gov', 'tasklog', taskId], queryFn: () => call<GovTaskLog>(govTaskLogRoute, { params: { taskId } }) });
/* ── 成员与组织(ADR-012)———————————————————————————————————————— */
/**
* 各屏的查询键 —— 写完**按碰到的那几屏**失效,别一把 `['gov','org']` 全清。
* 成员那一趟最贵(服务端要逐人问角色,见 debt),把它拖进来重取,
* 屏上那棵组织树就多钉着已经没了的组好几秒。
*/
export const ORG_KEYS = {
users: ['gov', 'org', 'users'] as const,
user: ['gov', 'org', 'user'] as const,
roles: ['gov', 'org', 'roles'] as const,
groups: ['gov', 'org', 'groups'] as const,
groupMembers: ['gov', 'org', 'group-members'] as const,
roleAccess: ['gov', 'org', 'role-access'] as const,
};
export type GovOrgUsers = RouteOutput<typeof listOrgUsersRoute>;
export type GovOrgUserDetail = RouteOutput<typeof getOrgUserRoute>;
export type GovOrgRoles = RouteOutput<typeof listOrgRolesRoute>;
export type GovOrgGroups = RouteOutput<typeof listOrgGroupsRoute>;
export type GovOrgGroupMembers = RouteOutput<typeof listOrgGroupMembersRoute>;
export type GovRoleAccess = RouteOutput<typeof listRoleAccessRoute>;
/**
* 成员列表 —— 检索与「含停用」都在**服务端**过滤(那边握着全量,前端只拿要摆的那些)。
* 服务端第一次取要几秒(Keycloak 没有批量取角色的口,见 debt),它自己有 60 秒缓存;
* 这边 `staleTime` 跟着设一档,免得同一屏的几个组件各要一遍。
*/
export const useOrgUsers = (q: string, includeDisabled: boolean): UseQueryResult<GovOrgUsers> =>
useQuery({
queryKey: ['gov', 'org', 'users', q, includeDisabled],
queryFn: () =>
call<GovOrgUsers>(listOrgUsersRoute, {
query: { q: q === '' ? undefined : q, includeDisabled: includeDisabled ? '1' : undefined },
}),
staleTime: 30_000,
});
export const useOrgUser = (sub: string | undefined): UseQueryResult<GovOrgUserDetail> =>
useQuery({
queryKey: ['gov', 'org', 'user', sub],
queryFn: () => call<GovOrgUserDetail>(getOrgUserRoute, { params: { sub: sub! } }),
enabled: sub !== undefined,
});
export const useOrgRoles = (): UseQueryResult<GovOrgRoles> =>
useQuery({ queryKey: ['gov', 'org', 'roles'], queryFn: () => call<GovOrgRoles>(listOrgRolesRoute), staleTime: 30_000 });
export const useOrgGroups = (): UseQueryResult<GovOrgGroups> =>
useQuery({ queryKey: ['gov', 'org', 'groups'], queryFn: () => call<GovOrgGroups>(listOrgGroupsRoute), staleTime: 30_000 });
export const useOrgGroupMembers = (groupId: string | undefined): UseQueryResult<GovOrgGroupMembers> =>
useQuery({
queryKey: ['gov', 'org', 'group-members', groupId],
queryFn: () => call<GovOrgGroupMembers>(listOrgGroupMembersRoute, { params: { id: groupId! } }),
enabled: groupId !== undefined,
});
/** 角色 → 端 —— 这张表是**本地**的(ADR-012),不是 Keycloak 的字段 */
export const useRoleAccess = (): UseQueryResult<GovRoleAccess> =>
useQuery({ queryKey: ['gov', 'org', 'role-access'], queryFn: () => call<GovRoleAccess>(listRoleAccessRoute) });
+15 -6
View File
@@ -113,10 +113,10 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
desc: '活动的运行时:任务 = 活动被调起的一次运行,恰有一名负责人。这里做任务与审批单的在途监控、干预与控制——暂停可恢复、叫停归负责人、终止级联作废。工作流一级去「工作流」区看。',
},
people: {
title: '员工管理',
title: '成员与组织',
icon: 'users',
accent: 'linear-gradient(140deg,#2bc7d4,#0e9fd8)',
desc: '员工负载、岗位与授权。权限按岗位授予:客体三类 × 动作三种;停用前置检查——在途任务与待答卡须先改派完毕。',
desc: '成员与组织的真源是 Keycloak:这里建的人立刻能登录、停的人立刻登不上,没有本地草稿,也没有撤销。权限只到端——一个角色能进设计中心 / 运营中心 / 工作台的哪几个,由本系统的「角色 → 端」表说了算。',
},
workflows: {
title: '流程包管理',
@@ -179,7 +179,10 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
* 还留着多格的只有运营侧的任务与员工(它们确实是几个不同维度),
* 以及 `ledgers`:它的页签是数据域,随发布动态生成(壳层运行时铺开),故此表留空。
*/
export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string }>>> = {
/** `hint` 摆在页签栏右端 —— 一句话说清这一格管什么、写下去会发生什么 */
export const TABS: Readonly<
Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string; hint?: string }>>
> = {
blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }],
/*
* 测试台三格 —— 从前是一格加一个「两档旋钮」,旋钮长在内容里,别处跳不进来、
@@ -209,10 +212,16 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
{ key: 'active', label: '激活中', ico: 'zap' },
{ key: 'disabled', label: '已禁用', ico: 'ban' },
],
/*
* 成员与组织(2026-08-26 用户定):三个页签一一对着 Keycloak 的三样东西 ——
* 成员 = users、权限 = realm roles、组织 = groups。
* 原「岗位授权」与「工作组」两个页签先拿掉(用户定);两处**后端数据与接口原样留着**,
* 拿掉的只是入口 —— 岗位仍决定活动派单的候选集,工作组仍在起单那一刻快照进工作流。
*/
people: [
{ key: 'emp', label: '员工', ico: 'users' },
{ key: 'roles', label: '岗位授权', ico: 'key' },
{ key: 'groups', label: '工作组', ico: 'users' },
{ key: 'members', label: '成员', ico: 'users', hint: 'Keycloak 目录投影:成员列表 · 新建 · 启停,点行进详情' },
{ key: 'perms', label: '权限', ico: 'shield-check', hint: '角色 → 能进哪几个端(本地配置)· 授收角色(穿透 Keycloak)' },
{ key: 'orgs', label: '组织', ico: 'network', hint: '组织树:建组 / 改名 / 删组 / 成员进出组' },
],
ledgers: [], // 动态:页签即数据域(短名呈现),由壳层按已发布域生成
system: [{ key: 'chan', label: '通道实例', ico: 'mail' }],
+24
View File
@@ -5,7 +5,31 @@ import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../..', import.meta.url));
const p = (rel: string) => fileURLToPath(new URL(rel, import.meta.url));
/*
* 稳定版(./stable.sh)用 `vite preview` 托 .build/stable 的静态文件。preview 有一道宿主校验
* (防 DNS rebinding),默认只放 localhost 与 IP —— 经域名 / 反代进来会被拦:
* 「Blocked request. This host ("…") is not allowed.」
* 放哪些宿主由起它的人说(PREVIEW_ALLOWED_HOSTS,逗号分隔;`.example.com` 放整个子域;`*` 全放)。
* 开发那台 `vite` 不读这个,不受影响。
*/
const previewHosts = process.env.PREVIEW_ALLOWED_HOSTS?.trim();
const preview =
previewHosts === undefined || previewHosts === ''
? {}
: {
preview: {
allowedHosts:
previewHosts === '*'
? (true as const)
: previewHosts
.split(',')
.map((s) => s.trim())
.filter((s) => s !== ''),
},
};
export default defineConfig({
...preview,
plugins: [react()],
resolve: {
alias: {