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:
@@ -178,10 +178,10 @@ export function AppShell() {
|
||||
<Ico name="user" s={14} />
|
||||
个人中心
|
||||
</a>
|
||||
{/* 设计中心直链数据域页,整页导航过去(2026-08-25 用户定) */}
|
||||
<a href="/design#/design/data/domains">
|
||||
<Ico name="pen-line" s={14} />
|
||||
设计中心
|
||||
{/* 运营中心直链活动板块(2026-08-28 用户定,原设计中心入口改此),整页导航过去 */}
|
||||
<a href="/ops#/ops/tasks/active">
|
||||
<Ico name="list-checks" s={14} />
|
||||
运营中心
|
||||
</a>
|
||||
<button type="button" className="danger" onClick={logout}>
|
||||
<Ico name="log-out" s={14} />
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
/* 但右上角装饰光晕定位 right:-60px,裁切一放开它就探出视口右缘 60px,整页出现横向滚动
|
||||
(2026-08-25 用户报:页面能左右滑)—— 把它收回盒内,光晕照留 */
|
||||
.proto-root .jc-topbar.jc-topbar-app::after { right: 0; }
|
||||
/* 身份菜单:头像 + 姓名,悬停出下拉(个人中心 / 设计中心 / 退出登录)—— 纯 CSS 悬停无 JS;
|
||||
/* 身份菜单:头像 + 姓名,悬停出下拉(个人中心 / 运营中心 / 退出登录)—— 纯 CSS 悬停无 JS;
|
||||
登出入口收进下拉,是全站唯一入口(2026-08-25 用户定);2026-08-26 从顶栏挪进左轨底部(见下方 rail 变体) */
|
||||
.proto-root .jc-usermenu { position: relative; display: flex; align-items: center; gap: 8px; padding: 4px 8px 4px 4px; border-radius: 10px; cursor: default; color: var(--ink-4); transition: background var(--t-fast); }
|
||||
.proto-root .jc-usermenu:hover { background: var(--soft); }
|
||||
|
||||
@@ -736,9 +736,17 @@
|
||||
border-top: 1px solid var(--tone-line, var(--cline)); border-right: 1px solid var(--tone-line, var(--cline)); }
|
||||
.g-actvd .g-popask p, .g-actid .g-popask p { margin: 0; font-size: 13px; font-weight: 600; line-height: 1.55; color: var(--ink); }
|
||||
.g-actvd .g-popask-a, .g-actid .g-popask-a { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
/* 失败原因留在气泡里(2026-08-28 用户报:终止点了不知道成没成)—— 选择器多带一层 .g-popask,压过上面的 p 基则 */
|
||||
.g-actvd .g-popask .g-popask-err, .g-actid .g-popask .g-popask-err { margin: 8px 0 0; font-size: 12px; font-weight: 500; line-height: 1.5; color: var(--danger-accent, #d83931); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.g-actvd .g-popask, .g-actid .g-popask { animation: none; }
|
||||
}
|
||||
/* 行内终止的轻气泡(2026-08-27 接接口,文档 §10:L2 行内终止也要二次确认)——
|
||||
锚在操作格里(触发钮左边,垂直对行中线);不进退场动画:行是表格流,气泡从格子里长出来就够 */
|
||||
.g-actvd .g-irow .a { position: relative; }
|
||||
.g-actvd .g-popask.inrow { top: 50%; right: calc(100% + 8px); transform: translateY(-50%); animation: none; }
|
||||
/* 实例详情:文件列表与键值区之间补一道距 */
|
||||
.g-actid .g-files { display: grid; gap: 8px; margin-top: 10px; }
|
||||
|
||||
/* ══ 启动活动 · 参数抽屉(2026-08-27 用户定)══
|
||||
骨架复用全局 g-drawer;portal 挂在 body 上,作用域类 .g-launch 得自己带着走。
|
||||
@@ -759,6 +767,162 @@
|
||||
.g-launch .g-combo .g-input { flex: 1; min-width: 0; }
|
||||
.g-launch .g-combo-back { flex: none; padding: 4px 6px; border: 0; border-radius: 6px; background: transparent; color: var(--brand); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.g-launch .g-combo-back:hover { text-decoration: underline; }
|
||||
|
||||
/* ══ 员工模块(成员与组织)改版(2026-08-27 用户定:只改布局与样式,数据/接口/功能不动)══
|
||||
作用域 .g-people:共享件(g-* 基类)本体不动,这里全是本模块的件;
|
||||
目录行 = 表头与数据同一条栅格;首字圆块色板轮换(t-brand/collab/warn/decision)是本页唯一记忆点 */
|
||||
|
||||
/* —— 卡内容区补距(2026-08-27 用户:卡片内容贴边、太挤)——
|
||||
.g-card-b(padding 14px 16px / gap 12px)定义了但从没接线,内容直落 .g-card 贴边(见 236 行注释);
|
||||
共享件不动,本模块作用域内补同一组数值:左右 16、上 12、末尾 14。
|
||||
整幅列表(目录行 .g-mtab / 角色开关 .g-rtab)不进这条 —— 它们边到边,用自己的行分隔线 */
|
||||
.g-people .g-card > :not(.g-card-h) { margin: 12px 16px 0; }
|
||||
.g-people .g-card > :not(.g-card-h):last-child { margin-bottom: 14px; }
|
||||
.g-people .g-card > .g-mtab, .g-people .g-card > .g-rtab { margin: 0; border: 0; border-top: 1px solid var(--line-soft); border-radius: 0; }
|
||||
|
||||
/* —— 成员 · 工具条:从卡头抽出,卡内第一行;左检索,右筛选 + 主操作 —— */
|
||||
.g-people-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.g-people-search { position: relative; flex: none; width: 280px; display: flex; align-items: center; }
|
||||
.g-people-search > svg { position: absolute; left: 10px; color: var(--ink-4); pointer-events: none; }
|
||||
.g-people-search .g-input { padding-left: 30px; }
|
||||
.g-people-bar .g-tick { margin-left: auto; }
|
||||
|
||||
/* —— 成员目录行(用户选定:目录行而非卡片网格)—— */
|
||||
.g-people .g-mtab { background: var(--card); border: 1px solid var(--cline); border-radius: 12px; overflow: hidden; }
|
||||
.g-people .g-mrow-h, .g-people .g-mrow { display: grid; grid-template-columns: 40px minmax(160px, 1.3fr) minmax(190px, 1.1fr) minmax(130px, .9fr) minmax(170px, 1.1fr) 76px 16px; align-items: center; gap: 12px; padding: 0 14px; }
|
||||
.g-people .g-mrow-h { height: 34px; font-size: 11px; font-weight: 600; letter-spacing: .04em; color: var(--ink-4); background: #fbfcfe; border-bottom: 1px solid var(--line-soft); }
|
||||
.g-people .g-mrow { width: 100%; min-height: 56px; padding-top: 8px; padding-bottom: 8px; border: 0; border-bottom: 1px solid var(--line-soft); background: transparent; font: inherit; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||
/* 斑马:第 1 个 child 是表头,数据行从 2 起 —— odd 命中的正是第 2、4… 条数据 */
|
||||
.g-people .g-mrow:nth-child(odd) { background: #fafbfd; }
|
||||
.g-people .g-mrow:hover { background: #f3f6fb; }
|
||||
.g-people .g-mrow:last-child { border-bottom: 0; }
|
||||
.g-people .g-mrow:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
|
||||
/* 停用行整行降饱和 —— 一眼分出在职/停用,不必等读到状态丸 */
|
||||
.g-people .g-mrow.off { opacity: .62; }
|
||||
.g-people .g-mava { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; font-size: 14px; font-weight: 700; color: var(--tone-ink); background: var(--tone-soft); border: 1px solid var(--tone-line); }
|
||||
.g-people .who { min-width: 0; line-height: 1.45; }
|
||||
.g-people .who b { display: block; font-size: 13px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.g-people .who em { display: block; font-style: normal; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.g-people .g-mrow .mail { font-size: 12px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.g-people .g-mrow .role { font-family: var(--mono); font-size: 11.5px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.g-people .g-mrow .ends { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
|
||||
.g-people .g-mrow .st { display: flex; justify-content: flex-end; }
|
||||
.g-people .g-mrow .x { color: var(--ink-4); display: grid; place-items: center; transition: color var(--t-fast), transform var(--t-fast); }
|
||||
.g-people .g-mrow:hover .x { color: var(--brand); transform: translateX(2px); }
|
||||
|
||||
/* —— 权限 · 一行一角色:身份 | 可进端 | 操作;描述收成灰字,确认带与编辑区走全宽 —— */
|
||||
.g-people .g-ptab { display: flex; flex-direction: column; gap: 8px; }
|
||||
.g-people .g-prole { background: var(--card); border: 1px solid var(--cline); border-radius: 12px; padding: 12px 14px; }
|
||||
.g-people .g-prole-row { display: grid; grid-template-columns: minmax(200px, 1fr) minmax(0, 2fr) auto; align-items: center; gap: 14px; }
|
||||
.g-people .g-prole .id { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.g-people .g-prole .id b { font-size: 13px; font-weight: 700; color: var(--ink); }
|
||||
.g-people .g-prole .id em { font-style: normal; font-size: 11px; color: var(--ink-4); white-space: nowrap; }
|
||||
.g-people .g-prole .body { min-width: 0; }
|
||||
.g-people .g-prole .ends { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.g-people .g-prole .desc { display: block; margin-top: 5px; font-size: 12px; line-height: 1.6; color: var(--ink-4); }
|
||||
.g-people .g-prole .a { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.g-people .g-prole > .g-note { margin-top: 10px; }
|
||||
.g-people .g-prole .g-ask { margin-top: 10px; }
|
||||
.g-people .g-prole-edit { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line-soft); animation: g-fade .18s ease-out; }
|
||||
|
||||
/* —— 组织 · 树行 / 建组条 / 空态 / 组成员行 —— */
|
||||
.g-people .g-tree-r { min-height: 34px; font-size: 12.5px; }
|
||||
.g-people .g-tree-r.on { background: var(--brand-wash); box-shadow: inset 3px 0 0 var(--brand); }
|
||||
.g-people .g-tree-r em { font-family: var(--mono); }
|
||||
.g-people-tadd { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
|
||||
.g-people-tadd .g-input { flex: 1; min-width: 0; }
|
||||
.g-people-tadd .g-note { flex-basis: 100%; }
|
||||
.g-people-empty { min-height: 320px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 24px; background: var(--card); border: 1px dashed var(--cline); border-radius: 12px; color: var(--ink-4); text-align: center; }
|
||||
.g-people-empty p { margin: 0; max-width: 340px; font-size: 12.5px; line-height: 1.7; }
|
||||
.g-people-empty .g-people-tadd { width: min(360px, 100%); margin-top: 4px; padding-top: 0; border-top: 0; }
|
||||
.g-people .g-gmtab { background: var(--card); border: 1px solid var(--cline); border-radius: 12px; overflow: hidden; }
|
||||
.g-people .g-gmrow { display: flex; align-items: center; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--line-soft); transition: background var(--t-fast); }
|
||||
.g-people .g-gmrow:hover { background: #f7f9fc; }
|
||||
.g-people .g-gmrow:last-child { border-bottom: 0; }
|
||||
.g-people .g-gmrow .who { flex: 1; }
|
||||
.g-people .g-gmrow.off { opacity: .62; }
|
||||
|
||||
/* —— 成员详情 · 主从 2:1(DESIGN-SPEC §9)+ 危险区通栏 —— */
|
||||
.g-people-dgrid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 14px; align-items: start; }
|
||||
.g-people-dgrid .main, .g-people-dgrid .rail { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
|
||||
@media (max-width: 1024px) { .g-people-dgrid { grid-template-columns: 1fr; } }
|
||||
/* 危险区:白卡 + 红描边标题行;钮非实心(danger 类自带),确认带三必答不变 */
|
||||
.g-people-danger { margin-top: 14px; background: var(--card); border: 1px solid #f6c6c3; border-radius: 12px; padding: 13px 16px; }
|
||||
.g-people-danger .hd { display: flex; align-items: center; gap: 8px; color: var(--danger); }
|
||||
.g-people-danger .hd b { font-size: 13px; font-weight: 700; }
|
||||
.g-people-danger .hd span { margin-left: 4px; font-size: 11.5px; line-height: 1.6; color: var(--ink-4); }
|
||||
|
||||
/* ── 第二轮深化(2026-08-27 用户:还要更好看)───────────────────────── */
|
||||
|
||||
/* 成员行:首字圆块 hover 轻放大(transform-only,不推别人);角色列 = 迷你 mono 小丸 */
|
||||
.g-people .g-mava { transition: transform var(--t-fast); }
|
||||
.g-people .g-mrow:hover .g-mava { transform: scale(1.08); }
|
||||
.g-people .g-mrow .role { display: flex; flex-wrap: wrap; gap: 4px; font-family: var(--mono); font-size: 11.5px; color: var(--ink-2); }
|
||||
.g-people .g-mrow .role .rp { font-style: normal; font-family: var(--mono); font-size: 10.5px; font-weight: 600; padding: 1px 7px; border-radius: 6px; border: 1px solid var(--line-soft); background: #f6f8fb; color: var(--ink-2); white-space: nowrap; }
|
||||
|
||||
/* 权限行:可进端三槽点位 —— 三个端固定位,授了 brand 实底、没授虚灰;列向下扫即成矩阵 */
|
||||
.g-people .g-prole .ends3 { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
|
||||
.g-people .g-prole .ends3 .ep { font-style: normal; font-size: 10.5px; font-weight: 600; padding: 2px 9px; border-radius: 999px; border: 1px dashed var(--cline); color: var(--ink-4); background: transparent; white-space: nowrap; }
|
||||
.g-people .g-prole .ends3 .ep.on { border: 1px solid var(--tone-line, #c5d8ff); background: #f0f4ff; color: #245bdb; }
|
||||
.g-people .g-prole { transition: border-color var(--t-fast); }
|
||||
.g-people .g-prole:hover { border-color: #c9d4e2; }
|
||||
|
||||
/* 成员详情 hero:这个人是主角 —— 60px 首字圆块 + 名/状态 + 可进端;右侧两条动态事实 */
|
||||
.g-people-d .g-mhero { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; margin-bottom: 14px; background: var(--card); border: 1px solid var(--cline); border-radius: 14px; }
|
||||
.g-people-d .g-mhero-ava { flex: none; width: 60px; height: 60px; border-radius: 50%; display: grid; place-items: center; font-size: 24px; font-weight: 700; color: var(--tone-ink); background: var(--tone-soft); border: 1px solid var(--tone-line); }
|
||||
.g-people-d .g-mhero-t { flex: 1; min-width: 0; }
|
||||
.g-people-d .g-mhero-t > em { font-style: normal; font-size: 11px; font-weight: 600; letter-spacing: .06em; color: var(--ink-4); }
|
||||
.g-people-d .g-mhero-t h2 { display: flex; align-items: center; gap: 10px; margin: 3px 0 0; font-size: 19px; font-weight: 700; color: var(--ink); }
|
||||
.g-people-d .g-mhero-t p { margin: 6px 0 0; font-size: 12px; line-height: 1.6; color: var(--ink-3); }
|
||||
.g-people-d .g-mhero-t p i { margin: 0 7px; font-style: normal; color: var(--ink-4); }
|
||||
.g-people-d .g-mhero-ends { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 9px; }
|
||||
.g-people-d .g-mhero-meta { flex: none; display: flex; flex-direction: column; gap: 8px; text-align: right; }
|
||||
.g-people-d .g-mhero-meta em { display: block; font-style: normal; font-size: 10.5px; color: var(--ink-4); }
|
||||
.g-people-d .g-mhero-meta b { display: block; margin-top: 2px; font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--ink-2); }
|
||||
|
||||
/* 身份卡键值改 2×2 栅格(事实只有 4 条,别再竖排长条) */
|
||||
.g-people-d .g-rows { display: grid; grid-template-columns: 1fr 1fr; box-shadow: none; }
|
||||
.g-people-d .g-rows > div { grid-template-columns: 96px minmax(0, 1fr); padding: 10px 14px; }
|
||||
|
||||
/* 角色 = 开关清单:行 = 名字+描述 | 可进端 | 开关 */
|
||||
.g-people-d .g-rtab { display: flex; flex-direction: column; }
|
||||
.g-people-d .g-rrow { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--line-soft); transition: background var(--t-fast); }
|
||||
.g-people-d .g-rrow:last-child { border-bottom: 0; }
|
||||
.g-people-d .g-rrow:hover { background: #f7f9fc; }
|
||||
.g-people-d .g-rrow .id { min-width: 0; }
|
||||
.g-people-d .g-rrow .id b { display: block; font-size: 12.5px; font-weight: 700; color: var(--ink); }
|
||||
.g-people-d .g-rrow .id em { display: block; margin-top: 2px; font-style: normal; font-size: 11px; line-height: 1.5; color: var(--ink-4); }
|
||||
.g-people-d .g-rrow .ends { font-size: 11px; color: var(--ink-3); }
|
||||
.g-people-d .g-role-sw { position: relative; flex: none; width: 36px; height: 20px; padding: 0; border: 1px solid var(--cline); border-radius: 999px; background: #e8edf3; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.g-people-d .g-role-sw i { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(19, 28, 38, .25); transition: transform .15s ease-out; }
|
||||
.g-people-d .g-role-sw.on { background: var(--brand); border-color: var(--brand); }
|
||||
.g-people-d .g-role-sw.on i { transform: translateX(16px); }
|
||||
.g-people-d .g-role-sw:disabled { opacity: .45; cursor: default; }
|
||||
.g-people-d .g-role-sw:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
|
||||
|
||||
/* 危险区:两条动作行 —— 每行说清动作与后果,钮在行右 */
|
||||
.g-people-danger .g-drow { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--line-soft); }
|
||||
.g-people-danger .hd + .g-drow { margin-top: 6px; }
|
||||
.g-people-danger .g-drow .t { flex: 1; min-width: 0; }
|
||||
.g-people-danger .g-drow .t b { display: block; font-size: 12.5px; font-weight: 600; color: var(--ink); }
|
||||
.g-people-danger .g-drow .t em { display: block; margin-top: 2px; font-style: normal; font-size: 11px; line-height: 1.6; color: var(--ink-4); }
|
||||
.g-people-danger .g-ask { margin-top: 10px; }
|
||||
|
||||
/* 详情页进场:一处克制的错落上浮(≤300ms,reduced-motion 全关) */
|
||||
@keyframes people-rise { from { opacity: 0; transform: translateY(5px); } }
|
||||
.g-people-d .g-mhero, .g-people-d .g-people-dgrid .main > *, .g-people-d .g-people-dgrid .rail > *, .g-people-d .g-people-danger { animation: people-rise .28s ease-out both; }
|
||||
.g-people-d .g-people-dgrid .main > :nth-child(2) { animation-delay: .05s; }
|
||||
.g-people-d .g-people-dgrid .rail > :nth-child(1) { animation-delay: .04s; }
|
||||
.g-people-d .g-people-dgrid .rail > :nth-child(2) { animation-delay: .08s; }
|
||||
.g-people-d .g-people-dgrid .rail > :nth-child(3) { animation-delay: .12s; }
|
||||
.g-people-d .g-people-danger { animation-delay: .14s; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.g-people .g-prole-edit { animation: none; }
|
||||
.g-people .g-mrow, .g-people .g-mrow .x, .g-people .g-mava, .g-people .g-prole { transition: none; }
|
||||
.g-people-d .g-mhero, .g-people-d .g-people-dgrid .main > *, .g-people-d .g-people-dgrid .rail > *, .g-people-d .g-people-danger { animation: none; }
|
||||
.g-people-d .g-role-sw, .g-people-d .g-role-sw i { transition: none; }
|
||||
}
|
||||
.g-fcard .mbtn { flex: none; width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--ink-4); cursor: pointer; }
|
||||
.g-fcard .mbtn:hover, .g-fcard .mbtn.on { border-color: var(--cline); background: #f3f6fa; color: var(--ink); }
|
||||
.g-fcard .mbtn.bad:hover { border-color: #e6c4c4; background: #fdf2f2; color: var(--danger); }
|
||||
|
||||
@@ -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,34 +81,56 @@ 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="身份">
|
||||
<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.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 ? (
|
||||
@@ -144,36 +170,50 @@ export function MemberPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card ico="shield-check" title="能进哪几个端" extra={<span className="x">由下面的角色 × 「权限」页签的配置算出来</span>}>
|
||||
<div className="g-chiprow">
|
||||
<EndChips ends={u.ends} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 角色 = 一行一角色的开关清单(2026-08-27 第二轮):读得清每个角色给什么,拨一下就授收 */}
|
||||
<Card ico="key" title="角色" extra={<span className="x">授收都穿透 Keycloak;收权当场踢掉他的会话</span>}>
|
||||
<QueryGate query={roles}>
|
||||
<div className="g-chiprow">
|
||||
<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
|
||||
key={r.name}
|
||||
className={'g-btn' + (has ? ' primary' : '')}
|
||||
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.join(' / ')})`
|
||||
: `授予「${r.name}」(可进:${r.ends.length === 0 ? '无端' : r.ends.map((e) => SURFACE_LABEL[e]).join(' / ')})`
|
||||
}
|
||||
>
|
||||
{(has ? '✓ ' : '+ ') + r.name}
|
||||
<i />
|
||||
</Btn>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</QueryGate>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
@@ -205,50 +245,73 @@ export function MemberPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
</div>
|
||||
<Note>服务端不回显、不留痕 —— 这一下按完,这串字就只在你和他之间。</Note>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card ico="power" title="启停与删除">
|
||||
<Acts
|
||||
items={[
|
||||
{
|
||||
label: u.enabled ? '停用' : '启用',
|
||||
ico: 'power',
|
||||
danger: u.enabled,
|
||||
busy: setEnabled.isPending,
|
||||
ask: {
|
||||
{/* 危险区(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>
|
||||
<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: '立刻',
|
||||
},
|
||||
impact: { who: u.displayName, undo: '可以再改回来', when: '立刻' },
|
||||
okLabel: u.enabled ? '确认停用' : '确认启用',
|
||||
onOk: () => setEnabled.mutate(!u.enabled),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
ico: 'trash-2',
|
||||
danger: true,
|
||||
busy: drop.isPending,
|
||||
ask: {
|
||||
}}
|
||||
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: '按下即生效',
|
||||
},
|
||||
impact: { who: u.displayName, undo: '不能撤销 —— 从 Keycloak 消失,再建一个是新的 sub', when: '按下即生效' },
|
||||
okLabel: '确认删除',
|
||||
onOk: () => drop.mutate(),
|
||||
},
|
||||
},
|
||||
]}
|
||||
}}
|
||||
danger
|
||||
onCancel={() => setAsk(null)}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</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)}
|
||||
/>
|
||||
</span>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
</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,6 +32,7 @@ export function OrgsTab() {
|
||||
const loaded = groups.data !== undefined;
|
||||
|
||||
return (
|
||||
<div className="g-people">
|
||||
<div className="g-split">
|
||||
<Card ico="network" title="组织树" extra={<Chip tone={loaded ? 'brand' : 'mute'}>{loaded ? `${tops} 顶层组` : '数着…'}</Chip>}>
|
||||
<Note>Keycloak 组 · 点一行看它的成员与操作</Note>
|
||||
@@ -53,19 +56,23 @@ export function OrgsTab() {
|
||||
</div>
|
||||
)}
|
||||
</QueryGate>
|
||||
<GroupCreate label="建顶层组" hint="组名(同级不得重名)" />
|
||||
{/* 建组收成卡底一条(2026-08-27 改版):不再是沉在卡里的表单块 */}
|
||||
<GroupCreate bar label="建顶层组" hint="组名(同级不得重名)" />
|
||||
</Card>
|
||||
|
||||
{current === undefined ? (
|
||||
<Card ico="users" title="成员">
|
||||
<Clear>
|
||||
/* 空态(2026-08-27 改版):一句说明 + 顺手能办的事,不再是一条突兀的色带 */
|
||||
<div className="g-people-empty">
|
||||
<Ico name="network" s={22} />
|
||||
<p>
|
||||
{!loaded
|
||||
? '组织树还在取…'
|
||||
: rows.length === 0
|
||||
? '还没有组 —— 先在左侧「建顶层组」,这里看它的成员与操作。'
|
||||
? '还没有组 —— 先建一个顶层组,这里看它的成员与操作。'
|
||||
: '在左侧点一个组,这里看它的成员与操作。'}
|
||||
</Clear>
|
||||
</Card>
|
||||
</p>
|
||||
{loaded && rows.length === 0 && <GroupCreate bar label="建顶层组" hint="组名(同级不得重名)" />}
|
||||
</div>
|
||||
) : (
|
||||
<GroupDetail
|
||||
key={current.id}
|
||||
@@ -74,11 +81,12 @@ export function OrgsTab() {
|
||||
/>
|
||||
)}
|
||||
</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,15 +219,17 @@ 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={
|
||||
<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}
|
||||
@@ -216,11 +237,9 @@ function GroupDetail({
|
||||
>
|
||||
移出
|
||||
</Btn>
|
||||
}
|
||||
onClick={() => undefined}
|
||||
/>
|
||||
</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,6 +37,7 @@ export function PermsTab() {
|
||||
const rows = roles.data?.roles ?? [];
|
||||
|
||||
return (
|
||||
<div className="g-people">
|
||||
<Card
|
||||
ico="shield-check"
|
||||
title="角色 → 能进哪几个端"
|
||||
@@ -50,7 +52,8 @@ export function PermsTab() {
|
||||
{rows.length === 0 ? (
|
||||
<Clear>realm 里还没有业务角色 —— 去 Keycloak 建(家具角色不摆在这儿)。</Clear>
|
||||
) : (
|
||||
<div className="g-rows">
|
||||
/* 一行一角色(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)} />
|
||||
))}
|
||||
@@ -58,6 +61,7 @@ export function PermsTab() {
|
||||
)}
|
||||
</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>
|
||||
<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>}
|
||||
<span className="x">{`${role.holders} 人持有`}</span>
|
||||
<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,18 +172,9 @@ 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
|
||||
? [
|
||||
items={[
|
||||
{ label: '取消', onClick: () => { setDraft(undefined); setErr(undefined); } },
|
||||
{
|
||||
label: '保存',
|
||||
@@ -151,34 +193,10 @@ function RoleRow({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,10 +27,12 @@ import type {
|
||||
ProcessPackage,
|
||||
GovLedgerByActivityOutput,
|
||||
} from '@contracts';
|
||||
import type { ApprovalTemplate } from '@contracts';
|
||||
import type { ApprovalTemplate, ActivityStatus, InstanceState } from '@contracts';
|
||||
import {
|
||||
browseLedgerRoute,
|
||||
ledgerByActivityRoute,
|
||||
getOpsActivityRoute,
|
||||
getOpsInstanceRoute,
|
||||
govMetricsRoute,
|
||||
govTaskLogRoute,
|
||||
govWorkflowRoute,
|
||||
@@ -44,6 +46,8 @@ import {
|
||||
listLedgerObjectsRoute,
|
||||
listLlmInstancesRoute,
|
||||
listLlmProvidersRoute,
|
||||
listOpsActivitiesRoute,
|
||||
listOpsInstancesRoute,
|
||||
listProcessPackagesRoute,
|
||||
getOrgUserRoute,
|
||||
listOrgGroupMembersRoute,
|
||||
@@ -55,6 +59,7 @@ import {
|
||||
listToolsRoute,
|
||||
listTriggersRoute,
|
||||
listWorkGroupsRoute,
|
||||
opsLaunchParamsRoute,
|
||||
validateProcessRoute,
|
||||
} from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
@@ -259,3 +264,83 @@ export const useOrgGroupMembers = (groupId: string | undefined): UseQueryResult<
|
||||
/** 角色 → 端 —— 这张表是**本地**的(ADR-012),不是 Keycloak 的字段 */
|
||||
export const useRoleAccess = (): UseQueryResult<GovRoleAccess> =>
|
||||
useQuery({ queryKey: ['gov', 'org', 'role-access'], queryFn: () => call<GovRoleAccess>(listRoleAccessRoute) });
|
||||
|
||||
/* ── 活动模块(运营中心 · 活动区,契约见 docs/API文档-活动模块.md)──────────────── */
|
||||
|
||||
/**
|
||||
* 查询键统一挂在 `['gov', 'activities', …]` 下 —— 写路由(启动/禁用/激活/终止)成功后
|
||||
* 服务端广播 `{kind:'system', id:'gov'}` 会失效整个 `['gov']` 前缀,自动重取;
|
||||
* 前端 mutation 落地时也按这个前缀失效(不等服务端那一下)。
|
||||
*/
|
||||
export const ACTIVITY_OPS_KEYS = ['gov', 'activities'] as const;
|
||||
|
||||
export type OpsActivities = RouteOutput<typeof listOpsActivitiesRoute>;
|
||||
export type OpsActivity = RouteOutput<typeof getOpsActivityRoute>;
|
||||
export type OpsInstances = RouteOutput<typeof listOpsInstancesRoute>;
|
||||
export type OpsLaunchParams = RouteOutput<typeof opsLaunchParamsRoute>;
|
||||
export type OpsInstance = RouteOutput<typeof getOpsInstanceRoute>;
|
||||
|
||||
/** L1 清单:两个页签(激活中 / 已禁用)各取一份 */
|
||||
export const useOpsActivities = (status: ActivityStatus): UseQueryResult<OpsActivities> =>
|
||||
useQuery({
|
||||
queryKey: [...ACTIVITY_OPS_KEYS, 'list', status],
|
||||
queryFn: () => call<OpsActivities>(listOpsActivitiesRoute, { query: { status } }),
|
||||
});
|
||||
|
||||
/** L2 活动定义 */
|
||||
export const useOpsActivity = (activityId: string): UseQueryResult<OpsActivity> =>
|
||||
useQuery({
|
||||
queryKey: [...ACTIVITY_OPS_KEYS, 'activity', activityId],
|
||||
queryFn: () => call<OpsActivity>(getOpsActivityRoute, { params: { activityId } }),
|
||||
});
|
||||
|
||||
/** 实例清单的检索条件(2026-08-26 用户定的四条件);值 undefined 的键不发 */
|
||||
export type OpsInstanceQuery = {
|
||||
id?: string | undefined;
|
||||
ownerId?: string | undefined;
|
||||
state?: InstanceState | undefined;
|
||||
startedFrom?: string | undefined;
|
||||
startedTo?: string | undefined;
|
||||
/** 0 起;发出去时 +1(服务端 page 从 1 起) */
|
||||
page: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
/** L2 实例清单:检索与分页全在服务端,前端不再本地过滤 */
|
||||
export const useOpsInstances = (activityId: string, q: OpsInstanceQuery): UseQueryResult<OpsInstances> =>
|
||||
useQuery({
|
||||
queryKey: [...ACTIVITY_OPS_KEYS, 'instances', activityId, q],
|
||||
queryFn: () =>
|
||||
call<OpsInstances>(listOpsInstancesRoute, {
|
||||
params: { activityId },
|
||||
query: {
|
||||
id: q.id,
|
||||
ownerId: q.ownerId,
|
||||
state: q.state,
|
||||
startedFrom: q.startedFrom,
|
||||
startedTo: q.startedTo,
|
||||
page: String(q.page + 1),
|
||||
size: String(q.size),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** 启动参数表:开抽屉那一刻才取(`enabled` 由调用方把着) */
|
||||
export const useOpsLaunchParams = (activityId: string, enabled: boolean): UseQueryResult<OpsLaunchParams> =>
|
||||
useQuery({
|
||||
queryKey: [...ACTIVITY_OPS_KEYS, 'launch-params', activityId],
|
||||
queryFn: () => call<OpsLaunchParams>(opsLaunchParamsRoute, { params: { activityId } }),
|
||||
enabled,
|
||||
});
|
||||
|
||||
/** L3 实例详情 —— 7 秒轮询拿最新(2026-08-28 用户定):只在做工时轮(待执行/进行中),
|
||||
进了终态(完成/驳回/失败 —— 手动终止也归 failed)数据不再变,轮询自动停 */
|
||||
export const useOpsInstance = (activityId: string, instanceId: string): UseQueryResult<OpsInstance> =>
|
||||
useQuery({
|
||||
queryKey: [...ACTIVITY_OPS_KEYS, 'instance', activityId, instanceId],
|
||||
queryFn: () => call<OpsInstance>(getOpsInstanceRoute, { params: { activityId, instanceId } }),
|
||||
refetchInterval: (q) => {
|
||||
const s = q.state.data?.state;
|
||||
return s === 'pending' || s === 'running' ? 7000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,7 +94,10 @@ L1(模块首页 · 活动清单)需要一个**标签页**切换,因为活
|
||||
- 2026-08-26 · **二次确认的规格(用户定)**:活动模块的确认走**轻气泡**(锚在触发钮下方的小泡泡,只问一句 + 确认/取消,点罩即取消)—— 不摆影响三必答;三必答(AskBand)是治理台重度动作的规格,别处的确认带暂不动。
|
||||
- 2026-08-27 · **「启动活动」按钮接功能(用户定)**:L2 头部「启动活动」不再占位 —— 点击弹**参数抽屉**(复用全局 `g-drawer` 右抽屉骨架),填启动参数后启动。**参数表由服务端按活动下发**(参数名 / 中文名 / 控件种类 / 必填 / 描述),每个活动要的参数不一样,前端只按规格渲染不写死字段;控件种类已支持单行 / 多行 / 单选 / 字符串数组(可传多个,如候选 PN)。接口未落地前用 `LAUNCH_PARAMS` 占位表(询价接收与结构化 = 入参说明的极简四参),提交先在历史活动清单头落一条「待执行」演示全链路,接口对接后换启动 mutation。**已禁用的活动不能启动**(钮置灰,先激活再启动)。
|
||||
- 2026-08-27 · **禁用规则重定(用户定,废除 08-25 的「有实例不能禁用」)**:**任何时刻都可以禁用活动** —— 禁用后只拦「新启动」(启动接口返回 412);已完成的实例是历史留痕、进行中的实例继续跑完,都不受影响;激活即恢复。前端占位的拦截逻辑(有实例弹「无法禁用」提示条)已随此删除;接口口径见 `docs/API文档-活动模块.md` §0/§8。
|
||||
- 2026-08-27 · **启动参数的控件口径(用户定)**:启动表单**大部分是自由输入框**;客户 / 料号这类「台账里可能有」的参数用 **combo(选择+新增)** —— 先摆选择框,候选末项「新增」选了换输入框自己填。**填了库里没有的值不拦启动**:启动后服务端做存在性检查,查不到就派**数据工单**到工作台补档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。前端已加 `combo` 控件类型;接口口径见 `docs/API文档-活动模块.md` §4「combo 语义」/§5。
|
||||
- 2026-08-27 · **启动参数的控件口径(用户定)**:启动表单**大部分是自由输入框**;客户 / 料号这类「台账里可能有」的参数用 **combo(选择+新增)** —— 先摆选择框,「新增」固定候选**第一项**(2026-08-28 用户定,从末项提上来),选了换输入框自己填。**填了库里没有的值不拦启动**:启动后服务端做存在性检查,查不到就派**数据工单**到工作台补档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。前端已加 `combo` 控件类型;接口口径见 `docs/API文档-活动模块.md` §4「combo 语义」/§5。
|
||||
- 2026-08-27 · **启动固定参数「责任人」(用户定)**:每次启动必须指定这次实例的责任人(负责人验收、数据工单都落到他身上)—— 它是**启动契约的固定字段 `ownerId`**,不进按活动下发的参数表。抽屉里已加「固定参数」段:人员下拉来自员工名册真接口(`GET /api/gov/staff`,只列在职),默认当前登录人(标注「我自己」);接口口径见 `docs/API文档-活动模块.md` §5。
|
||||
- 2026-08-28 · **轻气泡确认的反馈闭环(用户报修)**:禁用/激活/终止实例确认后气泡**不再立即消失** —— 请求在途留场转忙碌(两钮禁用、罩不可点),成功自动关(列表/详情由失效重取刷新,状态翻转有弹出高亮),失败把接口原因留在气泡里可重试。此前确认即关 + mutation 无 onError,失败时全哑,点了不知道成没成。
|
||||
- 2026-08-28 · **L3 实例详情 7 秒轮询(用户定)**:`useOpsInstance` 加 `refetchInterval` —— 待执行/进行中每 7 秒重取一次;进终态(完成/驳回/失败)自动停,不白打接口。
|
||||
|
||||
## 三、功能表(整理稿)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user