diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 922f042..4508bc1 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -662,3 +662,220 @@ .g-maprow > .g-chip { grid-column: 3 / 6; justify-self: start; } .g-maprow > .g-btn { grid-column: 6; justify-self: stretch; padding: 4px 0; display: inline-flex; align-items: center; justify-content: center; } .g-maparr { color: var(--sh-steel, #46586a); font-weight: 700; text-align: center; } + +/* ═══════════ 测试台 · 仿真试跑(2026-07-29)═══════════ + 测试台是**后端检测活动与流程**的地方:在这儿装配并起单,真实任务照旧落到被指派人的 + 工作台上 —— 这一屏只管两件事:**把流跑起来** + **看穿它怎么跑的**。 + 形制沿用图纸语言(与 .g-vercard / .g-nodecard 同一套 --sh-*):白纸方角、细线、 + 钢青题头、黄铜是「人 / 生效 / 裁定」的颜色。 + 全屏只有一处戏眼 —— TRACE 那张**「树 + 甘特」同排的走查记录纸**: + 左边是结构(枝线画出 span 树),右边是时间(同一条刻度轴上的条),一行读两件事。 */ + +.g-tb { + --sh-ink: #161b22; --sh-steel: #46586a; --sh-muted: #6a7686; + --sh-line: #d8dee6; --sh-brass: #8a5f1c; --sh-brass-wash: #f7f0e0; + --sh-grid: #edf1f5; + --sh-mono: "SF Mono", "JetBrains Mono", "Menlo", "Roboto Mono", ui-monospace, monospace; + display: flex; flex-direction: column; gap: 12px; min-width: 0; +} + +/* 模式选择:不是第二条页签栏 —— 前置 mono 墨签点明这是个**控件**,方角、黄铜底线定当前 */ +.g-tbmode { display: flex; align-items: center; gap: 8px; } +.g-tbmode > em { font: 600 9.5px/1 var(--sh-mono, var(--mono)); font-style: normal; letter-spacing: .14em; text-transform: uppercase; color: #6a7686; } +.g-tbmode-b { display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 12px; border: 1px solid #d8dee6; border-bottom-width: 2px; background: #f7f9fb; color: #6a7686; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast); } +.g-tbmode-b:hover { color: #46586a; background: #fff; } +.g-tbmode-b.on { background: #fff; color: #161b22; font-weight: 700; border-color: #c6cfd9; border-bottom-color: #8a5f1c; } +.g-tbmode-b:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; } + +/* ── 图纸卡:方角白纸 + mono 三字码题头(与流程区的 VER — / FIG — 同一idiom)── */ +.g-sheet { display: flex; flex-direction: column; min-width: 0; background: #fff; border: 1px solid var(--sh-line); box-shadow: var(--cshadow); } +.g-sheet-h { flex: none; display: flex; align-items: center; gap: 8px; padding: 11px 14px 10px; border-bottom: 1px solid var(--sh-line); font-family: var(--sh-mono); font-size: 9.5px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--sh-steel); } +.g-sheet-h svg { color: var(--sh-steel); opacity: .65; } +/* 题头右角写的是**数据**(包 id / 计数 / 来源),不是标签 —— 不跟着题头转大写 */ +.g-sheet-h .x { margin-left: auto; display: flex; align-items: center; gap: 10px; letter-spacing: .06em; text-transform: none; color: var(--sh-muted); text-align: right; } +.g-sheet-b { padding: 13px 14px 15px; display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.g-sheet-b.tight { padding: 0; } +/* 装配台:左选包、右填单 —— 窄了就叠 */ +.g-tb-rig { display: grid; grid-template-columns: minmax(230px, .8fr) minmax(0, 2fr); gap: 12px; align-items: start; } +.g-tb-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; } + +/* 图纸卡里的表单件:压回方角、纸上填线(输入只留底线,聚焦转钢蓝)—— 同 .g-nodecard 的处理 */ +.g-tb .g-field { gap: 3px; } +.g-tb .g-field > em { font: 600 9px/1.6 var(--sh-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--sh-steel); } +.g-tb .g-field > em i { font-family: var(--sans); font-size: 10px; font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--sh-muted); } +.g-tb .g-input, .g-tb .g-select, .g-tb .g-ta { border: 0; border-bottom: 1px solid var(--sh-line); border-radius: 0; background-color: transparent; padding: 5px 2px; } +.g-tb .g-input:focus, .g-tb .g-select:focus, .g-tb .g-ta:focus { outline: 0; border-color: #4e7daf; box-shadow: 0 1px 0 0 #4e7daf; } +.g-tb .g-select { padding-right: 22px; background-position: right 8px center, right 3px center; } +.g-tb .g-btn, .g-tb .g-chip, .g-tb .g-note, .g-tb .g-seg, .g-tb .g-seg-b { border-radius: 0; } +.g-tb .g-btn { border-color: var(--sh-line); color: var(--sh-steel); } +.g-tb .g-btn:hover:not(:disabled) { border-color: #9fb0c0; background: #f4f6f9; } +.g-tb .g-btn.primary { border-color: var(--sh-brass); background: var(--sh-brass); color: #fff; } +.g-tb .g-btn.primary:hover:not(:disabled) { border-color: #6f4b14; background: #6f4b14; } +.g-tb .g-note { border: 1px solid var(--sh-line); border-left: 3px solid var(--sh-steel); background: #fafbfd; font-size: 11px; line-height: 1.8; color: var(--sh-muted); } +.g-tb .g-note svg { color: var(--sh-steel); } +.g-tb .g-note.warn { border-color: #ecd9ab; border-left-color: var(--sh-brass); background: var(--sh-brass-wash); color: var(--sh-brass); } +.g-tb .g-note.warn svg { color: var(--sh-brass); } +.g-tb .g-note.ok { border-color: #c6dcc9; border-left-color: #2e7d4f; background: #f4f9f5; color: #2e7d4f; } +.g-tb .g-note.ok svg { color: #2e7d4f; } +.g-tb .g-ta.mono { font-family: var(--sh-mono); font-size: 11.5px; } +.g-tb .g-btn.xs { height: 22px; padding: 0 8px; font-size: 10.5px; letter-spacing: 0; text-transform: none; } +/* 调用参数两列排:字段是**声明里的键**,不是题头 —— 保 mono 但不转大写(大写把驼峰读没了) */ +.g-tbargs { display: grid; grid-template-columns: repeat(auto-fit, minmax(208px, 1fr)); gap: 11px 18px; } +.g-tb-f { min-width: 0; } +.g-tb-f .g-field > em { text-transform: none; letter-spacing: .03em; font-size: 10.5px; color: var(--sh-ink); } +/* 必填不靠星号靠**颜色**:注记转黄铜(黄铜在这套语言里恒表「生效 / 裁定 / 人」这类要紧事) */ +.g-tb-f .g-field > em i { font-family: var(--sh-mono); font-size: 9px; letter-spacing: .06em; color: var(--sh-muted); } +.g-tb-f.req .g-field > em i { color: var(--sh-brass); font-weight: 600; } + +/* 起单条 = 装配单的落款:题在左,动作在右,一行收口 */ +.g-tbrun { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; } +.g-tbrun > .g-tb-f { flex: 0 1 440px; } +.g-tbrun > .g-acts { flex: none; margin-left: auto; } + +/* ── PKG — 流程包:一包一行,生效版行按黄铜左线(与流程区的「生效版」同一记号)── */ +.g-tbpkgs { display: flex; flex-direction: column; gap: 7px; } +.g-tbpkg { display: flex; flex-direction: column; gap: 5px; padding: 9px 11px; border: 1px solid var(--sh-line); border-left: 3px solid var(--sh-line); background: #fff; text-align: left; font: inherit; cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast); } +.g-tbpkg:hover { border-color: #aeb9c6; } +.g-tbpkg.on { border-color: #cbb98d; border-left-color: var(--sh-brass); background: var(--sh-brass-wash); } +.g-tbpkg:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; } +.g-tbpkg b { font-size: 12.5px; font-weight: 700; color: var(--sh-ink); } +.g-tbpkg .id { font-family: var(--sh-mono); font-size: 9.5px; letter-spacing: .06em; color: var(--sh-muted); overflow-wrap: anywhere; } +.g-tbpkg .cnt { display: flex; gap: 10px; font-family: var(--sh-mono); font-size: 9.5px; color: var(--sh-muted); } +.g-tbpkg.on .cnt, .g-tbpkg.on .id { color: #8a7743; } + +/* ── ASN — 节点指派:活动点与审批点各一行,留空即照旧轮值 ── */ +.g-asn-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 0 28px; } +.g-asn { display: flex; flex-direction: column; min-width: 0; } +.g-asn-h, .g-asn-r { display: grid; grid-template-columns: 38px 38px minmax(0, 1fr) minmax(130px, 175px); align-items: center; gap: 10px; } +.g-asn-h { font: 600 8.5px/1 var(--sh-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--sh-muted); padding-bottom: 6px; border-bottom: 1px solid var(--sh-line); } +.g-asn-r { padding: 5px 0; border-bottom: 1px dashed #eef1f5; } +.g-asn-r:last-child { border-bottom: 0; } +.g-asn-r > .nid { font-family: var(--sh-mono); font-size: 11px; font-weight: 700; color: var(--sh-ink); } +.g-asn-r > .kd { font: 600 8.5px/1 var(--sh-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--sh-muted); } +.g-asn-r > .lb { font-size: 11.5px; color: #3a4452; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.g-asn-r.pinned > .nid, .g-asn-r.pinned > .lb { color: var(--sh-brass); } + +/* ── 图签栏:工程图右下角那张表 —— 一条流的身份卡,纯细线不着色,把颜色让给 TRACE ── */ +.g-tblock { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 1px; background: var(--sh-line); } +.g-tblock > div { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 10px 12px 11px; background: #fff; } +.g-tblock em { font: 600 8.5px/1 var(--sh-mono); font-style: normal; letter-spacing: .14em; text-transform: uppercase; color: var(--sh-muted); } +.g-tblock b { font-family: var(--sh-mono); font-size: 12px; font-weight: 700; color: var(--sh-ink); overflow-wrap: anywhere; } +.g-tblock b.brass { color: var(--sh-brass); } +.g-tblock b.run { color: #2e6f8f; } + +/* 派工带:测试台不代答 —— 任务真的落在别人工位上,这一条必须说满 */ +.g-tbdesk { display: flex; flex-direction: column; gap: 6px; padding: 10px 13px 11px; border: 1px solid #cbb98d; border-left: 3px solid var(--sh-brass); background: var(--sh-brass-wash); } +.g-tbdesk-h { display: flex; align-items: center; gap: 7px; font: 600 9.5px/1 var(--sh-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--sh-brass); } +.g-tbdesk-h .g-btn { margin-left: auto; border-color: #cbb98d; background: #fff; color: var(--sh-brass); } +.g-tbdesk-h .g-btn:hover { border-color: var(--sh-brass); background: #fff; } +.g-tbdesk-r { display: flex; flex-wrap: wrap; align-items: baseline; gap: 7px; font-size: 12px; color: #6f4b14; } +.g-tbdesk-r b { font-weight: 700; color: var(--sh-brass); } +.g-tbdesk-r .nid { font-family: var(--sh-mono); font-size: 10px; color: #8a7743; } +.g-tbdesk p { margin: 0; font-size: 11px; line-height: 1.8; color: #8a7743; } + +/* ══ TRC — 全链 TRACE:走查记录纸 ══ + 一行两读:左半是**结构**(枝线连出 span 树,方角接头,细线画法同图框), + 右半是**时间**(同一条刻度轴上的条,底纹是等距细格 —— 方格纸)。 + 未收尾的段按**剖面线**画:图纸里未定型的剖切面就是这么标的,不用再解释「进行中」。 */ +.g-trc { --trc-cols: 62px minmax(0, 1fr) 58px minmax(190px, 1.05fr); display: flex; flex-direction: column; } +/* 图例:只列这条流真出现过的 kind,顺带交代各出现几次 */ +.g-trc-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 9px 14px 10px; border-bottom: 1px solid var(--sh-line); background: #fbfcfd; } +.g-trc-lg { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: #3a4452; } +.g-trc-lg i { width: 9px; height: 9px; flex: none; background: var(--tk, #6a7686); } +.g-trc-lg em { font: 600 8.5px/1 var(--sh-mono); font-style: normal; letter-spacing: .12em; color: var(--tk, #6a7686); } +.g-trc-lg b { font-family: var(--sh-mono); font-size: 9.5px; font-weight: 700; color: var(--sh-muted); } +/* 刻度带:轴的读数写在这里,底下每一行的方格与它同一组百分比 —— 对得上不是碰巧 */ +.g-trc-rule { display: grid; grid-template-columns: var(--trc-cols); align-items: end; border-bottom: 1px solid var(--sh-line); font: 600 8.5px/1 var(--sh-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--sh-muted); } +.g-trc-rule > span { padding: 8px 10px 6px; } +.g-trc-rule > .t { text-align: right; } +.g-trc-rule > .d { text-align: right; } +.g-trc-rule > .g { position: relative; height: 26px; padding: 0; margin-right: 14px; } +.g-trc-rule .tick { position: absolute; bottom: 5px; font-family: var(--sh-mono); font-size: 8.5px; letter-spacing: .06em; text-transform: none; color: var(--sh-muted); transform: translateX(-1px); white-space: nowrap; } +.g-trc-rule .tick::before { content: ""; position: absolute; left: 0; bottom: -5px; height: 4px; border-left: 1px solid #c6cfd9; } +.g-trc-rule .tick.last { transform: translateX(-100%); } +.g-trc-rule .tick.last::before { left: auto; right: 0; } + +.g-trc-item { min-width: 0; } +.g-trc-item:last-child .g-trc-row { border-bottom: 0; } +.g-trc-row { display: grid; grid-template-columns: var(--trc-cols); align-items: center; width: 100%; padding: 0; border: 0; border-bottom: 1px dashed #eef1f5; background: transparent; font: inherit; text-align: left; } +button.g-trc-row { cursor: pointer; transition: background var(--t-fast); } +button.g-trc-row:hover { background: #f7f9fc; } +button.g-trc-row:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; } +.g-trc-row.open-x { background: #f7f9fc; border-bottom-style: solid; border-bottom-color: var(--sh-line); } +/* 时刻:相对起单的偏移,等宽数字对齐成一列读数 */ +.g-trc-t { padding: 0 10px 0 14px; text-align: right; font-family: var(--sh-mono); font-size: 9.5px; font-variant-numeric: tabular-nums; color: var(--sh-muted); } +.g-trc-t.zero { color: var(--sh-steel); font-weight: 700; } +/* 结构半:枝线 + 方形字号 + 名 */ +.g-trc-n { display: flex; align-items: center; gap: 8px; min-width: 0; align-self: stretch; padding: 5px 12px 5px 0; } +.g-trc-tree { display: flex; flex: none; align-self: stretch; } +.g-trc-tree i { position: relative; flex: none; width: 14px; } +.g-trc-tree i.on::before { content: ""; position: absolute; left: 6px; top: 0; bottom: 0; border-left: 1px solid #dfe5ec; } +.g-trc-tree i.elb::before { content: ""; position: absolute; left: 6px; top: 0; width: 7px; height: 50%; border-left: 1px solid #dfe5ec; border-bottom: 1px solid #dfe5ec; } +.g-trc-tree i.elb.mid::after { content: ""; position: absolute; left: 6px; top: 50%; bottom: 0; border-left: 1px solid #dfe5ec; } +.g-trc-k { flex: none; display: grid; place-items: center; width: 17px; height: 17px; border: 1px solid var(--tk); background: #fff; color: var(--tk); } +.g-trc-kn { flex: none; width: 46px; font: 600 8.5px/1 var(--sh-mono); letter-spacing: .1em; color: var(--tk); } +.g-trc-nm { min-width: 0; font-size: 11.5px; color: var(--sh-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.g-trc-me { flex: none; display: inline-flex; align-items: center; gap: 5px; font-family: var(--sh-mono); font-size: 9.5px; color: var(--sh-muted); } +.g-trc-me .who { color: var(--sh-brass); } +.g-trc-x-i { flex: none; color: #b6bfc9; display: grid; place-items: center; } +/* 耗时 */ +.g-trc-d { padding: 0 10px; text-align: right; font-family: var(--sh-mono); font-size: 9.5px; font-variant-numeric: tabular-nums; color: #3a4452; } +.g-trc-d.live { color: var(--sh-brass); } +/* 时间半:方格纸底 + 条 */ +.g-trc-g { position: relative; align-self: stretch; margin-right: 14px; background-image: repeating-linear-gradient(to right, var(--sh-grid) 0 1px, transparent 1px var(--tick)); } +.g-trc-bar { position: absolute; top: 50%; height: 8px; margin-top: -4px; min-width: 2px; background: var(--tk); } +/* 包络 = 尺寸线:两端界限 + 中缝细线 + 极淡的底 —— 流与节点自己不干活,时长是子段的外框, + 画成实心黑条会把真正干活的那几笔(两三毫秒)压成看不见的发丝 */ +.g-trc-bar.env { height: 11px; margin-top: -5.5px; background: color-mix(in srgb, var(--tk) 7%, transparent); border-left: 1px solid var(--tk); border-right: 1px solid var(--tk); } +.g-trc-bar.env::before { content: ""; position: absolute; left: 0; right: 0; top: 50%; border-top: 1px solid var(--tk); } +/* 续画线:细剖面线 + 右端箭头 —— 图纸里「未完待续」就是这么标的,不必再写一句「还在跑」 */ +.g-trc-bar.open { background: repeating-linear-gradient(135deg, var(--tk) 0 1px, transparent 1px 5px); min-width: 0; } +.g-trc-bar.open.env { background: none; border-left: 0; border-right: 0; } +.g-trc-bar.open.env::before { border-top-style: dashed; } +.g-trc-bar.err { background: #b0413e; } +.g-trc-bar.open::after { content: ""; position: absolute; right: -7px; top: 0; border: 4px solid transparent; border-left: 6px solid var(--tk); } +.g-trc-bar.open.env::after { top: 50%; margin-top: -4px; } +/* 展开:入参 / 出参 / 属性 / 错误 —— 摊在原地,不弹窗 */ +.g-trc-x { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); align-items: start; gap: 10px; padding: 11px 14px 13px; border-bottom: 1px solid var(--sh-line); background: #f7f9fc; } +.g-trc-x figure { display: flex; flex-direction: column; gap: 5px; min-width: 0; margin: 0; } +.g-trc-x figcaption { font: 600 8.5px/1 var(--sh-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--sh-steel); } +.g-trc-x pre { margin: 0; max-height: 148px; overflow: auto; padding: 8px 10px; border: 1px solid var(--sh-line); background: #fff; font-family: var(--sh-mono); font-size: 10.5px; line-height: 1.65; color: #3a4452; white-space: pre-wrap; overflow-wrap: anywhere; } +.g-trc-x figure.bad figcaption { color: #b0413e; } +.g-trc-x figure.bad pre { border-color: #e3b9b7; background: #fdf7f6; color: #b0413e; } +/* kind 字典:结构三色走灰阶(骨架),动作三色各有其人(工具 / 模型 / 人)—— 有色的才是「谁动的手」 */ +.g-tb .k-workflow { --tk: #46586a; } +.g-tb .k-node { --tk: #161b22; } +.g-tb .k-step { --tk: #6a7686; } +.g-tb .k-tool { --tk: #2c6b76; } +.g-tb .k-model { --tk: #4b4a8c; } +.g-tb .k-human { --tk: #8a5f1c; } + +/* ── NOD — 节点实例:哪个在跑 / 等 / 已放行 ── */ +.g-nins { display: flex; flex-direction: column; } +.g-nins-h, .g-nins-r { display: grid; grid-template-columns: 44px minmax(0, 1fr) 74px 96px minmax(0, .9fr); align-items: center; gap: 10px; padding: 0 14px; } +.g-nins-h { padding-top: 10px; padding-bottom: 7px; border-bottom: 1px solid var(--sh-line); font: 600 8.5px/1 var(--sh-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--sh-muted); } +.g-nins-r { padding-top: 7px; padding-bottom: 7px; border-bottom: 1px dashed #eef1f5; } +.g-nins-r:last-child { border-bottom: 0; } +.g-nins-r > .nid { font-family: var(--sh-mono); font-size: 11px; font-weight: 700; color: var(--sh-ink); } +.g-nins-r > .lb { font-size: 11.5px; color: #3a4452; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.g-nins-r > .st { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; color: var(--sh-muted); } +.g-nins-r > .st i { flex: none; width: 8px; height: 8px; background: currentcolor; } +.g-nins-r > .st.run { color: #2e6f8f; } +.g-nins-r > .st.wait { color: var(--sh-brass); } +.g-nins-r > .st.done { color: #2e7d4f; } +.g-nins-r > .st.bad { color: #b0413e; } +.g-nins-r > .who { font-size: 11px; color: var(--sh-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.g-nins-r > .who b { font-weight: 600; color: #3a4452; } +.g-nins-r > .ins { font-family: var(--sh-mono); font-size: 9.5px; color: var(--sh-muted); text-align: right; } + +/* 空页 = 图纸空页(与编排区的 .g-sheetempty 同款,这里自带一份以免依赖那边的容器) */ +.g-tb-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; max-width: 460px; margin: 0 auto; padding: 26px 0 30px; text-align: center; } +.g-tb-empty svg { color: var(--sh-steel); opacity: .5; } +.g-tb-empty b { font-size: 12px; font-weight: 700; color: var(--sh-steel); } +.g-tb-empty p { margin: 0; font-size: 11px; line-height: 1.9; color: var(--sh-muted); } + +@media (max-width: 1240px) { + .g-tb-rig { grid-template-columns: minmax(0, 1fr); } + .g-trc { --trc-cols: 56px minmax(0, 1fr) 52px minmax(150px, .8fr); } + .g-trc-kn { display: none; } +} diff --git a/apps/web/src/features/gov-testbed/internal/call-form.tsx b/apps/web/src/features/gov-testbed/internal/call-form.tsx new file mode 100644 index 0000000..bd4271c --- /dev/null +++ b/apps/web/src/features/gov-testbed/internal/call-form.tsx @@ -0,0 +1,183 @@ +/** + * 调用参数表单 —— 按流程包 `callInterface.input`(JSON Schema 2020-12 子集)现生成。 + * + * 表单不认业务字段,只认声明:必填由 `required` 决定,控件由 `type` / `enum` 决定。 + * 前端这一层只做**填得顺手**,不做裁决 —— 合不合契约由后端 ajv 判,判词原样回显。 + * 少填一个必填就把按钮按灰,是为了不让人白跑一趟接口,不是替后端把关。 + */ +import type { ReactNode } from 'react'; +import { Field } from '@web-shared/gov/primitives.tsx'; + +/** 控件五型:字符串 / 数值 / 真假 / 枚举 / 复合(对象与数组落 JSON 文本框) */ +export type CallFieldKind = 'text' | 'number' | 'bool' | 'enum' | 'json'; + +export type CallField = { + readonly key: string; + readonly required: boolean; + readonly kind: CallFieldKind; + readonly options: readonly string[]; + /** 声明里的人读名与说明(有就用,没有就退回字段名) */ + readonly title: string | undefined; + readonly hint: string | undefined; +}; + +export type CallValues = Readonly>; + +function asRecord(v: unknown): Record | undefined { + return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record) : undefined; +} + +function asStrings(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; +} + +function kindOf(prop: Record): CallFieldKind { + if (asStrings(prop['enum']).length > 0) return 'enum'; + const t = prop['type']; + if (t === 'number' || t === 'integer') return 'number'; + if (t === 'boolean') return 'bool'; + if (t === 'object' || t === 'array') return 'json'; + return 'text'; +} + +/** + * 声明 → 字段清单。**必填排前面**:properties 的键序来自库里的 jsonb, + * 是存储顺序不是语义顺序,照抄等于把「哪几个非填不可」藏进随机次序里。 + */ +export function callFieldsOf(schema: Readonly> | undefined): CallField[] { + const props = asRecord(schema?.['properties']); + if (props === undefined) return []; + const required = new Set(asStrings(schema?.['required'])); + const fields = Object.entries(props).map(([key, raw]) => { + const prop = asRecord(raw) ?? {}; + const title = typeof prop['title'] === 'string' ? prop['title'] : undefined; + const desc = typeof prop['description'] === 'string' ? prop['description'] : undefined; + const fmt = typeof prop['format'] === 'string' ? prop['format'] : undefined; + const t = typeof prop['type'] === 'string' ? prop['type'] : 'any'; + return { + key, + required: required.has(key), + kind: kindOf(prop), + options: asStrings(prop['enum']), + title, + hint: desc ?? (fmt === undefined ? t : `${t} · ${fmt}`), + }; + }); + return fields.sort((a, b) => + a.required === b.required ? a.key.localeCompare(b.key) : a.required ? -1 : 1, + ); +} + +/** 起单载荷 —— 空的选填字段不发出去,免得把 `""` 当成「填过了」 */ +export function buildCallData( + fields: readonly CallField[], + values: CallValues, +): { data: Record; errors: string[] } { + const data: Record = {}; + const errors: string[] = []; + for (const f of fields) { + const raw = (values[f.key] ?? '').trim(); + if (raw === '') { + if (f.required) errors.push(`${f.key} 必填`); + continue; + } + if (f.kind === 'number') { + const n = Number(raw); + if (Number.isNaN(n)) errors.push(`${f.key} 要是数字`); + else data[f.key] = n; + continue; + } + if (f.kind === 'bool') { + data[f.key] = raw === 'true'; + continue; + } + if (f.kind === 'json') { + try { + data[f.key] = JSON.parse(raw) as unknown; + } catch { + errors.push(`${f.key} 不是合法 JSON`); + } + continue; + } + data[f.key] = raw; + } + return { data, errors }; +} + +/** 占位值:按类型各填一份能过契约的样子,让人一键就能跑通一趟再回头改 */ +export function placeholderValues(fields: readonly CallField[]): Record { + const out: Record = {}; + for (const f of fields) { + out[f.key] = + f.kind === 'number' + ? '0' + : f.kind === 'bool' + ? 'false' + : f.kind === 'enum' + ? (f.options[0] ?? '') + : f.kind === 'json' + ? '{}' + : `${f.key}-占位`; + } + return out; +} + +/** + * 一格一字段。必填不靠星号靠**颜色**:整条注记转黄铜(黄铜在这套图纸语言里恒表 + * 「生效 / 裁定 / 人」这类要紧事),扫一眼就知道哪几格非填不可。 + */ +export function CallForm({ + fields, + values, + onChange, +}: { + fields: readonly CallField[]; + values: CallValues; + onChange: (key: string, v: string) => void; +}): ReactNode { + return ( + <> + {fields.map((f) => { + const v = values[f.key] ?? ''; + const hint = `${f.required ? '必填' : '选填'}${f.hint === undefined ? '' : ` · ${f.hint}`}`; + return ( +
+ + {f.kind === 'enum' ? ( + + ) : f.kind === 'bool' ? ( + + ) : f.kind === 'json' ? ( +