Archived
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 即落登录页。
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,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>}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user