feat(admin): 画布↔卡片数据打通——卡片树为唯一数据源(uid 即 contentJson 节点 id, 画布几-diagram 投影/反解); 多人审批默认或签; 选人弹窗换 /admin/user/teacherTree 教师树; 示意图页三视图(示意图/画布视图/进度图PNG)
build / build (push) Failing after 28s
build / build (push) Failing after 28s
This commit is contained in:
@@ -128,6 +128,10 @@ class FlowApi {
|
|||||||
adminUserTree() {
|
adminUserTree() {
|
||||||
return get(`${ROOT}/admin/user/tree`);
|
return get(`${ROOT}/admin/user/tree`);
|
||||||
}
|
}
|
||||||
|
/** 选人组件数据源·教师版(仿 Notice:组树骨架 + YschClassOpenApi 教师按 GroupIds 挂组) */
|
||||||
|
adminTeacherTree() {
|
||||||
|
return get(`${ROOT}/admin/user/teacherTree`);
|
||||||
|
}
|
||||||
/** 一键拉取教师组·预览(学校子树教师组容器 → 科室+组内成员) */
|
/** 一键拉取教师组·预览(学校子树教师组容器 → 科室+组内成员) */
|
||||||
mapGroupPreview() {
|
mapGroupPreview() {
|
||||||
return get(`${ROOT}/admin/map/groupPreview`);
|
return get(`${ROOT}/admin/map/groupPreview`);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default {
|
|||||||
sources: [
|
sources: [
|
||||||
{
|
{
|
||||||
name: "基础平台",
|
name: "基础平台",
|
||||||
loader: () => flowApi.adminUserTree().send() as Promise<any[]>,
|
loader: () => flowApi.adminTeacherTree().send() as Promise<any[]>,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ref, computed, onMounted, watch } from "vue";
|
|||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import { flowApi } from "@/api/module/flow";
|
import { flowApi } from "@/api/module/flow";
|
||||||
|
import config from "@/assets/config/host.config";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -12,16 +13,31 @@ const vo = ref<any>(null);
|
|||||||
const mode = ref<string>((route.query.mode as string) || "auto");
|
const mode = ref<string>((route.query.mode as string) || "auto");
|
||||||
const svgRef = ref<SVGSVGElement | null>(null);
|
const svgRef = ref<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
/** 出图形态(2026-09-17 三选一):auto=示意图(自动布局)|canvas=画布视图(设计器保存坐标)|img=进度图(服务端 PNG 流) */
|
||||||
|
const chartStyle = ref<string>((route.query.chart as string) || "auto");
|
||||||
|
const showImg = computed(() => chartStyle.value === "img");
|
||||||
|
|
||||||
|
/** 进度图 img 直嵌源(同 InstanceDetail 的"CAS 验证码式",img 不走 alova 显式带 serviceHost;管理面 defId 走 admin 端点) */
|
||||||
|
const pngSrc = computed(() => {
|
||||||
|
const q = route.query as Record<string, string>;
|
||||||
|
const base = `${config.serviceHost}/api/flow`;
|
||||||
|
const suffix = `&mode=${mode.value}`;
|
||||||
|
if (q.defId) return `${base}/admin/def/diagram.png?id=${q.defId}${suffix}`;
|
||||||
|
if (q.flowGuid) return `${base}/diagram.png?flowGuid=${encodeURIComponent(q.flowGuid)}${suffix}`;
|
||||||
|
return `${base}/diagram.png?instanceId=${q.instanceId || ""}${suffix}`;
|
||||||
|
});
|
||||||
|
|
||||||
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
|
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
|
||||||
|
|
||||||
// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用)
|
// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用)
|
||||||
import { layout as autoLayout, NODE_BOX, SLOT_W, SLOT_H, COL_GAP, ROW_GAP, PAD, Pos } from "@/utils/flowLayout";
|
import { layout as autoLayout, NODE_BOX, SLOT_W, SLOT_H, COL_GAP, ROW_GAP, PAD, Pos } from "@/utils/flowLayout";
|
||||||
|
|
||||||
/** 位置计算:优先用设计器保存的画布坐标(vo.layout,bpmn 画布 x/y),缺坐标回退自动布局 */
|
/** 位置计算:画布视图用设计器保存的画布坐标(vo.layout,bpmn 画布 x/y),示意图忽略坐标强制自动布局;
|
||||||
|
* 画布视图缺坐标(旧定义未在设计器编辑过)时回退自动布局 */
|
||||||
const positions = computed<Record<string, Pos>>(() => {
|
const positions = computed<Record<string, Pos>>(() => {
|
||||||
const nodes: any[] = vo.value?.nodes || [];
|
const nodes: any[] = vo.value?.nodes || [];
|
||||||
const edges: any[] = vo.value?.edges || [];
|
const edges: any[] = vo.value?.edges || [];
|
||||||
const saved = vo.value?.layout as Record<string, { x: number; y: number }> | undefined;
|
const saved = chartStyle.value === "canvas" ? vo.value?.layout as Record<string, { x: number; y: number }> | undefined : undefined;
|
||||||
if (saved && Object.keys(saved).length) {
|
if (saved && Object.keys(saved).length) {
|
||||||
const pos: Record<string, Pos> = {};
|
const pos: Record<string, Pos> = {};
|
||||||
let complete = true;
|
let complete = true;
|
||||||
@@ -124,6 +140,11 @@ function trunc(s: string | null, n: number): string {
|
|||||||
return s.length > n ? s.slice(0, n) + "…" : s;
|
return s.length > n ? s.slice(0, n) + "…" : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 出图形态切换;进出进度图无需重拉(数据 vo 共用),只换渲染形态 */
|
||||||
|
function switchChartStyle(val: string) {
|
||||||
|
chartStyle.value = val;
|
||||||
|
}
|
||||||
|
|
||||||
const viewMode = computed(() => (mode.value === "user" ? "user" : "dept"));
|
const viewMode = computed(() => (mode.value === "user" ? "user" : "dept"));
|
||||||
|
|
||||||
function switchMode(val: string) {
|
function switchMode(val: string) {
|
||||||
@@ -212,15 +233,24 @@ watch(() => route.query, () => load());
|
|||||||
<Tag color="blue">v{{ vo.version }}</Tag>
|
<Tag color="blue">v{{ vo.version }}</Tag>
|
||||||
<Tag :color="vo.status === 1 ? 'green' : vo.status === 0 ? 'orange' : 'gray'">
|
<Tag :color="vo.status === 1 ? 'green' : vo.status === 0 ? 'orange' : 'gray'">
|
||||||
{{ DEF_STATUS[vo.status] || vo.status }}</Tag>
|
{{ DEF_STATUS[vo.status] || vo.status }}</Tag>
|
||||||
<RadioGroup :model-value="viewMode" size="small" @on-change="switchMode">
|
<RadioGroup :model-value="chartStyle" size="small" type="button" @on-change="switchChartStyle">
|
||||||
|
<Radio label="auto">示意图</Radio>
|
||||||
|
<Radio label="canvas">画布视图</Radio>
|
||||||
|
<Radio label="img">进度图</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
<RadioGroup v-if="!showImg" :model-value="viewMode" size="small" @on-change="switchMode">
|
||||||
<Radio label="dept">科室视图</Radio>
|
<Radio label="dept">科室视图</Radio>
|
||||||
<Radio label="user">人员视图</Radio>
|
<Radio label="user">人员视图</Radio>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<Button size="small" @click="exportPng">导出 PNG</Button>
|
<Button v-if="!showImg" size="small" @click="exportPng">导出 PNG</Button>
|
||||||
<span class="muted">示意图按流程定义 v{{ vo.version }} 生成</span>
|
<span class="muted">示意图按流程定义 v{{ vo.version }} 生成</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="canvas" v-if="vo">
|
<!-- 进度图:服务端 PNG 流(含运行时状态着色/图例) -->
|
||||||
|
<div class="canvas img-box" v-if="vo && showImg">
|
||||||
|
<img :src="pngSrc" alt="流程示意图" />
|
||||||
|
</div>
|
||||||
|
<div class="canvas" v-else-if="vo">
|
||||||
<svg :width="vbSize.w" :height="vbSize.h" :viewBox="`0 0 ${vbSize.w} ${vbSize.h}`"
|
<svg :width="vbSize.w" :height="vbSize.h" :viewBox="`0 0 ${vbSize.w} ${vbSize.h}`"
|
||||||
xmlns="http://www.w3.org/2000/svg" ref="svgRef"
|
xmlns="http://www.w3.org/2000/svg" ref="svgRef"
|
||||||
style="font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif">
|
style="font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif">
|
||||||
@@ -298,6 +328,8 @@ watch(() => route.query, () => load());
|
|||||||
.title { font-size: 16px; font-weight: 600; }
|
.title { font-size: 16px; font-weight: 600; }
|
||||||
.muted { color: #999; font-size: 12px; }
|
.muted { color: #999; font-size: 12px; }
|
||||||
.canvas { overflow-x: auto; background: #fafafa; border: 1px solid #e8eaec; border-radius: 6px; }
|
.canvas { overflow-x: auto; background: #fafafa; border: 1px solid #e8eaec; border-radius: 6px; }
|
||||||
|
.img-box { display: flex; justify-content: center; padding: 12px; }
|
||||||
|
.img-box img { max-width: 100%; height: auto; }
|
||||||
.empty { padding: 40px; text-align: center; }
|
.empty { padding: 40px; text-align: center; }
|
||||||
/* 当前在办节点呼吸高亮 */
|
/* 当前在办节点呼吸高亮 */
|
||||||
.node-active { animation: nodePulse 1.6s ease-in-out infinite; }
|
.node-active { animation: nodePulse 1.6s ease-in-out infinite; }
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const form = reactive({
|
|||||||
type: "DEPT_KEY" as "DEPT_KEY" | "USERS",
|
type: "DEPT_KEY" as "DEPT_KEY" | "USERS",
|
||||||
deptKey: "",
|
deptKey: "",
|
||||||
userIds: [] as number[],
|
userIds: [] as number[],
|
||||||
countersign: "ALL" as "ALL" | "ANY",
|
countersign: "ANY" as "ALL" | "ANY",
|
||||||
deadlineHours: null as number | null,
|
deadlineHours: null as number | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ const userCheckModal = ref<any>();
|
|||||||
const userModalSources = [
|
const userModalSources = [
|
||||||
{
|
{
|
||||||
name: "基础平台",
|
name: "基础平台",
|
||||||
loader: () => flowApi.adminUserTree().send() as Promise<any[]>,
|
loader: () => flowApi.adminTeacherTree().send() as Promise<any[]>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,13 @@ import {
|
|||||||
ParallelNode,
|
ParallelNode,
|
||||||
createApproval,
|
createApproval,
|
||||||
newUid,
|
newUid,
|
||||||
|
resetUidSeq,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
|
|
||||||
/** 编辑树 → 后端 contentJson(code/name 恒取表单值,规避 contentJson 覆盖行字段的既有坑) */
|
/** 编辑树 → 后端 contentJson(code/name 恒取表单值,规避 contentJson 覆盖行字段的既有坑) */
|
||||||
export function toContentJson(model: EditorModel): DefJson {
|
export function toContentJson(model: EditorModel): DefJson {
|
||||||
const nodes: DefNodeJson[] = [];
|
const nodes: DefNodeJson[] = [];
|
||||||
const edges: DefEdgeJson[] = [];
|
const edges: DefEdgeJson[] = [];
|
||||||
let seq = 1;
|
|
||||||
const nid = (p: string) => `${p}${seq++}`;
|
|
||||||
|
|
||||||
// 端点 name 仅在既有定义带值时回写(新建流程不生成,保持最小 JSON)
|
// 端点 name 仅在既有定义带值时回写(新建流程不生成,保持最小 JSON)
|
||||||
const startNode: DefNodeJson = { id: "start", type: "START" };
|
const startNode: DefNodeJson = { id: "start", type: "START" };
|
||||||
@@ -40,7 +39,7 @@ export function toContentJson(model: EditorModel): DefJson {
|
|||||||
let cursor = prev;
|
let cursor = prev;
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
if (item.kind === "approval") {
|
if (item.kind === "approval") {
|
||||||
const id = nid("n");
|
const id = item.uid;
|
||||||
const node: DefNodeJson = {
|
const node: DefNodeJson = {
|
||||||
id,
|
id,
|
||||||
type: "APPROVAL",
|
type: "APPROVAL",
|
||||||
@@ -54,8 +53,8 @@ export function toContentJson(model: EditorModel): DefJson {
|
|||||||
cursor = id;
|
cursor = id;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const split = nid("split");
|
const split = `${item.uid}s`;
|
||||||
const join = nid("join");
|
const join = `${item.uid}j`;
|
||||||
nodes.push({ id: split, type: "PARALLEL_SPLIT" });
|
nodes.push({ id: split, type: "PARALLEL_SPLIT" });
|
||||||
nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" });
|
nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" });
|
||||||
edges.push({ from: cursor, to: split });
|
edges.push({ from: cursor, to: split });
|
||||||
@@ -87,6 +86,11 @@ export function toContentJson(model: EditorModel): DefJson {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toContentJson 的节点 id 直接取编辑态 uid(u 段 / uid 后缀 +s、+j):id 稳定可回溯,
|
||||||
|
* bpmn 画布与卡片共用同一套 id——画布上选中元素的 id 即卡片 uid,配置抽屉无需二次映射;
|
||||||
|
* id 仅要求"非空且唯一"(DefValidator 口径)。
|
||||||
|
*/
|
||||||
function cloneAssignee(a: Assignee | null): Assignee {
|
function cloneAssignee(a: Assignee | null): Assignee {
|
||||||
if (!a) return { type: "DEPT_KEY", deptKey: "" };
|
if (!a) return { type: "DEPT_KEY", deptKey: "" };
|
||||||
return a.type === "USERS" ? { type: "USERS", userIds: [...a.userIds] } : { type: "DEPT_KEY", deptKey: a.deptKey };
|
return a.type === "USERS" ? { type: "USERS", userIds: [...a.userIds] } : { type: "DEPT_KEY", deptKey: a.deptKey };
|
||||||
@@ -106,6 +110,8 @@ export interface ParsedDef {
|
|||||||
* contentJson → 编辑树。抛错即为"不可还原为卡片视图",调用方展示错误并建议源码模式。
|
* contentJson → 编辑树。抛错即为"不可还原为卡片视图",调用方展示错误并建议源码模式。
|
||||||
*/
|
*/
|
||||||
export function fromContentJson(def: any): ParsedDef {
|
export function fromContentJson(def: any): ParsedDef {
|
||||||
|
UsedIds.set.clear();
|
||||||
|
resetUidSeq();
|
||||||
if (!def || !Array.isArray(def.nodes) || !Array.isArray(def.edges)) {
|
if (!def || !Array.isArray(def.nodes) || !Array.isArray(def.edges)) {
|
||||||
throw new Error("JSON 需包含 nodes 与 edges 数组");
|
throw new Error("JSON 需包含 nodes 与 edges 数组");
|
||||||
}
|
}
|
||||||
@@ -240,8 +246,10 @@ export function fromContentJson(def: any): ParsedDef {
|
|||||||
|
|
||||||
function toApprovalNode(n: any): ApprovalNode {
|
function toApprovalNode(n: any): ApprovalNode {
|
||||||
const countersign: Countersign = n.countersign === "ANY" ? "ANY" : "ALL";
|
const countersign: Countersign = n.countersign === "ANY" ? "ANY" : "ALL";
|
||||||
|
// uid 复用 JSON 原节点 id(如 n3):载入→卡片→画布→保存全程同一套 id,卡片树与画布
|
||||||
|
// 节点一一对应(业务配置按 id 继承的通道依赖这一点);缺 id/撞 id 才退回新生成 uid。
|
||||||
return {
|
return {
|
||||||
uid: newUid(),
|
uid: UsedIds.take(n.id),
|
||||||
kind: "approval",
|
kind: "approval",
|
||||||
name: n.name || "审批",
|
name: n.name || "审批",
|
||||||
assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null,
|
assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null,
|
||||||
@@ -250,6 +258,22 @@ function toApprovalNode(n: any): ApprovalNode {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 载入会话内已占用的 uid 集(防重复/缺 id 撞车) */
|
||||||
|
const UsedIds = {
|
||||||
|
set: new Set<string>(),
|
||||||
|
take(raw: unknown): string {
|
||||||
|
const s = raw == null ? "" : String(raw).trim();
|
||||||
|
if (s && !this.set.has(s)) {
|
||||||
|
this.set.add(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
let u = newUid();
|
||||||
|
while (this.set.has(u)) u = newUid();
|
||||||
|
this.set.add(u);
|
||||||
|
return u;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** 还原时保留真实分支结构(含 1 分支的非常规定义,交给校验提示);仅空分支补占位,避免画布出现空容器 */
|
/** 还原时保留真实分支结构(含 1 分支的非常规定义,交给校验提示);仅空分支补占位,避免画布出现空容器 */
|
||||||
function makeParallel(branches: EditorItem[][]): ParallelNode {
|
function makeParallel(branches: EditorItem[][]): ParallelNode {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// 入口三态(query):无参=新建;?id={defId}=编辑草稿;?id={defId}&baseGuid={flowGuid}=升版本(载入该版本内容为底稿)。
|
// 入口三态(query):无参=新建;?id={defId}=编辑草稿;?id={defId}&baseGuid={flowGuid}=升版本(载入该版本内容为底稿)。
|
||||||
// 保存只产草稿,走既有 POST /api/flow/admin/def/save;发布仍留在流程定义列表页(后端 DefValidator 终审)。
|
// 保存只产草稿,走既有 POST /api/flow/admin/def/save;发布仍留在流程定义列表页(后端 DefValidator 终审)。
|
||||||
// 编辑态为结构化树(START/END 隐式固定端点),结构类校验错误从构造上不可能出现。
|
// 编辑态为结构化树(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 { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
|
||||||
import { Message, Modal } from "view-ui-plus";
|
import { Message, Modal } from "view-ui-plus";
|
||||||
import { flowApi } from "@/api/module/flow";
|
import { flowApi } from "@/api/module/flow";
|
||||||
@@ -54,21 +54,41 @@ const depts = reactive<DeptOption[]>([]);
|
|||||||
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
|
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
|
||||||
const userNames = reactive<Record<number, string>>({});
|
const userNames = reactive<Record<number, string>>({});
|
||||||
|
|
||||||
// ==================== 画布模式(默认):bpmn-js 建模任意 DAG ====================
|
// ==================== 双模式统一(2026-09-17):卡片树 = 唯一数据源,画布 = 其可视化投影 ====================
|
||||||
/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流(保留回退) */
|
/**
|
||||||
|
* 旧实现画布/卡片各持一份基准(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 editMode = ref<"canvas" | "cards">("canvas");
|
||||||
|
/** 画布侧数据是否可反解为卡片树(载入时探测,决定卡片单选是否可用) */
|
||||||
|
const cardsBroken = ref(false);
|
||||||
const bpmnWrap = ref<InstanceType<typeof BpmnCanvas> | null>(null);
|
const bpmnWrap = ref<InstanceType<typeof BpmnCanvas> | null>(null);
|
||||||
/** 画布数据基准(载入时的 contentJson,供配置按 id 继承) */
|
/** 画布数据源:载入用原始 contentJson;由卡片切回画布时用卡片树投影覆盖 */
|
||||||
|
const canvasSource = ref("");
|
||||||
|
/** 载入时的原始 contentJson(画布上"卡片树不存在"的新画节点配置兜底) */
|
||||||
const canvasBase = ref<any>(null);
|
const canvasBase = ref<any>(null);
|
||||||
/** 画布上审批节点的配置快照(element id → ApprovalNode 配置),点卡片写入,保存时并入 nodes */
|
/** 画布上新画节点的配置快照(element id → ApprovalNode 配置),保存时并入 nodes */
|
||||||
const canvasConfigs = reactive<Record<string, any>>({});
|
const canvasConfigs = reactive<Record<string, any>>({});
|
||||||
/** 原始 contentJson 快照(画布脏检查基准) */
|
/** 原始 contentJson 快照(画布裁剪兜底与 JsonDrawer 应用基准) */
|
||||||
const sourceJson = ref("");
|
const sourceJson = ref("");
|
||||||
|
|
||||||
function onCanvasNode(_el: any): void {
|
function onCanvasNode(_el: any): void {
|
||||||
// selection.changed 自画布 emit:选中审批节点 → 打开配置抽屉
|
// selection.changed 自画布 emit:选中审批节点 → 打开配置抽屉
|
||||||
if (!_el || _el.type !== "bpmn:UserTask") return;
|
if (!_el || _el.type !== "bpmn:UserTask") return;
|
||||||
const id = String(_el.businessObject.id || _el.id);
|
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) || {};
|
const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {};
|
||||||
if (!canvasConfigs[id]) {
|
if (!canvasConfigs[id]) {
|
||||||
canvasConfigs[id] = {
|
canvasConfigs[id] = {
|
||||||
@@ -76,7 +96,7 @@ function onCanvasNode(_el: any): void {
|
|||||||
kind: "approval",
|
kind: "approval",
|
||||||
name: _el.businessObject.name || baseNode.name || "审批",
|
name: _el.businessObject.name || baseNode.name || "审批",
|
||||||
assignee: baseNode.assignee || null,
|
assignee: baseNode.assignee || null,
|
||||||
countersign: baseNode.countersign || "ALL",
|
countersign: baseNode.countersign || "ANY",
|
||||||
deadlineHours: baseNode.deadlineHours || null,
|
deadlineHours: baseNode.deadlineHours || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -89,35 +109,91 @@ function canvasRemoveNode(uid: string): void {
|
|||||||
delete canvasConfigs[uid];
|
delete canvasConfigs[uid];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 画布模式序列化:画布 → contentJson(叠加 code/name/settings 与审批配置) */
|
/** 画布序列化 → contentJson 字符串:结构性内容(节点/连线/布局)出自画布,
|
||||||
function serializeCanvas(): string {
|
* 业务配置(审批人/会签/时限)以卡片树为 base 按 id 继承,settings(含催办)随 base 带出 */
|
||||||
const canvasJson = bpmnWrap.value?.serialize(
|
function serializeUnified(): string {
|
||||||
canvasBase.value,
|
const canvasJson = snapshotCanvasObject();
|
||||||
model.code.trim(),
|
// canvasConfigs(画布新画节点的抽屉配置)按 id 并入
|
||||||
model.name.trim()
|
(canvasJson.nodes || []).forEach((n: any) => {
|
||||||
);
|
|
||||||
if (!canvasJson) return "";
|
|
||||||
// 配置并入:canvasConfigs 以 element id 为键覆盖审批节点业务属性
|
|
||||||
canvasJson.nodes.forEach((n: any) => {
|
|
||||||
const cfg = canvasConfigs[String(n.id)];
|
const cfg = canvasConfigs[String(n.id)];
|
||||||
if (cfg && n.type === "APPROVAL") {
|
if (cfg && n.type === "APPROVAL") {
|
||||||
n.name = cfg.name || n.name;
|
n.name = cfg.name || n.name;
|
||||||
if (cfg.assignee) n.assignee = cfg.assignee;
|
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;
|
if (cfg.deadlineHours) n.deadlineHours = cfg.deadlineHours;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
canvasJson.settings = { rejectPolicy: "END_REJECTED" };
|
|
||||||
void model.settings; // 设置抽屉暂绑定卡模型(画布模式直接采用卡模型设置)
|
|
||||||
return JSON.stringify(canvasJson);
|
return JSON.stringify(canvasJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
const issues = computed<Issue[]>(() => validateModel(model, depts));
|
const issues = computed<Issue[]>(() => validateModel(model, depts));
|
||||||
const defJson = computed(() => toContentJson(model));
|
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 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 isDirty = computed(() => !loading.value && currentSnapshot.value !== savedSnapshot.value);
|
||||||
|
|
||||||
const nodeDrawer = ref(false);
|
const nodeDrawer = ref(false);
|
||||||
@@ -127,6 +203,8 @@ const editingUid = ref("");
|
|||||||
|
|
||||||
const editingNode = computed<ApprovalNode | null>(() => {
|
const editingNode = computed<ApprovalNode | null>(() => {
|
||||||
if (editMode.value === "canvas") {
|
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;
|
return canvasConfigs[editingUid.value] || null;
|
||||||
}
|
}
|
||||||
const item = editingUid.value ? findItem(model, 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 && !queryGuid) editId.value = queryId;
|
||||||
|
|
||||||
if (!queryId) {
|
if (!queryId) {
|
||||||
|
// 新建:画布源取卡片树投影(默认模板的审批节点 uid 与卡片一一对应),不用通用骨架
|
||||||
|
canvasSource.value = JSON.stringify(defJson.value);
|
||||||
savedSnapshot.value = currentSnapshot.value;
|
savedSnapshot.value = currentSnapshot.value;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -200,20 +280,17 @@ async function load(): Promise<void> {
|
|||||||
model.code = d?.code || "";
|
model.code = d?.code || "";
|
||||||
model.name = d?.name || "";
|
model.name = d?.name || "";
|
||||||
sourceJson.value = d?.contentJson || "{}";
|
sourceJson.value = d?.contentJson || "{}";
|
||||||
try {
|
canvasBase.value = safeJson(sourceJson.value);
|
||||||
canvasBase.value = fromContentJson(JSON.parse(d?.contentJson || "{}")) as any;
|
canvasSource.value = sourceJson.value;
|
||||||
} catch {
|
let parsedOk = true;
|
||||||
// 解析失败仍保留原始 JSON 供画布直接读取(fromContentJson 卡片树可能不支持)
|
|
||||||
canvasBase.value = JSON.parse(d?.contentJson || "{}");
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
|
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// 画布模式可编辑管线不支持的结构(多出口等),不再强制卡片树还原
|
// 卡片树反解失败(多出口等结构):留在画布模式,卡片单选禁用
|
||||||
if (editMode.value === "cards") {
|
parsedOk = false;
|
||||||
Message.error(`既有定义无法还原为卡片视图:${e?.message || e}。请切换画布模式编辑。`);
|
void e;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
cardsBroken.value = !parsedOk;
|
||||||
await resolveUserNames();
|
await resolveUserNames();
|
||||||
savedSnapshot.value = currentSnapshot.value;
|
savedSnapshot.value = currentSnapshot.value;
|
||||||
} finally {
|
} 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 共享) ====================
|
// ==================== 编辑操作(画布子树经 ctx 共享) ====================
|
||||||
|
|
||||||
/** 定位某节点所在的兄弟列表(删除前的"至少保留一个"判断用) */
|
/** 定位某节点所在的兄弟列表(删除前的"至少保留一个"判断用) */
|
||||||
@@ -244,6 +381,8 @@ function openNode(uid: string): void {
|
|||||||
|
|
||||||
function removeNode(uid: string): void {
|
function removeNode(uid: string): void {
|
||||||
if (editMode.value === "canvas") {
|
if (editMode.value === "canvas") {
|
||||||
|
// 卡片树是唯一数据源:卡片已有节点从两侧一起删;画布新画节点只删画布+配置快照
|
||||||
|
if (findItem(model, uid)) removeItem(model, uid);
|
||||||
canvasRemoveNode(uid);
|
canvasRemoveNode(uid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -302,6 +441,25 @@ function onUrgeSave(urge: UrgeSetting): void {
|
|||||||
model.settings.urge = urge;
|
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> {
|
async function onSave(): Promise<void> {
|
||||||
if (!model.name.trim()) {
|
if (!model.name.trim()) {
|
||||||
Message.warning("请填写流程名称");
|
Message.warning("请填写流程名称");
|
||||||
@@ -318,7 +476,12 @@ async function onSave(): Promise<void> {
|
|||||||
}
|
}
|
||||||
let content: string;
|
let content: string;
|
||||||
if (editMode.value === "canvas") {
|
if (editMode.value === "canvas") {
|
||||||
content = serializeCanvas();
|
try {
|
||||||
|
content = serializeUnified();
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(`画布数据读取失败:${e?.message || e}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const problems = checkCanvas(content);
|
const problems = checkCanvas(content);
|
||||||
if (problems.length) {
|
if (problems.length) {
|
||||||
Message.error(problems[0]);
|
Message.error(problems[0]);
|
||||||
@@ -328,6 +491,12 @@ async function onSave(): Promise<void> {
|
|||||||
Message.warning("画布为空,无法保存");
|
Message.warning("画布为空,无法保存");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 保存内容同步回卡片树(画布=数据源投影,保存后两侧一致,脏基准重算不误报)
|
||||||
|
try {
|
||||||
|
applyParsed(fromContentJson(JSON.parse(content)));
|
||||||
|
} catch {
|
||||||
|
// 理论不可达(checkCanvas 通过即可反解);失败不阻断保存
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
content = JSON.stringify(toContentJson(model));
|
content = JSON.stringify(toContentJson(model));
|
||||||
}
|
}
|
||||||
@@ -489,9 +658,9 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="hd-right">
|
<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="canvas">画布</Radio>
|
||||||
<Radio label="cards">卡片</Radio>
|
<Radio label="cards" :disabled="cardsBroken">卡片</Radio>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<Button @click="settingsDrawer = true">流程设置</Button>
|
<Button @click="settingsDrawer = true">流程设置</Button>
|
||||||
<Button @click="jsonDrawer = 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" />
|
<ApprovalDrawer v-model="nodeDrawer" :node="editingNode" @removed="nodeDrawer = false" />
|
||||||
<SettingsDrawer v-model="settingsDrawer" :settings="model.settings" @save="onUrgeSave" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface ApprovalNode {
|
|||||||
kind: "approval";
|
kind: "approval";
|
||||||
name: string;
|
name: string;
|
||||||
assignee: Assignee | null;
|
assignee: Assignee | null;
|
||||||
/** 多人审批方式:ALL=会签(默认,生成 JSON 时省略)|ANY=或签 */
|
/** 多人审批方式:ANY=或签(2026-09-17 起新建节点默认)|ALL=会签(后端缺省值,生成 JSON 时省略) */
|
||||||
countersign: Countersign;
|
countersign: Countersign;
|
||||||
/** 办理时限(小时):null=不限,生成 JSON 时省略 */
|
/** 办理时限(小时):null=不限,生成 JSON 时省略 */
|
||||||
deadlineHours: number | null;
|
deadlineHours: number | null;
|
||||||
@@ -114,8 +114,15 @@ export function newUid(): string {
|
|||||||
return `u${uidSeq}`;
|
return `u${uidSeq}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 重置 uid 序号(fromContentJson 反解前调用):uid 参与 contentJson 节点 id(画布↔卡片共用 id),
|
||||||
|
* 必须可复现,否则"图→树→图→树→图"连续两轮的 split/join id 漂移,往返不幂等 */
|
||||||
|
export function resetUidSeq(): void {
|
||||||
|
uidSeq = 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function createApproval(name = "审批节点", assignee: Assignee | null = { type: "DEPT_KEY", deptKey: "" }): ApprovalNode {
|
export function createApproval(name = "审批节点", assignee: Assignee | null = { type: "DEPT_KEY", deptKey: "" }): ApprovalNode {
|
||||||
return { uid: newUid(), kind: "approval", name, assignee, countersign: "ALL", deadlineHours: null };
|
// 默认或签(ANY)——需求 2026-09-17:多人审批默认一人同意即通过;后端缺省仍是 ALL,故 ANY 必须显式写 JSON
|
||||||
|
return { uid: newUid(), kind: "approval", name, assignee, countersign: "ANY", deadlineHours: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createParallel(): ParallelNode {
|
export function createParallel(): ParallelNode {
|
||||||
|
|||||||
Reference in New Issue
Block a user