Files
JX-BPOS/apps/web/src/features/gov-activity/internal/panel.tsx
T
ChaiHaoandClaude Opus 5 e5cfca638f 运营中心撤掉「数据」区;流程包首页重排:卡有层次、⋯ 管编辑与删除、新建变幽灵卡
**运营 · 数据(活动记忆四层)整个撤掉** —— 记忆的放行口还没成形,摆一个空区不如
不摆;真要做时随「数据资产」一并重开。区表、面归属、shell 映射、KnowledgeHome /
MemPage、活动详情里那枚入口一并清干净;旧深链 `#/gov/memory/layers` 落运营首页
(测试跟着改口径,不留死链)。运营中心自此六区。

**流程包首页**:
  · 「编排要素:四种」这枚计数删了 —— 网关早废,它是句没人用的口号;
  · 卡片重做。从前是「头 + 芯片行 + 一大片空 + 脚注」,158 高全靠留白撑;
    现在两段:头(图标 · 名 · 标识 · 生效版 · ⋯)+ **数据板**(活动/审批/边闭合/版本
    四格等宽、竖线分隔)。段段有内容、段间有线,高矮由内容说了算 —— 实测 111 高;
  · 卡上 **⋯ 菜单**:编辑(就地改名,落在卡面那一版上)/ 删除(整条流,菜单原地翻成
    确认,不另弹窗);
  · **新建按钮变幽灵卡**:虚线卡住在货架末位,与真卡同宽同高(111/111)。

删整条流是新能力:`DELETE /api/gov/process/:id` + `process.deleteFlow`。
生效版这道闸不设(明示动作,不是手滑),在途单那道照设(PinnedVersionError → 409);
**「还有调起规则指着它」由路由层反查** —— process 是 L7,不该知道 trigger 的存在。

实测(丁子乔账号):改名往返、删除被 2 条调起规则如实挡回、幽灵卡建 `tmp-drop`
再删掉整条流,库里回到只剩 unified-quote v4,试跑数据已清。五闸绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 22:38:52 +08:00

342 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* feature `gov-activity` —— 活动库:两维分类 × 包版本 × 资产版本
*
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
*/
import { Ico } from '@web-shared/Ico.tsx';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { installActivityRoute } from '@contracts';
import { ApiCallError, call } from '@web-shared/typed-fetch.ts';
import {
Acts,
Card,
Chip,
Clear,
Field,
Note,
PageHead,
Prop,
QueryGate,
St,
} from '@web-shared/gov/primitives.tsx';
/** 装入回参 —— 与 `installActivityRoute.output` 同形 */
type InstallReply = {
accepted: boolean;
activityId: string;
packageVersion: string;
staticFindings: ReadonlyArray<{ code: string; severity: string; message: string }>;
replay: {
oldPassStillPass: boolean;
checked: number;
skippedNoHistory: boolean;
regressions: ReadonlyArray<{ taskId: string; detail: string }>;
};
};
import { PARA } from '@web-shared/gov/tone.ts';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovActivities } from '@web-shared/gov/gov-api.ts';
export type HomeProps = { tab: string; nav: Nav };
export function LibraryHome({ nav }: HomeProps) {
const q = useGovActivities();
const activities = q.data?.activities ?? [];
return (
<QueryGate query={q}>
<>
<Note>
活动是任务的<b>定义</b>:编排引用一个活动,运行时就长出一个任务。业务分类 × 活动类型两维平级;
每活动两条版本序列——包版本走安装两审,资产版本走进化管线。
<b>审批点与网关不在这里</b>:它们是流程层的系统内置要素,零开发、不入库。
</Note>
<InstallCard />
{activities.length === 0 ? (
<Clear>货架是空的 —— 装一个活动进来,编排里才引用得到它。</Clear>
) : (
<div className="g-shelf">
{activities.map((a) => {
const para = PARA[a.paradigm];
const latest = a.packageVersions[a.packageVersions.length - 1];
const effective = a.assetVersions.filter((v) => v.effective).length;
return (
<button
key={a.activityId}
className={'g-tile t-' + para.tone}
onClick={() => nav.push({ k: 'act', id: a.activityId })}
>
<span className="g-tile-h">
<span className="g-tile-i">
<Ico name={para.ico} s={16} />
</span>
<span className="g-tile-t">
<b>{a.name}</b>
<em>{a.activityId}</em>
</span>
<Chip tone={para.tone}>{para.label}</Chip>
</span>
<span className="g-tile-m">
<Chip ico="boxes">{a.category}</Chip>
<Chip ico="git-commit">包 v{latest?.version ?? '—'}</Chip>
</span>
<span className="g-tile-f">
{/*
只报查得到的事实:资产生效版数与上架状态。
「实现到没到位」这一层,清单里没有可靠依据 —— 不在卡上编一个章。
*/}
<St tone={a.enabled ? 'ok' : 'mute'} ico={a.enabled ? 'circle-check' : 'power'}>
{a.enabled ? '已上架' : '已停用'}
</St>
<span className="r">资产生效 {effective}</span>
</span>
</button>
);
})}
</div>
)}
</>
</QueryGate>
);
}
/* ---------------- ⑥ 工具管理 ---------------- */
export function ActPage({ id }: { id: string }) {
const q = useGovActivities();
const a = (q.data?.activities ?? []).find((x) => x.activityId === id);
if (a === undefined) return <Note>活动不存在。</Note>;
return (
<>
<PageHead
ico={PARA[a.paradigm].ico}
tone={PARA[a.paradigm].tone}
kicker={`活动 · ${a.activityId}`}
title={a.name}
chips={
<>
<Chip tone={PARA[a.paradigm].tone}>{PARA[a.paradigm].label}</Chip>
<Chip ico="tag">{a.category}</Chip>
<Chip tone={a.enabled ? 'ok' : 'mute'}>{a.enabled ? '已启用' : '已停用'}</Chip>
</>
}
desc={PARA[a.paradigm].full + ' —— 活动是系统外开发、经此安装的外装件。'}
/>
{/*
契约面 —— 开发这个活动要照着的东西:吃什么、吐什么、用谁的工具、动哪些域对象。
它排在版本谱系前面:先看清契约,再看装了哪几版。
*/}
<Card ico="file-text" title="契约面 · 开发它要照着的" extra={`调人预算 ${a.contract.humanCallBudget} 次`}>
<Prop ico="arrow-left-right" title="入参" extra={`${a.contract.inputFields.length} 个字段`}>
{a.contract.inputFields.length === 0 ? (
<Clear>不吃结构化入参 —— 只凭委托消息启动。</Clear>
) : (
<div className="g-rows">
{a.contract.inputFields.map((f) => (
<div key={f.name}>
<em>
{f.name}
{f.required && <Chip tone="warn">必填</Chip>}
</em>
<b className="mono">{f.type}</b>
</div>
))}
</div>
)}
</Prop>
<Prop ico="send" title="输出口" extra="口名 + 载荷字段即下游可衔接的全部">
<div className="g-rows">
{a.contract.outputPorts.map((port) => (
<div key={port.name}>
<em>{port.name}</em>
<b className="mono">{port.fields.join(' · ') || '(无字段)'}</b>
</div>
))}
{a.contract.outputPorts.length === 0 && <Clear>没有声明输出口 —— 下游无从衔接,发布会被拦。</Clear>}
</div>
</Prop>
<Prop ico="wrench" title="声明的依赖" extra="授权在白名单与契约,不在活动代码">
<div className="g-rows">
<div>
<em>工具</em>
<b className="mono">{a.contract.tools.join(' · ') || '(不调工具)'}</b>
</div>
<div>
<em>模型</em>
<b className="mono">{a.contract.models.join(' · ') || '(不调模型)'}</b>
</div>
<div>
<em>岗位</em>
<b className="mono">{a.contract.roles.join(' · ') || '(未声明)'}</b>
</div>
<div>
<em>域对象</em>
<b className="mono">{a.contract.objects.join(' · ') || '(不读写台账)'}</b>
</div>
</div>
</Prop>
</Card>
<Card ico="package" title="包版本 · 安装两审">
<div className="g-rows">
{a.packageVersions.map((p) => (
<div key={p.version}>
<em>v{p.version}</em>
<b className="mono">
{p.state} · {p.installedAt.slice(0, 16).replace('T', ' ')}
</b>
</div>
))}
</div>
</Card>
<Card ico="trending-up" title="资产版本 · 进化管线">
<div className="g-rows">
{a.assetVersions.map((v, i) => (
<div key={i}>
<em>
{v.kind} v{v.version}
</em>
<b className="mono">{v.effective ? '生效中' : '历史'}</b>
</div>
))}
{a.assetVersions.length === 0 && <Clear>尚无资产版本。</Clear>}
</div>
</Card>
</>
);
}
/* ---------------- 装入 / 换版(安装通道) ---------------- */
/**
* 开发完一个活动,从这里装进来 —— **无代码通道**(ADR-004):交的是清单 + .js 产物,都是数据。
*
* 两审在服务端跑:静态审读清单,回放审重演历史(首装无历史即免)。
* 恒为新增、版本并存:装入不覆盖旧版本,在途任务按其启动时版本跑完。
*/
function InstallCard() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [manifestText, setManifestText] = useState('');
const [artifact, setArtifact] = useState('');
const install = useMutation({
mutationFn: () =>
call<InstallReply>(installActivityRoute, {
body: { manifest: JSON.parse(manifestText) as unknown, artifact },
}),
onSuccess: (out) => {
if (out.accepted) {
void queryClient.invalidateQueries({ queryKey: ['gov', 'activities'] });
setOpen(false);
}
},
});
const pick = (setter: (text: string) => void) => async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file !== undefined) setter(await file.text());
};
/** 两审未过的结论:412 走的是 ApiCallError,详情在 detail 里 */
const rejected =
install.error instanceof ApiCallError && install.error.status === 412
? (install.error.detail as InstallReply | undefined)
: install.data?.accepted === false
? install.data
: undefined;
if (!open) {
return (
<Card
ico="package"
title="装入 / 换版"
extra={
<button className="g-btn primary" onClick={() => setOpen(true)}>
装一个活动
</button>
}
>
</Card>
);
}
return (
<Card ico="package" title="装入 / 换版">
<Prop ico="file-text" title="清单 manifest.json" extra="身份 / 七件契约 / 声明 / 资产初始版 / 样例集 / 代码入口">
<Field k="选文件" hint="活动包 dist/manifest.json">
<input className="g-input" type="file" accept=".json,application/json" onChange={pick(setManifestText)} />
</Field>
<Field k="或直接粘贴">
<textarea
className="g-input"
rows={4}
value={manifestText}
placeholder='{ "identity": { "activityId": "rfq-intake", … } }'
onChange={(e) => setManifestText(e.target.value)}
/>
</Field>
</Prop>
<Prop ico="terminal" title="产物 index.js" extra="只当数据存 —— 不 import、不 eval">
<Field k="选文件" hint="活动包 dist/index.js">
<input className="g-input" type="file" accept=".js,.mjs,text/javascript" onChange={pick(setArtifact)} />
</Field>
<Field k="或直接粘贴">
<textarea
className="g-input"
rows={3}
value={artifact}
placeholder="export default defineActivity({ … })"
onChange={(e) => setArtifact(e.target.value)}
/>
</Field>
</Prop>
{rejected !== undefined && (
<Note tone="warn">
<b>两审未过,没有上架。</b>
{rejected.staticFindings.length > 0 && (
<> 静态审:{rejected.staticFindings.map((f) => f.message).join(';')}。</>
)}
{rejected.replay.regressions.length > 0 && (
<> 回放审:{rejected.replay.regressions.map((r) => `${r.taskId} ${r.detail}`).join(';')}。</>
)}
</Note>
)}
{install.data?.accepted === true && (
<Note tone="ok">
已装入 {install.data.activityId} v{install.data.packageVersion} —— 静态审{' '}
{install.data.staticFindings.length} 条提示;回放审
{install.data.replay.skippedNoHistory ? '免(无历史)' : `查了 ${install.data.replay.checked} 单`}。
</Note>
)}
<Acts
align="end"
items={[
{ label: '取消', ico: 'x', onClick: () => setOpen(false) },
{
label: install.isPending ? '两审中…' : '装入并送审',
ico: 'circle-check',
primary: true,
disabled: install.isPending || manifestText.trim() === '' || artifact.trim() === '',
ask: {
title: '经安装通道装入活动包',
note: <Note>无代码通道:交的是数据。两审过了才上架;不过审会把是哪一条拦的说清楚。</Note>,
impact: {
who: '此后新起的任务用新包版本;在途任务按启动时版本跑完',
undo: '版本并存,可停用回退',
when: '确认后当场跑两审',
},
okLabel: '确认装入',
onOk: () => install.mutate(),
},
},
]}
/>
</Card>
);
}