Archived
feat(org): 成员目录 —— Keycloak 读写面穿透、治理台成员/组织/权限三页、姓在前 personName、Staff.kind
后端:`KeycloakDirectory` 端口(users / groups / roles 读写;写直接穿透 Keycloak —— 这里建的人立刻能登录、 删的人立刻消失,停用与收权之后 logout 踢掉会话)、`keycloak-directory.ts` + `keycloak-admin-http.ts` 适配器、 org-directory 契约与 /api/gov 路由、`sync-roster` CLI(ops 路由够不着时整册照过来), 容器按 provisioning 服务账号有无决定注不注入(没配就如实说不可用)。 `personName` 姓在前:汉字直拼「柴浩」、否则留一个空格「Admin Amy」,token 与名册同步两条路同一把尺; `staffKindOf` 按 Keycloak 固定前缀 service-account- 认服务账号,员工那一屏不摆它(`Staff.kind` 不落库,按账号名现判)。 前端:员工那一屏重做成成员 / 组织 / 权限三页签(member-page 下钻),页签栏右端一句话说清这一格管什么。 契约变更:contracts org.ts / api/http.ts / api/org-directory.ts;api-report 已刷新;org CONTRACT.md 与 ADR-012 同步。 2026-08-26 做的,一直留在工作树没入库。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user