feat(admin): 画布↔卡片数据打通——卡片树为唯一数据源(uid 即 contentJson 节点 id, 画布=contentJson 投影/反解); 多人审批默认或签; 选人弹窗换 /admin/user/teacherTree 教师树; 示意图页三视图(示意图/画布视图/进度图PNG)
build / build (push) Successful in 2m3s

This commit is contained in:
chenxing
2026-09-17 14:52:55 +08:00
parent f9d52d2f5d
commit b76c419a6f
7 changed files with 288 additions and 52 deletions
+205 -36
View File
@@ -4,7 +4,7 @@
// 入口三态(query):无参=新建;?id={defId}=编辑草稿;?id={defId}&baseGuid={flowGuid}=升版本(载入该版本内容为底稿)。
// 保存只产草稿,走既有 POST /api/flow/admin/def/save;发布仍留在流程定义列表页(后端 DefValidator 终审)。
// 编辑态为结构化树(START/END 隐式固定端点),结构类校验错误从构造上不可能出现。
import { computed, onMounted, onUnmounted, provide, reactive, ref } from "vue";
import { computed, onMounted, onUnmounted, provide, reactive, ref, watch } from "vue";
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { flowApi } from "@/api/module/flow";
@@ -54,21 +54,41 @@ const depts = reactive<DeptOption[]>([]);
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
const userNames = reactive<Record<number, string>>({});
// ==================== 画布模式(默认):bpmn-js 建模任意 DAG ====================
/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流(保留回退) */
// ==================== 双模式统一(2026-09-17):卡片树 = 唯一数据源,画布 = 其可视化投影 ====================
/**
* 旧实现画布/卡片各持一份基准(sourceJson vs model),切换后互相读不到对方改动,"切回去数据变初始化"。
* 现统一为:EditorModel 永远持有业务配置(审批人/会签/时限)与卡片结构;
* - 进画布:canvasSource = toContentJson(model),节点 id 直接用卡片 uid(convert.ts),天然一一对应;
* - 出画布(切卡片/保存/源码应用后):serializeUnified 以 model 为 base 让 bpmn 画布结构性改动
* (新增节点/改连线/布局)叠加其上,再经 fromContentJson 反解回卡片树;卡片树不支持的结构(多出口
* DAG 等)拒绝切卡片,留在画布编辑。
*/
/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流 */
const editMode = ref<"canvas" | "cards">("canvas");
/** 画布侧数据是否可反解为卡片树(载入时探测,决定卡片单选是否可用) */
const cardsBroken = ref(false);
const bpmnWrap = ref<InstanceType<typeof BpmnCanvas> | null>(null);
/** 画布数据基准(载入时的 contentJson,供配置按 id 继承) */
/** 画布数据源:载入用原始 contentJson;由卡片切回画布时用卡片树投影覆盖 */
const canvasSource = ref("");
/** 载入时的原始 contentJson(画布上"卡片树不存在"的新画节点配置兜底) */
const canvasBase = ref<any>(null);
/** 画布上审批节点的配置快照(element id → ApprovalNode 配置),点卡片写入,保存时并入 nodes */
/** 画布上新画节点的配置快照(element id → ApprovalNode 配置),保存时并入 nodes */
const canvasConfigs = reactive<Record<string, any>>({});
/** 原始 contentJson 快照(画布脏检查基准) */
/** 原始 contentJson 快照(画布裁剪兜底与 JsonDrawer 应用基准) */
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 item = findItem(model, id);
if (item && item.kind === "approval") {
// 卡片树已有节点:抽屉直接编辑卡片本体(id=uid 天然对应)
editingUid.value = id;
nodeDrawer.value = true;
return;
}
// 画布上新画的节点(卡片树里没有):配置进 canvasConfigs,保存时并入
const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {};
if (!canvasConfigs[id]) {
canvasConfigs[id] = {
@@ -76,7 +96,7 @@ function onCanvasNode(_el: any): void {
kind: "approval",
name: _el.businessObject.name || baseNode.name || "审批",
assignee: baseNode.assignee || null,
countersign: baseNode.countersign || "ALL",
countersign: baseNode.countersign || "ANY",
deadlineHours: baseNode.deadlineHours || null,
};
}
@@ -89,35 +109,91 @@ function canvasRemoveNode(uid: string): void {
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) => {
/** 画布序列化 → contentJson 字符串:结构性内容(节点/连线/布局)出自画布,
* 业务配置(审批人/会签/时限)以卡片树为 base 按 id 继承,settings(含催办)随 base 带出 */
function serializeUnified(): string {
const canvasJson = snapshotCanvasObject();
// canvasConfigs(画布新画节点的抽屉配置)按 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.countersign) 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));
/** 脏检查基准(载入/保存后刷新);defJson 已含 code/name/settings,可完整代表待保存内容 */
/** 序列化 base:原始 contentJson 为底(保住卡片树不支持结构/新画节点的既有业务配置),
* code/name/settings 与卡片树审批节点配置(审批人/会签/时限)以卡片树为准覆盖 */
function buildSerializeBase(): any {
const modelDef = toContentJson(model);
const base = JSON.parse(JSON.stringify(canvasBase.value || {}));
base.code = model.code.trim();
base.name = model.name.trim();
base.settings = JSON.parse(JSON.stringify(modelDef.settings));
const byId: Record<string, any> = {};
(base.nodes || []).forEach((n: any) => {
byId[String(n.id)] = n;
});
model.list.forEach((item) => {
if (item.kind !== "approval") return;
const merged: any = { ...(byId[item.uid] || {}) };
merged.id = item.uid;
merged.type = "APPROVAL";
merged.name = item.name || "审批";
if (item.assignee) merged.assignee = JSON.parse(JSON.stringify(item.assignee));
if (item.countersign === "ANY") merged.countersign = "ANY";
else delete merged.countersign; // 后端缺省即会签,不回写
if (item.deadlineHours) merged.deadlineHours = item.deadlineHours;
byId[item.uid] = merged;
});
base.nodes = Object.values(byId);
return base;
}
/** 画布未挂载(载入中/刚切换未挂好)时,快照退回卡片树投影 */
function snapshotCanvasObject(): any {
if (!bpmnWrap.value) return JSON.parse(JSON.stringify(defJson.value));
const canvasJson = bpmnWrap.value?.serialize(
buildSerializeBase(),
model.code.trim(),
model.name.trim()
);
if (!canvasJson) throw new Error("画布不可用");
return canvasJson;
}
/** 归一化(去 layout 坐标、节点按 id 排序):卡片树投影与画布序列化表示不同但内容等价,脏比较必须同表示 */
function canonicalJson(content: any): string {
const copy: any = JSON.parse(JSON.stringify(content));
delete copy.layout;
(copy.nodes || []).forEach((n: any) => {
// 后端语义缺省≡ALL:画布反序列化不携带 waitFor(引擎按 ALL 处理),归一化抹平避免假脏
if (n.type === "PARALLEL_JOIN" && n.waitFor === "ALL") delete n.waitFor;
Object.keys(n).forEach((k) => {
if (n[k] === undefined) delete n[k];
});
});
copy.nodes = (copy.nodes || []).slice().sort((a, b) => String(a.id).localeCompare(String(b.id)));
copy.edges = (copy.edges || [])
.slice()
.sort((a, b) => `${a.from}->${a.name}`.localeCompare(`${b.from}->${b.name}`));
return JSON.stringify(copy);
}
/** 脏检查基准(载入/保存后刷新);随编辑形态取同一内容的对应投影,切换模式后不因表示差异误报 */
const savedSnapshot = ref("");
const currentSnapshot = computed(() => JSON.stringify(defJson.value));
const currentSnapshot = computed(() =>
editMode.value === "cards"
? canonicalJson(JSON.parse(JSON.stringify(defJson.value)))
: canonicalJson(snapshotCanvasObject())
);
const isDirty = computed(() => !loading.value && currentSnapshot.value !== savedSnapshot.value);
const nodeDrawer = ref(false);
@@ -127,6 +203,8 @@ const editingUid = ref("");
const editingNode = computed<ApprovalNode | null>(() => {
if (editMode.value === "canvas") {
const item = editingUid.value ? findItem(model, editingUid.value) : null;
if (item && item.kind === "approval") return item;
return canvasConfigs[editingUid.value] || null;
}
const item = editingUid.value ? findItem(model, editingUid.value) : null;
@@ -190,6 +268,8 @@ async function load(): Promise<void> {
if (queryId && !queryGuid) editId.value = queryId;
if (!queryId) {
// 新建:画布源取卡片树投影(默认模板的审批节点 uid 与卡片一一对应),不用通用骨架
canvasSource.value = JSON.stringify(defJson.value);
savedSnapshot.value = currentSnapshot.value;
return;
}
@@ -200,20 +280,17 @@ async function load(): Promise<void> {
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 || "{}");
}
canvasBase.value = safeJson(sourceJson.value);
canvasSource.value = sourceJson.value;
let parsedOk = true;
try {
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
} catch (e: any) {
// 画布模式可编辑管线不支持的结构(多出口等),不再强制卡片树还原
if (editMode.value === "cards") {
Message.error(`既有定义无法还原为卡片视图:${e?.message || e}。请切换画布模式编辑。`);
}
// 卡片树反解失败(多出口等结构):留在画布模式,卡片单选禁用
parsedOk = false;
void e;
}
cardsBroken.value = !parsedOk;
await resolveUserNames();
savedSnapshot.value = currentSnapshot.value;
} finally {
@@ -221,6 +298,66 @@ async function load(): Promise<void> {
}
}
function safeJson(s: string): any {
try {
return JSON.parse(s || "{}");
} catch {
return {};
}
}
// ==================== 模式切换(双向互通) ====================
/** 卡片 → 画布:画布源换成卡片树投影(画布重挂载即拿到卡片最新结构/配置) */
function toCanvas(): void {
canvasSource.value = JSON.stringify(defJson.value);
editMode.value = "canvas";
bumpSnapshot();
}
/** 画布 → 卡片:画布结构经 fromContentJson 反解回卡片树;不支持的结构拒绝切换 */
function toCards(): void {
let content = "";
try {
content = serializeUnified();
} catch (e: any) {
Message.error(`画布数据读取失败:${e?.message || e}`);
return;
}
if (!content) {
Message.warning("画布为空,无法切换到卡片视图");
return;
}
try {
applyParsed(fromContentJson(JSON.parse(content)));
} catch (e: any) {
Message.error(`画布含卡片视图不支持的结构(${e?.message || e}),请继续用画布编辑或先简化结构`);
return;
}
Object.keys(canvasConfigs).forEach((k) => delete canvasConfigs[k]);
editMode.value = "cards";
bumpSnapshot();
}
/** 切换后按"切换前是否已脏"重置脏基准:干净切换 representations 等价,直接对齐;脏切换保留脏态 */
function bumpSnapshot(): void {
savedSnapshot.value = currentSnapshot.value;
}
function onModeSwitch(next: string | number | boolean): void {
if (loading.value) return;
if (next === editMode.value) return;
if (next === "cards" && cardsBroken.value) {
Message.warning("该定义包含卡片视图不支持的结构(如多出口/退回环路),请用画布编辑");
return;
}
if (next === "canvas") {
toCanvas();
} else {
toCards();
}
}
// ==================== 编辑操作(画布子树经 ctx 共享) ====================
/** 定位某节点所在的兄弟列表(删除前的"至少保留一个"判断用) */
@@ -244,6 +381,8 @@ function openNode(uid: string): void {
function removeNode(uid: string): void {
if (editMode.value === "canvas") {
// 卡片树是唯一数据源:卡片已有节点从两侧一起删;画布新画节点只删画布+配置快照
if (findItem(model, uid)) removeItem(model, uid);
canvasRemoveNode(uid);
return;
}
@@ -302,6 +441,25 @@ function onUrgeSave(urge: UrgeSetting): void {
model.settings.urge = urge;
}
/** 抽屉关闭(确定/删除)后,画布上的节点名回写(配置改的是卡片树本体) */
watch(nodeDrawer, (open, was) => {
if (open || !was) return;
if (editMode.value !== "canvas" || !bpmnWrap.value) return;
const item = findItem(model, editingUid.value);
if (item && item.kind === "approval") {
bpmnWrap.value.renameNode(item.uid, item.name);
}
});
/** 源码抽屉应用:写回卡片树;画布模式下画布源同步换投影,避免看到旧结构 */
function onJsonApplied(p: ParsedDef): void {
applyParsed(p);
if (editMode.value === "canvas") {
canvasSource.value = JSON.stringify(defJson.value);
bumpSnapshot();
}
}
async function onSave(): Promise<void> {
if (!model.name.trim()) {
Message.warning("请填写流程名称");
@@ -318,7 +476,12 @@ async function onSave(): Promise<void> {
}
let content: string;
if (editMode.value === "canvas") {
content = serializeCanvas();
try {
content = serializeUnified();
} catch (e: any) {
Message.error(`画布数据读取失败:${e?.message || e}`);
return;
}
const problems = checkCanvas(content);
if (problems.length) {
Message.error(problems[0]);
@@ -328,6 +491,12 @@ async function onSave(): Promise<void> {
Message.warning("画布为空,无法保存");
return;
}
// 保存内容同步回卡片树(画布=数据源投影,保存后两侧一致,脏基准重算不误报)
try {
applyParsed(fromContentJson(JSON.parse(content)));
} catch {
// 理论不可达(checkCanvas 通过即可反解);失败不阻断保存
}
} else {
content = JSON.stringify(toContentJson(model));
}
@@ -489,9 +658,9 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</label>
</div>
<div class="hd-right">
<RadioGroup v-model="editMode" size="small" type="button">
<RadioGroup :model-value="editMode" size="small" type="button" @on-change="onModeSwitch">
<Radio label="canvas">画布</Radio>
<Radio label="cards">卡片</Radio>
<Radio label="cards" :disabled="cardsBroken">卡片</Radio>
</RadioGroup>
<Button @click="settingsDrawer = true">流程设置</Button>
<Button @click="jsonDrawer = true">源码</Button>
@@ -528,7 +697,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<ApprovalDrawer v-model="nodeDrawer" :node="editingNode" @removed="nodeDrawer = false" />
<SettingsDrawer v-model="settingsDrawer" :settings="model.settings" @save="onUrgeSave" />
<JsonDrawer v-model="jsonDrawer" :def="defJson" @applied="applyParsed" />
<JsonDrawer v-model="jsonDrawer" :def="defJson" @applied="onJsonApplied" />
</div>
</template>