E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排

活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2):
- 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进
  S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视
- 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo,
  计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标)
- 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源,
  正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填
- 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键;
  标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段
- 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数
- 阶段刻度对齐文档 7 步;起单样例预填满

基座侧:
- 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY
  广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻
- 多行 write 整批先验后写(dryRun),被拒 = 一行都没落
- 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors)
- 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行)
- 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端
- 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸
- 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组;
  JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖
- 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01

契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo,
objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。
1700 测 / typecheck / lint / depcruise / api:check 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
xcs
2026-08-25 01:23:30 +08:00
co-authored by Claude Fable 5
parent 92dd729c36
commit f3397a60ef
91 changed files with 3919 additions and 350 deletions
+3 -7
View File
@@ -28,6 +28,7 @@ import { call } from '@web-shared/typed-fetch.ts';
import { useChatWidth } from '@web-shared/useChatWidth.ts';
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { queryKeys } from './query.ts';
import { Btn } from '@web-shared/Btn.tsx';
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
@@ -165,15 +166,10 @@ export function AppShell() {
<span className="jc-topbar-title">{NAV_MODULES.find((m) => m.key === module)?.label ?? ''}</span>
<div className="jc-topbar-user">
<span className="jc-topbar-name">{me}</span>
<button
type="button"
className="jc-topbar-logout"
disabled={logout.isPending}
onClick={() => logout.mutate()}
>
<Btn className="jc-topbar-logout" busy={logout.isPending} onClick={() => logout.mutate()}>
<Ico name="log-out" s={14} />
退出登录
</button>
</Btn>
</div>
</header>
+3 -2
View File
@@ -36,6 +36,7 @@ import { ArchiveHome } from '@features/gov-archive';
import { TestbedHome } from '@features/gov-testbed';
import { CockpitHome } from '@features/gov-memory';
import { ChanPage, LlmPage, ModelsHome, SystemHome } from '@features/gov-system';
import { Btn } from '@web-shared/Btn.tsx';
/* ── URL 编解码:`#/<surface>/<zone>/<tab>[/<k>:<id>…]` ────────────────── */
@@ -203,10 +204,10 @@ export function GovShell({ surface }: { surface: Surface }) {
<Ico name="list-checks" s={13} />
工作台
</a>
<button className="g-tbtn" onClick={() => logout.mutate()} disabled={logout.isPending}>
<Btn className="g-tbtn" onClick={() => logout.mutate()} busy={logout.isPending}>
<Ico name="log-out" s={13} />
登出
</button>
</Btn>
</div>
</header>
+5
View File
@@ -62,6 +62,11 @@ if (govSurface !== undefined) {
await import('./styles/proto.vendor.css');
await import('./styles/app.css');
}
/*
* 键感与「在办」那一层三面共用,**装在最后** —— 同名规则以它为准。
* (2026-08-24 用户:「点击了都不知道点没点上」;见 styles/press.css 头注)
*/
await import('./styles/press.css');
/**
* 鉴权壳(ADR-010)—— 会话是**服务端**的事:401 即落登录页。
+10
View File
@@ -2907,6 +2907,16 @@
.proto-root .jc-tnav-btn:disabled { opacity: .35; cursor: default; }
.proto-root .jc-tnav-n { min-width: 34px; text-align: center; font-size: 11px; font-weight: 800; color: var(--ink-2); }
/* 两组翻页同时在场时(多张卡各自多页)各带一个字,单独在场时不摆 —— 一个数不必解释自己 */
/*
* 保存之后的红字 —— **有行没落下就摆在件的上方**(2026-08-24 用户:件上新增的
* 客户推到下一步就不见了)。它不是错误提示:别的格子确实存下了,只有没钥匙的那几行没落 ——
* 所以用「提醒」的分量,点名缺哪几列,人补上再存一次。
*/
.proto-root .jc-savewarn { display: flex; align-items: flex-start; gap: 7px; margin: 0 0 10px;
padding: 9px 12px; border: 1px solid #f3b6b3; border-radius: 9px; background: #fff5f5;
color: var(--danger); font-size: 12.5px; line-height: 1.6; }
.proto-root .jc-savewarn svg { flex: none; margin-top: 2px; }
.proto-root .jc-tnav-mk { font-style: normal; font-weight: 700; color: var(--ink-3); margin-right: 3px; }
.proto-root .jc-tnav-btn.back { width: auto; padding: 0 14px 0 10px; font-size: 12.5px; font-weight: 700; line-height: 1; white-space: nowrap; }
.proto-root .jc-tnav-btn svg { flex: none; display: block; }
+54
View File
@@ -245,6 +245,60 @@
哪几张只是一单一份的凭证。而「该不该管它、谁有权改它」全看性质:
主数据要治理(谁能改、依据、去重、时效),单据只需查得到、派生根本不该手写。
所以分区要**看得见**:一条带色的区头 + 一句说清这一类是什么。 */
/* 试跑台:「连主数据一起落真库」那一格 —— 破例要看得见自己在破例 */
.tb-land { display: flex; align-items: flex-start; gap: 9px; padding: 10px 12px; cursor: pointer;
border: 1px dashed var(--g-line, #dfe1e5); border-radius: 10px; background: #fbfbfc; transition: all .16s; }
.tb-land:hover { border-color: var(--g-brand-soft, #b6d0ff); }
.tb-land.on { border-style: solid; border-color: #f3b6b3; background: #fff7f6; }
.tb-land input { margin-top: 2px; flex: none; }
.tb-land b { display: block; font-size: 12.5px; font-weight: 700; color: var(--g-ink, #1f2329); }
.tb-land.on b { color: #c9372c; }
.tb-land em { display: block; margin-top: 2px; font-style: normal; font-size: 11.5px; line-height: 1.65; color: var(--g-ink-3, #646a73); }
/* ══ 数据域:按活动分组(2026-08-24 重写)══════════════════════════════
一屏回答的是「我这个活动写了哪几张表、现在各有几行」——
所以行数是主角:字号最大、放行末端;性质降成一枚小徽,地址与主键退成灰字。 */
.g-actdata { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px 13px;
background: #fff; border: 1px solid var(--g-line); border-radius: 12px; box-shadow: var(--g-shadow, 0 1px 2px rgba(31,35,41,.05)); }
.g-actdata + .g-actdata { margin-top: 14px; }
.g-actdata-h { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.g-actdata-h > svg { color: var(--g-brand, #3370ff); flex: none; }
.g-actdata-h b { font-size: 14.5px; font-weight: 800; color: var(--g-ink, #1f2329); }
.g-actdata-h .n { margin-left: auto; font-size: 12px; font-weight: 700; color: var(--g-ink-3, #646a73); }
.g-actdata-h .n em { font-style: normal; color: var(--g-brand, #3370ff); }
.g-actdata-h p { flex-basis: 100%; margin: 2px 0 0; font-size: 12px; line-height: 1.7; color: var(--g-ink-3, #646a73); }
.g-actdata-tables { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 8px; }
.g-tbl { display: grid; grid-template-columns: 1fr auto; grid-template-areas: 't n' 'a n';
gap: 2px 12px; width: 100%; padding: 9px 12px; text-align: left; font: inherit; cursor: pointer;
background: var(--g-soft, #f7f8fa); border: 1px solid transparent; border-radius: 9px; transition: all .16s; }
.g-tbl:hover { background: #fff; border-color: var(--g-brand-soft, #b6d0ff); box-shadow: 0 2px 8px rgba(51,112,255,.10); }
.g-tbl-t { grid-area: t; display: flex; align-items: center; gap: 6px; min-width: 0; }
.g-tbl-t b { font-size: 13px; font-weight: 700; color: var(--g-ink, #1f2329); }
.g-tbl-k { font-style: normal; font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 4px;
background: #eef1f5; color: #5a6068; }
.g-tbl-k.t-m { background: #eaf1ff; color: #2b5fd9; }
.g-tbl-k.t-r { background: #f2f3f5; color: #5a6068; }
.g-tbl-k.t-d { background: #fdf0e6; color: #b25e15; }
.g-tbl-a { grid-area: a; font-size: 11px; color: var(--g-ink-4, #8f959e); overflow-wrap: anywhere; }
.g-tbl-a em { font-style: normal; }
/* 行数:这一屏的主角 */
.g-tbl-n { grid-area: n; align-self: center; text-align: right; font-size: 11.5px; color: var(--g-ink-3, #646a73); white-space: nowrap; }
.g-tbl-n b { font-size: 17px; font-weight: 800; color: var(--g-ink, #1f2329); margin-right: 2px; }
.g-tbl-n .none { font-style: normal; color: var(--g-ink-4, #8f959e); }
.g-tbl-n .draft { display: block; font-style: normal; font-size: 10.5px; font-weight: 700; color: var(--g-brand, #3370ff); }
.g-actdata-runs { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; padding-top: 2px; }
.g-actdata-runs > em { font-style: normal; font-size: 11px; font-weight: 700; color: var(--g-ink-4, #8f959e); }
.g-run { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 999px;
font-size: 11px; font-weight: 700; background: #f2f3f5; color: #5a6068; }
.g-run i { font-style: normal; font-weight: 600; opacity: .72; }
.g-run.t-ok { background: #e9f7ee; color: #1f7a45; }
.g-run.t-live { background: #eaf1ff; color: #2b5fd9; }
.g-run.t-wait { background: #fdf0e6; color: #b25e15; }
.g-run.t-warn { background: #fff1f0; color: #c9372c; }
.g-actdata.t-u { background: #fbfbfc; }
.g-kind { display: flex; flex-direction: column; gap: 12px; }
.g-kind + .g-kind { margin-top: 22px; }
.g-kind-h { display: grid; grid-template-columns: auto auto auto 1fr; align-items: center; gap: 8px;
+136
View File
@@ -0,0 +1,136 @@
/* ==========================================================================
按下去要有反应,在办事要说出来 —— 三面共用的一层(2026-08-24)
起因是用户一句话:「点击了都不知道点没点上」。查下来是两件事各缺一半:
① **按下那一刻没有键感** —— 治理台整套 `g-*` 控件一条 `:active` 都没有,
工作台也只有零星几个。点下去屏上纹丝不动,人只能靠「后来发生了什么」倒推。
② **等回音那一段完全静默** —— 请求在路上的几百毫秒里,按钮长得和没点过一模一样。
这一段恰恰是人最想要个准信的时候(慢的接口要好几秒)。
所以这一层只做两件事:**键感**(`:active`)与**在办**(`aria-busy`)。
两者都只动 transform / background,不改布局、不换 DOM —— 谁都不必为它改结构。
装载在两份基座之后(main.tsx),所以同名规则以它为准;
台面自己已经写过的 `:active` 特异度更高,原样保留(它们本就是同一个手感)。
========================================================================== */
/* --------------------------------------------------------------------------
一 · 键感:按下就下沉
小控件缩 .97 手感最准;整行整卡那种大件同样缩会「晃」,只给极轻的一点。
-------------------------------------------------------------------------- */
button:not(:disabled):not([aria-busy='true']):active,
summary:active,
[role='button']:not([aria-disabled='true']):active {
transform: scale(.97);
}
/*
* 按下要**立刻**到位。不少控件把 transform 一起塞进了 0.16s 的过渡里
* (`transition: all var(--t-fast)`)—— 而一次点击常常比这还短:
* 手抬起来的时候键帽才刚下去一半,看着就像「没反应」。
* 按下这一刻不过渡;松开后 `:active` 不再命中,回弹仍走控件自己的过渡(软的)。
* 两档特异度是为了压过工作台那批 `.proto-root .x`(0,2,0)的基础规则。
*/
.proto-root button:not(:disabled):active,
.proto-root [role='button']:not([aria-disabled='true']):active,
button:not(:disabled):active,
[role='button']:not([aria-disabled='true']):active {
transition-duration: 0s;
}
/* 大件(整行清单 / 整张卡 / 整条规则):幅度收到几乎看不见,只留一点「陷下去」 */
.g-nrow:active,
.g-entry:active,
.g-tile:active,
.g-wf:active,
.g-rule:active,
.g-fcard:active,
.g-mcard:active,
.proto-root .jc-dv-row:active,
.proto-root .task-item:active,
.proto-root .jc-branch:active {
transform: scale(.995);
}
/* 键盘走到哪儿要看得见 —— 鼠标点击不出这个圈(:focus-visible 的本分) */
button:focus-visible,
a:focus-visible,
summary:focus-visible,
[role='button']:focus-visible {
outline: 2px solid var(--brand, #3370ff);
outline-offset: 2px;
}
/* --------------------------------------------------------------------------
二 · 在办:脚下走一道不定长进度条
**为什么是 `::after` 而不是 background-image**:第一版把这道条画进按钮自己的
background,在真页面上量出来是**画不出来的** —— `.tbtn` 这类控件的皮肤用
`background:` 简写上色,特异度(0,2,0 起)压过 `[aria-busy]`(0,1,0),
`background-image` 当场被顶回 none(动画倒是在跑,条却不存在)。
`::after` 这一层没人跟它抢:全仓的按钮类没有一条定义过 `::before/::after`(审过)。
同理**不给 `<select>` 挂 aria-busy** —— 替换元素画不出伪元素,它那一行才是该点亮的东西。
条只动 transform,`currentColor` 取字色:白字蓝底的主钮出白条,深字白底的次钮出深条。
不给 `disabled`:那会把按钮画成灰的,而它此刻正在**做**这件事,不是做不了。
真做不了的照旧 `disabled`(见 shared/Btn.tsx 的注)。
-------------------------------------------------------------------------- */
[aria-busy='true'] {
position: relative;
cursor: progress;
}
[aria-busy='true']::after {
content: '';
position: absolute;
/* 左右各让出一点、离底边抬起 2px —— 贴着边画会与控件自己的边框糊成一条,看着只是「框粗了」 */
left: 6px;
right: 6px;
bottom: 2px;
height: 3px;
border-radius: 999px;
background: currentColor;
opacity: .85;
pointer-events: none;
animation: jc-busy-run 1.1s cubic-bezier(.45, 0, .55, 1) infinite;
}
/* 从左抽出、再从左收走 —— 不定长进度条的经典两拍,全程只有 transform 在动 */
@keyframes jc-busy-run {
0% { transform: scaleX(0); transform-origin: left; }
45% { transform: scaleX(1); transform-origin: left; }
55% { transform: scaleX(1); transform-origin: right; }
100% { transform: scaleX(0); transform-origin: right; }
}
/* 在办的时候不再摆「可点」的手感:悬停不上色、按下不下沉(点了也不算数) */
[aria-busy='true']:hover,
[aria-busy='true']:active {
transform: none;
}
/* --------------------------------------------------------------------------
三 · 关掉动效的人:条不跑了,但**仍然要看得出在办事** ——
停在中间摆一道静态的杠,别让这类用户回到「什么都没有」那个起点。
-------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
button:active,
summary:active,
[role='button']:active,
.g-nrow:active,
.g-entry:active,
.g-tile:active,
.g-wf:active,
.g-rule:active {
transform: none;
}
/* 条不跑了,但**仍然要看得出在办事** —— 摆一道静态的杠,别让这类用户回到「什么都没有」那个起点 */
[aria-busy='true']::after {
animation: none;
transform: scaleX(1);
}
}
@@ -10,6 +10,7 @@ import type { Session } from '@contracts';
import { loginRoute } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { ApiCallError, call } from '@web-shared/typed-fetch.ts';
import { Btn } from '@web-shared/Btn.tsx';
export function LoginPage({ onLogin }: { onLogin(session: Session): void }) {
const [account, setAccount] = useState('');
@@ -75,9 +76,10 @@ export function LoginPage({ onLogin }: { onLogin(session: Session): void }) {
</div>
)}
<button className="jc-login-go" type="submit" disabled={busy || !ready}>
{/* 在办的那一段照旧可提交 —— `submit` 自己有 `busy` 闸,按下去什么也不会多发一遍 */}
<Btn className="jc-login-go" type="submit" disabled={!ready} busy={busy}>
{busy ? '登录中…' : '登录'}
</button>
</Btn>
<p className="jc-login-hint">初始口令由部署时的 JCAI_SEED_PASSWORD 决定</p>
</form>
</div>
@@ -14,6 +14,7 @@ import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { atMinute } from '@web-shared/at.ts';
import { Btn } from '@web-shared/Btn.tsx';
const STATE_CHIP = {
pending: { tone: 'todo' as const, label: '待审批', pulse: true },
@@ -159,24 +160,24 @@ export function ApprovalDesk() {
/>
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
<div className="jc-mod-actions">
<button
type="button"
<Btn
className="card-btn"
busy={decide.isPending && decide.variables.verdict === 'reject'}
disabled={decide.isPending}
onClick={() => decide.mutate({ item: sel, verdict: 'reject' })}
>
<Ico name="rotate-ccw" s={13} />
驳回
</button>
<button
type="button"
</Btn>
<Btn
className="card-btn primary"
busy={decide.isPending && decide.variables.verdict === 'pass'}
disabled={decide.isPending}
onClick={() => decide.mutate({ item: sel, verdict: 'pass' })}
>
<Ico name="check" s={13} />
通过
</button>
</Btn>
</div>
</Section>
) : (
@@ -15,6 +15,7 @@ import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { atFull, atMinute } from '@web-shared/at.ts';
import { Btn } from '@web-shared/Btn.tsx';
const KIND_META: Readonly<Record<string, { icon: string; grad: readonly [string, string]; kicker: string }>> = {
notice: { icon: 'bell', grad: ['#7f3bf5', '#5b21cf'], kicker: '系统 · 通知' },
@@ -261,10 +262,10 @@ function ConfirmFire({ confirm }: { confirm: NonNullable<SystemMessageView['conf
return (
<>
<NoteBand>这封来件命中了触发器,但运营配置要求人工确认 —— 你点了,流程才起;不点,它就停在这儿。</NoteBand>
<button className="jc-btn primary" disabled={fire.isPending} onClick={() => fire.mutate()}>
<Btn className="jc-btn primary" busy={fire.isPending} onClick={() => fire.mutate()}>
<Ico name="play" s={13} />
{fire.isPending ? '起单中…' : '确认起单'}
</button>
</Btn>
{fire.isError && <NoteBand>没起成:{(fire.error as { message?: string }).message ?? '未知原因'}</NoteBand>}
</>
);
@@ -13,6 +13,7 @@ import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { AssistantAside, Avatar, AvatarStack, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { Btn } from '@web-shared/Btn.tsx';
export function MinuteDesk() {
const queryClient = useQueryClient();
@@ -165,15 +166,15 @@ export function MinuteDesk() {
<button type="button" className="card-btn" onClick={() => setComposing(false)}>
取消
</button>
<button
type="button"
<Btn
className="card-btn primary"
disabled={draft.title.trim() === '' || draft.occurredOn.trim() === '' || create.isPending}
disabled={draft.title.trim() === '' || draft.occurredOn.trim() === ''}
busy={create.isPending}
onClick={() => create.mutate()}
>
<Ico name="check" s={13} />
保存纪要
</button>
</Btn>
</div>
</DocPanel>
) : sel === undefined ? (
@@ -235,14 +236,9 @@ export function MinuteDesk() {
onChange={(e) => setPackageId(e.target.value)}
placeholder="流程包 id"
/>
<button
type="button"
className="card-btn primary"
disabled={register.isPending}
onClick={() => register.mutate(r.id)}
>
<Btn className="card-btn primary" busy={register.isPending} onClick={() => register.mutate(r.id)}>
提交起单
</button>
</Btn>
<button type="button" className="card-btn" onClick={() => setRegisterFor(undefined)}>
取消
</button>
@@ -291,10 +287,10 @@ function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button
type="button"
<Btn
className="card-btn primary"
disabled={text.trim() === '' || busy}
disabled={text.trim() === ''}
busy={busy}
onClick={() => {
onAppend(text.trim());
setText('');
@@ -302,7 +298,7 @@ function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void
>
<Ico name="plus" s={13} />
追加
</button>
</Btn>
</div>
);
}
@@ -13,6 +13,7 @@ import { Ico } from '@web-shared/Ico.tsx';
import ScrollArea from '@web-shared/ScrollArea.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { AssistantAside, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
import { Btn } from '@web-shared/Btn.tsx';
const PERIODS: ReadonlyArray<{ k: ReportPeriodName; ico: string }> = [
{ k: 'day', ico: 'calendar-days' },
@@ -183,10 +184,10 @@ export function ReportDesk() {
已确认提交 · 汇报进入组织视图
</span>
) : (
<button type="button" className="card-btn primary" disabled={submit.isPending} onClick={() => submit.mutate()}>
<Btn className="card-btn primary" busy={submit.isPending} onClick={() => submit.mutate()}>
<Ico name="check" s={13} />
确认提交
</button>
</Btn>
)}
</div>
</DocPanel>
@@ -14,6 +14,7 @@ import { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx';
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { ChatStream, WorkingStrip } from './chat-bubbles.tsx';
import { Btn } from '@web-shared/Btn.tsx';
export function TaskChat({
detail,
@@ -115,9 +116,23 @@ export function TaskChat({
}
}}
/>
<button className="copilot-send" disabled={say.isPending}>
<Btn
className="copilot-send"
busy={say.isPending}
onClick={(e) => {
/*
* 这一钮从前**没有 onClick** —— 点它什么都不会发生,只有在文本框里按 Enter 才发得出去。
* 「点了都不知道点没点上」在这儿是字面意思:它根本就没接线。
*/
const box = e.currentTarget.closest('.composer-row')?.querySelector('textarea');
const text = box?.value.trim() ?? '';
if (text === '' || box === null || box === undefined) return;
say.mutate(text);
box.value = '';
}}
>
<Ico name="send" s={14} /> 发送
</button>
</Btn>
</div>
</div>
</div>
@@ -131,6 +146,8 @@ export function TaskChat({
orders={detail.workOrders}
thinking={say.isPending}
working={working}
/* 卡上刚交出去的那一笔还在路上 —— 卡上的钮据此说「在办事」 */
replying={replyCard.isPending || replyOrder.isPending}
onSay={(text) => say.mutate(text)}
onReplyCard={(reply) => replyCard.mutate(reply)}
onReplyOrder={(reply) => replyOrder.mutate(reply)}
@@ -25,6 +25,7 @@ import type { ViewEdits, ViewSave } from '@ui';
import { GuideCard, WorkOrderReport } from './work-order-report.tsx';
import { FileViewer } from './FileViewer.tsx';
import { atFull, atMinute } from '@web-shared/at.ts';
import { Btn } from '@web-shared/Btn.tsx';
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
@@ -189,16 +190,30 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」。
* 落完重取任务投影,整份件跟着一起变。**工单不办结,放行门的位置没动。**
*/
/**
* **有行没落下就说出来**(2026-08-24 用户:「件上新增的那个客户,推到下一步就不见了」)。
* 主键那几格空着就凑不出钥匙、存不住;从前基座悄悄跳过、接口照回 ok ——
* 人看到「保存成功」,东西却不在。现在把没落下的行红字点名,他补上钥匙再存一次。
*/
const [saveWarn, setSaveWarn] = useState<string | undefined>(undefined);
const saveTable = useMutation({
mutationFn: (save: ViewSave) =>
call(saveTableRoute, {
call<{ ok: boolean; rows: number; unkeyed?: Array<{ hint: string }> }>(saveTableRoute, {
params: { taskId: detail.card.id },
body: {
tables: save.tables as Record<string, Array<Record<string, string>>>,
drops: save.drops as Record<string, Array<Record<string, string>>>,
},
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] }),
onSuccess: (out) => {
setSaveWarn(
out.unkeyed === undefined || out.unkeyed.length === 0
? undefined
: `有 ${out.unkeyed.length} 行没能存下 —— ${out.unkeyed.map((u) => u.hint).join(';')}`,
);
void queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] });
},
});
/*
@@ -221,6 +236,14 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
onError: (error: unknown) => setFileError(error instanceof Error ? error.message : '这份资料打不开'),
});
/*
* 正在取的是哪一份 —— 先摘成两个普通值再用。
* 直接在 JSX 里写 `openAssetFile.isPending && openAssetFile.variables.key === …` 不行:
* 上面那一层三元已经按 `isPending` 分过岔,进到 else 分支里它被收窄成「不在办」,
* `variables` 整个不存在(TS 判 never)—— 同一个判断在同一棵树里只能问一次。
*/
const fetchingFileKey = openAssetFile.isPending ? openAssetFile.variables.key : undefined;
const markRead = useMutation({
mutationFn: (input: { commId: string; read: boolean }) =>
call(markCommReadRoute, { params: { commId: input.commId }, body: { read: input.read } }),
@@ -490,7 +513,14 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
vpClassName="wf-body"
inset={-6}
>
{openFile !== undefined ? (
{openAssetFile.isPending ? (
/*
* **取件的那一段要说出来** —— 资料名是件上的一个链接,点它没有按钮可以变样;
* 那份文件又是服务端**现造**出来的字节(造 + 传,慢的有一两秒)。
* 从前这一段屏上一动不动,人只好再点一次 —— 于是同一份资料取两遍。
*/
<EmptyState ico="file-text" tone="brand" title="正在取这一份资料…" desc="文件是台账那一行现造出来的,造好就摆在这儿。" />
) : openFile !== undefined ? (
<FileViewer doc={openFile} href={fileHref} />
) : fileError !== undefined ? (
/* 那一份取不回来:**如实说是哪一句**(服务端原话),别把人留在原地猜 */
@@ -528,16 +558,15 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
不做「打开页签即已读」:那是替人宣布他看过了,而他可能只是路过。
反向也留着:看了一半想留个记号,标回未读。
*/}
<button
type="button"
<Btn
className={'jc-comm-mark' + (m.unread ? '' : ' done')}
disabled={markRead.isPending}
busy={markRead.isPending && markRead.variables.commId === m.id}
aria-label={m.unread ? `把「${m.subject}」标为已读` : `把「${m.subject}」标回未读`}
onClick={() => markRead.mutate({ commId: m.id, read: m.unread })}
>
<Ico name={m.unread ? 'check' : 'rotate-ccw'} s={11} />
{m.unread ? '标为已读' : '标回未读'}
</button>
</Btn>
</header>
<div className="jc-comm-meta">
<span className="jc-mono">{m.channel}</span>
@@ -641,10 +670,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
{detail.assetFiles.every((f) => f.archived) ? null : <i>在办中 · 放行时存档</i>}
</div>
{detail.assetFiles.map((f) => (
<button
<Btn
key={`${f.address}/${f.key}`}
type="button"
className={'jc-dv jc-dv-row s-ok m-file f-' + f.format}
busy={fetchingFileKey === f.key}
onClick={() => {
setFileError(undefined);
openAssetFile.mutate({ address: f.address, key: f.key });
@@ -664,7 +693,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
<span className={'jc-dv-fmt f-' + f.format}>{f.format}</span>
<Ico name="chevron-right" s={15} />
</span>
</button>
</Btn>
))}
{doneList.length > 0 && <div className="jc-dvs-sub">定稿报告<em>{doneList.length} 份</em></div>}
</>
@@ -780,6 +809,17 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* 判据换成直接问 `detail.viewHtml` 本身,`cur` 只在没有件、要退回卡壳
* 呈现,或收边传值(`onField`)时才用得上。
*/
<>
{/*
**有行没落下,红字摆在件的上方** —— 而不是让人以为存好了。
位置有意)在件之上:他刚按完保存,眼睛就在这一带。
*/}
{saveWarn !== undefined && (
<div className="jc-savewarn" role="alert">
<Ico name="info" s={14} />
<span>{saveWarn}</span>
</div>
)}
<ViewContainer
html={detail.viewHtml}
title={`${detail.card.activity} · 汇报报告`}
@@ -806,6 +846,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
if (cur !== undefined) collectEdits(save.tables, cur, onField);
}}
/>
</>
) : cur !== undefined ? (
/* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:那时工作区总不能是白的 */
<article className="jc-mst-doc">
@@ -15,13 +15,29 @@
*
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
*/
import { useEffect, useRef, useState } from 'react';
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import type { FormField, AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { Btn } from '@web-shared/Btn.tsx';
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
import { atClock } from '@web-shared/at.ts';
/**
* 「这一下交上去了,回音还在路上」—— 卡族共用一个在办标记。
*
* 卡上的钮全都要走一趟服务端(交工单 / 裁决 / 选一支 / 答一张卡),而这一趟里
* 屏上从前**什么都不变**:人按下「提交」之后只能干等,常常再按一次。
* 逐层往下传 prop 要穿过 ChatStream → Msg → 各卡三四层,卡族又都在这一份文件里 ——
* 用一个上下文最省事,也最不容易漏掉某一张卡(漏掉的那张就是下一次「点了没反应」)。
*/
const ReplyBusyContext = createContext(false);
/** 卡上的钮据此说「在办事」;`ChatStream` 从任务面递进来 */
function useReplyBusy(): boolean {
return useContext(ReplyBusyContext);
}
/**
* 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径)。
*
@@ -81,6 +97,8 @@ export type TaskChatProps = {
* 状态来自任务投影的 runtime,不是前端猜的。
*/
readonly working?: boolean;
/** 卡上刚交出去的那一笔还在路上 —— 卡上的钮据此说「在办事」 */
readonly replying?: boolean;
onSay(text: string): void;
onReplyCard(reply: ChatCardReply): void;
onReplyOrder(reply: WorkOrderReply): void;
@@ -98,6 +116,7 @@ export function ChatStream({
orders,
thinking,
working = false,
replying = false,
onSay,
onReplyCard,
onReplyOrder,
@@ -125,6 +144,7 @@ export function ChatStream({
};
return (
<ReplyBusyContext.Provider value={replying}>
<div className="jc-tch">
<header className="jc-tch-head">
<span className="jc-tch-mark">
@@ -186,12 +206,13 @@ export function ChatStream({
}
}}
/>
<button className="copilot-send" disabled={text.trim() === '' || blocked} onClick={send}>
<Btn className="copilot-send" disabled={text.trim() === '' || blocked} busy={thinking} onClick={send}>
<Ico name="send" s={14} /> 发送
</button>
</Btn>
</div>
</div>
</div>
</ReplyBusyContext.Provider>
);
}
@@ -274,6 +295,7 @@ function Msg({
orderValues?: Readonly<Record<string, string>> | undefined;
onField: (key: string, value: string) => void;
}) {
const busy = useReplyBusy();
// 人的消息:右侧品牌气泡(纯文本)
if (m.k === 'human') {
return (
@@ -495,9 +517,10 @@ function Msg({
{m.chosen === undefined ? (
<div className="jc-branches">
{m.options.map((o, i) => (
<button
<Btn
key={o.value}
className={'jc-branch' + (o.recommended === true ? ' rec' : '')}
busy={busy}
onClick={() => onReplyCard({ k: 'decision', cardId: m.id, value: o.value })}
>
<span className="jc-branch-n jc-mono">{String.fromCharCode(65 + i)}</span>
@@ -514,7 +537,7 @@ function Msg({
)}
</span>
<Ico name="chevron-right" s={14} />
</button>
</Btn>
))}
{m.allowCustom && (
<CustomOption
@@ -767,6 +790,7 @@ function ExecForm({
orderValues?: Readonly<Record<string, string>> | undefined;
onField: (key: string, value: string) => void;
}) {
const busy = useReplyBusy();
const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? '';
/*
* **必填还空着就按不下提交**(2026-08-02 用户定)——两处必填,一个门:
@@ -842,9 +866,10 @@ function ExecForm({
</div>
)}
<div className="jc-fc-ft">
<button
<Btn
className="jc-btn jc-fc-go"
disabled={!ok}
busy={busy}
onClick={() =>
onReplyOrder({
mode: 'execution',
@@ -854,7 +879,7 @@ function ExecForm({
}
>
<Ico name="check" s={13} /> {order.submitLabel ?? '提交'}
</button>
</Btn>
</div>
</div>
);
@@ -880,6 +905,7 @@ function AcceptForm({
*/
orderValues?: Readonly<Record<string, string>> | undefined;
}) {
const busy = useReplyBusy();
const [why, setWhy] = useState('');
/** 选择组:恰选一口,选口即裁分支 —— 没选之前「通过」不亮 */
const [port, setPort] = useState<string | undefined>(undefined);
@@ -971,9 +997,10 @@ function AcceptForm({
/>
</div>
<div className="jc-fc-ft">
<button
<Btn
className="jc-btn jc-btn-bad"
disabled={why.trim() === ''}
busy={busy}
onClick={() =>
onReplyOrder({
mode: 'acceptance',
@@ -987,11 +1014,12 @@ function AcceptForm({
}
>
重做
</button>
</Btn>
<span className="jc-fc-sp" />
<button
<Btn
className="jc-btn jc-btn-ok"
disabled={needChoice && port === undefined}
busy={busy}
title={needChoice && port === undefined ? `先在上面选一个「${choice.group}」` : undefined}
onClick={() =>
onReplyOrder({
@@ -1021,7 +1049,7 @@ function AcceptForm({
}
>
<Ico name="circle-check" s={14} /> {order.passLabel ?? '确认通过'}
</button>
</Btn>
</div>
</div>
);
@@ -1029,6 +1057,7 @@ function AcceptForm({
/* 决策的收尾项:自定义口径 */
function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void }) {
const busy = useReplyBusy();
const [txt, setTxt] = useState('');
return (
<div className="jc-branch jc-branch-custom">
@@ -1044,9 +1073,9 @@ function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void
if (e.key === 'Enter' && txt.trim() !== '') onPick(txt.trim());
}}
/>
<button className="jc-btn" disabled={txt.trim() === ''} onClick={() => onPick(txt.trim())}>
<Btn className="jc-btn" disabled={txt.trim() === ''} busy={busy} onClick={() => onPick(txt.trim())}>
就按这个
</button>
</Btn>
</span>
</span>
</div>
@@ -1068,6 +1097,7 @@ function FillForm({
* 人核对一眼、补上它填不出的那个即可。
* 用惰性初值而不是 `useEffect` 回填:回填会在人已经开始打字之后把他敲的字冲掉。
*/
const busy = useReplyBusy();
const [vals, setVals] = useState<Record<string, string>>(() =>
Object.fromEntries(fields.filter((f) => (f.value ?? '') !== '').map((f) => [f.key, f.value ?? ''])),
);
@@ -1093,9 +1123,9 @@ function FillForm({
<button type="button" className="jc-cant-link" onClick={() => setCant(false)}>
← 还是填吧
</button>
<button className="jc-btn" disabled={reason.trim() === ''} onClick={() => onCant(reason.trim())}>
<Btn className="jc-btn" disabled={reason.trim() === ''} busy={busy} onClick={() => onCant(reason.trim())}>
<Ico name="send" s={13} /> 让 AI 调整需求
</button>
</Btn>
</div>
</div>
);
@@ -1120,9 +1150,9 @@ function FillForm({
<button type="button" className="jc-cant-link" onClick={() => setCant(true)}>
没法填?告诉 AI 原因
</button>
<button className="jc-btn" disabled={!ok} onClick={() => onDone(vals)}>
<Btn className="jc-btn" disabled={!ok} busy={busy} onClick={() => onDone(vals)}>
<Ico name="check" s={13} /> 补给 AI
</button>
</Btn>
</div>
</div>
);
@@ -1130,6 +1160,7 @@ function FillForm({
/* 评价卡的打分器:星级 + 改进建议(建议进活动记忆) */
function RateForm({ onDone }: { onDone: (score: number, note: string) => void }) {
const busy = useReplyBusy();
const [score, setScore] = useState(0);
const [hover, setHover] = useState(0);
const [note, setNote] = useState('');
@@ -1164,9 +1195,9 @@ function RateForm({ onDone }: { onDone: (score: number, note: string) => void })
/>
</label>
<div className="jc-cfill-acts">
<button className="jc-btn" disabled={score === 0} onClick={() => onDone(score, note.trim())}>
<Btn className="jc-btn" disabled={score === 0} busy={busy} onClick={() => onDone(score, note.trim())}>
<Ico name="check" s={13} /> 提交评价
</button>
</Btn>
</div>
</div>
);
@@ -1192,6 +1223,7 @@ function EntryCard({
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉。
*/
const busy = useReplyBusy();
const groups = [
{
title: '',
@@ -1290,9 +1322,9 @@ function EntryCard({
{!done && (
<div className="jc-cfill-acts">
{blank.length > 0 && <span className="jc-entry-warn">还差:{blank.map((f) => f.label).join('、')}</span>}
<button className="jc-btn" disabled={blank.length > 0} onClick={() => onDone(vals)}>
<Btn className="jc-btn" disabled={blank.length > 0} busy={busy} onClick={() => onDone(vals)}>
<Ico name="database" s={13} /> 确认写入
</button>
</Btn>
</div>
)}
</>
@@ -41,6 +41,7 @@ import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovActivities, useGovApprovalTemplates } from '@web-shared/gov/gov-api.ts';
import type { GovApprovalTemplates } from '@web-shared/gov/gov-api.ts';
import { atFull } from '@web-shared/at.ts';
import { Btn } from '@web-shared/Btn.tsx';
export type HomeProps = { tab: string; nav: Nav };
@@ -177,14 +178,9 @@ function ApprovalTemplates({ data }: { data: GovApprovalTemplates['templates'] }
<button className="mbtn" aria-label="编辑" onClick={() => setEditing({ ...t })}>
<Ico name="pen-line" s={13} />
</button>
<button
className="mbtn bad"
aria-label="删除"
disabled={drop.isPending}
onClick={() => drop.mutate(t.id)}
>
<Btn className="mbtn bad" aria-label="删除" busy={drop.isPending} onClick={() => drop.mutate(t.id)}>
<Ico name="trash-2" s={13} />
</button>
</Btn>
</div>
<div className="plate">
<span>
@@ -288,7 +284,8 @@ function TemplateForm({
label: pending ? '保存中…' : '保存',
ico: 'send',
primary: true,
disabled: pending || !idOk || t.name.trim() === '' || t.basis.some((b) => b.field === '' || b.label === ''),
disabled: !idOk || t.name.trim() === '' || t.basis.some((b) => b.field === '' || b.label === ''),
busy: pending,
onClick: () => onSave({ ...t, name: t.name.trim() }),
},
]}
@@ -516,7 +513,8 @@ function InstallCard() {
label: install.isPending ? '两审中…' : '装入并送审',
ico: 'circle-check',
primary: true,
disabled: install.isPending || manifestText.trim() === '' || artifact.trim() === '',
disabled: manifestText.trim() === '' || artifact.trim() === '',
busy: install.isPending,
ask: {
title: '经安装通道装入活动包',
note: <Note>无代码通道:交的是数据。两审过了才上架;不过审会把是哪一条拦的说清楚。</Note>,
+157 -88
View File
@@ -11,14 +11,31 @@
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
*/
import { Card, Chip, Clear, Entry, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx';
import { Card, Chip, Clear, NList, NRow, Note, PageHead, QueryGate, Sec } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { Ico } from '@web-shared/Ico.tsx';
import { useGovLedger, useGovObjects } from '@web-shared/gov/gov-api.ts';
import { domainShort, sortDomains } from '@web-shared/gov/domains.ts';
import { useGovDataByActivity, useGovLedger, useGovObjects } from '@web-shared/gov/gov-api.ts';
import { domainShort } from '@web-shared/gov/domains.ts';
export type HomeProps = { tab: string; nav: Nav };
/**
* 记录浏览的格子文本 —— JSON 列(决策链地图 / 互动时间线)从前被 String() 成
* `[object Object]`(2026-08-24 用户报):对象与数组原样 stringify 摆出来,
* 超长截断进 title(截了才加省略号,悬停看全文)。这是取值,不是判断。
*/
const cellText = (v: unknown): string => {
if (v === null || v === undefined) return '';
if (typeof v === 'object') {
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
return String(v);
};
/* ==================== 设计侧:数据域(定义面) ==================== */
/**
@@ -26,103 +43,151 @@ export type HomeProps = { tab: string; nav: Nav };
* 行数与行浏览入口不在这里 —— 那在运营中心的台账。
*/
/**
* 三类的呈现次序与说辞 —— **按「要不要管它」排**,不按字母。
* **按活动看**(2026-08-24 用户定:「按活动数据来分类,要让每个活动执行完成后
* 台账在这里看到变化」)。
*
* 主数据摆最前:一屏里真正需要治理的就是它(谁能改、依据是什么、多久算过期)。
* 单据量最大但不需要「管」—— 查得到、回放得出就够了,所以摆中间。
* 派生摆最后,并且明写「不该手写」:手写的派生值会和它的来源打架,而没人会发现。
* 从前这一屏按**性质**分组(主数据/单据/派生)—— 那是「治理这张表」的人的分法,
* 答的是「这个对象定义全不全」。可人跑完一单来这儿,问的是另一件事:
* **我这个活动写了哪几张表,现在各有几行,刚才那一单落下了什么**。
* 按活动分组、把行数摆在最显眼处,那个问题才答得了;性质降成一枚徽,
* 它仍要紧(单据与主数据的治理方式不同),但不该再当分组的轴。
*
* `unknown` 是**库里有表、却没有哪个在装的活动声明它**——多半是退役活动留下的空壳。
* 它不是一种性质,是一种状况,所以单列一档、说清楚,不混进上面三类假装正常。
* 三个数各说各的,不许混成一个:
* · **正式** —— 过了放行门的事实,这张表现在真有几行;
* · **草稿** —— 还在某一单草稿层的行(在办中,放行才进正式表);
* · **未物化** —— 活动声明了、一次都没写过,所以库里还没这张表。
* 它与「有表但空着」是两件事,不拿 0 混过去。
*/
const KIND_ORDER = ['master', 'record', 'derived', 'unknown'] as const;
type KindKey = (typeof KIND_ORDER)[number];
const KIND_BADGE: Readonly<Record<string, { label: string; tone: string }>> = {
master: { label: '主数据', tone: 'm' },
record: { label: '单据', tone: 'r' },
derived: { label: '派生', tone: 'd' },
};
const KIND_VIEW: Readonly<Record<KindKey, { title: string; ico: string; desc: string; tone: string }>> = {
master: {
title: '主数据',
ico: 'library',
tone: 'm',
desc: '跨单积累的经营资产 —— 会被反复更新,下一单读它做判断。治理都在这一类上:谁能改、依据是什么、怎么去重、多久算过期。',
},
record: {
title: '单据',
ico: 'file-text',
tone: 'r',
desc: '一单一份的过程与结论 —— 放行即定稿、只增不改。它不需要「管理」,需要的是查得到、回放得出;主键第一列就是那一单的标识。',
},
derived: {
title: '派生',
ico: 'trending-up',
tone: 'd',
desc: '能由单据与主数据算出来的指标 —— 本不该手写:手写的派生值会和它的来源打架,而没人会发现。标出来,至少让它可被重算校验。',
},
unknown: {
title: '无人声明',
ico: 'help-circle',
tone: 'u',
desc: '库里有这张表,却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳。台账「只增不破坏」,清空壳表是一次性的人工决定。',
},
/** 一单此刻是什么样 —— 终态优先(它才是结论),没终态就说运行态 */
function runWord(run: { runtime: string; terminal?: string | undefined }): { text: string; tone: string } {
if (run.terminal === 'released') return { text: '已放行', tone: 'ok' };
if (run.terminal !== undefined) return { text: run.terminal, tone: 'warn' };
if (run.runtime === 'running') return { text: 'AI 推进中', tone: 'live' };
return { text: '等人办', tone: 'wait' };
}
const atMinute = (iso: string): string => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
export function DataHome({ nav }: HomeProps) {
const q = useGovObjects();
const objects = q.data?.objects ?? [];
const q = useGovDataByActivity();
const activities = q.data?.activities ?? [];
const unclaimed = q.data?.unclaimed ?? [];
// 一张表都没声明的活动不占地方 —— 那多半是还没写到台账的槽位
const withTables = activities.filter((a) => a.tables.length > 0);
return (
<QueryGate query={q}>
<>
{KIND_ORDER.map((kind) => {
const inKind = objects.filter((o) => (o.kind ?? 'unknown') === kind);
if (inKind.length === 0) return null;
const view = KIND_VIEW[kind];
const domains = sortDomains([...new Set(inKind.map((o) => o.domain))]);
{withTables.map((a) => {
const formal = a.tables.reduce((n, t) => n + (t.rows ?? 0), 0);
const drafting = a.tables.reduce((n, t) => n + t.drafts, 0);
return (
<section key={kind} className={'g-kind t-' + view.tone}>
<div className="g-kind-h">
<Ico name={view.ico} s={15} />
<b>{view.title}</b>
<span className="n">{inKind.length} 张表</span>
<p>{view.desc}</p>
<section key={a.activityId} className="g-actdata">
<div className="g-actdata-h">
<Ico name="list-checks" s={15} />
<b>{a.name}</b>
<span className="g-mono">
{a.activityId}
{a.version === undefined ? '' : `@${a.version}`}
</span>
<span className="n">
{a.tables.length} 张表 · 正式 {formal} 行
{drafting > 0 && <em> · 在办 {drafting} 行</em>}
</span>
</div>
{domains.map((d) => {
const inDomain = inKind.filter((o) => o.domain === d);
return (
<Card
key={d}
ico="database"
title={domainShort(d)}
extra={
<span className="g-mono">
{d} · {inDomain.length} 对象
<div className="g-actdata-tables">
{a.tables.map((t) => {
const badge = KIND_BADGE[t.kind ?? ''];
return (
<button
key={`${t.domain}.${t.object}`}
type="button"
className="g-tbl"
onClick={() => nav.push({ k: 'obj', id: `${t.domain}.${t.object}` })}
>
<span className="g-tbl-t">
<b>{t.label ?? t.object}</b>
{badge !== undefined && <i className={'g-tbl-k t-' + badge.tone}>{badge.label}</i>}
</span>
}
>
<div className="g-entries">
{inDomain.map((o) => (
<Entry
key={o.object}
ico="table"
// 人读名优先;没登记 label 才退回对象键 —— 治理台不该拿 `intake_record` 给人看
title={o.label ?? o.object}
/* 单据把**按什么分单**摆出来 —— 那是它和主数据最要紧的差别 */
desc={
o.caseKey === undefined
? `${o.domain}.${o.object}`
: `${o.domain}.${o.object} · 按 ${o.caseKey} 分单`
}
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })}
/>
))}
</div>
</Card>
);
})}
<span className="g-tbl-a g-mono">
{t.domain}.{t.object}
{t.primaryKey.length > 0 && <em> · 主键 {t.primaryKey.join(' + ')}</em>}
</span>
<span className="g-tbl-n">
{/* 「没物化」不写 0 —— 声明了没写过,与写过又空了不是一回事 */}
{t.rows === undefined ? (
<em className="none">还没建表</em>
) : (
<>
<b>{t.rows}</b> 行
</>
)}
{t.drafts > 0 && <i className="draft">在办 {t.drafts} 行</i>}
</span>
</button>
);
})}
</div>
{a.recent.length > 0 && (
<div className="g-actdata-runs">
<em>最近的单</em>
{a.recent.map((run) => {
const word = runWord(run);
return (
<span key={run.id} className={'g-run t-' + word.tone}>
{word.text}
<i>{atMinute(run.at)}</i>
</span>
);
})}
</div>
)}
</section>
);
})}
{objects.length === 0 && (
<Clear>还没有物化的对象 —— 活动第一次写入时,系统按它的声明把表建出来,然后出现在这里。</Clear>
{unclaimed.length > 0 && (
<section className="g-actdata t-u">
<div className="g-actdata-h">
<Ico name="help-circle" s={15} />
<b>无人声明</b>
<span className="n">{unclaimed.length} 张表</span>
<p>库里有这张表,却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳。台账「只增不破坏」,清空壳表是一次性的人工决定。</p>
</div>
<div className="g-actdata-tables">
{unclaimed.map((o) => (
<button
key={`${o.domain}.${o.object}`}
type="button"
className="g-tbl"
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })}
>
<span className="g-tbl-t">
<b>{o.label ?? o.object}</b>
</span>
<span className="g-tbl-a g-mono">
{o.domain}.{o.object}
</span>
<span className="g-tbl-n">{o.rows === undefined ? <em className="none">读不到</em> : <><b>{o.rows}</b> 行</>}</span>
</button>
))}
</div>
</section>
)}
{withTables.length === 0 && unclaimed.length === 0 && (
<Clear>还没有活动声明台账 —— 装一个活动包,它声明的表就出现在这里;第一次写入时表才真建出来。</Clear>
)}
</>
</QueryGate>
@@ -238,11 +303,15 @@ export function ObjPage({ id }: { id: string }) {
<tbody>
{(page.data?.rows ?? []).map((row, i) => (
<tr key={i}>
{columns.map((c) => (
<td key={c.name} className="g-mono">
{String(row[c.name] ?? '')}
</td>
))}
{columns.map((c) => {
const full = cellText(row[c.name]);
const long = full.length > 120;
return (
<td key={c.name} className="g-mono" title={long ? full : undefined}>
{long ? `${full.slice(0, 120)}…` : full}
</td>
);
})}
</tr>
))}
</tbody>
@@ -50,6 +50,7 @@ import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx';
import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts';
import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts';
import type { FlowEdge, FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts';
import { Btn } from '@web-shared/Btn.tsx';
export type HomeProps = { tab: string; nav: Nav };
@@ -239,7 +240,8 @@ function FlowShelf({ nav }: { nav: Nav }) {
label: create.isPending ? '建流程中…' : '建流程并进入',
ico: 'plus',
primary: true,
disabled: create.isPending || !idOk || idTaken || nfName.trim() === '',
disabled: !idOk || idTaken || nfName.trim() === '',
busy: create.isPending,
onClick: () => create.mutate(),
},
]}
@@ -333,9 +335,9 @@ function FlowShelf({ nav }: { nav: Nav }) {
<>
<span className="q">删掉整条流?{f.versions.length} 个版本一起没</span>
<button onClick={() => setMenu({ id: f.id, drop: false })}>算了</button>
<button className="bad" disabled={dropFlow.isPending} onClick={() => dropFlow.mutate(f.id)}>
<Btn className="bad" busy={dropFlow.isPending} onClick={() => dropFlow.mutate(f.id)}>
删除
</button>
</Btn>
</>
) : (
<>
@@ -793,19 +795,18 @@ function FlowLab({
*/}
{editingVersions ? (
<>
<button
<Btn
className="g-ver-act"
disabled={saveVersions.isPending}
onClick={() => {
setEditingVersions(false);
setEdits({});
}}
>
取消
</button>
<button
</Btn>
<Btn
className="g-ver-act"
disabled={saveVersions.isPending}
busy={saveVersions.isPending}
onClick={() => {
const drafts = versions.flatMap((v) => {
const d = edits[v.version];
@@ -825,13 +826,13 @@ function FlowLab({
}}
>
<Ico name="send" s={11} /> {saveVersions.isPending ? '保存中…' : '保存'}
</button>
</Btn>
</>
) : (
<>
<button className="g-ver-act" onClick={askClone} disabled={cloneVersion.isPending}>
<Btn className="g-ver-act" onClick={askClone} busy={cloneVersion.isPending}>
<Ico name="plus" s={11} /> 新建
</button>
</Btn>
{/* 这枚「编辑」只作用于**版本记录**:号、名、删 —— 画布的编辑在流程图卡上 */}
<button
className="g-ver-act"
@@ -995,15 +996,16 @@ function FlowLab({
节点 · 属性
{dirty && <span className="n dirty">有改动待保存</span>}
{/* 与「新建 / 编辑」同款素钮 —— 可不可存由禁用态说,不靠换色抢戏 */}
<button
<Btn
className="g-ver-act"
disabled={!dirty || save.isPending}
disabled={!dirty}
busy={save.isPending}
onClick={() => {
if (graph !== null) save.mutate({ graph, idMap: {} });
}}
>
<Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存 v${pkg.version}`}
</button>
</Btn>
</div>
<div className="g-nodecard-b">
{saveErr !== undefined && (
@@ -112,6 +112,16 @@ function PackageConfig({
['node-owners'],
);
/*
* 勾选与下拉也是「点一下就去请求」—— 它们不是按钮,给不了键感,
* 那就在办事的这一段把**那一行**点亮(条画在行脚下)。
* 认 `variables` 只亮真正在路上的那一行 —— 三个开关共用一个 mutation,
* 不认就变成整表一起闪,等于没说是哪一条在办。
* 条不画在 `<select>` 自己身上:它的下拉箭头就是 background-image,画上去会顶掉箭头。
*/
const busyTrigger = toggle.isPending ? toggle.variables.id : confirm.isPending ? confirm.variables.id : undefined;
const busyNode = setOwner.isPending ? setOwner.variables.nodeId : undefined;
return (
<Card ico="workflow" title={name} extra={<span className="g-mono">{packageId}</span>}>
{/* ── 触发器的运营面:开不开 · 要不要人点头 · 谁点头 —— 规则本身在设计中心改 ── */}
@@ -126,7 +136,7 @@ function PackageConfig({
triggers.map((t) => {
const confirmed = t.confirm.required;
return (
<div key={t.id} className="g-runconf-r">
<div key={t.id} className="g-runconf-r" aria-busy={busyTrigger === t.id ? true : undefined}>
<St tone={t.enabled ? 'ok' : 'mute'} ico="zap">
{t.name}
</St>
@@ -186,7 +196,7 @@ function PackageConfig({
</div>
<div className="g-runconf-own">
{nodes.map((n) => (
<label key={n.id} className="g-runconf-node">
<label key={n.id} className="g-runconf-node" aria-busy={busyNode === n.id ? true : undefined}>
<span className="nid">{n.id}</span>
<span className="lb">{n.label}</span>
{n.kind === 'approval' && <Chip tone="warn">审批</Chip>}
@@ -339,7 +339,8 @@ function WorkGroupForm({
label: save.isPending ? '保存中…' : `保存(已绑 ${bound} 个岗位)`,
ico: 'circle-check',
primary: true,
disabled: save.isPending || draftName.trim() === '',
disabled: draftName.trim() === '',
busy: save.isPending,
ask: {
title: `保存工作组:${draftName}`,
note: <Note>绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。</Note>,
@@ -15,6 +15,7 @@ import { Ico } from '@web-shared/Ico.tsx';
import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovChannels, useGovLlm, useGovLlmProviders } from '@web-shared/gov/gov-api.ts';
import { Btn } from '@web-shared/Btn.tsx';
export type HomeProps = { tab: string; nav: Nav };
@@ -256,10 +257,15 @@ function LlmDrawer({
</small>
</label>
<div className="g-testrow">
<button className="g-btn xs" disabled={test.isPending || d.model.trim() === '' || urlNeeded} onClick={() => test.mutate()}>
<Btn
className="g-btn xs"
disabled={d.model.trim() === '' || urlNeeded}
busy={test.isPending}
onClick={() => test.mutate()}
>
<Ico name={test.isPending ? 'hourglass' : 'zap'} s={11} />
{test.isPending ? '测试中…' : '测试连接'}
</button>
</Btn>
{probed !== undefined && (
<span className={'r' + (probed.ok ? ' ok' : ' bad')}>
{probed.ok ? `连通 · ${probed.latencyMs ?? '—'} ms` : (probed.error ?? '不通')}
@@ -286,12 +292,12 @@ function LlmDrawer({
</div>
<footer>
<button className="g-btn" onClick={onCancel}>
<Btn className="g-btn" onClick={onCancel}>
取消
</button>
<button className="g-btn primary" disabled={save.isPending || invalid} onClick={() => save.mutate()}>
</Btn>
<Btn className="g-btn primary" disabled={invalid} busy={save.isPending} onClick={() => save.mutate()}>
{save.isPending ? '保存中…' : isNew ? '接入' : '保存'}
</button>
</Btn>
</footer>
</div>
</div>,
@@ -362,10 +368,10 @@ function LlmCard({ i, nav, onEdit, onDropped }: { i: LlmInstanceView; nav: Nav;
<span className="host" title={i.baseUrl}>
{hostOf(i.baseUrl)}
</span>
<button className="tbtn" disabled={probe.isPending} onClick={() => probe.mutate()}>
<Btn className="tbtn" busy={probe.isPending} onClick={() => probe.mutate()}>
<Ico name={probe.isPending ? 'hourglass' : 'zap'} s={11} />
{probe.isPending ? '测试中' : '测试'}
</button>
</Btn>
</>
) : (
/* 不可用就别摆统计:一句判词 + 一条去路,比四个假数字有用 */
@@ -391,9 +397,9 @@ function LlmCard({ i, nav, onEdit, onDropped }: { i: LlmInstanceView; nav: Nav;
<>
<span className="q">删掉这条实例?活动里声明了它的名就删不掉</span>
<button onClick={() => setMenu('open')}>算了</button>
<button className="bad" disabled={drop.isPending} onClick={() => drop.mutate()}>
<Btn className="bad" busy={drop.isPending} onClick={() => drop.mutate()}>
删除
</button>
</Btn>
</>
) : (
<>
@@ -25,6 +25,7 @@ import { APR_ST, RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts'
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx';
import { atFull, atMinute } from '@web-shared/at.ts';
import { Btn } from '@web-shared/Btn.tsx';
import {
useGovApprovals,
useGovStaff,
@@ -379,6 +380,7 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
ico: semantic === 'terminate' ? 'power' : semantic === 'halt' ? 'hand' : 'pause',
danger,
disabled: !live,
busy: control.isPending,
ask: {
title: `控制事件 · ${CONTROL_SEMANTIC_LABEL[semantic]}:${task.title}`,
note: <Note>控制事件走统一事件信封(四类事件之控制事件),先持久后投递。</Note>,
@@ -422,6 +424,7 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
ico: 'play',
primary: true,
disabled: !suspended,
busy: resume.isPending,
ask: {
title: `恢复任务:${task.title}`,
note: <Note tone="ok">恢复 = 从挂起点继续;预算与监听解冻,任务记忆只追加、不回滚。</Note>,
@@ -457,10 +460,15 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
</div>
</div>
<div className="g-acts-r">
<button className="g-btn primary" disabled={assignee === '' || !live} onClick={() => reassign.mutate(assignee)}>
<Btn
className="g-btn primary"
disabled={assignee === '' || !live}
busy={reassign.isPending}
onClick={() => reassign.mutate(assignee)}
>
<Ico name="user-plus" s={13} />
确认改派
</button>
</Btn>
</div>
</Sec>
@@ -23,6 +23,7 @@ import { QueryGate } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { DraftGroups } from './drafts.tsx';
import { observeFace } from './observe-face.ts';
import { Btn } from '@web-shared/Btn.tsx';
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
export type ActivityState = Parsed<typeof testActivityStateRoute.output>;
@@ -54,6 +55,21 @@ export function ActivityRun({
const picked = activities.find((a) => a.activityId === pickedId) ?? activities[0];
const [data, setData] = useState<Readonly<Record<string, string>>>({});
const [ownerId, setOwner] = useState('');
/**
* **连主数据一起落真库**(2026-08-24 用户:「数据需要真实入库」「那你把这个开关打开了吗?」)。
*
* **试跑台这一处默认勾上** —— 人打开这一屏是来看活动真跑一遍的:
* 台账更新明细写着「亲密度 3 → 10」而 `customer.contact` 还是 3,
* 单据说改了、主数据没改,两份事实对不上,这一屏就白看了。
*
* 代价照旧存在,所以那一格勾上时是**红的**、并写清在破例:主数据按业务实体分行,
* 试跑往 `customer.profile` 建一位客户,那位客户就永久留在库里 ——
* 「新客户来函」那类**只能测一次**的样例,跑之前把这一格取消掉。
*
* 接口那一侧的默认仍是 `false`(`testRunActivityRoute`):e2e 与别的程序化调用照旧受保护,
* 那道保护本来就是为它们设的。默认的差别不是自相矛盾 —— **人手点的和脚本跑的,要的东西不一样**。
*/
const [landMaster, setLandMaster] = useState(true);
const [error, setError] = useState<string | undefined>(undefined);
const queryClient = useQueryClient();
@@ -68,7 +84,7 @@ export function ActivityRun({
}, [picked?.activityId]);
const start = useMutation({
mutationFn: (input: { activityId: string; data: Record<string, unknown>; ownerId: string }) =>
mutationFn: (input: { activityId: string; data: Record<string, unknown>; ownerId: string; landMaster: boolean }) =>
call<{ taskId: string; title: string }>(testRunActivityRoute, { body: input }),
onSuccess: (r) => {
setError(undefined);
@@ -172,7 +188,7 @@ export function ActivityRun({
}
}
setError(undefined);
start.mutate({ activityId: card.activityId, data: shaped, ownerId });
start.mutate({ activityId: card.activityId, data: shaped, ownerId, landMaster });
};
return (
@@ -321,6 +337,30 @@ export function ActivityRun({
</div>
</div>
<div className="tb-asn">
<label className={'tb-land' + (landMaster ? ' on' : '')}>
<input
type="checkbox"
checked={landMaster}
onChange={(e) => setLandMaster(e.target.checked)}
/>
<span>
<b>连主数据一起落真库</b>
{/* 星号是 Markdown,JSX 里不渲染 —— 屏上就真的印着两个星号(界面不撒谎,也别摆记号) */}
<em>
{landMaster ? (
<>
这一单放行时,客户档案 / 联系人 / 品牌内容库这类主数据会<b>真的写进正式表</b> ——
演示与联调要看的就是它
</>
) : (
<>只落单据:主数据留在活动缓存,免得试跑把样例客户写成老客户(「新客户来函」那条路就再也试不出来)</>
)}
</em>
</span>
</label>
</div>
<div className="g-rig-h">
<span className="n">03</span>
这个活动手上有什么
@@ -338,14 +378,15 @@ export function ActivityRun({
'起单会先销毁上一单;这是**真任务** —— 照旧过预算、案例模型与放行门,只是可以随时拔掉'
)}
</span>
<button
<Btn
className={'tb-go' + (fireable ? ' armed' : '')}
disabled={!fireable || start.isPending}
disabled={!fireable}
busy={start.isPending}
onClick={() => picked && fire(picked)}
>
<Ico name="play" s={13} />
{start.isPending ? '起单中…' : '起单试跑'}
</button>
</Btn>
</div>
</div>
</>
@@ -642,10 +683,10 @@ function ActivityObserve({
* 跑完之后已经没有什么可停的,还写「停止仿真」会让人以为不点它这一单就还占着;
* 而它真正的作用是把这一单连根拔掉、腾出仪器再来一单。
*/
<button className="tb-stop" disabled={stopping} onClick={onStop}>
<Btn className="tb-stop" busy={stopping} onClick={onStop}>
<Ico name={live ? 'square' : 'rotate-ccw'} s={11} />
{stopping ? '销毁中…' : live ? '停止仿真' : '清场 · 再来一单'}
</button>
</Btn>
),
})}
@@ -27,6 +27,7 @@ import type { Nav } from '@web-shared/gov/gov-route.ts';
import { ActivityRun } from './activity-run.tsx';
import { isLive, useRunTrace, useRunWorkflow } from './run-api.ts';
import { TraceSheet } from './trace-sheet.tsx';
import { Btn } from '@web-shared/Btn.tsx';
type RunOutput = { workflowId: string; state: string };
@@ -389,10 +390,10 @@ function PackageRun({ nav, hint }: { nav: Nav; hint?: ReactNode }): ReactNode {
'起单会先销毁上一单;任务真实落到被指派人的工作台,抽不出的键由受理补全单收齐'
)}
</span>
<button className={'tb-go' + (fireable ? ' armed' : '')} disabled={!fireable || run.isPending} onClick={fire}>
<Btn className={'tb-go' + (fireable ? ' armed' : '')} disabled={!fireable} busy={run.isPending} onClick={fire}>
<Ico name="play" s={13} />
{run.isPending ? '起单中…' : '起单试跑'}
</button>
</Btn>
</div>
</div>
</>
@@ -519,10 +520,10 @@ function Observe({
</>
}
right={
<button className="tb-stop" disabled={stopping} onClick={onBack}>
<Btn className="tb-stop" busy={stopping} onClick={onBack}>
<Ico name="square" s={11} />
{stopping ? '销毁中…' : '停止仿真'}
</button>
</Btn>
}
/>
@@ -20,6 +20,7 @@ import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { QueryGate } from '@web-shared/gov/primitives.tsx';
import { DraftGroups } from './drafts.tsx';
import { Btn } from '@web-shared/Btn.tsx';
/** 详情比列表多三样正文 —— 形状从契约的 parse 出参接出来,不手搓 */
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
@@ -119,15 +120,16 @@ export function TestLogs(): ReactNode {
<span className="word">试跑存档</span>
<span className="det">{runs.length === 0 ? '还没有存档' : `${runs.length} 次试跑`}</span>
<span className="x">
<button
<Btn
className="tb-stop"
disabled={runs.length === 0 || clearAll.isPending}
disabled={runs.length === 0}
busy={clearAll.isPending}
onClick={() => clearAll.mutate()}
title="清空全部存档"
>
<Ico name="trash-2" s={11} />
{clearAll.isPending ? '清空中…' : '清空'}
</button>
</Btn>
</span>
</div>
@@ -141,7 +143,12 @@ export function TestLogs(): ReactNode {
) : (
<div className="tb-logs">
{runs.map((r) => (
<button key={r.id} className="tb-log-r" onClick={() => setOpen(r.id)}>
<button
key={r.id}
className="tb-log-r"
aria-busy={dropOne.isPending && dropOne.variables === r.id ? true : undefined}
onClick={() => setOpen(r.id)}
>
<span className={'kd ' + r.kind}>{r.kind === 'package' ? '流程' : '活动'}</span>
<RunId id={r.id} />
<span className="sub">
@@ -218,10 +225,10 @@ function LogDetail({
<Ico name="corner-up-left" s={10} />
回目录
</button>
<button className="tb-stop" disabled={dropping} onClick={onDrop}>
<Btn className="tb-stop" busy={dropping} onClick={onDrop}>
<Ico name="trash-2" s={11} />
{dropping ? '删除中…' : '删掉这条'}
</button>
</Btn>
</span>
</div>
@@ -381,7 +381,8 @@ function ImportToolCard() {
label: importTool.isPending ? '两审中…' : '导入并送审',
ico: 'circle-check',
primary: true,
disabled: importTool.isPending || name.trim() === '' || endpoint.trim() === '',
disabled: name.trim() === '' || endpoint.trim() === '',
busy: importTool.isPending,
ask: {
title: `导入工具:${name.trim()}`,
note: <Note>与第一方同一条门:静态审 + 试运行审。过了才入目录;入目录还不等于能用,要再给活动授白名单。</Note>,
@@ -370,7 +370,8 @@ function TriggerForm({
label: save.isPending ? '保存中…' : '保存规则',
ico: 'circle-check',
primary: true,
disabled: save.isPending || draft.name.trim() === '',
disabled: draft.name.trim() === '',
busy: save.isPending,
ask: {
title: `保存调起规则:${draft.name}`,
note: <Note>触发器与流程包平级 —— 改规则不动任何流程包版本,已起的单照常跑完。</Note>,
+72
View File
@@ -0,0 +1,72 @@
/**
* 会去请求服务端的那种按钮 —— 三面共用(2026-08-24 用户:「点击了都不知道点没点上」)。
*
* 一枚按钮按下去之后,人要在**三个时刻**都看得见东西:
* · **按下那一刻** —— 键帽下沉(`:active`,纯 CSS,见 `styles/press.css`),证明这一下点着了;
* · **在办事的这一段** —— `aria-busy` 亮着,按钮脚下走一道不定长进度条,鼠标也进不来
* (`pointer-events: none`)。这一段从前是**完全静默**的:屏上什么都没变,人只好再点一次;
* · **办完那一刻** —— 条收掉,按钮恢复可按。
*
* 为什么用 `aria-disabled` + `pointer-events: none`,不用原生 `disabled`:
* `disabled` 会把按钮画成灰的 —— 那是「这件事现在做不了」,而它此刻正在**做**。
* 界面不撒谎:在办事就画成在办事,别画成不可用。真正做不了的照旧给 `disabled`。
*
* 在办的判断有两条路,谁先来算谁:
* · `busy`(常用)—— 把 react-query 的 `isPending` 递进来;
* · `onClick` 返回了 Promise —— 自己盯着它落地。
* 两条路之外还有唯一通道上的去重闸(`typed-fetch`),那是**最后一道**:
* 就算这里漏了,同一笔请求也不会真的发两遍。
*/
import { useCallback, useEffect, useRef, useState, type ButtonHTMLAttributes, type MouseEvent, type ReactNode } from 'react';
type BtnProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> & {
/** 在办事(通常是 `mutation.isPending`) */
busy?: boolean | undefined;
/** 返回 Promise 的话,不必再给 `busy` —— 落地之前一直算在办 */
onClick?: ((e: MouseEvent<HTMLButtonElement>) => unknown) | undefined;
children?: ReactNode;
};
function isThenable(v: unknown): v is Promise<unknown> {
return typeof v === 'object' && v !== null && typeof (v as { then?: unknown }).then === 'function';
}
export function Btn({ busy, onClick, disabled, type = 'button', ...rest }: BtnProps) {
const [awaiting, setAwaiting] = useState(false);
const alive = useRef(true);
// 上来先把旗子插回去 —— StrictMode 开发下 effect 走「挂载 → 拆卸 → 再挂载」,
// 只在拆卸时落旗的话第二次挂载后它永远是 false,靠 Promise 收尾的按钮会一直亮着「在办事」。
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const pending = busy === true || awaiting;
const fire = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
// 在办事的时候再点一下:什么也不做(不是拒绝,是这一下本来就是余震)
if (pending || disabled === true || onClick === undefined) return;
const r = onClick(e);
if (!isThenable(r)) return;
setAwaiting(true);
const settle = (): void => {
if (alive.current) setAwaiting(false);
};
r.then(settle, settle);
},
[pending, disabled, onClick],
);
return (
<button
{...rest}
type={type}
disabled={disabled === true}
{...(pending ? { 'aria-busy': true, 'aria-disabled': true } : {})}
onClick={fire}
/>
);
}
@@ -0,0 +1,125 @@
/**
* 唯一通道上的那道闸 —— 「同一笔请求不发两遍」(2026-08-24)。
*
* 这道闸不是性能优化,是**双击不该变成两笔**:点「新建纪要」手抖点两下,
* 从前真的建两份;点「装入」两下,真的装两遍。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { markCommReadRoute, sessionRoute } from '@contracts';
import { ApiCallError, DOUBLE_CLICK_MS, call, callKey, resetCallGuards } from '../typed-fetch.ts';
const write = markCommReadRoute;
const read = sessionRoute;
/** 手动控制何时落地的假网关 —— 在途那一段要摆得住才测得了 */
function gateway() {
const calls: string[] = [];
const pending: Array<(v: unknown) => void> = [];
const fetchMock = vi.fn((url: string, init?: { method?: string }) => {
calls.push(`${init?.method ?? 'GET'} ${url}`);
return new Promise((resolve) => {
pending.push(() => resolve({ ok: true, status: 200, json: () => Promise.resolve({ n: calls.length }) }));
});
});
vi.stubGlobal('fetch', fetchMock);
return { calls, settleAll: () => pending.splice(0).forEach((f) => f(undefined)) };
}
beforeEach(() => {
resetCallGuards();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('callKey —— 差一个字就是另一笔', () => {
it('方法 + 落地地址 + 报文,三样合起来才是身份', () => {
expect(callKey(write, { params: { commId: 'a' } })).toBe('POST /api/desk/comms/a/read ');
expect(callKey(write, { params: { commId: 'a' }, body: { read: true } })).toBe(
'POST /api/desk/comms/a/read {"read":true}',
);
});
it('**改了一格再存不算重复**:报文不同,身份就不同', () => {
expect(callKey(write, { body: { read: true } })).not.toBe(callKey(write, { body: { read: false } }));
});
});
describe('在途去重', () => {
it('**同一笔还在路上,第二次点击不再发第二个请求**', async () => {
const g = gateway();
const a = call(write, { params: { commId: 'a' } });
const b = call(write, { params: { commId: 'a' } });
expect(g.calls).toHaveLength(1);
g.settleAll();
expect(await a).toEqual(await b);
});
it('不同的那一笔照发不误', async () => {
const g = gateway();
void call(write, { params: { commId: 'a' } });
void call(write, { params: { commId: 'b' } });
expect(g.calls).toEqual(['POST /api/desk/comms/a/read', 'POST /api/desk/comms/b/read']);
g.settleAll();
await vi.runAllTimersAsync();
});
it('读请求同样只发一个(点开同一份资料连点两下)', async () => {
const g = gateway();
void call(read);
void call(read);
expect(g.calls).toHaveLength(1);
g.settleAll();
await vi.runAllTimersAsync();
});
});
describe('落地后的双击窗 —— 只管写请求', () => {
it('**刚成功那一笔在窗内原样重来,交回上一次的结果、不再发**', async () => {
const g = gateway();
const first = call(write, { params: { commId: 'a' } });
g.settleAll();
await first;
const again = await call(write, { params: { commId: 'a' } });
expect(g.calls).toHaveLength(1);
expect(again).toEqual(await first);
});
it('过了窗口就是新的一次点击,照发', async () => {
const g = gateway();
const first = call(write, { params: { commId: 'a' } });
g.settleAll();
await first;
vi.advanceTimersByTime(DOUBLE_CLICK_MS + 1);
void call(write, { params: { commId: 'a' } });
expect(g.calls).toHaveLength(2);
g.settleAll();
await vi.runAllTimersAsync();
});
it('**读请求不设这个窗** —— 写完立刻重取的那一下不能被当成余震压掉', async () => {
const g = gateway();
const first = call(read);
g.settleAll();
await first;
void call(read);
expect(g.calls).toHaveLength(2);
g.settleAll();
await vi.runAllTimersAsync();
});
it('**失败的不进窗**:那时候再点是重试,重试必须真的重试', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: false, status: 500, statusText: 'x', json: () => Promise.resolve({ code: 'internal', message: '炸了' }) })),
);
await expect(call(write, { params: { commId: 'a' } })).rejects.toBeInstanceOf(ApiCallError);
await expect(call(write, { params: { commId: 'a' } })).rejects.toBeInstanceOf(ApiCallError);
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
});
});
+4 -3
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { FlowEdge, FlowGraph, FlowNode, FlowNodeKind } from "./flow-layout.ts";
import { createFlowEditor, syncElementLabel, type FlowEditorHandles } from "./flow-editor-core.ts";
import "diagram-js/assets/diagram-js.css";
import { Btn } from '@web-shared/Btn.tsx';
/**
* 手工绘图编辑器(React 壳):内核是 flow-editor-core 的 diagram-js modeling 全栈
@@ -208,10 +209,10 @@ export function FlowEditor({ initial, drilledIds, onSave, onCancel }: FlowEditor
>
<Ico name="x" s={12} /> 取消
</button>
<button
type="button"
<Btn
className="be-btn solid"
disabled={!canSave}
busy={busy}
onClick={() => {
const editor = editorRef.current;
if (!editor) return;
@@ -225,7 +226,7 @@ export function FlowEditor({ initial, drilledIds, onSave, onCancel }: FlowEditor
}}
>
<Ico name="check" s={12} /> {busy ? "保存中…" : "保存绘图"}
</button>
</Btn>
</div>
{error && <p className="fe-error">{error}</p>}
+14
View File
@@ -24,10 +24,12 @@ import type {
GovWorkGroupsOutput,
GovWorkflowOutput,
ProcessPackage,
GovLedgerByActivityOutput,
} from '@contracts';
import type { ApprovalTemplate } from '@contracts';
import {
browseLedgerRoute,
ledgerByActivityRoute,
govMetricsRoute,
govTaskLogRoute,
govWorkflowRoute,
@@ -57,6 +59,7 @@ export type GovActivities = GovActivitiesOutput;
export type GovTools = GovToolsOutput;
export type GovObjects = GovObjectsOutput;
export type GovLedgerPage = GovLedgerPageOutput;
export type GovLedgerByActivity = GovLedgerByActivityOutput;
export type GovMemory = GovMemoryOutput;
export type GovMetrics = GovMetricsOutput;
export type GovChannels = GovChannelsOutput;
@@ -139,6 +142,17 @@ export const useGovObjects = (opts?: { enabled?: boolean }): UseQueryResult<GovO
enabled: opts?.enabled ?? true,
});
/**
* **按活动看数据域**(2026-08-24)—— 「我这个活动写了哪几张表,现在各有几行」。
* 键挂在 `['gov','data','by-activity']` 下:`kind:'system'` 的推送按 `gov` 前缀失效,
* 一单跑完这一屏跟着变(数据域不以任务为轴,靠的就是那条系统级广播)。
*/
export const useGovDataByActivity = (): UseQueryResult<GovLedgerByActivity> =>
useQuery({
queryKey: ['gov', 'data', 'by-activity'],
queryFn: () => call<GovLedgerByActivity>(ledgerByActivityRoute),
});
export const useGovLedger = (domain: string | undefined, object: string | undefined): UseQueryResult<GovLedgerPage> =>
useQuery({
queryKey: ['gov', 'ledger', domain, object],
+18 -12
View File
@@ -10,6 +10,7 @@
*/
import { useState, type CSSProperties, type Dispatch, type ReactNode, type SetStateAction } from 'react';
import { Ico } from '@web-shared/Ico.tsx';
import { Btn } from '@web-shared/Btn.tsx';
export function Chip({ tone = 'mute', ico, children }: { tone?: string; ico?: string; children: ReactNode }) {
return (
@@ -359,16 +360,16 @@ export function ListEdit({
placeholder={placeholder}
onChange={(e) => onChange(items.map((x, j) => (j === i ? e.target.value : x)))}
/>
<button className="g-le-x" title="删除该项" onClick={() => onChange(items.filter((_, j) => j !== i))}>
<Btn className="g-le-x" title="删除该项" onClick={() => onChange(items.filter((_, j) => j !== i))}>
<Ico name="trash-2" s={13} />
</button>
</Btn>
</div>
))}
{items.length === 0 && <span className="g-le-e">未声明</span>}
<button className="g-le-add" onClick={() => onChange([...items, ''])}>
<Btn className="g-le-add" onClick={() => onChange([...items, ''])}>
<Ico name="plus" s={12} />
添加
</button>
</Btn>
</div>
);
}
@@ -390,6 +391,8 @@ export type ActSpec = {
primary?: boolean;
danger?: boolean;
disabled?: boolean;
/** 这一钮的请求还在路上 —— 递 `mutation.isPending`,按钮自己会说「在办事」 */
busy?: boolean;
ask?: AskSpec | undefined;
onClick?: () => void;
};
@@ -417,10 +420,10 @@ export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: bo
</div>
</div>
<div className="g-ask-a">
<button className="g-btn" onClick={onCancel}>
<Btn className="g-btn" onClick={onCancel}>
取消
</button>
<button
</Btn>
<Btn
className={'g-btn ' + (danger ? 'danger' : 'primary')}
onClick={() => {
spec.onOk();
@@ -428,7 +431,7 @@ export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: bo
}}
>
{spec.okLabel}
</button>
</Btn>
</div>
</div>
);
@@ -441,9 +444,10 @@ export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; al
<div className="g-acts">
<div className="g-acts-r" style={{ justifyContent: align === 'end' ? 'flex-end' : 'flex-start' }}>
{items.map((it, i) => (
<button
<Btn
key={i}
disabled={it.disabled === true}
busy={it.busy === true}
className={
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
}
@@ -451,7 +455,7 @@ export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; al
>
{it.ico && <Ico name={it.ico} s={13} />}
{it.label}
</button>
</Btn>
))}
</div>
{a?.ask && <AskBand spec={a.ask} {...(a.danger === undefined ? {} : { danger: a.danger })} onCancel={() => setArmed(null)} />}
@@ -495,8 +499,10 @@ export function PageHead({
{acts && (
<div className="g-dh-a">
{acts.map((it, i) => (
<button
<Btn
key={i}
disabled={it.disabled === true}
busy={it.busy === true}
className={
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
}
@@ -504,7 +510,7 @@ export function PageHead({
>
{it.ico && <Ico name={it.ico} s={13} />}
{it.label}
</button>
</Btn>
))}
</div>
)}
+72 -1
View File
@@ -1,6 +1,16 @@
/**
* 服务端交互的唯一通道(编码规范 §9.2):组件内**禁止裸 fetch**。
* 路由契约来自 `@contracts/api`;**禁止 Hono RPC 客户端** —— 那要求前端 import 后端类型(§5.1)。
*
* **同一笔请求不发两遍**(2026-08-24):唯一通道正是安放这道闸的地方 ——
* 按在这里,所有点击路径(按钮 / 下拉 / 勾选 / 组件里临时写的 mutate)一次盖全,
* 不必指望每个调用点自己记得防抖。两段:
* · **在途去重** —— 同一笔还在路上,第二次点击拿到的是同一个 Promise,不再发第二个请求;
* · **落地后的双击窗**(仅写请求)—— 刚成功那一笔在 `DOUBLE_CLICK_MS` 内原样重来,
* 当作同一次点击的余震,交回上一次的结果。失败的不进这个窗:那时候再点是**重试**,
* 重试必须真的重试。
* 读请求(GET)只做在途去重,不设落地窗 —— 写完立刻重取的那一下不能被当成余震压掉,
* 否则屏上摆的是改之前的数(界面不撒谎)。
*/
import type { RouteContract } from '@contracts';
@@ -36,7 +46,36 @@ function queryString(query: Readonly<Record<string, string | undefined>> = {}):
return s === '' ? '' : `?${s}`;
}
export async function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> {
/** 双击阈值 —— 一次成功落地之后这么久之内的同一笔,算同一次点击的余震 */
export const DOUBLE_CLICK_MS = 450;
/**
* 一笔请求的身份 = 方法 + 落地后的完整地址 + 报文。
* 差一个字就是另一笔(改了一格再存不会被当成重复),一模一样才谈得上「点了两下」。
*/
export function callKey(route: RouteContract, options: CallOptions = {}): string {
const url = resolvePath(route.path, options.params) + queryString(options.query);
return `${route.method} ${url} ${options.body === undefined ? '' : JSON.stringify(options.body)}`;
}
/** 在路上的:键 → 那一个 Promise */
const inFlight = new Map<string, Promise<unknown>>();
/** 刚落地的写请求:键 → { 时刻, 结果 } */
const justDone = new Map<string, { at: number; value: unknown }>();
/** 过了窗口的旧条目扫掉 —— 这张表只服务「刚刚那一下」,不该随会话一直长 */
function sweep(): void {
const now = Date.now();
for (const [k, v] of justDone) if (now - v.at >= DOUBLE_CLICK_MS) justDone.delete(k);
}
/** 测试用:把两张表清空(浏览器里没有调用方) */
export function resetCallGuards(): void {
inFlight.clear();
justDone.clear();
}
async function send<T>(route: RouteContract, options: CallOptions): Promise<T> {
const response = await fetch(resolvePath(route.path, options.params) + queryString(options.query), {
method: route.method,
headers: { 'content-type': 'application/json' },
@@ -56,3 +95,35 @@ export async function call<T>(route: RouteContract, options: CallOptions = {}):
}
return payload as T;
}
export function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> {
const key = callKey(route, options);
const write = route.method !== 'GET';
const flying = inFlight.get(key);
if (flying !== undefined) return flying as Promise<T>;
if (write) {
const done = justDone.get(key);
if (done !== undefined && Date.now() - done.at < DOUBLE_CLICK_MS) return Promise.resolve(done.value as T);
if (done !== undefined) justDone.delete(key);
}
const promise = send<T>(route, options).then(
(value) => {
inFlight.delete(key);
// 只记成功的:失败之后再点是重试,重试得真的发出去
if (write) {
sweep();
justDone.set(key, { at: Date.now(), value });
}
return value;
},
(error: unknown) => {
inFlight.delete(key);
throw error;
},
);
inFlight.set(key, promise);
return promise;
}