From 7f32a7e45034459077d091434bce49abe3fc11e9 Mon Sep 17 00:00:00 2001 From: shanqin <45168502@qq.com> Date: Thu, 27 Aug 2026 22:52:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E6=A8=A1=E5=9D=97=E6=8E=A5?= =?UTF-8?q?=E7=9C=9F=E6=8E=A5=E5=8F=A3=20+=20=E5=91=98=E5=B7=A5=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E7=95=8C=E9=9D=A2=E6=94=B9=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contracts 补建 activity-ops(schema + 11 条路由);L1/L2/L3 全真接口对接(清单/详情/实例/启动抽屉/沙箱) - 启动抽屉:参数表服务端下发、幂等键防重、400 逐格报错;combo「新增」提为候选第一项 - 终止/禁用/激活轻气泡反馈闭环:在途留场转忙碌、成功自闭合并刷新、失败原因留气泡可重试 - L3 实例详情 7 秒轮询(待执行/进行中),终态自动停 - 员工模块四页改版:目录行+首字圆块、权限三槽点位、组织树/空态、成员详情 hero+角色开关;卡内容区内边距补齐 --- apps/web/src/app/styles/console.css | 164 +++ .../gov-staff/internal/member-page.tsx | 377 +++--- .../features/gov-staff/internal/members.tsx | 99 +- .../src/features/gov-staff/internal/orgs.tsx | 147 ++- .../src/features/gov-staff/internal/perms.tsx | 194 +-- .../src/features/gov-task/internal/panel.tsx | 1077 +++++++++-------- apps/web/src/shared/gov/gov-api.ts | 87 +- docs/API文档-活动模块.md | 587 ++++++--- docs/活动模块重构需求-2026-08-25.md | 4 +- packages/contracts/src/activity-ops.ts | 206 ++++ packages/contracts/src/api/activity-ops.ts | 147 +++ packages/contracts/src/api/index.ts | 1 + packages/contracts/src/index.ts | 1 + 13 files changed, 2061 insertions(+), 1030 deletions(-) create mode 100644 packages/contracts/src/activity-ops.ts create mode 100644 packages/contracts/src/api/activity-ops.ts diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 34fa5a5..7cfab01 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -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); } diff --git a/apps/web/src/features/gov-staff/internal/member-page.tsx b/apps/web/src/features/gov-staff/internal/member-page.tsx index 7f26b98..e486422 100644 --- a/apps/web/src/features/gov-staff/internal/member-page.tsx +++ b/apps/web/src/features/gov-staff/internal/member-page.tsx @@ -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(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 ? ( {`Keycloak 里没有这个人(${id})—— 可能刚被删了。`} ) : ( - <> - - {u.username} - + /* 人物主页式重排(2026-08-27 第二轮,用户定):hero 让「这个人」当主角; + 主从 2:1(身份/角色 | 端/组/口令);危险区两条动作行收底 */ +
+ {/* hero:大首字圆块 + 姓名/用户名/状态 + 可进端 + 两条动态事实(几时来过/岗位) */} +
+ + {u.displayName.trim().charAt(0).toUpperCase() || '·'} + +
+ 成员 · 成员详情 +

+ {u.displayName} + {/* key 带状态:启停翻转时状态丸弹出一次高亮 */} + {u.enabled ? '在职' : '已停用'} - - } - desc="身份与角色的真源是 Keycloak —— 这一屏的每个动作都直接改那边,没有撤销。" - /> +

+

+ @{u.username} + · + 身份与角色的真源是 Keycloak —— 这一屏的每个动作都直接改那边,没有撤销。 +

+ + + +
+
+ + 几时来过 + {u.lastSeenAt ?? '还没访问过'} + + + 本地岗位 + {u.positionCode ?? '未分配'} + +
+
{err !== undefined && {err}} - - - {editing === undefined ? ( - - setEditing({ firstName: u.firstName ?? '', lastName: u.lastName ?? '', email: u.email ?? '' }), - }, - ]} - /> - ) : ( -
-
改资料(用户名建后不可改)
-
- - setEditing({ ...editing, lastName: e.target.value })} /> - - - setEditing({ ...editing, firstName: e.target.value })} /> - - - setEditing({ ...editing, email: e.target.value })} /> - +
+
+ 改资料中}> + {/* 只摆档案事实;姓名/岗位/几时来过已上 hero,不重复摆 */} + + {editing === undefined ? ( 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 ?? '' }), + }, ]} /> -
-
- )} - + ) : ( +
+
改资料(用户名建后不可改)
+
+ + setEditing({ ...editing, lastName: e.target.value })} /> + + + setEditing({ ...editing, firstName: e.target.value })} /> + + + setEditing({ ...editing, email: e.target.value })} /> + + setEditing(undefined) }, + { label: '保存', ico: 'check', primary: true, busy: patch.isPending, onClick: () => patch.mutate(editing) }, + ]} + /> +
+
+ )} + - 由下面的角色 × 「权限」页签的配置算出来}> -
- + {/* 角色 = 一行一角色的开关清单(2026-08-27 第二轮):读得清每个角色给什么,拨一下就授收 */} + 授收都穿透 Keycloak;收权当场踢掉他的会话}> + +
+ {(roles.data?.roles ?? []).map((r) => { + const has = u.roles.includes(r.name); + return ( +
+ + {r.name} + {r.description !== undefined && {r.description}} + + + {r.ends.length === 0 ? '无端可进' : r.ends.map((e) => SURFACE_LABEL[e]).join(' / ')} + + 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(' / ')})` + } + > + + +
+ ); + })} +
+
+
-
- 授收都穿透 Keycloak;收权当场踢掉他的会话}> - -
- {(roles.data?.roles ?? []).map((r) => { - const has = u.roles.includes(r.name); - return ( - setRole.mutate({ role: r.name, granted: !has })} - title={ - has - ? `收掉「${r.name}」—— 他手上的会话会被当场踢掉` - : `授予「${r.name}」(可进:${r.ends.length === 0 ? '无端' : r.ends.join(' / ')})` - } - > - {(has ? '✓ ' : '+ ') + r.name} - - ); - })} -
-
-
+
+ 角色 × 「权限」页签算出}> +
+ +
+
- - {u.groups.length === 0 ? ( - 不属于任何组 —— 进出组在「组织」页签里做。 - ) : ( -
- {u.groups.map((g) => ( - - {g.path} - - ))} -
- )} -
+ + {u.groups.length === 0 ? ( + 不属于任何组 —— 进出组在「组织」页签里做。 + ) : ( +
+ {u.groups.map((g) => ( + + {g.path} + + ))} +
+ )} +
- 一律临时口令 —— 本人首登必须改}> -
- setPwd(e.target.value)} - /> - reset.mutate()}> - 重置口令 + 一律临时口令 —— 本人首登必须改}> +
+ setPwd(e.target.value)} + /> + reset.mutate()}> + 重置口令 + +
+ 服务端不回显、不留痕 —— 这一下按完,这串字就只在你和他之间。 +
+
+
+ + {/* 危险区(2026-08-27 第二轮):两条动作行 —— 每行说清这个动作是什么、会怎样,钮在行右 */} +
+
+ + 启停与删除 +
+
+ + {u.enabled ? '停用此人' : '启用此人'} + + {u.enabled + ? '停用当场踢掉他的全部会话 —— 手上那枚 token 立刻失效,不用等过期;可以再启用。' + : '启用之后他就能重新登录。'} + + + setAsk({ kind: 'enable' })} + > + + {u.enabled ? '停用' : '启用'}
- 服务端不回显、不留痕 —— 这一下按完,这串字就只在你和他之间。 - - - - 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(), - }, - }, - ]} - /> - - +
+ + 删除此人 + 不能撤销 —— 从 Keycloak 消失,再建一个是新的 sub;本地名册留痕不删,在途任务与台账不受影响。 + + setAsk({ kind: 'drop' })}> + + 删除 + +
+ {ask?.kind === 'enable' && ( + setEnabled.mutate(!u.enabled), + }} + danger={u.enabled} + onCancel={() => setAsk(null)} + /> + )} + {ask?.kind === 'drop' && ( + drop.mutate(), + }} + danger + onCancel={() => setAsk(null)} + /> + )} +
+
)} ); diff --git a/apps/web/src/features/gov-staff/internal/members.tsx b/apps/web/src/features/gov-staff/internal/members.tsx index 0f8c372..64260b0 100644 --- a/apps/web/src/features/gov-staff/internal/members.tsx +++ b/apps/web/src/features/gov-staff/internal/members.tsx @@ -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 ( - <> +
+ extra={{counted ? `${rows.length} 人 · ${active} 在职` : '数着…'}} + > + {/* 工具条独立成行(2026-08-27 改版):不再与卡片标题挤 —— 左检索,右筛选与主操作 */} +
+ + setQ(e.target.value)} /> - - {counted ? `${rows.length} 人 · ${active} 在职` : '数着…'} - setCreating((v) => !v)}> - {creating ? '收起' : '新建成员'} - - } - > + + setCreating((v) => !v)}> + {creating ? '收起' : '新建成员'} + +
真源是 Keycloak · 停用的人立刻登不上;在途任务不受影响(需手动改派)。服务账号不在这份名单里 —— 它是身份不是人。 @@ -77,31 +87,56 @@ export function MembersTab({ nav }: { nav: Nav }) { {rows.length === 0 ? ( {q === '' ? '一个人都没有 —— 去 Keycloak 建人,或用「新建成员」。' : `没有匹配「${q}」的成员。`} ) : ( - + /* 目录行(2026-08-27 改版,用户定):表头与数据同一条栅格,列标签不再悬空 */ +
+
+ + 姓名 + 邮箱 + 角色 + 可进端 + 状态 + +
{rows.map((u) => ( - }, - ]} - right={ + ))} - +
)}
- +
); } diff --git a/apps/web/src/features/gov-staff/internal/orgs.tsx b/apps/web/src/features/gov-staff/internal/orgs.tsx index 16262ff..e469fd2 100644 --- a/apps/web/src/features/gov-staff/internal/orgs.tsx +++ b/apps/web/src/features/gov-staff/internal/orgs.tsx @@ -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 ( -
- {loaded ? `${tops} 顶层组` : '数着…'}}> - Keycloak 组 · 点一行看它的成员与操作 - - {rows.length === 0 ? ( - 还没有组 —— 先在下面「建顶层组」。 - ) : ( -
- {rows.map((g) => ( - - ))} -
- )} -
- -
- - {current === undefined ? ( - - - {!loaded - ? '组织树还在取…' - : rows.length === 0 - ? '还没有组 —— 先在左侧「建顶层组」,这里看它的成员与操作。' - : '在左侧点一个组,这里看它的成员与操作。'} - +
+
+ {loaded ? `${tops} 顶层组` : '数着…'}}> + Keycloak 组 · 点一行看它的成员与操作 + + {rows.length === 0 ? ( + 还没有组 —— 先在下面「建顶层组」。 + ) : ( +
+ {rows.map((g) => ( + + ))} +
+ )} +
+ {/* 建组收成卡底一条(2026-08-27 改版):不再是沉在卡里的表单块 */} +
- ) : ( - setPicked(undefined)} - /> - )} + + {current === undefined ? ( + /* 空态(2026-08-27 改版):一句说明 + 顺手能办的事,不再是一条突兀的色带 */ +
+ +

+ {!loaded + ? '组织树还在取…' + : rows.length === 0 + ? '还没有组 —— 先建一个顶层组,这里看它的成员与操作。' + : '在左侧点一个组,这里看它的成员与操作。'} +

+ {loaded && rows.length === 0 && } +
+ ) : ( + setPicked(undefined)} + /> + )} +
); } -/** 建组 —— `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(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 ( +
+ setName(e.target.value)} /> + create.mutate()}> + 建组 + + {err !== undefined && {err}} +
+ ); + } return (
{label}
@@ -200,27 +219,27 @@ function GroupDetail({ {inGroup.length === 0 ? ( 这个组还没有人 —— 在下面「把人加进来」。 ) : ( - + /* 组成员行与成员页同构(2026-08-27 改版):首字圆块 + 姓名/用户名 + 行内移出 */ +
{inGroup.map((m) => ( - setMember.mutate({ sub: m.sub, member: false })} - > - 移出 - - } - onClick={() => undefined} - /> +
+ + {m.displayName.trim().charAt(0).toUpperCase() || '·'} + + + {m.displayName} + {m.username} + + setMember.mutate({ sub: m.sub, member: false })} + > + 移出 + +
))} - +
)} diff --git a/apps/web/src/features/gov-staff/internal/perms.tsx b/apps/web/src/features/gov-staff/internal/perms.tsx index 600ca81..3fb8dcd 100644 --- a/apps/web/src/features/gov-staff/internal/perms.tsx +++ b/apps/web/src/features/gov-staff/internal/perms.tsx @@ -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 ( - {roles.data === undefined ? '数着…' : `${rows.length} 个角色`}} - > - - 角色来自 Keycloak(realm roles),能进哪几个端由这里说了算 —— 每个请求都按它判, - 不合就回 403。改完至少要有人还能进运营中心,否则这张表自己就锁死了。 - - - {rows.length === 0 ? ( - realm 里还没有业务角色 —— 去 Keycloak 建(家具角色不摆在这儿)。 - ) : ( -
- {rows.map((r) => ( - x.role === r.name)} /> - ))} -
- )} -
-
+
+ {roles.data === undefined ? '数着…' : `${rows.length} 个角色`}} + > + + 角色来自 Keycloak(realm roles),能进哪几个端由这里说了算 —— 每个请求都按它判, + 不合就回 403。改完至少要有人还能进运营中心,否则这张表自己就锁死了。 + + + {rows.length === 0 ? ( + realm 里还没有业务角色 —— 去 Keycloak 建(家具角色不摆在这儿)。 + ) : ( + /* 一行一角色(2026-08-27 改版):身份 | 可进端 | 操作一条栅格;描述收成灰字,不再整幅色带 */ +
+ {rows.map((r) => ( + x.role === r.name)} /> + ))} +
+ )} +
+
+
); } @@ -71,6 +75,8 @@ function RoleRow({ const qc = useQueryClient(); const [draft, setDraft] = useState(undefined); const [err, setErr] = useState(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 ( -
-
- {role.name} - {role.composite && 组合角色} - {`${role.holders} 人持有`} +
+
+ + {role.name} + {role.composite && 组合角色} + {`${role.holders} 人持有`} + + + {/* 可进端改三槽点位(2026-08-27 第二轮):三个端永远都在,授了的亮、没授的虚灰 —— 列向下扫即读 */} + + {SURFACES.map((s) => ( + + {SURFACE_LABEL[s]} + + ))} + {!configured && 还没配过} + + {role.description !== undefined && {role.description}} + + + {!editing && ( + <> + { setDraft(role.ends); setErr(undefined); }}> + 改可进端 + + {configured && ( + setAskDel(true)}> + 删掉这一行 + + )} + + )} +
-
- {role.description !== undefined && {role.description}} - {editing ? ( + {err !== undefined && {err}} + {askDel && ( + remove.mutate(), + }} + danger + onCancel={() => setAskDel(false)} + /> + )} + {editing && ( +
{SURFACES.map((s) => (
- ) : ( -
- - {!configured && 还没配过} -
- )} - {err !== undefined && {err}} - { 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), - }, + { 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(), - }, - }, - ] - : []), - ] - } - /> -
+ okLabel: '确认改', + onOk: () => save.mutate(shown), + }, + }, + ]} + /> +
+ )}
); } diff --git a/apps/web/src/features/gov-task/internal/panel.tsx b/apps/web/src/features/gov-task/internal/panel.tsx index 83455b2..83cbf81 100644 --- a/apps/web/src/features/gov-task/internal/panel.tsx +++ b/apps/web/src/features/gov-task/internal/panel.tsx @@ -5,23 +5,33 @@ * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 * 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。 */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { + ACTIVITY_STATUS_LABEL, + ACTIVITY_VIEW_HEIGHT_MESSAGE, CONTROL_SEMANTIC_LABEL, + INSTANCE_STATE_LABEL, + INSTANCE_STATES, TASK_RUNTIME_STATE_LABEL, TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, + activateOpsActivityRoute, + authTicketRoute, controlTaskRoute, + disableOpsActivityRoute, + launchOpsInstanceRoute, meRoute, reassignTaskRoute, resumeTaskRoute, + terminateOpsInstanceRoute, } from '@contracts'; -import type { ControlSemantic, Principal } from '@contracts'; +import type { ControlSemantic, InstanceState, LaunchInput, Principal } from '@contracts'; +import type { InstanceFile, InstanceStep, LaunchParam } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; -import { call } from '@web-shared/typed-fetch.ts'; -import { Ava, Card, Chip, Clear, Entry, Facts, NList, NRow, Note, PageHead, Sec } from '@web-shared/gov/primitives.tsx'; +import { ApiCallError, call } from '@web-shared/typed-fetch.ts'; +import { Ava, Card, Chip, Clear, Entry, Facts, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx'; import type { ActSpec } from '@web-shared/gov/primitives.tsx'; import { RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts'; @@ -29,7 +39,19 @@ import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx'; import { atFull, atMinute } from '@web-shared/at.ts'; import { Btn } from '@web-shared/Btn.tsx'; import { queryKeys } from '@web-shared/query-keys.ts'; -import { useGovStaff, useGovTaskLog, useGovTasks, useGovWorkflow } from '@web-shared/gov/gov-api.ts'; +import { + ACTIVITY_OPS_KEYS, + useGovStaff, + useGovTaskLog, + useGovTasks, + useGovWorkflow, + useOpsActivities, + useOpsActivity, + useOpsInstance, + useOpsInstances, + useOpsLaunchParams, +} from '@web-shared/gov/gov-api.ts'; +import type { OpsInstance, OpsInstanceQuery } from '@web-shared/gov/gov-api.ts'; export type HomeProps = { tab: string; nav: Nav }; @@ -130,137 +152,77 @@ export function WorkflowBoard({ nav }: { nav: Nav }) { * 尾部幽灵卡 = 创建活动入口(创建功能本期不做,纯占位)。 * L2(活动详情 + 实例清单)、L3(实例详情)随后接。 */ -type DemoActivity = { - id: string; - name: string; - biz: string; - instances: number; - owner: string; - createdAt: string; - /** 运营态:激活中 / 已禁用(2026-08-25 用户定)—— L2 里可改;发布/审核是设计部分,暂缓 */ - status: 'active' | 'disabled'; - /** 当前版本号 —— 活动无编辑/删除,演进只能靠发新版本(2026-08-25 用户定) */ - version?: string; - /** 语义图标与业务色相 —— 色即业务,一卡一色(2026-08-25 视觉增强) */ - ico: string; - hue: 'blue' | 'teal' | 'amber' | 'violet'; - /** 这个活动绑定的 LLM —— 每个活动一个专属 agent,负责把流程跑通(2026-08-25 用户定:L2 要展示) */ - llm: string; -}; - -/** 占位静态数据 —— 接口设计定稿后整体换掉(2026-08-25 用户定:先出页面模版看效果) */ -const DEMO_ACTIVITIES: readonly DemoActivity[] = [ - { id: 'quote-intake', name: '询价接收与结构化', biz: '报价业务', instances: 12, owner: '柴浩', createdAt: '08-12', status: 'active', version: '1.3.0', ico: 'inbox', hue: 'blue', llm: 'K3 · quote-intake' }, - { id: 'quote-pricing', name: '报价定价测算', biz: '报价业务', instances: 8, owner: '丁子乔', createdAt: '08-12', status: 'active', version: '1.2.0', ico: 'calculator', hue: 'blue', llm: 'K3 · quote-pricing' }, - { id: 'crm-coverage', name: '关键人触达计划', biz: 'CRM 业务', instances: 5, owner: '冯兵', createdAt: '08-15', status: 'active', version: '1.0.0', ico: 'target', hue: 'teal', llm: 'K3 · crm-coverage' }, - { id: 'competitor-watch', name: '竞品价格监测', biz: '报价业务', instances: 2, owner: '柴浩', createdAt: '08-24', status: 'active', version: '1.1.0', ico: 'eye', hue: 'blue', llm: 'K3 · competitor-watch' }, - { id: 'market-weekly', name: '市场信号周报生成', biz: '市场业务', instances: 3, owner: '林佩萱', createdAt: '08-18', status: 'disabled', version: '2.1.0', ico: 'trending-up', hue: 'amber', llm: 'K3 · market-weekly' }, - { id: 'supplier-verify', name: '供应商资质核验', biz: '采购业务', instances: 1, owner: '丁子乔', createdAt: '08-25', status: 'disabled', version: '1.0.0', ico: 'shield-check', hue: 'violet', llm: 'K3 · supplier-verify' }, -]; - function RuntimeHome({ tab, nav }: HomeProps) { - const items = DEMO_ACTIVITIES.filter((a) => a.status === tab); + /* 接口对接(2026-08-27):静态占位数组撤了,清单来自 GET /api/gov/activities(docs/API文档-活动模块.md §2.1) */ + const q = useOpsActivities(tab === 'disabled' ? 'disabled' : 'active'); + const items = q.data?.activities ?? []; return (
-
- {items.map((a) => ( -
- {/* 整卡热区 → L2 活动详情(2026-08-25 接) */} -
- {/* 输入/输出契约不上卡(2026-08-25 用户定):它是 L2 详情的内容,卡上只留识别信息 */} -
- - {a.instances} - 实例 - - - {a.owner} - 负责人 - - - {a.createdAt} - 创建于 - - {/* 版本进底板第四格(2026-08-25 用户定):与实例/负责人/创建并列 */} - - {a.version === undefined ? '—' : `v${a.version}`} - 版本 - -
-
- ))} - {/* - 幽灵卡 = 创建入口 —— 2026-08-25 用户定:设计功能暂缓,先做运营部分, - 创建活动的幽灵卡先注释掉;恢复时把这段 JSX 解开即可(样式 .g-fcard.ghost 保留着) -
- - 创建活动 + ))} + {items.length === 0 && 这个页签下还没有活动。}
- */} -
+
); } -/* ==================== 活动详情(L2,2026-08-25 重构模版) ==================== */ +/* ==================== 活动详情(L2,2026-08-25 重构模版;2026-08-27 接真接口) ==================== */ -/** 实例状态五态(2026-08-25 用户定)—— 进行中蓝 / 待执行琥珀 / 已完成绿 / 驳回红 / 失败灰 */ -type InstanceState = '进行中' | '待执行' | '已完成' | '驳回' | '失败'; +/** 实例五态的色调(2026-08-25 用户定)—— 进行中蓝 / 待执行琥珀 / 已完成绿 / 驳回红 / 失败灰;状态码与中文见契约 INSTANCE_STATE_LABEL */ const INSTANCE_ST: Readonly> = { - 进行中: 'brand', - 待执行: 'warn', - 已完成: 'ok', - 驳回: 'danger', - 失败: 'mute', + running: 'brand', + pending: 'warn', + completed: 'ok', + rejected: 'danger', + failed: 'mute', }; -type DemoInstance = { - id: string; - input: string; - state: InstanceState; - owner: string; - startedAt: string; - output: string; -}; - -/** 占位实例清单 —— 每个活动先共用同一份样子,接口定稿后按活动取(2026-08-25 模版) */ -const demoInstances = (owner: string): readonly DemoInstance[] => [ - { id: 'INS-2417', input: 'Itelma 车身控制器询价 · 3 项物料', state: '进行中', owner, startedAt: '08-25 10:12', output: '—' }, - { id: 'INS-2416', input: '伏尔加重卡灯组询价 · 12 项物料', state: '待执行', owner, startedAt: '08-25 09:40', output: '—' }, - { id: 'INS-2415', input: 'GAZ 底盘线束询价 · 7 项物料', state: '已完成', owner, startedAt: '08-24 17:03', output: 'quote-20260824-07.json' }, - { id: 'INS-2414', input: 'KAMAZ 传感器询价 · 5 项物料', state: '已完成', owner, startedAt: '08-24 14:26', output: 'quote-20260824-03.json' }, - { id: 'INS-2413', input: 'UAZ 控制模块询价 · 4 项物料', state: '驳回', owner, startedAt: '08-23 16:51', output: '—' }, - { id: 'INS-2412', input: 'Lada 车机询价 · 9 项物料', state: '失败', owner, startedAt: '08-23 11:05', output: '—' }, - { id: 'INS-2411', input: 'Itelma 座椅电机询价 · 6 项物料', state: '已完成', owner, startedAt: '08-22 18:22', output: 'quote-20260822-11.json' }, - { id: 'INS-2410', input: 'GAZ 门锁执行器询价 · 2 项物料', state: '已完成', owner, startedAt: '08-22 15:47', output: 'quote-20260822-06.json' }, - { id: 'INS-2409', input: 'KAMAZ 变速箱询价 · 8 项物料', state: '驳回', owner, startedAt: '08-22 10:19', output: '—' }, - { id: 'INS-2408', input: '伏尔加仪表集群询价 · 5 项物料', state: '已完成', owner, startedAt: '08-21 16:33', output: 'quote-20260821-09.json' }, - { id: 'INS-2407', input: 'UAZ 分动箱询价 · 3 项物料', state: '失败', owner, startedAt: '08-21 13:58', output: '—' }, - { id: 'INS-2406', input: 'Lada 悬挂件询价 · 11 项物料', state: '已完成', owner, startedAt: '08-21 09:24', output: 'quote-20260821-02.json' }, - { id: 'INS-2405', input: 'Itelma 雨刮电机询价 · 1 项物料', state: '已完成', owner, startedAt: '08-20 17:41', output: 'quote-20260820-08.json' }, -]; - /** 实例清单单页上限(2026-08-25 用户定:6 → 8,6 张时页面与卡内留白太多) */ const INSTANCE_PAGE_SIZE = 8; /* ── 历史活动检索(2026-08-26 用户定:列表就要能搜)────────────────────── 条件下拉四选:实例(号,模糊)/ 负责人(人员下拉)/ 状态(五态)/ 启动时间(日期段); - 条件一换,右边的控件跟着换 —— 一个条件一个趁手的组件 */ + 条件一换,右边的控件跟着换 —— 一个条件一个趁手的组件; + 2026-08-27 接接口:过滤与分页全部在服务端(§3),前端只拼查询参数 */ type InstanceFilterKey = 'id' | 'owner' | 'state' | 'time'; @@ -271,44 +233,43 @@ const INSTANCE_FILTER_LABEL: Readonly> = { time: '启动时间', }; -/** 占位数据的启动时间是 `MM-DD HH:mm`(2026 年),转成可比较的 Date;接口定稿后随真数据格式一起换 */ -function instanceTime(at: string): number { - const m = /^(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(at); - if (m === null) return 0; - return new Date(2026, Number(m[1]) - 1, Number(m[2]), Number(m[3]), Number(m[4])).getTime(); -} - -/** 日期段 [from, to](`YYYY-MM-DD`,含两端整日)—— 空端不卡那一边 */ -function inTimeRange(at: string, from: string, to: string): boolean { - const t = instanceTime(at); - if (from !== '' && t < new Date(`${from}T00:00:00`).getTime()) return false; - if (to !== '' && t > new Date(`${to}T23:59:59`).getTime()) return false; - return true; -} - /** 轻量二次确认的规格(2026-08-26 用户定:只问一句 + 确认/取消 —— 影响三必答(AskBand)是治理台重度动作的规格,禁用活动/终止实例是轻动作,不摆) */ type PopAskSpec = { title: string; okLabel: string; danger?: boolean; onOk: () => void }; -/** 气泡式二次确认 —— 锚在页头右下的小泡泡:一句话问清,确认/取消两钮;点气泡外的透明罩即取消 */ -function PopAsk({ spec, onCancel }: { spec: PopAskSpec; onCancel: () => void }) { +/** 接口报错取人话(与启动抽屉同一取法):有 message 摆 message,没有兜底一句 */ +const errText = (e: unknown): string => (e as { message?: string } | null)?.message ?? '网络错误,请稍后重试'; + +/** 气泡式二次确认 —— 锚在页头右下的小泡泡:一句话问清,确认/取消两钮;点气泡外的透明罩即取消; + `inrow` = 锚在行内操作格(终止实例用,接口文档 §10),定位由 CSS 的 .inrow 变体给。 + 2026-08-28 反馈闭环(用户报:终止点了不知道成没成):确认后气泡**不再立即消失** —— + 在途时留场转忙碌(两钮禁用、罩不可点),成功由调用方 onSuccess 关掉,失败把原因留在气泡里可重试 */ +function PopAsk({ spec, onCancel, inrow, busy, error }: { spec: PopAskSpec; onCancel: () => void; inrow?: boolean; busy?: boolean; error?: string | null }) { return ( <> -
-
+
+

{spec.title}

+ {error != null && error !== '' && ( +

+ {error} +

+ )}
-
@@ -316,75 +277,19 @@ function PopAsk({ spec, onCancel }: { spec: PopAskSpec; onCancel: () => void }) ); } -/* ── 启动活动 · 参数抽屉(2026-08-27 用户定)──────────────────────────── - 点「启动活动」弹抽屉填启动参数:**参数表由服务端按活动下发**(参数名/中文名/控件种类/必填/描述), - 每个活动要的参数不一样 —— 前端只按规格渲染,不写死字段; +/* ── 启动活动 · 参数抽屉(2026-08-27 用户定;当日接上真接口)──────────────────── + 点「启动活动」弹抽屉填启动参数:**参数表由服务端按活动下发**(GET …/launch-params, + docs/API文档-活动模块.md §4),每个活动要的参数不一样 —— 前端只按规格渲染,不写死字段; 控件口径(2026-08-27 用户定):大部分是自由输入;客户/料号这类「台账里可能有」的用 combo (选择+新增:先选,候选没有就新增自填)—— **填了库里没有的值不拦启动**,存在性检查在启动后做,缺口派数据工单; - 接口未落地前用 LAUNCH_PARAMS 占位,提交先在清单头落一条「待执行」看全链路效果 */ - -/** 启动参数的一项规格 —— 将来就是接口返回的参数表行 */ -type LaunchParam = { - /** 提交给接口的字段名 */ - key: string; - /** 表单上的中文名 */ - label: string; - /** 控件种类:text 单行 / textarea 多行 / select 单选(只能选) / combo 选择+新增(候选没有就自己填) / list 字符串数组(一次可传多个) */ - type: 'text' | 'textarea' | 'select' | 'combo' | 'list'; - required?: boolean; - /** 参数描述(服务端给),挂在控件下当辅助说明 */ - desc?: string; - placeholder?: string; - /** select / combo 的候选项 */ - options?: readonly string[]; -}; - -/** 占位参数表 —— 一活动一份,接口定稿后按活动取(2026-08-27 用户定:参数由服务器给,每个活动不一样) */ -const LAUNCH_PARAMS: Readonly> = { - /* 极简四参(2026-08-26《一键启动入参说明》口径:客户/需求/原PN/候选PN数组,其余启动后进工单补); - 客户与料号用 combo(2026-08-27 用户定):台账里有就先选,没有就新增自填 —— 不拦启动,缺口派数据工单 */ - 'quote-intake': [ - { key: 'customer_id', label: '客户', type: 'combo', required: true, desc: '台账里有就直接选;是新客户就自己填,启动后派数据工单补档案。', options: ['Itelma', '伏尔加', 'GAZ', 'KAMAZ', 'UAZ', 'Lada'] }, - { key: 'request_description', label: '客户需求', type: 'textarea', required: true, desc: '保留原始需求语义,据此新建需求主记录(V1)。', placeholder: '如:车身控制器询价 · 3 项物料,期望 9 月交期' }, - { key: 'original_internal_pn', label: '原内部料号', type: 'combo', required: true, desc: '差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。', options: ['PN-880101', 'PN-880205', 'PN-880310'], placeholder: '如 PN-880501' }, - { key: 'candidate_internal_pn', label: '候选内部料号', type: 'list', required: true, desc: '被评估物料,一次可传多个、逐个评估;库里没有的 PN 照样填,至少 1 个。', placeholder: '如 PN-880412' }, - ], - 'quote-pricing': [ - { key: 'quote_id', label: '询价单号', type: 'text', required: true, desc: '针对哪一张询价单做测算。', placeholder: '如 INS-2415' }, - { key: 'strategy', label: '计价策略', type: 'select', required: true, desc: '测算走哪套成本模型。', options: ['成本加成', '市场竞争', '历史跟随'] }, - { key: 'target_margin', label: '目标毛利率', type: 'text', desc: '选填,留空按客户线默认。', placeholder: '如 18%' }, - ], - 'crm-coverage': [ - { key: 'customer_id', label: '客户', type: 'combo', required: true, desc: '为哪家客户排触达计划;新客户直接填,启动后补档案。', options: ['Itelma', '伏尔加', 'GAZ', 'KAMAZ', 'UAZ', 'Lada'] }, - { key: 'cadence', label: '触达节奏', type: 'select', required: true, desc: '关键人触达的排期密度。', options: ['每周', '双周', '每月'] }, - { key: 'note', label: '补充说明', type: 'textarea', desc: '选填。' }, - ], - 'competitor-watch': [ - { key: 'rival_pn', label: '竞品型号', type: 'text', required: true, desc: '监测哪个竞品料号的价格动向。', placeholder: '如 Bosch-0261S04' }, - { key: 'window', label: '监测窗口', type: 'select', required: true, desc: '往回拉多长时间的价格数据。', options: ['7 天', '30 天', '90 天'] }, - ], - 'market-weekly': [ - { key: 'region', label: '覆盖区域', type: 'text', required: true, desc: '周报覆盖的市场区域。', placeholder: '如 东欧 · 商用车' }, - { key: 'window', label: '数据窗口', type: 'select', required: true, desc: '汇总哪个时间跨度的信号。', options: ['自然周', '自然月'] }, - ], - 'supplier-verify': [ - { key: 'supplier', label: '供应商名称', type: 'text', required: true, desc: '核验哪家供应商的资质。', placeholder: '如 深圳某某电子有限公司' }, - { key: 'note', label: '资质说明', type: 'textarea', desc: '选填 —— 已有的资质线索先记下来,核验时对照。' }, - ], -}; - -/** 没在表里占位的活动给一份最小通用参数 —— 接口落地后不会有这条路 */ -const LAUNCH_PARAMS_FALLBACK: readonly LaunchParam[] = [ - { key: 'subject', label: '启动事项', type: 'text', required: true, desc: '这次启动要处理什么,一句话。' }, - { key: 'note', label: '补充说明', type: 'textarea', desc: '选填。' }, -]; + 占位参数表 LAUNCH_PARAMS 已随接口对接撤掉(2026-08-27) */ type LaunchValues = Record; -/** 参数值初始形:list 先摆一行空行(至少一行),其余空串 */ +/** 参数值初始形:list 先摆一行空行(至少一行),其余空串;服务端给了 default 就预填 */ function initLaunchValues(spec: readonly LaunchParam[]): LaunchValues { const v: LaunchValues = {}; - for (const p of spec) v[p.key] = p.type === 'list' ? [''] : ''; + for (const p of spec) v[p.key] = p.default !== undefined ? p.default : p.type === 'list' ? [''] : ''; return v; } @@ -397,7 +302,7 @@ function paramFilled(p: LaunchParam, v: string | string[]): boolean { const COMBO_CREATE = '__create__'; /** - * 选择+新增(2026-08-27 用户定):先摆选择框,候选末项是「新增 …」; + * 选择+新增(2026-08-27 用户定):先摆选择框,「新增 …」是候选**第一项**(2026-08-28 用户定,从末项提上来); * 选了它就换成输入框自己填(库里没有的值也收 —— 不拦启动,缺口由服务端派数据工单), * 旁边留「返回选择」换回去。 */ @@ -406,7 +311,7 @@ function ComboInput({ p, value, onChange }: { p: LaunchParam; value: string; onC if (custom) { return ( - onChange(e.target.value)} /> + onChange(e.target.value)} /> + + )} {spec.map((p) => (