/** * 成员 —— 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 无端可进; return ( <> {ends.map((e) => ( {SURFACE_LABEL[e]} ))} ); } 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 ( <> setQ(e.target.value)} /> {counted ? `${rows.length} 人 · ${active} 在职` : '数着…'} setCreating((v) => !v)}> {creating ? '收起' : '新建成员'} } > 真源是 Keycloak · 停用的人立刻登不上;在途任务不受影响(需手动改派)。服务账号不在这份名单里 —— 它是身份不是人。 {creating && setCreating(false)} />} {rows.length === 0 ? ( {q === '' ? '一个人都没有 —— 去 Keycloak 建人,或用「新建成员」。' : `没有匹配「${q}」的成员。`} ) : ( {rows.map((u) => ( }, ]} right={ {u.enabled ? '在职' : '已停用'} } onClick={() => nav.push({ k: 'emp', id: u.sub })} /> ))} )} ); } /** * 新建成员 —— 口令一律**临时**:本人首登必须改。 * 我们不存它、不回显 —— 它经过这个表单就再也不出现了,所以建完那一句话得把它说清楚。 */ 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(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 (
新建成员
setUsername(e.target.value)} placeholder="如 zhangsan" /> setLastName(e.target.value)} /> setFirstName(e.target.value)} /> setEmail(e.target.value)} /> setPassword(e.target.value)} /> {err !== undefined && {err}} create.mutate(), }, }, ]} />
); }