Files
flow-center-front/src/views/flow/admin/def-editor/index.vue
T
chenxing 0542279636 feat(condition): 条件表达式内联变量 chips(议题一 P0)
编辑器出口表达式下展示可用变量(近期 50 条办件 formData 顶层键聚合去重截 40 + 内置,口径同调试弹窗),点击插入光标处;未知标识符内联提示疑似拼错(仅提示不拦截,去字符串/函数名/属性访问后比对)。EditorCtx 增 availableVars,index.vue 载入一次(拉取失败退化为仅内置,不阻塞编辑)。vite build:base 通过。
2026-10-14 18:18:42 +08:00

980 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>