Archived
人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的: 件上就地改 · 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名, 于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 —— 而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。 · **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。 删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)—— 「没回传就是删了」会把一次不完整的回传变成一次删表。 · **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。 · 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。 判据与门 · 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来, 人填好的目标价它照样判「客户未提供」的毛病由此消掉。 · 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。 · 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开, 于是一单没打标就进了放行门。三个数全由基座现数。 · 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。 **指向两扇打不开的门,比不指还糟。** 界面不撒谎 · 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。 · 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、 往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。 · 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。 · 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。 · 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。 A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。 五道门禁全绿(971 passed)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 推送 → 失效映射 —— **这段错了不报错,只是画面比事实旧**。
|
||||
*
|
||||
* 它整段失效过一次:键前缀写成 `['tasks']`,而工作台的键是 `['desk','tasks']` ——
|
||||
* 推送照发、一条也匹配不到,任务办完了列表不挪窝。这类错编译器与类型都拦不住,
|
||||
* 只能靠断言「推送到了,哪几把查询会重取」。
|
||||
*
|
||||
* 测法:把每一屏真实在用的键都塞进 QueryClient,发一次 touch,看谁被标脏。
|
||||
* 新增一屏就往 SCREENS 里加一行 —— 这张表本身就是「哪一屏靠什么刷新」的账。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { invalidateFor } from '../subscribe.ts';
|
||||
import type { Touch } from '../subscribe.ts';
|
||||
|
||||
/** 各屏真实在用的键(与代码里的 queryKey 逐条对齐) */
|
||||
const SCREENS: ReadonlyArray<readonly [string, readonly unknown[]]> = [
|
||||
['工作台 · 任务列表', ['desk', 'tasks']],
|
||||
['工作台 · 任务详情', ['desk', 'task', 'T1']],
|
||||
['工作台 · 消息', ['desk', 'messages']],
|
||||
['工作台 · 审批', ['desk', 'approvals']],
|
||||
['运营台 · 任务', ['gov', 'tasks']],
|
||||
['运营台 · 任务日志', ['gov', 'tasklog', 'T1']],
|
||||
['运营台 · 工作流', ['gov', 'workflow', 'W1']],
|
||||
['运营台 · 审批', ['gov', 'approvals']],
|
||||
['运营台 · 驾驶舱', ['gov', 'metrics']],
|
||||
['设计台 · 活动库', ['gov', 'activities']],
|
||||
['设计台 · 工具箱', ['gov', 'tools']],
|
||||
['设计台 · 大模型', ['gov', 'llm']],
|
||||
['设计台 · 流程包', ['gov', 'process']],
|
||||
['设计台 · 数据域', ['gov', 'ledger', 'quote_intake', 'record']],
|
||||
['测试台 · 当前在跑', ['gov', 'testbed', 'current']],
|
||||
['测试台 · 单活动观测', ['gov', 'testbed', 'activity', 'T1']],
|
||||
['测试台 · 流程包观测', ['gov', 'testbed', 'workflow', 'W1']],
|
||||
['测试台 · trace', ['gov', 'testbed', 'trace', 'W1']],
|
||||
['员工', ['gov', 'staff']],
|
||||
];
|
||||
|
||||
/** 发一次推送,回报哪几屏被标脏 */
|
||||
function dirtiedBy(touch: Touch): string[] {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
for (const [, key] of SCREENS) qc.setQueryData(key, { seeded: true });
|
||||
invalidateFor(qc, touch);
|
||||
return SCREENS.filter(([, key]) => qc.getQueryState(key)?.isInvalidated === true).map(([name]) => name);
|
||||
}
|
||||
|
||||
describe('task touch —— 一单动了', () => {
|
||||
const dirty = dirtiedBy({ kind: 'task', id: 'T1', reason: 'advanced' });
|
||||
|
||||
it('工作台该动的都动:列表 / 这一单 / 审批', () => {
|
||||
expect(dirty).toEqual(expect.arrayContaining(['工作台 · 任务列表', '工作台 · 任务详情', '工作台 · 审批']));
|
||||
});
|
||||
|
||||
it('运营台的任务面与日志跟着动', () => {
|
||||
expect(dirty).toEqual(expect.arrayContaining(['运营台 · 任务', '运营台 · 任务日志']));
|
||||
});
|
||||
|
||||
/*
|
||||
* 测试台的键自成一支(`gov.testbed.*`),不在上面几条的前缀里。
|
||||
* 漏了它 = 测试台只剩 2 秒轮询,而轮询到终态就停:一单跑完那一屏就此定格。
|
||||
*/
|
||||
it('**测试台跟着动** —— 它不在任何一条任务键的前缀里', () => {
|
||||
expect(dirty).toEqual(expect.arrayContaining(['测试台 · 当前在跑', '测试台 · 单活动观测']));
|
||||
});
|
||||
|
||||
it('不误伤配置面 —— 一单推进不该让活动库/工具箱整屏重取', () => {
|
||||
expect(dirty).not.toContain('设计台 · 活动库');
|
||||
expect(dirty).not.toContain('设计台 · 工具箱');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow touch —— 一条流动了', () => {
|
||||
const dirty = dirtiedBy({ kind: 'workflow', id: 'W1', reason: 'task-advanced' });
|
||||
|
||||
it('流自己、名下任务与审批、测试台一起动', () => {
|
||||
expect(dirty).toEqual(
|
||||
expect.arrayContaining([
|
||||
'运营台 · 工作流',
|
||||
'工作台 · 任务列表',
|
||||
'工作台 · 审批',
|
||||
'运营台 · 审批',
|
||||
'测试台 · 当前在跑',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('system touch —— 全局变了(配置改了 / 试跑单被销毁 / 台账落了新事实)', () => {
|
||||
const dirty = dirtiedBy({ kind: 'system', id: 'gov', reason: 'POST /api/gov/activities' });
|
||||
|
||||
it('治理两台全屏失效:配置面、数据域、驾驶舱', () => {
|
||||
expect(dirty).toEqual(
|
||||
expect.arrayContaining([
|
||||
'设计台 · 活动库',
|
||||
'设计台 · 工具箱',
|
||||
'设计台 · 大模型',
|
||||
'设计台 · 流程包',
|
||||
'设计台 · 数据域',
|
||||
'运营台 · 驾驶舱',
|
||||
'测试台 · 当前在跑',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
* 销毁试跑单拔掉的是**真任务** —— 它这一刻还挂在负责人的工作台列表上。
|
||||
* 不连 desk 一起失效,那儿会留着一张点进去就 404 的鬼卡片。
|
||||
*/
|
||||
it('**工作台也要失效** —— 被销毁的试跑单还挂在人家列表上', () => {
|
||||
expect(dirty).toEqual(expect.arrayContaining(['工作台 · 任务列表', '工作台 · 任务详情']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('staff touch —— 某个人的东西变了', () => {
|
||||
const dirty = dirtiedBy({ kind: 'staff', id: 'S1', reason: 'elicitation' });
|
||||
|
||||
it('只动消息气泡与员工面,不惊动任务面', () => {
|
||||
expect(dirty).toEqual(expect.arrayContaining(['工作台 · 消息', '员工']));
|
||||
expect(dirty).not.toContain('工作台 · 任务列表');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Session, TaskCard, TaskDetail, WorkOrderReply } from '@contracts';
|
||||
import {
|
||||
@@ -45,6 +45,22 @@ export function AppShell() {
|
||||
const [module, setModule] = useState<ModuleKey>('tasks');
|
||||
const [taskId, setTaskId] = useState('');
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
/*
|
||||
* 工单填报值 —— **壳层持有**,工作区与对话区共用同一份。
|
||||
*
|
||||
* 验收工单是「AI 已填好、可编辑」,而**裁决在对话区的验收单上**(PRD 口径):
|
||||
* 编辑与裁决是同一件事的两半,却分在两个区里做。缓冲只活在工作区里,
|
||||
* 人在报告上改的值就到不了裁决那一刻 —— 验收回传的 `values` 恒空,修订永远落不下来。
|
||||
*
|
||||
* 换任务即清空:填报值是**过程状态**,不跨单。
|
||||
*/
|
||||
const [orderValues, setOrderValues] = useState<Record<string, string>>({});
|
||||
/*
|
||||
* 对话区点「打开工单」→ 视图区跳转定位(PRD:locate)。
|
||||
* `seq` 每点递增:同一张卡点两次也要再跳一次 —— 人就是想回到它。
|
||||
*/
|
||||
const [locate, setLocate] = useState<{ seq: number; orderId?: string; tab?: 'comms' } | null>(null);
|
||||
const locateSeq = useRef(0);
|
||||
const split = useChatWidth();
|
||||
|
||||
|
||||
@@ -196,7 +212,12 @@ export function AppShell() {
|
||||
tasks={list}
|
||||
selectedId={current}
|
||||
collapsed={collapsed}
|
||||
onPick={setTaskId}
|
||||
onPick={(id) => {
|
||||
// 换任务即清空填报缓冲与定位:它们是**过程状态**,不跨单
|
||||
setOrderValues({});
|
||||
setLocate(null);
|
||||
setTaskId(id);
|
||||
}}
|
||||
onCollapse={() => setCollapsed(true)}
|
||||
/>
|
||||
{/*
|
||||
@@ -208,9 +229,20 @@ export function AppShell() {
|
||||
key={current}
|
||||
detail={detail.data}
|
||||
onReplyOrder={(reply) => replyOrder.mutate(reply)}
|
||||
values={orderValues}
|
||||
onField={(k, v) => setOrderValues((old) => ({ ...old, [k]: v }))}
|
||||
locate={locate}
|
||||
/>
|
||||
) : (
|
||||
<main className="execution-panel">
|
||||
/*
|
||||
* **手上没任务时,中栏也得是一张卡**。
|
||||
*
|
||||
* 其余四个模块(审批 / 消息 / 纪要 / 汇报)的空态都挂 `jc-panel-card`,
|
||||
* 只有任务这一栏是裸的 `execution-panel` —— 而那个类在工作区里被显式
|
||||
* 去边框去底色(视图区不再有外层卡)。于是同一套工作台上,四个模块的空态
|
||||
* 是白卡片,任务这一栏却是画布上飘着的一段文字,看着像没渲染出来。
|
||||
*/
|
||||
<main className="execution-panel jc-panel-card">
|
||||
{tasks.isLoading ? (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="list-checks" s={18} />
|
||||
@@ -235,7 +267,20 @@ export function AppShell() {
|
||||
/>
|
||||
<section className="session-chat">
|
||||
{detail.data !== undefined ? (
|
||||
<TaskChat key={current} detail={detail.data} onLocate={() => undefined} />
|
||||
<TaskChat
|
||||
key={current}
|
||||
detail={detail.data}
|
||||
onLocate={(orderId) => {
|
||||
locateSeq.current += 1;
|
||||
setLocate({ seq: locateSeq.current, orderId });
|
||||
}}
|
||||
onOpenComms={() => {
|
||||
locateSeq.current += 1;
|
||||
setLocate({ seq: locateSeq.current, tab: 'comms' });
|
||||
}}
|
||||
orderValues={orderValues}
|
||||
onField={(k, v) => setOrderValues((old) => ({ ...old, [k]: v }))}
|
||||
/>
|
||||
) : (
|
||||
<div className="jc-tch">
|
||||
<header className="jc-tch-head">
|
||||
|
||||
+292
-10
@@ -116,7 +116,7 @@
|
||||
/* ===== 三栏衔接:列表区 / 视图区 / 对话区统一为三张等距同规格卡片 =====
|
||||
覆盖 vendor 的「左中融合 + 右侧浮卡」旧结构:等间距 gutter、同圆角、同描边、同投影 */
|
||||
/* 对话区宽度 = var(--jc-chat-w)(用户拖拽分隔柄写入 workspace inline style),未拖时走各断点默认 */
|
||||
.proto-root .workspace { position: relative; gap: 12px; padding: 12px 14px; --jc-ws-pr: 14px; --jcai-ai-chat-width: var(--jc-chat-w, 440px); grid-template-columns: 310px minmax(var(--jc-work-min), 1fr) minmax(0, var(--jcai-ai-chat-width)); }
|
||||
.proto-root .workspace { position: relative; gap: 12px; padding: 12px 14px; --jc-ws-pr: 14px; --jcai-ai-chat-width: var(--jc-chat-w, 440px); grid-template-columns: minmax(var(--jc-side-min), 310px) minmax(var(--jc-work-min), 1fr) minmax(var(--jc-chat-min), var(--jcai-ai-chat-width)); }
|
||||
/* 收起态 grid:无媒体查询、全断点生效,覆盖 vendor 各断点的三栏定义(修列错位 bug)。
|
||||
清单收起→[视图区 1fr | 会话区];会话收起→[清单 | 视图区 1fr];都收起→[视图区 1fr]。 */
|
||||
.proto-root .workspace.jc-side-collapsed:not(.jc-chat-collapsed) { grid-template-columns: minmax(0, 1fr) var(--jcai-ai-chat-width, 440px); }
|
||||
@@ -146,10 +146,25 @@
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
/*
|
||||
空态要在这张卡里**上下居中**,不是贴着顶。
|
||||
|
||||
`.jc-empty` 早就写了 `margin: auto`,可它一直只把自己横向居中 ——
|
||||
`margin-top/bottom: auto` 只有在 flex/grid 容器里才吃得开,在块级容器里算 0。
|
||||
而这张卡(`.jc-panel-card`)恰恰没设 display:flex(设了 flex 的是它的反面
|
||||
`:not(.jc-panel-card)`),于是五个模块的空态全贴在卡的顶边上。
|
||||
|
||||
只挑「直接儿子是空态」的那些卡改 —— `DocPanel`(纪要正文)用的是同一个类名,
|
||||
它里头是一整块要滚的文档,居中了就成了上下留白、内容夹在中间。
|
||||
`:has()` 在这儿正好:按内容挑,不必给六处组件各加一个修饰类。 */
|
||||
.proto-root .workspace .execution-panel.jc-panel-card:has(> .jc-empty) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.proto-root .session-chat { border-radius: 12px; }
|
||||
/* 窄屏(≤1380 视口):收窄三栏配比,保证对话区不被裁切(拖拽值 --jc-chat-w 仍优先) */
|
||||
@media (max-width: 1380px) {
|
||||
.proto-root .workspace { --jc-ws-pr: 12px; --jcai-ai-chat-width: var(--jc-chat-w, clamp(430px, 34vw, 560px)); grid-template-columns: 256px minmax(var(--jc-work-min), 1fr) minmax(0, var(--jcai-ai-chat-width)); gap: 10px; padding: 10px 12px; }
|
||||
.proto-root .workspace { --jc-ws-pr: 12px; --jcai-ai-chat-width: var(--jc-chat-w, clamp(430px, 34vw, 560px)); grid-template-columns: minmax(var(--jc-side-min), 256px) minmax(var(--jc-work-min), 1fr) minmax(var(--jc-chat-min), var(--jcai-ai-chat-width)); gap: 10px; padding: 10px 12px; }
|
||||
}
|
||||
|
||||
/* 对话区/视图区可调比例:workspace 直属分隔柄,锚在「视图区↔对话区」的 12px gutter 上
|
||||
@@ -1638,6 +1653,12 @@
|
||||
.proto-root .jc-tbl th { background: var(--soft); text-align: left; padding: 8px 10px; font-weight: 600; color: var(--ink-2); white-space: nowrap; }
|
||||
.proto-root .jc-tbl td { padding: 7px 10px; border-top: 1px solid var(--line-soft); color: var(--ink); }
|
||||
.proto-root .jc-tbl input { width: 100%; border: 1px solid var(--brand); border-radius: 5px; padding: 3px 6px; font: inherit; font-size: 12px; }
|
||||
/* 空格子喊「待填写」—— 红边红占位字,填上即恢复常态(一片文字里的两个空格,不喊没人看得见) */
|
||||
.proto-root .jc-tbl input.jc-tbl-todo { border-color: var(--danger); background: var(--danger-soft); }
|
||||
.proto-root .jc-tbl input.jc-tbl-todo::placeholder { color: var(--danger); font-weight: 700; opacity: 1; }
|
||||
.proto-root .jc-tbl input.jc-tbl-todo:focus { outline: 2px solid var(--danger); outline-offset: -1px; }
|
||||
/* 只读态的空格子:如实说没填,不留一片让人以为「本来就没这项」的空白 */
|
||||
.proto-root .jc-tbl .jc-tbl-empty { font-style: normal; font-size: 11px; color: var(--ink-4); }
|
||||
.proto-root .jc-tbl-bar { display: flex; gap: 7px; align-items: center; justify-content: flex-end; }
|
||||
|
||||
.proto-root .jc-bars { display: flex; flex-direction: column; gap: 7px; }
|
||||
@@ -2115,6 +2136,20 @@
|
||||
.proto-root .jc-dv-h { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.proto-root .jc-dv-ico { flex: none; width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; color: var(--ink-4); background: var(--soft); }
|
||||
.proto-root .jc-dv.s-ok .jc-dv-ico { color: var(--ok); background: var(--ok-soft); }
|
||||
/*
|
||||
* **执行与验收两种颜色**(2026-08-02 用户定):一摞成果里「我办过的那张单」与
|
||||
* 「我签过字的那一关」是两类东西 —— 同色排下来,人得逐行读标题才分得出。
|
||||
* 执行走品牌蓝(在办的调子),验收走绿(过了关的调子);退回压过两者走警示色。
|
||||
*/
|
||||
.proto-root .jc-dv.m-exe .jc-dv-ico { color: var(--brand); background: var(--brand-wash); }
|
||||
.proto-root .jc-dv.m-exe .jc-dv-st { color: var(--brand-deep); background: var(--brand-wash); }
|
||||
.proto-root .jc-dv.m-exe { border-left: 3px solid var(--brand-soft); }
|
||||
.proto-root .jc-dv.m-acc .jc-dv-ico { color: var(--ok); background: var(--ok-soft); }
|
||||
.proto-root .jc-dv.m-acc .jc-dv-st { color: var(--ok); background: var(--ok-soft); }
|
||||
.proto-root .jc-dv.m-acc { border-left: 3px solid var(--ok); }
|
||||
.proto-root .jc-dv.s-bad.m-exe, .proto-root .jc-dv.s-bad.m-acc { border-left-color: var(--danger); }
|
||||
.proto-root .jc-dv.s-bad.m-exe .jc-dv-ico, .proto-root .jc-dv.s-bad.m-acc .jc-dv-ico { color: var(--danger); background: var(--danger-soft); }
|
||||
.proto-root .jc-dv.s-bad.m-exe .jc-dv-st, .proto-root .jc-dv.s-bad.m-acc .jc-dv-st { color: var(--danger); background: var(--danger-soft); }
|
||||
.proto-root .jc-dv.s-live .jc-dv-ico { color: #fff; background: var(--ai-grad); }
|
||||
.proto-root .jc-dv-t { flex: 1; min-width: 0; }
|
||||
.proto-root .jc-dv-t em { display: block; font-style: normal; font-size: 10px; font-weight: 700; letter-spacing: .04em; color: var(--ink-4); font-family: var(--mono); }
|
||||
@@ -2267,6 +2302,15 @@
|
||||
/* 卡体与动作栏 */
|
||||
.proto-root .jc-fc-b { padding: 11px 13px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.proto-root .jc-fc-ft { display: flex; align-items: center; gap: 8px; padding: 9px 13px; border-top: 1px solid var(--line-soft); background: #fbfcfe; }
|
||||
/*
|
||||
* **交不了的时候,说清差在哪** —— 只摆一个灰按钮等于让人猜自己哪儿做错了。
|
||||
* 底色用警示色而不是错误红:这不是出错,是活还没干完。
|
||||
*/
|
||||
.proto-root .jc-fc-gate { padding: 9px 13px; border-top: 1px solid #f2dfc2; background: #fffaf2; }
|
||||
.proto-root .jc-fc-gate p { display: flex; align-items: center; gap: 6px; margin: 0; color: #9a5b02; font-size: 12.5px; }
|
||||
.proto-root .jc-fc-gate p svg { flex: 0 0 auto; }
|
||||
.proto-root .jc-fc-gate ul { margin: 6px 0 0; padding: 0 0 0 19px; list-style: disc; color: var(--ink-2); font-size: 12px; line-height: 1.75; }
|
||||
.proto-root .jc-fc-gate li.more { list-style: none; margin-left: -19px; color: var(--ink-3); }
|
||||
.proto-root .jc-fc-sp { flex: 1; }
|
||||
.proto-root .jc-fc-open { width: 100%; justify-content: center; }
|
||||
/* 说明 / 建议 */
|
||||
@@ -2286,12 +2330,48 @@
|
||||
.proto-root .jc-fc-doc:hover { border-color: var(--fc); background: #fff; box-shadow: var(--shadow-1); }
|
||||
.proto-root .jc-fc-doc-ico { flex: none; width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; color: #fff; background: var(--fc); }
|
||||
.proto-root .jc-fc-doc-bd { flex: 1; min-width: 0; }
|
||||
.proto-root .jc-fc-doc-bd em { display: block; font-style: normal; font-size: 9.5px; font-weight: 800; letter-spacing: .05em; color: var(--ink-4); }
|
||||
.proto-root .jc-fc-doc-bd b { display: block; margin-top: 1px; font-size: 12.5px; font-weight: 700; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/*
|
||||
* **四字粗体在上、小字说明在下**(2026-08-02 用户:执行卡与验收卡「格式不统一」)。
|
||||
* 从前是反的(小字在上),而两张卡里只有一张这么排 —— 人扫过去落点对不齐。
|
||||
*/
|
||||
.proto-root .jc-fc-doc-bd b { display: block; font-size: 12.5px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-fc-doc-bd em { display: block; margin-top: 2px; font-style: normal; font-size: 10.5px; font-weight: 500; line-height: 1.5; color: var(--ink-3); }
|
||||
.proto-root .jc-fc-doc > svg { flex: none; color: var(--ink-4); transition: transform var(--t-fast); }
|
||||
.proto-root .jc-fc-doc:hover > svg { transform: translateX(2px); color: var(--fc); }
|
||||
/* 判据表(卡内紧凑版) */
|
||||
.proto-root .jc-fc .jc-rp-crit.sm li { padding: 6px 11px; font-size: 11.5px; }
|
||||
/*
|
||||
* **作业指导书与验收判据同一套版面**(2026-08-02 用户定)。
|
||||
* 从前指导书是一串松散的圆点(`.jc-steps`),判据是一摞带章的行 ——
|
||||
* 同一张卡上两套版面语言,人得在两种读法之间来回切。
|
||||
* 差别只留一处:指导书**没有达标/未达标** —— 它是要办的事,不是已办成的事;
|
||||
* 不给步骤画对勾:系统并不知道人做没做,画上去就是撒谎。
|
||||
*/
|
||||
.proto-root .jc-guide li { align-items: flex-start; }
|
||||
.proto-root .jc-guide .jc-lv { flex: none; margin-top: 1px; }
|
||||
.proto-root .jc-guide .jc-lv-step { background: var(--brand-wash); color: var(--brand); border: 1px solid var(--brand-soft); }
|
||||
.proto-root .jc-guide .jc-lv-done { display: inline-grid; place-items: center; background: #fff4e5; color: #de7802; border: 1px solid #f2dfc2; padding: 1px 4px; }
|
||||
.proto-root .jc-guide .t { line-height: 1.65; }
|
||||
/* 末行是这几步的收口 —— 底色分开,读到那儿就知道「做到这样算数」 */
|
||||
.proto-root .jc-guide li.done { background: #fffaf2; }
|
||||
.proto-root .jc-guide li.done .t b { display: block; margin-bottom: 1px; color: #9a5b02; font-weight: 800; }
|
||||
|
||||
/*
|
||||
* **办结之后那一屏:落地结果**(2026-08-02 用户定)。
|
||||
* 判据说的是「签之前要核什么」;签完之后人问的是「世界上发生了什么」。
|
||||
*/
|
||||
.proto-root .jc-landing { display: flex; flex-direction: column; gap: 8px; }
|
||||
.proto-root .jc-landing-h { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 12.5px; color: var(--ok); }
|
||||
.proto-root .jc-landing-h b { font-weight: 800; }
|
||||
.proto-root .jc-landing-h span { color: var(--ink-2); font-weight: 700; }
|
||||
.proto-root .jc-landing .jc-rp-crit .r { color: var(--ink-3); font-weight: 700; }
|
||||
.proto-root .jc-landing-p { display: flex; align-items: flex-start; gap: 6px; margin: 0; font-size: 11.5px; color: var(--ink-2); line-height: 1.6; }
|
||||
.proto-root .jc-landing-p svg { flex: 0 0 auto; margin-top: 2px; color: var(--brand); }
|
||||
.proto-root .jc-landing-p b { margin-right: 6px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-landing-p.warn { color: #9a5b02; }
|
||||
.proto-root .jc-landing-p.warn svg { color: #de7802; }
|
||||
/* 带过去了哪些字段 —— 摆名字不摆值:值在成果件里,这一行答的是「带了哪些东西」 */
|
||||
.proto-root .jc-landing-carry { display: block; margin-top: 3px; font-style: normal; font-size: 11px; color: var(--ink-3); line-height: 1.6; }
|
||||
|
||||
/* 决策选项(卡内) */
|
||||
.proto-root .jc-tch .jc-branches { margin: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
@@ -2685,7 +2765,15 @@
|
||||
.proto-root .jc-mdonut .jc-donut-lg span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ══ 空占位(待处理 / 已完成):层叠光环 + 图标 + 主副文 + 提示囊 ══ */
|
||||
.proto-root .jc-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 58px 24px 64px; text-align: center; animation: rise var(--t-med) both; }
|
||||
/*
|
||||
* 空态 —— **在它那一栏里居中**,不是吊在顶上。
|
||||
*
|
||||
* 从前是固定的上下内边距(58/64),于是在一栏一人高的工作区里,那幅图与那两行字
|
||||
* 挤在顶部三分之一处,底下留着一大片白。空态是常态不是缺页:它该像一张摆正的卡片,
|
||||
* 而不是页面还没加载完的残影。
|
||||
* `margin: auto` 让它在 flex 容器里上下左右都居中;容器不给高度时退回原来的内边距。
|
||||
*/
|
||||
.proto-root .jc-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; margin: auto; gap: 8px; padding: 40px 24px; text-align: center; animation: rise var(--t-med) both; }
|
||||
.proto-root .jc-empty-art { position: relative; width: 108px; height: 108px; margin-bottom: 6px; }
|
||||
.proto-root .jc-empty-art i { position: absolute; border-radius: 999px; }
|
||||
.proto-root .jc-empty-art .r1 { inset: 0; opacity: .55; }
|
||||
@@ -2864,12 +2952,29 @@
|
||||
*/
|
||||
.proto-root .workspace {
|
||||
--jc-work-min: 690px;
|
||||
/*
|
||||
* 对话区也要有地板(2026-07-31 补)。
|
||||
*
|
||||
* 上面那段只给了中栏地板,对话区写的是 `minmax(0, …)` —— 意思是「让到多少算多少」。
|
||||
* 三种 MacBook Air 都还剩 384px 以上,于是当时认为够。**1280 宽不在那张表里**:
|
||||
* 1280 − 内边距 28 − 间距 24 = 1228,减去 310(清单)+ 690(中栏)只剩 228 ——
|
||||
* 比它自己写的下限 340px 还窄。实测后果:决策卡的两个选项被压成一列竖排的单字,
|
||||
* 「我按缺口起草一封询问函」一个字一行,人根本没法读,更谈不上点。
|
||||
*
|
||||
* 三个都到地板要 180 + 690 + 340 = 1210 ≤ 1228,装得下 ——
|
||||
* 所以让**清单**先窄(它是一列任务卡,窄一点照样读),而不是压对话区。
|
||||
*/
|
||||
--jc-chat-min: 340px;
|
||||
--jc-side-min: 180px;
|
||||
}
|
||||
|
||||
/* 收起清单 / 收起对话时中栏本就宽,地板不必再管 —— 免得收起态反被地板顶出横向溢出 */
|
||||
.proto-root .workspace.jc-side-collapsed,
|
||||
.proto-root .workspace.jc-chat-collapsed {
|
||||
--jc-work-min: 0px;
|
||||
/* 收起态那一栏根本不在网格里,地板留着只会把剩下的栏顶出横向溢出 */
|
||||
--jc-chat-min: 0px;
|
||||
--jc-side-min: 0px;
|
||||
}
|
||||
|
||||
/* 列表区空态 —— 紧凑版:软色圆 + 主副文,与主面板的整屏光环同一语气 */
|
||||
@@ -2924,16 +3029,112 @@
|
||||
}
|
||||
|
||||
/* ── 工作日志(补充,非原型)—— 一行一步,标明谁的产出 ────────────────── */
|
||||
/* ── 沟通记录:一单往来的**原件** —— 一封一张纸,收/发两向分色 ── */
|
||||
/* 系统开场白上的那一下 —— 灰调不抢戏:它是个去处,不是要人拍板的动作 */
|
||||
.proto-root .jc-sys-act { display: inline-flex; align-items: center; gap: 5px; margin-top: 8px; padding: 4px 11px; border: 1px solid var(--line); border-radius: 999px; background: #fff; font: inherit; font-size: 11.5px; font-weight: 600; color: var(--ink-2); cursor: pointer; transition: border-color var(--t-fast), color var(--t-fast); }
|
||||
.proto-root .jc-sys-act:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.proto-root .jc-sys-act:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
|
||||
/* ── 数据录入卡(第五类人类工具,2026-07-31)──────────────────────────
|
||||
壳、头、表单、按钮**一律用卡族公用的那套**(`jc-fc` / `jc-fc-h` / `jc-cfill`)。
|
||||
上一版自造了一整套 `jc-entry-*` 骨架:圆角、描边、头部配色全跟兄弟卡对不上,
|
||||
连类型章(图标 + 类型名)都没有 —— 五张卡摆在一条对话流里,唯独它像外来的。
|
||||
卡族要长得像一家人,身份写在头上,不靠各造一套框来区分。
|
||||
|
||||
只留三样这张卡独有的语义:落表提示、必填星、「待填写」小签。 */
|
||||
.proto-root .jc-fc.t-entry { --fc: var(--ok); --fc-soft: var(--ok-soft); }
|
||||
/* 落到哪张表、哪一行 —— 按下确认写的是什么,得先说清楚 */
|
||||
/*
|
||||
* **卡面摆的是表的人读名,不是 `customer.profile`**(2026-08-02 用户定)——
|
||||
* 界面不摆系统内的英文名。所以这一格不再用等宽字体:它现在是一句人话。
|
||||
*/
|
||||
.proto-root .jc-entry-tbl { display: block; margin-top: 1px; font-size: 11.5px; font-weight: 700; color: var(--ink-2); }
|
||||
.proto-root .jc-entry-key { display: block; margin-top: 2px; font-style: normal; font-size: 10.5px; color: var(--ink-4); }
|
||||
/* 自增的那几列不摆进表单,但要说一声 —— 一声不吭地拿掉,人会以为这张档少了一格 */
|
||||
.proto-root .jc-entry-auto { display: flex; align-items: center; gap: 5px; margin: 6px 0 0; font-size: 11px; color: var(--ink-3); }
|
||||
.proto-root .jc-entry-auto svg { flex: 0 0 auto; color: var(--brand); }
|
||||
/* 两段以上才出的小标题 —— 一段的卡上摆它,是把上面那句又说了一遍 */
|
||||
.proto-root .jc-entry-sec { margin: 10px 0 0; font-size: 11px; font-weight: 800; letter-spacing: .04em; color: var(--ink-3); }
|
||||
.proto-root .jc-fc-b > .jc-cfill { margin-top: 0; }
|
||||
.proto-root .jc-cfill-f .req { font-style: normal; color: var(--danger); margin-left: 2px; }
|
||||
/* 待填写用**警示色的小签**,不染整格:染整格会把已填好的那几格也搅乱 */
|
||||
.proto-root .jc-entry-todo { font-style: normal; margin-left: 6px; padding: 0 6px; border-radius: 999px; background: var(--danger); color: #fff; font-size: 10px; font-weight: 700; }
|
||||
.proto-root .jc-cfill-f.todo input { border-color: var(--danger-soft, #f6c6c6); }
|
||||
.proto-root .jc-entry-warn { margin-right: auto; font-size: 11px; color: var(--danger); }
|
||||
.proto-root .jc-comms { display: flex; flex-direction: column; gap: 10px; padding: 2px 0; }
|
||||
.proto-root .jc-comm { border: 1px solid var(--line-soft); border-radius: 10px; background: #fff; overflow: hidden; }
|
||||
.proto-root .jc-comm.t-in { border-left: 3px solid var(--brand); }
|
||||
.proto-root .jc-comm.t-out { border-left: 3px solid #8b93a7; }
|
||||
.proto-root .jc-comm-h { display: flex; align-items: center; gap: 10px; padding: 10px 14px 8px; }
|
||||
/* ── 沟通记录的未读(2026-07-31 用户定)──────────────────────────────
|
||||
一封客户来件落进来之后,「看没看过」从前没人记 —— 新到的信和三天前的信
|
||||
长得一模一样,人只能靠时间戳自己比。未读要**一眼扫得出**:左缘一道红边 +
|
||||
标题旁一枚「未读」小签,不靠底色染整块(整块变色会把正文的可读性也搭进去)。
|
||||
计数只跟着按钮走:打开页签不自动清 —— 那是替人宣布他看过了。 */
|
||||
.proto-root .jc-comm.unread { border-color: var(--danger-soft, #f6c6c6); box-shadow: inset 3px 0 0 var(--danger); }
|
||||
.proto-root .jc-comm-new { flex: none; padding: 1px 7px; border-radius: 999px; background: var(--danger); color: #fff; font-size: 10px; font-weight: 800; letter-spacing: .04em; }
|
||||
/* 时间戳把按钮顶到最右 —— 标记是这一行的收尾动作,不跟标题抢位置 */
|
||||
.proto-root .jc-comm-h .jc-comm-at { margin-left: auto; }
|
||||
.proto-root .jc-comm-mark { flex: none; display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px; border: 1px solid var(--line-soft); border-radius: 999px; background: #fff; font: inherit; font-size: 11px; font-weight: 600; color: var(--ink-3); cursor: pointer; transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast); }
|
||||
.proto-root .jc-comm-mark:hover:not(:disabled) { border-color: var(--brand); color: var(--brand); }
|
||||
.proto-root .jc-comm-mark:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
|
||||
.proto-root .jc-comm-mark:disabled { opacity: .55; cursor: default; }
|
||||
/* 已读那一枚淡下去 —— 它是「想改回未读」的入口,不该跟未读的按钮抢注意力 */
|
||||
.proto-root .jc-comm-mark.done { color: var(--ink-4); border-style: dashed; }
|
||||
.proto-root .jc-comm-dir { display: inline-flex; align-items: center; gap: 4px; flex: none; padding: 2px 7px; border-radius: 5px; background: #eef2fb; color: var(--brand); font-size: 11px; font-weight: 600; }
|
||||
.proto-root .jc-comm-dir.t-out { background: #f1f2f5; color: #5d6577; }
|
||||
.proto-root .jc-comm-sub { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 600; color: var(--ink); }
|
||||
.proto-root .jc-comm-at { flex: none; font-size: 11px; color: var(--ink-4); }
|
||||
.proto-root .jc-comm-meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; padding: 0 14px 10px; font-size: 11.5px; color: var(--ink-3); }
|
||||
.proto-root .jc-comm-meta b { font-weight: 600; color: var(--ink-2); }
|
||||
.proto-root .jc-comm-meta em { font-style: normal; color: var(--ink-4); }
|
||||
/* ── 邮件正文:让它读起来像**一封信**,不像第二张卡 ────────────────────
|
||||
从前是「整块灰底 + 一条硬分割线」:白色信头下面接一块灰,两者拼成两个盒子,
|
||||
人第一眼看到的是两张摞着的卡,而不是一封有抬头有正文的信。
|
||||
改成**引文那一路**:同底色、左缘一道浅竖线、往里缩一格 —— 竖线说的是
|
||||
「下面这段是对方写的原话」,这比一块灰底说得清楚。
|
||||
|
||||
三处取自 ui-ux-pro-max 的排版规则:
|
||||
· 行长限 65–75 字符(`max-width: 68ch`)—— 正文原来横铺整栏,一行三四十个汉字,
|
||||
眼睛回扫时容易串行;
|
||||
· 行高落到 1.75(原来 1.85 偏散,那是给大段散文的);
|
||||
· 正文是这一屏的主内容,字号提到 13px —— 它不该比元信息还小。
|
||||
|
||||
`overflow-wrap` 从 `anywhere` 收成 `break-word`:`anywhere` 会把词从中间劈开,
|
||||
而料号(LM358DR)被劈成两行是**读错的开始**,只有超长且无处可断时才允许断。
|
||||
|
||||
字体写 `inherit` 不写 `var(--sans)`:**`--sans` 这个变量全仓库没有定义过**
|
||||
(只有 `--mono` 有)。从前这里是 `font: 400 12.5px/1.85 var(--sans)` 简写 ——
|
||||
带未定义 var 的简写会**整条作废**,字号行高一起丢,实测算出来是 16px/normal。
|
||||
`<pre>` 的 UA 默认字体是等宽,所以这一句必须显式写,不能省。 */
|
||||
.proto-root .jc-comm-body { margin: 0 14px 12px; padding: 2px 0 2px 12px; border-left: 2px solid var(--line); max-width: 68ch; font-family: inherit; font-size: 13px; font-weight: 400; line-height: 1.75; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: break-word; }
|
||||
/* 来件与发件各给一点色相 —— 与信头左缘那道边同源,一眼分得出方向 */
|
||||
.proto-root .jc-comm.t-in .jc-comm-body { border-left-color: var(--brand-wash, #dbe6fb); }
|
||||
.proto-root .jc-comm.t-out .jc-comm-body { border-left-color: #e3e5ea; }
|
||||
.proto-root .jc-comm-att { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 14px; border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 8px; border: 1px solid var(--line-soft); border-radius: 6px; background: #fff; font-size: 11.5px; color: var(--ink-2); }
|
||||
.proto-root .jc-att svg { flex: none; color: #4f8a52; }
|
||||
/* 读不出内容的件:警示色 + 那句说明照抄 —— 摆一个「已上传 ✓」是撒谎 */
|
||||
.proto-root .jc-att.blind { border-color: #e4c9a5; background: #fdf8f0; }
|
||||
.proto-root .jc-att.blind svg { color: #b5803c; }
|
||||
.proto-root .jc-att b { flex: none; font-weight: 600; }
|
||||
.proto-root .jc-att i { flex: none; font-style: normal; font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-att em { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: normal; font-size: 10.5px; color: var(--ink-4); }
|
||||
|
||||
.proto-root .jc-log { display: flex; flex-direction: column; }
|
||||
.proto-root .jc-log-row { display: flex; gap: 10px; align-items: baseline; padding: 8px 2px; }
|
||||
.proto-root .jc-log-row + .jc-log-row { border-top: 1px solid var(--line-soft); }
|
||||
.proto-root .jc-log-at { flex: none; font-size: 10.5px; color: var(--ink-4); }
|
||||
.proto-root .jc-log-side { flex: none; font-size: 10.5px; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--soft); color: var(--ink-2); }
|
||||
/* 归侧四类各一色:机器 / 人 / 人裁 / 世界等待 —— 一眼看出这一步是谁推的 */
|
||||
.proto-root .jc-log-row.t-machine .jc-log-side { background: var(--brand-wash); color: var(--brand-deep); }
|
||||
.proto-root .jc-log-row.t-human .jc-log-side { background: #e6f7f4; color: #0a6a64; }
|
||||
.proto-root .jc-log-row.t-human-verdict .jc-log-side { background: #fff4e5; color: #de7802; }
|
||||
.proto-root .jc-log-row.t-world-wait .jc-log-side { background: var(--soft); color: var(--ink-3); }
|
||||
/* 归侧四类各一色:机器 / 人 / 人裁 / 系统等待 —— 一眼看出这一步是谁推的 */
|
||||
/*
|
||||
* **日志只有三色**(2026-08-02 用户定):负责人绿 · 智能体蓝 · 系统灰。
|
||||
* 从前按归侧(machine / human / human-verdict / world-wait / trigger)上五色 ——
|
||||
* 那是度量的分法,对读日志的人不构成区别:他只问「这一步是我办的、AI 办的,
|
||||
* 还是流程自己走到的」。三档一档一意,一眼扫得动。
|
||||
*/
|
||||
.proto-root .jc-log-row.t-agent .jc-log-side { background: var(--brand-wash); color: var(--brand-deep); }
|
||||
.proto-root .jc-log-row.t-owner .jc-log-side { background: #e6f7f4; color: #0a6a64; }
|
||||
.proto-root .jc-log-row.t-system .jc-log-side { background: var(--soft); color: var(--ink-3); }
|
||||
.proto-root .jc-log-t { flex: 1; min-width: 0; }
|
||||
.proto-root .jc-log-t b { display: block; font-size: 12.5px; font-weight: 600; }
|
||||
.proto-root .jc-log-t em { display: block; margin-top: 2px; font-style: normal; font-size: 11px; line-height: 1.6; color: var(--ink-3); }
|
||||
@@ -2992,3 +3193,84 @@
|
||||
.proto-root .jc-port.on .jc-port-dot { border-color: var(--brand); background: var(--brand); box-shadow: inset 0 0 0 2.5px #fff; }
|
||||
.proto-root .jc-port b { font-weight: 700; }
|
||||
.proto-root .jc-port em { margin-left: auto; font-style: normal; font-size: 10px; color: var(--ink-4); }
|
||||
|
||||
/* ═════════ AI 正在工作 —— 两处状态动画(2026-07-30) ═════════
|
||||
语义:「在持续办事」,不是「在打字」。三点打字气泡(jc-typing)只在
|
||||
AI 正回你的话时出现;这里的均衡条与涟漪表达的是**环在自己转**。
|
||||
动效纪律:纯 CSS、幅度克制、只动 transform/opacity(不回流);
|
||||
prefers-reduced-motion 下全部静止但状态仍可读。 */
|
||||
|
||||
/* ── ① 对话区 · AI 办事条(消息流末尾的常驻在场) ── */
|
||||
.proto-root .jc-aiwork { display: inline-flex; align-items: center; gap: 9px; padding: 9px 13px; background: linear-gradient(135deg, rgba(51, 112, 255, 0.06), rgba(51, 112, 255, 0.02)); border: 1px solid rgba(51, 112, 255, 0.18); border-radius: 4px 14px 14px 14px; }
|
||||
.proto-root .jc-aiwork-eq { display: inline-flex; align-items: flex-end; gap: 2.5px; height: 14px; }
|
||||
.proto-root .jc-aiwork-eq i { width: 3px; height: 5px; border-radius: 2px; background: var(--brand); transform-origin: bottom; animation: jcEq 1.15s ease-in-out infinite; }
|
||||
.proto-root .jc-aiwork-eq i:nth-child(2) { animation-delay: 0.14s; }
|
||||
.proto-root .jc-aiwork-eq i:nth-child(3) { animation-delay: 0.28s; }
|
||||
.proto-root .jc-aiwork-eq i:nth-child(4) { animation-delay: 0.42s; }
|
||||
.proto-root .jc-aiwork-txt { font-size: 12px; font-weight: 600; color: var(--brand); letter-spacing: 0.01em; }
|
||||
.proto-root .jc-aiwork-sub { font-size: 11px; color: var(--ink-3); }
|
||||
@keyframes jcEq { 0%, 100% { transform: scaleY(0.45); opacity: 0.55; } 40% { transform: scaleY(1.9); opacity: 1; } }
|
||||
/* 头像呼吸环:AI 在场的第二信号 —— 很轻,只有留意的人看得见 */
|
||||
.proto-root .jc-ava-live { animation: jcBreathe 2.4s ease-in-out infinite; }
|
||||
@keyframes jcBreathe { 0%, 100% { box-shadow: 0 0 0 0 rgba(51, 112, 255, 0.28); } 50% { box-shadow: 0 0 0 6px rgba(51, 112, 255, 0); } }
|
||||
|
||||
/* ── ② 空工作区 · AI 推进中场景(涟漪 + 工作线) ── */
|
||||
.proto-root .jc-working { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 52px 24px 58px; text-align: center; animation: rise var(--t-med) both; }
|
||||
.proto-root .jc-working-art { position: relative; width: 116px; height: 116px; margin-bottom: 4px; display: grid; place-items: center; }
|
||||
/* 涟漪两环:从中心缓慢扩散消隐 —— 「环在转」的空间隐喻 */
|
||||
.proto-root .jc-working-art .rp { position: absolute; inset: 22px; border-radius: 999px; border: 1.5px solid rgba(51, 112, 255, 0.35); animation: jcRipple 2.6s cubic-bezier(0.2, 0.6, 0.36, 1) infinite; }
|
||||
.proto-root .jc-working-art .rp2 { animation-delay: 1.3s; }
|
||||
.proto-root .jc-working-ico { position: relative; z-index: 1; width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center; color: #fff; background: linear-gradient(140deg, var(--brand), #244ecf); box-shadow: 0 6px 18px rgba(51, 112, 255, 0.32); animation: jcBreathe 2.4s ease-in-out infinite; }
|
||||
@keyframes jcRipple { 0% { transform: scale(0.72); opacity: 0; } 18% { opacity: 0.9; } 100% { transform: scale(1.55); opacity: 0; } }
|
||||
/* 工作线:三行微光骨架 —— AI 正在把报告一行行写出来的意象 */
|
||||
.proto-root .jc-working-lines { display: flex; flex-direction: column; gap: 7px; width: 190px; margin: 8px 0 2px; }
|
||||
.proto-root .jc-working-lines i { height: 7px; border-radius: 4px; background: linear-gradient(90deg, var(--soft) 25%, rgba(51, 112, 255, 0.16) 50%, var(--soft) 75%); background-size: 220% 100%; animation: jcShimmer 1.7s ease-in-out infinite; }
|
||||
.proto-root .jc-working-lines i:nth-child(2) { width: 78%; animation-delay: 0.18s; }
|
||||
.proto-root .jc-working-lines i:nth-child(3) { width: 56%; animation-delay: 0.36s; }
|
||||
@keyframes jcShimmer { 0% { background-position: 120% 0; } 100% { background-position: -100% 0; } }
|
||||
.proto-root .jc-working > b { font-size: 15px; font-weight: 800; color: var(--ink); }
|
||||
.proto-root .jc-working > p { margin: 0; max-width: 30em; font-size: 12px; color: var(--ink-3); line-height: 1.8; }
|
||||
.proto-root .jc-working-hint { display: inline-flex; align-items: center; gap: 6px; margin-top: 6px; padding: 5px 13px; background: rgba(51, 112, 255, 0.07); border: 1px solid rgba(51, 112, 255, 0.2); border-radius: 999px; font-size: 11px; font-weight: 600; color: var(--brand); }
|
||||
|
||||
/* 动效偏好:全部静止 —— 状态仍在(条不动、环常亮、线为素底),信息一分不少 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.proto-root .jc-aiwork-eq i,
|
||||
.proto-root .jc-ava-live,
|
||||
.proto-root .jc-working-art .rp,
|
||||
.proto-root .jc-working-ico,
|
||||
.proto-root .jc-working-lines i { animation: none; }
|
||||
.proto-root .jc-working-art .rp { opacity: 0.35; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
/* ═══ 算出来的去向 ═══════════════════════════════════════════════════════
|
||||
* 与「选口」那一组同一副面孔,但**不是按钮**:去向是分类表算出来的事实,
|
||||
* 不该长得像一道可点的选择题。判据原文摆在下面 —— 人要能回核「凭什么走这条」。
|
||||
*/
|
||||
.proto-root .jc-ports.routed .jc-port { cursor: default; }
|
||||
.proto-root .jc-ports.routed .jc-port:hover { transform: none; }
|
||||
.proto-root .jc-ports-why { margin: 6px 0 0; font-size: 11px; color: var(--ink-4); }
|
||||
.proto-root .jc-ports-why code { font-size: 10.5px; color: var(--ink-3); background: var(--soft); border-radius: 5px; padding: 1px 6px; }
|
||||
|
||||
/* ── 验收判据的第三态 —— 放在末尾,压得过后面那条 `li:hover` ───────────────
|
||||
*
|
||||
* 裁过、裁成不满足:那不是「活没干完」,是 AI 办过、办不到,把判断交给人
|
||||
* (提交门本来就允许这样提交:裁过即可)。从前它与「压根没裁」同挤成一个红叉,
|
||||
* 人看到一条永远不绿的项,既不知道 AI 有没有办过、也不知道为什么,只能凭感觉签。
|
||||
* 用琥珀不用红:**红是缺口,琥珀是「等你拿个主意」**。
|
||||
*
|
||||
* 位置有讲究:`.jc-rp-crit li:hover` 与它同特异性(0,3,1),写在前面就会被悬停盖掉 ——
|
||||
* 鼠标一扫过,那一条就变回普通底色,人以为自己看错了。
|
||||
*/
|
||||
/*
|
||||
* 标法用**左侧色条**而不是整行底色:底色那一层在这条链上压不过既有的
|
||||
* `li` / `li:hover`(同特异性、位置更后),追下去只会变成一串 !important;
|
||||
* 而色条是 inset 阴影,谁也不跟它抢 —— 本仓标「命中行」本来就用这一招
|
||||
* (`jc-dmn-r.hit::before` 那条左边的竖线)。视觉语言一致,还更醒目。
|
||||
*/
|
||||
.proto-root .jc-rp-crit li.judged { box-shadow: inset 3px 0 0 #de7802; }
|
||||
.proto-root .jc-rp-crit li.judged .r { color: #de7802; }
|
||||
/* 理由那一句:裁成不满足时它才是签字的人真正要读的东西 */
|
||||
.proto-root .jc-rp-crit .jc-crit-why {
|
||||
display: block; margin-top: 3px; font-style: normal; font-size: 11px;
|
||||
line-height: 1.6; color: var(--ink-3);
|
||||
}
|
||||
|
||||
@@ -226,6 +226,56 @@
|
||||
.g-card-h svg { color: var(--ink-4); }
|
||||
.g-card-h .x { margin-left: auto; font-size: 10.5px; font-weight: 600; color: var(--ink-4); }
|
||||
.g-card-b { padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
/* `.g-card-b` 全仓库无一处使用——`Card` 组件的 `children` 直接落进 `.g-card`,从没吐过这层内容区。
|
||||
自带边框/内边距的件(`.g-rows` 等)塞进去还成立,`.g-entries` 网格自身 `padding: 0`,
|
||||
数据域一塞就和外框贴死成两条 1px 边框叠一条线(设计中心 · 数据域实测四面 0-1px)。
|
||||
`Card` 有 26 处调用横跨十个治理台面板,动 `Card` 本身或 `.g-card` 得逐面板复核会不会
|
||||
变成双重内边距,面太大;这里只在「网格直吐进卡体」这一种组合上后代选择器精确落刀,
|
||||
不碰共享组件与其余调用点。取值同 `.g-card-b`(与 `.g-card-h` 的 16px 横向对齐)。 */
|
||||
.g-card > .g-entries { padding: 14px 16px; }
|
||||
/* `.g-newflow`(新建流程 / 新建审批活动两处表单容器)此前**全仓库零条规则**——
|
||||
`Card` 的 children 直落 `.g-card`(见上一条注释),这层什么都不兜:输入框贴着卡的
|
||||
圆角边框「漏」出去、字段之间没有竖向行距、操作条贴死卡的右边缘(治理台实测截图为证)。
|
||||
两处调用点(gov-activity 审批活动表单 · gov-process 新建流程表单)结构相同,直接给
|
||||
`.g-newflow` 补一份通用表单容器,两边一起受益;取值抄 `.g-card-b`——那条规则本来就是
|
||||
给「直落 .g-card 的内容」定的,只是从没接线,不是发明新数字。 */
|
||||
.g-newflow { padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
/* ── 数据域:按**性质**分区(2026-07-31)────────────────────────────────
|
||||
从前这一屏只按域分组,19 张表看着一模一样 —— 人分不出哪几张是要治理的主数据、
|
||||
哪几张只是一单一份的凭证。而「该不该管它、谁有权改它」全看性质:
|
||||
主数据要治理(谁能改、依据、去重、时效),单据只需查得到、派生根本不该手写。
|
||||
所以分区要**看得见**:一条带色的区头 + 一句说清这一类是什么。 */
|
||||
.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;
|
||||
padding: 10px 14px; border-left: 3px solid var(--kind-c, var(--brand)); border-radius: 0 8px 8px 0;
|
||||
background: var(--kind-bg, var(--brand-wash)); }
|
||||
.g-kind-h svg { color: var(--kind-c, var(--brand)); }
|
||||
.g-kind-h b { font-size: 13.5px; font-weight: 800; color: var(--ink); letter-spacing: .02em; }
|
||||
.g-kind-h .n { padding: 1px 8px; border-radius: 999px; background: #fff;
|
||||
font: 700 10.5px/1.7 var(--sh-mono, ui-monospace); color: var(--kind-c, var(--brand)); }
|
||||
/* 说辞占满剩下那一格,窄屏时整条换行到下一行 —— 它不是装饰,是这一类的定义 */
|
||||
.g-kind-h p { grid-column: 4; margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); }
|
||||
@media (max-width: 1100px) {
|
||||
.g-kind-h { grid-template-columns: auto auto auto; }
|
||||
.g-kind-h p { grid-column: 1 / -1; margin-top: 4px; }
|
||||
}
|
||||
/* 四档各一个色:主数据(要治理)最重,派生用警示色 —— 它标的是「这本不该手写」 */
|
||||
.g-kind.t-m { --kind-c: #1a6dd6; --kind-bg: #eff5ff; }
|
||||
.g-kind.t-r { --kind-c: #5b6b7f; --kind-bg: #f4f6f9; }
|
||||
.g-kind.t-d { --kind-c: #b8791a; --kind-bg: #fdf6ea; }
|
||||
.g-kind.t-u { --kind-c: #9aa4b2; --kind-bg: #f6f7f9; }
|
||||
|
||||
/*
|
||||
卡内网格改 `auto-fill` —— **每张卡一样大,不随本域有几个对象变形**(2026-07-31)。
|
||||
`auto-fit` 会把空轨道**折叠掉**,剩下的轨道再把整行分完:于是同一屏上
|
||||
只有 1 个对象的域(customer / quote_archive)那张卡被拉成**整行宽**、
|
||||
2 个对象的域(supplier / quote_pricing / quote_negotiate)每张占**半行**、
|
||||
≥3 个的才是正常的三分之一 —— 一页里三种尺寸,看着像三种东西。
|
||||
`auto-fill` 保留空轨道:轨道宽只由容器宽定,与本域对象数无关,
|
||||
1 个对象就摆 1 张、右边留白,尺寸和别的域一模一样。
|
||||
留白是对的:卡的大小该表示「它是一张对象卡」,不该表示「这个域有几个对象」。 */
|
||||
.g-card > .g-entries { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
.g-code { display: block; padding: 11px 13px; border-radius: 10px; background: #f5f7fa; border: 1px solid var(--line-soft); font-family: var(--mono); font-size: 11.5px; line-height: 1.75; color: var(--ink-2); overflow-wrap: anywhere; }
|
||||
|
||||
/* ============ 矩阵表 ============ */
|
||||
@@ -426,6 +476,12 @@
|
||||
/* 审批依据:一行两格 + 删 */
|
||||
.g-basis { display: flex; flex-direction: column; gap: 6px; }
|
||||
.g-basis-r { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: 6px; }
|
||||
/* 「加一条」按钮是 `.g-basis`(纵向 flex)的直接子项,继承默认 `align-items: stretch`
|
||||
被拉成贯穿整行的横杠、看着像一行内容而不是按钮(实测截图为证)。`.g-basis-r` 那几行
|
||||
要满宽(两个输入 + 一枚删除钮),不能把 `.g-basis` 整体收窄去将就按钮,所以只钉住
|
||||
这一枚按钮自己的 cross-axis,不碰 `.g-basis` 本身、也不影响别处的 `.g-ver-act`
|
||||
(全仓库 `.g-basis` 只此一处调用,这条选择器不会外溢)。 */
|
||||
.g-basis > .g-ver-act { align-self: flex-start; }
|
||||
|
||||
/* 幽灵卡:同尺寸的虚线位 —— 一眼看出「这儿还能再添一条」 */
|
||||
.g-fcard.ghost { flex-direction: row; justify-content: center; align-items: center; gap: 7px; min-height: 122px; border-style: dashed; border-color: #c0cbd8; background: #fcfdfe; box-shadow: none; color: var(--ink-4); font: 600 12.5px/1 var(--sans); cursor: pointer; }
|
||||
@@ -936,6 +992,67 @@
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .04), 0 10px 26px -16px rgba(8, 15, 28, .55);
|
||||
}
|
||||
|
||||
/* ── 单在另一台上:页签是人点的,不抢 —— 只在这一台顶上挂一句「它在那边」 ── */
|
||||
.tb-else { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border: 1px solid var(--sh-line); border-radius: 8px; background: rgba(0, 0, 0, .22); }
|
||||
.tb-else b { font: 600 11px/1 var(--sh-mono); letter-spacing: .08em; color: var(--sh-steel); }
|
||||
.tb-else span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--sh-muted); }
|
||||
.tb-else .tb-ghost { margin-left: auto; flex: none; }
|
||||
|
||||
/* 起单入参纸上的必填记号 —— 一枚星号,跟在字段名后半格(「必填」二字挤得字段名折行) */
|
||||
.tb-pp-r > em .req { margin-left: 2px; font-style: normal; font-weight: 700; color: #c0553c; }
|
||||
|
||||
/* ── 契约面读数:起单前就该看见这个活动手上有什么 ──
|
||||
跑不动的原因八成在这几行里:工具没装、模型没绑、台账没物化、预算给太紧。 */
|
||||
.tb-face { display: flex; flex-direction: column; gap: 2px; padding: 8px 0 2px; }
|
||||
.tb-face-r { display: grid; grid-template-columns: 46px minmax(0, 1fr); align-items: baseline; gap: 10px; padding: 6px 0; border-bottom: 1px solid #16233c; }
|
||||
.tb-face-r > em { font: 600 10px/1.5 var(--sh-mono); font-style: normal; letter-spacing: .1em; color: var(--sh-muted); }
|
||||
.tb-face-r .v { display: flex; flex-wrap: wrap; gap: 5px; min-width: 0; font-size: 12px; color: var(--sh-ink); }
|
||||
.tb-face-r .v.mono { font: 500 11px/1.5 var(--sh-mono); color: var(--sh-steel); }
|
||||
.tb-face-r .v.none { font-size: 11.5px; color: var(--sh-muted); }
|
||||
.tb-face-r .chip { font: 500 10.5px/1.5 var(--sh-mono); font-style: normal; padding: 1px 7px; border: 1px solid var(--sh-line); border-radius: 999px; color: var(--sh-steel); overflow-wrap: anywhere; }
|
||||
|
||||
/* ── 预算读数三格:Harness 在环外掐的就是这三个数 ── */
|
||||
.tb-gauges { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; padding: 12px 0 4px; }
|
||||
.tb-g { display: flex; flex-direction: column; gap: 5px; padding: 9px 12px; border: 1px solid #1b2a44; border-radius: 9px; background: rgba(0, 0, 0, .22); }
|
||||
.tb-g > em { font: 600 9.5px/1 var(--sh-mono); font-style: normal; letter-spacing: .16em; text-transform: uppercase; color: var(--sh-muted); }
|
||||
.tb-g > b { font: 700 15px/1 var(--sh-mono); color: var(--sh-live); }
|
||||
.tb-g > b > i { font: 500 11px/1 var(--sh-mono); font-style: normal; color: var(--sh-muted); }
|
||||
.tb-g .bar { display: block; height: 3px; border-radius: 2px; background: #16233a; overflow: hidden; }
|
||||
.tb-g .bar > i { display: block; height: 100%; background: var(--sh-live); transition: width var(--t-fast); }
|
||||
.tb-g.warm > b, .tb-g.warm .bar > i { color: var(--sh-brass); background-color: var(--sh-brass); }
|
||||
.tb-g.warm > b { background: none; }
|
||||
.tb-g.hot > b, .tb-g.hot .bar > i { color: var(--sh-alarm); background-color: var(--sh-alarm); }
|
||||
.tb-g.hot > b { background: none; }
|
||||
.tb-reason { margin: 6px 0 0; font-size: 12px; color: var(--sh-brass); }
|
||||
|
||||
/* ── 逐拍轨迹:活动内部的流水账 —— 「第三拍为什么被拒」这句话只在这儿 ── */
|
||||
.tb-trail { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; }
|
||||
.tb-trail li { display: grid; grid-template-columns: 62px minmax(0, 1fr); gap: 10px; padding: 5px 0; border-bottom: 1px solid #141f34; }
|
||||
.tb-trail li .t { font: 500 10px/1.6 var(--sh-mono); color: var(--sh-muted); }
|
||||
.tb-trail li .b { min-width: 0; font-size: 12px; line-height: 1.6; color: var(--sh-steel); overflow-wrap: anywhere; }
|
||||
.tb-trail li .b .d { display: block; font: 400 10.5px/1.6 var(--sh-mono); font-style: normal; color: var(--sh-muted); overflow-wrap: anywhere; }
|
||||
/* 三种要紧的行各给一色:干成了(磷光)/ 被拒(珊瑚)/ 引擎迁移(黄铜) */
|
||||
.tb-trail li.k-action .b { color: var(--sh-live); }
|
||||
.tb-trail li.k-result .b { color: var(--sh-ink); }
|
||||
.tb-trail li.k-engine-transition .b { color: var(--sh-brass); }
|
||||
|
||||
/* ── 台账草稿:成果就是这些行 ── */
|
||||
.tb-drafts { display: flex; flex-direction: column; gap: 10px; padding: 8px 0 2px; }
|
||||
.tb-draft-g .addr { display: block; font: 600 10px/1.8 var(--sh-mono); letter-spacing: .08em; color: var(--sh-muted); }
|
||||
/* 主键那一列给够宽:它是「这一行是谁」(询价单号 · PN),88px 会把它折成三行 */
|
||||
.tb-draft-g .row { display: grid; grid-template-columns: minmax(0, clamp(110px, 24%, 220px)) minmax(0, 1fr); gap: 10px; padding: 4px 0; border-bottom: 1px solid #141f34; }
|
||||
.tb-draft-g .row .k { font: 600 11px/1.6 var(--sh-mono); color: var(--sh-live); overflow-wrap: anywhere; }
|
||||
.tb-draft-g .row .p { font: 400 10.5px/1.6 var(--sh-mono); color: var(--sh-steel); overflow-wrap: anywhere; }
|
||||
.tb-none { margin: 8px 0 0; font-size: 11.5px; line-height: 1.75; color: var(--sh-muted); }
|
||||
|
||||
/* ── 卡:问了谁什么 ── */
|
||||
.tb-cards { display: flex; flex-direction: column; padding: 6px 0 2px; }
|
||||
.tb-cards .row { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: baseline; gap: 9px; padding: 6px 0; border-bottom: 1px solid #141f34; }
|
||||
.tb-cards .row .m { font: 600 9.5px/1.6 var(--sh-mono); letter-spacing: .06em; color: var(--sh-brass); }
|
||||
.tb-cards .row .t { min-width: 0; font-size: 12px; color: var(--sh-ink); overflow-wrap: anywhere; }
|
||||
.tb-cards .row .s { font: 500 10px/1.6 var(--sh-mono); color: var(--sh-brass); }
|
||||
.tb-cards .row.done .s { color: var(--sh-muted); }
|
||||
|
||||
/* ── 仪器顶条:一盏灯 + 状态词 —— 装配态与观测态共用同一根 ── */
|
||||
.tb-strip { display: flex; align-items: center; gap: 11px; min-height: 30px; padding: 2px 2px 10px; border-bottom: 1px solid #1b2a44; margin-bottom: 2px; }
|
||||
.tb-lamp { flex: none; width: 9px; height: 9px; border-radius: 50%; background: currentColor; box-shadow: 0 0 9px currentColor; }
|
||||
@@ -1026,7 +1143,10 @@
|
||||
|
||||
/* ── ② 节点指派:一条竖轨挂着所有节点,右端是插座 —— 插了人点琥珀 ── */
|
||||
.tb-asn { display: flex; flex-direction: column; padding: 4px 0 10px; }
|
||||
.tb-asn-r { display: grid; grid-template-columns: 20px 34px minmax(0, 1fr) 40px minmax(150px, 190px); align-items: center; gap: 9px; padding: 6px 0; }
|
||||
.tb-asn-r { display: grid; grid-template-columns: 20px 34px minmax(0, 1fr) 40px minmax(184px, 238px); align-items: center; gap: 9px; padding: 6px 0; }
|
||||
/* 单活动那一台只有一格插座、没有「活动/审批」那一列 —— 少一格就得少一列,
|
||||
不然 select 掉进 40px 那一格里,名字挤成一坨(踩过:「下拉框尺寸不够」) */
|
||||
.tb-asn-r.solo { grid-template-columns: 20px 52px minmax(0, 1fr) minmax(184px, 238px); }
|
||||
.tb-asn-r .rail { position: relative; align-self: stretch; }
|
||||
.tb-asn-r .rail::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; border-left: 1px solid var(--sh-line); }
|
||||
.tb-asn-r:first-child .rail::before { top: 50%; }
|
||||
@@ -1057,6 +1177,63 @@
|
||||
.tb-go:disabled { opacity: .45; cursor: default; }
|
||||
.tb-go:focus-visible { outline: 2px solid var(--sh-live); outline-offset: 2px; }
|
||||
|
||||
/* ── 附件格:选的是**真文件** —— 一件一枚签条,读出来什么就写什么 ── */
|
||||
.tb-files { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 4px 0; }
|
||||
.tb-file { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 3px 6px 3px 8px; border: 1px solid #ddd7c8; border-radius: 4px; background: #f4f1e8; font: 500 11px/1.5 var(--sans); color: #4a4536; }
|
||||
.tb-file svg { flex: none; color: #6a8f5e; }
|
||||
.tb-file.blind svg { color: #c0885c; }
|
||||
.tb-file b { flex: none; font-weight: 600; }
|
||||
.tb-file i { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 10px/1.5 var(--sh-mono); font-style: normal; color: #8b8674; }
|
||||
.tb-file button { display: inline-flex; flex: none; padding: 2px; border: 0; background: transparent; color: #8b8674; cursor: pointer; }
|
||||
.tb-file button:hover { color: #b0413e; }
|
||||
.tb-file-add { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border: 1px dashed #c9c2ae; border-radius: 4px; color: #8b8674; font: 500 11px/1.5 var(--sans); cursor: pointer; }
|
||||
.tb-file-add:hover { border-color: #8b8674; color: #4a4536; }
|
||||
.tb-file-add:focus-within { outline: 2px solid #8b8674; outline-offset: 1px; }
|
||||
.tb-file-add input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
|
||||
/* ── 试跑存档:目录一行一次试跑 —— 读数 mono、题目无衬线,右端一枚删 ── */
|
||||
.tb-logs { display: flex; flex-direction: column; gap: 6px; }
|
||||
/* 第二列是**试跑短号**(2026-07-31 加):同一条流反复试跑,主题一模一样,
|
||||
没有号就只能靠报时分秒指认「哪一次」—— 号在最前面才好一眼扫到并对上话 */
|
||||
.tb-log-r { display: grid; grid-template-columns: 44px 74px minmax(0, 1fr) 82px 168px 138px 92px 24px; align-items: center; gap: 10px; width: 100%; padding: 9px 12px; border: 1px solid var(--sh-line); border-radius: 8px; background: var(--sh-panel); text-align: left; cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast); }
|
||||
.tb-log-r:hover { border-color: var(--sh-lineb); background: var(--sh-panel-2); }
|
||||
.tb-log-r:focus-visible { outline: 2px solid var(--sh-live); outline-offset: 1px; }
|
||||
.tb-log-r .kd { justify-self: start; padding: 2px 6px; border-radius: 4px; border: 1px solid var(--sh-line); font: 600 9px/1.5 var(--sh-mono); letter-spacing: .1em; color: var(--sh-muted); }
|
||||
/* 试跑短号 —— 等宽、可点、点了拷完整 id;列表与详情共用一枚 */
|
||||
.tb-runid { justify-self: start; padding: 2px 6px; border-radius: 4px; border: 1px dashed var(--sh-line); background: transparent; font: 600 10px/1.5 var(--sh-mono); letter-spacing: .04em; color: var(--sh-muted); cursor: pointer; white-space: nowrap; transition: color var(--t-fast), border-color var(--t-fast); }
|
||||
.tb-runid:hover { color: var(--sh-live); border-color: var(--sh-live); border-style: solid; }
|
||||
.tb-runid:focus-visible { outline: 2px solid var(--sh-live); outline-offset: 1px; }
|
||||
/* 拷上了就实心亮一下 —— 界面不撒谎:拷不上不会走到这个态 */
|
||||
.tb-runid.ok { color: var(--sh-live); border-color: var(--sh-live); border-style: solid; }
|
||||
/* 详情页那一枚跟在标题后面,别跟标题挤在一起 */
|
||||
.tb-strip .tb-runid { margin-left: 8px; }
|
||||
.tb-log-r .kd.package { border-color: rgba(98, 169, 224, .34); color: var(--sh-live); }
|
||||
.tb-log-r .kd.activity { border-color: rgba(242, 180, 92, .4); color: var(--sh-brass); }
|
||||
.tb-log-r .sub { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.tb-log-r .sub b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; font-weight: 600; color: var(--sh-ink); }
|
||||
.tb-log-r .sub em { font: 400 9.5px/1 var(--sh-mono); font-style: normal; letter-spacing: .06em; color: var(--sh-muted); }
|
||||
.tb-log-r .st { font: 600 10px/1 var(--sh-mono); letter-spacing: .06em; color: var(--sh-muted); }
|
||||
.tb-log-r .st.ok { color: var(--sh-live); }
|
||||
.tb-log-r .st.bad { color: var(--sh-alarm); }
|
||||
.tb-log-r .st.cut { color: var(--sh-brass); }
|
||||
.tb-log-r .rd { font: 400 10px/1 var(--sh-mono); color: var(--sh-muted); }
|
||||
.tb-log-r .at { font: 400 10px/1 var(--sh-mono); color: var(--sh-steel); }
|
||||
.tb-log-r .del { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 5px; color: var(--sh-muted); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
|
||||
.tb-log-r .del:hover { background: rgba(224, 96, 92, .14); color: var(--sh-alarm); }
|
||||
.tb-log-r .del:focus-visible { outline: 2px solid var(--sh-alarm); outline-offset: 1px; }
|
||||
|
||||
/* 存档正文:逐拍轨迹 / 草稿 / 人卡 —— 三张表同一副骨架,一行一条 */
|
||||
.tb-trail { display: flex; flex-direction: column; max-height: 420px; overflow: auto; }
|
||||
.tb-trail-r { display: grid; grid-template-columns: 62px 116px minmax(0, 1fr) auto; align-items: baseline; gap: 10px; padding: 6px 14px; border-bottom: 1px solid var(--sh-line); }
|
||||
.tb-trail-r:last-child { border-bottom: 0; }
|
||||
.tb-trail-r .at { font: 400 10px/1.6 var(--sh-mono); color: var(--sh-muted); }
|
||||
.tb-trail-r .at.cut { color: var(--sh-brass); }
|
||||
.tb-trail-r .kd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9.5px/1.6 var(--sh-mono); letter-spacing: .08em; color: var(--sh-steel); }
|
||||
.tb-trail-r .tt { min-width: 0; font-size: 11.5px; line-height: 1.6; color: var(--sh-ink); word-break: break-word; }
|
||||
.tb-trail-r .tt.mono { font: 400 10.5px/1.7 var(--sh-mono); color: var(--sh-steel); }
|
||||
/* 草稿与人卡那两张只有三格:头一格空着比错位好 */
|
||||
.tb-trail-r > :first-child:not(.at) { grid-column: 1 / span 2; }
|
||||
|
||||
/* 空台面:没有生效流 —— 一个虚线的空槽,指路去流程包 */
|
||||
.tb-slot { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 44px 16px 46px; border: 1.5px dashed var(--sh-line); border-radius: 10px; text-align: center; }
|
||||
.tb-slot svg { color: var(--sh-muted); opacity: .7; }
|
||||
|
||||
@@ -10,14 +10,17 @@
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from './query.ts';
|
||||
|
||||
type Touch = { kind: 'task' | 'workflow' | 'staff' | 'system'; id: string; reason: string };
|
||||
export type Touch = { kind: 'task' | 'workflow' | 'staff' | 'system'; id: string; reason: string };
|
||||
|
||||
export function connectStream(queryClient: QueryClient): () => void {
|
||||
const source = new EventSource('/api/stream');
|
||||
|
||||
source.addEventListener('touch', (event) => {
|
||||
const touch = JSON.parse((event as MessageEvent<string>).data) as Touch;
|
||||
switch (touch.kind) {
|
||||
/**
|
||||
* 一次推送该失效哪些键 —— **单独导出是为了让它可测**。
|
||||
*
|
||||
* 这段映射曾经整段失效(键前缀写成 `['tasks']`,而工作台的键是 `['desk','tasks']`)——
|
||||
* 推送照发、画面纹丝不动,而且不报错。这类错只能靠断言「推送到了哪把查询会重取」来守,
|
||||
* 所以把它从 EventSource 里剥出来:连接是 I/O,映射是逻辑。
|
||||
*/
|
||||
export function invalidateFor(queryClient: QueryClient, touch: Touch): void {
|
||||
switch (touch.kind) {
|
||||
/*
|
||||
* **键名一律取自 `queryKeys`**,不手写字面量:TanStack 的失效按**前缀**匹配,
|
||||
* 而工作台的键都以 `desk` 打头(`['desk','tasks']`)。从前这里写的是 `['tasks']` ——
|
||||
@@ -31,6 +34,13 @@ export function connectStream(queryClient: QueryClient): () => void {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.approvals });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'tasks'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'tasklog', touch.id] });
|
||||
/*
|
||||
* **测试台也要跟着动。** 它的键自成一支(`gov.testbed.*`),不在上面几条的前缀里 ——
|
||||
* 从前只靠自己 2 秒一轮的轮询,而轮询到终态就停:一单跑完之后,
|
||||
* 那一屏就此定格,连「当前在跑的是哪一单」都不再问(实测:跑完了旋钮还锁着)。
|
||||
* 推送到了就失效,轮询退回成兜底。
|
||||
*/
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] });
|
||||
break;
|
||||
case 'workflow':
|
||||
// 流动了,自己名下的任务与审批都可能变 —— 工作台一并失效
|
||||
@@ -39,15 +49,30 @@ export function connectStream(queryClient: QueryClient): () => void {
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'workflow', touch.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'tasks'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'approvals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] });
|
||||
break;
|
||||
case 'staff':
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.messages });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'staff'] });
|
||||
break;
|
||||
case 'system':
|
||||
/*
|
||||
* 全局变更(配置改了 / 试跑单被销毁 / 台账落了新事实)——**两面一起失效**。
|
||||
*
|
||||
* 只失效 `gov` 是不够的:销毁试跑单拔掉的是**真任务**,它这一刻还挂在
|
||||
* 负责人的工作台列表上;不连 `desk` 一起失效,那儿会留着一张点进去就 404 的卡片。
|
||||
*/
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['desk'] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function connectStream(queryClient: QueryClient): () => void {
|
||||
const source = new EventSource('/api/stream');
|
||||
|
||||
source.addEventListener('touch', (event) => {
|
||||
invalidateFor(queryClient, JSON.parse((event as MessageEvent<string>).data) as Touch);
|
||||
});
|
||||
|
||||
// 重连即补课:断线期间的推送收不到,回来先整体失效一次
|
||||
|
||||
@@ -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 { atMinute } from '@web-shared/at.ts';
|
||||
|
||||
const STATE_CHIP = {
|
||||
pending: { tone: 'todo' as const, label: '待审批', pulse: true },
|
||||
@@ -89,7 +90,7 @@ export function ApprovalDesk() {
|
||||
<span className="jc-acard-from">
|
||||
<Ico name="workflow" s={12} />
|
||||
<b>{a.workflowSubject}</b>
|
||||
<time className="jc-mono">{a.raisedAt.slice(5, 16).replace('T', ' ')}</time>
|
||||
<time className="jc-mono">{atMinute(a.raisedAt)}</time>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -14,6 +14,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 { atFull, atMinute } from '@web-shared/at.ts';
|
||||
|
||||
const KIND_META: Readonly<Record<string, { icon: string; grad: readonly [string, string]; kicker: string }>> = {
|
||||
notice: { icon: 'bell', grad: ['#7f3bf5', '#5b21cf'], kicker: '系统 · 通知' },
|
||||
@@ -84,7 +85,7 @@ export function MessageDesk() {
|
||||
</span>
|
||||
<strong className="jc-mcard-title jc-clamp2">{m.title}</strong>
|
||||
<span className="jc-mcard-foot">
|
||||
<time className="jc-mono">{m.at.slice(5, 16).replace('T', ' ')}</time>
|
||||
<time className="jc-mono">{atMinute(m.at)}</time>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -114,7 +115,7 @@ export function MessageDesk() {
|
||||
icon={metaOf(sel.kind).icon}
|
||||
kicker={metaOf(sel.kind).kicker}
|
||||
title={sel.title}
|
||||
meta={<time className="jc-mono">{sel.at.slice(0, 16).replace('T', ' ')}</time>}
|
||||
meta={<time className="jc-mono">{atFull(sel.at)}</time>}
|
||||
chip={<Chip tone={sel.read ? 'done' : 'brand'} label={sel.read ? '已读' : '未读'} />}
|
||||
/>
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export function ReportDesk() {
|
||||
))}
|
||||
</div>
|
||||
<NoteBand>
|
||||
自主率 = 机器产出 ÷(机器产出 + 人产出),裁决与等待不进分母;它与纠正率
|
||||
自主率 = 机器产出 ÷(机器产出 + 员工动作),裁决与等待不进分母;它与纠正率
|
||||
<b>成对读</b>,复合为有效自主率 —— 单看任一都会被优化坏。
|
||||
单位成本 {Math.round(report.metrics.unitCost.tokens)} token /
|
||||
{report.metrics.unitCost.minutes.toFixed(1)} 分钟每放行单,分母只认放行通过。
|
||||
|
||||
@@ -13,11 +13,37 @@ import { replyChatCardRoute, replyWorkOrderRoute, sayRoute } from '@contracts';
|
||||
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 } from './chat-bubbles.tsx';
|
||||
import { ChatStream, WorkingStrip } from './chat-bubbles.tsx';
|
||||
|
||||
export function TaskChat({ detail, onLocate }: { detail: TaskDetail; onLocate: (orderId: string) => void }) {
|
||||
export function TaskChat({
|
||||
detail,
|
||||
onLocate,
|
||||
onOpenComms,
|
||||
orderValues,
|
||||
onField,
|
||||
}: {
|
||||
detail: TaskDetail;
|
||||
onLocate: (orderId: string) => void;
|
||||
/** 起单开场白上的「查看邮件」—— 跳到沟通记录那一屏 */
|
||||
onOpenComms: () => void;
|
||||
/**
|
||||
* 人在工作区报告上改过的填报值 —— **裁决时一并带走**。
|
||||
*
|
||||
* 验收工单「AI 已填好、可编辑」而裁决在这儿:两半分在两个区里做,
|
||||
* 不把缓冲带过来,回传的 `values` 就恒空 —— 人改了报告,落地时用的还是 AI 的原值。
|
||||
*/
|
||||
orderValues?: Readonly<Record<string, string>>;
|
||||
/** 卡上填的值进同一个缓冲 —— 执行单的表单如今就长在对话区的卡上 */
|
||||
onField: (key: string, value: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const taskId = detail.card.id;
|
||||
/*
|
||||
* AI 正在办这单 = Loop 在自己转(runtime running)。
|
||||
* 等人(awaiting-human)、等外部(awaiting-event)、已收尾都不算 ——
|
||||
* 那时亮「工作中」就是撒谎:环没在转,是在等。
|
||||
*/
|
||||
const working = detail.card.terminal === undefined && detail.card.runtime === 'running';
|
||||
|
||||
/** 回传后一律重取任务投影 —— 卡与工单的状态由服务端说,前端不自行推演 */
|
||||
const refresh = () => {
|
||||
@@ -50,12 +76,29 @@ export function TaskChat({ detail, onLocate }: { detail: TaskDetail; onLocate: (
|
||||
<span className="jc-tch-desc">盯着这单推进</span>
|
||||
</header>
|
||||
<div className="jc-tch-scroll">
|
||||
<EmptyState
|
||||
ico="messages-square"
|
||||
tone="brand"
|
||||
title="还没有消息"
|
||||
desc="AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。"
|
||||
/>
|
||||
{/*
|
||||
* **起手就是头像 + 气泡,不是空占位**。
|
||||
*
|
||||
* 从前这里在 working 时同时摆两样:一张「AI 已经开工」的空态插画,底下再挂一条办事条。
|
||||
* 于是这一单的开头是「一幅画 + 一条」,而第一句真消息到达时那幅画忽然消失、
|
||||
* 版式整个跳一下 —— 人看到的是界面在换布景,不是 AI 在办事。
|
||||
*
|
||||
* 现在:一开工就渲染与真消息**同形**的气泡(头像 + 署名行 + 内容),
|
||||
* 内容是办事条。第一句话到了,它接在同一条流里,位置不动、形状不变。
|
||||
* 真正的空态只留给「还没开工也没消息」那一种。
|
||||
*/}
|
||||
{working ? (
|
||||
<div className="jc-chat-flow" style={{ padding: '10px 14px 0' }}>
|
||||
<WorkingStrip />
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
ico="messages-square"
|
||||
tone="brand"
|
||||
title="还没有消息"
|
||||
desc="AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="copilot-composer session-composer">
|
||||
<div className="composer-row">
|
||||
@@ -83,13 +126,17 @@ export function TaskChat({ detail, onLocate }: { detail: TaskDetail; onLocate: (
|
||||
|
||||
return (
|
||||
<ChatStream
|
||||
{...(orderValues === undefined ? {} : { orderValues })}
|
||||
chat={detail.chat}
|
||||
orders={detail.workOrders}
|
||||
thinking={say.isPending}
|
||||
working={working}
|
||||
onSay={(text) => say.mutate(text)}
|
||||
onReplyCard={(reply) => replyCard.mutate(reply)}
|
||||
onReplyOrder={(reply) => replyOrder.mutate(reply)}
|
||||
onLocate={onLocate}
|
||||
onOpenComms={onOpenComms}
|
||||
onField={onField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,16 @@ function TaskCardView({
|
||||
onPick: () => void;
|
||||
}) {
|
||||
const done = task.terminal !== undefined;
|
||||
const pct = done ? 100 : Math.round((task.position / task.total) * 100);
|
||||
/*
|
||||
* 进度条读**活动自己的阶段**,不读工作流序位。
|
||||
*
|
||||
* 序位答的是「这单在整条流的第几个活动」——A1 在七节点流里恒 1/7,
|
||||
* 从起单到收口一动不动,画成进度条就是一条永远 14% 的线;
|
||||
* 单活动试跑更荒唐:没有工作流,序位恒 1/1,起单那一刻就满格。
|
||||
* 活动没声明阶段就**不画** —— 编一个假的比不画更坏。
|
||||
*/
|
||||
const phase = task.phase;
|
||||
const pct = done ? 100 : phase === undefined ? undefined : Math.round((phase.index / phase.total) * 100);
|
||||
return (
|
||||
<button
|
||||
className={'task-card jc-tc jc-tc3' + (selected ? ' selected' : '')}
|
||||
@@ -155,13 +164,22 @@ function TaskCardView({
|
||||
<span className="jc-tc2-sub jc-clamp2">{task.workflowSubject}</span>
|
||||
<span className="jc-tc3-meta">
|
||||
<code className="jc-mono">{task.id.slice(0, 8)}</code>
|
||||
<b className="jc-mono" title="它在工作流上的序位">
|
||||
{task.position}/{task.total}
|
||||
</b>
|
||||
</span>
|
||||
<span className="jc-tc-bar">
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
{/* 阶段名 + x/N:有阶段就报阶段(人问的是「办到哪了」),没有就退回工作流序位 */}
|
||||
{phase === undefined ? (
|
||||
<b className="jc-mono" title="它在工作流上的序位">
|
||||
{task.position}/{task.total}
|
||||
</b>
|
||||
) : (
|
||||
<b className="jc-mono" title={`阶段:${phase.name}`}>
|
||||
{phase.name} {phase.index}/{phase.total}
|
||||
</b>
|
||||
)}
|
||||
</span>
|
||||
{pct !== undefined && (
|
||||
<span className="jc-tc-bar">
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,14 +11,19 @@
|
||||
* 不写「这块还没做」—— 后者是开发视角,用户不关心,也看不懂。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { FileDoc, TaskDetail, WorkOrderReply } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { markCommReadRoute, saveTableRoute } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import type { FileDoc, TaskDetail, WorkOrder, WorkOrderReply } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL, isRejection } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
||||
import { ViewBlocks } from '@ui';
|
||||
import { WorkOrderReport } from './work-order-report.tsx';
|
||||
import { ViewBlocks, ViewContainer } from '@ui';
|
||||
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';
|
||||
|
||||
|
||||
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
|
||||
@@ -27,24 +32,136 @@ export type ViewRegionProps = {
|
||||
readonly detail: TaskDetail;
|
||||
/** 工单回传 —— 由壳层接路由,视图区只管把人填的东西交出去 */
|
||||
onReplyOrder: (reply: WorkOrderReply) => void;
|
||||
/**
|
||||
* 填报值由**壳层持有** —— 不是这一份的局部状态。
|
||||
*
|
||||
* PRD 的口径:验收工单「AI 已填好、可编辑」,而**裁决在对话区的验收单上**。
|
||||
* 编辑与裁决是同一件事的两半,却分在两个区里做 —— 缓冲只活在工作区里,
|
||||
* 人在报告上改的值就到不了裁决那一刻:验收回传的 `values` 恒空,修订永远落不下来。
|
||||
*/
|
||||
readonly values: Readonly<Record<string, string>>;
|
||||
onField: (key: string, value: string) => void;
|
||||
/**
|
||||
* 对话区点了「打开工单」→ 视图区跳转定位(PRD:locate)。
|
||||
* `seq` 每点一次递增 —— 同一张卡点两次也要再跳一次(人就是想回到它)。
|
||||
* 待办的跳「待处理」并翻到那一页;办结的跳「工作成果」并打开那份定稿报告。
|
||||
*/
|
||||
/*
|
||||
* `orderId` 跳工单;`tab` 直接跳页签 —— 起单那句系统开场白的「查看邮件」用后者
|
||||
* (它指的不是某张工单,是那封来件本身)。
|
||||
*/
|
||||
readonly locate?: { seq: number; orderId?: string; tab?: Tab } | null;
|
||||
};
|
||||
|
||||
|
||||
export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
|
||||
/**
|
||||
* 件上改出来的行 → 回传载荷 —— **按台账地址对上工单声明的那张表**。
|
||||
*
|
||||
* 件里那几张可改的表各挂着 `data-edit="域.对象"`;工单的 `blocks[i]` 声明了
|
||||
* `ledger`(写哪张表)与 `fields`(列在哪个位置)。这里按地址找到那一块,
|
||||
* 再**照它的 `fields` 顺序**摆格子 —— 于是回传仍是老样子 `[列名, ...行]`,
|
||||
* 服务端那道写回(`applyBoundTableEdits`)一个字都不用改。
|
||||
*
|
||||
* 按地址找而不是按位置找:件与卡是两份东西,位置对齐迟早会错开一位,
|
||||
* 而错开一位的后果是把年用量写进目标价那一列 —— 那种错没人看得出来。
|
||||
*/
|
||||
function collectEdits(edits: ViewEdits, order: WorkOrder, onField: (k: string, v: string) => void): void {
|
||||
const blocks = order.blocks ?? [];
|
||||
for (const [address, rows] of Object.entries(edits)) {
|
||||
const at = blocks.findIndex((b) => b.t === 'table' && b.ledger === address);
|
||||
const block = blocks[at];
|
||||
if (at < 0 || block === undefined || block.t !== 'table') continue;
|
||||
const fields = block.fields ?? [];
|
||||
if (fields.length === 0) continue;
|
||||
const table = [block.cols, ...rows.map((row) => fields.map((f) => row[f] ?? ''))];
|
||||
onField(`table:${at}`, JSON.stringify(table));
|
||||
}
|
||||
}
|
||||
|
||||
export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: ViewRegionProps) {
|
||||
const [view, setView] = useState<Tab>('open');
|
||||
const [page, setPage] = useState(0);
|
||||
/** 表单值是**过程状态**,只活在这一次填写里:回传成功后画面由服务端重投影 */
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
/** 工作成果:列表里点开的那份定稿报告 */
|
||||
const [doneSel, setDoneSel] = useState<string | null>(null);
|
||||
const [openFile, setOpenFile] = useState<FileDoc | undefined>(undefined);
|
||||
|
||||
const orders = detail.workOrders;
|
||||
const openList = orders.filter((o) => o.done === undefined);
|
||||
/* 未读条数 —— 页签那枚红气泡读的就是它;标记按钮按一下这个数才动 */
|
||||
const unreadN = detail.comms.filter((m) => m.unread).length;
|
||||
|
||||
/*
|
||||
* 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点,
|
||||
* 这一屏跟着重取。不在前端手写缓存(订阅层统一做,组件不自建过程状态)。
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
/**
|
||||
* 件上按下「保存」→ **落草稿层 + 让服务端按新数据重渲那一帧**。
|
||||
*
|
||||
* 不落库,人改的东西只活在浏览器里,刷新一次就没了;不重渲,由数据算出来的东西
|
||||
* (类徽 A/B/C、分布条、结论横幅那条去向)会停在上一次渲染那一刻 ——
|
||||
* 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」。
|
||||
* 落完重取任务投影,整份件跟着一起变。**工单不办结,放行门的位置没动。**
|
||||
*/
|
||||
const saveTable = useMutation({
|
||||
mutationFn: (save: ViewSave) =>
|
||||
call(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] }),
|
||||
});
|
||||
|
||||
const markRead = useMutation({
|
||||
mutationFn: (input: { commId: string; read: boolean }) =>
|
||||
call(markCommReadRoute, { params: { commId: input.commId }, body: { read: input.read } }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk'] }),
|
||||
});
|
||||
const doneList = orders.filter((o) => o.done !== undefined);
|
||||
/*
|
||||
* **字签下了,但这一单还没收尾** —— 签完到收尾之间那一小段(合并 / 发口 / 落终态)。
|
||||
* 认的是「有一张办结的验收单 + 还没有终态」:两者同时成立只有这一种局面。
|
||||
*/
|
||||
const signed =
|
||||
detail.card.terminal === undefined &&
|
||||
orders.some((o) => o.mode === 'acceptance' && o.done !== undefined && !isRejection(o.done.result));
|
||||
const pos = Math.min(page, Math.max(0, openList.length - 1));
|
||||
const cur = openList[pos];
|
||||
const doneItem = doneSel === null ? undefined : doneList.find((o) => o.id === doneSel);
|
||||
|
||||
/*
|
||||
* 对话区点击 → 跳到工单所在页签与页位(渲染期调整 state,seen 保证幂等 —— PRD 同款)。
|
||||
* 办结的跳成果详情;待办的跳待处理并翻到那一页。
|
||||
*/
|
||||
const [seenLoc, setSeenLoc] = useState(0);
|
||||
if (locate != null && locate.seq !== seenLoc) {
|
||||
setSeenLoc(locate.seq);
|
||||
if (locate.tab !== undefined) {
|
||||
setOpenFile(undefined);
|
||||
setView(locate.tab);
|
||||
setPage(0);
|
||||
setDoneSel(null);
|
||||
}
|
||||
const hit = orders.find((o) => o.id === locate.orderId);
|
||||
if (hit !== undefined) {
|
||||
setOpenFile(undefined);
|
||||
if (hit.done !== undefined) {
|
||||
setView('done');
|
||||
setDoneSel(hit.id);
|
||||
} else {
|
||||
setView('open');
|
||||
setPage(Math.max(0, openList.findIndex((o) => o.id === locate.orderId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const go = (to: Tab) => {
|
||||
setView(to);
|
||||
setPage(0);
|
||||
setDoneSel(null);
|
||||
};
|
||||
|
||||
const terminalLabel = detail.card.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[detail.card.terminal];
|
||||
@@ -114,6 +231,8 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
|
||||
onClick={() => go('comms')}
|
||||
>
|
||||
<Ico name="mail" s={14} /> 沟通记录
|
||||
{/* 未读气泡:红的,数字是**未读条数**,不是总条数 —— 它要回答「有没有我还没看的」 */}
|
||||
{unreadN > 0 && <em className="jc-unread">{unreadN}</em>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -155,6 +274,10 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
|
||||
<button type="button" className="jc-tnav-btn jc-tnav-back" onClick={() => setOpenFile(undefined)}>
|
||||
<Ico name="arrow-left" s={14} /> 返回
|
||||
</button>
|
||||
) : view === 'done' && doneItem !== undefined ? (
|
||||
<button type="button" className="jc-tnav-btn back" onClick={() => setDoneSel(null)}>
|
||||
<Ico name="chevron-left" s={14} /> 返回列表
|
||||
</button>
|
||||
) : view === 'open' ? (
|
||||
<span className="jc-tnav-pg" aria-label="翻页">
|
||||
<button
|
||||
@@ -184,7 +307,7 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
|
||||
|
||||
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */}
|
||||
<ScrollArea
|
||||
key={`${view}:${cur?.id ?? '-'}`}
|
||||
key={`${view}:${view === 'done' ? (doneSel ?? 'list') : (cur?.id ?? '-')}`}
|
||||
className="jc-sa--fill"
|
||||
vpClassName="wf-body"
|
||||
inset={-6}
|
||||
@@ -193,26 +316,88 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
|
||||
<FileViewer doc={openFile} />
|
||||
) : view === 'comms' ? (
|
||||
/*
|
||||
* 「还没接」和「暂无数据」是两回事,占位不能混:
|
||||
* 邮件与通道消息的数据在 `channel` / `event` 里确实有,但按单归档的投影还没做 ——
|
||||
* 所以在这儿等下去也不会自己冒出内容来。说「都会归到这里」就是把没接说成了空,
|
||||
* 人会一直等,或者以为坏了。如实说没接通,再说接通之后是什么样。
|
||||
* 沟通记录 = 一单往来的**原件**。进来的那一封是开案的那份料 ——
|
||||
* 人一点开这单就该看见「这单从哪来的」,不必等模型跑几拍再说。
|
||||
*
|
||||
* 附件那一行照抄基座读出来的说明:读出多少字 / 为什么读不出。
|
||||
* **摆一个「已上传 ✓」是撒谎** —— 活动读不出内容的件,人得当场知道。
|
||||
*/
|
||||
<EmptyState
|
||||
ico="mail"
|
||||
tone="muted"
|
||||
title="往来记录还没接通"
|
||||
desc="跟客户、供应商往来的邮件和消息,目前还没有按单归档到这一屏。接通后连原件一起存:谈过什么、什么时候谈的,回头都查得到。"
|
||||
/>
|
||||
detail.comms.length === 0 ? (
|
||||
<EmptyState
|
||||
ico="mail"
|
||||
tone="muted"
|
||||
title="这一单还没有往来记录"
|
||||
desc="跟客户、供应商往来的邮件和消息会按单归到这里,连原件一起存:谈过什么、什么时候谈的,回头都查得到。"
|
||||
/>
|
||||
) : (
|
||||
<div className="jc-comms">
|
||||
{detail.comms.map((m) => (
|
||||
<article key={m.id} className={'jc-comm t-' + m.direction + (m.unread ? ' unread' : '')}>
|
||||
<header className="jc-comm-h">
|
||||
<span className={'jc-comm-dir t-' + m.direction}>
|
||||
<Ico name={m.direction === 'in' ? 'corner-down-left' : 'corner-up-right'} s={12} />
|
||||
{m.direction === 'in' ? '收到' : '发出'}
|
||||
</span>
|
||||
<b className="jc-comm-sub">{m.subject}</b>
|
||||
{/* 未读点:一眼扫得出哪几封还没看 —— 与右边那个按钮说的是同一件事 */}
|
||||
{m.unread && <span className="jc-comm-new">未读</span>}
|
||||
<span className="jc-comm-at jc-mono">{atMinute(m.at)}</span>
|
||||
{/*
|
||||
标记按钮 —— **气泡计数只跟着这一下走**。
|
||||
不做「打开页签即已读」:那是替人宣布他看过了,而他可能只是路过。
|
||||
反向也留着:看了一半想留个记号,标回未读。
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
className={'jc-comm-mark' + (m.unread ? '' : ' done')}
|
||||
disabled={markRead.isPending}
|
||||
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>
|
||||
</header>
|
||||
<div className="jc-comm-meta">
|
||||
<span className="jc-mono">{m.channel}</span>
|
||||
<span>{m.direction === 'in' ? '来自' : '发往'}</span>
|
||||
<b>{m.party === '' ? '未署名' : m.party}</b>
|
||||
{m.note !== '' && <em>{m.note}</em>}
|
||||
</div>
|
||||
{m.body !== '' && <pre className="jc-comm-body">{m.body}</pre>}
|
||||
{m.attachments.length > 0 && (
|
||||
<div className="jc-comm-att">
|
||||
{m.attachments.map((a) => (
|
||||
<span key={a.name} className={'jc-att' + (a.readable === true ? '' : ' blind')}>
|
||||
<Ico name={a.readable === true ? 'file-text' : 'alert-triangle'} s={12} />
|
||||
<b>{a.name}</b>
|
||||
{a.size !== undefined && <i className="jc-mono">{(a.size / 1024).toFixed(0)} KB</i>}
|
||||
{a.note !== undefined && <em>{a.note}</em>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : view === 'wf' ? (
|
||||
<WfDetail detail={detail} openN={openList.length} doneN={orders.length - openList.length} />
|
||||
) : view === 'log' ? (
|
||||
detail.log.length > 0 ? (
|
||||
<div className="jc-log">
|
||||
{detail.log.map((line) => (
|
||||
<div key={line.id} className={'jc-log-row t-' + line.side}>
|
||||
<span className="jc-log-at jc-mono">{line.at.slice(5, 16).replace('T', ' ')}</span>
|
||||
<span className="jc-log-side">{line.sideLabel}</span>
|
||||
/*
|
||||
* **三色一档一意**(2026-08-02 用户定):负责人绿 · 智能体蓝 · 系统灰。
|
||||
* 从前按归侧上色,而归侧有五种 —— 那是**度量**的分法
|
||||
* (自主率要把「等外面」与「机器干的」分开);人扫这一屏只问
|
||||
* 「这一步是我办的、AI 办的,还是流程自己走到的」。
|
||||
*/
|
||||
<div key={line.id} className={'jc-log-row t-' + line.who}>
|
||||
<span className="jc-log-at jc-mono">{atMinute(line.at)}</span>
|
||||
{/* 章摆的是**动作**(四字,与对话区同词),不是归侧 ——
|
||||
归侧仍在 `t-${side}` 的配色里,度量也照旧靠它 */}
|
||||
<span className="jc-log-side">{line.action}</span>
|
||||
<span className="jc-log-t">
|
||||
<b>{line.title}</b>
|
||||
{line.summary !== undefined && <em>{line.summary}</em>}
|
||||
@@ -229,35 +414,185 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
|
||||
/>
|
||||
)
|
||||
) : view === 'done' ? (
|
||||
<EmptyState
|
||||
ico="archive"
|
||||
tone="brand"
|
||||
title="还没有成果"
|
||||
desc="这一单产出的报告与文件会归档在这里,一行一份、点开整篇看。"
|
||||
/>
|
||||
doneItem !== undefined ? (
|
||||
/*
|
||||
* 定稿归档 = **同一份件,不加编辑态**(§9.3 第 2 条:在办与归档同一张模版)。
|
||||
* 与待处理那一屏一样,这里也只放件本身 —— 判据留在对话区那张办结卡上,
|
||||
* 归档之后「凭什么算过」比当时更常被问起,所以它跟着卡走,不跟着件走。
|
||||
*/
|
||||
doneItem.mode === 'acceptance' && detail.viewHtml !== undefined ? (
|
||||
<ViewContainer html={detail.viewHtml} title={`${detail.card.activity} · 定稿报告`} />
|
||||
) : (
|
||||
<article className="jc-mst-doc">
|
||||
<WorkOrderReport
|
||||
order={doneItem}
|
||||
detail={detail}
|
||||
typeLine={doneItem.mode === 'acceptance' ? '验收工单 · 定稿归档' : '执行工单 · 定稿归档'}
|
||||
blocks={doneItem.blocks !== undefined && doneItem.blocks.length > 0 ? doneItem.blocks : detail.view}
|
||||
values={{}}
|
||||
onField={() => undefined}
|
||||
onReply={() => undefined}
|
||||
/>
|
||||
</article>
|
||||
)
|
||||
) : doneList.length > 0 ? (
|
||||
/* 一行一份成果报告,点进去看整篇(PRD:jc-dvs 列表) */
|
||||
<div className="jc-dvs">
|
||||
{doneList.map((o) => {
|
||||
const res = o.done?.result;
|
||||
const bad = res !== undefined && isRejection(res);
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
/*
|
||||
* **执行与验收两种颜色**(2026-08-02 用户定):一摞成果里
|
||||
* 「我办过的那张单」与「我签过字的那一关」是两类东西 ——
|
||||
* 同色排下来,人得逐行读标题才分得出。退回的仍压过颜色走警示色。
|
||||
*/
|
||||
className={
|
||||
'jc-dv jc-dv-row ' + (bad ? 's-bad' : 's-ok') + (o.mode === 'acceptance' ? ' m-acc' : ' m-exe')
|
||||
}
|
||||
onClick={() => setDoneSel(o.id)}
|
||||
>
|
||||
<span className="jc-dv-h">
|
||||
<span className="jc-dv-ico">
|
||||
<Ico name={o.mode === 'acceptance' ? 'shield-check' : 'file-text'} s={16} />
|
||||
</span>
|
||||
<span className="jc-dv-t">
|
||||
<em>
|
||||
派发 {atMinute(o.at)}
|
||||
{o.done !== undefined ? ` · 办结 ${atMinute(o.done.at)}` : ''}
|
||||
</em>
|
||||
<b>{o.title}</b>
|
||||
</span>
|
||||
<span className="jc-dv-st">
|
||||
{bad ? '已退回' : o.mode === 'acceptance' ? '验收通过' : '已办结'}
|
||||
</span>
|
||||
<Ico name="chevron-right" s={15} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
ico="archive"
|
||||
tone="brand"
|
||||
title="还没有成果报告"
|
||||
desc="办结一件工单,它就会定稿成一份可回看的报告,归档在这里 —— 一行一份、点开整篇看。"
|
||||
hint="先去「待处理」把手上的事办完"
|
||||
/>
|
||||
)
|
||||
) : cur !== undefined ? (
|
||||
/*
|
||||
有工单就把工单摆出来 —— 角标说有 1 件、正文说「手上没有待办」,那是占位在说谎。
|
||||
办这张单要用的上下文(活动推的那一屏、依据、表单)由 WorkOrderReport 一并铺开。
|
||||
*/
|
||||
<WorkOrderReport
|
||||
order={cur}
|
||||
detail={detail}
|
||||
typeLine={cur.mode === 'acceptance' ? '验收工单 · 终局唯放行' : '执行工单 · 作业指导书 + 表单'}
|
||||
blocks={cur.mode === 'execution' ? (cur.blocks ?? detail.view) : detail.view}
|
||||
{...(detail.viewHtml === undefined ? {} : { html: detail.viewHtml, srcTitle: detail.card.activity })}
|
||||
values={values}
|
||||
onField={(k, v) => setValues((old) => ({ ...old, [k]: v }))}
|
||||
onReply={onReplyOrder}
|
||||
/>
|
||||
* **工作区只放那份件本身**(2026-08-01 用户定)。
|
||||
*
|
||||
* 从前这里是 `WorkOrderReport`:它自带题头、编号带、依据带、判据表、
|
||||
* 章节头,再把活动推的那一屏**包在自己里面** —— 于是人看到的是
|
||||
* 「一份报告里嵌着另一份报告」,两套版面语言叠在一起。用户原话:
|
||||
* 「你用你自己的报告格式嵌套了一层我要的报告格式」。
|
||||
*
|
||||
* 现在分家:件归工作区(整篇铺开、可就地改),
|
||||
* **判据与作业指导书跟着「要你办的这件事」走,落在对话区的卡上**;
|
||||
* 提交那一下也在那儿 —— 编辑与提交分在两个区,缓冲由壳层持有(values)。
|
||||
*
|
||||
* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:
|
||||
* 那时工作区总不能是白的。
|
||||
*/
|
||||
detail.viewHtml !== undefined ? (
|
||||
<ViewContainer
|
||||
html={detail.viewHtml}
|
||||
title={`${detail.card.activity} · 汇报报告`}
|
||||
editable
|
||||
onEdits={(save) => {
|
||||
/*
|
||||
* **落库按地址走** —— 件自己说了它是哪张表(`data-edit="域.对象"`),
|
||||
* 原样送上去即可。从前借道当下那张卡的 `blocks[i]` 收列名,于是
|
||||
* 卡上没有那一块时(熔断卡就没有)**保存一格都送不出去**,
|
||||
* 而屏上照旧有闸、有编辑、有保存 —— 界面给了个落不了地的口子。
|
||||
*
|
||||
* 缓冲那一份仍按卡的形状收(`table:N`):卡上那张表由提交那一下带走,
|
||||
* 与件上这一张是两条路,卡没有对应的块时它自然收不到东西,不影响落库。
|
||||
*/
|
||||
saveTable.mutate(save);
|
||||
collectEdits(save.tables, cur, onField);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<article className="jc-mst-doc">
|
||||
<WorkOrderReport
|
||||
order={cur}
|
||||
detail={detail}
|
||||
typeLine={cur.mode === 'acceptance' ? '验收工单 · 终局唯放行' : '执行工单 · 作业指导书 + 表单'}
|
||||
blocks={cur.blocks !== undefined && cur.blocks.length > 0 ? cur.blocks : detail.view}
|
||||
values={values}
|
||||
onField={onField}
|
||||
onReply={onReplyOrder}
|
||||
/>
|
||||
{cur.mode === 'execution' && <GuideCard order={cur} />}
|
||||
</article>
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
ico="list-checks"
|
||||
tone="brand"
|
||||
title="手上没有待办"
|
||||
desc="需要你执行或验收的事会送到这里,带着办这件事要用的全部上下文 —— 不用你去别处找。"
|
||||
hint="AI 正在往下推进,轮到你时会第一时间出现"
|
||||
/>
|
||||
/*
|
||||
* 空待处理**四态分明** —— 状态如实,不许一句「没有待办」把四种局面糊成一种:
|
||||
* 推进中 = AI 在自己转(动画场景);等外部 = 环停着听回音;
|
||||
* 都办完了 = 你的活清零、单还在走;已收尾 = 一切定稿。
|
||||
*/
|
||||
detail.card.terminal !== undefined ? (
|
||||
<EmptyState
|
||||
ico="circle-check"
|
||||
tone="ok"
|
||||
title="这单已收尾"
|
||||
desc="所有工单都定稿归档了。要回看哪一份,去「工作成果」一行一份点开整篇。"
|
||||
/>
|
||||
) : signed ? (
|
||||
/*
|
||||
* **签完到收尾之间那一小段**(2026-08-02 用户:「点完验收按钮后,
|
||||
* 状态变化时交互上会闪一下中间态,视觉效果不好」)。
|
||||
*
|
||||
* 那一闪不是渲染毛病,是**真的中间态**:字签下去了,合并、发口、收尾还在跑。
|
||||
* 从前这一段落在通用四态里 —— 待办清零 → 「都办完了」闪一下 → 再跳「已收尾」。
|
||||
* 「都办完了」这句话在那一刻是对的,可它说的是**另一件事**(你手上没活了),
|
||||
* 于是读起来像跳了一帧。
|
||||
*
|
||||
* 现在这一段有自己的话:**字已签下,正在落地**。它不是为了盖住中间态 ——
|
||||
* 中间态本来就该说出来,只是要说对。
|
||||
*/
|
||||
<EmptyState
|
||||
ico="hourglass"
|
||||
tone="brand"
|
||||
title="字已签下,正在落地"
|
||||
desc="成果正在并进正式表、按去向发往下游。落完这一屏会换成收尾结果 —— 不用刷新。"
|
||||
hint="通常几秒钟"
|
||||
/>
|
||||
) : detail.card.runtime === 'running' ? (
|
||||
<WorkingScene doneN={doneList.length} />
|
||||
) : detail.card.runtime === 'awaiting-event' ? (
|
||||
<EmptyState
|
||||
ico="hourglass"
|
||||
tone="brand"
|
||||
title="AI 在等一个外部回音"
|
||||
desc="等的可能是一封回信、一批数据、或一个时点。回音一到它就接着推进;需要你出手时,工单会带着完整上下文出现在这里。"
|
||||
hint="不用盯着 —— 轮到你时左侧列表会亮角标"
|
||||
/>
|
||||
) : doneList.length > 0 ? (
|
||||
<EmptyState
|
||||
ico="circle-check"
|
||||
tone="ok"
|
||||
title="都办完了"
|
||||
desc="手上的工单清零。AI 接着往下推进,需要你执行或验收的,会第一时间送到这里。"
|
||||
hint="工作成果里可以回看每一份定稿报告"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
ico="list-checks"
|
||||
tone="brand"
|
||||
title="手上没有待办"
|
||||
desc="需要你执行或验收的事会送到这里,带着办这件事要用的全部上下文 —— 不用你去别处找。"
|
||||
hint="轮到你时会第一时间出现"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</ScrollArea>
|
||||
</section>
|
||||
@@ -265,6 +600,41 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ═════════════════ AI 推进中 · 空工作区的工作场景 ═════════════════ */
|
||||
|
||||
/**
|
||||
* AI 正在办这单、还没轮到人 —— 空待处理不是「没有东西」,是**有人在干活**。
|
||||
* 涟漪说「环在转」,微光工作线说「报告正在一行行写出来」;
|
||||
* 都是纯 CSS、只动 transform/opacity,prefers-reduced-motion 下静止但状态仍可读。
|
||||
*/
|
||||
function WorkingScene({ doneN }: { doneN: number }) {
|
||||
return (
|
||||
<div className="jc-working" aria-live="polite">
|
||||
<span className="jc-working-art" aria-hidden>
|
||||
<i className="rp" />
|
||||
<i className="rp rp2" />
|
||||
<span className="jc-working-ico">
|
||||
<Ico name="bot" s={26} />
|
||||
</span>
|
||||
</span>
|
||||
<b>AI 正在推进这单</b>
|
||||
<p>
|
||||
看材料、写台账、调工具都由它来;需要你执行或验收的,工单会带着完整上下文出现在这里。
|
||||
{doneN > 0 ? `此前你办结的 ${doneN} 件都在「工作成果」里。` : ''}
|
||||
</p>
|
||||
<span className="jc-working-lines" aria-hidden>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<span className="jc-working-hint">
|
||||
<i className="jc-pulse-dot" />
|
||||
实时盯着 · 有进展这里立即更新
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═════════════════ 任务详情:KPI 带 + 流程链 + 上下文档案 ═════════════════ */
|
||||
|
||||
function WSec({ ico, label, aside }: { ico: string; label: string; aside?: React.ReactNode }) {
|
||||
@@ -386,7 +756,7 @@ function WfDetail({ detail, openN, doneN }: { detail: TaskDetail; openN: number;
|
||||
<em>编号</em>
|
||||
<p className="jc-mono">
|
||||
{detail.card.id}
|
||||
<span className="jc-mono">起于 {detail.card.startedAt.slice(0, 16).replace('T', ' ')}</span>
|
||||
<span className="jc-mono">起于 {atFull(detail.card.startedAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,46 +7,110 @@
|
||||
* 「对话流是**一切回环输入的唯一入口**」(OS 4.1);输入侧不参与控制权转交(AM 07)。
|
||||
* AI 的内容只有两种形态、不允许中间态:
|
||||
* · 纯文本气泡 —— 结论 / 说明 / 回执
|
||||
* · 完整表单卡 —— 工单卡 / 验收单 / 决策单 / 补全单 / 评价单
|
||||
* · 完整表单卡 —— 工单卡 / 验收单 / 决策单 / 补全单 / 录入单 / 评价单
|
||||
* 六张卡共用一套外壳 `jc-fc`(头 = 图标 + 类型名 + 计数,身 = `jc-fc-b`)——
|
||||
* 谁自造一套骨架,谁在对话流里就显得是外来的。
|
||||
* 一条消息 = 署名行(AI · 时间 · 类型章)+ 一或两个**纯**块。
|
||||
* ⛔ 补全 / 决策卡吐出期间 composer 锁定 —— 先办卡,再说话。
|
||||
*
|
||||
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
|
||||
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 { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
|
||||
import { atClock } from '@web-shared/at.ts';
|
||||
|
||||
/** 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径) */
|
||||
/**
|
||||
* 类型章:结论 = 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' },
|
||||
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) => iso.slice(11, 16);
|
||||
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;
|
||||
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, onSay, onReplyCard, onReplyOrder, onLocate }: TaskChatProps) {
|
||||
export function ChatStream({
|
||||
chat,
|
||||
orders,
|
||||
thinking,
|
||||
working = 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]);
|
||||
}, [chat.length, thinking, working]);
|
||||
|
||||
// ⛔ 补全 / 决策卡未办结期间不能对话 —— 锁的是**卡**,不是工单
|
||||
const blocked = chat.some(
|
||||
@@ -80,9 +144,12 @@ export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReply
|
||||
onReplyCard={onReplyCard}
|
||||
onReplyOrder={onReplyOrder}
|
||||
onLocate={onLocate}
|
||||
onOpenComms={onOpenComms}
|
||||
onField={onField}
|
||||
{...(orderValues === undefined ? {} : { orderValues })}
|
||||
/>
|
||||
))}
|
||||
{thinking && (
|
||||
{thinking ? (
|
||||
<article className="jc-ag">
|
||||
<ChatAvatar who="ai" />
|
||||
<div className="jc-ag-col">
|
||||
@@ -94,7 +161,9 @@ export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReply
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
) : working ? (
|
||||
<WorkingStrip />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="copilot-composer session-composer">
|
||||
@@ -126,9 +195,51 @@ export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReply
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, children }: { meta: string; tag?: AiTag | undefined; children: React.ReactNode }) {
|
||||
const t = tag === undefined ? undefined : TAG_UI[tag];
|
||||
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" />
|
||||
@@ -149,12 +260,19 @@ function Msg({
|
||||
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;
|
||||
}) {
|
||||
// 人的消息:右侧品牌气泡(纯文本)
|
||||
if (m.k === 'human') {
|
||||
@@ -181,10 +299,20 @@ function Msg({
|
||||
<div className="jc-ag-col">
|
||||
<div className="jc-msg-meta">
|
||||
系统 · {hm(m.at)}
|
||||
<b className="jc-mtag m-sys">{EVENT_TYPE_LABEL[m.type]}事件</b>
|
||||
<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>
|
||||
@@ -196,12 +324,26 @@ function Msg({
|
||||
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}>
|
||||
<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} />
|
||||
<AcceptForm
|
||||
order={order}
|
||||
onReplyOrder={onReplyOrder}
|
||||
onLocate={onLocate}
|
||||
{...(orderValues === undefined ? {} : { orderValues })}
|
||||
/>
|
||||
) : order.mode === 'execution' && !settled ? (
|
||||
/* 没办结的执行单 = 一张能就地办的卡:指导书 + 依据 + 表单 + 提交 */
|
||||
<ExecForm
|
||||
order={order}
|
||||
onReplyOrder={onReplyOrder}
|
||||
onLocate={onLocate}
|
||||
onField={onField}
|
||||
{...(orderValues === undefined ? {} : { orderValues })}
|
||||
/>
|
||||
) : (
|
||||
<WoCard order={order} onLocate={onLocate} />
|
||||
))}
|
||||
@@ -291,6 +433,36 @@ function Msg({
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* 数据录入卡 —— **确认那一下就是写入**(第五类人类工具)。
|
||||
* 与补全卡长得像,分量完全不同:补全卡收的是「答案」,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
|
||||
@@ -382,7 +554,7 @@ function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* 工单卡:题头 + 单体 + 打开动作 —— 派单 / 已裁决的验收都用它 */
|
||||
/* 工单卡:题头 + 单体 + 打开动作 —— 已办结的、以及没有作业指导书的都用它 */
|
||||
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
|
||||
const result = order.done?.result;
|
||||
const bad = result !== undefined && isRejection(result);
|
||||
@@ -398,10 +570,290 @@ function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string)
|
||||
</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} />
|
||||
<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 模版,可就地改),
|
||||
* 判据与指导书跟着「要你办的这件事」走,落在对话区的卡上。
|
||||
*
|
||||
* 表格那一份值不在这儿收 —— 人是在工作区的件上改的,由外层缓冲带过来
|
||||
* (与验收裁决同一条路子:编辑与提交分在两个区,缓冲得由壳层持有)。
|
||||
*/
|
||||
function ExecForm({
|
||||
order,
|
||||
onReplyOrder,
|
||||
onLocate,
|
||||
orderValues,
|
||||
onField,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
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 t-exe">
|
||||
<div className="jc-fc-h">
|
||||
<Ico name="clipboard-check" s={13} />
|
||||
<b>工单 · 执行</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="file-text" s={15} />
|
||||
</span>
|
||||
<span className="jc-fc-doc-bd">
|
||||
<b>执行工单</b>
|
||||
<em>整份清单在工作区,每一格都能就地改;改完回这里提交</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">
|
||||
<button
|
||||
className="jc-btn jc-fc-go"
|
||||
disabled={!ok}
|
||||
onClick={() =>
|
||||
onReplyOrder({
|
||||
mode: 'execution',
|
||||
workOrderId: order.id,
|
||||
values: { ...orderValues },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Ico name="check" s={13} /> {order.submitLabel ?? '提交'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,10 +865,20 @@ 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 [why, setWhy] = useState('');
|
||||
/** 选择组:恰选一口,选口即裁分支 —— 没选之前「通过」不亮 */
|
||||
@@ -439,23 +901,14 @@ function AcceptForm({
|
||||
<Ico name="file-text" s={15} />
|
||||
</span>
|
||||
<span className="jc-fc-doc-bd">
|
||||
<em>成果报告 · 视图区可读可编辑</em>
|
||||
<b>{order.title}</b>
|
||||
{/* 四字粗体在上、说明在下 —— 与执行卡同序,人扫两张卡时落点一致 */}
|
||||
<b>验收工单</b>
|
||||
<em>{order.title} · 成果报告在工作区,可读可就地改</em>
|
||||
</span>
|
||||
<Ico name="chevron-right" s={14} />
|
||||
</button>
|
||||
<ul className="jc-rp-crit sm">
|
||||
{order.criteria.map((c, i) => (
|
||||
<li key={i} className={c.pass ? 'ok' : 'no'}>
|
||||
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
|
||||
<span className="t">{c.label}</span>
|
||||
<span className="r">
|
||||
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
|
||||
{c.pass ? '达标' : '未达标'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* 与办结卡同一份 —— 两处各写一遍,三态迟早只在一处生效 */}
|
||||
<Crit list={order.criteria} />
|
||||
{order.actions !== undefined && order.actions.length > 0 && (
|
||||
<Kv
|
||||
items={order.actions.map((a) => ({
|
||||
@@ -464,6 +917,34 @@ function AcceptForm({
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{/*
|
||||
* **算出来的去向:只告诉他,不给他选**(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">
|
||||
@@ -498,7 +979,8 @@ function AcceptForm({
|
||||
mode: 'acceptance',
|
||||
workOrderId: order.id,
|
||||
verdict: 'redo',
|
||||
values: {},
|
||||
// 驳回也把改过的值带上:人常常一边改一边发现问题,改的痕不该丢
|
||||
values: { ...(orderValues ?? {}) },
|
||||
comment: why.trim(),
|
||||
selectedPorts: {},
|
||||
})
|
||||
@@ -516,9 +998,25 @@ function AcceptForm({
|
||||
mode: 'acceptance',
|
||||
workOrderId: order.id,
|
||||
verdict: 'pass',
|
||||
values: {},
|
||||
// 恰选一口:选中的口随放行一并确认 —— 它就是这次运行的 outcome
|
||||
selectedPorts: choice === undefined || port === undefined ? {} : { [choice.group]: port },
|
||||
// 人在报告上改过的值随放行落地;一个字没改就是空的(= 全盘采信 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 },
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -561,11 +1059,18 @@ function FillForm({
|
||||
onDone,
|
||||
onCant,
|
||||
}: {
|
||||
fields: ReadonlyArray<{ key: string; label: string; placeholder?: string | undefined }>;
|
||||
fields: ReadonlyArray<{ key: string; label: string; placeholder?: string | undefined; value?: string | undefined }>;
|
||||
onDone: (values: Record<string, string>) => void;
|
||||
onCant: (reason: string) => void;
|
||||
}) {
|
||||
const [vals, setVals] = useState<Record<string, string>>({});
|
||||
/*
|
||||
* **以预填值开局** —— AI 从来件里读得出的(公司名、联系人姓名)已经填在格子里,
|
||||
* 人核对一眼、补上它填不出的那个即可。
|
||||
* 用惰性初值而不是 `useEffect` 回填:回填会在人已经开始打字之后把他敲的字冲掉。
|
||||
*/
|
||||
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() !== '');
|
||||
@@ -666,3 +1171,130 @@ function RateForm({ onDone }: { onDone: (score: number, note: string) => void })
|
||||
</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 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>}
|
||||
<button className="jc-btn" disabled={blank.length > 0} onClick={() => onDone(vals)}>
|
||||
<Ico name="database" s={13} /> 确认写入
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
*
|
||||
* 自原型 `_components/ViewRegion.tsx` 的 `Report` 与卡组二平移,DOM 与类名逐字照搬。
|
||||
*
|
||||
* **当前未挂载**:工作区正处在「一步一步描述、一步一步接」的阶段,待处理页签暂为空占位。
|
||||
* 这一份留在这里等着接回来 —— 描述到工单该长什么样时,直接用或按新口径改,不必重写。
|
||||
* 两种工单同一张报告,差别只在**表单的来路与裁决的落点**:
|
||||
* · 执行 —— 空占位表单(值只来自人填),报告页就地提交;
|
||||
* · 验收 —— AI 已填好、可编辑,**裁决在对话区的验收单上**(PRD 口径)。
|
||||
* 所以编辑缓冲不能只活在这一份里 —— 它由上层持有,对话区裁决时一并带走。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { FormField, TaskDetail, ViewBlock, WorkOrder, WorkOrderReply } from '@contracts';
|
||||
import { ACTION_EFFECT_LABEL, WORK_ORDER_RESULT_LABEL, isRejection } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ViewBlocks, ViewContainer } from '@ui';
|
||||
import { atMinute } from '@web-shared/at.ts';
|
||||
|
||||
function Part({ label, aside }: { label: string; aside?: string | undefined }) {
|
||||
return (
|
||||
@@ -69,12 +72,52 @@ export function WorkOrderReport({
|
||||
onField: (key: string, value: string) => void;
|
||||
onReply: (reply: WorkOrderReply) => void;
|
||||
}) {
|
||||
/*
|
||||
* 验收单默认只读(要按「修改」才进编辑);**执行工单上那张可填的表就是表单本身** ——
|
||||
* 它没有「修改」按钮,人打开就该能填。默认只读会让这条路直接走不通:
|
||||
* 活动派了一张标着 editable 的需求表,人看到的是一张改不动的表(实测)。
|
||||
*/
|
||||
/** 人在可编辑表上改出来的行 —— 按块序号存,提交时并进回传载荷 */
|
||||
const [tableEdits, setTableEdits] = useState<Readonly<Record<number, readonly (readonly string[])[]>>>({});
|
||||
const [editing, setEditing] = useState(false);
|
||||
/*
|
||||
* **换一张工单要重算编辑态** —— `useState` 的初值只在首次挂载时算一次。
|
||||
*
|
||||
* 从前这里把「执行工单带可编辑表 → 默认进编辑态」写进 useState 的初值:
|
||||
* 首次挂载那张卡若不是这种,`editing` 就此钉死为 false,之后翻页 / 换任务
|
||||
* 组件并不重新挂载,于是那张明明标了 `editable: true` 的表**永远渲染成只读**。
|
||||
* 实测:A1 那一单好的,同一个界面切到 A2 就一个输入框都没有,而载荷里两张表都是 editable。
|
||||
*
|
||||
* 渲染期对齐(与本仓 §9.2「前端零过程状态」同款,不用 effect):
|
||||
* 记住这一张是谁,换了就按它自己的形态重算。
|
||||
*/
|
||||
const [seenOrder, setSeenOrder] = useState<string | undefined>(undefined);
|
||||
if (seenOrder !== order.id) {
|
||||
setSeenOrder(order.id);
|
||||
setTableEdits({});
|
||||
/*
|
||||
* 可编辑与**是不是验收单无关**(2026-08-01 去掉 execution 那道限制)。
|
||||
*
|
||||
* 验收是这一单最重的一道门:人得能在**成果本身**上改,而不只是在旁边几个字段上改。
|
||||
* 放行卡上那张绑了台账的成果表就是给他改的 —— 改完由基座写回草稿层、随放行合并。
|
||||
* 从前写死只有执行单才进编辑态,于是验收页上那张表摆着却动不了:
|
||||
* 「可修订报告」四个字成了摆设。
|
||||
*/
|
||||
setEditing((order.blocks ?? []).some((b) => b.t === 'table' && b.editable === true));
|
||||
}
|
||||
const isAcc = order.mode === 'acceptance';
|
||||
const disabled = order.done !== undefined;
|
||||
const fields = isAcc ? [] : order.fields;
|
||||
/** 执行 = 空占位表单:值只来自人填(或办结留痕),不吃 AI 拟值 */
|
||||
const val = (f: FormField) => values[f.key] ?? order.done?.values?.[f.key] ?? '';
|
||||
/*
|
||||
* **两种工单都有填报表单,只是来路不同**:
|
||||
* · 执行 = 空占位表单,值只来自人填(AI 不替人办事);
|
||||
* · 验收 = **AI 已填好、可编辑**(PRD:一张工单 → 卡组一「HTML 报告 · AI 已填好、可编辑」)。
|
||||
*
|
||||
* 从前这里写的是 `isAcc ? [] : order.fields` —— 验收工单的 fields 被整个丢掉:
|
||||
* 「修改」按钮点了没有任何东西可改,人工修订也就永远落不下来(第 12 章③ 的一半)。
|
||||
*/
|
||||
const fields = order.fields;
|
||||
/** 值的三层来路:人这次改的 > 办结留痕 > **AI 拟值**(只有验收单带) */
|
||||
const val = (f: FormField) => values[f.key] ?? order.done?.values?.[f.key] ?? f.ai ?? '';
|
||||
const ok = fields.every((f) => f.required !== true || val(f).trim() !== '');
|
||||
const result = order.done?.result;
|
||||
const rejected = result !== undefined && isRejection(result);
|
||||
@@ -125,12 +168,12 @@ export function WorkOrderReport({
|
||||
</span>
|
||||
<span>
|
||||
<i>派发</i>
|
||||
<b className="jc-mono">{order.at.slice(5, 16).replace('T', ' ')}</b>
|
||||
<b className="jc-mono">{atMinute(order.at)}</b>
|
||||
</span>
|
||||
{order.done !== undefined && (
|
||||
<span>
|
||||
<i>办结</i>
|
||||
<b className="jc-mono">{order.done.at.slice(5, 16).replace('T', ' ')}</b>
|
||||
<b className="jc-mono">{atMinute(order.done.at)}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -159,17 +202,27 @@ export function WorkOrderReport({
|
||||
{isAcc && order.criteria.length > 0 && (
|
||||
<>
|
||||
<Part label="验收判据" aside={`${order.criteria.filter((c) => c.pass).length}/${order.criteria.length} 达标`} />
|
||||
{/*
|
||||
裁量项三态与对话区那份同一套口径:裁成不满足 = AI 办过、办不到、交你定,
|
||||
不是「活没干完」。两处措辞若分叉,同一条判据在两屏上会给出两种印象。
|
||||
*/}
|
||||
<ul className="jc-rp-crit">
|
||||
{order.criteria.map((c, i) => (
|
||||
<li key={i} className={c.pass ? 'ok' : 'no'}>
|
||||
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
|
||||
<span className="t">{c.label}</span>
|
||||
<span className="r">
|
||||
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
|
||||
{c.pass ? '达标' : '未达标'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{order.criteria.map((c, i) => {
|
||||
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={13} />
|
||||
{c.pass ? '达标' : judged ? 'AI 判为不满足 · 交你定' : '未达标'}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
@@ -201,7 +254,20 @@ export function WorkOrderReport({
|
||||
{blocks.length > 0 && (
|
||||
<div>
|
||||
<Part label={html === undefined ? (isAcc ? '成果内容' : '数据与分析') : '结构化明细'} aside={srcTitle} />
|
||||
<ViewBlocks blocks={blocks} editing={isAcc && editing && !disabled} />
|
||||
{/*
|
||||
* 可编辑与**是不是验收单无关** —— 它是块自己说了算的事(`editable: true`)。
|
||||
*
|
||||
* 从前这里写死 `isAcc &&`:于是执行工单上那张标了 editable 的表**永远只读**。
|
||||
* A1 的「我去问客户」那条路正卡在这儿:活动派了一张可填的需求表,
|
||||
* 人打开看到的却是一张改不动的表 —— 他问到了值也没地方填,这条路走不通。
|
||||
* 验收单的判据表照旧只在 `editing` 时可改,那由块的 `editable` 与这里的
|
||||
* `editing` 共同决定,不需要再拿单子的种类兜一层。
|
||||
*/}
|
||||
<ViewBlocks
|
||||
blocks={blocks}
|
||||
editing={editing && !disabled}
|
||||
onTableChange={(i, rows) => setTableEdits((prev) => ({ ...prev, [i]: rows }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -244,7 +310,7 @@ export function WorkOrderReport({
|
||||
</b>
|
||||
<span>
|
||||
{disabled
|
||||
? `已于 ${(order.done?.at ?? order.at).slice(5, 16).replace('T', ' ')} 办结 · 已记入工作日志`
|
||||
? `已于 ${atMinute(order.done?.at ?? order.at)} 办结 · 已记入工作日志`
|
||||
: isAcc
|
||||
? editing
|
||||
? '改完记得保存 —— 裁决在对话区的验收单上'
|
||||
@@ -274,7 +340,23 @@ export function WorkOrderReport({
|
||||
onReply({
|
||||
mode: 'execution',
|
||||
workOrderId: order.id,
|
||||
values: Object.fromEntries(fields.map((f) => [f.key, val(f)])),
|
||||
/*
|
||||
* 回传 = 表单字段 + **人在可编辑表上改的行**。
|
||||
*
|
||||
* 表格那一份从前根本没交出去(只活在 ViewBlocks 的局部 state 里):
|
||||
* 人填满 13 个格子提交,Agent 收到的是空的,于是又发一张同样的单 ——
|
||||
* 人以为自己白填了一遍。表按块序号编键(`table:0`),值是整张表的行,
|
||||
* Agent 拿它对着自己派出去的那张表逐行读回来。
|
||||
*/
|
||||
values: {
|
||||
...Object.fromEntries(fields.map((f) => [f.key, val(f)])),
|
||||
...Object.fromEntries(
|
||||
Object.entries(tableEdits).map(([i, rows]) => [
|
||||
`table:${i}`,
|
||||
JSON.stringify([blocks[Number(i)]?.t === 'table' ? (blocks[Number(i)] as { cols: string[] }).cols : [], ...rows]),
|
||||
]),
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
@@ -40,6 +40,7 @@ type InstallReply = {
|
||||
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';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
@@ -378,7 +379,7 @@ export function ActPage({ id }: { id: string }) {
|
||||
<div key={p.version}>
|
||||
<em>v{p.version}</em>
|
||||
<b className="mono">
|
||||
{p.state} · {p.installedAt.slice(0, 16).replace('T', ' ')}
|
||||
{p.state} · {atFull(p.installedAt)}
|
||||
</b>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { Card, Chip, Clear, Entry, 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';
|
||||
|
||||
@@ -24,40 +25,100 @@ export type HomeProps = { tab: string; nav: Nav };
|
||||
* 只呈现**结构**:域分组 → 对象 → 列与主键。
|
||||
* 行数与行浏览入口不在这里 —— 那在运营中心的台账。
|
||||
*/
|
||||
/**
|
||||
* 三类的呈现次序与说辞 —— **按「要不要管它」排**,不按字母。
|
||||
*
|
||||
* 主数据摆最前:一屏里真正需要治理的就是它(谁能改、依据是什么、多久算过期)。
|
||||
* 单据量最大但不需要「管」—— 查得到、回放得出就够了,所以摆中间。
|
||||
* 派生摆最后,并且明写「不该手写」:手写的派生值会和它的来源打架,而没人会发现。
|
||||
*
|
||||
* `unknown` 是**库里有表、却没有哪个在装的活动声明它**——多半是退役活动留下的空壳。
|
||||
* 它不是一种性质,是一种状况,所以单列一档、说清楚,不混进上面三类假装正常。
|
||||
*/
|
||||
const KIND_ORDER = ['master', 'record', 'derived', 'unknown'] as const;
|
||||
type KindKey = (typeof KIND_ORDER)[number];
|
||||
|
||||
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: '库里有这张表,却没有哪个在装的活动声明它 —— 多半是退役活动留下的空壳。台账「只增不破坏」,清空壳表是一次性的人工决定。',
|
||||
},
|
||||
};
|
||||
|
||||
export function DataHome({ nav }: HomeProps) {
|
||||
const q = useGovObjects();
|
||||
const objects = q.data?.objects ?? [];
|
||||
const domains = sortDomains([...new Set(objects.map((o) => o.domain))]);
|
||||
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
{domains.map((d) => {
|
||||
const inDomain = objects.filter((o) => o.domain === d);
|
||||
{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))]);
|
||||
return (
|
||||
<Card
|
||||
key={d}
|
||||
ico="database"
|
||||
title={domainShort(d)}
|
||||
extra={
|
||||
<span className="g-mono">
|
||||
{d} · {inDomain.length} 对象
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="g-entries">
|
||||
{inDomain.map((o) => (
|
||||
<Entry
|
||||
key={o.object}
|
||||
ico="table"
|
||||
// 人读名优先;没登记 label 才退回对象键 —— 治理台不该拿 `intake_record` 给人看
|
||||
title={o.label ?? o.object}
|
||||
desc={`${o.domain}.${o.object}`}
|
||||
onClick={() => nav.push({ k: 'obj', id: `${o.domain}.${o.object}` })}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
{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} 对象
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{objects.length === 0 && (
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { ActSpec } from '@web-shared/gov/primitives.tsx';
|
||||
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 {
|
||||
useGovApprovals,
|
||||
useGovStaff,
|
||||
@@ -82,7 +83,7 @@ export function WorkflowBoard({ nav }: { nav: Nav }) {
|
||||
<button key={w.id} className={`g-wf t-${ln.tone}`} onClick={() => nav.push({ k: 'wf', id: w.id })}>
|
||||
<span className="g-wf-top">
|
||||
{w.id.slice(0, 8)}
|
||||
<span className="r">{w.startedAt.slice(5, 16).replace('T', ' ')}</span>
|
||||
<span className="r">{atMinute(w.startedAt)}</span>
|
||||
</span>
|
||||
<span className="g-wf-t">{w.subject}</span>
|
||||
<span className="g-wf-m">
|
||||
@@ -188,7 +189,7 @@ function RuntimeHome({ tab, nav }: HomeProps) {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ k: '起始', v: <span className="g-mono">{t.startedAt.slice(5, 16).replace('T', ' ')}</span> },
|
||||
{ k: '起始', v: <span className="g-mono">{atMinute(t.startedAt)}</span> },
|
||||
]}
|
||||
right={
|
||||
<St tone={st.tone} ico={st.ico}>
|
||||
@@ -437,7 +438,7 @@ export function TaskPage({ id, nav }: { id: string; nav: Nav }) {
|
||||
items={[
|
||||
{ k: '步数', v: `${task.usedSteps}/${task.capSteps}`, ratio: task.usedSteps / Math.max(task.capSteps, 1) },
|
||||
{ k: '活动', v: task.activityId },
|
||||
{ k: '起始', v: task.startedAt.slice(0, 16).replace('T', ' ') },
|
||||
{ k: '起始', v: atFull(task.startedAt) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
@@ -482,7 +483,7 @@ export function TaskLogPage({ id }: { id: string }) {
|
||||
ico="scroll-text"
|
||||
kicker={`任务日志 · ${id.slice(0, 8)}`}
|
||||
title="过程留痕"
|
||||
desc="任务记忆只追加、不修改:每条都带信封戳与归侧标注(机器产出 / 人产出 / 人裁决 / 世界等待)。"
|
||||
desc="任务记忆只追加、不修改:每条都带信封戳与归侧标注(机器产出 / 员工动作 / 人裁决 / 系统等待)。"
|
||||
/>
|
||||
<div className="g-entries">
|
||||
{lines.map((l) => (
|
||||
@@ -495,7 +496,8 @@ export function TaskLogPage({ id }: { id: string }) {
|
||||
<em>{l.at.slice(0, 19).replace('T', ' ')}</em>
|
||||
</span>
|
||||
<span className="g-entry-m">
|
||||
<Chip>{l.sideLabel}</Chip>
|
||||
{/* 与工作台同口径:章是四字动作;归侧另有度量面在看 */}
|
||||
<Chip>{l.action}</Chip>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
/**
|
||||
* 试跑**一个活动** —— 测试台的第二种试法,与试跑流程包并列。
|
||||
*
|
||||
* 为什么要单有一种:开发一个活动时,关心的是「这个活动自己跑不跑得动」——
|
||||
* 案例模型推不推得开、判据取不取得齐、台账写不写得对、验收卡长什么样。
|
||||
* 走流程包试,前面几个节点的噪音得先趟一遍,而且每改一次活动就要重跑整条流。
|
||||
*
|
||||
* 起出来的是**真任务**:照旧过装载两审、Harness 预算、案例模型推进、放行门 ——
|
||||
* 试跑不等于假跑,假跑试不出任何东西。区别只有一个 `origin='testbed'`,好随时连根拔掉。
|
||||
*
|
||||
* 视觉沿用同一台仪器的语言:**机器说 mono**(读数、灯、编号),
|
||||
* **外界伸进来的东西说无衬线**(起单入参就是外界给的那份料,所以它也是一张纸)。
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { TERMINAL_STATE_LABEL, testActivityStateRoute, testRunActivityRoute, testStopRoute, uploadFileRoute } from '@contracts';
|
||||
import type { UploadedFile } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { useGovActivities, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||
import { QueryGate } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { DraftGroups } from './drafts.tsx';
|
||||
|
||||
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
|
||||
export type ActivityState = Parsed<typeof testActivityStateRoute.output>;
|
||||
|
||||
type ActivityCard = NonNullable<ReturnType<typeof useGovActivities>['data']>['activities'][number];
|
||||
|
||||
/* ==================== 装配态 ==================== */
|
||||
|
||||
export function ActivityRun({
|
||||
taskId,
|
||||
onStarted,
|
||||
onStopped,
|
||||
strip,
|
||||
nav,
|
||||
}: {
|
||||
/** 已在跑的那一单(刷新页面也认得回来) */
|
||||
taskId?: string;
|
||||
onStarted: (id: string) => void;
|
||||
onStopped: () => void;
|
||||
strip: (input: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode }) => ReactNode;
|
||||
nav: Nav;
|
||||
}): ReactNode {
|
||||
const actQ = useGovActivities();
|
||||
const staffQ = useGovStaff();
|
||||
const staff = useMemo(() => (staffQ.data?.staff ?? []).filter((s) => s.state === 'active'), [staffQ.data]);
|
||||
const activities = useMemo(() => (actQ.data?.activities ?? []).filter((a) => a.enabled), [actQ.data]);
|
||||
|
||||
const [pickedId, setPicked] = useState('');
|
||||
const picked = activities.find((a) => a.activityId === pickedId) ?? activities[0];
|
||||
const [data, setData] = useState<Readonly<Record<string, string>>>({});
|
||||
const [ownerId, setOwner] = useState('');
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/*
|
||||
* 换活动就换一套入参 —— 上一个活动填的值留在表单里毫无意义(字段名都不一样),
|
||||
* 而且极易让人以为「我填过了」。有样例就照样例预填:包自带的样例本来就是
|
||||
* 「这个活动一单长什么样」的权威说法,让人对着空表单编,编出来的多半跑不通。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (picked === undefined) return;
|
||||
setData(sampleValues(picked, 0));
|
||||
}, [picked?.activityId]);
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: (input: { activityId: string; data: Record<string, unknown>; ownerId: string }) =>
|
||||
call<{ taskId: string; title: string }>(testRunActivityRoute, { body: input }),
|
||||
onSuccess: (r) => {
|
||||
setError(undefined);
|
||||
onStarted(r.taskId);
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] });
|
||||
},
|
||||
// 后端的判词照抄:「活动未装」「入参不合契约」「模型未绑」都是要人改配置的话,不该被改写
|
||||
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
|
||||
});
|
||||
|
||||
const stop = useMutation({
|
||||
mutationFn: (id: string) => call<{ destroyed: boolean; tasks: number }>(testStopRoute, { body: { taskId: id } }),
|
||||
onSuccess: () => {
|
||||
onStopped();
|
||||
start.reset();
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'testbed'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['desk'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (taskId !== undefined) {
|
||||
return <ActivityObserve taskId={taskId} strip={strip} stopping={stop.isPending} onStop={() => stop.mutate(taskId)} />;
|
||||
}
|
||||
|
||||
const fields = orderFields(picked?.contract.inputFields ?? [], picked?.contract.requiredInputs ?? []);
|
||||
// 必填缺一即不可起 —— 门口这道校验与后端同一口径(契约的 required)
|
||||
const missing = (picked?.contract.inputFields ?? [])
|
||||
.filter((f) => f.required && (data[f.name] ?? '').trim() === '')
|
||||
.map((f) => f.label ?? f.name);
|
||||
const fireable = picked !== undefined && missing.length === 0;
|
||||
const ai = staff.find((s) => s.name === 'AI');
|
||||
|
||||
/*
|
||||
* 起单前**按契约声明归型**:表单格子里全是文本,而契约说得清哪个字段是数组、哪个是对象
|
||||
* (`attachments: string[]`)。不归型就把 `["BOM.xlsx"]` 这串字面量当字符串送进去,
|
||||
* 活动拿到的类型与自己声明的对不上 —— 这类错一路要到 Agent 读入参时才看得出来。
|
||||
* 归不了型当场说,并且**照厂商原话说**:说「不是合法 JSON」,不含糊成「起单失败」。
|
||||
*/
|
||||
const fire = (card: ActivityCard): void => {
|
||||
const typeOf = new Map(card.contract.inputFields.map((f) => [f.name, f.type]));
|
||||
const shaped: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(data)) {
|
||||
if (raw.trim() === '') continue;
|
||||
const type = typeOf.get(key) ?? 'string';
|
||||
if (!type.endsWith('[]') && type !== 'object') {
|
||||
shaped[key] = raw;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
shaped[key] = JSON.parse(raw);
|
||||
} catch {
|
||||
setError(`${typeLabel(card, key)}要填 JSON(${type}),这串解析不了:${raw.slice(0, 40)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError(undefined);
|
||||
start.mutate({ activityId: card.activityId, data: shaped, ownerId });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{strip({
|
||||
lamp: 'idle',
|
||||
breathe: false,
|
||||
word: '装配中',
|
||||
detail: picked === undefined ? '活动库里还没有可试的活动' : `${picked.name} · 单活动仿真`,
|
||||
right: picked === undefined ? undefined : `${picked.activityId}`,
|
||||
})}
|
||||
|
||||
<QueryGate query={actQ}>
|
||||
{activities.length === 0 ? (
|
||||
<div className="tb-slot">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>活动库里还没有可试的活动</b>
|
||||
<p>装一个活动包回来,它就出现在这里 —— 试跑的是真任务,得先有个真活动。</p>
|
||||
<button className="g-btn" onClick={() => nav.jump('library', 'shelf')}>
|
||||
<Ico name="package" s={12} />
|
||||
去活动库
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activities.length > 1 && (
|
||||
<div className="tb-pkrow">
|
||||
{activities.map((a) => (
|
||||
<button
|
||||
key={a.activityId}
|
||||
className={'g-pk' + (a.activityId === picked?.activityId ? ' on' : '')}
|
||||
onClick={() => setPicked(a.activityId)}
|
||||
>
|
||||
<b>{a.name}</b>
|
||||
<span className="id">{a.activityId}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="g-rig">
|
||||
{/* ── 左:起单入参 —— 外界给的那份料,所以它也是一张纸 ── */}
|
||||
<section className="g-rig-l">
|
||||
<div className="g-rig-h">
|
||||
<span className="n">01</span>
|
||||
输入 · 起单入参
|
||||
<span className="x">
|
||||
{(picked?.contract.samples ?? []).map((s, i) => (
|
||||
<button key={s.name} className="tb-ghost" onClick={() => picked && setData(sampleValues(picked, i))}>
|
||||
<Ico name="rotate-ccw" s={10} />
|
||||
{s.name.length > 14 ? `样例 ${i + 1}` : s.name}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tb-paper">
|
||||
<div className="tb-pp-h">
|
||||
{fields.map((f) => (
|
||||
<label key={f.name} className="tb-pp-r">
|
||||
<em>
|
||||
{f.label ?? f.name}
|
||||
{/* 必填只打一枚星号 —— 「必填」二字在窄标签栏里把字段名挤到折行 */}
|
||||
{f.required && (
|
||||
<i className="req" aria-label="必填">
|
||||
*
|
||||
</i>
|
||||
)}
|
||||
</em>
|
||||
{f.format === 'file' ? (
|
||||
<FilePicker
|
||||
label={f.label ?? f.name}
|
||||
value={data[f.name] ?? ''}
|
||||
onChange={(next) => setData((prev) => ({ ...prev, [f.name]: next }))}
|
||||
onError={setError}
|
||||
/>
|
||||
) : isLong(f) ? (
|
||||
<textarea
|
||||
className="tb-pp-i"
|
||||
rows={5}
|
||||
value={data[f.name] ?? ''}
|
||||
placeholder={f.type}
|
||||
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="tb-pp-i"
|
||||
value={data[f.name] ?? ''}
|
||||
placeholder={f.type}
|
||||
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 右:派给谁 + 契约摘要 ── */}
|
||||
<section className="g-rig-r">
|
||||
<div className="g-rig-h">
|
||||
<span className="n">02</span>
|
||||
派给谁
|
||||
<span className="x">
|
||||
{ai !== undefined && (
|
||||
<button className="tb-ghost" onClick={() => setOwner(ai.id)}>
|
||||
<Ico name="bot" s={10} />
|
||||
派给 AI
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tb-asn">
|
||||
{/* solo:这一台只有一格插座,没有「活动/审批」那一列 —— 少一格,少一列 */}
|
||||
<div className={'tb-asn-r solo' + (ownerId !== '' ? ' pin' : '')}>
|
||||
<span className="rail">
|
||||
<i />
|
||||
</span>
|
||||
<span className="nid">OWNER</span>
|
||||
<span className="lb">任务负责人</span>
|
||||
<select
|
||||
className="tb-socket"
|
||||
aria-label="这一单派给谁"
|
||||
value={ownerId}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
>
|
||||
<option value="">我自己</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} · {s.position.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="g-rig-h">
|
||||
<span className="n">03</span>
|
||||
这个活动手上有什么
|
||||
</div>
|
||||
<ContractFace card={picked} />
|
||||
</section>
|
||||
|
||||
<div className="tb-fire">
|
||||
<span className="say">
|
||||
{error !== undefined ? (
|
||||
<b className="bad">{error}</b>
|
||||
) : missing.length > 0 ? (
|
||||
`还缺:${missing.join('、')}`
|
||||
) : (
|
||||
'起单会先销毁上一单;这是**真任务** —— 照旧过预算、案例模型与放行门,只是可以随时拔掉'
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
className={'tb-go' + (fireable ? ' armed' : '')}
|
||||
disabled={!fireable || start.isPending}
|
||||
onClick={() => picked && fire(picked)}
|
||||
>
|
||||
<Ico name="play" s={13} />
|
||||
{start.isPending ? '起单中…' : '起单试跑'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type InputField = {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
label?: string | undefined;
|
||||
/** `file` = 这一格收真文件(契约里的 `format`)—— 界面给文件选择器 */
|
||||
format?: string | undefined;
|
||||
};
|
||||
|
||||
/** 长文本:正文、对象、数组 —— 一行输入框装不下的东西 */
|
||||
|
||||
/* ==================== 真文件那一格 ==================== */
|
||||
|
||||
/**
|
||||
* 附件格 —— **选的是真文件**,不是一串文件名。
|
||||
*
|
||||
* 选完当场传上去,基座在入口就把它读成文本(xlsx 读成行列),回传的描述件里带着
|
||||
* `text` 与一句 `note`。这一格摆的就是那句 note:**读出来什么就说什么** ——
|
||||
* 读不出的类型如实说读不出,不摆一个「已上传 ✓」让人以为活动看得见里面。
|
||||
*
|
||||
* 值仍以 JSON 串存在表单状态里(与其它格同一形状),起单时由 `fire` 统一归型。
|
||||
*/
|
||||
function FilePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onError,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
onError: (msg: string | undefined) => void;
|
||||
}): ReactNode {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const files: UploadedFile[] = (() => {
|
||||
if (value.trim() === '') return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? (parsed as UploadedFile[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
const take = async (picked: FileList | null): Promise<void> => {
|
||||
if (picked === null || picked.length === 0) return;
|
||||
setBusy(true);
|
||||
onError(undefined);
|
||||
try {
|
||||
const added: UploadedFile[] = [];
|
||||
for (const file of Array.from(picked)) {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
let bin = '';
|
||||
for (const b of buf) bin += String.fromCharCode(b);
|
||||
added.push(
|
||||
await call<UploadedFile>(uploadFileRoute, {
|
||||
body: { name: file.name, contentType: file.type, dataBase64: btoa(bin) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
onChange(JSON.stringify([...files, ...added]));
|
||||
} catch (e: unknown) {
|
||||
// 后端的判词照抄(「超过 8MB」「解出来是 0 字节」都是要人自己处置的话)
|
||||
onError((e as { message?: string }).message ?? '上传失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="tb-files">
|
||||
{files.map((f, i) => (
|
||||
<span key={f.ref} className={'tb-file' + (f.text === undefined ? ' blind' : '')}>
|
||||
<Ico name={f.text === undefined ? 'alert-triangle' : 'file-text'} s={11} />
|
||||
<b>{f.name}</b>
|
||||
<i>{(f.size / 1024).toFixed(0)} KB · {f.note}</i>
|
||||
<button
|
||||
aria-label={`移除 ${f.name}`}
|
||||
onClick={() => onChange(JSON.stringify(files.filter((_, j) => j !== i)))}
|
||||
>
|
||||
<Ico name="x" s={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<label className="tb-file-add">
|
||||
<Ico name="paperclip" s={11} />
|
||||
{busy ? '读取中…' : `选文件${files.length === 0 ? '' : '(再加)'}`}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
aria-label={`选择${label}`}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
void take(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 报错要说人话:契约里写了中文名就用中文名 */
|
||||
function typeLabel(card: ActivityCard, name: string): string {
|
||||
const f = card.contract.inputFields.find((x) => x.name === name);
|
||||
return f?.label ?? name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 哪些格子要摊成大方框:正文、对象、对象数组。
|
||||
* **一串字符串不算**(`string[]`,比如一行附件名)—— 给它五行高的框,
|
||||
* 标签会孤零零吊在一片空白左边,而里面只有一行 `["BOM.xlsx"]`。
|
||||
*/
|
||||
function isLong(f: InputField): boolean {
|
||||
return f.name === 'body' || f.type === 'object' || f.type === 'object[]';
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单字段的顺序**不靠契约里的键序** —— 那个顺序到不了这里。
|
||||
*
|
||||
* 清单存进库是 jsonb,PG 读回时会把对象键重排(先短后长、再字典序):
|
||||
* 一份写作「客户邮箱 / 主题 / 邮件内容 / 附件」的契约,读回来变成
|
||||
* 「主题 / 客户邮箱 / 附件 / 邮件内容」—— 键序在这一层已经不可信。
|
||||
*
|
||||
* 唯一活着穿过 jsonb 的顺序是 **`required` 那个数组**(数组有序,对象无序)。
|
||||
* 所以:必填照它排 —— 契约作者写下的次序就是他要人填的次序;选填跟在后面,长正文垫底。
|
||||
*/
|
||||
function orderFields(fields: readonly InputField[], requiredOrder: readonly string[]): InputField[] {
|
||||
const rank = (f: InputField): number => {
|
||||
const i = requiredOrder.indexOf(f.name);
|
||||
return i === -1 ? requiredOrder.length : i;
|
||||
};
|
||||
return [...fields].sort((a, b) => {
|
||||
if (a.required !== b.required) return a.required ? -1 : 1;
|
||||
if (a.required) return rank(a) - rank(b);
|
||||
if (isLong(a) !== isLong(b)) return isLong(a) ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** 样例 → 表单值:一律转成字符串(表单是文本,归型交给活动契约那一侧) */
|
||||
function sampleValues(card: ActivityCard, index: number): Record<string, string> {
|
||||
const sample = card.contract.samples[index];
|
||||
if (sample === undefined) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(sample.input).map(([k, v]) => [k, typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v ?? '')]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 契约面读数 —— **起单前就该看见**这个活动手上有什么。
|
||||
* 跑不动的原因八成在这几行里:工具没装、模型没绑、台账对象没物化、预算给太紧。
|
||||
*/
|
||||
function ContractFace({ card }: { card: ActivityCard | undefined }): ReactNode {
|
||||
if (card === undefined) return null;
|
||||
const c = card.contract;
|
||||
return (
|
||||
<div className="tb-face">
|
||||
<Row label="工具" values={c.tools} empty="不调工具" />
|
||||
<Row label="模型" values={c.models} empty="不问模型" />
|
||||
<Row label="台账" values={c.objects} empty="不写台账" />
|
||||
<Row label="出口" values={c.outputPorts.map((p) => p.name)} empty="无出口" />
|
||||
<div className="tb-face-r">
|
||||
<em>预算</em>
|
||||
<span className="v mono">调人 ≤ {c.humanCallBudget}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, values, empty }: { label: string; values: readonly string[]; empty: string }): ReactNode {
|
||||
return (
|
||||
<div className="tb-face-r">
|
||||
<em>{label}</em>
|
||||
{values.length === 0 ? <span className="v none">{empty}</span> : (
|
||||
<span className="v">
|
||||
{values.map((v) => (
|
||||
<i key={v} className="chip">
|
||||
{v}
|
||||
</i>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ==================== 观测态 ==================== */
|
||||
|
||||
const RUNTIME_LAMP: Readonly<Record<string, 'live' | 'wait' | 'done' | 'bad'>> = {
|
||||
running: 'live',
|
||||
'awaiting-human': 'wait',
|
||||
'awaiting-event': 'wait',
|
||||
'landing-blocked': 'bad',
|
||||
terminal: 'done',
|
||||
};
|
||||
|
||||
/**
|
||||
* 单活动试跑的观测面 —— 一屏看穿这一单。
|
||||
*
|
||||
* 流程包那侧看 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**:
|
||||
* 每一拍干了什么、拿回了什么、被拒的理由、成果落到台账哪一行、卡问了谁什么。
|
||||
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
|
||||
*/
|
||||
function ActivityObserve({
|
||||
taskId,
|
||||
strip,
|
||||
stopping,
|
||||
onStop,
|
||||
}: {
|
||||
taskId: string;
|
||||
strip: (input: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode }) => ReactNode;
|
||||
stopping: boolean;
|
||||
onStop: () => void;
|
||||
}): ReactNode {
|
||||
const q = useQuery({
|
||||
queryKey: ['gov', 'testbed', 'activity', taskId],
|
||||
queryFn: () => call<ActivityState>(testActivityStateRoute, { params: { taskId } }),
|
||||
/*
|
||||
* 轮询是**兜底**,不是主路:每一拍推进后端都会广播,订阅层按 `gov.testbed` 前缀
|
||||
* 失效这把查询(见 `app/subscribe.ts`)—— 画面跟着推送走,比 2 秒快得多。
|
||||
* 轮询留着是为了推送漏掉的那种情形(断线、别的进程推进的单)。
|
||||
* 到终态就停:已经不动的东西没必要一直问。
|
||||
*/
|
||||
refetchInterval: (query) => (query.state.data?.terminal === undefined ? 2000 : false),
|
||||
});
|
||||
const s = q.data;
|
||||
const live = s !== undefined && s.terminal === undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{strip({
|
||||
lamp: s === undefined ? 'live' : (RUNTIME_LAMP[s.runtime] ?? 'live'),
|
||||
breathe: live,
|
||||
word: s?.terminal === undefined ? (s?.runtime ?? '取数中') : (TERMINAL_STATE_LABEL[s.terminal] ?? s.terminal),
|
||||
detail: (
|
||||
<>
|
||||
{s?.title ?? taskId.slice(0, 8)}
|
||||
{live && <i className="auto"> · 自动刷新 2s</i>}
|
||||
</>
|
||||
),
|
||||
right: (
|
||||
/*
|
||||
* 一个钮两种说法 —— **在跑时是「停」,跑完了是「清场」**:
|
||||
* 跑完之后已经没有什么可停的,还写「停止仿真」会让人以为不点它这一单就还占着;
|
||||
* 而它真正的作用是把这一单连根拔掉、腾出仪器再来一单。
|
||||
*/
|
||||
<button className="tb-stop" disabled={stopping} onClick={onStop}>
|
||||
<Ico name={live ? 'square' : 'rotate-ccw'} s={11} />
|
||||
{stopping ? '销毁中…' : live ? '停止仿真' : '清场 · 再来一单'}
|
||||
</button>
|
||||
),
|
||||
})}
|
||||
|
||||
<QueryGate query={q}>
|
||||
{s !== undefined && (
|
||||
<>
|
||||
{/* 读数屏:预算三格 —— Harness 在环外掐的就是这三个数 */}
|
||||
<div className="tb-gauges">
|
||||
<Gauge label="步数" used={s.used.steps} cap={s.caps.steps} />
|
||||
<Gauge label="token" used={s.used.tokens} cap={s.caps.tokens} />
|
||||
<Gauge label="调人" used={s.used.humanCalls} cap={s.caps.humanCalls} />
|
||||
</div>
|
||||
{s.reason !== undefined && <p className="tb-reason">{s.reason}</p>}
|
||||
|
||||
<div className="g-rig">
|
||||
<section className="g-rig-l">
|
||||
<div className="g-rig-h">
|
||||
<Ico name="list" s={12} />
|
||||
逐拍轨迹
|
||||
<span className="x">{s.trail.length} 条</span>
|
||||
</div>
|
||||
<ol className="tb-trail">
|
||||
{s.trail.map((t, i) => (
|
||||
<li key={`${t.at}-${i}`} className={'k-' + t.kind}>
|
||||
<span className="t">{new Date(t.at).toLocaleTimeString('zh-CN', { hour12: false })}</span>
|
||||
<span className="b">
|
||||
{t.title}
|
||||
{detailLine(t.detail) !== '' && <i className="d">{detailLine(t.detail)}</i>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="g-rig-r">
|
||||
<div className="g-rig-h">
|
||||
<Ico name="database" s={12} />
|
||||
台账草稿 · 成果就是这些行
|
||||
<span className="x">{s.drafts.length} 行</span>
|
||||
</div>
|
||||
{s.drafts.length === 0 ? (
|
||||
<p className="tb-none">还没有写下任何一行 —— 活动的成果就是台账里的行,一行没有就是还没交东西。</p>
|
||||
) : (
|
||||
<div className="tb-drafts">
|
||||
<DraftGroups drafts={s.drafts} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="g-rig-h">
|
||||
<Ico name="inbox" s={12} />
|
||||
卡 · 问了谁什么
|
||||
<span className="x">{s.cards.length} 张</span>
|
||||
</div>
|
||||
{s.cards.length === 0 ? (
|
||||
<p className="tb-none">还没有发过卡。</p>
|
||||
) : (
|
||||
<div className="tb-cards">
|
||||
{s.cards.map((card) => (
|
||||
<div key={card.id} className={'row' + (card.done ? ' done' : '')}>
|
||||
<span className="m">{card.mode === 'acceptance' ? '验收' : '执行'}</span>
|
||||
<span className="t">{card.title}</span>
|
||||
<span className="s">{card.done ? (card.result ?? '已答') : '待答'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Gauge({ label, used, cap }: { label: string; used: number; cap: number }): ReactNode {
|
||||
const pct = cap === 0 ? 0 : Math.min(100, Math.round((used / cap) * 100));
|
||||
return (
|
||||
<div className={'tb-g' + (pct >= 90 ? ' hot' : pct >= 70 ? ' warm' : '')}>
|
||||
<em>{label}</em>
|
||||
<b>
|
||||
{used.toLocaleString('zh-CN')}
|
||||
<i> / {cap.toLocaleString('zh-CN')}</i>
|
||||
</b>
|
||||
<span className="bar">
|
||||
<i style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 轨迹行的附注:回填值 / 工具出参 / 错误 —— 与喂给 Agent 的那一份同源 */
|
||||
function detailLine(detail: Record<string, unknown> | undefined): string {
|
||||
if (detail === undefined) return '';
|
||||
const parts: string[] = [];
|
||||
const values = detail['values'];
|
||||
if (typeof values === 'object' && values !== null) parts.push(`回填 ${JSON.stringify(values)}`);
|
||||
const output = detail['output'];
|
||||
if (output !== undefined && output !== null) parts.push(`出参 ${JSON.stringify(output)}`);
|
||||
const error = detail['error'];
|
||||
if (typeof error === 'string' && error !== '') parts.push(error);
|
||||
const text = parts.join(' · ');
|
||||
return text.length > 180 ? `${text.slice(0, 180)}…` : text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 台账草稿的摆法 —— **主键先摆,补丁后摆**。
|
||||
*
|
||||
* 一行草稿分两半存:`key` 是主键(询价单号 · PN…),`patch` 是这次写下的列。
|
||||
* 只摊 `patch` 就等于把主键整列吞掉 —— 而主键恰恰是最要紧的那几个字段。
|
||||
* 实测撞过一次:试跑日志里三行受理记录只显示「eau=12000 lead_time=8周」,
|
||||
* 看的人据此判定「PN 没有提取到」,而 PN 三条一条不少,全在 key 里。
|
||||
* 界面不撒谎,这一条也算 —— **没显示不等于没有,但屏上看起来就是没有**。
|
||||
*
|
||||
* 两处观测面(单活动试跑的实时面 / 试跑日志的存档面)共用这一份,免得再走岔。
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type DraftRow = {
|
||||
readonly address: string;
|
||||
readonly key: Record<string, unknown>;
|
||||
readonly patch: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function groupDrafts<T extends DraftRow>(drafts: readonly T[]): Record<string, T[]> {
|
||||
const out: Record<string, T[]> = {};
|
||||
for (const d of drafts) (out[d.address] ??= []).push(d);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 主键摘要:`RFQ-2026-0001 · LM358DR` —— 这一行是谁 */
|
||||
export function draftKey(key: Record<string, unknown>): string {
|
||||
return Object.values(key)
|
||||
.filter((v) => v !== null && v !== undefined && v !== '')
|
||||
.map((v) => String(v))
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
/** 补丁摘要:只摆有值的列 —— 空列摆出来会把真正写下的东西淹掉 */
|
||||
export function compactPatch(patch: Record<string, unknown>): string {
|
||||
return Object.entries(patch)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== '')
|
||||
.map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** 一张表下的若干行 —— 地址一行,其下逐行「主键 → 补丁」 */
|
||||
export function DraftGroups({ drafts }: { drafts: readonly DraftRow[] }): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{Object.entries(groupDrafts(drafts)).map(([address, rows]) => (
|
||||
<div key={address} className="tb-draft-g">
|
||||
<b className="addr">{address}</b>
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="row">
|
||||
<span className="k">{draftKey(r.key)}</span>
|
||||
<span className="p">{compactPatch(r.patch)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { SimulateRun } from './simulate.tsx';
|
||||
import { TestLogs } from './test-logs.tsx';
|
||||
|
||||
export function TestbedHome({ nav }: { tab: string; nav: Nav }): ReactNode {
|
||||
return <SimulateRun nav={nav} />;
|
||||
/**
|
||||
* 三格 —— **流程 / 活动 / 日志**(2026-07-30 从「一格 + 两档旋钮」改过来)。
|
||||
*
|
||||
* 试流程包看的是编排(节点怎么衔接、旗往哪儿指、出口发没发);
|
||||
* 试活动看的是活动内部(案例模型推不推得开、判据取不取得齐、台账写没写对);
|
||||
* 日志是跑过之后的存档 —— 单已经拔了,存档还在,改一版比一版。
|
||||
* 从前那个旋钮长在内容里:别处跳不进来、刷新记不住试的是哪一台。
|
||||
*/
|
||||
export function TestbedHome({ tab, nav }: { tab: string; nav: Nav }): ReactNode {
|
||||
if (tab === 'log') return <TestLogs />;
|
||||
return <SimulateRun nav={nav} bench={tab === 'act' ? 'activity' : 'package'} />;
|
||||
}
|
||||
|
||||
@@ -24,11 +24,22 @@ import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { useGovChannels, useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||
import { QueryGate } from '@web-shared/gov/primitives.tsx';
|
||||
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';
|
||||
|
||||
type RunOutput = { workflowId: string; state: string };
|
||||
|
||||
/** 当前这台仪器上的单 —— `live` 才算占着(见 testCurrentRoute 的注释) */
|
||||
type TestCurrent = {
|
||||
workflowId?: string;
|
||||
subject?: string;
|
||||
taskId?: string;
|
||||
activityId?: string;
|
||||
live: boolean;
|
||||
terminal?: string;
|
||||
};
|
||||
|
||||
/** 只有活动点与审批点会解析负责人 —— 起点终点没有「派给谁」这回事 */
|
||||
const isAssignable = (n: ProcessNode): boolean => n.kind === 'activity' || n.kind === 'approval';
|
||||
|
||||
@@ -85,7 +96,87 @@ function Strip({
|
||||
|
||||
/* ==================== 装配态 ==================== */
|
||||
|
||||
export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
/**
|
||||
* 这台仪器测两样东西:**一条流程包**,或**一个活动** —— 试哪一台由页签说了算(壳层给的 `tab`)。
|
||||
*
|
||||
* 分开是因为要看的东西不同:试流程包看的是编排(节点怎么衔接、旗往哪儿指、出口发没发);
|
||||
* 试活动看的是活动内部(案例模型推不推得开、判据取不取得齐、台账写没写对);
|
||||
* 拿流程包试一个活动,前面几个节点的噪音得先趟一遍,改一次活动就要重跑整条流。
|
||||
*
|
||||
* 从前这儿是个长在内容里的**两档旋钮**(2026-07-30 提到页签):旋钮在内容里,
|
||||
* 别处跳不进来、刷新也记不住试的是哪一台;而「日志」这第三样东西根本塞不进一个二档旋钮。
|
||||
*/
|
||||
export type Bench = 'package' | 'activity';
|
||||
|
||||
export function SimulateRun({ nav, bench }: { nav: Nav; bench: Bench }): ReactNode {
|
||||
const [soloTask, setSoloTask] = useState<string | undefined>(undefined);
|
||||
/*
|
||||
* 一次只跑一条 —— 两种试法共用这一个「当前在跑」:刷新页面认得回来,
|
||||
* 而且换台之前先看见上一单还在跑(不然人会以为没起过,再起一单)。
|
||||
*/
|
||||
const currentAny = useQuery({
|
||||
queryKey: ['gov', 'testbed', 'current'],
|
||||
queryFn: () => call<TestCurrent>(testCurrentRoute),
|
||||
});
|
||||
const cur = currentAny.data;
|
||||
/*
|
||||
* **服务端说没了,本地就得放手**(§9.2 前端零过程状态)。
|
||||
*
|
||||
* `soloTask` 是起单那一刻本地记下的号,为的是不等 `current` 回来就能进观测态。
|
||||
* 可它一旦比服务端活得久,画面就开始撒谎:别处(另一个标签页、另一个人、
|
||||
* 或一句 curl)把这一单销毁了,推送到了、`current` 也重取了,而本地那个号还钉着 ——
|
||||
* 屏上留着一张「取数失败 —— 任务不存在」的鬼卡片(实测)。
|
||||
* 渲染期对齐:`current` 已有数据且它说的不是这一单,本地立刻松手。
|
||||
*/
|
||||
if (currentAny.data !== undefined && soloTask !== undefined && currentAny.data.taskId !== soloTask) {
|
||||
setSoloTask(undefined);
|
||||
}
|
||||
const shownTask = soloTask ?? cur?.taskId;
|
||||
const shownFlow = cur?.workflowId;
|
||||
|
||||
/*
|
||||
* **单在另一台上** —— 页签是人点的,不抢:这一台照旧摆装配台,
|
||||
* 只在顶上挂一句「它在那边」。抢页签的代价见过了:人点「活动」是想去起一单活动,
|
||||
* 结果屏上蹦出上一条流程包的观测面,而他并没有要看它。
|
||||
*
|
||||
* (仪器一次只认一单,所以在这一台起单会先销毁那一单 —— 起单条上写着这句。)
|
||||
*/
|
||||
const elsewhere: 'run' | 'act' | undefined =
|
||||
bench === 'package' && shownTask !== undefined ? 'act' : bench === 'activity' && shownFlow !== undefined ? 'run' : undefined;
|
||||
const hint =
|
||||
elsewhere === undefined ? undefined : (
|
||||
<div className="tb-else">
|
||||
<i className={'tb-lamp ' + (cur?.live === true ? 'live breathe' : 'done')} />
|
||||
<b>{cur?.live === true ? '仪器上有一单在跑' : '上一单还摆在另一台上'}</b>
|
||||
<span>{cur?.subject ?? ''}</span>
|
||||
<button className="tb-ghost" onClick={() => nav.jump('testbed', elsewhere)}>
|
||||
<Ico name="corner-up-right" s={10} />
|
||||
去{elsewhere === 'act' ? '活动' : '流程'}那一台
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (bench === 'activity') {
|
||||
return (
|
||||
<div className="g-tb">
|
||||
{hint}
|
||||
<ActivityRun
|
||||
{...(shownTask === undefined ? {} : { taskId: shownTask })}
|
||||
nav={nav}
|
||||
onStarted={setSoloTask}
|
||||
onStopped={() => {
|
||||
setSoloTask(undefined);
|
||||
void currentAny.refetch();
|
||||
}}
|
||||
strip={(input) => <Strip {...input} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <PackageRun nav={nav} hint={hint} />;
|
||||
}
|
||||
|
||||
function PackageRun({ nav, hint }: { nav: Nav; hint?: ReactNode }): ReactNode {
|
||||
const pkgQ = useGovPackages();
|
||||
const staffQ = useGovStaff();
|
||||
const chanQ = useGovChannels();
|
||||
@@ -110,8 +201,13 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
*/
|
||||
const current = useQuery({
|
||||
queryKey: ['gov', 'testbed', 'current'],
|
||||
queryFn: () => call<{ workflowId?: string; subject?: string }>(testCurrentRoute),
|
||||
queryFn: () => call<TestCurrent>(testCurrentRoute),
|
||||
});
|
||||
// 服务端说没了,本地就得放手 —— 同 SimulateRun 里那段注释
|
||||
if (current.data !== undefined && workflowId !== undefined && current.data.workflowId !== workflowId) {
|
||||
setWorkflowId(undefined);
|
||||
}
|
||||
// 摆出来的是最近这一单(跑完也照旧摆着看结果);只有 live 才算占着仪器
|
||||
const running = workflowId ?? current.data?.workflowId;
|
||||
|
||||
/** 停止仿真 = **销毁**:工作流、活动任务、工单、实例、trace 一并拔掉 */
|
||||
@@ -166,6 +262,7 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
|
||||
return (
|
||||
<div className="g-tb">
|
||||
{hint}
|
||||
<Strip
|
||||
lamp="idle"
|
||||
breathe={false}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 试跑日志 —— **跑过的单不再随手扔掉**。
|
||||
*
|
||||
* 试跑单是「停止仿真 = 连根拔掉」的:工作流、任务、卡、留痕一并销毁,不在别人工作台上
|
||||
* 留半单。可拔干净之后,人手里也就什么都不剩了 —— 上一版跑了几拍、写了哪些草稿、
|
||||
* 被拒在哪一步,全随着那一拔没了,想比一比「改完之后是不是真好了」无从比起。
|
||||
*
|
||||
* 所以拔之前先**存一份**(服务端 `archiveRun`):一次试跑一条存档,
|
||||
* 一条存档 = 逐拍轨迹 + 台账草稿 + 发出去的人卡。这一屏就是那些存档的目录:
|
||||
* 列表看读数,点进去看正文,一条一条删或者一把清空 —— 留多久由人说了算。
|
||||
*
|
||||
* 存档是**死的**:任务已经不存在了,这里的一切都不再变化,故不轮询、不跳转回任务。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { clearTestLogsRoute, deleteTestLogRoute, listTestLogsRoute, testLogDetailRoute } from '@contracts';
|
||||
import type { TestLogRow } from '@contracts';
|
||||
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';
|
||||
|
||||
/** 详情比列表多三样正文 —— 形状从契约的 parse 出参接出来,不手搓 */
|
||||
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
|
||||
type TestLogDetail = Parsed<typeof testLogDetailRoute.output>;
|
||||
|
||||
const KEY = ['gov', 'testbed', 'logs'] as const;
|
||||
|
||||
/** 终态词照运行侧的说法;缺终态 = 人中途按了停 */
|
||||
const TERMINAL_WORD: Readonly<Record<string, string>> = {
|
||||
released: '已放行',
|
||||
completed: '已完成',
|
||||
abnormal: '异常',
|
||||
cancelled: '已取消',
|
||||
running: '还在跑时被拔',
|
||||
waiting: '等人时被拔',
|
||||
};
|
||||
|
||||
const stamp = (iso: string | undefined): string =>
|
||||
iso === undefined ? '—' : new Date(iso).toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
/**
|
||||
* 试跑短号 —— **一次试跑得有个能指认它的名字**。
|
||||
*
|
||||
* 列表上一版只有「主题 + 存档时刻」,而同一条流反复试跑,主题是**一模一样**的
|
||||
* (「【询价】RFQ-2026-0004 车规 Analog 三项」连着五条)—— 想跟人说「第三次那回
|
||||
* 卡在 A2」,除了报时分秒没有别的说法,对方还得自己数。
|
||||
*
|
||||
* 取 uuid 前 8 位:它**就是库里那个 id 的前缀**,拿它 `where id like '2e091d25%'`
|
||||
* 直接查得到。不另编一个流水号 —— 流水号删几条就重排,今天的「第 3 条」明天指向别人。
|
||||
*/
|
||||
const shortId = (id: string): string => id.slice(0, 8);
|
||||
|
||||
/** 点一下把完整 id 拷走 —— 短号用来说话,完整 id 用来查库 */
|
||||
function RunId({ id, full = false }: { id: string; full?: boolean }): ReactNode {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<span
|
||||
className={'tb-runid' + (copied ? ' ok' : '')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={copied ? '已复制完整编号' : `${id}\n点一下复制完整编号`}
|
||||
onClick={(e) => {
|
||||
// 列表里这枚号长在整行按钮上,不拦住就会顺手把详情页点开
|
||||
e.stopPropagation();
|
||||
void navigator.clipboard?.writeText(id).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
},
|
||||
// 拷不了就别装作拷上了(无 clipboard 权限 / 非安全上下文)
|
||||
() => setCopied(false),
|
||||
);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.stopPropagation();
|
||||
e.currentTarget.click();
|
||||
}}
|
||||
>
|
||||
{copied ? '已复制' : full ? id : shortId(id)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestLogs(): ReactNode {
|
||||
const [openId, setOpen] = useState<string | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
const listQ = useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: () => call<{ runs: TestLogRow[] }>(listTestLogsRoute),
|
||||
});
|
||||
|
||||
const dropOne = useMutation({
|
||||
mutationFn: (id: string) => call<{ deleted: number }>(deleteTestLogRoute, { params: { id } }),
|
||||
onSuccess: () => {
|
||||
setOpen(undefined);
|
||||
void queryClient.invalidateQueries({ queryKey: KEY });
|
||||
},
|
||||
});
|
||||
const clearAll = useMutation({
|
||||
mutationFn: () => call<{ deleted: number }>(clearTestLogsRoute),
|
||||
onSuccess: () => {
|
||||
setOpen(undefined);
|
||||
void queryClient.invalidateQueries({ queryKey: KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const runs = listQ.data?.runs ?? [];
|
||||
if (openId !== undefined) {
|
||||
return <LogDetail id={openId} onBack={() => setOpen(undefined)} onDrop={() => dropOne.mutate(openId)} dropping={dropOne.isPending} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="g-tb">
|
||||
<div className="tb-strip">
|
||||
<i className="tb-lamp done" />
|
||||
<span className="word">试跑存档</span>
|
||||
<span className="det">{runs.length === 0 ? '还没有存档' : `${runs.length} 次试跑`}</span>
|
||||
<span className="x">
|
||||
<button
|
||||
className="tb-stop"
|
||||
disabled={runs.length === 0 || clearAll.isPending}
|
||||
onClick={() => clearAll.mutate()}
|
||||
title="清空全部存档"
|
||||
>
|
||||
<Ico name="trash-2" s={11} />
|
||||
{clearAll.isPending ? '清空中…' : '清空'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<QueryGate query={listQ}>
|
||||
{runs.length === 0 ? (
|
||||
<div className="tb-slot">
|
||||
<Ico name="scroll-text" s={26} />
|
||||
<b>还没有跑过的单</b>
|
||||
<p>去流程或活动那一台起一单;停止仿真时,这一单的轨迹、草稿与人卡会先存进这里,再销毁。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tb-logs">
|
||||
{runs.map((r) => (
|
||||
<button key={r.id} className="tb-log-r" onClick={() => setOpen(r.id)}>
|
||||
<span className={'kd ' + r.kind}>{r.kind === 'package' ? '流程' : '活动'}</span>
|
||||
<RunId id={r.id} />
|
||||
<span className="sub">
|
||||
<b>{r.subject}</b>
|
||||
<em>{r.target}</em>
|
||||
</span>
|
||||
<span className={'st' + (r.terminal === 'released' || r.terminal === 'completed' ? ' ok' : r.terminal === undefined ? ' cut' : ' bad')}>
|
||||
{r.terminal === undefined ? '人工停止' : (TERMINAL_WORD[r.terminal] ?? r.terminal)}
|
||||
</span>
|
||||
<span className="rd">
|
||||
{r.used.steps ?? 0} 拍 · {((r.used.tokens ?? 0) / 1000).toFixed(1)}k tok · 人 {r.used.humanCalls ?? 0}
|
||||
</span>
|
||||
<span className="rd">
|
||||
痕 {r.trailCount} · 草稿 {r.draftCount} · 卡 {r.cardCount}
|
||||
</span>
|
||||
<span className="at">{stamp(r.archivedAt)}</span>
|
||||
<i
|
||||
className="del"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`删除 ${r.subject} 的存档`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dropOne.mutate(r.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.stopPropagation();
|
||||
dropOne.mutate(r.id);
|
||||
}}
|
||||
>
|
||||
<Ico name="x" s={11} />
|
||||
</i>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</QueryGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ==================== 一条存档的正文 ==================== */
|
||||
|
||||
function LogDetail({
|
||||
id,
|
||||
onBack,
|
||||
onDrop,
|
||||
dropping,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
onDrop: () => void;
|
||||
dropping: boolean;
|
||||
}): ReactNode {
|
||||
const q = useQuery({
|
||||
queryKey: [...KEY, id],
|
||||
queryFn: () => call<TestLogDetail>(testLogDetailRoute, { params: { id } }),
|
||||
});
|
||||
const d = q.data;
|
||||
|
||||
return (
|
||||
<div className="g-tb">
|
||||
<div className="tb-strip">
|
||||
<i className="tb-lamp done" />
|
||||
<span className="word">{d?.subject ?? '取数中'}</span>
|
||||
{/* 详情页给**完整编号**:说话用短号,查库要整串 */}
|
||||
<RunId id={id} full />
|
||||
<span className="det">
|
||||
{d === undefined ? '' : `${d.target} · ${stamp(d.archivedAt)} 存档`}
|
||||
</span>
|
||||
<span className="x">
|
||||
<button className="tb-ghost" onClick={onBack}>
|
||||
<Ico name="corner-up-left" s={10} />
|
||||
回目录
|
||||
</button>
|
||||
<button className="tb-stop" disabled={dropping} onClick={onDrop}>
|
||||
<Ico name="trash-2" s={11} />
|
||||
{dropping ? '删除中…' : '删掉这条'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<QueryGate query={q}>
|
||||
{d !== undefined && (
|
||||
<>
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="file-text" s={12} />
|
||||
RUN — 这一单的读数
|
||||
<span className="x">{d.kind === 'package' ? '流程包试跑' : '单活动试跑'}</span>
|
||||
</div>
|
||||
<div className="g-tblock">
|
||||
<div>
|
||||
<em>终态</em>
|
||||
<b className={d.terminal === 'released' || d.terminal === 'completed' ? 'brass' : 'run'}>
|
||||
{d.terminal === undefined ? '人工停止' : (TERMINAL_WORD[d.terminal] ?? d.terminal)}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>拍数</em>
|
||||
<b>
|
||||
{d.used.steps ?? 0} / {d.caps.steps ?? '—'}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>token</em>
|
||||
<b>{(d.used.tokens ?? 0).toLocaleString('en-US')}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>叫人次数</em>
|
||||
<b>{d.used.humanCalls ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>起单时刻</em>
|
||||
<b>{stamp(d.startedAt)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>收口理由</em>
|
||||
<b>{d.reason ?? '—'}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="list-checks" s={12} />
|
||||
TRL — 逐拍轨迹
|
||||
<span className="x">{d.trail.length} 条</span>
|
||||
</div>
|
||||
{d.trail.length === 0 ? (
|
||||
<div className="g-tb-empty">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>没有留痕</b>
|
||||
<p>这一单还没走出第一拍就被停了。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tb-trail">
|
||||
{d.trail.map((t, i) => (
|
||||
<div key={`${t.at}#${i}`} className="tb-trail-r">
|
||||
<span className="at">{new Date(t.at).toLocaleTimeString('zh-CN', { hour12: false })}</span>
|
||||
<span className="kd">{t.kind}</span>
|
||||
<span className="tt">{t.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{d.drafts.length > 0 && (
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="database" s={12} />
|
||||
DFT — 台账草稿
|
||||
<span className="x">{d.drafts.length} 行</span>
|
||||
</div>
|
||||
{/*
|
||||
* 主键先摆,补丁后摆 —— 只摊 patch 会把 PN、单号这些主键整列吞掉。
|
||||
* 实测:三行受理记录屏上只剩「eau / lead_time」,看的人判成「PN 没提取到」,
|
||||
* 而 PN 三条一条不少,全在 key 里。
|
||||
*/}
|
||||
<div className="tb-drafts">
|
||||
<DraftGroups drafts={d.drafts} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.cards.length > 0 && (
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="briefcase" s={12} />
|
||||
CRD — 发给人的卡
|
||||
<span className="x">{d.cards.length} 张</span>
|
||||
</div>
|
||||
<div className="tb-trail">
|
||||
{d.cards.map((c) => (
|
||||
<div key={c.id} className="tb-trail-r">
|
||||
<span className="kd">{c.mode}</span>
|
||||
<span className="tt">{c.title}</span>
|
||||
<span className={'at' + (c.done ? '' : ' cut')}>{c.done ? (c.result ?? '已办') : '没等到人办'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import { useGovTools } from '@web-shared/gov/gov-api.ts';
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
/**
|
||||
* 人类工具目录 —— 系统内置的五种形态(语义出处 `packages/contracts/src/card.ts`)。
|
||||
* 人类工具目录 —— 系统内置的六种形态(语义出处 `packages/contracts/src/card.ts`)。
|
||||
* 它们不在工具目录里注册:调人的能力是基座给的,不是谁装上来的。
|
||||
*/
|
||||
const HUMAN_TOOLS = [
|
||||
@@ -66,6 +66,19 @@ const HUMAN_TOOLS = [
|
||||
ico: 'pen-line',
|
||||
desc: '就地要几个小信息;「没法填」旁路与提交两条路必走其一,不许静默挂着',
|
||||
},
|
||||
/*
|
||||
* 数据录入卡 —— 唯一**落到主数据**的人类工具,与补全卡的分工要说清楚:
|
||||
* 补全卡问的是「这一单缺的几个事实」,答完只进这一单的上下文;
|
||||
* 录入卡改的是**客户/项目/供应商/市场**这些先于活动存在、活动退役了还在的档案,
|
||||
* 所以它必须指名落哪张表、哪一行(主键),由人确认那一下才写。
|
||||
*/
|
||||
{
|
||||
name: '数据录入卡',
|
||||
mode: 'chat-card · entry',
|
||||
ico: 'table',
|
||||
// 卡面只放得下三行,写长了会被截断成「待填…」—— 该说清的话要在能看见的长度里说完
|
||||
desc: '录入或编辑主数据(客户/项目/供应商/市场)的一条记录或其中几个字段:AI 先把读得出的填好、缺的留空,人确认那一下才写',
|
||||
},
|
||||
{
|
||||
name: '评价卡',
|
||||
mode: 'chat-card · rating',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 时刻一律本地 —— **差八小时的时刻比没有时刻更坏**,它看起来像一个可信的事实。
|
||||
*
|
||||
* 实测栽过:后端存 UTC,前端满屏 `at.slice(5, 16)` 直接摆上去,
|
||||
* 于是一封 08-01 04:18 到的信,工单上写着「派发 07-31 20:18」、气泡写着「20:18」;
|
||||
* 人拿它去核 SLA、去跟客户对「我们几点收到的」,一核一个错。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { atClock, atFull, atMinute } from '../at.ts';
|
||||
|
||||
const two = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
describe('ISO → 本地时刻', () => {
|
||||
const iso = '2026-07-31T20:18:33.000Z';
|
||||
const at = new Date(iso);
|
||||
|
||||
it('atMinute 给「月-日 时:分」,按本地时区', () => {
|
||||
expect(atMinute(iso)).toBe(
|
||||
`${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('atClock 给「时:分」', () => {
|
||||
expect(atClock(iso)).toBe(`${two(at.getHours())}:${two(at.getMinutes())}`);
|
||||
});
|
||||
|
||||
it('atFull 带上年', () => {
|
||||
expect(atFull(iso)).toBe(
|
||||
`${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('**不是照搬 UTC 那几个字符** —— 时区不为零时两者必不相同', () => {
|
||||
const offset = new Date(iso).getTimezoneOffset();
|
||||
if (offset !== 0) expect(atMinute(iso)).not.toBe(iso.slice(5, 16).replace('T', ' '));
|
||||
});
|
||||
|
||||
it('不是时刻就留空,不摆一个假的', () => {
|
||||
for (const bad of ['', '这不是时刻', 'undefined']) {
|
||||
expect(atMinute(bad)).toBe('');
|
||||
expect(atClock(bad)).toBe('');
|
||||
expect(atFull(bad)).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('秒不进屏 —— 同一分钟内的两个时刻摆出来一模一样', () => {
|
||||
expect(atMinute('2026-08-01T04:18:01.000Z')).toBe(atMinute('2026-08-01T04:18:59.999Z'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 时刻的摆法 —— **屏上一律本地时刻**。
|
||||
*
|
||||
* 后端存的是 UTC ISO 串。从前满屏都是 `at.slice(5, 16).replace('T', ' ')` ——
|
||||
* 把 UTC 那八个字符直接摆上去,于是**每一个时刻都差着时区**:
|
||||
* 一封 08-01 04:18 到的信,工单上写着「派发 07-31 20:18」,对话气泡写着「20:18」。
|
||||
* 差八小时的时刻比没有时刻更坏 —— 它看起来像一个可信的事实,人拿它去核 SLA、
|
||||
* 去跟客户对「我们几点收到的」,一核一个错(实测在来件那一行栽过一次)。
|
||||
*
|
||||
* 只做一件事:ISO → 本地。**不做「几分钟前」这类相对时间** ——
|
||||
* 相对时间读着舒服,可它随打开的时刻变,截图发给同事就对不上了。
|
||||
*/
|
||||
|
||||
const two = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
/** `月-日 时:分` —— 工单、气泡、日志里那一小行 */
|
||||
export function atMinute(iso: string): string {
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return '';
|
||||
return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** `时:分` —— 同一天之内的密集条目(对话流) */
|
||||
export function atClock(iso: string): string {
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return '';
|
||||
return `${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** `年-月-日 时:分` —— 详情页那种要说清哪一天的地方 */
|
||||
export function atFull(iso: string): string {
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return '';
|
||||
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||
}
|
||||
@@ -2,7 +2,22 @@
|
||||
* 数据域短名 —— 字典命中给人读名,字典外回落键尾段:轨上永远有个能认的名字。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { domainShort, sortDomains } from '../domains.ts';
|
||||
import { DOMAIN_SHORT, domainShort, sortDomains } from '../domains.ts';
|
||||
|
||||
/** 库里现有的全部数据域(2026-07-31 实测 `/api/gov/data/objects`) */
|
||||
const LIVE_DOMAINS = [
|
||||
'customer',
|
||||
'intel',
|
||||
'project',
|
||||
'quote_alternative',
|
||||
'quote_archive',
|
||||
'quote_intake',
|
||||
'quote_negotiate',
|
||||
'quote_pricing',
|
||||
'quote_sourcing',
|
||||
'shared',
|
||||
'supplier',
|
||||
];
|
||||
|
||||
describe('domainShort', () => {
|
||||
it('已知域给 2-3 字人读名', () => {
|
||||
@@ -12,20 +27,51 @@ describe('domainShort', () => {
|
||||
expect(domainShort('supplier')).toBe('供应商');
|
||||
});
|
||||
|
||||
it('未知域回落到键的最后一段,超长截 4 字符', () => {
|
||||
it('六个活动域跟着活动叫', () => {
|
||||
expect(domainShort('quote_intake')).toBe('受理');
|
||||
expect(domainShort('quote_alternative')).toBe('选型');
|
||||
expect(domainShort('quote_sourcing')).toBe('寻源');
|
||||
expect(domainShort('quote_pricing')).toBe('定价');
|
||||
expect(domainShort('quote_negotiate')).toBe('议价');
|
||||
expect(domainShort('quote_archive')).toBe('归档');
|
||||
expect(domainShort('shared')).toBe('共享');
|
||||
});
|
||||
|
||||
/*
|
||||
* 这条是用户提的硬要求:数据域**必须都显示中文**。
|
||||
* 从前 `quote_*` 一个都没登记,治理台上蹦出 alte / sour / pric 这样的英文残词。
|
||||
* 拿库里真实存在的域逐个过一遍 —— 漏登记一个,这条就红。
|
||||
*/
|
||||
it('库里现有的每一个域都有中文短名 —— 一个都不许漏', () => {
|
||||
const missing = LIVE_DOMAINS.filter((d) => DOMAIN_SHORT[d] === undefined);
|
||||
expect(missing).toEqual([]);
|
||||
for (const d of LIVE_DOMAINS) {
|
||||
expect(domainShort(d)).toMatch(/^[一-龥]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 字典外回落到**完整尾段**,不再截 4 个字符 ——
|
||||
* `alternative` 截成 `alte` 不是「能认的名字」,而且把「还没登记」伪装成了「它就叫这个」。
|
||||
*/
|
||||
it('未知域回落到键的最后一段,整段留着不截', () => {
|
||||
expect(domainShort('warehouse_ops')).toBe('ops');
|
||||
expect(domainShort('some_verylongtail')).toBe('very');
|
||||
expect(domainShort('some_verylongtail')).toBe('verylongtail');
|
||||
expect(domainShort('solo')).toBe('solo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortDomains —— 按经营视角排,不按字母', () => {
|
||||
it('已知域按业务序(项目最前),未知域殿后', () => {
|
||||
expect(sortDomains(['supplier', 'intel', 'zzz_custom', 'project'])).toEqual([
|
||||
'project',
|
||||
'intel',
|
||||
'supplier',
|
||||
it('先按一单走下来的次序摆业务域,沉淀域在后,未知域殿后', () => {
|
||||
expect(sortDomains(['shared', 'quote_pricing', 'zzz_custom', 'quote_intake'])).toEqual([
|
||||
'quote_intake',
|
||||
'quote_pricing',
|
||||
'shared',
|
||||
'zzz_custom',
|
||||
]);
|
||||
});
|
||||
|
||||
it('退役占位槽留下的 project 压在已知域最后', () => {
|
||||
expect(sortDomains(['project', 'quote_intake', 'customer'])).toEqual(['quote_intake', 'customer', 'project']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,14 +6,50 @@
|
||||
* 字典之外的域回落到键的最后一段(截 4 字符)—— 新域没配短名时,先有个能认的名字。
|
||||
*/
|
||||
export const DOMAIN_SHORT: Readonly<Record<string, string>> = {
|
||||
project: '项目',
|
||||
/* 沉淀资产:跨单积累的事实 */
|
||||
customer: '客户',
|
||||
intel: '情报',
|
||||
supplier: '供应商',
|
||||
shared: '共享',
|
||||
market: '市场',
|
||||
/* 每一单的流转单据 —— `project` 是占位槽时代的统一域,活动转正后各归各的 */
|
||||
project: '项目',
|
||||
/*
|
||||
* 真活动包各自的域(2026-07-31 补齐)。
|
||||
*
|
||||
* 从前这几个一个都没登记,全落到「截键尾 4 字符」的兜底上:
|
||||
* `quote_alternative` 显示成 **alte**、`quote_sourcing` 成 **sour**、
|
||||
* `quote_pricing` 成 **pric** —— 一屏中文里蹦出几个没意义的英文残词。
|
||||
* 短名跟着活动叫,与设计中心活动库里那六个名字对得上(受理 / 选型 / 寻源 / 定价 / 议价 / 归档)。
|
||||
*/
|
||||
quote_intake: '受理',
|
||||
quote_alternative: '选型',
|
||||
quote_sourcing: '寻源',
|
||||
quote_pricing: '定价',
|
||||
quote_negotiate: '议价',
|
||||
quote_archive: '归档',
|
||||
};
|
||||
|
||||
/** 业务序:项目(每天看的)→ 客户 → 情报 → 供应商 —— 按经营视角排,不按字母 */
|
||||
const DOMAIN_ORDER = ['project', 'customer', 'intel', 'supplier'];
|
||||
/**
|
||||
* 业务序 —— 按经营视角排,不按字母。
|
||||
* 先按**一单走下来的次序**摆六个业务域(受理→选型→寻源→定价→议价→归档),
|
||||
* 再摆跨单沉淀的资产域;`project` 是退役占位槽留下的,压在最后。
|
||||
*/
|
||||
const DOMAIN_ORDER = [
|
||||
'quote_intake',
|
||||
'quote_alternative',
|
||||
'quote_sourcing',
|
||||
'quote_pricing',
|
||||
'quote_negotiate',
|
||||
'quote_archive',
|
||||
'shared',
|
||||
/* 主数据四域:谁在买 → 为什么买 → 从哪买 → 买什么 */
|
||||
'customer',
|
||||
'project',
|
||||
'supplier',
|
||||
'market',
|
||||
'intel',
|
||||
];
|
||||
|
||||
export function sortDomains(domains: readonly string[]): string[] {
|
||||
return [...domains].sort((a, b) => {
|
||||
@@ -30,6 +66,14 @@ export function sortDomains(domains: readonly string[]): string[] {
|
||||
export function domainShort(domain: string): string {
|
||||
const hit = DOMAIN_SHORT[domain];
|
||||
if (hit !== undefined) return hit;
|
||||
const tail = domain.split('_').at(-1) ?? domain;
|
||||
return tail.length <= 4 ? tail : tail.slice(0, 4);
|
||||
/*
|
||||
* 字典外的域**回落到键的完整尾段,不再截字符**(2026-07-31 改)。
|
||||
*
|
||||
* 从前截 4 个字符,`quote_alternative` 出来是 `alte`、`quote_pricing` 是 `pric` ——
|
||||
* 那不是「能认的名字」,是一串看不出所以然的残词,而且它把「这个域还没登记短名」
|
||||
* 这件事伪装成了「它就叫这个」。整段留着,至少一眼看得出是哪个域、也看得出没配中文。
|
||||
*
|
||||
* 新加数据域时请一并往 `DOMAIN_SHORT` 里登记中文短名 —— 这条兜底是**提醒**,不是归宿。
|
||||
*/
|
||||
return domain.split('_').at(-1) ?? domain;
|
||||
}
|
||||
|
||||
@@ -172,7 +172,15 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
||||
*/
|
||||
export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string }>>> = {
|
||||
blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }],
|
||||
testbed: [{ key: 'run', label: '仿真试跑', ico: 'flask-conical' }],
|
||||
/*
|
||||
* 测试台三格 —— 从前是一格加一个「两档旋钮」,旋钮长在内容里,别处跳不进来、
|
||||
* 刷新也记不住试的是哪一台。改成页签:试什么、看什么由地址说了算。
|
||||
*/
|
||||
testbed: [
|
||||
{ key: 'run', label: '流程', ico: 'workflow' },
|
||||
{ key: 'act', label: '活动', ico: 'package' },
|
||||
{ key: 'log', label: '日志', ico: 'scroll-text' },
|
||||
],
|
||||
triggers: [{ key: 'rules', label: '调起规则', ico: 'zap' }],
|
||||
archive: [{ key: 'docs', label: '资料', ico: 'library' }],
|
||||
models: [{ key: 'llm', label: '模型实例', ico: 'cpu' }],
|
||||
|
||||
@@ -40,7 +40,8 @@ export const APR_ST: Readonly<Record<'pending' | 'passed' | 'rejected', ToneIco
|
||||
|
||||
export const SIDE_ST: Readonly<Record<string, ToneIco>> = {
|
||||
机器产出: { tone: 'auto', ico: 'bot' },
|
||||
人产出: { tone: 'brand', ico: 'user-round' },
|
||||
员工动作: { tone: 'brand', ico: 'user-round' },
|
||||
人裁决: { tone: 'decision', ico: 'shield-check' },
|
||||
世界等待: { tone: 'wait', ico: 'hourglass' },
|
||||
系统等待: { tone: 'wait', ico: 'hourglass' },
|
||||
触发事件: { tone: 'brand', ico: 'zap' },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user