Archived
M6(2/n): 治理台九功能 + SSE 订阅层 + 读/治分权
治理台按 §9.1 拆成九个 gov-* feature,共用件落 shared/gov:
· 外壳(左轨/顶栏/面包屑/页签/整页下钻)自原型 Governance.tsx 平移,
DOM 与 g-* 类名原样;路由状态即应用状态,区/页签/下钻栈全编码进 hash 可深链
· 编排画布 diagram-js 画布件 1:1 平移(flow-layout/flow-node-paint/FlowDiagram),
新增 pkg-to-graph 把 ProcessPackage 转成画布图模型;统一报价 v1 全图 12 节点 15 边渲染通过
· 后端补四路由:审批单 / 工作流下钻 / 通道实例 / 度量仪表
SSE 订阅层(§9.2):app 层单一连接,按信封目标失效缓存;
组件不再自行轮询(去掉 refetchInterval)。广播只说「哪个目标动了」,不带业务载荷,
且在投递已落库已送达之后才发 —— 不是第二条投递链路。
两处实质修正:
· 治理路由一律要 govern → 读路由改要 read。客体三类 × 动作三种的分法就是为此:
BD 有 task:read 能看在途、动不了控制事件;一律 govern 等于把三种动作压成一种
· 控制事件/恢复/改派原记为 engine-transition(归机器产出)→ 新增 control 条目种类,
归人裁决侧。归侧是按动作类型的纯推导,所以「谁干的」必须体现在 kind 上;
否则人的叫停会被算进自主率的机器分子
判据 ⑩ 治理台一半跑通:在途监控 / 叫停 / 恢复,治理动作留痕可查。
无权查看不再画成空清单,按岗位说明缺哪一项。
gates: tsc / eslint / depcruise(286 模块)/ api-report / 360 测试 全绿
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
import Diagram from "diagram-js";
|
||||
import BaseRenderer from "diagram-js/lib/draw/BaseRenderer";
|
||||
import InteractionEventsModule from "diagram-js/lib/features/interaction-events";
|
||||
import type Canvas from "diagram-js/lib/core/Canvas";
|
||||
import type ElementFactory from "diagram-js/lib/core/ElementFactory";
|
||||
import type EventBus from "diagram-js/lib/core/EventBus";
|
||||
import type { ShapeLike } from "diagram-js/lib/core/Types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
layoutFlow,
|
||||
type ConnectionLayout,
|
||||
type FlowGraph,
|
||||
type FlowLayoutResult,
|
||||
type FlowNode,
|
||||
type FlowVariant,
|
||||
type ShapeLayout,
|
||||
} from "./flow-layout";
|
||||
import { arrowHeadPoints, CAPTION_STYLE, FLOW_MONO, paintNode, svgEl as svg, TONE_COLOR } from "./flow-node-paint";
|
||||
import { OwnerSelect } from "./support";
|
||||
import type { SpPosition } from "./support";
|
||||
import "diagram-js/assets/diagram-js.css";
|
||||
|
||||
/**
|
||||
* 流程图绘制:diagram-js 承载节点(shape + 点击交互),
|
||||
* 连线/箭头/标注按布局引擎产出的几何画进独立图层。
|
||||
* 节点/连线画笔在 flow-node-paint.ts,与 FlowEditor(手绘)共用一套视觉。
|
||||
* 模型只产结构化数据——坐标来自 flow-layout,绘制在这里,SVG 不经模型之手。
|
||||
*/
|
||||
|
||||
/** 画布上方预留的留白(布局坐标单位;避免顶部标注贴边)。 */
|
||||
const DIAGRAM_TOP_PAD = 26;
|
||||
|
||||
interface FlowBO {
|
||||
node: FlowNode;
|
||||
variant: FlowVariant;
|
||||
clickable: boolean;
|
||||
}
|
||||
|
||||
/** 节点绘制(共用画笔 + diagram-js 命中层标记)。 */
|
||||
class FlowShapeRenderer extends BaseRenderer {
|
||||
static $inject = ["eventBus"];
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
super(eventBus, 2000);
|
||||
}
|
||||
|
||||
override canRender(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override drawShape(parent: SVGElement, element: ShapeLike): SVGElement {
|
||||
const { node, variant, clickable } = (element as { businessObject: FlowBO }).businessObject;
|
||||
const group = svg("g", {});
|
||||
if (clickable) {
|
||||
// 光标由 diagram-js 盖在最上层的 .djs-hit 命中层决定(默认 move 十字)——
|
||||
// 在元素组上打标记类,由 process-doc.css 把该层光标改成 pointer。
|
||||
(parent.parentElement as SVGElement | null)?.classList.add("flow-node-clickable");
|
||||
}
|
||||
paintNode(group, node, element.width, element.height, variant);
|
||||
parent.appendChild(group);
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
const rendererModule = {
|
||||
__init__: ["flowShapeRenderer"],
|
||||
flowShapeRenderer: ["type", FlowShapeRenderer],
|
||||
};
|
||||
|
||||
/** 连线 + 箭头 + 线上标签,画进注解图层(几何全部来自布局引擎)。 */
|
||||
function drawConnection(layer: SVGElement, connection: ConnectionLayout, variant: FlowVariant): void {
|
||||
const color = TONE_COLOR[connection.tone];
|
||||
const [first, ...rest] = connection.waypoints;
|
||||
if (first === undefined) return;
|
||||
const d = `M${first.x},${first.y} ${rest.map((p) => `L${p.x},${p.y}`).join(" ")}`;
|
||||
layer.append(svg("path", { d, fill: "none", stroke: color, "stroke-width": 1.4 }));
|
||||
layer.append(svg("polygon", { points: arrowHeadPoints(connection.waypoints), fill: color }));
|
||||
|
||||
if (connection.label) {
|
||||
layer.append(
|
||||
svg(
|
||||
"text",
|
||||
{
|
||||
x: connection.label.x,
|
||||
y: connection.label.y,
|
||||
"text-anchor": connection.label.anchor,
|
||||
"font-family": FLOW_MONO,
|
||||
"font-size": variant === "business" ? 9.8 : 8.6,
|
||||
fill: color,
|
||||
},
|
||||
connection.label.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function drawAnnotations(layer: SVGElement, layout: FlowLayoutResult, variant: FlowVariant): void {
|
||||
for (const connection of layout.connections) drawConnection(layer, connection, variant);
|
||||
for (const caption of layout.captions) {
|
||||
const style = CAPTION_STYLE[caption.role];
|
||||
// 严格索引:未知 role 不画注记,而不是画一个半成品
|
||||
if (style === undefined) continue;
|
||||
layer.append(
|
||||
svg(
|
||||
"text",
|
||||
{
|
||||
x: caption.x,
|
||||
y: caption.y,
|
||||
"text-anchor": caption.anchor,
|
||||
"font-family": style.font,
|
||||
"font-size": style.size(variant),
|
||||
...(style.weight ? { "font-weight": style.weight } : {}),
|
||||
fill: style.fill,
|
||||
...(layout.ghost ? { opacity: 0.55 } : {}),
|
||||
},
|
||||
caption.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlowDiagramProps {
|
||||
graph: FlowGraph | null | undefined;
|
||||
variant: FlowVariant;
|
||||
/** 点击活动节点(下钻);不传则节点不可点。 */
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
/** 双击活动节点就地改文字(标题/小字/主办);不传则不可改。 */
|
||||
onEditNode?: (nodeId: string, patch: { label: string; owner?: string }) => Promise<void>;
|
||||
/** 执行人候选(L1 业务能力)——传了则执行字段只能下拉选缩写,不开自由填。 */
|
||||
ownerOptions?: SpPosition[];
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
interface NodeEdit {
|
||||
node: FlowNode;
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export function FlowDiagram({ graph, variant, onNodeClick, onEditNode, ownerOptions, ariaLabel }: FlowDiagramProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const editRef = useRef<HTMLDivElement>(null);
|
||||
const clickRef = useRef(onNodeClick);
|
||||
clickRef.current = onNodeClick;
|
||||
const editNodeRef = useRef(onEditNode);
|
||||
editNodeRef.current = onEditNode;
|
||||
// 单击下钻延时:等一拍看是否双击(双击=改文字,取消下钻)。
|
||||
const drillTimer = useRef<number | null>(null);
|
||||
// 活动文字就地编辑浮层(双击活动)。
|
||||
const [nodeEdit, setNodeEdit] = useState<NodeEdit | null>(null);
|
||||
const [editDraft, setEditDraft] = useState({ label: "", owner: "" });
|
||||
const [editBusy, setEditBusy] = useState(false);
|
||||
const [editErr, setEditErr] = useState<string | null>(null);
|
||||
|
||||
const layout = useMemo(
|
||||
() => layoutFlow(graph ?? { nodes: [], rows: [], anchors: [], edges: [] }, variant),
|
||||
[graph, variant],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const diagram = new Diagram({
|
||||
canvas: { container },
|
||||
modules: [rendererModule, InteractionEventsModule],
|
||||
});
|
||||
const canvas = diagram.get<Canvas>("canvas");
|
||||
const factory = diagram.get<ElementFactory>("elementFactory");
|
||||
const eventBus = diagram.get<EventBus>("eventBus");
|
||||
|
||||
const root = factory.createRoot({ id: "flow-root" });
|
||||
canvas.setRootElement(root);
|
||||
|
||||
for (const shape of layout.shapes) {
|
||||
const businessObject: FlowBO = {
|
||||
node: shape.node,
|
||||
variant,
|
||||
clickable: Boolean(clickRef.current) && shape.node.kind === "activity" && !shape.node.ghost && !layout.ghost,
|
||||
};
|
||||
canvas.addShape(
|
||||
factory.createShape({
|
||||
id: shape.node.id,
|
||||
x: shape.x,
|
||||
y: shape.y,
|
||||
width: shape.w,
|
||||
height: shape.h,
|
||||
businessObject,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 连线与标注画在形状之下的图层(层序号小于默认元素层)。
|
||||
const annotationLayer = canvas.getLayer("flow-annotations", -1);
|
||||
drawAnnotations(annotationLayer as SVGElement, layout, variant);
|
||||
if (layout.ghost) {
|
||||
(canvas.getContainer().querySelector(".djs-container svg, svg") as SVGElement | null)?.setAttribute(
|
||||
"opacity",
|
||||
"0.75",
|
||||
);
|
||||
}
|
||||
|
||||
const overlayPos = (oe: MouseEvent | undefined, boxW: number, boxH: number) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const rawLeft = (oe?.clientX ?? rect.left + rect.width / 2) - rect.left;
|
||||
const rawTop = (oe?.clientY ?? rect.top + rect.height / 2) - rect.top;
|
||||
return {
|
||||
left: Math.max(8, Math.min(rawLeft, rect.width - boxW)),
|
||||
top: Math.max(8, Math.min(rawTop + 10, rect.height - boxH)),
|
||||
};
|
||||
};
|
||||
const onClick = (event: { element?: { businessObject?: FlowBO }; originalEvent?: MouseEvent }) => {
|
||||
const bo = event.element?.businessObject;
|
||||
if (!bo?.clickable) return;
|
||||
if (!clickRef.current) return;
|
||||
const id = bo.node.id;
|
||||
if (editNodeRef.current) {
|
||||
// 可改文字时:单击延一拍下钻,给双击留窗口。
|
||||
if (drillTimer.current) window.clearTimeout(drillTimer.current);
|
||||
drillTimer.current = window.setTimeout(() => {
|
||||
drillTimer.current = null;
|
||||
clickRef.current?.(id);
|
||||
}, 260);
|
||||
} else {
|
||||
clickRef.current(id);
|
||||
}
|
||||
};
|
||||
const onDblClick = (event: { element?: { businessObject?: FlowBO }; originalEvent?: MouseEvent }) => {
|
||||
const bo = event.element?.businessObject;
|
||||
if (!editNodeRef.current || bo?.node.kind !== "activity" || bo.node.ghost) return;
|
||||
if (drillTimer.current) {
|
||||
window.clearTimeout(drillTimer.current);
|
||||
drillTimer.current = null;
|
||||
}
|
||||
setEditDraft({ label: bo.node.label, owner: bo.node.owner ?? "" });
|
||||
setEditErr(null);
|
||||
setEditBusy(false);
|
||||
setNodeEdit({ node: bo.node, ...overlayPos(event.originalEvent, 300, 190) });
|
||||
};
|
||||
eventBus.on("element.click", onClick);
|
||||
eventBus.on("element.dblclick", onDblClick);
|
||||
setNodeEdit(null);
|
||||
|
||||
// 自动布局的网关/起点标注画在节点上方(负 y),需要顶部留白;
|
||||
// 手绘图旁注都在节点下方且布局已收顶,大留白反而碍眼。
|
||||
const topPad = graph?.manual ? 6 : DIAGRAM_TOP_PAD;
|
||||
const fit = () => {
|
||||
canvas.viewbox({
|
||||
x: 0,
|
||||
y: -topPad,
|
||||
width: layout.width,
|
||||
height: layout.height + topPad,
|
||||
});
|
||||
};
|
||||
fit();
|
||||
const observer = new ResizeObserver(() => {
|
||||
canvas.resized();
|
||||
fit();
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
diagram.destroy();
|
||||
};
|
||||
}, [layout, variant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodeEdit) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
if (editRef.current?.contains(event.target as Node)) return;
|
||||
setNodeEdit(null);
|
||||
};
|
||||
document.addEventListener("pointerdown", close, true);
|
||||
return () => document.removeEventListener("pointerdown", close, true);
|
||||
}, [nodeEdit]);
|
||||
|
||||
return (
|
||||
<div className="flow-diagram-wrap" style={{ position: "relative" }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className="flow-diagram"
|
||||
data-ghost={layout.ghost ? "true" : undefined}
|
||||
style={{ width: "100%", aspectRatio: `${layout.width} / ${layout.height + (graph?.manual ? 6 : DIAGRAM_TOP_PAD)}` }}
|
||||
/>
|
||||
{nodeEdit && (
|
||||
<div ref={editRef} className="gw-pop" style={{ left: nodeEdit.left, top: nodeEdit.top }} role="dialog" aria-label={`编辑活动 · ${nodeEdit.node.id}`}>
|
||||
<div className="gp-hd">
|
||||
<span className="gp-dia" aria-hidden>▭</span>
|
||||
<span className="gp-nm">{nodeEdit.node.id} · 改文字</span>
|
||||
</div>
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">标题</span>
|
||||
<input value={editDraft.label} maxLength={16} onChange={(e) => setEditDraft((d) => ({ ...d, label: e.target.value }))} />
|
||||
</div>
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">执行</span>
|
||||
{ownerOptions ? (
|
||||
<OwnerSelect value={editDraft.owner} positions={ownerOptions} onChange={(v) => setEditDraft((d) => ({ ...d, owner: v }))} />
|
||||
) : (
|
||||
<input value={editDraft.owner} maxLength={16} placeholder="执行人岗位缩写(可空)" onChange={(e) => setEditDraft((d) => ({ ...d, owner: e.target.value }))} />
|
||||
)}
|
||||
</div>
|
||||
{editErr && <p className="gp-basis muted" style={{ color: "#8f3330" }}>{editErr}</p>}
|
||||
<div className="gp-btns">
|
||||
<button type="button" className="be-btn ghost" disabled={editBusy} onClick={() => setNodeEdit(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="be-btn solid"
|
||||
disabled={editBusy || editDraft.label.trim().length === 0}
|
||||
onClick={() => {
|
||||
setEditBusy(true);
|
||||
setEditErr(null);
|
||||
editNodeRef
|
||||
.current?.(nodeEdit.node.id, {
|
||||
label: editDraft.label.trim(),
|
||||
...(editDraft.owner.trim() === "" ? {} : { owner: editDraft.owner.trim() }),
|
||||
})
|
||||
.then(() => setNodeEdit(null))
|
||||
.catch((err) => {
|
||||
setEditErr(err instanceof Error ? err.message : "保存失败");
|
||||
setEditBusy(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{editBusy ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 供节点导航等场景推导活动节点清单(与渲染同一数据源)。 */
|
||||
export function activityNodes(graph: FlowGraph | null | undefined): FlowNode[] {
|
||||
return (graph?.nodes ?? []).filter((node) => node.kind === "activity" && !node.ghost);
|
||||
}
|
||||
|
||||
export type { ShapeLayout };
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 编排画布 —— diagram-js 画布件的宿主(编码规范 §6.1「PRD 画布件 1:1 平移」)。
|
||||
*
|
||||
* 原型的 `HostExample.FlowFigureBlock` 同时带手绘编辑器;此处先接**只读态**:
|
||||
* 流程包 → `FlowGraph`(`pkg-to-graph`)→ 布局引擎算几何 → `FlowDiagram` 绘制。
|
||||
* 节点实例落位以角注呈现,几何不因运行态而变——**图是定义,标注是运行**。
|
||||
*/
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import { FlowDiagram } from './FlowDiagram.tsx';
|
||||
import { packageToGraph } from './pkg-to-graph.ts';
|
||||
import type { InstanceMark } from './pkg-to-graph.ts';
|
||||
import './flow-canvas.css';
|
||||
|
||||
export function FlowFigure({
|
||||
pkg,
|
||||
instances,
|
||||
onNodeClick,
|
||||
}: {
|
||||
pkg: ProcessPackage;
|
||||
instances: readonly InstanceMark[];
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
}) {
|
||||
const graph = packageToGraph(pkg, instances);
|
||||
return (
|
||||
<div className="process-desk">
|
||||
<div className="process-doc">
|
||||
<FlowDiagram
|
||||
graph={graph}
|
||||
variant="business"
|
||||
ariaLabel={`流程图 · ${pkg.name}`}
|
||||
{...(onNodeClick === undefined ? {} : { onNodeClick })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
/* ============================================================
|
||||
L2 流程图画布 + 手工绘图编辑器 —— 抽离自 Mind-Tinker
|
||||
apps/web/src/components/workspace/process/process-doc.css
|
||||
(仅保留流程图相关规则,选择器与原文件逐字一致)。
|
||||
画布是嵌在暗色工作区里的一张"白纸":组件必须放在
|
||||
.process-desk > .process-doc 容器内,这些 scoped 规则才生效。
|
||||
============================================================ */
|
||||
|
||||
/* ---------- 纸面容器与设计令牌 ---------- */
|
||||
.process-desk {
|
||||
background: #e7eaef;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
.process-doc {
|
||||
--ink: #161b22;
|
||||
--ink-soft: #3a4452;
|
||||
--muted: #6a7686;
|
||||
--steel: #46586a;
|
||||
--brass: #b07d2b;
|
||||
--brass-deep: #8a5f1c;
|
||||
--brass-wash: #f7f0e0;
|
||||
--line: #d8dee6;
|
||||
--grid: #eceff3;
|
||||
--alert: #b0413e;
|
||||
--mono: "SF Mono", "JetBrains Mono", "Menlo", "Roboto Mono", ui-monospace, monospace;
|
||||
--sans: "PingFang SC", "Noto Sans SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
|
||||
width: 100%;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
padding: 40px 44px 52px;
|
||||
box-shadow: 0 12px 40px -18px rgba(20, 27, 34, 0.3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---------- 区块小标题(view-lbl,编辑芯片右对齐) ---------- */
|
||||
.process-doc .view-lbl {
|
||||
font-family: var(--mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--steel);
|
||||
margin: 16px 0 2px;
|
||||
/* 编辑铅笔统一右对齐(2026-07 user rule)。 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.process-doc .view-lbl .be-pencil {
|
||||
margin-left: auto;
|
||||
}
|
||||
.process-doc .view-lbl.b {
|
||||
color: var(--brass-deep);
|
||||
}
|
||||
|
||||
/* ---------- 记号图例 ---------- */
|
||||
.process-doc .legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 16px;
|
||||
font-family: var(--mono);
|
||||
font-size: 9.5px;
|
||||
color: var(--ink-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--steel);
|
||||
background: #f7f9fb;
|
||||
padding: 9px 14px;
|
||||
margin: 12px 0 2px;
|
||||
}
|
||||
.process-doc .legend b {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.process-doc .legend .sep {
|
||||
color: var(--line);
|
||||
}
|
||||
|
||||
/* ---------- 框图容器(hero/cap)+ 只读画布(flow-diagram) ---------- */
|
||||
.process-doc .hero {
|
||||
margin: 16px 0 4px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
.process-doc .hero .cap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
padding: 2px 4px 10px;
|
||||
}
|
||||
.process-doc .flow-diagram {
|
||||
display: block;
|
||||
}
|
||||
.process-doc .flow-diagram .djs-container {
|
||||
background: transparent !important;
|
||||
}
|
||||
/* diagram-js 的 svg 没有 viewBox,靠 height=100% 属性撑满容器——
|
||||
CSS 优先级高于表现属性,别让下面静态 SVG 的 height:auto 把它压回 150px。 */
|
||||
.process-doc .flow-diagram svg {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
/* diagram-js 把 svg 设为可聚焦(tabindex),点击节点时它吃焦点会触发全局
|
||||
:focus-visible 蓝框——这张只读图的键盘焦点无导航意义(节点间无法 tab),
|
||||
去掉它的焦点轮廓;下钻的点击态由节点自身的 hover/pointer 表达。 */
|
||||
.process-doc .flow-diagram svg:focus,
|
||||
.process-doc .flow-diagram svg:focus-visible,
|
||||
.process-doc .flow-diagram .djs-container:focus,
|
||||
.process-doc .flow-diagram [tabindex]:focus,
|
||||
.process-doc .flow-diagram [tabindex]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
/* 只读画布:命中层默认不给 move 十字;可下钻的活动节点给手指。 */
|
||||
.process-doc .flow-diagram .djs-hit {
|
||||
cursor: default;
|
||||
}
|
||||
.process-doc .flow-diagram .flow-node-clickable .djs-hit {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------- 待确认红框(草稿预览) ---------- */
|
||||
.process-doc .changed {
|
||||
position: relative;
|
||||
outline: 2px solid var(--alert);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.process-doc .changed::after {
|
||||
content: "待确认";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 6px;
|
||||
background: var(--alert);
|
||||
color: #fff;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 1.5px 7px;
|
||||
border-radius: 2px;
|
||||
line-height: 1.5;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ---------- 浮层纸卡(gw-pop:双击活动改文字 + 编辑器属性卡共用) ---------- */
|
||||
.gw-pop {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
width: 288px;
|
||||
background: #fffdf9;
|
||||
border: 1px solid #c9c2b6;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(22, 27, 34, 0.22);
|
||||
padding: 9px 11px 10px;
|
||||
font-family: "PingFang SC", "Noto Sans SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.gw-pop .gp-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.gw-pop .gp-dia {
|
||||
color: #46586a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.gw-pop .gp-nm {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #161b22;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.gw-pop .gp-owner {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #8a5f1c;
|
||||
border: 1px solid #d8c9a8;
|
||||
background: #f6efe0;
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gw-pop .gp-basis {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #3a4452;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.gw-pop .gp-basis.muted {
|
||||
color: #8a93a0;
|
||||
}
|
||||
|
||||
/* ---------- 浮层按钮(be-btn) ---------- */
|
||||
.process-doc .be-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 6px;
|
||||
padding: 4px 12px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.process-doc .be-btn.ghost {
|
||||
border-color: #cfd8e2;
|
||||
background: #fff;
|
||||
color: #5a6b7e;
|
||||
}
|
||||
.process-doc .be-btn.ghost:hover {
|
||||
border-color: #9fb0c0;
|
||||
}
|
||||
.process-doc .be-btn.solid {
|
||||
background: #46586a;
|
||||
color: #fff;
|
||||
}
|
||||
.process-doc .be-btn.solid:hover {
|
||||
background: #3a4a5a;
|
||||
}
|
||||
.process-doc .be-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- 浮层编辑行(gp-edit-row) ---------- */
|
||||
/* 流程图活动文字就地编辑浮层(gw-pop 同款纸卡) */
|
||||
.gw-pop .gp-edit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.gw-pop .gp-edit-row .lb {
|
||||
flex: none;
|
||||
width: 34px;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
color: #5a6b7e;
|
||||
}
|
||||
.gw-pop .gp-edit-row input {
|
||||
flex: 1;
|
||||
border: 1px solid #cfd8e2;
|
||||
border-radius: 5px;
|
||||
padding: 3px 7px;
|
||||
font-size: 12px;
|
||||
color: #161b22;
|
||||
outline: none;
|
||||
}
|
||||
.gw-pop .gp-edit-row input:focus {
|
||||
border-color: #5a718c;
|
||||
}
|
||||
.gw-pop .gp-btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
/* ---------- 手工绘图编辑器(FlowEditor):珊格画布 + 工具条 + 属性卡 ---------- */
|
||||
.process-doc .fig-body {
|
||||
position: relative;
|
||||
}
|
||||
.gw-pop .be-btn.danger {
|
||||
color: #8f3330;
|
||||
border-color: #d8a5a3;
|
||||
}
|
||||
.gw-pop .gp-edit-row select,
|
||||
.gw-pop .gp-edit-row textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #cfd8e2;
|
||||
border-radius: 5px;
|
||||
padding: 3px 6px;
|
||||
font-size: 11.5px;
|
||||
color: #161b22;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
.gw-pop .gp-edit-row textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.flow-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.flow-editor .fe-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e2e7ed;
|
||||
border-radius: 7px;
|
||||
background: #f7f9fb;
|
||||
}
|
||||
.flow-editor .fe-bar .be-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flow-editor .fe-bar .be-btn.danger {
|
||||
color: #8f3330;
|
||||
border-color: #d8a5a3;
|
||||
}
|
||||
.flow-editor .fe-sep {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: #d8dfe7;
|
||||
margin: 0 2px;
|
||||
}
|
||||
.flow-editor .fe-el .fe-chip {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
transform: translateY(-1px);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.flow-editor .fe-chip.sop {
|
||||
color: #46586a;
|
||||
}
|
||||
.flow-editor .fe-chip.case {
|
||||
color: #3f7a5c;
|
||||
}
|
||||
.flow-editor .fe-chip.gw {
|
||||
color: #46586a;
|
||||
}
|
||||
.flow-editor .fe-chip.start {
|
||||
color: #3f7a5c;
|
||||
}
|
||||
.flow-editor .fe-chip.end {
|
||||
color: #2f5c45;
|
||||
}
|
||||
.flow-editor .fe-chip.bad {
|
||||
color: #b0413e;
|
||||
}
|
||||
.flow-editor .fe-hint {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
text-align: right;
|
||||
font-size: 10.5px;
|
||||
color: #8a93a0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.flow-editor .fe-error {
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d8a5a3;
|
||||
border-radius: 6px;
|
||||
background: #fdf3f2;
|
||||
color: #8f3330;
|
||||
font-size: 11.5px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.flow-editor .fe-canvas {
|
||||
position: relative;
|
||||
border: 1px solid #e2e7ed;
|
||||
border-radius: 7px;
|
||||
background: #fdfefe;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
height: 480px;
|
||||
}
|
||||
.flow-editor .fe-djs {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.flow-editor .fe-foot {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: #9aa6b4;
|
||||
}
|
||||
.gw-pop.fe-prop {
|
||||
width: 290px;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* ---------- diagram-js 元素盘/上下文小盘:浅色纸面主题 ---------- */
|
||||
/* 元素盘:画布左上角横向铺开(2026-07 user rule)。 */
|
||||
.flow-editor .djs-palette {
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe5ec;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 14px rgba(22, 27, 34, 0.08);
|
||||
width: auto;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.flow-editor .djs-palette .djs-palette-entries,
|
||||
.flow-editor .djs-palette .group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.flow-editor .djs-palette .entry {
|
||||
width: 38px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
font-size: 15px;
|
||||
color: #46586a;
|
||||
cursor: grab;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.flow-editor .djs-palette .entry:hover {
|
||||
background: #eef3f8;
|
||||
color: #1e3a5f;
|
||||
}
|
||||
.flow-editor .djs-context-pad {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe5ec;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 4px 14px rgba(22, 27, 34, 0.1);
|
||||
padding: 3px;
|
||||
width: auto;
|
||||
}
|
||||
.flow-editor .djs-context-pad .entry {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
color: #46586a;
|
||||
cursor: pointer;
|
||||
}
|
||||
.flow-editor .djs-context-pad .entry:hover {
|
||||
background: #eef3f8;
|
||||
}
|
||||
|
||||
/* SVG 图标:与画布元素同形同色(蓝/绿活动框、×/+ 菱形、粗细圆环、红卡点)。 */
|
||||
.fe-pi,
|
||||
.fe-ci {
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
.fe-pi-sop {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18"><rect x="1" y="2.5" width="22" height="13" rx="3" fill="%23E8F1FB" stroke="%234E7DAF" stroke-width="1.5"/></svg>');
|
||||
background-size: 22px auto;
|
||||
}
|
||||
.fe-pi-case {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18"><rect x="1" y="2.5" width="22" height="13" rx="3" fill="%23E6F1EA" stroke="%233F7A5C" stroke-width="1.5"/></svg>');
|
||||
background-size: 22px auto;
|
||||
}
|
||||
|
||||
.fe-pi-approval {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18"><rect x="1" y="2.5" width="22" height="13" rx="3" fill="%23FBF0DC" stroke="%23B98432" stroke-width="1.5"/><path d="M7.5 9.2l2.6 2.6 5-5" fill="none" stroke="%23B98432" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||||
background-size: 22px auto;
|
||||
}
|
||||
|
||||
/* ---------- 编辑芯片(view-lbl 里的 dc2-addbtn) ---------- */
|
||||
.process-doc .view-lbl .dc2-addbtn {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid #b9cfe8;
|
||||
border-radius: 6px;
|
||||
background: #edf4fc;
|
||||
color: #23507e;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.process-doc .view-lbl .dc2-addbtn:hover {
|
||||
border-color: #4e7daf;
|
||||
box-shadow: 0 0 0 2px rgba(78, 125, 175, 0.14);
|
||||
}
|
||||
/* 编辑态原位切换:取消/保存 双芯片占据「编辑」的位置(2026-07 user rule)。 */
|
||||
.process-doc .view-lbl .lbl-btns {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.process-doc .view-lbl .lbl-btns .dc2-addbtn {
|
||||
margin-left: 0;
|
||||
}
|
||||
.process-doc .view-lbl .dc2-addbtn.primary {
|
||||
background: #4e7daf;
|
||||
border-color: #4e7daf;
|
||||
color: #ffffff;
|
||||
}
|
||||
.process-doc .view-lbl .dc2-addbtn.primary:hover {
|
||||
background: #3f6a99;
|
||||
}
|
||||
.process-doc .view-lbl .dc2-addbtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- 元素盘/小盘 SVG 图标 + 图例小图标 + 选中描边 ---------- */
|
||||
.fe-pi-call {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18"><rect x="1" y="2.5" width="22" height="13" rx="3" fill="%23EEF1F5" stroke="%235C6B80" stroke-width="1.5"/><rect x="3.5" y="5" width="17" height="8" rx="1.6" fill="none" stroke="%235C6B80" stroke-width="1.1"/></svg>');
|
||||
background-size: 22px auto;
|
||||
}
|
||||
.fe-pi-gwx {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><polygon points="10,1 19,10 10,19 1,10" fill="%23EEF1F5" stroke="%2346586A" stroke-width="1.4"/><path d="M7.2 7.2l5.6 5.6M12.8 7.2l-5.6 5.6" stroke="%2346586A" stroke-width="1.6"/></svg>');
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
.fe-pi-gwand {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><polygon points="10,1 19,10 10,19 1,10" fill="%23EEF1F5" stroke="%2346586A" stroke-width="1.4"/><path d="M10 5.5v9M5.5 10h9" stroke="%2346586A" stroke-width="1.6"/></svg>');
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
.fe-pi-start {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="%23E6F1EA" stroke="%233F7A5C" stroke-width="1.8"/></svg>');
|
||||
background-size: 17px 17px;
|
||||
}
|
||||
.fe-pi-end {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><circle cx="10" cy="10" r="6.5" fill="%23E6F1EA" stroke="%232F5C45" stroke-width="3"/></svg>');
|
||||
background-size: 17px 17px;
|
||||
}
|
||||
.fe-pi-callout {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16"><rect x="1" y="2" width="22" height="12" rx="3" fill="%23F6E7E6" stroke="%23B0413E" stroke-width="1.5"/></svg>');
|
||||
background-size: 22px auto;
|
||||
}
|
||||
.fe-ci-connect {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M3.5 16.5L13 7" fill="none" stroke="%231E3A5F" stroke-width="1.7"/><polygon points="16.5,3.5 17.2,9 11.4,8.2" fill="%231E3A5F"/></svg>');
|
||||
background-size: 15px 15px;
|
||||
}
|
||||
.fe-ci-del {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4 6h12M8.2 6V4h3.6v2M6 6l1 10.5h6L14 6M8.6 9v4.5M11.4 9v4.5" fill="none" stroke="%238F3330" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"/></svg>');
|
||||
background-size: 15px 15px;
|
||||
}
|
||||
.fe-ci-edit {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4.5 15.5l1-3.8 8-8 2.8 2.8-8 8-3.8 1z" fill="%23fff" stroke="%235A6B7E" stroke-width="1.4" stroke-linejoin="round"/><path d="M12 5.2l2.8 2.8" stroke="%235A6B7E" stroke-width="1.2"/></svg>');
|
||||
background-size: 15px 15px;
|
||||
}
|
||||
/* 小盘里的元素图标略缩一号(24px 格)。 */
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-sop,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-case,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-approval,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-callout {
|
||||
background-size: 17px auto;
|
||||
}
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-gwx,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-gwand,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-start,
|
||||
.flow-editor .djs-context-pad .entry.fe-pi-end {
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
/* 图例小图标:与元素盘同一套 SVG,缩排进文字行(全局垂直居中契约)。 */
|
||||
.process-doc .legend .lg-i {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 14px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: auto 12px;
|
||||
vertical-align: middle;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.process-doc .legend .lg-i.fe-pi-sop,
|
||||
.process-doc .legend .lg-i.fe-pi-case,
|
||||
.process-doc .legend .lg-i.fe-pi-approval,
|
||||
.process-doc .legend .lg-i.fe-pi-callout {
|
||||
background-size: 18px auto;
|
||||
}
|
||||
.process-doc .legend .lg-i.lg-flow {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 12"><path d="M2 6h16" fill="none" stroke="%2346586A" stroke-width="1.6"/><polygon points="22,6 16,3 16,9" fill="%2346586A"/></svg>');
|
||||
background-size: 18px auto;
|
||||
}
|
||||
|
||||
/* 选中描边/悬停反馈(diagram-js outline)贴黄铜色。 */
|
||||
.flow-editor .djs-outline {
|
||||
stroke: #b58a2e;
|
||||
stroke-dasharray: 4 3;
|
||||
stroke-width: 1.2px;
|
||||
}
|
||||
.flow-editor .djs-element.hover .djs-outline {
|
||||
stroke: #b58a2e;
|
||||
}
|
||||
.flow-editor .djs-bendpoint .floating {
|
||||
fill: #b58a2e;
|
||||
}
|
||||
.flow-editor .djs-segment-dragger .djs-visual,
|
||||
.flow-editor .djs-bendpoint .djs-visual {
|
||||
fill: #b58a2e;
|
||||
stroke: #8a6a20;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
import { fitLine, type FlowNode, type FlowTone, type FlowVariant } from "./flow-layout";
|
||||
|
||||
/**
|
||||
* 流程图节点/连线的共用画笔:FlowDiagram(diagram-js 渲染)与 FlowEditor(手绘编辑)
|
||||
* 都从这里取形——同一套配色/字级/几何,编辑态与展示态不许有视觉漂移。
|
||||
*/
|
||||
|
||||
const svgNS = "http://www.w3.org/2000/svg";
|
||||
|
||||
export function svgEl<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number>,
|
||||
text?: string,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const el = document.createElementNS(svgNS, tag);
|
||||
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value));
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
export const FLOW_SANS = '"PingFang SC","Noto Sans SC","Hiragino Sans GB","Microsoft YaHei",sans-serif';
|
||||
export const FLOW_MONO = '"SF Mono","JetBrains Mono",Menlo,ui-monospace,monospace';
|
||||
|
||||
export const TONE_COLOR: Record<FlowTone, string> = { main: "#46586A", ok: "#3F7A5C", bad: "#B0413E" };
|
||||
|
||||
/** 节点画形(往 group 里追加图元;调用方自己定位 group)。 */
|
||||
export function paintNode(group: SVGElement, node: FlowNode, w: number, h: number, variant: FlowVariant): void {
|
||||
if (node.kind === "start" || node.kind === "end" || node.kind === "end-bad") {
|
||||
const bad = node.kind === "end-bad";
|
||||
group.append(
|
||||
svgEl("circle", {
|
||||
cx: w / 2,
|
||||
cy: h / 2,
|
||||
r: w / 2,
|
||||
fill: bad ? "#F6E7E6" : "#E6F1EA",
|
||||
stroke: bad ? "#B0413E" : "#3F7A5C",
|
||||
"stroke-width": node.kind === "start" ? 1.8 : bad ? 2.4 : 2.6,
|
||||
}),
|
||||
);
|
||||
} else if (node.kind === "gateway") {
|
||||
const hd = w / 2;
|
||||
// 2026-07 精简:网关无弹窗、无铜点——判据存于节点数据,导出派生表与 L3 upstreamGates 承接。
|
||||
group.append(
|
||||
svgEl("polygon", {
|
||||
points: `${hd},0 ${w},${hd} ${hd},${h} 0,${hd}`,
|
||||
fill: "#EEF1F5",
|
||||
stroke: "#46586A",
|
||||
"stroke-width": 1.3,
|
||||
}),
|
||||
);
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{
|
||||
x: hd,
|
||||
y: hd + 4,
|
||||
"text-anchor": "middle",
|
||||
"font-family": FLOW_MONO,
|
||||
"font-size": variant === "business" ? 13 : 12,
|
||||
"font-weight": 700,
|
||||
fill: "#46586A",
|
||||
},
|
||||
node.mode === "and" ? "+" : "×",
|
||||
),
|
||||
);
|
||||
} else if (node.kind === "callout") {
|
||||
// 卡点统一红(绿色让位给 Case 活动节点);去向以框内 →id 表达。
|
||||
group.append(
|
||||
svgEl("rect", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: w,
|
||||
height: h,
|
||||
rx: variant === "business" ? 5 : 6,
|
||||
fill: "#F6E7E6",
|
||||
stroke: "#B0413E",
|
||||
"stroke-width": variant === "business" ? 1.2 : 1.3,
|
||||
}),
|
||||
);
|
||||
const fitted = fitLine(
|
||||
node.to ? `${node.label}→${node.to}` : node.label,
|
||||
w - 12,
|
||||
variant === "business" ? 10 : 11.5,
|
||||
9,
|
||||
);
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{
|
||||
x: w / 2,
|
||||
y: h / 2 + 3.5,
|
||||
"text-anchor": "middle",
|
||||
"font-family": FLOW_SANS,
|
||||
"font-size": fitted.size,
|
||||
"font-weight": 600,
|
||||
fill: "#8F3330",
|
||||
},
|
||||
fitted.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 方框三色:标准活动 浅蓝(缺省)/ 案例活动 绿(一事一议)/ 审批点 琥珀(人裁节点)。
|
||||
// 审批点非活动,只是共用方框几何——模型上它是系统内置的编排要素(见 flow-layout 的 flavor 注释)。
|
||||
// 活动调用已整体退役(2026-07 终拍:所有节点都是内置节点;schema 容忍遗留 wait 但不再有专属画法)。
|
||||
const ghost = node.ghost === true;
|
||||
const isCase = node.flavor === "case";
|
||||
const isApproval = node.flavor === "approval";
|
||||
const skin = isApproval
|
||||
? { fill: "#FBF0DC", stroke: "#B98432", ink: "#6E4B12" }
|
||||
: isCase
|
||||
? { fill: "#E6F1EA", stroke: "#3F7A5C", ink: "#2F5C45" }
|
||||
: { fill: "#E8F1FB", stroke: "#4E7DAF", ink: "#161B22" };
|
||||
group.append(
|
||||
svgEl("rect", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: w,
|
||||
height: h,
|
||||
rx: 6,
|
||||
fill: ghost ? "#F4F6F9" : skin.fill,
|
||||
stroke: ghost ? "#9AA6B4" : skin.stroke,
|
||||
"stroke-width": ghost ? 1.2 : 1.3,
|
||||
...(ghost ? { "stroke-dasharray": "4 3" } : {}),
|
||||
}),
|
||||
);
|
||||
const titleSize = variant === "business" ? 12 : 11.5;
|
||||
const inkColor = ghost ? "#6A7686" : skin.ink;
|
||||
const title = fitLine(node.label, w - 12, titleSize, 10);
|
||||
// L2 主图只留 标号/活动名/责任角标(2026-07 精简)——sub 小字仅步骤图(steps)渲染。
|
||||
if (node.sub && variant !== "business") {
|
||||
const sub = fitLine(node.sub, w - 12, 8.6, 8);
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{ x: w / 2, y: 18, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": title.size, "font-weight": 600, fill: inkColor },
|
||||
title.text,
|
||||
),
|
||||
);
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{ x: w / 2, y: 32, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": sub.size, fill: ghost ? "#9AA6B4" : "#6A7686" },
|
||||
sub.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{ x: w / 2, y: h / 2 + 4, "text-anchor": "middle", "font-family": FLOW_SANS, "font-size": title.size, "font-weight": 600, fill: inkColor },
|
||||
title.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
// 标号角标:business 一贯有;步骤图(steps)的真实节点也要 S#/B# 编号与指导书条目对齐
|
||||
// (2026-07 用户纠正)——ghost 骨架节点不标。
|
||||
if (variant === "business" || !ghost) {
|
||||
group.append(
|
||||
svgEl("text", { x: 6, y: 11, "font-family": FLOW_MONO, "font-size": 8.5, fill: "#8A93A0" }, node.code ?? node.id),
|
||||
);
|
||||
}
|
||||
// 责任下沉:执行人角标在右上——图即责任表。
|
||||
if (node.owner && !ghost) {
|
||||
const ownerFit = fitLine(node.owner, w * 0.55, 8.5, 7);
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{
|
||||
x: w - 6,
|
||||
y: 11,
|
||||
"text-anchor": "end",
|
||||
"font-family": FLOW_SANS,
|
||||
"font-size": ownerFit.size,
|
||||
"font-weight": 600,
|
||||
fill: skin.stroke,
|
||||
},
|
||||
ownerFit.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
// 分工角标已随词签退役(2026-07 user「删除 AI/系统/人 选项配置」):步骤图不再画 assign 角标。
|
||||
}
|
||||
}
|
||||
|
||||
/** 箭头三角(按折线最后一段方向)。 */
|
||||
export function arrowHeadPoints(waypoints: Array<{ x: number; y: number }>): string {
|
||||
const tip = waypoints[waypoints.length - 1];
|
||||
const prev = waypoints[waypoints.length - 2];
|
||||
// 严格索引:两点以下画不出箭头,直接退空(布局引擎不会产出这种连线,此处只为类型闭合)
|
||||
if (tip === undefined || prev === undefined) return "";
|
||||
const dx = Math.sign(tip.x - prev.x);
|
||||
const dy = Math.sign(tip.y - prev.y);
|
||||
return dx !== 0
|
||||
? `${tip.x},${tip.y} ${tip.x - dx * 9},${tip.y - 4.5} ${tip.x - dx * 9},${tip.y + 4.5}`
|
||||
: `${tip.x},${tip.y} ${tip.x - 4.5},${tip.y - dy * 9} ${tip.x + 4.5},${tip.y - dy * 9}`;
|
||||
}
|
||||
|
||||
export const CAPTION_STYLE: Record<string, { fill: string; size: (v: FlowVariant) => number; font: string; weight?: number }> = {
|
||||
gateway: { fill: "#3A4452", size: (v) => (v === "business" ? 10 : 9), font: FLOW_SANS, weight: 600 },
|
||||
start: { fill: "#3F7A5C", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 },
|
||||
end: { fill: "#2F5C45", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 },
|
||||
"end-bad": { fill: "#8F3330", size: (v) => (v === "business" ? 10 : 8.6), font: FLOW_SANS, weight: 600 },
|
||||
note: { fill: "#8A5F1C", size: (v) => (v === "business" ? 9.8 : 8.6), font: FLOW_MONO },
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 流程包 → 画布图模型:**连线即映射**的可视一侧。
|
||||
*
|
||||
* 画布件(`flow-layout` / `flow-node-paint` / `FlowDiagram`)是框架无关的纯几何与画笔,
|
||||
* 它认的是 `FlowGraph`;我们的权威模型是 `ProcessPackage`。这里做且只做一次形状转换,
|
||||
* **不产生任何新语义** —— 节点仍是编排层五要素,审批点仍是系统内置人裁节点(共用方框几何而已)。
|
||||
*/
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import type { FlowEdge, FlowGraph, FlowNode } from './flow-layout.ts';
|
||||
|
||||
/** 节点实例落位:running / waiting / terminal 决定描边高亮,不改几何 */
|
||||
export type InstanceMark = {
|
||||
readonly nodeId: string;
|
||||
readonly running: boolean;
|
||||
readonly waiting: boolean;
|
||||
readonly activated: boolean;
|
||||
readonly terminal?: string | undefined;
|
||||
};
|
||||
|
||||
/** 每行最多几个方框 —— 蛇形折行的行宽;取原型 business 预设的观感 */
|
||||
const ROW_WIDTH = 4;
|
||||
|
||||
export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark[] = []): FlowGraph {
|
||||
const markOf = new Map(marks.map((m) => [m.nodeId, m]));
|
||||
|
||||
const nodes: FlowNode[] = pkg.nodes.map((n) => {
|
||||
const mark = markOf.get(n.id);
|
||||
const state =
|
||||
mark === undefined || !mark.activated
|
||||
? undefined
|
||||
: mark.terminal !== undefined
|
||||
? mark.terminal === 'released'
|
||||
? '已放行'
|
||||
: '已终止'
|
||||
: mark.running
|
||||
? '运行中'
|
||||
: mark.waiting
|
||||
? '等待中'
|
||||
: '已激活';
|
||||
|
||||
if (n.kind === 'gateway') {
|
||||
return {
|
||||
id: n.id,
|
||||
kind: 'gateway',
|
||||
label: n.label,
|
||||
// 并行网关画 +,异或网关画 ×(判断)
|
||||
mode: n.gateway === 'parallel' ? 'and' : 'x',
|
||||
...(n.mode === 'join' ? { sub: '汇合' } : { sub: '分叉' }),
|
||||
...(state === undefined ? {} : { note: state }),
|
||||
};
|
||||
}
|
||||
|
||||
if (n.kind === 'start') return { id: n.id, kind: 'start', label: n.label };
|
||||
if (n.kind === 'end') return { id: n.id, kind: 'end', label: n.label };
|
||||
|
||||
const base: FlowNode = {
|
||||
id: n.id,
|
||||
kind: 'activity',
|
||||
label: n.label,
|
||||
code: n.id,
|
||||
...(state === undefined ? {} : { note: state }),
|
||||
};
|
||||
if (n.kind === 'approval') {
|
||||
// approval **不是一种活动**:它挂在 kind:"activity" 下仅因共用同一套方框几何
|
||||
return { ...base, flavor: 'approval', owner: n.approver.position };
|
||||
}
|
||||
return { ...base, owner: n.owner.position };
|
||||
});
|
||||
|
||||
/** 蛇形折行:偶数行 L→R、奇数行 R→L —— 行的划分交给拓扑序 */
|
||||
const order = topologicalOrder(pkg);
|
||||
const rows: string[][] = [];
|
||||
for (let i = 0; i < order.length; i += ROW_WIDTH) rows.push(order.slice(i, i + ROW_WIDTH));
|
||||
|
||||
const edges: FlowEdge[] = pkg.edges.map((e) => ({
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
// 回边按声明走「回 A4 重开新实例」,视觉上标红以区分正向流转
|
||||
...(e.reopen ? { tone: 'bad' as const } : {}),
|
||||
...(e.label === undefined ? (e.condition === undefined ? {} : { label: e.condition }) : { label: e.label }),
|
||||
}));
|
||||
|
||||
return { nodes, rows, anchors: [], edges };
|
||||
}
|
||||
|
||||
/** 拓扑序:回边不参与定序(否则成环),同层按声明顺序稳定排列 */
|
||||
function topologicalOrder(pkg: ProcessPackage): string[] {
|
||||
const ids = pkg.nodes.map((n) => n.id as string);
|
||||
const forward = pkg.edges.filter((e) => !e.reopen);
|
||||
const indegree = new Map<string, number>(ids.map((id) => [id, 0]));
|
||||
for (const e of forward) indegree.set(e.to, (indegree.get(e.to) ?? 0) + 1);
|
||||
|
||||
const ready = ids.filter((id) => indegree.get(id) === 0);
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (ready.length > 0) {
|
||||
const id = ready.shift()!;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
for (const e of forward.filter((x) => x.from === id)) {
|
||||
const left = (indegree.get(e.to) ?? 0) - 1;
|
||||
indegree.set(e.to, left);
|
||||
if (left === 0) ready.push(e.to);
|
||||
}
|
||||
}
|
||||
// 环(理论上闭合校验已挡住)或孤点:兜底补齐,画布不因数据异常而空白
|
||||
for (const id of ids) if (!seen.has(id)) out.push(id);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 抽离配套件:画布组件对宿主应用仅有的两个依赖,原样搬来使之自足。
|
||||
* - SpPosition 来自 apps/web/src/lib/process-types.ts(业务能力/岗位台账行)
|
||||
* - OwnerSelect 来自 apps/web/src/components/workspace/process/BlockEdit.tsx
|
||||
* 执行人(owner)是按"能力缩写"的软引用:活动框右上角标、属性卡的执行下拉都用它。
|
||||
*/
|
||||
|
||||
/** 能力岗位:工作总览里的角色台账。 */
|
||||
export interface SpPosition {
|
||||
id: string;
|
||||
name: string;
|
||||
abbr: string;
|
||||
icon?: string;
|
||||
/** 绑定的负责人账号 userId(1:1)。 */
|
||||
account?: string;
|
||||
}
|
||||
|
||||
/** 执行人下拉:只能选能力缩写,空=不标;遗留值保留为一项防静默改值。 */
|
||||
export function OwnerSelect({
|
||||
value,
|
||||
onChange,
|
||||
positions,
|
||||
emptyLabel = "(不标执行人)",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
positions: SpPosition[] | undefined;
|
||||
emptyLabel?: string;
|
||||
}) {
|
||||
// owner 是按缩写的软引用——同缩写只保留一条(否则重复能力会在下拉里重影)。
|
||||
const seenAbbr = new Set<string>();
|
||||
const options = (positions ?? []).filter((p) => {
|
||||
const abbr = p.abbr.trim();
|
||||
if (!abbr || seenAbbr.has(abbr)) return false;
|
||||
seenAbbr.add(abbr);
|
||||
return true;
|
||||
});
|
||||
const legacy = value && !options.some((p) => p.abbr === value) ? value : null;
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">{emptyLabel}</option>
|
||||
{legacy && <option value={legacy}>{legacy}(遗留)</option>}
|
||||
{options.map((p) => (
|
||||
<option key={p.id} value={p.abbr}>
|
||||
{p.abbr} · {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user