「flow:按**步序位置**推送声明的模版」(AM 07)。原先这条链只有写面没有读面,
活动的 viewTemplates 是一行占位字符串,工作台视图区永远只显示一份硬编码概览。
补齐整条链:
· 步声明新增 `view`:这一步呈现哪张模版 —— 推什么由声明给定,不由临场决定。
· 新增模版填数的纯函数(interaction/domain):`data-slot` 取值、`data-rows` 逐条展开。
**填数只做取值与转义**,没有表达式、没有分支、没有循环 —— 一旦模版能算,
它就成了代码,容器的「无脚本执行权」也就形同虚设。填充**消费槽位**,
交出去的是干净 HTML,不是带模板属性的半成品。
· interaction 补读面 `viewOf`;desk 路由把推送的 HTML 交给前端;
ViewRegion 经 `@ui` 的 ViewContainer(sandbox="" 的 iframe)渲染 —— feature 不碰 innerHTML。
· A1 写了三张真模版(事态全景 / 打标判据 / 成果预览),内联自带样式
(沙箱页面不继承工作台 CSS),按步序位置挂在 S3 / S5 / S6 上。
沿路两处修:
① 取值路径遇到**本身带点号的键**(台账地址 `域.对象`)会被拆成两级、永远取不到。
改为整键优先、找不到再下行;`view-render` 与 `step-sequence` 两处同修。
② 「本单交付」不能画成台账全表:任务视图是正式版 + 我的草稿的合并结果,
直接拿来列会把**别单**放行过的记录一并列出。本单自己写下的行单独放在
`mine:域.对象` 下(整键,取值不歧义)。
另:视图推送失败原先静默吞掉 —— 会让「视图区一直空着」变成查不出原因的怪事;
改为留痕(仍不影响步序推进,呈现是投影)。
实测:一封 RFQ 入站 → 放行前那一屏列出本单交付 LM358DR / TI / 12000 / 8.2 / 10周,
以及 Type A「直接快答」的分类结论与四项打标判据。
gates: tsc / eslint / depcruise(321 模块)/ api-report / 423 测试 全绿
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
102 lines
3.7 KiB
TypeScript
102 lines
3.7 KiB
TypeScript
/**
|
|
* interaction 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
|
|
*/
|
|
import type { ChatMessage, StaffId, TaskId, ViewBlock } from '@contracts';
|
|
import { cardIdSchema } from '@contracts';
|
|
import type { InteractionService, PushEnvelope, Subscription } from '../../contract.ts';
|
|
import { TemplateRejectedError } from '../../contract.ts';
|
|
import type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStore, MemoryRecorder } from '../../port.ts';
|
|
import type { InteractionPhase } from '../domain/control-handover.ts';
|
|
import { checkTemplate, outputControl } from '../domain/control-handover.ts';
|
|
|
|
export type InteractionServiceDeps = {
|
|
readonly store: InteractionStore;
|
|
readonly events: HumanEventPublisher;
|
|
readonly memory: MemoryRecorder;
|
|
readonly audience: Audience;
|
|
readonly clock: Clock;
|
|
readonly ids: IdGenerator;
|
|
};
|
|
|
|
type Subscriber = { staffId: StaffId; onPush: (envelope: PushEnvelope) => void };
|
|
|
|
export function createInteractionService(deps: InteractionServiceDeps): InteractionService {
|
|
// SSE 订阅者表是**连接级**状态,不是过程状态:进程内持有,刷新即从投影重建(§9.2)
|
|
const subscribers = new Set<Subscriber>();
|
|
|
|
function broadcast(envelope: PushEnvelope): void {
|
|
for (const subscriber of subscribers) {
|
|
try {
|
|
subscriber.onPush(envelope);
|
|
} catch {
|
|
subscribers.delete(subscriber);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
async timeline(taskId) {
|
|
return deps.store.listMessages(taskId);
|
|
},
|
|
|
|
/** 负责人的自由输入 —— 人事件的第二形态;写入任务记忆,不驱动 flow 步 */
|
|
async say(taskId: TaskId, by: StaffId, text: string): Promise<ChatMessage> {
|
|
const message: ChatMessage = {
|
|
k: 'human',
|
|
id: cardIdSchema.parse(deps.ids.next()),
|
|
at: deps.clock.now().toISOString(),
|
|
text,
|
|
by,
|
|
};
|
|
await deps.store.appendMessage(taskId, message);
|
|
await deps.memory.append({ taskId, kind: 'human-input', title: text, detail: { by } });
|
|
await deps.events.publishHumanEvent({ taskId, cardId: message.id, payload: { kind: 'free-input', text } });
|
|
broadcast({ kind: 'chat', taskId, message });
|
|
return message;
|
|
},
|
|
|
|
async pushView(taskId: TaskId, blocks: readonly ViewBlock[]) {
|
|
await deps.store.putView(taskId, { blocks });
|
|
broadcast({ kind: 'view', taskId, blocks });
|
|
},
|
|
|
|
/** 视图模版过净化门:含脚本 / 事件处理器 / 外部资源一律拒推 */
|
|
async pushViewHtml(taskId, html) {
|
|
const violations = checkTemplate(html);
|
|
if (violations.length > 0) throw new TemplateRejectedError(violations);
|
|
await deps.store.putView(taskId, { html });
|
|
broadcast({ kind: 'view-html', taskId, html });
|
|
},
|
|
|
|
async viewOf(taskId) {
|
|
return deps.store.getView(taskId);
|
|
},
|
|
|
|
async setPhase(taskId: TaskId, phase: InteractionPhase) {
|
|
await deps.store.setPhase(taskId, phase);
|
|
const output = outputControl(phase);
|
|
broadcast({ kind: 'control', taskId, phase, output });
|
|
return output;
|
|
},
|
|
|
|
async phaseOf(taskId) {
|
|
return (await deps.store.phaseOf(taskId)) ?? 'activating';
|
|
},
|
|
|
|
subscribe(staffId, onPush) {
|
|
const subscriber: Subscriber = { staffId, onPush };
|
|
subscribers.add(subscriber);
|
|
return { close: () => void subscribers.delete(subscriber) } satisfies Subscription;
|
|
},
|
|
|
|
invalidate(target, id) {
|
|
broadcast({ kind: 'invalidate', target, id });
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 供 app 的 SSE 路由复用:按可见性过滤(避免把别人的任务推给不相干的人) */
|
|
export async function visibleTo(audience: Audience, staffId: StaffId, taskId: TaskId): Promise<boolean> {
|
|
return audience.canSee(staffId, taskId);
|
|
}
|