- def-editor 新增画布模式(默认):bpmn-js 内存画布(精简 palette/汉化/撤销重做/缩放/出口命名/导出 BPMN), toBpmn/fromBpmn 双向序列化,contentJson 仍为持久格式(+layout 坐标);卡片模式保留回退 - 新增 utils/flowLayout.ts 分层布局(设计器与查看器共用) - TaskHandle:多出口节点出口选择下拉;退回/移交按钮与弹窗;轨迹显示已退回/已移交及所选出口 - FlowDiagram:优先使用保存坐标;边画出口名标签;办件状态着色/当前节点高亮 - api/flow:approve 带 edgeName,新增 returnBack/transfer
This commit is contained in:
@@ -31,6 +31,7 @@ import NodeList from "./components/NodeList.vue";
|
||||
import ApprovalDrawer from "./components/ApprovalDrawer.vue";
|
||||
import SettingsDrawer from "./components/SettingsDrawer.vue";
|
||||
import JsonDrawer from "./components/JsonDrawer.vue";
|
||||
import BpmnCanvas from "./components/BpmnCanvas.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -53,6 +54,64 @@ const depts = reactive<DeptOption[]>([]);
|
||||
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
|
||||
const userNames = reactive<Record<number, string>>({});
|
||||
|
||||
// ==================== 画布模式(默认):bpmn-js 建模任意 DAG ====================
|
||||
/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流(保留回退) */
|
||||
const editMode = ref<"canvas" | "cards">("canvas");
|
||||
const bpmnWrap = ref<InstanceType<typeof BpmnCanvas> | null>(null);
|
||||
/** 画布数据基准(载入时的 contentJson,供配置按 id 继承) */
|
||||
const canvasBase = ref<any>(null);
|
||||
/** 画布上审批节点的配置快照(element id → ApprovalNode 配置),点卡片写入,保存时并入 nodes */
|
||||
const canvasConfigs = reactive<Record<string, any>>({});
|
||||
/** 原始 contentJson 快照(画布脏检查基准) */
|
||||
const sourceJson = ref("");
|
||||
|
||||
function onCanvasNode(_el: any): void {
|
||||
// selection.changed 自画布 emit:选中审批节点 → 打开配置抽屉
|
||||
if (!_el || _el.type !== "bpmn:UserTask") return;
|
||||
const id = String(_el.businessObject.id || _el.id);
|
||||
const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {};
|
||||
if (!canvasConfigs[id]) {
|
||||
canvasConfigs[id] = {
|
||||
uid: id,
|
||||
kind: "approval",
|
||||
name: _el.businessObject.name || baseNode.name || "审批",
|
||||
assignee: baseNode.assignee || null,
|
||||
countersign: baseNode.countersign || "ALL",
|
||||
deadlineHours: baseNode.deadlineHours || null,
|
||||
};
|
||||
}
|
||||
editingUid.value = id;
|
||||
nodeDrawer.value = true;
|
||||
}
|
||||
|
||||
function canvasRemoveNode(uid: string): void {
|
||||
bpmnWrap.value?.removeNode(uid);
|
||||
delete canvasConfigs[uid];
|
||||
}
|
||||
|
||||
/** 画布模式序列化:画布 → contentJson(叠加 code/name/settings 与审批配置) */
|
||||
function serializeCanvas(): string {
|
||||
const canvasJson = bpmnWrap.value?.serialize(
|
||||
canvasBase.value,
|
||||
model.code.trim(),
|
||||
model.name.trim()
|
||||
);
|
||||
if (!canvasJson) return "";
|
||||
// 配置并入:canvasConfigs 以 element id 为键覆盖审批节点业务属性
|
||||
canvasJson.nodes.forEach((n: any) => {
|
||||
const cfg = canvasConfigs[String(n.id)];
|
||||
if (cfg && n.type === "APPROVAL") {
|
||||
n.name = cfg.name || n.name;
|
||||
if (cfg.assignee) n.assignee = cfg.assignee;
|
||||
if (cfg.countersign && cfg.countersign !== "ALL") n.countersign = cfg.countersign;
|
||||
if (cfg.deadlineHours) n.deadlineHours = cfg.deadlineHours;
|
||||
}
|
||||
});
|
||||
canvasJson.settings = { rejectPolicy: "END_REJECTED" };
|
||||
void model.settings; // 设置抽屉暂绑定卡模型(画布模式直接采用卡模型设置)
|
||||
return JSON.stringify(canvasJson);
|
||||
}
|
||||
|
||||
const issues = computed<Issue[]>(() => validateModel(model, depts));
|
||||
const defJson = computed(() => toContentJson(model));
|
||||
|
||||
@@ -67,6 +126,9 @@ const jsonDrawer = ref(false);
|
||||
const editingUid = ref("");
|
||||
|
||||
const editingNode = computed<ApprovalNode | null>(() => {
|
||||
if (editMode.value === "canvas") {
|
||||
return canvasConfigs[editingUid.value] || null;
|
||||
}
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
return item && item.kind === "approval" ? item : null;
|
||||
});
|
||||
@@ -137,10 +199,20 @@ async function load(): Promise<void> {
|
||||
sourceVersion.value = d?.version ?? 0;
|
||||
model.code = d?.code || "";
|
||||
model.name = d?.name || "";
|
||||
sourceJson.value = d?.contentJson || "{}";
|
||||
try {
|
||||
canvasBase.value = fromContentJson(JSON.parse(d?.contentJson || "{}")) as any;
|
||||
} catch {
|
||||
// 解析失败仍保留原始 JSON 供画布直接读取(fromContentJson 卡片树可能不支持)
|
||||
canvasBase.value = JSON.parse(d?.contentJson || "{}");
|
||||
}
|
||||
try {
|
||||
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
|
||||
} catch (e: any) {
|
||||
Message.error(`既有定义无法还原为卡片视图:${e?.message || e}。请改用源码模式编辑。`);
|
||||
// 画布模式可编辑管线不支持的结构(多出口等),不再强制卡片树还原
|
||||
if (editMode.value === "cards") {
|
||||
Message.error(`既有定义无法还原为卡片视图:${e?.message || e}。请切换画布模式编辑。`);
|
||||
}
|
||||
}
|
||||
await resolveUserNames();
|
||||
savedSnapshot.value = currentSnapshot.value;
|
||||
@@ -171,6 +243,10 @@ function openNode(uid: string): void {
|
||||
}
|
||||
|
||||
function removeNode(uid: string): void {
|
||||
if (editMode.value === "canvas") {
|
||||
canvasRemoveNode(uid);
|
||||
return;
|
||||
}
|
||||
const siblings = containerOfItem(uid);
|
||||
if (siblings && siblings.length <= 1) {
|
||||
Message.warning("每个流程/分支至少保留一个节点");
|
||||
@@ -236,10 +312,25 @@ async function onSave(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const errors = countErrors(issues.value);
|
||||
if (errors.length) {
|
||||
if (errors.length && editMode.value === "cards") {
|
||||
Message.error(`校验未通过:${errors[0].text}`);
|
||||
return;
|
||||
}
|
||||
let content: string;
|
||||
if (editMode.value === "canvas") {
|
||||
content = serializeCanvas();
|
||||
const problems = checkCanvas(content);
|
||||
if (problems.length) {
|
||||
Message.error(problems[0]);
|
||||
return;
|
||||
}
|
||||
if (!content || content === "{}") {
|
||||
Message.warning("画布为空,无法保存");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
content = JSON.stringify(toContentJson(model));
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
// code/name 同时出现在 body 与 contentJson 且取值同源,规避 contentJson 静默覆盖行字段的既有坑
|
||||
@@ -247,7 +338,7 @@ async function onSave(): Promise<void> {
|
||||
id: editId.value || undefined,
|
||||
code: model.code.trim(),
|
||||
name: model.name.trim(),
|
||||
contentJson: JSON.stringify(toContentJson(model)),
|
||||
contentJson: content,
|
||||
baseGuid: baseGuid.value || undefined,
|
||||
}).send();
|
||||
savedSnapshot.value = currentSnapshot.value;
|
||||
@@ -258,6 +349,72 @@ async function onSave(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 画布产物轻校验(保存前拦截;结构完整校验仍以发布期 DefValidator 为准) */
|
||||
function checkCanvas(content: string): string[] {
|
||||
const problems: string[] = [];
|
||||
let def: any;
|
||||
try {
|
||||
def = JSON.parse(content);
|
||||
} catch {
|
||||
return ["画布数据序列化失败,请重试"];
|
||||
}
|
||||
const nodes = def.nodes || [];
|
||||
const edges = def.edges || [];
|
||||
const nodeIds = new Set(nodes.map((n: any) => n.id));
|
||||
if (nodes.filter((n: any) => n.type === "START").length !== 1) {
|
||||
problems.push("画布必须有且仅有 1 个开始节点");
|
||||
}
|
||||
if (nodes.filter((n: any) => n.type === "END").length !== 1) {
|
||||
problems.push("画布必须有且仅有 1 个结束节点");
|
||||
}
|
||||
nodes.forEach((n: any) => {
|
||||
if (n.type === "APPROVAL") {
|
||||
if (!n.name) problems.push("审批节点未命名");
|
||||
if (!n.assignee) problems.push("审批节点「" + (n.name || n.id) + "」缺少审批人配置");
|
||||
}
|
||||
});
|
||||
edges.forEach((e: any) => {
|
||||
if (!nodeIds.has(e.to) || !nodeIds.has(e.from)) {
|
||||
problems.push("存在指向缺失节点的连线,请放置后重画");
|
||||
}
|
||||
});
|
||||
// 多出口命名(镜像后端 DefValidator.validateOutlets 口径)
|
||||
const outNames: Record<string, Set<string>> = {};
|
||||
edges.forEach((e: any) => {
|
||||
(outNames[e.from] = outNames[e.from] || new Set()).add(e.name || "");
|
||||
});
|
||||
nodes.forEach((n: any) => {
|
||||
const outs = edges.filter((e: any) => e.from === n.id);
|
||||
if (n.type === "APPROVAL" && outs.length > 1) {
|
||||
outs.forEach((e: any) => {
|
||||
if (!e.name?.trim()) problems.push(`多出口节点「${n.name || n.id}」每个出口必须命名(按钮文案)`);
|
||||
});
|
||||
outNames[n.id]?.forEach((x) => {
|
||||
// 重复名展示一次即可
|
||||
});
|
||||
}
|
||||
});
|
||||
// 可达性(simple BFS from start,无孤儿)
|
||||
const start = def.start;
|
||||
if (start) {
|
||||
const seen = new Set([start]);
|
||||
const queue = [start];
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()!;
|
||||
edges.forEach((e: any) => {
|
||||
if (e.from === cur && nodeIds.has(e.to) && !seen.has(e.to)) {
|
||||
seen.add(e.to);
|
||||
queue.push(e.to);
|
||||
}
|
||||
});
|
||||
}
|
||||
nodes.forEach((n: any) => {
|
||||
if (!seen.has(n.id)) problems.push(`从开始不可达的节点:${n.name || n.id}`);
|
||||
});
|
||||
}
|
||||
return [...new Set(problems)];
|
||||
}
|
||||
|
||||
function onOpenDiagram(): void {
|
||||
if (!editId.value) return;
|
||||
const href = router.resolve({ path: "/diagram", query: { defId: String(editId.value), mode: "auto" } }).href;
|
||||
@@ -332,6 +489,10 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</label>
|
||||
</div>
|
||||
<div class="hd-right">
|
||||
<RadioGroup v-model="editMode" size="small" type="button">
|
||||
<Radio label="canvas">画布</Radio>
|
||||
<Radio label="cards">卡片</Radio>
|
||||
</RadioGroup>
|
||||
<Button @click="settingsDrawer = true">流程设置</Button>
|
||||
<Button @click="jsonDrawer = true">源码</Button>
|
||||
<Button :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
|
||||
@@ -339,7 +500,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main v-if="!loading" class="ed-canvas">
|
||||
<!-- 画布模式:bpmn-js 建模(多出口/并联/退回环路均可画) -->
|
||||
<BpmnCanvas v-if="!loading && editMode === 'canvas'" ref="bpmnWrap"
|
||||
:source="sourceJson" @node-config="onCanvasNode" />
|
||||
|
||||
<main v-if="!loading && editMode === 'cards'" class="ed-canvas">
|
||||
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 并行分支)</div>
|
||||
<div class="ed-flow">
|
||||
<div class="capsule start">发起开始</div>
|
||||
@@ -347,7 +512,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
<div class="capsule end">流程结束</div>
|
||||
</div>
|
||||
</main>
|
||||
<main v-else class="ed-canvas loading">
|
||||
<main v-else-if="loading" class="ed-canvas loading">
|
||||
<Spin size="large" fix />
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user