编辑器出口表达式下展示可用变量(近期 50 条办件 formData 顶层键聚合去重截 40 + 内置,口径同调试弹窗),点击插入光标处;未知标识符内联提示疑似拼错(仅提示不拦截,去字符串/函数名/属性访问后比对)。EditorCtx 增 availableVars,index.vue 载入一次(拉取失败退化为仅内置,不阻塞编辑)。vite build:base 通过。
980 lines
36 KiB
Vue
980 lines
36 KiB
Vue
<script setup lang="ts">
|
||
// 流程编辑器(飞书流派,2026-10-03 画布退役):卡片树是唯一编辑形态与数据源;
|
||
// 多出口=审批节点出口按钮+泳道(OutletLanes),结构域封死 → 出图确定性布局。
|
||
//
|
||
// 入口三态(query):无参=新建;?id={defId}=编辑草稿;?id={defId}&baseGuid={flowGuid}=升版本(载入该版本内容为底稿)。
|
||
// 保存只产草稿,走既有 POST /api/flow/admin/def/save;发布仍留在流程定义列表页(后端 DefValidator 终审)。
|
||
// 编辑态为结构化树(START/END 隐式固定端点),结构类校验错误从构造上不可能出现。
|
||
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";
|
||
import {
|
||
ApprovalNode,
|
||
ConditionNode,
|
||
DeptOption,
|
||
EDITOR_CTX,
|
||
EditorCtx,
|
||
EditorItem,
|
||
EditorModel,
|
||
NotifyNode,
|
||
RouterOption,
|
||
UrgeSetting,
|
||
collectUserIds,
|
||
createApproval,
|
||
createCondition,
|
||
createEmptyModel,
|
||
createNotify,
|
||
createParallel,
|
||
branchesOf,
|
||
findItem,
|
||
removeItem,
|
||
resolveContainer,
|
||
walkItems,
|
||
} from "./model";
|
||
import { ParsedDef, fromContentJson, toContentJson } from "./convert";
|
||
import { Issue, countErrors, countWarns, validateModel } from "./validate";
|
||
import NodeList from "./components/NodeList.vue";
|
||
import ApprovalDrawer from "./components/ApprovalDrawer.vue";
|
||
import NotifyDrawer from "./components/NotifyDrawer.vue";
|
||
import ConditionDrawer from "./components/ConditionDrawer.vue";
|
||
import SettingsDrawer from "./components/SettingsDrawer.vue";
|
||
import JsonDrawer from "./components/JsonDrawer.vue";
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
|
||
|
||
const loading = ref(true);
|
||
const saving = ref(false);
|
||
/** 编辑目标草稿 id(升版本场景不带,交由 baseGuid 建新版本) */
|
||
const editId = ref<number | null>(null);
|
||
/** 升版本:源流程 flowGuid */
|
||
const baseGuid = ref("");
|
||
/** 载入源定义的状态(已发布/停用版本进入编辑器时提示保存会新建草稿版本) */
|
||
const sourceStatus = ref(0);
|
||
const sourceVersion = ref(0);
|
||
/** 源流程线 flowGuid(新建态为空,右面板显示「保存后生成」) */
|
||
const sourceFlowGuid = ref("");
|
||
/** 归属应用(flw_def.app_key;按应用筛选维度,空=未分配) */
|
||
const appKey = ref("");
|
||
/** 应用清单下拉(应用设置白名单 flw_app enabled=1,显示应用名),拉取失败降级为仅当前值 */
|
||
const appOptions = ref<{ appKey: string; appName: string }[]>([]);
|
||
|
||
const model = reactive<EditorModel>(createEmptyModel());
|
||
/** 科室列表(deptList 聚合行):仅用于下拉选项与"无启用人员"提示 */
|
||
const depts = reactive<DeptOption[]>([]);
|
||
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
|
||
const userNames = reactive<Record<number, string>>({});
|
||
/** 条件分支的判断服务注册(flw_router;拉取失败为空数组,校验降级提示) */
|
||
const routers = reactive<RouterOption[]>([]);
|
||
/** 可用变量 chips:近期办件 formData 顶层键聚合(去重截 40)+ 内置变量;拉取失败仅内置 */
|
||
const availableVars = reactive<string[]>([]);
|
||
|
||
const issues = computed<Issue[]>(() => validateModel(model, depts, routers));
|
||
const defJson = computed(() => toContentJson(model));
|
||
|
||
/** 结构统计(右面板分区4):walkItems 已递归条件/并行分支的子树 */
|
||
const stats = computed(() => {
|
||
const s: Record<string, number> = { approval: 0, notify: 0, condition: 0, parallel: 0 };
|
||
walkItems(model.list, (item) => {
|
||
if (s[item.kind] !== undefined) s[item.kind] += 1;
|
||
});
|
||
return s;
|
||
});
|
||
|
||
/** 校验清单(右面板分区3):错误项在前、警告项在后(原型 E2 顺序) */
|
||
const issueList = computed<Issue[]>(() => [
|
||
...issues.value.filter((i) => i.level === "error"),
|
||
...issues.value.filter((i) => i.level !== "error"),
|
||
]);
|
||
|
||
/** 点清单项 → 画布滚动到对应节点并高亮 ~3s;uid 为空(空容器类)不可定位 */
|
||
function locateIssue(uid: string): void {
|
||
const el = document.getElementById("nd-" + uid);
|
||
if (!el) return;
|
||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
el.classList.add("nd-locate");
|
||
window.setTimeout(() => el.classList.remove("nd-locate"), 2800);
|
||
}
|
||
|
||
/** flowGuid 复制:clipboard 不可用/被拒时降级 console,不打断编辑 */
|
||
function copyGuid(): void {
|
||
const g = sourceFlowGuid.value;
|
||
if (!g) return;
|
||
if (navigator.clipboard?.writeText) {
|
||
navigator.clipboard.writeText(g).then(
|
||
() => Message.success("flowGuid 已复制"),
|
||
() => console.warn("flowGuid 复制失败,请手工选取:", g),
|
||
);
|
||
return;
|
||
}
|
||
console.warn("flowGuid 复制失败,请手工选取:", g);
|
||
}
|
||
|
||
/** 归一化(去 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(() => canonicalJson(JSON.parse(JSON.stringify(defJson.value))));
|
||
const isDirty = computed(() => !loading.value && currentSnapshot.value !== savedSnapshot.value);
|
||
|
||
const nodeDrawer = ref(false);
|
||
const notifyDrawer = ref(false);
|
||
const conditionDrawer = ref(false);
|
||
const settingsDrawer = ref(false);
|
||
const jsonDrawer = ref(false);
|
||
const editingUid = ref("");
|
||
// 议题二③:升版本改动说明弹窗(仅「本次保存将创建新版本」时出现,可跳过)
|
||
const logModal = ref(false);
|
||
const logText = ref("");
|
||
const LOG_CHIPS = ["修复审批人缺失", "调整审批链", "新增抄送对象", "修改条件表达式", "调整流程设置"];
|
||
|
||
const editingNode = computed<ApprovalNode | null>(() => {
|
||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||
return item && item.kind === "approval" ? item : null;
|
||
});
|
||
|
||
const editingNotify = computed<NotifyNode | null>(() => {
|
||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||
return item && item.kind === "notify" ? item : null;
|
||
});
|
||
|
||
const editingCondition = computed<ConditionNode | null>(() => {
|
||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||
return item && item.kind === "condition" ? item : null;
|
||
});
|
||
|
||
// ==================== 载入 ====================
|
||
|
||
async function loadDepts(): Promise<void> {
|
||
try {
|
||
// 科室管理(2026-10-10 切源):deptList 返回聚合行 {deptKey,deptName,status,count}
|
||
const rows: any[] = (await flowApi.deptList().send()) || [];
|
||
depts.splice(0, depts.length, ...rows.map((r) => ({
|
||
deptKey: String(r.deptKey),
|
||
deptName: r.deptName || "",
|
||
// 停用科室不进编辑器下拉(与发布校验口径一致:解析不出办理人)
|
||
count: r.status === 0 ? r.count : 0,
|
||
})));
|
||
} catch {
|
||
// 科室拉取失败不阻塞编辑:审批人下拉退化为"仅当前值",校验降级为提示
|
||
Message.warning("科室数据拉取失败,科室下拉可能不完整");
|
||
}
|
||
}
|
||
|
||
/** 补全 USERS 型审批人的姓名快照(既有定义载入时逐个检索,失败降级 #id 不阻塞) */
|
||
async function resolveUserNames(): Promise<void> {
|
||
const ids = collectUserIds(model).filter((id) => !userNames[id]);
|
||
for (const id of ids) {
|
||
try {
|
||
const u: any = await flowApi.mapLookupUser(id).send();
|
||
userNames[id] = u?.name || `#${id}`;
|
||
} catch {
|
||
userNames[id] = `#${id}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 条件分支的判断服务注册下拉(flw_router 启用项;失败不阻塞编辑,校验降级提示) */
|
||
async function loadRouters(): Promise<void> {
|
||
try {
|
||
const rows: any[] = (await flowApi.routerList().send()) || [];
|
||
routers.splice(0, routers.length, ...rows);
|
||
} catch {
|
||
Message.warning("判断服务注册拉取失败,条件分支的判断服务下拉可能不完整");
|
||
}
|
||
}
|
||
|
||
/** 表达式可用变量:近期 50 条办件 formData 顶层键聚合(去重截 40)+ 内置,口径同调试弹窗;失败仅内置 */
|
||
const BUILTIN_VARS = ["schId", "instanceId", "instanceVersion", "bizType", "bizId", "nodeId", "nodeName", "submitterId"];
|
||
async function loadVars(): Promise<void> {
|
||
const keys = new Set<string>();
|
||
try {
|
||
const rows: any[] = (await flowApi.adminInstances({ page: 1, size: 50 }).send())?.content || [];
|
||
rows.forEach((r) => {
|
||
try {
|
||
const v = JSON.parse(r.formDataJson || "{}");
|
||
if (v && typeof v === "object" && !Array.isArray(v)) Object.keys(v).forEach((k) => keys.add(k));
|
||
} catch {
|
||
/* 脏 formData 跳过,不影响其它办件的键聚合 */
|
||
}
|
||
});
|
||
} catch {
|
||
/* 办件列表拉取失败:退化为仅内置变量,不阻塞编辑 */
|
||
}
|
||
const merged = Array.from(new Set([...Array.from(keys).slice(0, 40), ...BUILTIN_VARS]));
|
||
availableVars.splice(0, availableVars.length, ...merged);
|
||
}
|
||
|
||
function applyParsed(parsed: ParsedDef): void {
|
||
model.code = parsed.code || model.code;
|
||
model.name = parsed.name || model.name;
|
||
model.list = parsed.list;
|
||
model.settings = parsed.settings;
|
||
// 端点 name 随定义带出,保存时回写(新建流程为空,生成 JSON 不含该字段)
|
||
model.startName = parsed.startName;
|
||
model.endName = parsed.endName;
|
||
void resolveUserNames();
|
||
}
|
||
|
||
async function load(): Promise<void> {
|
||
loading.value = true;
|
||
try {
|
||
await loadDepts();
|
||
await loadRouters();
|
||
void loadVars();
|
||
try {
|
||
const rows: any[] = (await flowApi.appList().send()) || [];
|
||
appOptions.value = rows
|
||
.filter((r) => r.enabled === 1)
|
||
.map((r) => ({ appKey: String(r.appKey), appName: r.appName || String(r.appKey) }));
|
||
} catch {
|
||
appOptions.value = [];
|
||
}
|
||
|
||
const queryId = route.query.id ? Number(route.query.id) : null;
|
||
const queryGuid = (route.query.baseGuid as string) || "";
|
||
if (queryGuid) baseGuid.value = queryGuid;
|
||
// 升版本:query 带 baseGuid + id(源为已发布版本),保存时不带 id 以创建新版本草稿
|
||
if (queryId && !queryGuid) editId.value = queryId;
|
||
|
||
if (!queryId) {
|
||
// 新建:卡片树默认模板起步
|
||
savedSnapshot.value = currentSnapshot.value;
|
||
return;
|
||
}
|
||
|
||
const d: any = await flowApi.defGet(queryId).send();
|
||
sourceStatus.value = d?.status ?? 0;
|
||
sourceVersion.value = d?.version ?? 0;
|
||
sourceFlowGuid.value = d?.flowGuid || queryGuid || "";
|
||
// 非草稿进入提示(2026-10-10 议题2):结构改动保存将升版本;仅改名称/归属应用请回列表用「编辑信息」
|
||
if (d?.status === 1 || d?.status === 2) {
|
||
Message.info("结构改动保存将升版本;仅改名称/归属应用请返回列表用「编辑信息」(不升版本)");
|
||
}
|
||
model.code = d?.code || "";
|
||
model.name = d?.name || "";
|
||
appKey.value = d?.appKey || "";
|
||
try {
|
||
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
|
||
} catch (e: any) {
|
||
Modal.error({
|
||
title: "该定义无法用卡片编辑器打开",
|
||
content: `内容包含卡片视图不支持的结构(${e?.message || e})。此类结构多为历史画布草稿,请从流程定义列表处理。`,
|
||
onOk: () => router.replace("/admin/defs"),
|
||
});
|
||
loading.value = false;
|
||
return;
|
||
}
|
||
await resolveUserNames();
|
||
savedSnapshot.value = currentSnapshot.value;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
// ==================== 编辑操作(画布子树经 ctx 共享) ====================
|
||
|
||
/** 定位某节点所在的兄弟列表(删除前的"至少保留一个"判断用) */
|
||
function containerOfItem(uid: string): EditorItem[] | null {
|
||
let container: string | null = null;
|
||
walkItems(model.list, (item, c) => {
|
||
if (item.uid === uid) container = c;
|
||
});
|
||
const c: string | null = container;
|
||
return c ? resolveContainer(model, c) : null;
|
||
}
|
||
|
||
function issueOf(uid: string): Issue[] {
|
||
return issues.value.filter((i) => i.uid === uid);
|
||
}
|
||
|
||
function openNode(uid: string): void {
|
||
editingUid.value = uid;
|
||
const item = findItem(model, uid);
|
||
if (item && item.kind === "notify") {
|
||
notifyDrawer.value = true;
|
||
} else if (item && item.kind === "condition") {
|
||
conditionDrawer.value = true;
|
||
} else {
|
||
nodeDrawer.value = true;
|
||
}
|
||
}
|
||
|
||
function removeNode(uid: string): void {
|
||
const siblings = containerOfItem(uid);
|
||
if (siblings && siblings.length <= 1) {
|
||
Message.warning("每个流程/分支至少保留一个节点");
|
||
return;
|
||
}
|
||
removeItem(model, uid);
|
||
}
|
||
|
||
function insertItem(container: string, index: number, kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||
const list = resolveContainer(model, container);
|
||
if (!list) return;
|
||
if (kind === "approval") {
|
||
const node = createApproval();
|
||
list.splice(index, 0, node);
|
||
// 新建即弹出配置,引导填审批人(避免留下"任务无主"节点)
|
||
openNode(node.uid);
|
||
} else if (kind === "notify") {
|
||
const node = createNotify();
|
||
list.splice(index, 0, node);
|
||
openNode(node.uid);
|
||
} else if (kind === "condition") {
|
||
const node = createCondition();
|
||
list.splice(index, 0, node);
|
||
// 新建即弹配置选判断服务(未配置的判断服务运行到该节点会挂起)
|
||
openNode(node.uid);
|
||
} else {
|
||
list.splice(index, 0, createParallel());
|
||
}
|
||
}
|
||
|
||
/** 添加分支(并行/条件分支/多出口审批容器通用;条件分支与出口同步补一个占位出口名) */
|
||
function addBranch(containerUid: string): void {
|
||
const item = findItem(model, containerUid);
|
||
if (!item) return;
|
||
if (item.kind === "parallel") {
|
||
item.branches.push([createApproval()]);
|
||
} else if (item.kind === "condition") {
|
||
const no = item.branches.length + 1;
|
||
item.branches.push([createApproval()]);
|
||
item.outlets.push(`出口${["一", "二", "三", "四", "五", "六"][no - 1] || no}`);
|
||
// EXPRESSION 模式条件与 outlets 同位(SERVICE 模式不写,序列化时不携带)
|
||
if (item.judgeMode === "EXPRESSION") {
|
||
item.conditions = item.conditions || [];
|
||
while (item.conditions.length < item.outlets.length) item.conditions.push("");
|
||
}
|
||
} else if (item.kind === "approval" && item.outlets && item.outlets.length >= 2) {
|
||
const no = (item.branches?.length || 0) + 1;
|
||
(item.branches = item.branches || []).push([createApproval()]);
|
||
item.outlets.push(`出口${["一", "二", "三", "四", "五", "六"][no - 1] || no}`);
|
||
}
|
||
}
|
||
|
||
function removeBranch(containerUid: string, index: number): void {
|
||
const item = findItem(model, containerUid);
|
||
const isOutlet = item.kind === "approval" && !!(item.outlets && item.outlets.length >= 2);
|
||
if (!item || (item.kind !== "parallel" && item.kind !== "condition" && !isOutlet)) return;
|
||
const branches = item.branches!;
|
||
if (branches.length <= 2) {
|
||
Message.warning("分支至少保留 2 个");
|
||
return;
|
||
}
|
||
branches.splice(index, 1);
|
||
if (item.kind === "condition" || isOutlet) item.outlets!.splice(index, 1);
|
||
if (item.kind === "condition" && item.conditions) item.conditions.splice(index, 1);
|
||
}
|
||
|
||
/** 退出多出口:仅保留出口①泳道内容,替换为「本节点(去 outlets) + ①泳道节点序列」 */
|
||
function exitOutlets(uid: string): void {
|
||
const rec = (items: EditorItem[]): boolean => {
|
||
const i = items.findIndex((it) => it.uid === uid);
|
||
if (i >= 0) {
|
||
const node = items[i] as ApprovalNode;
|
||
const lane0 = node.branches?.[0] || [];
|
||
const clean: ApprovalNode = { ...node };
|
||
delete clean.outlets;
|
||
delete clean.branches;
|
||
items.splice(i, 1, clean, ...lane0);
|
||
return true;
|
||
}
|
||
return items.some((it) => branchesOf(it)?.some((b) => rec(b)) || false);
|
||
};
|
||
rec(model.list);
|
||
}
|
||
|
||
provide<EditorCtx>(EDITOR_CTX, {
|
||
model,
|
||
depts,
|
||
userNames,
|
||
routers,
|
||
availableVars,
|
||
issueOf,
|
||
openNode,
|
||
removeNode,
|
||
insertItem,
|
||
addBranch,
|
||
removeBranch,
|
||
exitOutlets,
|
||
});
|
||
|
||
// ==================== 保存 / 离开 ====================
|
||
|
||
function onUrgeSave(urge: UrgeSetting): void {
|
||
model.settings.urge = urge;
|
||
}
|
||
|
||
/** 源码抽屉应用:写回卡片树(应用源码后 isDirty 自然为 true,预期行为) */
|
||
function onJsonApplied(p: ParsedDef): void {
|
||
applyParsed(p);
|
||
}
|
||
|
||
async function onSave(): Promise<void> {
|
||
if (!model.name.trim()) {
|
||
Message.warning("请填写流程名称");
|
||
return;
|
||
}
|
||
if (!model.code.trim()) {
|
||
Message.warning("请填写流程编码");
|
||
return;
|
||
}
|
||
// 归属应用必填(2026-10-12 拍板):新建必须选;升版本继承源线(baseGuid 载入时已带回),
|
||
// 源线未归属(存量 NULL)时必须在此补选
|
||
if (!(appKey.value || "").trim()) {
|
||
Message.warning("请选择归属应用(应用需先在 流程管理 > 应用设置 中开启)");
|
||
return;
|
||
}
|
||
const errors = countErrors(issues.value);
|
||
if (errors.length) {
|
||
Message.error(`校验未通过:${errors[0].text}`);
|
||
return;
|
||
}
|
||
// 新建:同编码软提示(编码是分类标签,多条流程线可共用同一编码,这里只提醒不阻断)
|
||
if (!editId.value && !baseGuid.value) {
|
||
const rows: any[] = (await flowApi.defList().send()) || [];
|
||
const sameCode = rows.filter((r) => r.code === model.code.trim()).length;
|
||
if (sameCode > 0) {
|
||
Modal.confirm({
|
||
title: "存在同编码流程",
|
||
content: `编码「${model.code.trim()}」已有 ${sameCode} 条流程,保存将新建一条独立流程线(版本从 v1 开始)。继续吗?`,
|
||
okText: "继续保存",
|
||
cancelText: "取消",
|
||
onOk: () => void doSave(),
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
await doSave();
|
||
}
|
||
|
||
async function doSave(): Promise<void> {
|
||
// 议题二③:本次保存将复制出新版本行时先问改动说明(挂在 doSave 入口,onSave 与同编码软确认两条路径都经过)
|
||
if (willCreateVersion()) {
|
||
logText.value = "";
|
||
logModal.value = true;
|
||
return;
|
||
}
|
||
await submitSave();
|
||
}
|
||
|
||
/** 升版本判据:baseGuid 入口(列表「升版本」)必带 baseGuid;编辑已发布/停用版会走后端复制升版分支 */
|
||
function willCreateVersion(): boolean {
|
||
return (!editId.value && !!baseGuid.value) || (!!editId.value && sourceStatus.value !== 0);
|
||
}
|
||
|
||
/** 常用改动类型点选:追加到说明文本(分号连接),可再手工补充 */
|
||
function appendLogChip(text: string): void {
|
||
const cur = logText.value.trim();
|
||
logText.value = cur ? `${cur};${text}` : text;
|
||
}
|
||
|
||
async function submitSave(changeLog?: string): Promise<void> {
|
||
logModal.value = false;
|
||
const content = JSON.stringify(toContentJson(model));
|
||
saving.value = true;
|
||
try {
|
||
// code/name 同时出现在 body 与 contentJson 且取值同源,规避 contentJson 静默覆盖行字段的既有坑
|
||
await flowApi.defSave({
|
||
id: editId.value || undefined,
|
||
code: model.code.trim(),
|
||
name: model.name.trim(),
|
||
contentJson: content,
|
||
baseGuid: baseGuid.value || undefined,
|
||
appKey: (appKey.value || "").trim() || undefined,
|
||
changeLog: changeLog || undefined,
|
||
}).send();
|
||
savedSnapshot.value = currentSnapshot.value;
|
||
Message.success(willCreateVersion()
|
||
? (changeLog ? "已保存草稿 · 改动说明已留存" : "已保存草稿(未填写改动说明)")
|
||
: "已保存草稿");
|
||
router.push("/admin/defs");
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
|
||
function onOpenDiagram(): void {
|
||
if (!editId.value) return;
|
||
const href = router.resolve({ path: "/diagram", query: { defId: String(editId.value), mode: "auto" } }).href;
|
||
window.open(`${location.origin}${location.pathname}${href}`, "_blank");
|
||
}
|
||
|
||
function onBack(): void {
|
||
if (!isDirty.value) {
|
||
router.push("/admin/defs");
|
||
return;
|
||
}
|
||
Modal.confirm({
|
||
title: "离开编辑器",
|
||
content: "当前改动尚未保存,确定离开吗?",
|
||
okText: "离开",
|
||
cancelText: "继续编辑",
|
||
onOk: () => router.push("/admin/defs"),
|
||
});
|
||
}
|
||
|
||
function onBeforeUnload(e: BeforeUnloadEvent): void {
|
||
if (!isDirty.value) return;
|
||
e.preventDefault();
|
||
e.returnValue = "";
|
||
}
|
||
|
||
onBeforeRouteLeave((_to, _from, next) => {
|
||
if (!isDirty.value) {
|
||
next();
|
||
return;
|
||
}
|
||
Modal.confirm({
|
||
title: "离开编辑器",
|
||
content: "当前改动尚未保存,确定离开吗?",
|
||
okText: "离开",
|
||
cancelText: "继续编辑",
|
||
onOk: () => next(),
|
||
onCancel: () => next(false),
|
||
});
|
||
});
|
||
|
||
onMounted(() => {
|
||
window.addEventListener("beforeunload", onBeforeUnload);
|
||
load();
|
||
});
|
||
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||
</script>
|
||
|
||
<template>
|
||
<div class="def-editor">
|
||
<header class="ed-header">
|
||
<div class="hd-left">
|
||
<span class="hd-back" title="返回流程定义列表" @click="onBack">←</span>
|
||
<span class="hd-title">流程编辑器</span>
|
||
<!-- 深链可带任意 defId:编辑已发布/停用版本时,后端 save 会复制出新草稿版本,此处显式告知 -->
|
||
<Tag v-if="editId && sourceStatus === 0" color="orange">草稿</Tag>
|
||
<Tag v-else-if="editId" color="red">{{ DEF_STATUS[sourceStatus] }} · 保存将新建草稿版本</Tag>
|
||
<Tag v-else-if="baseGuid" color="blue">升版本 · 基于 v{{ sourceVersion }}({{ DEF_STATUS[sourceStatus] }})</Tag>
|
||
<Tag v-else color="green">新建</Tag>
|
||
</div>
|
||
<div class="hd-right">
|
||
<Button type="primary" :loading="saving" @click="onSave">保存草稿</Button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="ed-body">
|
||
<main v-if="!loading" class="ed-canvas">
|
||
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)</div>
|
||
<div class="ed-flow">
|
||
<div class="capsule start">发起开始</div>
|
||
<NodeList :items="model.list" container="root" />
|
||
<div class="capsule end">流程结束</div>
|
||
</div>
|
||
</main>
|
||
<main v-else class="ed-canvas loading">
|
||
<Spin size="large" fix />
|
||
</main>
|
||
|
||
<!-- 议题一:右信息面板(五分区,规格对齐原型 E1) -->
|
||
<aside class="ed-info">
|
||
<section class="ip-sec">
|
||
<h4>流程信息<span class="ip-mini">可编辑 · 保存走现有 defSave</span></h4>
|
||
<div class="ip-fld">
|
||
<label>名称</label>
|
||
<Input v-model="model.name" placeholder="流程名称" :maxlength="30" />
|
||
</div>
|
||
<div class="ip-fld">
|
||
<label>编码<span class="ip-mini">升版本态禁用(发布后不可改)</span></label>
|
||
<Input v-model="model.code" placeholder="内部管理键,发布后不可改"
|
||
:disabled="!!editId || !!baseGuid" :maxlength="40" />
|
||
</div>
|
||
<div class="ip-fld">
|
||
<label>归属应用 <span style="color: #ed4014">*</span></label>
|
||
<Select v-model="appKey" placeholder="归属应用(必选)" filterable>
|
||
<Option v-for="a in appOptions" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
||
</Select>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="ip-sec">
|
||
<h4>状态与版本<span class="ip-mini">只读</span></h4>
|
||
<div class="ip-tags">
|
||
<Tag v-if="editId && sourceStatus === 0" color="orange">草稿</Tag>
|
||
<Tag v-else-if="editId" color="red">{{ DEF_STATUS[sourceStatus] }} · 保存将新建草稿版本</Tag>
|
||
<Tag v-else-if="baseGuid" color="blue">升版本 · 基于 v{{ sourceVersion }}</Tag>
|
||
<Tag v-else color="green">新建</Tag>
|
||
<Tag v-if="sourceVersion" color="gray">v{{ sourceVersion }}</Tag>
|
||
</div>
|
||
<div class="ip-guid">
|
||
<span v-if="sourceFlowGuid" class="ip-mono">{{ sourceFlowGuid }}</span>
|
||
<span v-else class="ip-mini">保存后生成</span>
|
||
<Button v-if="sourceFlowGuid" size="small" @click="copyGuid">复制</Button>
|
||
</div>
|
||
<div class="ip-tip">flowGuid 发布后不变,业务方绑定流程用此键<br>保存仅生成草稿;发布校验以后端为准</div>
|
||
</section>
|
||
|
||
<section class="ip-sec">
|
||
<h4>校验问题清单
|
||
<span class="ip-mini">{{ countErrors(issues).length ? `${issueList.length} 项待处理`
|
||
: (countWarns(issues).length ? `${countWarns(issues).length} 个警告` : "无问题") }}</span>
|
||
</h4>
|
||
<div v-if="!issueList.length" class="ip-ok">✓ 校验通过</div>
|
||
<div v-else-if="!countErrors(issues).length" class="ip-ok">
|
||
✓ 校验通过({{ countWarns(issues).length }} 个警告)
|
||
</div>
|
||
<div v-for="(i, idx) in issueList" :key="`${i.uid}-${idx}`" class="ip-issue"
|
||
:class="[(i.level === 'error' ? 'err' : 'warn'), (i.uid ? 'loc' : 'fix')]"
|
||
@click="i.uid && locateIssue(i.uid)">
|
||
<span class="ip-ic">{{ i.level === "error" ? "✗" : "!" }}</span>
|
||
<span class="ip-tx">{{ i.path }}:{{ i.msg }}
|
||
<span v-if="i.level !== 'error'" class="ip-sub">(警告,不阻塞保存)</span>
|
||
</span>
|
||
<span v-if="i.uid" class="ip-go">定位 →</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="ip-sec">
|
||
<h4>结构统计</h4>
|
||
<div class="ip-stats">
|
||
<div class="ip-stat"><b>{{ stats.approval }}</b><span>审批节点</span></div>
|
||
<div class="ip-stat"><b>{{ stats.notify }}</b><span>抄送节点</span></div>
|
||
<div class="ip-stat"><b>{{ stats.condition }}</b><span>条件分支</span></div>
|
||
<div class="ip-stat"><b>{{ stats.parallel }}</b><span>并行分支</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="ip-sec">
|
||
<h4>快捷入口</h4>
|
||
<div class="ip-quick">
|
||
<Button size="small" @click="settingsDrawer = true">流程设置</Button>
|
||
<Button size="small" @click="jsonDrawer = true">源码</Button>
|
||
<Button size="small" :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
|
||
</div>
|
||
<div class="ip-tip">「图示预览」仅已载入的草稿/版本可用(同现状 :disabled="!editId")</div>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
|
||
<ApprovalDrawer v-model="nodeDrawer" :node="editingNode" @removed="nodeDrawer = false" />
|
||
<NotifyDrawer v-model="notifyDrawer" :node="editingNotify" @removed="notifyDrawer = false" />
|
||
<ConditionDrawer v-model="conditionDrawer" :node="editingCondition" @removed="conditionDrawer = false" />
|
||
<SettingsDrawer v-model="settingsDrawer" :settings="model.settings" @save="onUrgeSave" />
|
||
<JsonDrawer v-model="jsonDrawer" :def="defJson" @applied="onJsonApplied" />
|
||
|
||
<!-- 议题二③:升版本改动说明(仅「本次保存将创建新版本」时出现,跳过也放行) -->
|
||
<Modal v-model="logModal" title="保存为新版本草稿" :mask-closable="false" width="520">
|
||
<p class="cl-note">
|
||
当前为「升版本 · 基于 v{{ sourceVersion }}({{ DEF_STATUS[sourceStatus] }})」,
|
||
保存将创建新版本草稿(flowGuid 不变,版本号自动 +1)。
|
||
</p>
|
||
<div class="cl-label">本次改动说明<span class="cl-skip">可跳过</span></div>
|
||
<div class="cl-chips">
|
||
<span v-for="c in LOG_CHIPS" :key="c" class="cl-chip" @click="appendLogChip(c)">{{ c }}</span>
|
||
</div>
|
||
<Input v-model="logText" type="textarea" :rows="3" :maxlength="500" show-word-limit
|
||
placeholder="一句话说清本版改了什么,如:教务处节点补充审批人配置;版本历史与列表可见" />
|
||
<template #footer>
|
||
<span class="cl-hint">跳过也允许保存——但「多一条记录」的区分度就没了</span>
|
||
<Button :disabled="saving" @click="submitSave()">跳过</Button>
|
||
<Button type="primary" :loading="saving" @click="submitSave(logText.trim())">保存草稿</Button>
|
||
</template>
|
||
</Modal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.def-editor {
|
||
display: flex;
|
||
flex-direction: column;
|
||
/* 撑满布局壳可视区:100vh − 56(顶栏) − 56(面包屑 .path-nav) − 24(main-container 下内边距)
|
||
(实测值,见通道 base.layout.css .header-container/.path-nav/.base-wrap-main .main-container) */
|
||
height: calc(100vh - 136px);
|
||
background: #f5f7fa;
|
||
}
|
||
.ed-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 10px 16px;
|
||
background: #fff;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||
z-index: 10;
|
||
}
|
||
.hd-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
.hd-back {
|
||
cursor: pointer;
|
||
color: #808695;
|
||
font-size: 18px;
|
||
}
|
||
.hd-back:hover {
|
||
color: var(--brand);
|
||
}
|
||
.hd-title {
|
||
font-size: 15px;
|
||
}
|
||
.hd-right {
|
||
display: flex;
|
||
gap: 8px;
|
||
white-space: nowrap;
|
||
margin-left: auto;
|
||
}
|
||
.ed-canvas {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
padding: 20px 16px 60px;
|
||
overflow: auto;
|
||
position: relative;
|
||
}
|
||
.ed-hint {
|
||
color: #a5abb8;
|
||
font-size: 12px;
|
||
background: #fff;
|
||
padding: 5px 14px;
|
||
border-radius: 14px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
margin-bottom: 16px;
|
||
white-space: nowrap;
|
||
}
|
||
.ed-flow {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
.capsule {
|
||
width: 132px;
|
||
padding: 10px 0;
|
||
text-align: center;
|
||
border-radius: 24px;
|
||
font-weight: 600;
|
||
letter-spacing: 1px;
|
||
user-select: none;
|
||
}
|
||
.capsule.start {
|
||
background: #e8f9f0;
|
||
color: #19be6b;
|
||
border: 1.5px solid #19be6b;
|
||
}
|
||
.capsule.end {
|
||
background: #f0f1f3;
|
||
color: #808695;
|
||
border: 1.5px solid #808695;
|
||
}
|
||
/* 升版本改动说明弹窗(议题二③,规格对齐原型 V2) */
|
||
.cl-note {
|
||
background: #fff7e6;
|
||
border: 1px solid #ffd77a;
|
||
border-radius: 6px;
|
||
padding: 9px 12px;
|
||
font-size: 12px;
|
||
color: #8a5a17;
|
||
line-height: 1.7;
|
||
margin-bottom: 14px;
|
||
}
|
||
.cl-label {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #17233d;
|
||
margin-bottom: 8px;
|
||
}
|
||
.cl-skip {
|
||
font-weight: 400;
|
||
color: #808695;
|
||
font-size: 12px;
|
||
margin-left: 6px;
|
||
}
|
||
.cl-chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.cl-chip {
|
||
border: 1px solid #e3e6eb;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
padding: 3px 11px;
|
||
font-size: 12px;
|
||
color: #515a6e;
|
||
cursor: pointer;
|
||
}
|
||
.cl-chip:hover {
|
||
border-color: var(--brand);
|
||
color: var(--brand);
|
||
}
|
||
.cl-hint {
|
||
float: left;
|
||
font-size: 12px;
|
||
color: #b0b6c1;
|
||
line-height: 32px;
|
||
}
|
||
|
||
/* 议题一:左画布 + 右信息面板(原型 E1,面板宽 340) */
|
||
.ed-body {
|
||
flex: 1;
|
||
display: flex;
|
||
min-height: 0;
|
||
}
|
||
.ed-info {
|
||
width: 340px;
|
||
flex: none;
|
||
overflow: auto;
|
||
border-left: 1px solid #e8eaec;
|
||
background: #fff;
|
||
padding: 4px 14px 20px;
|
||
}
|
||
.ip-sec {
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid #f0f1f3;
|
||
}
|
||
.ip-sec:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.ip-sec h4 {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #17233d;
|
||
margin: 0 0 10px;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
}
|
||
.ip-mini {
|
||
font-weight: 400;
|
||
font-size: 12px;
|
||
color: #a5abb8;
|
||
}
|
||
.ip-fld {
|
||
margin-bottom: 10px;
|
||
}
|
||
.ip-fld label {
|
||
display: block;
|
||
font-size: 12px;
|
||
color: #808695;
|
||
margin-bottom: 4px;
|
||
}
|
||
.ip-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.ip-guid {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.ip-mono {
|
||
font-family: Consolas, Monaco, "Courier New", monospace;
|
||
font-size: 12px;
|
||
color: #515a6e;
|
||
word-break: break-all;
|
||
}
|
||
.ip-tip {
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
color: #a5abb8;
|
||
line-height: 1.6;
|
||
}
|
||
.ip-ok {
|
||
font-size: 12.5px;
|
||
color: #19be6b;
|
||
}
|
||
.ip-issue {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: flex-start;
|
||
font-size: 12.5px;
|
||
line-height: 1.6;
|
||
padding: 5px 6px;
|
||
border-radius: 4px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.ip-issue.err {
|
||
background: #fff5f5;
|
||
color: #ed4014;
|
||
}
|
||
.ip-issue.warn {
|
||
background: #fff9f0;
|
||
color: #ff9900;
|
||
}
|
||
.ip-issue.loc {
|
||
cursor: pointer;
|
||
}
|
||
.ip-issue.loc:hover {
|
||
filter: brightness(0.97);
|
||
}
|
||
.ip-issue.fix {
|
||
cursor: default;
|
||
}
|
||
.ip-ic {
|
||
flex: none;
|
||
font-weight: 700;
|
||
}
|
||
.ip-tx {
|
||
flex: 1;
|
||
}
|
||
.ip-sub {
|
||
color: #a5abb8;
|
||
}
|
||
.ip-go {
|
||
flex: none;
|
||
font-size: 12px;
|
||
}
|
||
.ip-stats {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.ip-stat {
|
||
flex: 1;
|
||
text-align: center;
|
||
background: #f7f9fc;
|
||
border-radius: 6px;
|
||
padding: 8px 0;
|
||
}
|
||
.ip-stat b {
|
||
display: block;
|
||
font-size: 16px;
|
||
color: #17233d;
|
||
}
|
||
.ip-stat span {
|
||
font-size: 12px;
|
||
color: #808695;
|
||
}
|
||
.ip-quick {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
/* 校验清单定位高亮(原型 E2,用项目错误红不做原型紫);卡片在子组件内,需 :deep 命中 */
|
||
:deep(.nd-locate) {
|
||
box-shadow: 0 0 0 4px rgba(237, 64, 20, 0.18) !important;
|
||
}
|
||
</style>
|