Archived
用户在工作台上连报六条(2026-08-27):
- 对话区把数据工单摆成「执行工单」→ 契约 ExecutionWorkOrder.variant:'data'(引擎侧与执行工单无异,
只换面孔),归一层从卡上带下来,web 新增 DataForm(同一套办法、另一副面孔:色/图标/「数据工单」/「确认写入」),
办结卡与治理台人类工具目录同步;活动的两张数据工单卡骨架标 variant:"data"。
- 表显示不全不能右滑 → 目标件把 overflow-x:auto 写在 .tbl-wrap,平台包装是 .jc-tbl-wrap,谁都没管到;补一条。
- 表头只留中文 → 三张数据工单表去英文列名与 .thd 小字。
- 编辑态内部料号「只读字 + 输入框」并排 → 目标件 #tbl1 .jc-vcell{display:inline}(id 特异度)盖过
table.on 的隐藏规则,表 1 每格其实都并排,PN 那格恰好有字;用 id 特异度覆盖回去。
- input 与 select 没对齐 → 徽标从前包 .ro 编辑态留着、压在下拉框头上把那格顶低 13px,改成随只读格一起藏;
input/select 钉同一高度。
- 第一步「派数据工单还是放弃候选」不该问人 → 决策卡退役(覆盖规格「候选 ≥2 可放弃」那一条,用户定),
查无记录一律自动派;剧本/说明书/测试/e2e 同步(e2e 新增 expectAsks.max 断言「不该问的没问」)。
顺带:5173 的 /api 反代默认指向一条公网隧道(150.158.83.131:46100 → nps → npc → 本机 :8787),
浏览器每次请求先出公网再绕回来;默认改回本机,要远端的显式给 API_PROXY_TARGET。
静态渲染实证:900px 宽的框里横向能滑 200px;编辑态同一行 3 input + 1 select 全部 32px 同一 y;
只读 PN 格 display:none。活动包 1.4.0;2097 测 / typecheck / lint / depcruise 绿。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
1378 lines
54 KiB
TypeScript
1378 lines
54 KiB
TypeScript
/**
|
|
* 对话流的气泡与卡族 —— 自原型 `_components/TaskChat.tsx` 平移(气泡二元制,2026-07-26 定稿)。
|
|
*
|
|
* **当前未挂载**:对话区正处在「一步一步描述、一步一步接」的阶段,暂为空占位。
|
|
* 这一份留着等接回 —— 描述到对话该长什么样时直接用,或按新口径改。
|
|
*
|
|
* 「对话流是**一切回环输入的唯一入口**」(OS 4.1);输入侧不参与控制权转交(AM 07)。
|
|
* AI 的内容只有两种形态、不允许中间态:
|
|
* · 纯文本气泡 —— 结论 / 说明 / 回执
|
|
* · 完整表单卡 —— 工单卡 / 验收单 / 决策单 / 补全单 / 录入单 / 评价单
|
|
* 六张卡共用一套外壳 `jc-fc`(头 = 图标 + 类型名 + 计数,身 = `jc-fc-b`)——
|
|
* 谁自造一套骨架,谁在对话流里就显得是外来的。
|
|
* 一条消息 = 署名行(AI · 时间 · 类型章)+ 一或两个**纯**块。
|
|
* ⛔ 补全 / 决策卡吐出期间 composer 锁定 —— 先办卡,再说话。
|
|
*
|
|
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
|
|
*/
|
|
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 口径)。
|
|
*
|
|
* **同一个签,带不带工单说的是两件事**:
|
|
* 带工单(`ref`)的那条是「我派了一张卡给你」;不带的是 AI 在**交代进展**。
|
|
* 只按签取字的话,一句「三条已归一,缺 1 个交期」会被标成「派了工单」——
|
|
* 它一张卡也没派。界面不撒谎:没有卡就不写卡的字(实测踩过,满屏「派了工单」)。
|
|
*/
|
|
const TAG_UI: Readonly<Record<AiTag, { label: string; cls: string }>> = {
|
|
conclusion: { label: '阶段结论', cls: 'auto' },
|
|
guidance: { label: '派出工单', cls: 'exe' },
|
|
criterion: { label: '请你验收', cls: 'acc' },
|
|
};
|
|
|
|
/**
|
|
* 交代进展的章 —— 同一批签,不带工单时的说法。
|
|
*
|
|
* **一律四个字**(2026-07-31 用户定):这一列的每一格只说一件事,
|
|
* 字数一致那一列才扫得动。从前这里是「进展」「判据」两个字,
|
|
* 跟旁边的「阶段结论」一长一短,像两套词表混着用 —— 事实上它们就是一套。
|
|
* 工作日志用的也是这一套词(见 `task-log.ts` 的 `actionOf`)。
|
|
*/
|
|
const SAY_UI: Readonly<Record<AiTag, { label: string; cls: string }>> = {
|
|
conclusion: { label: '阶段结论', cls: 'auto' },
|
|
guidance: { label: '交代进展', cls: 'auto' },
|
|
criterion: { label: '验收判据', cls: 'acc' },
|
|
};
|
|
|
|
/**
|
|
* 系统气泡的章 —— **对话区说人话,契约说类型**。
|
|
*
|
|
* `EVENT_TYPE_LABEL` 是事件分类学(data / time / human / control),治理面按它分区没错;
|
|
* 可直接摆进对话流就成了「数据事件」——「一封邮件触发了这条流程」被标成「数据事件」,
|
|
* 人看不出这跟自己有什么关系。归侧词表(`SIDE_ST`)里这一类早就叫**触发事件**了,
|
|
* 工作日志用的就是它;两屏说同一件事,得用同一个词。
|
|
*/
|
|
const SYS_TAG: Readonly<Record<string, string>> = {
|
|
data: '触发事件',
|
|
time: '时间到点',
|
|
// 系统气泡里 `human` 只有一个来源:人办结了执行工单(chat-projection 显式打的),
|
|
// 裸「事件到达(human)」那条早在投影里就被滤掉了 —— 所以这里说得死一点
|
|
human: '员工办结',
|
|
control: '控制事件',
|
|
};
|
|
|
|
/** ISO → HH:MM(署名行只要时分) */
|
|
const hm = (iso: string): string => atClock(iso);
|
|
|
|
export type TaskChatProps = {
|
|
readonly chat: readonly ChatMessage[];
|
|
readonly orders: readonly WorkOrder[];
|
|
/** AI 正在回话 —— 请求在途即亮「输入中」 */
|
|
readonly thinking: boolean;
|
|
/**
|
|
* AI 正在办这单(Loop 在自己转)—— 与「在打字」是两种状态:
|
|
* 打字是**在回你的话**(三点气泡);办事是**环在推进**(均衡条常驻在场)。
|
|
* 状态来自任务投影的 runtime,不是前端猜的。
|
|
*/
|
|
readonly working?: boolean;
|
|
/** 卡上刚交出去的那一笔还在路上 —— 卡上的钮据此说「在办事」 */
|
|
readonly replying?: boolean;
|
|
onSay(text: string): void;
|
|
onReplyCard(reply: ChatCardReply): void;
|
|
onReplyOrder(reply: WorkOrderReply): void;
|
|
/** 点卡上的「打开工单」→ 视图区定位到那张工单 */
|
|
onLocate(orderId: string): void;
|
|
onOpenComms(): void;
|
|
/** 人在工作区报告上改过的填报值 —— 验收裁决时一并带走(见 TaskChat 的注释) */
|
|
readonly orderValues?: Readonly<Record<string, string>>;
|
|
/** 卡上填的值也进同一个缓冲 —— 执行单的表单如今长在对话区的卡上 */
|
|
onField(key: string, value: string): void;
|
|
};
|
|
|
|
export function ChatStream({
|
|
chat,
|
|
orders,
|
|
thinking,
|
|
working = false,
|
|
replying = false,
|
|
onSay,
|
|
onReplyCard,
|
|
onReplyOrder,
|
|
onLocate,
|
|
onOpenComms,
|
|
orderValues,
|
|
onField,
|
|
}: TaskChatProps) {
|
|
const [text, setText] = useState('');
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
boxRef.current?.scrollTo({ top: 1e9, behavior: 'smooth' });
|
|
}, [chat.length, thinking, working]);
|
|
|
|
// ⛔ 补全 / 决策卡未办结期间不能对话 —— 锁的是**卡**,不是工单
|
|
const blocked = chat.some(
|
|
(m) => (m.k === 'elicitation' && m.done === undefined) || (m.k === 'decision' && m.chosen === undefined),
|
|
);
|
|
|
|
const send = () => {
|
|
const t = text.trim();
|
|
if (t === '' || blocked) return;
|
|
setText('');
|
|
onSay(t);
|
|
};
|
|
|
|
return (
|
|
<ReplyBusyContext.Provider value={replying}>
|
|
<div className="jc-tch">
|
|
<header className="jc-tch-head">
|
|
<span className="jc-tch-mark">
|
|
<Ico name="bot" s={15} />
|
|
</span>
|
|
<b className="jc-tch-name">任务助理</b>
|
|
<em className="jc-tch-tag">Agent</em>
|
|
<span className="jc-tch-desc">盯着这单推进 · 有事随时说</span>
|
|
</header>
|
|
|
|
<div className="jc-tch-scroll" ref={boxRef}>
|
|
{chat.map((m) => (
|
|
<Msg
|
|
key={m.id}
|
|
m={m}
|
|
orders={orders}
|
|
onReplyCard={onReplyCard}
|
|
onReplyOrder={onReplyOrder}
|
|
onLocate={onLocate}
|
|
onOpenComms={onOpenComms}
|
|
onField={onField}
|
|
{...(orderValues === undefined ? {} : { orderValues })}
|
|
/>
|
|
))}
|
|
{thinking ? (
|
|
<article className="jc-ag">
|
|
<ChatAvatar who="ai" />
|
|
<div className="jc-ag-col">
|
|
<div className="jc-msg-meta">AI</div>
|
|
<span className="jc-typing">
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</span>
|
|
</div>
|
|
</article>
|
|
) : working ? (
|
|
<WorkingStrip />
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="copilot-composer session-composer">
|
|
{blocked && (
|
|
<div className="jc-comp-lock">
|
|
<Ico name="lock" s={12} /> 有一张卡在等你(补全 / 决策)——处理完就能继续对话
|
|
</div>
|
|
)}
|
|
<div className="composer-row">
|
|
<textarea
|
|
rows={2}
|
|
value={text}
|
|
disabled={blocked}
|
|
placeholder={blocked ? '先处理上面的卡片…' : '有什么要补充或交代的?(Enter 发送)'}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
send();
|
|
}
|
|
}}
|
|
/>
|
|
<Btn className="copilot-send" disabled={text.trim() === '' || blocked} busy={thinking} onClick={send}>
|
|
<Ico name="send" s={14} /> 发送
|
|
</Btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ReplyBusyContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* AI 办事条 —— 消息流末尾的**常驻在场**:环在自己转(写台账 / 调工具 / 判局面)。
|
|
* 均衡条不是打字点:打字说「正在回你」,均衡条说「正在办事」。
|
|
* 头像加呼吸环:第二信号,很轻 —— 只有留意的人看得见。
|
|
*
|
|
* 导出是给**起手那一刻**用的(`TaskChat` 的空流分支):一单刚起、还没有任何消息时,
|
|
* 摆的就该是这一条,而不是一张空态插画 —— 两者形状不同,第一句话到了版式会跳。
|
|
*/
|
|
export function WorkingStrip() {
|
|
return (
|
|
<article className="jc-ag" aria-live="polite">
|
|
<span className="jc-ava-live" style={{ display: 'inline-flex', borderRadius: 999 }}>
|
|
<ChatAvatar who="ai" />
|
|
</span>
|
|
<div className="jc-ag-col">
|
|
<div className="jc-msg-meta">AI · 正在办这单</div>
|
|
<span className="jc-aiwork">
|
|
<span className="jc-aiwork-eq" aria-hidden>
|
|
<i />
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</span>
|
|
<b className="jc-aiwork-txt">持续推进中</b>
|
|
<em className="jc-aiwork-sub">有新进展会随时在这里说</em>
|
|
</span>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
/* ── Agent 侧包装:头像 + (署名行 → 内容流) ── */
|
|
function Ag({
|
|
meta,
|
|
tag,
|
|
hasOrder = false,
|
|
children,
|
|
}: {
|
|
meta: string;
|
|
tag?: AiTag | undefined;
|
|
/** 这条消息带不带工单 —— 带才用卡的说法(见 TAG_UI 的注释) */
|
|
hasOrder?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const t = tag === undefined ? undefined : (hasOrder ? TAG_UI : SAY_UI)[tag];
|
|
return (
|
|
<article className="jc-ag">
|
|
<ChatAvatar who="ai" />
|
|
<div className="jc-ag-col">
|
|
<div className="jc-msg-meta">
|
|
{meta}
|
|
{t !== undefined && <b className={'jc-mtag m-' + t.cls}>{t.label}</b>}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function Msg({
|
|
m,
|
|
orders,
|
|
onReplyCard,
|
|
onReplyOrder,
|
|
onLocate,
|
|
onOpenComms,
|
|
orderValues,
|
|
onField,
|
|
}: {
|
|
m: ChatMessage;
|
|
orders: readonly WorkOrder[];
|
|
onReplyCard: (reply: ChatCardReply) => void;
|
|
onReplyOrder: (reply: WorkOrderReply) => void;
|
|
onLocate: (orderId: string) => void;
|
|
onOpenComms: () => void;
|
|
/** 人在工作区报告上改过的值 —— 验收裁决时一并带走 */
|
|
orderValues?: Readonly<Record<string, string>> | undefined;
|
|
onField: (key: string, value: string) => void;
|
|
}) {
|
|
const busy = useReplyBusy();
|
|
// 人的消息:右侧品牌气泡(纯文本)
|
|
if (m.k === 'human') {
|
|
return (
|
|
<article className="copilot-msg human">
|
|
<div className="jc-msg-col r">
|
|
<div className="jc-msg-meta r">{hm(m.at)}</div>
|
|
<div className="copilot-bubble">
|
|
<p className="jc-content">{m.text}</p>
|
|
</div>
|
|
</div>
|
|
<ChatAvatar who="user" />
|
|
</article>
|
|
);
|
|
}
|
|
|
|
// 系统事件:灰头像 + 灰调文本气泡。三类唤醒事件在章上标明是哪一类
|
|
if (m.k === 'system') {
|
|
return (
|
|
<article className="jc-ag">
|
|
<div className="chat-avatar jc-ava-sys" aria-label="系统">
|
|
<Ico name="server" s={15} />
|
|
</div>
|
|
<div className="jc-ag-col">
|
|
<div className="jc-msg-meta">
|
|
系统 · {hm(m.at)}
|
|
<b className="jc-mtag m-sys">{SYS_TAG[m.type] ?? `${EVENT_TYPE_LABEL[m.type]}事件`}</b>
|
|
</div>
|
|
<div className="jc-ag-b sys">
|
|
<p className="jc-content">{m.text}</p>
|
|
{/*
|
|
起单开场白带的那一下 —— 原件在沟通记录里。
|
|
**点不点都不挡路**:这条消息之后 AI 照常开跑,不等人按。
|
|
*/}
|
|
{m.openComms === true && (
|
|
<button type="button" className="jc-sys-act" onClick={onOpenComms}>
|
|
<Ico name="mail" s={12} />
|
|
查看邮件
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
// AI 发言:一次回复 = 一个气泡 —— 文字在上、表单卡嵌在**同一气泡**里
|
|
if (m.k === 'ai') {
|
|
const order = m.ref === undefined ? undefined : orders.find((o) => o.id === m.ref);
|
|
const settled = order?.done !== undefined;
|
|
return (
|
|
<Ag meta={`AI · ${hm(m.at)}`} tag={m.tag} hasOrder={order !== undefined}>
|
|
<div className={'jc-ag-b' + (order === undefined ? '' : ' card')}>
|
|
<p className="jc-content">{m.text}</p>
|
|
{order !== undefined &&
|
|
(order.mode === 'acceptance' && m.tag === 'criterion' && !settled ? (
|
|
<AcceptForm
|
|
order={order}
|
|
onReplyOrder={onReplyOrder}
|
|
onLocate={onLocate}
|
|
{...(orderValues === undefined ? {} : { orderValues })}
|
|
/>
|
|
) : order.mode === 'execution' && order.variant === 'data' && !settled ? (
|
|
/* 数据工单 = 执行工单的变体(契约 `variant`):同一套办法,另一副面孔 */
|
|
<DataForm
|
|
order={order}
|
|
onReplyOrder={onReplyOrder}
|
|
onLocate={onLocate}
|
|
onField={onField}
|
|
{...(orderValues === undefined ? {} : { orderValues })}
|
|
/>
|
|
) : order.mode === 'execution' && !settled ? (
|
|
/* 没办结的执行单 = 一张能就地办的卡:指导书 + 依据 + 表单 + 提交 */
|
|
<ExecForm
|
|
order={order}
|
|
onReplyOrder={onReplyOrder}
|
|
onLocate={onLocate}
|
|
onField={onField}
|
|
{...(orderValues === undefined ? {} : { orderValues })}
|
|
/>
|
|
) : (
|
|
<WoCard order={order} onLocate={onLocate} />
|
|
))}
|
|
</div>
|
|
</Ag>
|
|
);
|
|
}
|
|
|
|
// 补全:问题文字在上,表单卡嵌在同一气泡里
|
|
if (m.k === 'elicitation') {
|
|
return (
|
|
<Ag meta={`AI · ${hm(m.at)}`}>
|
|
<div className="jc-ag-b card">
|
|
<p className="jc-content">{m.ask}</p>
|
|
<div className="jc-fc t-fill">
|
|
<div className="jc-fc-h">
|
|
<Ico name="square-pen" s={13} />
|
|
<b>请你补全</b>
|
|
<em>{m.fields.length} 项</em>
|
|
</div>
|
|
<div className="jc-fc-b">
|
|
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
|
|
{m.done === undefined ? (
|
|
<FillForm
|
|
fields={m.fields}
|
|
onDone={(values) => onReplyCard({ k: 'elicitation', cardId: m.id, values })}
|
|
onCant={(reason) => onReplyCard({ k: 'elicitation', cardId: m.id, values: {}, reason })}
|
|
/>
|
|
) : m.done.reason === undefined ? (
|
|
<p className="jc-chosen">
|
|
<Ico name="circle-check" s={14} /> 你补了:
|
|
<b>{m.fields.map((f) => m.done?.values[f.key] ?? '—').join(';')}</b>
|
|
<code className="jc-mono">{hm(m.done.at)}</code>
|
|
</p>
|
|
) : (
|
|
<p className="jc-chosen cant">
|
|
<Ico name="alert-circle" s={14} /> 你说明了没法填:
|
|
<b>{m.done.reason}</b>
|
|
<code className="jc-mono">{hm(m.done.at)}</code>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Ag>
|
|
);
|
|
}
|
|
|
|
// 任务评价:星级打分 + 改进建议,并入活动记忆
|
|
if (m.k === 'rating') {
|
|
return (
|
|
<Ag meta={`AI · ${hm(m.at)}`}>
|
|
<div className="jc-ag-b card">
|
|
<p className="jc-content">{m.ask}</p>
|
|
<div className="jc-fc t-rate">
|
|
<div className="jc-fc-h">
|
|
<Ico name="star" s={13} />
|
|
<b>任务评价</b>
|
|
<em>5 分制</em>
|
|
</div>
|
|
<div className="jc-fc-b">
|
|
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
|
|
{m.done === undefined ? (
|
|
<RateForm
|
|
onDone={(score, note) =>
|
|
onReplyCard({ k: 'rating', cardId: m.id, score, ...(note === '' ? {} : { note }) })
|
|
}
|
|
/>
|
|
) : (
|
|
<p className="jc-chosen">
|
|
<span className="jc-stars ro">
|
|
{[1, 2, 3, 4, 5].map((n) => (
|
|
<i key={n} className={'jc-star' + (n <= (m.done?.score ?? 0) ? ' on' : '')}>
|
|
<Ico name="star" s={16} />
|
|
</i>
|
|
))}
|
|
</span>
|
|
<b>{m.done.score} 分</b>
|
|
{m.done.note !== undefined && <span className="jc-rate-note">{m.done.note}</span>}
|
|
<code className="jc-mono">{hm(m.done.at)}</code>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Ag>
|
|
);
|
|
}
|
|
|
|
/*
|
|
* 数据录入卡 —— **确认那一下就是写入**(第五类人类工具)。
|
|
* 与补全卡长得像,分量完全不同:补全卡收的是「答案」,AI 拿去再写;
|
|
* 这一张收的是「这一行数据」,按下确认基座当场落进台账草稿层,AI 不经手。
|
|
* 所以卡面上要**明写落到哪张表** —— 人得知道自己这一下写的是什么。
|
|
*/
|
|
if (m.k === 'entry') {
|
|
const written = m.done !== undefined;
|
|
return (
|
|
<Ag meta={`AI · ${hm(m.at)}`}>
|
|
<div className="jc-ag-b card">
|
|
<p className="jc-content">{m.ask}</p>
|
|
{/* 外壳与补全/决策/评价同一套 `jc-fc` —— 卡族要长得像一家人,
|
|
各自的身份写在头上(图标 + 类型名),不靠各造一套框来区分 */}
|
|
<div className="jc-fc t-entry">
|
|
<div className="jc-fc-h">
|
|
<Ico name="table" s={13} />
|
|
<b>数据录入</b>
|
|
<em className={written ? 'ok' : ''}>{written ? '已写入' : `${m.fields.length} 项`}</em>
|
|
</div>
|
|
<div className="jc-fc-b">
|
|
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
|
|
<EntryCard card={m} onDone={(values) => onReplyCard({ k: 'entry', cardId: m.id, values })} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Ag>
|
|
);
|
|
}
|
|
|
|
// 决策:问题文字在上,选择卡嵌在同一气泡里
|
|
const chosenLabel =
|
|
m.chosen === undefined
|
|
? ''
|
|
: (m.options.find((o) => o.value === m.chosen?.value)?.label ??
|
|
(m.chosen.value.startsWith('custom:') ? m.chosen.value.slice(7) : m.chosen.value));
|
|
return (
|
|
<Ag meta={`AI · ${hm(m.at)}`}>
|
|
<div className="jc-ag-b card">
|
|
<p className="jc-content">{m.ask}</p>
|
|
<div className="jc-fc t-dec">
|
|
<div className="jc-fc-h">
|
|
<Ico name="git-branch" s={13} />
|
|
<b>需要你定</b>
|
|
<em>
|
|
{m.options.length + (m.allowCustom ? 1 : 0)} 选 1
|
|
</em>
|
|
</div>
|
|
<div className="jc-fc-b">
|
|
<Kv items={m.basis} />
|
|
{m.recommend !== undefined && (
|
|
<p className="jc-fc-note">
|
|
<Ico name="sparkles" s={13} />
|
|
<span>
|
|
<b>AI 建议</b>
|
|
{m.recommend}
|
|
</span>
|
|
</p>
|
|
)}
|
|
{m.chosen === undefined ? (
|
|
<div className="jc-branches">
|
|
{m.options.map((o, i) => (
|
|
<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>
|
|
<span className="jc-branch-bd">
|
|
<b>
|
|
{o.label}
|
|
{o.recommended === true && <em>推荐</em>}
|
|
</b>
|
|
{o.detail !== undefined && <p>{o.detail}</p>}
|
|
{o.consequence !== undefined && (
|
|
<p className="jc-conseq">
|
|
<Ico name="arrow-right" s={12} /> {o.consequence}
|
|
</p>
|
|
)}
|
|
</span>
|
|
<Ico name="chevron-right" s={14} />
|
|
</Btn>
|
|
))}
|
|
{m.allowCustom && (
|
|
<CustomOption
|
|
n={String.fromCharCode(65 + m.options.length)}
|
|
onPick={(txt) => onReplyCard({ k: 'decision', cardId: m.id, value: 'custom:' + txt, note: txt })}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="jc-chosen">
|
|
<Ico name="circle-check" s={14} /> 你选了:
|
|
<b>{chosenLabel}</b>
|
|
<code className="jc-mono">{hm(m.chosen.at)}</code>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Ag>
|
|
);
|
|
}
|
|
|
|
/* 参考信息:表单卡内的结构化 kv 表 */
|
|
function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
|
|
return (
|
|
<div className="jc-fc-kv">
|
|
<i>参考信息</i>
|
|
<div className="jc-fc-kv-g">
|
|
{items.map((b, i) => (
|
|
<span key={i}>
|
|
<em>{b.k}</em>
|
|
<b>{b.v}</b>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* 工单卡:题头 + 单体 + 打开动作 —— 已办结的、以及没有作业指导书的都用它 */
|
|
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
|
|
const result = order.done?.result;
|
|
const bad = result !== undefined && isRejection(result);
|
|
const exe = order.mode === 'execution';
|
|
const data = order.mode === 'execution' && order.variant === 'data';
|
|
return (
|
|
<div className={'jc-fc ' + (data ? 't-data' : exe ? 't-exe' : 't-acc')}>
|
|
<div className="jc-fc-h">
|
|
<Ico name={data ? 'database' : exe ? 'file-text' : 'shield-check'} s={13} />
|
|
<b>工单 · {data ? '数据' : exe ? '执行' : '验收'}</b>
|
|
<em className={result === undefined ? '' : bad ? 'bad' : 'ok'}>
|
|
{result === undefined ? '待处理' : bad ? '已退回' : '已办结'}
|
|
</em>
|
|
</div>
|
|
<div className="jc-fc-b">
|
|
<p className="jc-fc-q strong">{order.title}</p>
|
|
{/*
|
|
* **签完之后换一屏**(2026-08-02 用户:「完成验收后反而要把落地结果展示出来」)。
|
|
*
|
|
* 放行卡上摆的是签字前要核的东西(判据);签完之后人关心的是另一件事 ——
|
|
* 这一签下去,世界上发生了什么:落了几行、进了哪几张表、转去哪儿了。
|
|
* 那一摞对勾说的还是签之前的话,回看时它答不了这个问题。
|
|
* (落地事实取不到的老单仍退回判据表:宁可摆旧的,也不摆空的。)
|
|
*/}
|
|
{order.mode === 'acceptance' &&
|
|
(order.landing !== undefined ? (
|
|
<Landing landing={order.landing} />
|
|
) : (
|
|
order.criteria.length > 0 && <Crit list={order.criteria} />
|
|
))}
|
|
</div>
|
|
<div className="jc-fc-ft">
|
|
<button className="jc-btn jc-btn-ghost jc-fc-open" onClick={() => onLocate(order.id)}>
|
|
<Ico name="panel-left-open" s={13} /> 打开报告 <Ico name="arrow-right" s={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 判据逐条 —— 验收卡与办结卡同一份。
|
|
*
|
|
* **「裁了但办不到」不是「未达标」**(2026-08-01 用户当场指出:那条 L4 的红叉「为啥还在」)。
|
|
* 裁量项(L3/L4)有三态,卡上要分得出来:
|
|
* · 裁成满足 → 达标(绿);
|
|
* · **裁成不满足 → 「AI 判为不满足」**(琥珀)+ 理由 —— 它不是活没干完,
|
|
* 是 AI 办过、办不到,**把判断交给你**;提交门本来就允许这样提交(裁过即可)。
|
|
* · 没裁过 → 未达标(红)—— 那才是活没干完。
|
|
* 从前三态挤成两态,人看到一条永远不绿的红叉,既不知道 AI 有没有办过、也不知道为什么,
|
|
* 只能凭感觉签 —— 而理由那一句正是他要看的东西。
|
|
*/
|
|
function Crit({
|
|
list,
|
|
}: {
|
|
list: ReadonlyArray<{ label: string; level: string; pass: boolean; settled?: boolean | undefined; rationale?: string | undefined }>;
|
|
}) {
|
|
return (
|
|
<ul className="jc-rp-crit sm">
|
|
{list.map((c, i) => {
|
|
// 裁量项:settled 有值才是它。裁过而未满足 = 交给人定,不是缺口
|
|
const judged = c.settled === true && !c.pass;
|
|
return (
|
|
<li key={i} className={c.pass ? 'ok' : judged ? 'judged' : 'no'}>
|
|
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
|
|
<span className="t">
|
|
{c.label}
|
|
{judged && c.rationale !== undefined && <em className="jc-crit-why">{c.rationale}</em>}
|
|
</span>
|
|
<span className="r">
|
|
<Ico name={c.pass ? 'circle-check' : judged ? 'circle-alert' : 'circle-x'} s={12} />
|
|
{c.pass ? '达标' : judged ? 'AI 判为不满足 · 交你定' : '未达标'}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 落地结果 —— **办结之后那一屏**:这一签下去,世界上发生了什么。
|
|
*
|
|
* 三件事,一件也不多:进了哪几张表各几行、转去哪儿了、试跑挡下了几行。
|
|
* 摆的都是放行那一刻的结构化留痕,不转述、不估算 ——
|
|
* 「合并 9 行」而其中两行没进表的话,那句话就是假的,所以试跑那一行必须说。
|
|
*/
|
|
function Landing({
|
|
landing,
|
|
}: {
|
|
landing: NonNullable<Extract<WorkOrder, { mode: 'acceptance' }>['landing']>;
|
|
}) {
|
|
return (
|
|
<div className="jc-landing">
|
|
<p className="jc-landing-h">
|
|
<Ico name="circle-check" s={13} />
|
|
<b>已落地</b>
|
|
<span>{landing.rows} 行进正式表</span>
|
|
</p>
|
|
{landing.tables.length > 0 && (
|
|
<ul className="jc-rp-crit sm jc-guide">
|
|
{landing.tables.map((t, i) => (
|
|
<li key={i}>
|
|
<span className="jc-lv jc-lv-done">
|
|
<Ico name="file-spreadsheet" s={11} />
|
|
</span>
|
|
<span className="t">{t.label}</span>
|
|
<span className="r">{t.rows} 行</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{/*
|
|
* **收口要答两件事**(2026-08-02 用户定):落了什么、往哪儿去。
|
|
* 往哪儿去分两种:转给下一个活动(带了哪些字段一并说清),
|
|
* 或者一个口都没发 —— 那就是这条流到此为止,得说出来,别让人自己猜。
|
|
*/}
|
|
{landing.ports.length > 0 ? (
|
|
<p className="jc-landing-p">
|
|
<Ico name="git-branch" s={12} />
|
|
<span>
|
|
<b>转出</b>
|
|
{landing.ports.join(' · ')}
|
|
{landing.carried.length > 0 && (
|
|
<em className="jc-landing-carry">带过去:{landing.carried.slice(0, 12).join('、')}
|
|
{landing.carried.length > 12 ? ` 等 ${landing.carried.length} 项` : ''}
|
|
</em>
|
|
)}
|
|
</span>
|
|
</p>
|
|
) : (
|
|
<p className="jc-landing-p">
|
|
<Ico name="circle-check" s={12} />
|
|
<span>
|
|
<b>到此为止</b>
|
|
没有下游出口 —— 这条流走完了
|
|
</span>
|
|
</p>
|
|
)}
|
|
{landing.simulated > 0 && (
|
|
/* 界面不撒谎:试跑挡下的那几行没进表,不说就等于报了一个假数 */
|
|
<p className="jc-landing-p warn">
|
|
<Ico name="alert-triangle" s={12} />
|
|
<span>试跑:另有 {landing.simulated} 行主数据只留在活动缓存,未落正式表</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 作业指导书 —— **与验收判据同一套版面**(2026-08-02 用户定)。
|
|
*
|
|
* 「这一单要你做什么」与「做成什么算数」是同一类东西:一条一行、左边一枚章、
|
|
* 右边一句结论。判据那一摞已经是这个样子,指导书从前却是一串松散的圆点 ——
|
|
* 同一张卡上两套版面语言,人得在两种读法之间来回切。
|
|
*
|
|
* 与判据的差别只有一处:**指导书没有「达标 / 未达标」** —— 它是要办的事,
|
|
* 不是已办成的事。右边那一格只留给最后那条「算做完的标准」:
|
|
* 它是这几步的收口,摆在末行才读得顺。**不给步骤画对勾** ——
|
|
* 系统并不知道人做没做,画上去就是撒谎。
|
|
*/
|
|
function Guide({ steps, standard }: { steps?: readonly string[] | undefined; standard?: string | undefined }) {
|
|
const list = steps ?? [];
|
|
if (list.length === 0 && standard === undefined) return null;
|
|
return (
|
|
<ul className="jc-rp-crit sm jc-guide">
|
|
{list.map((x, i) => (
|
|
<li key={i}>
|
|
<span className="jc-lv jc-lv-step">{i + 1}</span>
|
|
<span className="t">{x}</span>
|
|
</li>
|
|
))}
|
|
{standard !== undefined && (
|
|
<li className="done">
|
|
<span className="jc-lv jc-lv-done">
|
|
<Ico name="target" s={11} />
|
|
</span>
|
|
<span className="t">
|
|
<b>算做完的标准</b>
|
|
{standard}
|
|
</span>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 执行工单卡 —— **作业指导书 + 依据 + 填报表单 + 提交,全在对话区**(2026-08-01 用户定)。
|
|
*
|
|
* 从前这三样都长在工作区的工单报告里,而那份报告又把活动推的那一屏包在自己里面 ——
|
|
* 于是人看到的是「一份报告里嵌着另一份报告」。用户原话:
|
|
* 「你用你自己的报告格式嵌套了一层我要的报告格式」。
|
|
* 现在分开:**工作区只放件本身**(那份 HTML 模版,可就地改),
|
|
* 判据与指导书跟着「要你办的这件事」走,落在对话区的卡上。
|
|
*
|
|
* 表格那一份值不在这儿收 —— 人是在工作区的件上改的,由外层缓冲带过来
|
|
* (与验收裁决同一条路子:编辑与提交分在两个区,缓冲得由壳层持有)。
|
|
*/
|
|
/** 执行工单与数据工单是同一套办法、两副面孔 —— 面孔在这儿定,办法只写一份 */
|
|
type FormFace = {
|
|
readonly klass: 't-exe' | 't-data';
|
|
readonly headIcon: 'clipboard-check' | 'database';
|
|
readonly head: string;
|
|
readonly docIcon: 'file-text' | 'table';
|
|
readonly docTitle: string;
|
|
readonly docHint: string;
|
|
readonly submit: string;
|
|
};
|
|
const EXEC_FACE: FormFace = {
|
|
klass: 't-exe',
|
|
headIcon: 'clipboard-check',
|
|
head: '工单 · 执行',
|
|
docIcon: 'file-text',
|
|
docTitle: '执行工单',
|
|
docHint: '整份清单在工作区,每一格都能就地改;改完回这里提交',
|
|
submit: '提交',
|
|
};
|
|
/**
|
|
* 数据工单(2026-08-27 用户:「工单类型新加入了一类数据工单…照着这个大概的样式为数据工单单独做一个组件」):
|
|
* 让人**从零建一张主数据表** —— 件上的表就是要建的记录,填齐、保存,回这里确认即入库,活动从「等待数据」唤醒。
|
|
* 引擎那一侧与执行工单无异(契约 `ExecutionWorkOrder.variant = 'data'`),所以只换面孔:色、图标、四字、按钮字。
|
|
*/
|
|
const DATA_FACE: FormFace = {
|
|
klass: 't-data',
|
|
headIcon: 'database',
|
|
head: '工单 · 数据',
|
|
docIcon: 'table',
|
|
docTitle: '数据工单',
|
|
docHint: '件上的表就是要建的记录:每格就地填、保存;填齐后回这里确认,确认即入库',
|
|
submit: '确认写入',
|
|
};
|
|
|
|
type ExecFormProps = {
|
|
order: Extract<WorkOrder, { mode: 'execution' }>;
|
|
onReplyOrder: (reply: WorkOrderReply) => void;
|
|
onLocate: (id: string) => void;
|
|
orderValues?: Readonly<Record<string, string>> | undefined;
|
|
onField: (key: string, value: string) => void;
|
|
};
|
|
|
|
/** 数据工单卡 —— 执行工单的另一副面孔(见 DATA_FACE) */
|
|
function DataForm(props: ExecFormProps) {
|
|
return <ExecForm {...props} face={DATA_FACE} />;
|
|
}
|
|
|
|
function ExecForm({ order, onReplyOrder, onLocate, orderValues, onField, face = EXEC_FACE }: ExecFormProps & { face?: FormFace }) {
|
|
const busy = useReplyBusy();
|
|
const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? '';
|
|
/*
|
|
* **必填还空着就按不下提交**(2026-08-02 用户定)——两处必填,一个门:
|
|
* · 卡上那几格表单字段(`fields[].required`);
|
|
* · 件上那张表里的必填列(`gate`,由基座按台账声明现算,与放行门的 L1 同源)。
|
|
*
|
|
* 从前只看前者:件上那张表压根不在这道门里,于是一条料缺着年用量也能交回来 ——
|
|
* 这一单一路走到放行门才被 L1 拦住,模型连拒五拍熔断(实测)。
|
|
* 同一件事在提交那一刻拦下便宜得多:那时缺的是哪一格就摆在他眼前。
|
|
* 算不出来(卡上没绑表)就没有 `gate`,按钮照常能按 —— 没有依据的时候不拦人。
|
|
*/
|
|
const gate = order.gate;
|
|
const ok = order.fields.every((f) => f.required !== true || val(f).trim() !== '') && gate?.ready !== false;
|
|
return (
|
|
<div className={'jc-fc ' + face.klass}>
|
|
<div className="jc-fc-h">
|
|
<Ico name={face.headIcon} s={13} />
|
|
<b>{face.head}</b>
|
|
{order.steps !== undefined && order.steps.length > 0 && <em>{order.steps.length} 步</em>}
|
|
</div>
|
|
<div className="jc-fc-b">
|
|
<p className="jc-fc-q strong">{order.title}</p>
|
|
{/* 件在工作区 —— 这一钮就是去看它、去改它的门 */}
|
|
{/*
|
|
* 与验收卡那一钮**同一个形状**(2026-08-02 用户:「格式不统一」)——
|
|
* 图标一格、四字粗体一行、小字说明一行、右侧一枚箭头。
|
|
* 两张卡是同一族的东西,长成两个样子只会让人多认一次。
|
|
*/}
|
|
<button className="jc-fc-doc" onClick={() => onLocate(order.id)}>
|
|
<span className="jc-fc-doc-ico">
|
|
<Ico name={face.docIcon} s={15} />
|
|
</span>
|
|
<span className="jc-fc-doc-bd">
|
|
<b>{face.docTitle}</b>
|
|
<em>{face.docHint}</em>
|
|
</span>
|
|
<Ico name="chevron-right" s={14} />
|
|
</button>
|
|
<p className="jc-fc-q">{order.ask}</p>
|
|
<Guide steps={order.steps} standard={order.standard} />
|
|
{order.basis.length > 0 && <Kv items={order.basis} />}
|
|
{order.fields.map((f) => (
|
|
<label key={f.key} className="jc-fc-fld">
|
|
<span>
|
|
{f.label}
|
|
{f.required === true && <em className="jc-req">必填</em>}
|
|
</span>
|
|
<textarea
|
|
className="jc-ta"
|
|
rows={2}
|
|
placeholder={f.placeholder ?? '写一句就行'}
|
|
value={val(f)}
|
|
onChange={(e) => onField(f.key, e.target.value)}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
{gate !== undefined && !gate.ready && (
|
|
/* 拦住就得说清差在哪 —— 只有一个灰按钮的话,人只能猜自己哪儿做错了 */
|
|
<div className="jc-fc-gate">
|
|
<p>
|
|
<Ico name="alert-triangle" s={13} />
|
|
<b>{gate.why}</b>
|
|
</p>
|
|
{gate.blanks.length > 0 && (
|
|
<ul>
|
|
{gate.blanks.slice(0, 8).map((x, i) => (
|
|
<li key={i}>{x}</li>
|
|
))}
|
|
{gate.blanks.length > 8 && <li className="more">还有 {gate.blanks.length - 8} 条</li>}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="jc-fc-ft">
|
|
<Btn
|
|
className="jc-btn jc-fc-go"
|
|
disabled={!ok}
|
|
busy={busy}
|
|
onClick={() =>
|
|
onReplyOrder({
|
|
mode: 'execution',
|
|
workOrderId: order.id,
|
|
values: { ...orderValues },
|
|
})
|
|
}
|
|
>
|
|
<Ico name="check" s={13} /> {order.submitLabel ?? face.submit}
|
|
</Btn>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* 验收单:报告入口 + 判据表 + 落地动作 + 裁决(通过 / 重做)—— 驳回必附意见 */
|
|
function AcceptForm({
|
|
order,
|
|
onReplyOrder,
|
|
onLocate,
|
|
orderValues,
|
|
}: {
|
|
order: Extract<WorkOrder, { mode: 'acceptance' }>;
|
|
onReplyOrder: (reply: WorkOrderReply) => void;
|
|
onLocate: (id: string) => void;
|
|
/**
|
|
* 人在工作区报告上改过的填报值 —— **裁决时一并带走**。
|
|
*
|
|
* 报告是「AI 已填好、可编辑」,裁决在这儿:两半分在两个区里做。
|
|
* 不带过来,回传的 `values` 就恒空 —— 人改了报告,落地时用的还是 AI 的原值
|
|
* (第 12 章③ 的一半:人工修订永远落不下来)。
|
|
* 一个字没改就是空的 —— 空 = 全盘采信 AI 拟值,契约本来就这么定的。
|
|
*/
|
|
orderValues?: Readonly<Record<string, string>> | undefined;
|
|
}) {
|
|
const busy = useReplyBusy();
|
|
const [why, setWhy] = useState('');
|
|
/** 选择组:恰选一口,选口即裁分支 —— 没选之前「通过」不亮 */
|
|
const [port, setPort] = useState<string | undefined>(undefined);
|
|
const choice = order.portChoice;
|
|
const needChoice = choice !== undefined;
|
|
const passN = order.criteria.filter((c) => c.pass).length;
|
|
return (
|
|
<div className="jc-fc t-acc">
|
|
<div className="jc-fc-h">
|
|
<Ico name="shield-check" s={13} />
|
|
<b>请你验收</b>
|
|
<em className={passN === order.criteria.length ? 'ok' : 'bad'}>
|
|
{passN}/{order.criteria.length} 达标
|
|
</em>
|
|
</div>
|
|
<div className="jc-fc-b">
|
|
<button className="jc-fc-doc" onClick={() => onLocate(order.id)}>
|
|
<span className="jc-fc-doc-ico">
|
|
<Ico name="file-text" s={15} />
|
|
</span>
|
|
<span className="jc-fc-doc-bd">
|
|
{/* 四字粗体在上、说明在下 —— 与执行卡同序,人扫两张卡时落点一致 */}
|
|
<b>验收工单</b>
|
|
<em>{order.title} · 成果报告在工作区,可读可就地改</em>
|
|
</span>
|
|
<Ico name="chevron-right" s={14} />
|
|
</button>
|
|
{/* 与办结卡同一份 —— 两处各写一遍,三态迟早只在一处生效 */}
|
|
<Crit list={order.criteria} />
|
|
{order.actions !== undefined && order.actions.length > 0 && (
|
|
<Kv
|
|
items={order.actions.map((a) => ({
|
|
k: a.effect === 'write' ? '通过即执行 · 写' : '通过即执行 · 读',
|
|
v: a.label,
|
|
}))}
|
|
/>
|
|
)}
|
|
{/*
|
|
* **算出来的去向:只告诉他,不给他选**(2026-08-01)。
|
|
*
|
|
* 这一单往哪走是分类表那几个数的函数(有没有 C 类、有没有 B 类),
|
|
* 契约给每个出口写了判据、基座按序求值定下 —— 摆成选择题,
|
|
* 等于把一个可回放的判定换成一次不可回放的手感。
|
|
* 但**必须让他看见**:凭哪一条走的这一口,他要能回核。
|
|
*/}
|
|
{order.routedPort !== undefined && (
|
|
/*
|
|
* **卡上不摆口名与判据表达式**(2026-08-02 用户指出:卡片上还有
|
|
* 「凭 quote_intake_record_type_tag_c > 0」)。
|
|
*
|
|
* `to-alternative` 与那一串谓词都是给排障看的内部名;签字的人要知道的是
|
|
* **「这一单转到哪儿去、凭什么」**,而「凭什么」的人话答案是那一口自己的说明
|
|
* (「有 C 类要替代 —— 去选型」),不是它的表达式。
|
|
* 报告那一侧已经这么改了,卡这一侧漏了 —— 同一件事两处呈现,口径得一致。
|
|
*/
|
|
<div className="jc-ports routed">
|
|
<p className="jc-ports-t">
|
|
这一单的去向 <em>已按料号分类算出 —— 不用选,改分类它就跟着变</em>
|
|
</p>
|
|
<div className="jc-port on">
|
|
<span className="jc-port-dot" />
|
|
<b>{order.routedPort.label ?? order.routedPort.name}</b>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{choice !== undefined && (
|
|
<div className="jc-ports">
|
|
<p className="jc-ports-t">
|
|
{choice.group} <em>放行前必选 —— 选的这一口就是这单的去向</em>
|
|
</p>
|
|
{choice.ports.map((p2) => (
|
|
<button
|
|
key={p2.name}
|
|
className={'jc-port' + (port === p2.name ? ' on' : '')}
|
|
onClick={() => setPort(p2.name)}
|
|
>
|
|
<span className="jc-port-dot" />
|
|
<b>{p2.label ?? p2.name}</b>
|
|
<em className="jc-mono">{p2.name}</em>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<textarea
|
|
className="jc-note"
|
|
placeholder="要重做?写明原因(也可以在下面对话里聊)"
|
|
value={why}
|
|
onChange={(e) => setWhy(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="jc-fc-ft">
|
|
<Btn
|
|
className="jc-btn jc-btn-bad"
|
|
disabled={why.trim() === ''}
|
|
busy={busy}
|
|
onClick={() =>
|
|
onReplyOrder({
|
|
mode: 'acceptance',
|
|
workOrderId: order.id,
|
|
verdict: 'redo',
|
|
// 驳回也把改过的值带上:人常常一边改一边发现问题,改的痕不该丢
|
|
values: { ...(orderValues ?? {}) },
|
|
comment: why.trim(),
|
|
selectedPorts: {},
|
|
})
|
|
}
|
|
>
|
|
重做
|
|
</Btn>
|
|
<span className="jc-fc-sp" />
|
|
<Btn
|
|
className="jc-btn jc-btn-ok"
|
|
disabled={needChoice && port === undefined}
|
|
busy={busy}
|
|
title={needChoice && port === undefined ? `先在上面选一个「${choice.group}」` : undefined}
|
|
onClick={() =>
|
|
onReplyOrder({
|
|
mode: 'acceptance',
|
|
workOrderId: order.id,
|
|
verdict: 'pass',
|
|
// 人在报告上改过的值随放行落地;一个字没改就是空的(= 全盘采信 AI 拟值)
|
|
values: { ...(orderValues ?? {}) },
|
|
/*
|
|
* 恰选一口 —— **算出来的那一口也要交回去**。
|
|
*
|
|
* 两条来路:能算就算(`routedPort`,基座按出口谓词求值)、算不出才让人选
|
|
* (`portChoice`)。从前这里只认后者:去向算出来的那一路 `selectedPorts`
|
|
* 恒空,人按下「确认通过」→ 放行落地当场判「选择组未选出口」→ 整单 blocked
|
|
* (实测:台账都合并进正式表了,单子却死在最后一步)。
|
|
* 试跑脚本一直自己带着 port,所以这条只在真人点的那条路上现形。
|
|
*
|
|
* 人按下的是「确认」:确认的正是基座算出来的那一口。
|
|
*/
|
|
selectedPorts:
|
|
order.routedPort !== undefined
|
|
? { [order.routedPort.group]: order.routedPort.name }
|
|
: choice === undefined || port === undefined
|
|
? {}
|
|
: { [choice.group]: port },
|
|
})
|
|
}
|
|
>
|
|
<Ico name="circle-check" s={14} /> {order.passLabel ?? '确认通过'}
|
|
</Btn>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* 决策的收尾项:自定义口径 */
|
|
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">
|
|
<span className="jc-branch-n jc-mono">{n}</span>
|
|
<span className="jc-branch-bd">
|
|
<b>都不合适,我来定</b>
|
|
<span className="jc-custom-row">
|
|
<input
|
|
value={txt}
|
|
placeholder="写下你的口径…"
|
|
onChange={(e) => setTxt(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && txt.trim() !== '') onPick(txt.trim());
|
|
}}
|
|
/>
|
|
<Btn className="jc-btn" disabled={txt.trim() === ''} busy={busy} onClick={() => onPick(txt.trim())}>
|
|
就按这个
|
|
</Btn>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* 补全卡的小表单:就地作答;「没法填」是并列的第二条路(两条必走其一) */
|
|
function FillForm({
|
|
fields,
|
|
onDone,
|
|
onCant,
|
|
}: {
|
|
fields: ReadonlyArray<{ key: string; label: string; placeholder?: string | undefined; value?: string | undefined }>;
|
|
onDone: (values: Record<string, string>) => void;
|
|
onCant: (reason: string) => void;
|
|
}) {
|
|
/*
|
|
* **以预填值开局** —— AI 从来件里读得出的(公司名、联系人姓名)已经填在格子里,
|
|
* 人核对一眼、补上它填不出的那个即可。
|
|
* 用惰性初值而不是 `useEffect` 回填:回填会在人已经开始打字之后把他敲的字冲掉。
|
|
*/
|
|
const busy = useReplyBusy();
|
|
const [vals, setVals] = useState<Record<string, string>>(() =>
|
|
Object.fromEntries(fields.filter((f) => (f.value ?? '') !== '').map((f) => [f.key, f.value ?? ''])),
|
|
);
|
|
const [cant, setCant] = useState(false);
|
|
const [reason, setReason] = useState('');
|
|
const ok = fields.every((f) => (vals[f.key] ?? '').trim() !== '');
|
|
|
|
if (cant) {
|
|
return (
|
|
<div className="jc-cfill">
|
|
<label className="jc-cfill-f">
|
|
<span>没法填的原因(AI 会据此调整需求)</span>
|
|
<textarea
|
|
className="jc-note"
|
|
rows={2}
|
|
value={reason}
|
|
autoFocus
|
|
placeholder="如:没和客户谈过价位 / 这个数据在别人手里…"
|
|
onChange={(e) => setReason(e.target.value)}
|
|
/>
|
|
</label>
|
|
<div className="jc-cfill-acts">
|
|
<button type="button" className="jc-cant-link" onClick={() => setCant(false)}>
|
|
← 还是填吧
|
|
</button>
|
|
<Btn className="jc-btn" disabled={reason.trim() === ''} busy={busy} onClick={() => onCant(reason.trim())}>
|
|
<Ico name="send" s={13} /> 让 AI 调整需求
|
|
</Btn>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="jc-cfill">
|
|
{fields.map((f) => (
|
|
<label key={f.key} className="jc-cfill-f">
|
|
<span>{f.label}</span>
|
|
<input
|
|
value={vals[f.key] ?? ''}
|
|
placeholder={f.placeholder}
|
|
onChange={(e) => setVals({ ...vals, [f.key]: e.target.value })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && ok) onDone(vals);
|
|
}}
|
|
/>
|
|
</label>
|
|
))}
|
|
<div className="jc-cfill-acts">
|
|
<button type="button" className="jc-cant-link" onClick={() => setCant(true)}>
|
|
没法填?告诉 AI 原因
|
|
</button>
|
|
<Btn className="jc-btn" disabled={!ok} busy={busy} onClick={() => onDone(vals)}>
|
|
<Ico name="check" s={13} /> 补给 AI
|
|
</Btn>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* 评价卡的打分器:星级 + 改进建议(建议进活动记忆) */
|
|
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('');
|
|
const TXT = ['', '很不顺畅,返工严重', '不顺畅,返工明显', '一般,多处需人工介入', '顺畅,个别节点需修正', '非常顺畅,全程无需返工'];
|
|
const cur = hover !== 0 ? hover : score;
|
|
return (
|
|
<div className="jc-cfill">
|
|
<div className="jc-stars" role="radiogroup" aria-label="整体评分">
|
|
{[1, 2, 3, 4, 5].map((n) => (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
className={'jc-star' + (n <= cur ? ' on' : '')}
|
|
onMouseEnter={() => setHover(n)}
|
|
onMouseLeave={() => setHover(0)}
|
|
onClick={() => setScore(n)}
|
|
aria-label={`${n} 分`}
|
|
>
|
|
<Ico name="star" s={20} />
|
|
</button>
|
|
))}
|
|
<em className="jc-stars-txt">{cur !== 0 ? `${cur} 分 · ${TXT[cur]}` : '点星打分'}</em>
|
|
</div>
|
|
<label className="jc-cfill-f">
|
|
<span>改进建议(会进入活动记忆,用于优化下一单)</span>
|
|
<textarea
|
|
className="jc-note"
|
|
rows={2}
|
|
value={note}
|
|
placeholder="如:报价台账自动关联客户项目号;英文报价邮件模板固化为标准件"
|
|
onChange={(e) => setNote(e.target.value)}
|
|
/>
|
|
</label>
|
|
<div className="jc-cfill-acts">
|
|
<Btn className="jc-btn" disabled={score === 0} busy={busy} onClick={() => onDone(score, note.trim())}>
|
|
<Ico name="check" s={13} /> 提交评价
|
|
</Btn>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 数据录入卡的表单 —— AI 预填、人可改、必填空着不让确认。
|
|
*
|
|
* 与补全卡的 `FillForm` 分开写而不是加个开关:两者的**语义**不同 ——
|
|
* 那边按「确认」是交答案,这边按下去是**往台账写一行**。
|
|
* 按钮上的字、落表的提示、必填的拦法都得说这件事,合成一个组件只会让两边都含糊。
|
|
*/
|
|
function EntryCard({
|
|
card,
|
|
onDone,
|
|
}: {
|
|
card: Extract<ChatMessage, { k: 'entry' }>;
|
|
onDone: (values: Record<string, string>) => void;
|
|
}) {
|
|
/*
|
|
* **一张卡可以落好几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。
|
|
* 建一位新客户落的是两条主数据:公司档 + 联系人档。从前卡上只有公司那一半,
|
|
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
|
|
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉。
|
|
*/
|
|
const busy = useReplyBusy();
|
|
const groups = [
|
|
{
|
|
title: '',
|
|
ledger: card.ledger,
|
|
...(card.ledgerLabel === undefined ? {} : { ledgerLabel: card.ledgerLabel }),
|
|
key: card.key,
|
|
...(card.keyLabels === undefined ? {} : { keyLabels: card.keyLabels }),
|
|
fields: card.fields,
|
|
...(card.autoNote === undefined ? {} : { autoNote: card.autoNote }),
|
|
},
|
|
...(card.also ?? []),
|
|
];
|
|
const at = (i: number, key: string) => `${i}.${key}`;
|
|
/* 以 AI 预填值开局 —— 惰性初值,不用 effect 回填(那会冲掉人已经敲的字) */
|
|
const [vals, setVals] = useState<Record<string, string>>(() =>
|
|
Object.fromEntries(groups.flatMap((g, i) => g.fields.map((f) => [at(i, f.key), f.value ?? '']))),
|
|
);
|
|
const done = card.done !== undefined;
|
|
const shown = (i: number, key: string) =>
|
|
done ? (card.done?.values[at(i, key)] ?? card.done?.values[key] ?? '') : (vals[at(i, key)] ?? '');
|
|
const blank = groups.flatMap((g, i) => g.fields.filter((f) => f.required && shown(i, f.key).trim() === ''));
|
|
|
|
/*
|
|
* **落表那一句合成一条**(2026-08-02 用户:「多个合并成一个:已写入 xxx、xxx」)。
|
|
*
|
|
* 从前每一段各摆一条「确认后写入 · 客户档案」——两段就是两条横幅,说的是同一件事
|
|
* (这一下确认会落库),只是落的表不同。人要知道的是「会写到哪几张表」,
|
|
* 那是一句话:**确认后写入 客户档案、客户联系人**。
|
|
*/
|
|
const tables = groups.map((g) => g.ledgerLabel ?? g.ledger).join('、');
|
|
/*
|
|
* **主键行只摆整张卡都填不到的那几格**(2026-08-02)。
|
|
*
|
|
* 从前按段算:联系人那段的表单里没有客户编码,于是它被当成「上游定死的」摆了出来 ——
|
|
* 可人正要在**上一段**亲手填它。同一个值在一张卡上出现两次,后一次还是旁白。
|
|
* 现在跨段看:任何一段的表单里有,就不摆;一格不剩就整行不出。
|
|
*/
|
|
const onAnyForm = new Set(groups.flatMap((g) => g.fields.map((f) => f.key)));
|
|
const fixed = groups
|
|
.flatMap((g, i) =>
|
|
Object.entries(g.key)
|
|
.filter(([k, v]) => !onAnyForm.has(k) && (shown(i, k) || v) !== '')
|
|
.map(([k, v]) => `${g.keyLabels?.[k] ?? k} ${shown(i, k) || v}`),
|
|
)
|
|
.filter((x, i, all) => all.indexOf(x) === i);
|
|
|
|
return (
|
|
<>
|
|
{/* 落表提示用公用的 `jc-fc-note` —— 它自带本卡色调,不必自造一条横幅 */}
|
|
<p className="jc-fc-note">
|
|
<Ico name="database" s={13} />
|
|
<span>
|
|
<b>{done ? '已写入' : '确认后写入'}</b>
|
|
{/* 摆的是登记的人读名(「客户档案」);没登记人读名才退回地址 —— 界面不摆英文表名 */}
|
|
<span className="jc-entry-tbl">{tables}</span>
|
|
{fixed.length > 0 && <em className="jc-entry-key">{fixed.join(' · ')}</em>}
|
|
</span>
|
|
</p>
|
|
{groups.map((g, i) => {
|
|
return (
|
|
<div key={i}>
|
|
{/* 两段以上才需要小标题 —— 一段的卡上摆「客户档案」是把上面那句又说了一遍 */}
|
|
{g.title !== '' && <p className="jc-entry-sec">{g.title}</p>}
|
|
{g.autoNote !== undefined && (
|
|
/* 自增的那几列压根不在表单里 —— 但不能一声不吭地拿掉,人会以为这张档少了一格 */
|
|
<p className="jc-entry-auto">
|
|
<Ico name="circle-dot" s={12} />
|
|
{g.autoNote}
|
|
</p>
|
|
)}
|
|
<div className="jc-cfill">
|
|
{g.fields.map((f) => {
|
|
const v = shown(i, f.key);
|
|
const todo = !done && v.trim() === '';
|
|
return (
|
|
<label key={f.key} className={'jc-cfill-f' + (todo ? ' todo' : '')}>
|
|
<span>
|
|
{f.label}
|
|
{f.required && <i className="req">*</i>}
|
|
{/* 空着的明标「待填写」—— AI 填不出来的那几格,人一眼看得见 */}
|
|
{todo && <em className="jc-entry-todo">待填写</em>}
|
|
</span>
|
|
<input
|
|
value={v}
|
|
disabled={done}
|
|
placeholder={f.placeholder ?? (f.required ? '必填' : '可留空')}
|
|
onChange={(e) => setVals({ ...vals, [at(i, f.key)]: e.target.value })}
|
|
/>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{!done && (
|
|
<div className="jc-cfill-acts">
|
|
{blank.length > 0 && <span className="jc-entry-warn">还差:{blank.map((f) => f.label).join('、')}</span>}
|
|
<Btn className="jc-btn" disabled={blank.length > 0} busy={busy} onClick={() => onDone(vals)}>
|
|
<Ico name="database" s={13} /> 确认写入
|
|
</Btn>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|