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
@@ -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>,