Archived
- contracts 补建 activity-ops(schema + 11 条路由);L1/L2/L3 全真接口对接(清单/详情/实例/启动抽屉/沙箱) - 启动抽屉:参数表服务端下发、幂等键防重、400 逐格报错;combo「新增」提为候选第一项 - 终止/禁用/激活轻气泡反馈闭环:在途留场转忙碌、成功自闭合并刷新、失败原因留气泡可重试 - L3 实例详情 7 秒轮询(待执行/进行中),终态自动停 - 员工模块四页改版:目录行+首字圆块、权限三槽点位、组织树/空态、成员详情 hero+角色开关;卡内容区内边距补齐
This commit is contained in:
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
+427
-160
@@ -1,102 +1,303 @@
|
||||
# API 文档 —— 活动模块(L1 清单 / L2 活动详情 / L3 实例详情)
|
||||
|
||||
> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,
|
||||
> 给后端编写接口、前端取数用。鉴权 / 票据 / 错误体 / 端归属通则:`docs/API文档-鉴权与人员.md` §0,本文不重复。
|
||||
> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,给前端取数、后端维护用。
|
||||
> 鉴权 / 票据 / 错误体 / 端归属的**通则**在 `docs/API文档-鉴权与人员.md` §0,本文 §0.1 只摘调用时必须知道的几条。
|
||||
> 需求出处:`docs/活动模块重构需求-2026-08-25.md`;启动参数口径:`ACT-WORK-05-一键启动入参说明.md`(极简四参)。
|
||||
> 契约落地后唯一真相在 `packages/contracts/src/api/*.ts`(zod schema,前后端共用);两者不一致时以契约为准并回来改本文。
|
||||
> **契约已落地(2026-08-27)**:`packages/contracts/src/activity-ops.ts`(数据结构 zod)+ `packages/contracts/src/api/activity-ops.ts`
|
||||
> (11 条路由,花名册 `ACTIVITY_OPS_ROUTES`);前端一律 `import { xxxRoute } from '@contracts'` 经 `shared/typed-fetch` 的 `call(route, { params, query, body })` 调用,
|
||||
> 类型直接用 `@contracts` 导出的(§1 每张表都标了导出名)。两者不一致时以契约为准并回来改本文。
|
||||
> 设计端的活动库两条路由已改名让位:`GET/POST /api/gov/activities` → `/api/gov/activity-packages`(前端 import 路由对象,不用改)。
|
||||
|
||||
## 0. 通则(本文特有)
|
||||
## 0. 通则
|
||||
|
||||
### 0.1 调用约定
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 前缀 | `/api/gov/activities/*`;**全部 `access: 'ops'`** —— 活动区落在运营中心,与工作流/任务看板同端 |
|
||||
| 时间 | 一律 ISO 8601 UTC 字符串(`2026-08-27T02:25:11.000Z`);**展示格式化是前端的事**(`YYYY-MM-DD HH:mm:ss` 本地时区) |
|
||||
| 人员 | 凡出人,都是 `{ "id": "<staffId>", "name": "柴浩" }` 两段 —— 展示用 name,筛选/提交用 id;人员候选名单用既有 `GET /api/gov/staff`,本文不另做 |
|
||||
| 广播 | 写路由(启动 / 禁用 / 激活 / 终止)成功后广播 `{kind:'system', id:'gov'}` —— 前端按既有套路失效 `['gov',*]` 重取 |
|
||||
| 实例状态 | 五态,英文码:`pending`(待执行)/ `running`(进行中)/ `completed`(已完成)/ `rejected`(驳回)/ `failed`(失败);**没有第六态** —— 手动终止归入 `failed`,用 `reason.kind='terminated'` 区分「自己跑挂」(见 §6) |
|
||||
| 禁用语义 | **任何时刻都可禁用**(2026-08-27 用户重定,废除「有实例不能禁用」):禁用只拦「新启动」—— 已完成的实例是历史留痕,进行中的实例**继续跑完不受影响**;激活即恢复启动能力 |
|
||||
| 前缀 | `/api/gov/activities/*`;**11 条全部 `access: 'ops'`** —— 登录人的 `ends` 里要有 `ops`,否则 403 |
|
||||
| 请求头 | `Authorization: Bearer <Keycloak access_token>`;写请求 `content-type: application/json` |
|
||||
| 票据 | 只有两条路带不了请求头(`<iframe src>` / `<a href download>`):§2.3 视图、§7 文件。GET 时改带 `?ticket=<票据>`(取自 `GET /api/auth/ticket`,**60 秒有效,用的那一刻再取**) |
|
||||
| 响应 | 除 §2.3(`text/html`)与 §7 `?raw=1`(文件字节)外,全部 `application/json` |
|
||||
| 时间 | 一律 ISO 8601 UTC 字符串(`2026-08-27T02:25:11.000Z`);**展示格式化是前端的事**(`YYYY-MM-DD HH:mm:ss` 本地时区,用 `shared/at.ts`) |
|
||||
| 人员 | 凡出人,都是 `StaffRef = { id, name }` —— 展示用 `name`,筛选 / 提交用 `id`(= 名册 staffId);候选名单用既有 `GET /api/gov/staff` |
|
||||
| 实时刷新 | 写路由(启动 / 禁用 / 激活 / 终止)成功后服务端广播 `{kind:'system', id:'gov'}`,前端既有 SSE 订阅会失效 `['gov', …]` 缓存 —— 查询键放在 `['gov', 'activities', …]` 下就自动重取 |
|
||||
| 分页 | 只有 §3 实例清单分页(`page` 从 1 起,`size` 默认 8、上限 50);其余不分页 |
|
||||
|
||||
状态码怎么读(通则之外本文特有的):
|
||||
### 0.2 错误体
|
||||
|
||||
| 状态 | code | 何时 |
|
||||
所有非 2xx 都是同一形状:
|
||||
|
||||
```json
|
||||
{ "code": "validation-failed", "message": "启动参数不合要求:candidateInternalPns 必填,至少一项", "detail": [ { "key": "candidateInternalPns", "message": "必填,至少一项" } ] }
|
||||
```
|
||||
|
||||
| 状态 | `code` | 何时 | `detail` |
|
||||
|---|---|---|---|
|
||||
| 400 | `validation-failed` | 查询参数 / 路径参数 / 请求体不合 schema;启动参数缺必填 / 类型不对 / select 值不在候选 / 不认识的参数名 / 责任人不在职 | schema 不合 → zod issues 数组;启动参数问题 → `[{ key, message }]`,`key` 是哪一格(责任人那一条 `key='ownerId'`) |
|
||||
| 401 | `unauthenticated` | 没带 token / 过期 / 票据坏了 | — |
|
||||
| 403 | `forbidden` | 登录人进不了运营中心 | `{ need, ends }` |
|
||||
| 404 | `not-found` | 活动不存在;实例不存在或不属于这个活动;活动没配视图;文件序号不存在 | — |
|
||||
| 409 | `conflict` | 启动:同一 `idempotencyKey` 的实例**正在创建**(60 秒内再来);或这枚键已用在别的活动上 | — |
|
||||
| 412 | `precondition-failed` | 启动:活动已禁用;终止:实例已终态 | — |
|
||||
| 500 | `internal` | 服务端出错 | — |
|
||||
|
||||
### 0.3 状态口径
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 活动状态 `ActivityStatus` | `active`(激活中)/ `disabled`(已禁用)。中文在 `@contracts` 的 `ACTIVITY_STATUS_LABEL` |
|
||||
| 实例状态 `InstanceState` | 五态英文码:`pending`(待执行)/ `running`(进行中)/ `completed`(已完成)/ `rejected`(驳回)/ `failed`(失败);中文在 `INSTANCE_STATE_LABEL`。**没有第六态** —— 手动终止归 `failed`,靠 `reason.kind='terminated'` 区分「自己跑挂」。口径:`pending` = 建了单、推进器还没领到;`running` = 在途(含等人回传 / 监听中);`completed` = 放行完成;`rejected` = 验收被负责人打回、模型还没重新提交的那一段(重新提交后回 `running`);`failed` = 跑挂(受阻 / 停滞熔断 / 预算耗尽)与手动终止 |
|
||||
| 禁用语义 | **任何时刻都可禁用**:只拦「新启动」(§5 回 412);已完成的实例是历史留痕,进行中的**继续跑完**;激活即恢复 |
|
||||
| 活动档案 | 负责人 / 说明 / 图标 / 色相来自运营侧档案表,不在活动包里;**没建档时 `owner` 是 `null`、`desc` 是空串、`ico`/`hue` 缺席**,前端摆「—」并兜底图标色 |
|
||||
|
||||
## 1. 数据结构(`@contracts` 导出名在括号里)
|
||||
|
||||
字段列「必有」= 响应里一定带这个键(可能是 `null` 的会单独写);「可缺」= 键可能不出现。
|
||||
|
||||
### 1.1 `StaffRef`(`staffRefSchema`)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| 400 | `validation-failed` | 入参不合 schema(`detail` 是 zod issues);启动参数缺必填 / 类型不对(`detail` 指明是哪个 `key`) |
|
||||
| 404 | `not-found` | 活动 / 实例不存在;实例不属于这个活动 |
|
||||
| 412 | `precondition-failed` | 启动:活动已禁用;终止:实例不在 `running` |
|
||||
| `id` | string | 名册 staffId |
|
||||
| `name` | string | 姓名 |
|
||||
|
||||
## 1. L1 活动清单
|
||||
### 1.2 `ActivitySummary`(`activitySummarySchema`)—— L1 卡片
|
||||
|
||||
### `GET /api/gov/activities?status=active|disabled` —— access: `ops`
|
||||
| 字段 | 类型 | 必有 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | string | 是 | 活动 id,如 `part-substitution-assessment`;后面所有路径里的 `:activityId` |
|
||||
| `name` | string | 是 | 活动名 |
|
||||
| `biz` | string | 是 | 业务分类(卡片副题) |
|
||||
| `status` | `ActivityStatus` | 是 | `active` / `disabled` |
|
||||
| `version` | string | 是 | 当前生效的包版本,如 `1.5.1` |
|
||||
| `owner` | `StaffRef` \| **null** | 是 | 活动负责人;没建档 = `null`,摆「—」 |
|
||||
| `llm` | string | 是 | 绑定的模型(逻辑名,如 `deepseek-v4-flash`) |
|
||||
| `instanceCount` | number | 是 | 该活动的实例总数(含测试台试跑单) |
|
||||
| `createdAt` | string(ISO) | 是 | 首个版本装入的时刻 |
|
||||
| `ico` | string | 可缺 | 语义图标名;缺省前端兜底 `inbox` |
|
||||
| `hue` | `'blue' \| 'teal' \| 'amber' \| 'violet'` | 可缺 | 业务色相;缺省前端兜底 `blue` |
|
||||
|
||||
L1 两个页签(激活中 / 已禁用)各取一份;不带 `status` = 全量。量小,**不分页**。
|
||||
### 1.3 `ActivityDetail`(`activityDetailSchema`)—— L2 头部
|
||||
|
||||
`ActivitySummary` **去掉 `instanceCount`**,加:
|
||||
|
||||
| 字段 | 类型 | 必有 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `desc` | string | 是 | 定义说明;没写就是 `''` |
|
||||
| `view` | `{ url: string }` \| **null** | 是 | 沙箱要渲染的活动视图地址(相对路径,见 §2.3);`null` = 藏起整个沙箱区块 |
|
||||
|
||||
### 1.4 `InstanceSummary`(`instanceSummarySchema`)—— L2 表格一行
|
||||
|
||||
| 字段 | 类型 | 必有 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | string | 是 | 实例的**寻址键**(任务 id,uuid):L3 路由、终止、文件都用它 |
|
||||
| `no` | string | 是 | 人读实例号 `INS-<序号>`(表格上摆它、模糊搜也认它) |
|
||||
| `state` | `InstanceState` | 是 | 五态 |
|
||||
| `owner` | `StaffRef` | 是 | 责任人 |
|
||||
| `input` | string | 是 | 输入一句话摘要(≤80 字,可为 `''`) |
|
||||
| `output` | string \| **null** | 是 | 产出一句话摘要;只有 `completed` 才有值(文件名,或「已发口:<口名>」);`null` 摆 `-` |
|
||||
| `startedAt` | string(ISO) | 是 | 启动时刻 |
|
||||
|
||||
### 1.5 `InstanceStats` / `InstanceList`(`instanceStatsSchema` / `instanceListSchema`)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `stats.total` | number | 该活动实例总数(**不随检索条件动**,页头统计带用) |
|
||||
| `stats.byState` | `Record<InstanceState, number>` | 五个键**都在**(没有的是 0) |
|
||||
| `total` | number | **筛后**条数,分页器按它 |
|
||||
| `page` | number | 当前页(回显请求值,默认 1) |
|
||||
| `size` | number | 单页条数(回显请求值,默认 8) |
|
||||
| `items` | `InstanceSummary[]` | 当前页,启动时间倒序 |
|
||||
|
||||
### 1.6 `LaunchParam` / `LaunchParams`(`launchParamSchema` / `launchParamsSchema`)—— 启动表单规格
|
||||
|
||||
`LaunchParams`:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `activityId` | string | |
|
||||
| `version` | string | 参数表对应的包版本 |
|
||||
| `params` | `LaunchParam[]` | **按这个顺序渲染表单** |
|
||||
|
||||
`LaunchParam`:
|
||||
|
||||
| 字段 | 类型 | 必有 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `key` | string | 是 | 提交时 `params` 里的键名(英文) |
|
||||
| `label` | string | 是 | 表单上的中文名 |
|
||||
| `type` | `'text' \| 'textarea' \| 'select' \| 'combo' \| 'list'` | 是 | 控件种类:单行 / 多行 / 单选(只能选候选)/ **选择+新增**(候选末项「新增」,选了换输入框自填)/ 字符串数组(逐行自由填,可加多项)。**遇到不认识的 type 摆「暂不支持的控件」** |
|
||||
| `required` | boolean | 可缺 | 缺席 = 选填。前端没填完禁提交;服务端仍再验一道(400) |
|
||||
| `desc` | string | 可缺 | 控件下的辅助说明 |
|
||||
| `placeholder` | string | 可缺 | text / textarea / combo(新增态)/ list 用 |
|
||||
| `options` | `{ value: string; label: string }[]` | 可缺 | `select` / `combo` 的候选,两段式:`value` 是提交值,`label` 是显示名。**combo 可能没有这个键**(候选读不到),那时就是自由输入 |
|
||||
| `default` | string \| string[] | 可缺 | 预填值:`list` 给字符串数组,其余给字符串 |
|
||||
|
||||
### 1.7 `LaunchInput`(`launchInputSchema`)—— 启动请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `idempotencyKey` | string(1–128) | 是 | 幂等键:**每开一次抽屉生成一枚**(`crypto.randomUUID()`);重复点提交、网络重试都带同一枚 |
|
||||
| `ownerId` | string | 是 | 责任人 staffId(固定字段,不在参数表里);默认当前登录人 `me.staff.id`,允许改;只列 `GET /api/gov/staff` 里 `state='active'` 的 |
|
||||
| `params` | `Record<string, string \| string[]>` | 是 | 键 = `LaunchParam.key`;`list` 传 `string[]`,其余传 `string`;`select` 传某个 `options[].value`;`combo` 传候选的 `value` **或**新增的原样字符串(两种都合法);选填没填可以不传或传 `''` |
|
||||
|
||||
### 1.8 `InstanceFile`(`instanceFileSchema`)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `index` | number | 在同一侧(input / output)文件里的序号,从 0 起;§7 下载用 |
|
||||
| `name` | string | 文件名(含扩展名) |
|
||||
| `contentType` | string | MIME |
|
||||
| `size` | number | 字节数 |
|
||||
|
||||
### 1.9 `InstanceReason`(`instanceReasonSchema`)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `kind` | `'rejected' \| 'failed' \| 'terminated'` | 驳回 / 自己跑挂 / 手动终止 |
|
||||
| `text` | string | 人话原因,直接摆。驳回:「验收驳回:<负责人写的意见> —— 负责人 <名>」(没写意见时「验收驳回:负责人未写明具体意见,已退回重做」);失败:引擎原话(预算耗尽已翻成「预算耗尽:步数 90/90、活跃时长 120/120 分钟」);终止:「由 <操作人> 手动终止」 |
|
||||
|
||||
### 1.10 `InstanceSource`(`instanceSourceSchema`)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `kind` | string | `manual`(运营端手动启动)/ `upstream`(上游工作流节点)/ `mail` 等通道名(来件触发)/ `testbed`(测试台试跑)/ `system`(其余)。**开放枚举**,不认识的按 `label` 摆 |
|
||||
| `label` | string | 人读形式,直接摆:`手动启动 · 柴浩` / `上游 · <工作流主题>` / `邮件 · <发件方>` / `测试台试跑` / `系统起单` |
|
||||
|
||||
### 1.11 `InstanceStep`(`instanceStepSchema`)—— L3 时间线一行
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `seq` | number | 从 1 起,按活动案例模型的步骤序 |
|
||||
| `name` | string | 步骤名(活动定义的计划项名) |
|
||||
| `by` | `'agent' \| 'human'` | 这一步谁办:AI / 人 |
|
||||
| `byName` | string | `agent` → 模型名(同 `llm`);`human` → 责任人姓名 |
|
||||
| `at` | string(ISO) \| null | 开始时刻;没到这一步 = `null` |
|
||||
| `durationMs` | number \| null | 用时(毫秒);未完成 = `null`。格式化如 `6m` |
|
||||
| `res` | `'done' \| 'fail' \| 'current' \| 'pending'` | 已完成 / 出事(性质看 `reason.kind`:驳回 · 失败 · 已终止)/ 正在办 / 未到 |
|
||||
| `note` | string | 这一步交出什么的一句话;可为 `''` |
|
||||
|
||||
### 1.12 `InstanceDetail`(`instanceDetailSchema`)—— L3 整页
|
||||
|
||||
| 字段 | 类型 | 必有 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | string | 是 | 任务 id |
|
||||
| `no` | string | 是 | 人读实例号 |
|
||||
| `activityId` | string | 是 | |
|
||||
| `state` | `InstanceState` | 是 | |
|
||||
| `owner` | `StaffRef` | 是 | 责任人 |
|
||||
| `llm` | string | 是 | 模型名 |
|
||||
| `startedAt` | string(ISO) | 是 | |
|
||||
| `endedAt` | string(ISO) \| **null** | 是 | 未结束 = `null`;终止时 = 终止时刻 |
|
||||
| `durationMs` | number \| **null** | 是 | `endedAt − startedAt`;未结束 = `null` |
|
||||
| `reason` | `InstanceReason` \| **null** | 是 | `null` = 不需要(待执行 / 进行中 / 正常完成) |
|
||||
| `input.content` | string | 是 | 输入一句话摘要(= 清单上的 `input`) |
|
||||
| `input.source` | `InstanceSource` | 是 | 来源 |
|
||||
| `input.submittedAt` | string(ISO) | 是 | 提交时刻(= `startedAt`) |
|
||||
| `input.files` | `InstanceFile[]` | 是 | 输入文件元数据(可为空数组) |
|
||||
| `output.files` | `InstanceFile[]` | 是 | 产出文件元数据;没产出是 `[]`,摆「暂无产出」 |
|
||||
| `steps` | `InstanceStep[]` | 是 | 时间线 |
|
||||
|
||||
注意:详情里**没有** `input`/`output` 那两句摘要字符串(它们是清单行的字段),`input`/`output` 在详情里是上面的结构体。
|
||||
|
||||
## 2. L1 / L2 活动
|
||||
|
||||
### 2.1 活动清单 —— `GET /api/gov/activities`(`listOpsActivitiesRoute`)
|
||||
|
||||
**请求**
|
||||
|
||||
| 位置 | 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| query | `status` | `'active' \| 'disabled'` | 否 | L1 两个页签各取一份;不带 = 全量。别的值 400 |
|
||||
|
||||
**响应 200**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `activities` | `ActivitySummary[]` | 量小不分页;顺序 = 活动库顺序 |
|
||||
|
||||
```json
|
||||
{
|
||||
"activities": [
|
||||
{
|
||||
"id": "quote-intake",
|
||||
"name": "询价接收与结构化",
|
||||
"biz": "报价业务",
|
||||
"id": "part-substitution-assessment",
|
||||
"name": "ACT-WORK-05 · 差异评估",
|
||||
"biz": "需求分析与方案匹配",
|
||||
"status": "active",
|
||||
"version": "1.3.0",
|
||||
"owner": { "id": "8f3c…", "name": "柴浩" },
|
||||
"llm": "K3 · quote-intake",
|
||||
"version": "1.5.1",
|
||||
"owner": null,
|
||||
"llm": "deepseek-v4-flash",
|
||||
"instanceCount": 12,
|
||||
"createdAt": "2026-08-12T01:00:00.000Z",
|
||||
"ico": "inbox",
|
||||
"hue": "blue"
|
||||
"createdAt": "2026-08-26T01:00:00.000Z",
|
||||
"ico": "git-compare",
|
||||
"hue": "teal"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `ico` / `hue`:展示元数据(语义图标名 / 业务色相 `blue|teal|amber|violet`),创建活动时可配;**缺省时前端兜底**(`inbox` / `blue`),后端可以暂时不存。
|
||||
- `llm`:这个活动绑定的专属 agent 的人读标签;要链到 LLM 实例时再加 `llmId`,本期没有。
|
||||
### 2.2 活动详情 —— `GET /api/gov/activities/:activityId`(`getOpsActivityRoute`)
|
||||
|
||||
## 2. L2 活动定义
|
||||
**请求**:路径参数 `activityId`(string,必填)。
|
||||
|
||||
### `GET /api/gov/activities/:activityId` —— access: `ops`
|
||||
**响应 200**:`ActivityDetail`。**404**:活动不存在。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "quote-intake",
|
||||
"name": "询价接收与结构化",
|
||||
"biz": "报价业务",
|
||||
"id": "part-substitution-assessment",
|
||||
"name": "ACT-WORK-05 · 差异评估",
|
||||
"biz": "需求分析与方案匹配",
|
||||
"status": "active",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.1",
|
||||
"owner": { "id": "8f3c…", "name": "柴浩" },
|
||||
"llm": "K3 · quote-intake",
|
||||
"createdAt": "2026-08-12T01:00:00.000Z",
|
||||
"ico": "inbox",
|
||||
"hue": "blue",
|
||||
"desc": "活动定义只读 —— 输入与输出在创建时定死,演进走版本迭代;验收在工作台,这里只看状态。",
|
||||
"view": { "url": "/api/gov/activities/quote-intake/view" }
|
||||
"llm": "deepseek-v4-flash",
|
||||
"createdAt": "2026-08-26T01:00:00.000Z",
|
||||
"ico": "git-compare",
|
||||
"hue": "teal",
|
||||
"desc": "对候选料号逐项比较参数、判四态、定推荐等级,审核后落 P-11 替代关系并回写 P-01。",
|
||||
"view": { "url": "/api/gov/activities/part-substitution-assessment/view" }
|
||||
}
|
||||
```
|
||||
|
||||
- `desc`:定义说明,创建时填,只读。
|
||||
- `view`:沙箱要渲染的活动视图;**为 `null` 时前端藏起整个沙箱区块**(不再摆空 iframe)。
|
||||
### 2.3 活动视图(沙箱)—— `GET /api/gov/activities/:activityId/view`(`opsActivityViewRoute`)
|
||||
|
||||
### `GET /api/gov/activities/:activityId/view` —— access: `ops`(支持 `?ticket=`)
|
||||
**请求**
|
||||
|
||||
→ `content-type: text/html`,服务器托管的活动视图 HTML,前端 `<iframe sandbox="allow-scripts">` 直接渲染。
|
||||
iframe 带不了 Authorization 头,走票据(`GET /api/auth/ticket`,ADR-012 §6)—— 前端每次装载前取一张拼 `?ticket=`。
|
||||
**内容高度用 postMessage 通讯自适应(视图脚本向父页报高),协议随第一版视图实现一起定。**404:该活动没配视图。
|
||||
| 位置 | 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| path | `activityId` | string | 是 | |
|
||||
| query | `ticket` | string | 是(iframe 场景) | `<iframe src>` 带不了请求头:**装载那一刻** `call(authTicketRoute)` 取一张拼上,不要渲染时预取(60 秒过期) |
|
||||
|
||||
## 3. L2 实例清单(历史活动)
|
||||
**响应 200**:`content-type: text/html`(`cache-control: no-store`),整份 HTML,`<iframe sandbox="allow-scripts" src="...">` 直接渲染。
|
||||
**404**:活动不存在 / 该活动没配视图(`detail.view === null` 时压根别请求)。
|
||||
|
||||
### `GET /api/gov/activities/:activityId/instances` —— access: `ops`
|
||||
**高度协议**:页面自己会向父页 `postMessage({ type: 'jcai:view-height', height: <number> }, '*')`(load、尺寸变化、每秒兜底)。父页:
|
||||
|
||||
查询参数(可组合,AND;都不带 = 全部):
|
||||
```ts
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.source !== iframe.contentWindow) return; // 沙箱帧 origin 是 'null',按 source 认,不按 origin
|
||||
if (e.data?.type !== ACTIVITY_VIEW_HEIGHT_MESSAGE) return; // 常量在 @contracts,= 'jcai:view-height'
|
||||
setHeight(Number(e.data.height));
|
||||
});
|
||||
```
|
||||
|
||||
| 参数 | 形 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | 字符串 | 实例号**模糊**搜,如 `INS-24` |
|
||||
| `ownerId` | staffId | 精确到人 |
|
||||
| `state` | 五态之一 | 精确 |
|
||||
| `startedFrom` / `startedTo` | `YYYY-MM-DD` | 启动日期段,**含两端整日**;可只给一端 |
|
||||
| `page` | 整数,从 1 | 默认 1 |
|
||||
| `size` | 整数 | 默认 8(与前端单页一致),上限 50 |
|
||||
## 3. L2 实例清单 —— `GET /api/gov/activities/:activityId/instances`(`listOpsInstancesRoute`)
|
||||
|
||||
**请求**
|
||||
|
||||
| 位置 | 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| path | `activityId` | string | 是 | |
|
||||
| query | `id` | string | 否 | 实例号**模糊**搜,如 `INS-24`:对 `no` 与 `id` 的子串,不分大小写 |
|
||||
| query | `ownerId` | string(staffId) | 否 | 精确到人 |
|
||||
| query | `state` | `InstanceState` | 否 | 精确;别的值 400 |
|
||||
| query | `startedFrom` | `YYYY-MM-DD` | 否 | 启动日期段起点,**含整日**;格式不对 400 |
|
||||
| query | `startedTo` | `YYYY-MM-DD` | 否 | 终点,**含整日**;可只给一端 |
|
||||
| query | `page` | number ≥ 1 | 否 | 默认 1 |
|
||||
| query | `size` | number 1–50 | 否 | 默认 8(与前端单页一致);>50 → 400 |
|
||||
|
||||
条件之间 AND;都不带 = 全部。
|
||||
|
||||
**响应 200**:`InstanceList`。**404**:活动不存在。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -106,195 +307,226 @@ iframe 带不了 Authorization 头,走票据(`GET /api/auth/ticket`,ADR-012 §6)
|
||||
"size": 8,
|
||||
"items": [
|
||||
{
|
||||
"id": "INS-2417",
|
||||
"id": "0c9d3a1e-…",
|
||||
"no": "INS-2417",
|
||||
"state": "running",
|
||||
"owner": { "id": "8f3c…", "name": "柴浩" },
|
||||
"input": "Itelma 车身控制器询价 · 3 项物料",
|
||||
"input": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND…",
|
||||
"output": null,
|
||||
"startedAt": "2026-08-25T02:12:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "INS-2415",
|
||||
"id": "7b21e6c0-…",
|
||||
"no": "INS-2415",
|
||||
"state": "completed",
|
||||
"owner": { "id": "8f3c…", "name": "柴浩" },
|
||||
"input": "GAZ 底盘线束询价 · 7 项物料",
|
||||
"output": "quote-20260824-07.json",
|
||||
"input": "…",
|
||||
"output": "已发口:assessment-released",
|
||||
"startedAt": "2026-08-24T09:03:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **`stats` 是活动整体的五态统计,不随检索条件动**(页头统计带摆的是全局数);`total` 是**筛后**条数,分页器按它走。
|
||||
- `input` / `output` 都是**一句话摘要**;`output` 为 `null` 前端摆 `-`。排序:启动时间倒序(新的在前)。
|
||||
- 页头统计带摆 `stats`(全局),分页器按 `total`(筛后)走;筛中提示「筛出 `total` / 共 `stats.total`」。
|
||||
- 负责人下拉的候选用 `GET /api/gov/staff`(在职的),提交 `ownerId`。
|
||||
- 前端**不再**本地过滤 / 分页 / 数统计,全部上移到这里。
|
||||
|
||||
## 4. 启动参数表
|
||||
## 4. 启动参数表 —— `GET /api/gov/activities/:activityId/launch-params`(`opsLaunchParamsRoute`)
|
||||
|
||||
### `GET /api/gov/activities/:activityId/launch-params` —— access: `ops`
|
||||
点「启动活动」开抽屉时取。**一活动一份,前端只按 `params` 渲染表单,不写死字段。**
|
||||
|
||||
点「启动活动」开抽屉时取。**一活动一份,前端只按此表渲染表单,不写死字段。**
|
||||
**请求**:路径参数 `activityId`。**响应 200**:`LaunchParams`。**404**:活动不存在。
|
||||
|
||||
```json
|
||||
{
|
||||
"activityId": "quote-intake",
|
||||
"version": "1.3.0",
|
||||
"activityId": "part-substitution-assessment",
|
||||
"version": "1.5.1",
|
||||
"params": [
|
||||
{
|
||||
"key": "customer_id",
|
||||
"key": "customerCode",
|
||||
"label": "客户",
|
||||
"type": "combo",
|
||||
"required": true,
|
||||
"desc": "台账里有就直接选;是新客户就自己填,启动后派数据工单补档案。",
|
||||
"desc": "台账里有就直接选;是新客户就自己填客户编码,启动后派数据录入卡建档。",
|
||||
"placeholder": "CU-0142",
|
||||
"options": [
|
||||
{ "value": "cust-itelma", "label": "Itelma" },
|
||||
{ "value": "cust-gaz", "label": "GAZ" }
|
||||
{ "value": "CU-0142", "label": "Itelma" },
|
||||
{ "value": "CU-2039", "label": "青岛新星电子有限公司" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "request_description",
|
||||
"key": "customerName",
|
||||
"label": "客户名称(仅客户不在库时填)",
|
||||
"type": "text",
|
||||
"desc": "新客户先把名字摆上,启动后建档时让人核。",
|
||||
"placeholder": "深圳恒星电源有限公司"
|
||||
},
|
||||
{
|
||||
"key": "requestDescription",
|
||||
"label": "客户需求",
|
||||
"type": "textarea",
|
||||
"required": true,
|
||||
"desc": "保留原始需求语义,据此新建需求主记录(V1)。",
|
||||
"placeholder": "如:车身控制器询价 · 3 项物料,期望 9 月交期"
|
||||
"desc": "保留原始需求语义,据此新建 P-01 需求主记录(版本 V1)。",
|
||||
"placeholder": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料"
|
||||
},
|
||||
{
|
||||
"key": "original_internal_pn",
|
||||
"key": "originalInternalPn",
|
||||
"label": "原内部料号",
|
||||
"type": "combo",
|
||||
"required": true,
|
||||
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
|
||||
"placeholder": "如 PN-880501",
|
||||
"options": [
|
||||
{ "value": "PN-880101", "label": "PN-880101" }
|
||||
]
|
||||
"placeholder": "LM2596S-5.0",
|
||||
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }]
|
||||
},
|
||||
{
|
||||
"key": "candidate_internal_pn",
|
||||
"key": "candidateInternalPns",
|
||||
"label": "候选内部料号",
|
||||
"type": "list",
|
||||
"required": true,
|
||||
"desc": "被评估物料,一次可传多个、逐个评估 —— 至少 1 个。",
|
||||
"placeholder": "如 PN-880412"
|
||||
"desc": "被评估物料,一次可传多个、逐个评估 —— 至少 1 个;不在库的启动后经数据工单创建。",
|
||||
"placeholder": "XL2596S-5.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `key` | 提交时回传的字段名(接口契约,英文) |
|
||||
| `label` | 表单上的中文名 |
|
||||
| `type` | 控件种类:`text` 单行 / `textarea` 多行 / `select` 单选(只能选候选) / **`combo` 选择+新增**(候选末项是「新增」,选了换输入框自己填;见下方「combo 语义」) / `list` 字符串数组(一次可传多个,逐行自由填);**新增种类前要前后端一起升级**,前端遇到不认识的 type 摆「暂不支持的控件」 |
|
||||
| `required` | 必填;前端在没填完时禁提交,**服务端仍要再验一道**(400) |
|
||||
| `desc` | 参数描述,挂在控件下当辅助说明 |
|
||||
| `placeholder` | 选填,text / textarea / combo(新增态) / list 用 |
|
||||
| `options` | `select` / `combo` 必填;**两段式 `{ value, label }`** —— value 是提交的标识(如客户 ID),label 是显示名;标识即显示名时两段相同 |
|
||||
| `default` | 选填;预填值(`list` 给字符串数组,其余给字符串)—— 上游触发预填场景用 |
|
||||
**combo 语义(2026-08-27 用户定)**:启动表单大部分是自由输入;客户 / 料号这类「台账里可能有」的参数才用 combo —— 先选,候选里没有就新增自填。**提交候选之外的值是合法的,不拦启动**:服务端按值在台账里查,查不到照样启动,启动后派**数据工单**到工作台补这条档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。combo 提交的值统一是字符串:选中候选传它的 `value`,新增传用户填的原样字符串。
|
||||
|
||||
**combo 语义(2026-08-27 用户定)**:启动表单大部分是自由输入;客户 / 料号这类「台账里可能有」的参数才用 combo —— 先选,候选里没有就新增自填。**提交候选之外的值是合法的,不拦启动**:服务端按 value 在台账里查,查不到照样启动,启动后派**数据工单**到工作台补这条档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。combo 提交的值统一回字符串:选中候选回它的 `value`,新增回用户填的原样字符串 —— 服务端不靠类型区分新旧,靠台账存在性检查。
|
||||
候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口;**本期一律内联 `options`**(封顶 500 行)。
|
||||
|
||||
候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口,**本期一律内联 `options`**。
|
||||
> 活动清单侧怎么声明(前端不用管,给写活动包的人看):`properties.<key>` 上 `title`→label、`description`→desc、`examples[0]`→placeholder、`default`、`required[]`;`enum`+`enumNames`→select;`'x-control'` 定标量控件(`text|textarea|select|combo`);`'x-options': { ledger:'域.对象', value:'列', label:'列' }` 让候选从正式台账现读;`type:'array'` 且元素是字符串→list;**`'x-order': <数字>` 定表单次序**(清单存在 jsonb 里,书写顺序到不了这里);`format:'file'` 的字段本期不上表单;select 没候选降成 text。
|
||||
|
||||
## 5. 启动活动(创建实例)
|
||||
## 5. 启动活动(创建实例)—— `POST /api/gov/activities/:activityId/instances`(`launchOpsInstanceRoute`)
|
||||
|
||||
### `POST /api/gov/activities/:activityId/instances` —— access: `ops`
|
||||
**请求**:路径参数 `activityId`;请求体 `LaunchInput`(§1.7)。
|
||||
|
||||
```json
|
||||
{
|
||||
"idempotencyKey": "01J9…",
|
||||
"idempotencyKey": "b5a2d6f0-3e1c-4a7b-9d2e-8f1c0a4b7e93",
|
||||
"ownerId": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44",
|
||||
"params": {
|
||||
"customer_id": "cust-itelma",
|
||||
"request_description": "车身控制器询价 · 3 项物料,期望 9 月交期",
|
||||
"original_internal_pn": "PN-880101",
|
||||
"candidate_internal_pn": ["PN-880412", "PN-880530"]
|
||||
"customerCode": "CU-0142",
|
||||
"requestDescription": "工业电源项目在用 LM2596S-5.0,原厂 PCN 转 NRND,客户要求验证替代料",
|
||||
"originalInternalPn": "LM2596S-5.0",
|
||||
"candidateInternalPns": ["XL2596S-5.0", "LM2596S-ADJ"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`ownerId`(必填,2026-08-27 用户定)是启动契约的固定字段,不进 §4 参数表** —— 每个活动启动都要指定这次实例的**责任人**:负责人验收、存在性检查派出的数据工单都落到责任人身上。候选名单 = 既有 `GET /api/gov/staff`(前端只列 `state='active'` 的);前端默认填当前登录人,允许改。服务端要验:ownerId 是名册里在职的人(400),不验「是不是启动人自己」(代别人启动是合法场景)。
|
||||
启动成功后,实例视图(§3/§6)里的 `owner` 就是它。
|
||||
**响应 200**:`InstanceDetail`(§1.12,与 §6 同形)——新建的实例,初始 `state='pending'`(推进器领到即转 `running`,靠 SSE 失效重取)。**同一 `idempotencyKey` 重放也回 200,给的是第一次创建的那一单,不起第二个。**
|
||||
|
||||
→ 200 一条实例详情(同 §6)。**同一 `idempotencyKey` 重放返回第一次创建的实例,不起第二个**(前端每开一次抽屉生成一枚;重复点提交、网络重试都靠它挡住)。
|
||||
`params` 的键值形状由 §4 参数表决定:`list` 回字符串数组,其余回字符串;`select` 回 `options` 里某个 `value`;**`combo` 回候选的 `value` 或新增的原样字符串,两种都合法**。
|
||||
412:活动已禁用(先激活再启动)。400:缺必填 / 类型不符 / select 的值不在 options 里(**combo 不适用这条** —— 候选之外的值是合法新增)。
|
||||
**错误**
|
||||
|
||||
正常落地初始状态 `pending`(排入队列,调度器领到即转 `running`);**存在性检查(客户 / 料号在不在台账)在启动后做,查不到不拦启动** —— 按入参说明口径,缺口派数据工单到工作台逐项补(确认即入库,活动从断点续跑);响应里不单独回这件事,工单出现在工作台那一侧(工单接口不在本文范围)。
|
||||
| 状态 | 何时 | `detail` |
|
||||
|---|---|---|
|
||||
| 400 | 请求体不合 schema | zod issues |
|
||||
| 400 | 启动参数缺必填 / `list` 没传数组 / 标量传了数组 / `select` 值不在候选 / 不认识的参数名 | `[{ key, message }]`,逐格点名 —— 前端把 `message` 摆到对应控件下 |
|
||||
| 400 | 责任人不在名册 / 已停用 | `[{ key: 'ownerId', message }]` |
|
||||
| 404 | 活动不存在 | — |
|
||||
| 409 | 同一 `idempotencyKey` 的实例正在创建(60 秒内);或这枚键已用在别的活动上 | — |
|
||||
| 412 | 活动已禁用(先激活再启动) | — |
|
||||
|
||||
## 6. L3 实例详情
|
||||
成功后:关抽屉、失效 `['gov']`、按返回的 `id` 跳 L3(路由 id = `${activityId}:${id}`)。存在性检查(客户 / 料号在不在台账)在启动后由活动做,查不到不拦启动 —— 响应里不单独回这件事,工单出现在工作台那一侧。
|
||||
|
||||
### `GET /api/gov/activities/:activityId/instances/:instanceId` —— access: `ops`
|
||||
## 6. L3 实例详情 —— `GET /api/gov/activities/:activityId/instances/:instanceId`(`getOpsInstanceRoute`)
|
||||
|
||||
**请求**:路径参数 `activityId`、`instanceId`(= 清单行的 `id`,任务 uuid,**不是** `no`)。
|
||||
|
||||
**响应 200**:`InstanceDetail`(§1.12)。**404**:实例不存在或不属于这个活动。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "INS-2417",
|
||||
"activityId": "quote-intake",
|
||||
"id": "0c9d3a1e-…",
|
||||
"no": "INS-2417",
|
||||
"activityId": "part-substitution-assessment",
|
||||
"state": "running",
|
||||
"owner": { "id": "8f3c…", "name": "柴浩" },
|
||||
"llm": "K3 · quote-intake",
|
||||
"llm": "deepseek-v4-flash",
|
||||
"startedAt": "2026-08-25T02:12:00.000Z",
|
||||
"endedAt": null,
|
||||
"durationMs": null,
|
||||
"reason": null,
|
||||
"input": {
|
||||
"content": "Itelma 车身控制器询价 · 3 项物料",
|
||||
"content": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND…",
|
||||
"source": { "kind": "manual", "label": "手动启动 · 柴浩" },
|
||||
"submittedAt": "2026-08-25T02:12:00.000Z",
|
||||
"files": [
|
||||
{ "index": 0, "name": "RFQ-2026-081.eml", "contentType": "message/rfc822", "size": 48210 }
|
||||
{ "index": 0, "name": "XL2596S-5.0.pdf", "contentType": "application/pdf", "size": 48210 }
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"files": [
|
||||
{ "index": 0, "name": "quote-20260825-07.json", "contentType": "application/json", "size": 6102 }
|
||||
{ "index": 0, "name": "差异评估报告.docx", "contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "size": 6102 }
|
||||
]
|
||||
},
|
||||
"steps": [
|
||||
{ "seq": 1, "name": "输入接收", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:12:00.000Z", "durationMs": 60000, "res": "done", "note": "询价单已进入,要素识别完成,实例正式起步。" },
|
||||
{ "seq": 2, "name": "要素结构化", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:15:00.000Z", "durationMs": 360000, "res": "done", "note": "抽取客户 / 物料 / 交期等字段,落入结构化模型。" },
|
||||
{ "seq": 3, "name": "定价测算", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:21:00.000Z", "durationMs": null, "res": "current", "note": "按成本模型逐项给出建议价与毛利测算。" },
|
||||
{ "seq": 4, "name": "负责人验收", "by": "human", "byName": "柴浩", "at": null, "durationMs": null, "res": "pending", "note": "确认测算结果 —— 规划类产出必须由人验收担责。" },
|
||||
{ "seq": 5, "name": "输出归档", "by": "agent", "byName": "K3 · quote-intake", "at": null, "durationMs": null, "res": "pending", "note": "生成报价文件并归档,产出回写台账。" }
|
||||
{ "seq": 1, "name": "建立评估上下文:绑定四项、新建需求、存在性核对", "by": "agent", "byName": "deepseek-v4-flash", "at": "2026-08-25T02:12:00.000Z", "durationMs": 60000, "res": "done", "note": "铸案号、建需求 V1、锁规则版本、逐个料号查库、写存在性核对表" },
|
||||
{ "seq": 2, "name": "数据工单:补建芯片主数据(P-08)", "by": "human", "byName": "柴浩", "at": "2026-08-25T02:13:00.000Z", "durationMs": null, "res": "current", "note": "" },
|
||||
{ "seq": 3, "name": "评估输入与资料补齐确认", "by": "human", "byName": "柴浩", "at": null, "durationMs": null, "res": "pending", "note": "" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `endedAt` / `durationMs`:未结束(null)。`durationMs` 毫秒,前端格式化(`27m`)。
|
||||
- **`reason`**:终态的原因说明,`null` = 不需要(进行中 / 待执行 / 正常完成)。
|
||||
`{ "kind": "rejected", "text": "验收驳回:测算毛利低于阈值,退回重新测算 —— 负责人 柴浩。" }`
|
||||
`{ "kind": "failed", "text": "要素结构化失败:询价单附件缺失,已通知发起人补齐后重试。" }`
|
||||
`{ "kind": "terminated", "text": "已于 08-27 10:41 由 柴浩 手动终止。" }` —— **手动终止的实例状态归 `failed`,靠 `reason.kind='terminated'` 与「自己跑挂」区分**(过程面当前步记「已终止」,不摆失败原因)。
|
||||
- `input.source.kind`:`manual`(运营端手动启动)/ `upstream`(上游活动触发)/ `mail`(邮件触发)…… 枚举随触发渠道增加;`label` 是人读形式,前端直接摆。
|
||||
- `steps[].res`:`done` / `fail` / `current` / `pending`;`fail` 那行的性质由 `reason.kind` 定(驳回 / 失败 / 已终止)。
|
||||
- `input.files` / `output.files`:只有元数据;**下载走 §7**。没有产出时 `output.files` 是空数组(前端摆「暂无产出」)。
|
||||
三种终局的 `reason` 示例:
|
||||
|
||||
## 7. 实例文件下载
|
||||
```json
|
||||
{ "kind": "rejected", "text": "验收驳回:测算毛利低于阈值,退回重新测算 —— 负责人 柴浩" }
|
||||
{ "kind": "failed", "text": "预算耗尽:步数 90/90、活跃时长 120/120 分钟" }
|
||||
{ "kind": "terminated", "text": "由 柴浩 手动终止" }
|
||||
```
|
||||
|
||||
### `GET /api/gov/activities/:activityId/instances/:instanceId/files/:io/:index` —— access: `ops`(支持 `?ticket=`)
|
||||
- 头部:状态丸(`state`)+ 责任人 + 启动 / 结束 · 耗时(`startedAt` / `endedAt` / `durationMs`)+ 模型(`llm`)。
|
||||
- 驳回 / 失败 / 已终止出原因块(`reason.text`);`reason === null` 不摆。
|
||||
- 时间线按 `steps` 渲染;`res='fail'` 那一行的字眼按 `reason.kind` 选(驳回 / 失败 / 已终止)。
|
||||
- 右栏输入(`input.content` / `input.source.label` / `input.submittedAt` / `input.files`)、输出(`output.files`;空数组摆「暂无产出」);文件行的下载走 §7。
|
||||
|
||||
`:io` ∈ `input` | `output`,`:index` 对应 §6 文件行的 `index`。
|
||||
→ `{ "name": "…", "contentType": "…", "dataBase64": "…" }`(与 `GET /api/gov/upload/:id` 同形);可带 `?ticket=` 用 `<a href download>`。
|
||||
**本期可以只回元数据、这条路先 404** —— 前端下载钮当前就是「随接口开放」的置灰态。
|
||||
## 7. 实例文件下载 —— `GET /api/gov/activities/:activityId/instances/:instanceId/files/:io/:index`(`opsInstanceFileRoute`)
|
||||
|
||||
**请求**
|
||||
|
||||
| 位置 | 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| path | `activityId` | string | 是 | |
|
||||
| path | `instanceId` | string | 是 | 任务 id |
|
||||
| path | `io` | `'input' \| 'output'` | 是 | 哪一侧;别的值 400 |
|
||||
| path | `index` | number ≥ 0 | 是 | 对应 §6 文件行的 `index`;不是整数 → 400 |
|
||||
| query | `raw` | `'1'` | 否 | 带 `raw=1` 直出文件字节(`content-type` = 文件 MIME,`content-disposition: attachment; filename*=UTF-8''…`);不带回 JSON |
|
||||
| query | `ticket` | string | `<a href download>` 场景必填 | 点击那一刻取票据拼上(`shared` 里的 `downloadWithTicket(url)`) |
|
||||
|
||||
**响应 200**(不带 `raw`):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `name` | string | 文件名 |
|
||||
| `contentType` | string | MIME |
|
||||
| `dataBase64` | string | 文件字节的 base64(与 `GET /api/gov/upload/:id` 同形) |
|
||||
|
||||
**404**:实例不存在 / 该序号没有文件。
|
||||
|
||||
下载钮的写法:`downloadWithTicket(`/api/gov/activities/${activityId}/instances/${id}/files/${io}/${index}?raw=1`)`。
|
||||
|
||||
## 8. 禁用 / 激活 / 终止
|
||||
|
||||
### `POST /api/gov/activities/:activityId/disable` —— access: `ops`
|
||||
### 8.1 禁用 —— `POST /api/gov/activities/:activityId/disable`(`disableOpsActivityRoute`)
|
||||
|
||||
→ 200 活动视图(§2 同形)。**任何时刻都可禁用,没有前置条件**(2026-08-27 用户重定):
|
||||
禁用后这个活动不再允许启动(§5 返回 412);**已完成的实例是历史留痕,进行中的实例继续跑完,都不受影响**。
|
||||
已禁用再禁用 = 幂等 200。
|
||||
**请求**:路径参数 `activityId`;**body 空**。
|
||||
**响应 200**:`ActivityDetail`(`status='disabled'`)。**404**:活动不存在。已禁用再禁用 = 幂等 200。
|
||||
任何时刻都可禁用,没有前置条件:禁用后 §5 回 412;已完成的实例是历史留痕,进行中的继续跑完。
|
||||
|
||||
### `POST /api/gov/activities/:activityId/activate` —— access: `ops`
|
||||
### 8.2 激活 —— `POST /api/gov/activities/:activityId/activate`(`activateOpsActivityRoute`)
|
||||
|
||||
→ 200 活动视图。已激活再激活 = 幂等 200。
|
||||
**请求**:路径参数 `activityId`;**body 空**。
|
||||
**响应 200**:`ActivityDetail`(`status='active'`)。**404**:活动不存在。已激活再激活 = 幂等 200。
|
||||
|
||||
### `POST /api/gov/activities/:activityId/instances/:instanceId/terminate` —— access: `ops`
|
||||
### 8.3 终止实例 —— `POST /api/gov/activities/:activityId/instances/:instanceId/terminate`(`terminateOpsInstanceRoute`)
|
||||
|
||||
body 空(终止理由本期不填,`reason.text` 由服务端生成)。
|
||||
→ 200 实例详情(§6 同形,`state='failed'` + `reason.kind='terminated'`)。412:实例不在 `running`(已终态的不能再终止)。
|
||||
**请求**:路径参数 `activityId`、`instanceId`;**body 空**(终止理由本期不填,服务端生成「由 <操作人> 手动终止」)。
|
||||
**响应 200**:`InstanceDetail`,`state='failed'`、`reason.kind='terminated'`、`endedAt` = 终止时刻。
|
||||
**404**:实例不存在。**412**:实例已终态(已完成 / 已失败 / 已终止的不能再终止;`pending` 与 `running` 都可以)。
|
||||
前端:二次确认(轻气泡)后调用;成功后由服务端重投影(广播 → 失效 → 重取),不要就地改 state。
|
||||
|
||||
## 9. Schema 附录(落地时进 `packages/contracts`,同名)
|
||||
## 9. 类型附录(`packages/contracts/src/activity-ops.ts`,同名导出)
|
||||
|
||||
```ts
|
||||
type ActivityStatus = 'active' | 'disabled';
|
||||
@@ -302,41 +534,76 @@ type InstanceState = 'pending' | 'running' | 'completed' | 'rejected' | 'failed'
|
||||
type StaffRef = { id: string; name: string };
|
||||
|
||||
type ActivitySummary = { id: string; name: string; biz: string; status: ActivityStatus; version: string;
|
||||
owner: StaffRef; llm: string; instanceCount: number; createdAt: string; ico?: string; hue?: 'blue' | 'teal' | 'amber' | 'violet' };
|
||||
owner: StaffRef | null; llm: string; instanceCount: number; createdAt: string; ico?: string; hue?: 'blue' | 'teal' | 'amber' | 'violet' };
|
||||
type ActivityDetail = Omit<ActivitySummary, 'instanceCount'> & { desc: string; view: { url: string } | null };
|
||||
|
||||
type InstanceSummary = { id: string; state: InstanceState; owner: StaffRef; input: string; output: string | null; startedAt: string };
|
||||
type InstanceSummary = { id: string; no: string; state: InstanceState; owner: StaffRef; input: string; output: string | null; startedAt: string };
|
||||
type InstanceStats = { total: number; byState: Record<InstanceState, number> };
|
||||
type InstanceList = { stats: InstanceStats; total: number; page: number; size: number; items: InstanceSummary[] };
|
||||
|
||||
type LaunchParamType = 'text' | 'textarea' | 'select' | 'combo' | 'list';
|
||||
type LaunchParam = { key: string; label: string; type: LaunchParamType; required?: boolean; desc?: string;
|
||||
placeholder?: string; options?: { value: string; label: string }[] /* select / combo 必填 */; default?: string | string[] };
|
||||
placeholder?: string; options?: { value: string; label: string }[]; default?: string | string[] };
|
||||
type LaunchParams = { activityId: string; version: string; params: LaunchParam[] };
|
||||
type LaunchInput = { idempotencyKey: string; ownerId: string /* 责任人,固定字段,名册 staffId */; params: Record<string, string | string[]> };
|
||||
type LaunchInput = { idempotencyKey: string; ownerId: string; params: Record<string, string | string[]> };
|
||||
|
||||
type InstanceFile = { index: number; name: string; contentType: string; size: number };
|
||||
type InstanceReason = { kind: 'rejected' | 'failed' | 'terminated'; text: string };
|
||||
type InstanceSource = { kind: string; label: string };
|
||||
type InstanceStep = { seq: number; name: string; by: 'agent' | 'human'; byName: string;
|
||||
at: string | null; durationMs: number | null; res: 'done' | 'fail' | 'current' | 'pending'; note: string };
|
||||
type InstanceDetail = InstanceSummary & { activityId: string; llm: string; endedAt: string | null; durationMs: number | null;
|
||||
type InstanceDetail = Omit<InstanceSummary, 'input' | 'output'> & { activityId: string; llm: string; endedAt: string | null; durationMs: number | null;
|
||||
reason: InstanceReason | null;
|
||||
input: { content: string; source: { kind: string; label: string }; submittedAt: string; files: InstanceFile[] };
|
||||
input: { content: string; source: InstanceSource; submittedAt: string; files: InstanceFile[] };
|
||||
output: { files: InstanceFile[] };
|
||||
steps: InstanceStep[] };
|
||||
```
|
||||
|
||||
路由常量(`packages/contracts/src/api/activity-ops.ts`):
|
||||
|
||||
| 常量 | 方法 · 路径 |
|
||||
|---|---|
|
||||
| `listOpsActivitiesRoute` | GET `/api/gov/activities` |
|
||||
| `getOpsActivityRoute` | GET `/api/gov/activities/:activityId` |
|
||||
| `opsActivityViewRoute` | GET `/api/gov/activities/:activityId/view` |
|
||||
| `listOpsInstancesRoute` | GET `/api/gov/activities/:activityId/instances` |
|
||||
| `opsLaunchParamsRoute` | GET `/api/gov/activities/:activityId/launch-params` |
|
||||
| `launchOpsInstanceRoute` | POST `/api/gov/activities/:activityId/instances` |
|
||||
| `getOpsInstanceRoute` | GET `/api/gov/activities/:activityId/instances/:instanceId` |
|
||||
| `opsInstanceFileRoute` | GET `/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index` |
|
||||
| `disableOpsActivityRoute` | POST `/api/gov/activities/:activityId/disable` |
|
||||
| `activateOpsActivityRoute` | POST `/api/gov/activities/:activityId/activate` |
|
||||
| `terminateOpsInstanceRoute` | POST `/api/gov/activities/:activityId/instances/:instanceId/terminate` |
|
||||
|
||||
其它导出:`INSTANCE_STATES` / `INSTANCE_STATE_LABEL`(五态中文)、`ACTIVITY_STATUSES` / `ACTIVITY_STATUS_LABEL`、`ACTIVITY_HUES`、`LAUNCH_PARAM_TYPES`、`ACTIVITY_VIEW_HEIGHT_MESSAGE`、`ACTIVITY_OPS_ROUTES`。
|
||||
|
||||
调用示例(前端唯一通道 `shared/typed-fetch`):
|
||||
|
||||
```ts
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { launchOpsInstanceRoute, listOpsInstancesRoute } from '@contracts';
|
||||
import type { InstanceDetail, InstanceList } from '@contracts';
|
||||
|
||||
const list = await call<InstanceList>(listOpsInstancesRoute, { params: { activityId }, query: { state: 'running', page: '2', size: '8' } });
|
||||
const created = await call<InstanceDetail>(launchOpsInstanceRoute, { params: { activityId }, body: { idempotencyKey, ownerId, params: values } });
|
||||
// 失败抛 ApiCallError { status, code, message, detail }
|
||||
```
|
||||
|
||||
## 10. 前端对接要点(现状 → 接口落地)
|
||||
|
||||
接口已落地,前端仍是占位数据(对接另起一轮,2026-08-27 用户定)。届时照这张表换:
|
||||
|
||||
| 现状(占位) | 接口落地后 |
|
||||
|---|---|
|
||||
| `DEMO_ACTIVITIES` 静态数组 | §1;L1 页签切换即换 `status` 重取 |
|
||||
| `demoInstances()` 每活动共用一份 | §3;检索四条件与分页全部上移到查询参数,前端删掉本地过滤/分页 |
|
||||
| `DEMO_ACTIVITIES` 静态数组 | §2.1;L1 页签切换即换 `status` 重取;`owner` 为 null 摆「—」,`ico`/`hue` 缺省兜底 |
|
||||
| `demoInstances()` 每活动共用一份 | §3;检索四条件与分页全部上移到查询参数,前端删掉本地过滤/分页;表格摆 `no`,寻址用 `id` |
|
||||
| 统计带由前端从全量数组数 | §3 `stats`(服务端给,分页下才数得准) |
|
||||
| `LAUNCH_PARAMS` 占位参数表 | §4;`options` 从纯字符串升级为 `{value,label}` 两段(渲染器小改);控件种类已含 `combo`(选择+新增,前端已实现) |
|
||||
| `onLaunch` 在清单头落假行 | §5;抽屉每开一次生成 `idempotencyKey`,提交走 mutation,成功失效 `['gov']` |
|
||||
| —(新增)责任人固定字段 | §5 `ownerId`;抽屉「固定参数」段已实现(名册下拉、默认当前登录人),接接口后直接随 body 走 |
|
||||
| 禁用/激活/终止就地改 state | §8;mutation 成功后由服务端重投影(广播 → 失效 → 重取) |
|
||||
| 中文状态字面量当键 | 五态英文码 + `INSTANCE_STATE_LABEL`,前端一张 `Record<InstanceState, {tone, ico}>` 映射 |
|
||||
| `LAUNCH_PARAMS` 占位参数表 | §4;`options` 从纯字符串升级为 `{value,label}` 两段(渲染器小改);控件种类已含 `combo` |
|
||||
| `onLaunch` 在清单头落假行 | §5;抽屉每开一次生成 `idempotencyKey`(`crypto.randomUUID()`),提交走 mutation,400 的 `detail[].key` 对回控件,成功失效 `['gov']` 并跳 L3 |
|
||||
| 责任人固定字段 | §5 `ownerId`;抽屉加「固定参数」段(名册下拉取 `GET /api/gov/staff` 在职的人,默认当前登录人 `me.staff.id`) |
|
||||
| 禁用/激活/终止就地改 state | §8;mutation 成功后由服务端重投影(广播 → 失效 → 重取);L2 行内「终止活动」也要二次确认 |
|
||||
| `demoSteps` / `terminatedSteps` 五步占位 | §6 `steps`;终止归态以 `reason.kind='terminated'` 为准,前端不再落「失败」占位 |
|
||||
| 沙箱 `src="/sandbox-demo/activity-view.html"` 固定 480 高 | §2 `view.url` + 票据;`view` 为 null 藏起沙箱区;高度 postMessage 随第一版视图定 |
|
||||
| 输出下载钮置灰 | §7(本期可继续 404,钮保持置灰) |
|
||||
| 沙箱 `src="/sandbox-demo/activity-view.html"` 固定 480 高 | §2.3 `view.url` + 票据(装载那一刻取);`view` 为 null 藏起沙箱区;高度听 `jcai:view-height` |
|
||||
| 输出下载钮置灰 | §7 已落地:`downloadWithTicket(url + '?raw=1')`(从 `desk-task/internal/FileViewer.tsx` 抽到 shared) |
|
||||
| 设计中心活动库 / 测试台的 `useGovActivities` | 路径改名对它们透明(import 的是路由对象),无需改动 |
|
||||
|
||||
@@ -94,8 +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 秒重取一次;进终态(完成/驳回/失败)自动停,不白打接口。
|
||||
|
||||
## 三、功能表(整理稿)
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 活动模块(运营中心 · 活动区)的数据结构 —— 口径:`docs/API文档-活动模块.md` §1。
|
||||
* 本文档以「契约已落地」的语气写成,但文件当时并不在仓库里(2026-08-27 对接时发现)——
|
||||
* 这里按文档 §1/§9 把 schema 补齐,两者不一致时回来改文档。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
/* ── 枚举与标签 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export const ACTIVITY_STATUSES = ['active', 'disabled'] as const;
|
||||
export const activityStatusSchema = z.enum(ACTIVITY_STATUSES);
|
||||
export type ActivityStatus = z.infer<typeof activityStatusSchema>;
|
||||
export const ACTIVITY_STATUS_LABEL: Readonly<Record<ActivityStatus, string>> = { active: '激活中', disabled: '已禁用' };
|
||||
|
||||
export const INSTANCE_STATES = ['pending', 'running', 'completed', 'rejected', 'failed'] as const;
|
||||
export const instanceStateSchema = z.enum(INSTANCE_STATES);
|
||||
export type InstanceState = z.infer<typeof instanceStateSchema>;
|
||||
export const INSTANCE_STATE_LABEL: Readonly<Record<InstanceState, string>> = {
|
||||
pending: '待执行',
|
||||
running: '进行中',
|
||||
completed: '已完成',
|
||||
rejected: '驳回',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
export const ACTIVITY_HUES = ['blue', 'teal', 'amber', 'violet'] as const;
|
||||
export const activityHueSchema = z.enum(ACTIVITY_HUES);
|
||||
export type ActivityHue = z.infer<typeof activityHueSchema>;
|
||||
|
||||
export const LAUNCH_PARAM_TYPES = ['text', 'textarea', 'select', 'combo', 'list'] as const;
|
||||
export const launchParamTypeSchema = z.enum(LAUNCH_PARAM_TYPES);
|
||||
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
|
||||
|
||||
/** 沙箱视图向父页报内容高度的 postMessage type(视图 origin 是 'null',父页按 source 认) */
|
||||
export const ACTIVITY_VIEW_HEIGHT_MESSAGE = 'jcai:view-height';
|
||||
|
||||
/* ── 基础件 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 凡出人都是这两段:展示用 name,筛选 / 提交用 id(= 名册 staffId) */
|
||||
export const staffRefSchema = z.object({ id: z.string(), name: z.string() });
|
||||
export type StaffRef = z.infer<typeof staffRefSchema>;
|
||||
|
||||
/* ── 活动 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** L1 卡片(§1.2) */
|
||||
export const activitySummarySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
biz: z.string(),
|
||||
status: activityStatusSchema,
|
||||
version: z.string(),
|
||||
/** 没建档 = null,前端摆「—」 */
|
||||
owner: staffRefSchema.nullable(),
|
||||
llm: z.string(),
|
||||
instanceCount: z.number(),
|
||||
createdAt: z.string(),
|
||||
/** 展示元数据可缺:缺省前端兜底 inbox / blue */
|
||||
ico: z.string().optional(),
|
||||
hue: activityHueSchema.optional(),
|
||||
});
|
||||
export type ActivitySummary = z.infer<typeof activitySummarySchema>;
|
||||
|
||||
/** L2 头部(§1.3)= Summary 去掉 instanceCount,加 desc / view */
|
||||
export const activityDetailSchema = activitySummarySchema.omit({ instanceCount: true }).extend({
|
||||
desc: z.string(),
|
||||
/** null = 前端藏起整个沙箱区块 */
|
||||
view: z.object({ url: z.string() }).nullable(),
|
||||
});
|
||||
export type ActivityDetail = z.infer<typeof activityDetailSchema>;
|
||||
|
||||
/* ── 实例清单(§1.4 / §1.5)─────────────────────────────────────────────── */
|
||||
|
||||
export const instanceSummarySchema = z.object({
|
||||
/** 寻址键(任务 id,uuid):L3 路由、终止、文件都用它 */
|
||||
id: z.string(),
|
||||
/** 人读实例号 INS-<序号>:表格上摆它、模糊搜也认它 */
|
||||
no: z.string(),
|
||||
state: instanceStateSchema,
|
||||
owner: staffRefSchema,
|
||||
/** 输入一句话摘要(≤80 字,可为 '') */
|
||||
input: z.string(),
|
||||
/** 产出一句话摘要;只有 completed 才有值;null 摆 `-` */
|
||||
output: z.string().nullable(),
|
||||
startedAt: z.string(),
|
||||
});
|
||||
export type InstanceSummary = z.infer<typeof instanceSummarySchema>;
|
||||
|
||||
export const instanceStatsSchema = z.object({
|
||||
/** 活动整体总数,不随检索条件动(页头统计带用) */
|
||||
total: z.number(),
|
||||
/** 五个键都在,没有的是 0 */
|
||||
byState: z.record(instanceStateSchema, z.number()),
|
||||
});
|
||||
export type InstanceStats = z.infer<typeof instanceStatsSchema>;
|
||||
|
||||
export const instanceListSchema = z.object({
|
||||
stats: instanceStatsSchema,
|
||||
/** 筛后条数,分页器按它 */
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
size: z.number(),
|
||||
items: z.array(instanceSummarySchema),
|
||||
});
|
||||
export type InstanceList = z.infer<typeof instanceListSchema>;
|
||||
|
||||
/* ── 启动(§1.6 / §1.7)─────────────────────────────────────────────────── */
|
||||
|
||||
export const launchParamOptionSchema = z.object({ value: z.string(), label: z.string() });
|
||||
|
||||
export const launchParamSchema = z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
type: launchParamTypeSchema,
|
||||
required: z.boolean().optional(),
|
||||
desc: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
/** select / combo 的候选,两段式;combo 可能没有这个键(候选读不到 = 自由输入) */
|
||||
options: z.array(launchParamOptionSchema).optional(),
|
||||
/** 预填值:list 给字符串数组,其余给字符串 */
|
||||
default: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
});
|
||||
export type LaunchParam = z.infer<typeof launchParamSchema>;
|
||||
|
||||
export const launchParamsSchema = z.object({
|
||||
activityId: z.string(),
|
||||
version: z.string(),
|
||||
/** 按这个顺序渲染表单 */
|
||||
params: z.array(launchParamSchema),
|
||||
});
|
||||
export type LaunchParams = z.infer<typeof launchParamsSchema>;
|
||||
|
||||
export const launchInputSchema = z.object({
|
||||
/** 幂等键:每开一次抽屉生成一枚(crypto.randomUUID());重复提交、网络重试都带同一枚 */
|
||||
idempotencyKey: z.string().min(1).max(128),
|
||||
/** 责任人 staffId(固定字段,不在参数表里) */
|
||||
ownerId: z.string().min(1),
|
||||
/** 键 = LaunchParam.key;list 传 string[],其余传 string;选填没填可以不传或传 '' */
|
||||
params: z.record(z.string(), z.union([z.string(), z.array(z.string())])),
|
||||
});
|
||||
export type LaunchInput = z.infer<typeof launchInputSchema>;
|
||||
|
||||
/* ── 实例详情(§1.8 – §1.12)────────────────────────────────────────────── */
|
||||
|
||||
export const instanceFileSchema = z.object({
|
||||
/** 同一侧(input / output)文件里的序号,从 0 起;下载用 */
|
||||
index: z.number(),
|
||||
name: z.string(),
|
||||
contentType: z.string(),
|
||||
size: z.number(),
|
||||
});
|
||||
export type InstanceFile = z.infer<typeof instanceFileSchema>;
|
||||
|
||||
export const instanceReasonSchema = z.object({
|
||||
/** 驳回 / 自己跑挂 / 手动终止 */
|
||||
kind: z.enum(['rejected', 'failed', 'terminated']),
|
||||
/** 人话原因,直接摆 */
|
||||
text: z.string(),
|
||||
});
|
||||
export type InstanceReason = z.infer<typeof instanceReasonSchema>;
|
||||
|
||||
export const instanceSourceSchema = z.object({
|
||||
/** manual / upstream / mail 等通道名 / testbed / system —— 开放枚举,不认识的按 label 摆 */
|
||||
kind: z.string(),
|
||||
/** 人读形式,直接摆 */
|
||||
label: z.string(),
|
||||
});
|
||||
export type InstanceSource = z.infer<typeof instanceSourceSchema>;
|
||||
|
||||
export const instanceStepSchema = z.object({
|
||||
/** 从 1 起,按活动案例模型的步骤序 */
|
||||
seq: z.number(),
|
||||
name: z.string(),
|
||||
by: z.enum(['agent', 'human']),
|
||||
/** agent → 模型名;human → 责任人姓名 */
|
||||
byName: z.string(),
|
||||
/** 开始时刻;没到这一步 = null */
|
||||
at: z.string().nullable(),
|
||||
/** 用时(毫秒);未完成 = null */
|
||||
durationMs: z.number().nullable(),
|
||||
/** 已完成 / 出事(性质看 reason.kind)/ 正在办 / 未到 */
|
||||
res: z.enum(['done', 'fail', 'current', 'pending']),
|
||||
note: z.string(),
|
||||
});
|
||||
export type InstanceStep = z.infer<typeof instanceStepSchema>;
|
||||
|
||||
export const instanceDetailSchema = instanceSummarySchema.omit({ input: true, output: true }).extend({
|
||||
activityId: z.string(),
|
||||
llm: z.string(),
|
||||
/** 未结束 = null;终止时 = 终止时刻 */
|
||||
endedAt: z.string().nullable(),
|
||||
/** endedAt − startedAt;未结束 = null */
|
||||
durationMs: z.number().nullable(),
|
||||
/** null = 不需要(待执行 / 进行中 / 正常完成) */
|
||||
reason: instanceReasonSchema.nullable(),
|
||||
input: z.object({
|
||||
content: z.string(),
|
||||
source: instanceSourceSchema,
|
||||
/** = startedAt */
|
||||
submittedAt: z.string(),
|
||||
files: z.array(instanceFileSchema),
|
||||
}),
|
||||
/** 没产出是 [],摆「暂无产出」 */
|
||||
output: z.object({ files: z.array(instanceFileSchema) }),
|
||||
steps: z.array(instanceStepSchema),
|
||||
});
|
||||
export type InstanceDetail = z.infer<typeof instanceDetailSchema>;
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 活动模块(运营中心 · 活动区)的 11 条路由 —— 口径:`docs/API文档-活动模块.md`。
|
||||
* 全部 `access: 'ops'`;写路由(启动 / 禁用 / 激活 / 终止)成功后服务端广播 `{kind:'system', id:'gov'}`。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { RouteContract } from './http.ts';
|
||||
import {
|
||||
activityDetailSchema,
|
||||
activityStatusSchema,
|
||||
activitySummarySchema,
|
||||
instanceDetailSchema,
|
||||
instanceListSchema,
|
||||
instanceStateSchema,
|
||||
launchInputSchema,
|
||||
launchParamsSchema,
|
||||
} from '../activity-ops.ts';
|
||||
|
||||
const activityIdParam = z.object({ activityId: z.string().min(1) });
|
||||
const instanceIdParams = z.object({ activityId: z.string().min(1), instanceId: z.string().min(1) });
|
||||
|
||||
/** L1 活动清单 —— 两页签(激活中 / 已禁用)各取一份;不带 status = 全量;不分页 */
|
||||
export const listOpsActivitiesRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/activities',
|
||||
access: 'ops',
|
||||
query: z.object({ status: activityStatusSchema.optional() }),
|
||||
output: z.object({ activities: z.array(activitySummarySchema) }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** L2 活动定义 */
|
||||
export const getOpsActivityRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/activities/:activityId',
|
||||
access: 'ops',
|
||||
params: activityIdParam,
|
||||
output: activityDetailSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 活动视图(沙箱):整份 HTML,iframe 直渲;装载那一刻取票据拼 ?ticket=(60 秒过期,别预取) */
|
||||
export const opsActivityViewRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/activities/:activityId/view',
|
||||
access: 'ops',
|
||||
params: activityIdParam,
|
||||
query: z.object({ ticket: z.string().optional() }),
|
||||
output: z.string(),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** L2 实例清单:检索四条件 + 分页全在服务端;stats 是全局五态统计,total 是筛后条数 */
|
||||
export const listOpsInstancesRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/activities/:activityId/instances',
|
||||
access: 'ops',
|
||||
params: activityIdParam,
|
||||
query: z.object({
|
||||
id: z.string().optional(),
|
||||
ownerId: z.string().optional(),
|
||||
state: instanceStateSchema.optional(),
|
||||
startedFrom: z.string().optional(),
|
||||
startedTo: z.string().optional(),
|
||||
page: z.string().optional(),
|
||||
size: z.string().optional(),
|
||||
}),
|
||||
output: instanceListSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 启动参数表:一活动一份,前端只按 params 渲染表单,不写死字段 */
|
||||
export const opsLaunchParamsRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/activities/:activityId/launch-params',
|
||||
access: 'ops',
|
||||
params: activityIdParam,
|
||||
output: launchParamsSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 启动活动(创建实例):同一 idempotencyKey 重放回 200 给第一单,不起第二个 */
|
||||
export const launchOpsInstanceRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/activities/:activityId/instances',
|
||||
access: 'ops',
|
||||
params: activityIdParam,
|
||||
body: launchInputSchema,
|
||||
output: instanceDetailSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** L3 实例详情;instanceId = 清单行的 id(任务 uuid),不是 no */
|
||||
export const getOpsInstanceRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/activities/:activityId/instances/:instanceId',
|
||||
access: 'ops',
|
||||
params: instanceIdParams,
|
||||
output: instanceDetailSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 实例文件:不带 raw 回 JSON(name / contentType / dataBase64);带 raw=1 直出文件字节(<a download> 走票据) */
|
||||
export const opsInstanceFileRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index',
|
||||
access: 'ops',
|
||||
params: instanceIdParams.extend({ io: z.enum(['input', 'output']), index: z.string().regex(/^\d+$/) }),
|
||||
query: z.object({ raw: z.literal('1').optional(), ticket: z.string().optional() }),
|
||||
output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 禁用:任何时刻都可禁用,没有前置条件;已禁用再禁用 = 幂等 200 */
|
||||
export const disableOpsActivityRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/activities/:activityId/disable',
|
||||
access: 'ops',
|
||||
params: activityIdParam,
|
||||
output: activityDetailSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 激活:幂等 */
|
||||
export const activateOpsActivityRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/activities/:activityId/activate',
|
||||
access: 'ops',
|
||||
params: activityIdParam,
|
||||
output: activityDetailSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 终止实例:pending / running 都可终止;已终态的 412。终止后 state='failed' + reason.kind='terminated' */
|
||||
export const terminateOpsInstanceRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/activities/:activityId/instances/:instanceId/terminate',
|
||||
access: 'ops',
|
||||
params: instanceIdParams,
|
||||
output: instanceDetailSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/**
|
||||
* 这一屏的全部路由 —— 归属一处列全,测试拿它逐条断言(同 ORG_DIRECTORY_ROUTES 的考量)。
|
||||
*/
|
||||
export const ACTIVITY_OPS_ROUTES = [
|
||||
listOpsActivitiesRoute,
|
||||
getOpsActivityRoute,
|
||||
opsActivityViewRoute,
|
||||
listOpsInstancesRoute,
|
||||
opsLaunchParamsRoute,
|
||||
launchOpsInstanceRoute,
|
||||
getOpsInstanceRoute,
|
||||
opsInstanceFileRoute,
|
||||
disableOpsActivityRoute,
|
||||
activateOpsActivityRoute,
|
||||
terminateOpsInstanceRoute,
|
||||
] as const;
|
||||
@@ -4,3 +4,4 @@ export * from './governance.ts';
|
||||
export * from './org-directory.ts';
|
||||
export * from './ingest.ts';
|
||||
export * from './mail.ts';
|
||||
export * from './activity-ops.ts';
|
||||
|
||||
@@ -15,4 +15,5 @@ export * from './process-package.ts';
|
||||
export * from './manifest.ts';
|
||||
export * from './org.ts';
|
||||
export * from './mail.ts';
|
||||
export * from './activity-ops.ts';
|
||||
export * from './api/index.ts';
|
||||
|
||||
Reference in New Issue
Block a user