Archived
合并 origin/mvp-foundation:活动模块接真接口 + 员工模块界面改版 + 启动抽屉「责任人」+ 身份菜单直链运营中心(shanqin 三条)
CI / ci (push) Failing after 11s
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:
@@ -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
Reference in New Issue
Block a user