合并 origin/mvp-foundation:活动模块接真接口 + 员工模块界面改版 + 启动抽屉「责任人」+ 身份菜单直链运营中心(shanqin 三条)
CI / ci (push) Failing after 11s

主支这边:W05 1.4.1–1.4.7 七条 + 刚合进来的 feat/activity-ops-w05(运营中心活动模块接口 L1/L2/L3、
W05 在途修订、同日 harness/工作台修);远端三条是队友的前端:活动模块 L1/L2/L3 接真接口(顺手按接口文档
补了 api/activity-ops.ts 契约)、员工模块界面改版、启动抽屉固定参数「责任人」、工作台身份菜单直链 /ops。

冲突三处,全是两边各写了一版同一样东西:
① `packages/contracts/src/activity-ops.ts` / `api/activity-ops.ts`(add/add):取后端那版 —— 队友是照
   《API文档-活动模块》补的,43 个导出同名、路由 12 条全同,只差 launchOptionSchema / INSTANCE_REASON_KINDS /
   STEP_RESULTS 三个名字;队友前端对着后端契约原样编译通过。
② `docs/API文档-活动模块.md`:取后端按契约回写的那份(已含 §5 责任人 ownerId、combo 语义、关键台账看板一节、
   候选料号改单选);队友那侧是回写前的旧稿。
设计端活动库两条路由改名 `/api/gov/activity-packages` 让位随 feat 分支进来,队友契约与治理台同名路由撞车那 7 条
路由注册表测试(远端主支自己红)就此消解;api-report 按合并树重刷。

五道门禁绿:2240 测试通过、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 23:22:57 +08:00
co-authored by Claude Fable 5
10 changed files with 1318 additions and 875 deletions
@@ -14,13 +14,15 @@ import {
resetOrgUserPasswordRoute,
setOrgUserEnabledRoute,
setOrgUserRoleRoute,
SURFACE_LABEL,
} 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 { Ico } from '@web-shared/Ico.tsx';
import { Acts, AskBand, Card, Chip, Clear, Field, Note, 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';
import { avaTone, EndChips } from './members.tsx';
export function MemberPage({ id, nav }: { id: string; nav: Nav }) {
const qc = useQueryClient();
@@ -30,6 +32,8 @@ export function MemberPage({ id, nav }: { id: string; nav: Nav }) {
const [err, setErr] = useState<string | undefined>(undefined);
const [pwd, setPwd] = useState('');
const [editing, setEditing] = useState<{ firstName: string; lastName: string; email: string } | undefined>(undefined);
/* 危险区的二次确认带(2026-08-27 第二轮):动作行进了危险区,确认带单独走全宽 */
const [ask, setAsk] = useState<{ kind: 'enable' | 'drop' } | null>(null);
/** 改一个人:他自己 + 名单 + 角色持有数;组的成员数没变(删人那一路另说) */
const bust = (): void => {
@@ -77,178 +81,237 @@ export function MemberPage({ id, nav }: { id: string; nav: Nav }) {
{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'}>
/* 人物主页式重排(2026-08-27 第二轮,用户定):hero 让「这个人」当主角;
主从 2:1(身份/角色 | 端/组/口令);危险区两条动作行收底 */
<div className="g-people g-people-d">
{/* hero:大首字圆块 + 姓名/用户名/状态 + 可进端 + 两条动态事实(几时来过/岗位) */}
<div className={`g-mhero t-${avaTone(u.displayName)}`}>
<span className="g-mhero-ava" aria-hidden>
{u.displayName.trim().charAt(0).toUpperCase() || '·'}
</span>
<div className="g-mhero-t">
<em>成员 · 成员详情</em>
<h2>
{u.displayName}
{/* key 带状态:启停翻转时状态丸弹出一次高亮 */}
<St key={u.enabled ? 'on' : 'off'} tone={u.enabled ? 'ok' : 'mute'} ico={u.enabled ? 'circle-check' : 'power'}>
{u.enabled ? '在职' : '已停用'}
</St>
</>
}
desc="身份与角色的真源是 Keycloak —— 这一屏的每个动作都直接改那边,没有撤销。"
/>
</h2>
<p>
<span className="g-mono">@{u.username}</span>
<i>·</i>
身份与角色的真源是 Keycloak —— 这一屏的每个动作都直接改那边,没有撤销。
</p>
<span className="g-mhero-ends">
<EndChips ends={u.ends} />
</span>
</div>
<div className="g-mhero-meta">
<span>
<em>几时来过</em>
<b>{u.lastSeenAt ?? '还没访问过'}</b>
</span>
<span>
<em>本地岗位</em>
<b>{u.positionCode ?? '未分配'}</b>
</span>
</div>
</div>
{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>
<div className="g-people-dgrid">
<div className="main">
<Card ico="user-check" title="身份" extra={editing === undefined ? undefined : <span className="x">改资料中</span>}>
{/* 只摆档案事实;姓名/岗位/几时来过已上 hero,不重复摆 */}
<Rows
items={[
{ k: '用户名', v: u.username, mono: true },
{ k: '邮箱', v: u.email ?? '—' },
{ k: '邮箱已验证', v: u.emailVerified ? '是' : '否' },
{ k: 'sub', v: u.sub, mono: true },
]}
/>
{editing === undefined ? (
<Acts
align="end"
items={[
{ label: '取消', onClick: () => setEditing(undefined) },
{ label: '保存', ico: 'check', primary: true, busy: patch.isPending, onClick: () => patch.mutate(editing) },
{
label: '改资料',
ico: 'pencil-line',
onClick: () =>
setEditing({ firstName: u.firstName ?? '', lastName: u.lastName ?? '', email: u.email ?? '' }),
},
]}
/>
</div>
</div>
)}
</Card>
) : (
<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} />
{/* 角色 = 一行一角色的开关清单(2026-08-27 第二轮):读得清每个角色给什么,拨一下就授收 */}
<Card ico="key" title="角色" extra={<span className="x">授收都穿透 Keycloak;收权当场踢掉他的会话</span>}>
<QueryGate query={roles}>
<div className="g-rtab">
{(roles.data?.roles ?? []).map((r) => {
const has = u.roles.includes(r.name);
return (
<div key={r.name} className={'g-rrow' + (has ? ' on' : '')}>
<span className="id">
<b className="g-mono">{r.name}</b>
{r.description !== undefined && <em>{r.description}</em>}
</span>
<span className="ends">
{r.ends.length === 0 ? '无端可进' : r.ends.map((e) => SURFACE_LABEL[e]).join(' / ')}
</span>
<Btn
role="switch"
aria-checked={has}
aria-label={`${has ? '收掉' : '授予'}角色 ${r.name}`}
className={'g-role-sw' + (has ? ' on' : '')}
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.map((e) => SURFACE_LABEL[e]).join(' / ')})`
}
>
<i />
</Btn>
</div>
);
})}
</div>
</QueryGate>
</Card>
</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>
<div className="rail">
<Card ico="shield-check" title="能进哪几个端" extra={<span className="x">角色 × 「权限」页签算出</span>}>
<div className="g-chiprow">
<EndChips ends={u.ends} />
</div>
</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="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()}>
重置口令
<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>
</div>
</div>
{/* 危险区(2026-08-27 第二轮):两条动作行 —— 每行说清这个动作是什么、会怎样,钮在行右 */}
<div className="g-people-danger">
<div className="hd">
<Ico name="alert-triangle" s={14} />
<b>启停与删除</b>
</div>
<div className="g-drow">
<span className="t">
<b>{u.enabled ? '停用此人' : '启用此人'}</b>
<em>
{u.enabled
? '停用当场踢掉他的全部会话 —— 手上那枚 token 立刻失效,不用等过期;可以再启用。'
: '启用之后他就能重新登录。'}
</em>
</span>
<Btn
className={'g-btn' + (u.enabled ? ' danger' : ' primary')}
busy={setEnabled.isPending}
onClick={() => setAsk({ kind: 'enable' })}
>
<Ico name="power" s={13} />
{u.enabled ? '停用' : '启用'}
</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>
</>
<div className="g-drow">
<span className="t">
<b>删除此人</b>
<em>不能撤销 —— 从 Keycloak 消失,再建一个是新的 sub;本地名册留痕不删,在途任务与台账不受影响。</em>
</span>
<Btn className="g-btn danger" busy={drop.isPending} onClick={() => setAsk({ kind: 'drop' })}>
<Ico name="trash-2" s={13} />
删除
</Btn>
</div>
{ask?.kind === 'enable' && (
<AskBand
spec={{
title: `${u.enabled ? '停用' : '启用'}「${u.displayName}」`,
note: u.enabled
? '停用会当场踢掉他的全部会话 —— 手上那枚 token 立刻失效,不用等过期。'
: '启用之后他就能重新登录了。',
impact: { who: u.displayName, undo: '可以再改回来', when: '立刻' },
okLabel: u.enabled ? '确认停用' : '确认启用',
onOk: () => setEnabled.mutate(!u.enabled),
}}
danger={u.enabled}
onCancel={() => setAsk(null)}
/>
)}
{ask?.kind === 'drop' && (
<AskBand
spec={{
title: `把「${u.displayName}」从 Keycloak 删掉`,
note: '本地名册那一行会停用但不删 —— 留痕、在途任务、台账里都还指着这个 sub,删了那些地方就成了悬空的 id。',
impact: { who: u.displayName, undo: '不能撤销 —— 从 Keycloak 消失,再建一个是新的 sub', when: '按下即生效' },
okLabel: '确认删除',
onOk: () => drop.mutate(),
}}
danger
onCancel={() => setAsk(null)}
/>
)}
</div>
</div>
)}
</QueryGate>
);
@@ -11,7 +11,8 @@ 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 { Ico } from '@web-shared/Ico.tsx';
import { Acts, Card, Chip, Clear, Field, 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';
@@ -30,6 +31,14 @@ export function EndChips({ ends }: { ends: readonly Surface[] }) {
);
}
/** 首字圆块的色板轮换(2026-08-27 员工模块改版):名字哈希取 4 档,同一个人永远同一个色 —— 本页唯一记忆点 */
const AVA_TONES = ['brand', 'collab', 'warn', 'decision'] as const;
export function avaTone(name: string): (typeof AVA_TONES)[number] {
let h = 0;
for (const ch of name) h = (h * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
return AVA_TONES[h % AVA_TONES.length]!;
}
export function MembersTab({ nav }: { nav: Nav }) {
const [q, setQ] = useState('');
const [includeDisabled, setIncludeDisabled] = useState(true);
@@ -44,30 +53,31 @@ export function MembersTab({ nav }: { nav: Nav }) {
const counted = users.data !== undefined;
return (
<>
<div className="g-people">
<Card
ico="users"
title="成员"
extra={
<span className="g-chiprow" style={{ alignItems: 'center' }}>
extra={<Chip tone={counted ? 'brand' : 'mute'}>{counted ? `${rows.length} 人 · ${active} 在职` : '数着…'}</Chip>}
>
{/* 工具条独立成行(2026-08-27 改版):不再与卡片标题挤 —— 左检索,右筛选与主操作 */}
<div className="g-people-bar">
<span className="g-people-search">
<Ico name="search" s={13} />
<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>
}
>
<label className="g-tick">
<input type="checkbox" checked={includeDisabled} onChange={(e) => setIncludeDisabled(e.target.checked)} />
含停用
</label>
<Btn className="g-btn primary" onClick={() => setCreating((v) => !v)}>
{creating ? '收起' : '新建成员'}
</Btn>
</div>
<Note>
真源是 Keycloak · 停用的人<b>立刻</b>登不上;在途任务不受影响(需手动改派)。服务账号不在这份名单里 ——
它是身份不是人。
@@ -77,31 +87,56 @@ export function MembersTab({ nav }: { nav: Nav }) {
{rows.length === 0 ? (
<Clear>{q === '' ? '一个人都没有 —— 去 Keycloak 建人,或用「新建成员」。' : `没有匹配「${q}」的成员。`}</Clear>
) : (
<NList>
/* 目录行(2026-08-27 改版,用户定):表头与数据同一条栅格,列标签不再悬空 */
<div className="g-mtab">
<div className="g-mrow-h">
<span />
<span>姓名</span>
<span>邮箱</span>
<span>角色</span>
<span>可进端</span>
<span>状态</span>
<span />
</div>
{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={
<button key={u.sub} className={'g-mrow' + (u.enabled ? '' : ' off')} onClick={() => nav.push({ k: 'emp', id: u.sub })}>
<span className={`g-mava t-${avaTone(u.displayName)}`} aria-hidden>
{u.displayName.trim().charAt(0).toUpperCase() || '·'}
</span>
<span className="who">
<b>{u.displayName}</b>
<em>{u.username}</em>
</span>
<span className="mail">{u.email ?? '—'}</span>
{/* 角色列(2026-08-27 第二轮):逗号串改迷你 mono 小丸,多角色一眼可数 */}
<span className="role">
{u.roles.length === 0
? '—'
: u.roles.map((r) => (
<i key={r} className="rp">
{r}
</i>
))}
</span>
<span className="ends">
<EndChips ends={u.ends} />
</span>
{/* key 带状态:启停翻转时状态丸弹出一次高亮 —— 变化要看得见 */}
<span key={u.enabled ? 'on' : 'off'} className="st">
<St tone={u.enabled ? 'ok' : 'mute'} ico={u.enabled ? 'circle-check' : 'power'}>
{u.enabled ? '在职' : '已停用'}
</St>
}
onClick={() => nav.push({ k: 'emp', id: u.sub })}
/>
</span>
<span className="x">
<Ico name="chevron-right" s={14} />
</span>
</button>
))}
</NList>
</div>
)}
</QueryGate>
</Card>
</>
</div>
);
}
@@ -15,10 +15,12 @@ import {
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 { Ico } from '@web-shared/Ico.tsx';
import { Acts, Card, Chip, Clear, 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';
import { avaTone } from './members.tsx';
export function OrgsTab() {
const groups = useOrgGroups();
@@ -30,55 +32,61 @@ export function OrgsTab() {
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>
<div className="g-people">
<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>
{/* 建组收成卡底一条(2026-08-27 改版):不再是沉在卡里的表单块 */}
<GroupCreate bar label="建顶层组" hint="组名(同级不得重名)" />
</Card>
) : (
<GroupDetail
key={current.id}
group={current}
onGone={() => setPicked(undefined)}
/>
)}
{current === undefined ? (
/* 空态(2026-08-27 改版):一句说明 + 顺手能办的事,不再是一条突兀的色带 */
<div className="g-people-empty">
<Ico name="network" s={22} />
<p>
{!loaded
? '组织树还在取…'
: rows.length === 0
? '还没有组 —— 先建一个顶层组,这里看它的成员与操作。'
: '在左侧点一个组,这里看它的成员与操作。'}
</p>
{loaded && rows.length === 0 && <GroupCreate bar label="建顶层组" hint="组名(同级不得重名)" />}
</div>
) : (
<GroupDetail
key={current.id}
group={current}
onGone={() => setPicked(undefined)}
/>
)}
</div>
</div>
);
}
/** 建组 —— `parentId` 给了就是子组,不给就是顶层组 */
function GroupCreate({ label, hint, parentId }: { label: string; hint: string; parentId?: string }) {
/** 建组 —— `parentId` 给了就是子组,不给就是顶层组;`bar` 形态收成单行条(卡底/空态用),默认表单块 */
function GroupCreate({ label, hint, parentId, bar }: { label: string; hint: string; parentId?: string; bar?: boolean }) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [err, setErr] = useState<string | undefined>(undefined);
@@ -95,6 +103,17 @@ function GroupCreate({ label, hint, parentId }: { label: string; hint: string; p
},
onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)),
});
if (bar === true) {
return (
<div className="g-people-tadd">
<input className="g-input" aria-label={label} placeholder={hint} value={name} onChange={(e) => setName(e.target.value)} />
<Btn className="g-btn" disabled={name.trim() === ''} busy={create.isPending} onClick={() => create.mutate()}>
建组
</Btn>
{err !== undefined && <Note tone="warn">{err}</Note>}
</div>
);
}
return (
<div className="g-prop">
<div className="g-prop-h">{label}</div>
@@ -200,27 +219,27 @@ function GroupDetail({
{inGroup.length === 0 ? (
<Clear>这个组还没有人 —— 在下面「把人加进来」。</Clear>
) : (
<NList>
/* 组成员行与成员页同构(2026-08-27 改版):首字圆块 + 姓名/用户名 + 行内移出 */
<div className="g-gmtab">
{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}
/>
<div key={m.sub} className={'g-gmrow' + (m.enabled ? '' : ' off')}>
<span className={`g-mava t-${avaTone(m.displayName)}`} aria-hidden>
{m.displayName.trim().charAt(0).toUpperCase() || '·'}
</span>
<span className="who">
<b>{m.displayName}</b>
<em>{m.username}</em>
</span>
<Btn
className="g-btn danger"
busy={setMember.isPending && setMember.variables?.sub === m.sub}
onClick={() => setMember.mutate({ sub: m.sub, member: false })}
>
移出
</Btn>
</div>
))}
</NList>
</div>
)}
</QueryGate>
@@ -15,7 +15,8 @@ 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 { Acts, AskBand, Card, Chip, Clear, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx';
import { Btn } from '@web-shared/Btn.tsx';
import { ORG_KEYS, useOrgRoles, useRoleAccess } from '@web-shared/gov/gov-api.ts';
import { EndChips } from './members.tsx';
@@ -36,28 +37,31 @@ export function PermsTab() {
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>
<div className="g-people">
<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>
) : (
/* 一行一角色(2026-08-27 改版):身份 | 可进端 | 操作一条栅格;描述收成灰字,不再整幅色带 */
<div className="g-ptab">
{rows.map((r) => (
<RoleRow key={r.name} role={r} configured={(access.data?.roles ?? []).some((x) => x.role === r.name)} />
))}
</div>
)}
</QueryGate>
</Card>
</div>
);
}
@@ -71,6 +75,8 @@ function RoleRow({
const qc = useQueryClient();
const [draft, setDraft] = useState<readonly Surface[] | undefined>(undefined);
const [err, setErr] = useState<string | undefined>(undefined);
/* 删除确认带(2026-08-27 改版):操作钮进了行右,确认带单独走全宽 —— 不挤在窄格里 */
const [askDel, setAskDel] = useState(false);
const editing = draft !== undefined;
const shown = draft ?? role.ends;
@@ -97,15 +103,60 @@ function RoleRow({
});
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 className="g-prole">
<div className="g-prole-row">
<span className="id">
<b className="g-mono">{role.name}</b>
{role.composite && <Chip tone="warn">组合角色</Chip>}
<em>{`${role.holders} 人持有`}</em>
</span>
<span className="body">
{/* 可进端改三槽点位(2026-08-27 第二轮):三个端永远都在,授了的亮、没授的虚灰 —— 列向下扫即读 */}
<span className="ends3">
{SURFACES.map((s) => (
<i key={s} className={'ep' + (role.ends.includes(s) ? ' on' : '')}>
{SURFACE_LABEL[s]}
</i>
))}
{!configured && <Chip tone="mute">还没配过</Chip>}
</span>
{role.description !== undefined && <span className="desc">{role.description}</span>}
</span>
<span className="a">
{!editing && (
<>
<Btn className="g-btn" onClick={() => { setDraft(role.ends); setErr(undefined); }}>
改可进端
</Btn>
{configured && (
<Btn className="g-btn danger" busy={remove.isPending} onClick={() => setAskDel(true)}>
删掉这一行
</Btn>
)}
</>
)}
</span>
</div>
<div className="g-prop-b">
{role.description !== undefined && <Note>{role.description}</Note>}
{editing ? (
{err !== undefined && <Note tone="warn">{err}</Note>}
{askDel && (
<AskBand
spec={{
title: `删掉「${role.name}」的端配置`,
note: '删掉之后这个角色不再命中任何端 —— 与「一个端都不勾」是同一个效果。',
impact: {
who: `持有这个角色的 ${role.holders} 个人`,
undo: '可以再配回来',
when: '立刻',
},
okLabel: '确认删',
onOk: () => remove.mutate(),
}}
danger
onCancel={() => setAskDel(false)}
/>
)}
{editing && (
<div className="g-prole-edit">
<div className="g-swrow">
{SURFACES.map((s) => (
<label key={s}>
@@ -121,64 +172,31 @@ function RoleRow({
))}
<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),
},
<Acts
align="end"
items={[
{ 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 不用重新登录也按新表判',
},
]
: [
{ 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>
okLabel: '确认改',
onOk: () => save.mutate(shown),
},
},
]}
/>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff