Compare commits

...
7 Commits
Author SHA1 Message Date
chenxing e01ef87380 fix(def/ui): 验收审计补丁——调试弹窗内置变量口径与 chips 区分;分组态空 flowGuid 兜底
build / build (push) Successful in 1m56s
- ExprEvalModal:内置变量条 submitterId 改显真实 id(原用 submitterName 冒充,用户照抄进
  样例 JSON 会填错类型);可用变量 chips 区分内置变量(灰底,对齐原型 .chip.bt)并让手动模式
  同样提供内置键(原型 S5「内置变量同款可插」);chips 去重,避免 formData 键与内置键同名重复。
- Defs:分组视图空 flowGuid 行不再被整体丢弃,各自单列一行;openMap 改用合成 key(__noguid__{id})
  防止与其他行共用 "" 键互相牵连;GUID 列空值显示「—」。
- 验证:npm run build 通过(1m16s,产物含 Defs / ExprEvalModal chunk);
  npx tsc --noEmit 48 条与既有基线完全一致、本次改动文件 0 条。

来源:流程中心改造-验收审计报告-B2-B6-20261012.md §三.3c / §三.5
2026-10-12 18:03:34 +08:00
chenxing 94f263d489 feat(condition): 新建条件分支缺省翻转为表达式(EXPRESSION 主路径)——抽屉初值+createCondition 默认;SERVICE 分支补提示文案(仅实时业务数据场景使用,端点与登记由流程中心接入件 starter 代办);来源列注释订正 starter 名 2026-10-12 17:13:21 +08:00
chenxing adcdd1217e B4 补充:办件下拉给 Option 显式 label(#id 标题),filterable 搜索与选中回显不依赖槽内富文本 2026-10-12 17:09:33 +08:00
chenxing b96d3d6451 B4+B5 表达式调试三模式弹窗(共享组件)+ 编辑器出口内联试算
- 新增 views/flow/components/ExprEvalModal.vue(原型 S1-S5,宽 560→700):
  1) 从办件导入:adminInstances(page1,size50) 的 filterable Select(#id·bizType/bizId·标题·提交人·时间+状态Tag)、
     bizType distinct chips 筛选、选中即 formData 解析缩进填充(解析失败填原文+红字)、内置变量只读条
     (instanceId/instanceVersion/bizType/bizId/submitterId;nodeId/nodeName 提示按调试目标自填;schId 不展示)
  2) 从路由日志导入:detail(instanceId) 取 kind=ROUTE 行,表格 节点名/注册键/命中出口(空=未命中)/attempt/时间
     + 每行 rawResponse <pre>(>200 字截断、title 全量、无留痕显示「(无留痕)」);
     「载入该办件变量并复现」= 填 formData + 保留表达式再求值;无 ROUTE 行给空态文案
  3) 手动编写:原 textarea 语义保留 + 最近 50 条办件 formData 键聚合 chips(点击插光标处)+ 引导文案
  可用变量 chips 点击插入表达式光标(selectionStart/End,无焦点退化追加);求值仍走 /admin/router/eval,
  结果区四态文案(true/false/error/未求值)含线上行为说明与「宁挂起不误放行」口径
- Routers.vue:删旧内联求值弹窗(evalForm/evalResult/doEval),改挂 <ExprEvalModal v-model="exprVisible">
- ConditionBox.vue(B5/S6):EXPRESSION 模式出口表达式行右侧加「试算」小钮(@click.stop 不触发容器交互),
  带该出口表达式打开同一弹窗

注意:本提交连带纳入了并发会话在 Routers.vue 的 6 行「来源」列改动(regAppKey 自注册标识,
属 SERVICE 收口工作),非本批次内容,review 时请单独留意
2026-10-12 16:35:26 +08:00
chenxing dda7336323 B3-2 编辑器顶栏精简 + 底部状态栏退役(配 B3-1 面板,两步提交可单独回滚)
- 顶栏只留 返回/「流程编辑器」/状态 Tag + 右侧「保存草稿」(名称/编码/应用三个输入框从 hd-mid 迁走,
  双向同源仍是 model.name/model.code/appKey 同一 ref)
- 删 footer.ed-status:错误/警告汇总进右面板分区3,「保存仅生成草稿;发布校验以后端为准」并入分区2 提示行
- 顺带删 .hd-mid/.ed-status/.st-* 死样式;.ed-canvas 加 min-width:0(1360px 档不出横向滚动)

验证:npm run build 通过
2026-10-12 16:21:54 +08:00
chenxing f0ec500348 B3-1 编辑器右信息面板 + 校验清单定位(先加面板,保存链路不动)
- index.vue:画布与面板包进 .ed-body(flex row),新增 aside.ed-info 五分区——
  流程信息(名称/编码/归属应用,与画布同源同一 ref,编码沿用 !!editId||!!baseGuid 禁用)/
  状态与版本(状态 Tag + v 版本号 + flowGuid 等宽 + 复制,新建态显示「保存后生成」)/
  校验问题清单(错误在前警告在后,项内 path+msg)/ 结构统计(walkItems 递归四类计数)/
  快捷入口(流程设置/源码/图示预览,:disabled=!editId 同现状)
- sourceFlowGuid ref:load() 里 defGet 后取 d.flowGuid,新建态空串
- locateIssue:清单项点击 scrollIntoView 居中 + .nd-locate 高亮 2.8s;三张卡片根元素加 :id="'nd-'+node.uid",
  uid 为空的清单项 class=fix 不可点;高亮样式走 :deep(卡片在子组件内)
- 画布左对齐:.ed-canvas align-items center→flex-start

验证:npm run build 通过;npx tsc --noEmit 48 条=基线无新增;convert 往返 ALL PASS
2026-10-12 16:19:51 +08:00
chenxing 5cf1ce8d1f B2 议题二前端:版本历史补全 + 升版本改动说明 + 列表分组 + 在途可见
- Defs.vue 版本历史弹窗 640→820,补 创建人/创建时间/改动说明 三列(存量说明显示「(未填写)」)
- def-editor 升版本保存弹「本次改动说明(可跳过)」:doSave 入口设闸(!editId&&baseGuid 或
  editId&&sourceStatus!==0),主体抽成 submitSave(changeLog) 防递归;常用类型 chips 分号追加,
  500 字上限;跳过与填写两条路径都放行并按说明有无区分 toast;草稿直改不弹不传
- Defs.vue 列表默认分组态(同 flowGuid 折叠一行,代表行=已发布最高版,无已发布标「无生效版」),
  展开行复用 rowActions;可切「显示全部版本」回现状平铺
- 在途(议题二④):defList 落位后批量取 distinct flowGuid → 新接口 defInflight,
  分组行/平铺 GUID 旁展示「在途 N 件」(0 也展示,接口失败降级「—」);停用确认 N>0 追加在途文案
- flow.ts:defSave 加 changeLog 入参、新增 defInflight(未跑 api:gen,按方案手工改)

验证:npm run build 通过;vue-tsc 1.8.27 与 TS 5.9.3 不兼容无法作门禁(另记)
2026-10-12 16:14:32 +08:00
10 changed files with 1065 additions and 180 deletions
+5 -1
View File
@@ -144,7 +144,7 @@ class FlowApi {
defGet(id: number | string) { defGet(id: number | string) {
return get(`${ROOT}/admin/def/get`, { id }); return get(`${ROOT}/admin/def/get`, { id });
} }
defSave(body: { id?: number; code: string; name: string; contentJson: string; baseGuid?: string; appKey?: string }) { defSave(body: { id?: number; code: string; name: string; contentJson: string; baseGuid?: string; appKey?: string; changeLog?: string }) {
return post(`${ROOT}/admin/def/save`, body); return post(`${ROOT}/admin/def/save`, body);
} }
/** 元数据原地编辑(2026-10-10 议题2):只改 name/appKey 不升版本;已发布亦可 */ /** 元数据原地编辑(2026-10-10 议题2):只改 name/appKey 不升版本;已发布亦可 */
@@ -164,6 +164,10 @@ class FlowApi {
defVersions(flowGuid: string) { defVersions(flowGuid: string) {
return get(`${ROOT}/admin/def/versions`, { flowGuid }); return get(`${ROOT}/admin/def/versions`, { flowGuid });
} }
/** 在途办件数(议题二④):flowGuid → 审批中办件数,无在途的流程线也返回 0 键 */
defInflight(flowGuids: string[]) {
return get(`${ROOT}/admin/def/inflight`, { flowGuids: flowGuids.join(",") });
}
/** 流程定义示意图(管理面:defId 任意版本,草稿可预览) */ /** 流程定义示意图(管理面:defId 任意版本,草稿可预览) */
adminDefDiagram(params: { id: number | string; mode?: string }) { adminDefDiagram(params: { id: number | string; mode?: string }) {
return get(`${ROOT}/admin/def/diagram`, params); return get(`${ROOT}/admin/def/diagram`, params);
+197 -38
View File
@@ -4,7 +4,7 @@
// 本页不再直接编辑 JSON 文本(编辑器内置"源码"高级模式兜底);行内「源码」为只读查看+复制。 // 本页不再直接编辑 JSON 文本(编辑器内置"源码"高级模式兜底);行内「源码」为只读查看+复制。
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Message, Tag } from "view-ui-plus"; import { Message, Table, Tag } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue"; import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
import { flowApi } from "@/api/module/flow"; import { flowApi } from "@/api/module/flow";
@@ -29,54 +29,190 @@ const defsH = useElementHeight(defsRegionRef);
const apps = ref<{ appKey: string; appName: string }[]>([]); const apps = ref<{ appKey: string; appName: string }[]>([]);
const appMap = ref<Record<string, string>>({}); const appMap = ref<Record<string, string>>({});
const filterApp = ref(""); const filterApp = ref("");
// 议题二②:列表视图(group=同 flowGuid 折叠一行,默认;flat=现状全版本平铺)——纯前端切换,defList 已全量返回
const viewMode = ref<"group" | "flat">("group");
// 分组展开态(key=flowGuid):Table 数据由 computed 派生,展开态必须存外面才不会丢
const openMap = ref<Record<string, boolean>>({});
// 议题二④:flowGuid → 在途办件数;拉取失败降级空 map(行内显示「—」,列表不崩)
const inflightMap = ref<Record<string, number>>({});
/** 在途展示:数字=件数(0 也展示);undefined=接口无该线数据 → 「—」 */
function inflightText(guid: string): string {
const n = inflightMap.value[guid];
return n == null ? "在途 —" : `在途 ${n} 件`;
}
/** 创建时间格式化 YYYY-MM-DD HH:mm(后端 LocalDateTime 带 T,空值显示 -) */
function fmtTime(raw: any): string {
return raw ? String(raw).replace("T", " ").slice(0, 16) : "-";
}
/** 应用列(未归属标记 2026-10-12 appKey 必填化):平铺/分组/展开行共用,口径不漂移 */
const renderApp = (h: any, p: any) => {
if (!p.row.appKey) {
return h(Tag, { color: "warning" }, () => "未归属");
}
return h("span", appMap.value[p.row.appKey] || p.row.appKey);
};
/** 状态列(草稿橙/已发布绿/停用灰):三态共用 */
const renderStatus = (h: any, p: any) => {
const c = p.row.status === 1 ? "green" : p.row.status === 0 ? "orange" : "gray";
return h(Tag, { color: c }, () => DEF_STATUS[p.row.status] || p.row.status);
};
/** 行操作集(平铺态、分组展开行同一套;禁用规则按 status) */
const rowActions = [
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => onEdit(r), disabledWhen: (r: any) => r.status !== 0 },
{ key: "publish", label: "发布", icon: "icon-fasong", on: (r: any) => onPublish(r), disabledWhen: (r: any) => r.status !== 0 },
{ key: "newVersion", label: "升版本", icon: "icon-tianjia", on: (r: any) => onNewVersion(r), disabledWhen: (r: any) => r.status !== 1 },
// 停用:破坏性动作(已发布→停用),danger 沉底并进「更多」
{ key: "offline", label: "停用", icon: "icon-stop", on: (r: any) => onOffline(r), disabledWhen: (r: any) => r.status !== 1, danger: true },
// 与「停用」对称的专用启用通道(后端 /def/enable:仅停用版→已发布,重跑发布校验;
// 草稿不走这里,草稿用「发布」)
{ key: "enable", label: "启用", icon: "icon-play", on: (r: any) => onEnable(r), disabledWhen: (r: any) => r.status !== 2 },
// 元数据原地编辑(2026-10-10 议题2):改名称/归属应用不升版本(含已发布/停用)
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
{ key: "diagram", label: "图示", icon: "icon-tupian", on: (r: any) => onDiagram(r) },
{ key: "source", label: "源码", icon: "icon-liebiao", on: (r: any) => onSource(r) },
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
];
const columns = [ const columns = [
{ title: "序号", type: "index", width: 70, align: "center" }, { title: "序号", type: "index", width: 70, align: "center" },
{ title: "流程", key: "name", minWidth: 180 }, { title: "流程", key: "name", minWidth: 180 },
{ title: "编码", key: "code", width: 160 }, { title: "编码", key: "code", width: 160 },
{ title: "应用", key: "appKey", width: 130, align: "center", render: (h: any, p: any) => { { title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
// 未归属标记(2026-10-12 appKey 必填化):存量 NULL 线在此醒目,随编辑/升版本收敛
if (!p.row.appKey) {
return h(Tag, { color: "warning" }, () => "未归属");
}
return h("span", appMap.value[p.row.appKey] || p.row.appKey);
} },
{ title: "版本", key: "version", width: 70, align: "center" }, { title: "版本", key: "version", width: 70, align: "center" },
{ title: "状态", width: 90, align: "center", render: (h: any, p: any) => { { title: "状态", width: 90, align: "center", render: renderStatus },
const c = p.row.status === 1 ? "green" : p.row.status === 0 ? "orange" : "gray"; { title: "GUID", key: "flowGuid", width: 240, render: (h: any, p: any) => {
return h(Tag, { color: c }, () => DEF_STATUS[p.row.status] || p.row.status); // 平铺态也带在途(议题二④):计数按流程线口径,故挂在 GUID 旁
const n = inflightMap.value[p.row.flowGuid];
return h("span", {}, [
p.row.flowGuid,
h(Tag, { color: n ? "blue" : "default", style: "margin-left:6px" }, () => inflightText(p.row.flowGuid)),
]);
} }, } },
{ title: "GUID", key: "flowGuid", width: 200 }, { title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", (p.row.publishTime || "").replace("T", " ") || "-") }, actionColumn(rowActions, { mode: "text", width: 180, fixed: "right" }),
actionColumn(
[
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => onEdit(r), disabledWhen: (r: any) => r.status !== 0 },
{ key: "publish", label: "发布", icon: "icon-fasong", on: (r: any) => onPublish(r), disabledWhen: (r: any) => r.status !== 0 },
{ key: "newVersion", label: "升版本", icon: "icon-tianjia", on: (r: any) => onNewVersion(r), disabledWhen: (r: any) => r.status !== 1 },
// 停用:破坏性动作(已发布→停用),danger 沉底并进「更多」
{ key: "offline", label: "停用", icon: "icon-stop", on: (r: any) => onOffline(r), disabledWhen: (r: any) => r.status !== 1, danger: true },
// 与「停用」对称的专用启用通道(后端 /def/enable:仅停用版→已发布,重跑发布校验;
// 草稿不走这里,草稿用「发布」)
{ key: "enable", label: "启用", icon: "icon-play", on: (r: any) => onEnable(r), disabledWhen: (r: any) => r.status !== 2 },
// 元数据原地编辑(2026-10-10 议题2):改名称/归属应用不升版本(含已发布/停用)
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
{ key: "diagram", label: "图示", icon: "icon-tupian", on: (r: any) => onDiagram(r) },
{ key: "source", label: "源码", icon: "icon-liebiao", on: (r: any) => onSource(r) },
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
],
{ mode: "text", width: 180, fixed: "right" },
),
]; ];
async function load() { async function load() {
loading.value = true; loading.value = true;
try { try {
rows.value = (await flowApi.defList({ appKey: filterApp.value || undefined }).send()) || []; rows.value = (await flowApi.defList({ appKey: filterApp.value || undefined }).send()) || [];
loadInflight();
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
/** 在途统计(议题二④):列表到位后取 distinct flowGuids 批量查,失败静默降级(行内显示「—」) */
async function loadInflight() {
const guids = Array.from(new Set(rows.value.map((r: any) => r.flowGuid).filter(Boolean))).slice(0, 100);
if (!guids.length) {
inflightMap.value = {};
return;
}
try {
inflightMap.value = (await flowApi.defInflight(guids).send()) || {};
} catch {
inflightMap.value = {};
}
}
/** 分组行(议题二②):同 flowGuid 折叠一行,代表行=已发布中最高版;无已发布取最高版并标「无生效版」。
* 空 flowGuid(历史脏数据)无流程线可折叠,各自单列为一行,避免被整体丢弃。 */
const groups = computed(() => {
const bucket: Record<string, any[]> = {};
const order: string[] = [];
const noGuid: any[] = [];
rows.value.forEach((r: any) => {
const g = r.flowGuid || "";
if (!g) {
noGuid.push(r);
return;
}
if (!bucket[g]) {
bucket[g] = [];
order.push(g);
}
bucket[g].push(r);
});
const out = order.map((g) => {
const vs = bucket[g].slice().sort((a: any, b: any) => b.version - a.version);
const published = vs.filter((v: any) => v.status === 1);
const cur = published[0] || vs[0];
return {
_key: g, flowGuid: g, id: cur.id, name: cur.name, code: cur.code, appKey: cur.appKey,
version: cur.version, status: cur.status, publishTime: cur.publishTime,
total: vs.length, hasPublished: published.length > 0,
versions: vs, _expanded: !!openMap.value[g],
};
});
// 空 flowGuid 兜底:openMap 用合成 key,避免与其他行共用 "" 键互相牵连
noGuid.forEach((r: any) => {
const k = `__noguid__${r.id}`;
out.push({
_key: k, flowGuid: "", id: r.id, name: r.name, code: r.code, appKey: r.appKey,
version: r.version, status: r.status, publishTime: r.publishTime,
total: 1, hasPublished: r.status === 1, versions: [r], _expanded: !!openMap.value[k],
});
});
return out;
});
/** 展开行:该流程线全部版本明细(行对象即 FlwDef,操作复用 rowActions)。
* 在途计数接口按 flowGuid 聚合,没有按版本的可靠数据,故明细列不铺在途、只在分组行展示。 */
const versionColumns = [
{ title: "版本", width: 70, render: (h: any, p: any) => h("b", `v${p.row.version}`) },
{ title: "状态", width: 90, align: "center", render: renderStatus },
{ title: "创建人", width: 100, render: (h: any, p: any) => h("span", p.row.createBy || "-") },
{ title: "创建时间", width: 140, render: (h: any, p: any) => h("span", fmtTime(p.row.createTime)) },
{ title: "改动说明", minWidth: 230, render: (h: any, p: any) => (p.row.changeLog
? h("span", p.row.changeLog)
: h("span", { style: "color:#999" }, "(未填写)")) },
actionColumn(rowActions, { mode: "text", width: 210 }),
];
const groupColumns = [
{ type: "expand", width: 40, render: (h: any, p: any) => h(Table, {
columns: versionColumns, data: p.row.versions, size: "small", noDataText: "暂无数据",
}) },
{ title: "序号", type: "index", width: 60, align: "center" },
{ title: "流程", minWidth: 260, render: (h: any, p: any) => {
const n = inflightMap.value[p.row.flowGuid];
return h("span", {}, [
h("b", p.row.name),
h(Tag, { color: p.row.hasPublished ? "green" : "default", style: "margin-left:6px" },
() => (p.row.hasPublished ? `当前生效 v${p.row.version}` : "无生效版")),
h(Tag, { color: n ? "blue" : "default", style: "margin-left:6px" }, () => inflightText(p.row.flowGuid)),
]);
} },
{ title: "编码", key: "code", width: 160 },
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
{ title: "版本", width: 120, render: (h: any, p: any) => h("span", `v${p.row.version} · 共 ${p.row.total} 版`) },
{ title: "状态", width: 90, align: "center", render: renderStatus },
{ title: "GUID", width: 130, render: (h: any, p: any) => (p.row.flowGuid
? h("span", { title: p.row.flowGuid }, `${String(p.row.flowGuid).slice(0, 8)}…`)
: h("span", { style: "color:#999" }, "—")) },
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
actionColumn(
[
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
],
{ mode: "text", width: 140, fixed: "right" },
),
];
/** 分组态点行展开/收起(展开箭头同一路径:箭头内部态会被下一帧的 _expanded 重新校准) */
function onGroupRowClick(row: any) {
if (row && row._key) {
openMap.value[row._key] = !openMap.value[row._key];
}
}
/** 应用清单(筛选下拉):应用设置白名单(flw_app enabled=1),失败降级为空下拉 */ /** 应用清单(筛选下拉):应用设置白名单(flw_app enabled=1),失败降级为空下拉 */
async function loadAppKeys() { async function loadAppKeys() {
try { try {
@@ -119,10 +255,13 @@ async function onPublish(row: any) {
} }
function onOffline(row: any) { function onOffline(row: any) {
// 议题二④:在途数 >0 才追加提示(0 或接口降级态沿用原文案)
const n = inflightMap.value[row.flowGuid] || 0;
MyConfirm({ MyConfirm({
title: "停用", title: "停用",
msg: `确定停用流程「${row.name}」吗?`, msg: `确定停用流程「${row.name}」吗?`,
info: "停用后无法发起新办件,进行中的办件不受影响", info: "停用后无法发起新办件,进行中的办件不受影响"
+ (n ? `;当前在途 ${n} 件仍跑本版直至完成` : ""),
async onOk() { async onOk() {
await flowApi.defOffline(row.id).send(); await flowApi.defOffline(row.id).send();
Message.success("已停用"); Message.success("已停用");
@@ -241,19 +380,39 @@ onMounted(() => {
style="width: 200px; margin-left: 8px" @on-change="load"> style="width: 200px; margin-left: 8px" @on-change="load">
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option> <Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
</Select> </Select>
<!-- 议题二②:分组(同 flowGuid 折叠一行,默认)/ 平铺(现状全版本行)纯前端切换 -->
<RadioGroup v-model="viewMode" type="button" button-style="solid" class="fl" style="margin-left: 8px">
<Radio label="group">仅最新版(分组)</Radio>
<Radio label="flat">显示全部版本</Radio>
</RadioGroup>
<span class="fl" style="margin-left: 12px; color: #808695; font-size: 12px; line-height: 32px">
{{ viewMode === "group"
? "同 flowGuid 多版折叠为一行,点行展开可见各版创建人 / 创建时间 / 改动说明"
: "平铺所有版本行(现状口径)" }}
</span>
</div> </div>
<div class="table-region" ref="defsRegionRef"> <div class="table-region" ref="defsRegionRef">
<Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box" <Table v-if="viewMode === 'group'" :columns="groupColumns" :data="groups" :loading="loading"
class="link-table table-bottom-box" :height="defsH || undefined" no-data-text="暂无数据"
@on-row-click="onGroupRowClick" />
<Table v-else :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
:height="defsH || undefined" no-data-text="暂无数据" /> :height="defsH || undefined" no-data-text="暂无数据" />
</div> </div>
<Modal v-model="versionVisible" title="版本历史" width="640"> <Modal v-model="versionVisible" title="版本历史" width="820">
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-bottom: 8px">
审计信息:创建人 / 创建时间 / 改动说明为本次新增列(存量版本未填说明时显示「(未填写)」)
</p>
<Table :columns="[ <Table :columns="[
{ title: '版本', key: 'version', width: 80 }, { title: '版本', key: 'version', width: 70, render: (h, p) => h('b', 'v' + p.row.version) },
{ title: '名称', key: 'name' }, { title: '名称', key: 'name', width: 150 },
{ title: '状态', width: 90, render: (h, p) => h(Tag, () => DEF_STATUS[p.row.status]) }, { title: '状态', width: 80, render: (h, p) => h(Tag, () => DEF_STATUS[p.row.status]) },
{ title: '发布时间', width: 160, render: (h, p) => (p.row.publishTime || '').replace('T', ' ') || '-' }, { title: '发布时间', width: 132, render: (h, p) => (p.row.publishTime || '').replace('T', ' ').slice(0, 16) || '-' },
{ title: '创建人', width: 90, render: (h, p) => p.row.createBy || '-' },
{ title: '创建时间', width: 132, render: (h, p) => (p.row.createTime || '').replace('T', ' ').slice(0, 16) || '-' },
{ title: '改动说明', minWidth: 230, render: (h, p) => p.row.changeLog
? h('span', p.row.changeLog) : h('span', { style: 'color:#999' }, '(未填写)') },
]" :data="versions" size="small" no-data-text="暂无数据" /> ]" :data="versions" size="small" no-data-text="暂无数据" />
</Modal> </Modal>
+11 -53
View File
@@ -5,6 +5,7 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { Message, Tag } from "view-ui-plus"; import { Message, Tag } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import ExprEvalModal from "@/views/flow/components/ExprEvalModal.vue";
import { flowApi } from "@/api/module/flow"; import { flowApi } from "@/api/module/flow";
import { actionColumn } from "@/utils/tableActions"; import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight"; import { useElementHeight } from "@/utils/hook/useElementHeight";
@@ -20,6 +21,12 @@ const columns = [
{ title: "注册键", key: "routerKey", width: 220 }, { title: "注册键", key: "routerKey", width: 220 },
{ title: "名称", key: "name", width: 160 }, { title: "名称", key: "name", width: 160 },
{ title: "服务目录键", key: "serviceKey", width: 110 }, { title: "服务目录键", key: "serviceKey", width: 110 },
{
// 2026-10-12 SERVICE 收口:reg_app_key 非空=yh-flow-callback starter 自注册行(归属应用)
title: "来源",
width: 160,
render: (h: any, p: any) => h("span", p.row.regAppKey ? `自注册 · ${p.row.regAppKey}` : "手工"),
},
{ title: "判断端点", key: "path", minWidth: 300 }, { title: "判断端点", key: "path", minWidth: 300 },
{ {
title: "basePath", title: "basePath",
@@ -129,33 +136,11 @@ function onEnable(row: any) {
}); });
} }
// ---- 表达式调试(2026-10-10 四议题):与运行时同一求值核心(/admin/router/eval) ---- // ---- 表达式调试(2026-10-10 四议题;2026-10-12 议题三改为三模式共享弹窗 ExprEvalModal)----
const evalVisible = ref(false); const exprVisible = ref(false);
const evaluating = ref(false);
const evalForm = ref<{ expression: string; variablesJson: string }>({ expression: "", variablesJson: "" });
const evalResult = ref<{ result: boolean; value?: string; error?: string } | null>(null);
function onEval() { function onEval() {
evalForm.value = { expression: "", variablesJson: "" }; exprVisible.value = true;
evalResult.value = null;
evalVisible.value = true;
}
async function doEval() {
if (!evalForm.value.expression.trim()) {
Message.warning("请填写表达式");
return;
}
evaluating.value = true;
evalResult.value = null;
try {
evalResult.value = (await flowApi.routerEval({
expression: evalForm.value.expression.trim(),
variablesJson: evalForm.value.variablesJson.trim(),
}).send()) || null;
} finally {
evaluating.value = false;
}
} }
onMounted(load); onMounted(load);
@@ -208,34 +193,7 @@ onMounted(load);
</template> </template>
</Modal> </Modal>
<Modal v-model="evalVisible" title="表达式调试" :mask-closable="false" :width="560"> <ExprEvalModal v-model="exprVisible" />
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-bottom: 10px">
与 CONDITION 节点 EXPRESSION 模式同一套求值(AviatorScript 沙箱)。变量 = 样例 JSON 的
键 + 线上另注入 schId/bizType/bizId/submitterId/instanceId/instanceVersion/nodeId/nodeName
(调试时请在样例里自行提供内置变量取值)。变量未提供会报错——线上同口径(宁挂起不误放行)
</p>
<Input v-model="evalForm.expression" placeholder="表达式,如 amount > 1000" :maxlength="200"
style="margin-bottom: 8px" />
<Input v-model="evalForm.variablesJson" type="textarea" :rows="5"
placeholder='样例变量 JSON,如 {"amount": 2000, "bizType": "leave"}' />
<div v-if="evalResult" style="margin-top: 10px; font-size: 13px">
<template v-if="evalResult.error">
<span style="color: #ed4014">✗ 求值失败:{{ evalResult.error }}</span>
</template>
<template v-else>
<span :style="{ color: evalResult.result ? '#19be6b' : '#ff9900' }">
{{ evalResult.result ? "✓ true(命中该出口)" : "✗ false(不命中)" }}
</span>
<span v-if="evalResult.value !== undefined" style="color: #808695; margin-left: 8px">
求值值:{{ evalResult.value }}
</span>
</template>
</div>
<template #footer>
<Button @click="evalVisible = false">关闭</Button>
<Button type="primary" :loading="evaluating" @click="doEval">求值</Button>
</template>
</Modal>
</div> </div>
</div> </div>
</template> </template>
@@ -3,9 +3,10 @@
// 分支按出口名互斥选路(运行时调判断服务/表达式求值),分支尾隐式汇合到容器后继。 // 分支按出口名互斥选路(运行时调判断服务/表达式求值),分支尾隐式汇合到容器后继。
// 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约); // 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约);
// EXPRESSION 模式(2026-10-10)下分支头部再就地编辑出边 condition,序号即求值优先级。 // EXPRESSION 模式(2026-10-10)下分支头部再就地编辑出边 condition,序号即求值优先级。
import { computed, inject } from "vue"; import { computed, inject, ref } from "vue";
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ConditionNode } from "../model"; import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ConditionNode } from "../model";
import NodeList from "./NodeList.vue"; import NodeList from "./NodeList.vue";
import ExprEvalModal from "@/views/flow/components/ExprEvalModal.vue";
const props = defineProps<{ node: ConditionNode }>(); const props = defineProps<{ node: ConditionNode }>();
@@ -35,10 +36,19 @@ function editCondition(i: number, v: string): void {
while (props.node.conditions.length <= i) props.node.conditions.push(""); while (props.node.conditions.length <= i) props.node.conditions.push("");
props.node.conditions[i] = v; props.node.conditions[i] = v;
} }
// B5 内联试算:把该出口表达式带进共享调试弹窗(ExprEvalModal)
const tryVisible = ref(false);
const tryExpr = ref("");
function openTry(i: number): void {
tryExpr.value = (props.node.conditions || [])[i] || "";
tryVisible.value = true;
}
</script> </script>
<template> <template>
<div class="condition-box" :class="{ error: hasError }"> <div :id="'nd-' + node.uid" class="condition-box" :class="{ error: hasError }">
<div class="cb-title" @click="ctx.openNode(node.uid)"> <div class="cb-title" @click="ctx.openNode(node.uid)">
条件分支(按出口互斥选路) 条件分支(按出口互斥选路)
<span class="cb-hint">(点击标题配置{{ expression ? "表达式" : "判断服务" }})</span> <span class="cb-hint">(点击标题配置{{ expression ? "表达式" : "判断服务" }})</span>
@@ -69,14 +79,17 @@ function editCondition(i: number, v: string): void {
<span class="cb-col-del" title="删除分支">×</span> <span class="cb-col-del" title="删除分支">×</span>
</Poptip> </Poptip>
</div> </div>
<input <div v-if="expression" class="cb-cond-row">
v-if="expression" <input
class="cb-cond" class="cb-cond"
:value="(node.conditions || [])[i] || ''" :value="(node.conditions || [])[i] || ''"
placeholder="条件表达式,如 amount > 1000" placeholder="条件表达式,如 amount > 1000"
maxlength="200" maxlength="200"
@input="editCondition(i, ($event.target as HTMLInputElement).value)" @input="editCondition(i, ($event.target as HTMLInputElement).value)"
/> />
<!-- B5 内联试算:用真实办件变量跑该出口表达式(同「条件路由」页弹窗,预填表达式) -->
<span class="cb-try" title="用真实办件变量试算该表达式" @click.stop="openTry(i)">试算</span>
</div>
<NodeList :items="branch" :container="`${node.uid}:${i}`" /> <NodeList :items="branch" :container="`${node.uid}:${i}`" />
<div v-if="!branch.length" class="cb-empty">该出口直达汇合点(直接通过)</div> <div v-if="!branch.length" class="cb-empty">该出口直达汇合点(直接通过)</div>
</div> </div>
@@ -85,6 +98,7 @@ function editCondition(i: number, v: string): void {
+ 添加分支 + 添加分支
</Button> </Button>
<div v-for="(issue, k) in issues" :key="k" class="cb-issue" :class="issue.level">⚠ {{ issue.text }}</div> <div v-for="(issue, k) in issues" :key="k" class="cb-issue" :class="issue.level">⚠ {{ issue.text }}</div>
<ExprEvalModal v-model="tryVisible" :preset-expression="tryExpr" />
</div> </div>
</template> </template>
@@ -192,6 +206,31 @@ function editCondition(i: number, v: string): void {
.cb-cond::placeholder { .cb-cond::placeholder {
color: #c5b3e8; color: #c5b3e8;
} }
.cb-cond-row {
display: flex;
align-items: center;
gap: 6px;
}
.cb-cond-row .cb-cond {
flex: 1;
width: auto;
margin-bottom: 8px;
}
.cb-try {
flex: none;
cursor: pointer;
font-size: 12px;
color: #0b7d72;
border: 1px solid #b7e3dc;
border-radius: 10px;
padding: 0 8px;
line-height: 18px;
user-select: none;
}
.cb-try:hover {
background: #f2fbf9;
border-color: #0b7d72;
}
.cb-col-del { .cb-col-del {
flex: none; flex: none;
cursor: pointer; cursor: pointer;
@@ -21,7 +21,8 @@ const ctx = inject(EDITOR_CTX)!;
const form = reactive({ const form = reactive({
name: "", name: "",
routerKey: "", routerKey: "",
judgeMode: "SERVICE" as JudgeMode, // 2026-10-12 SERVICE 收口:新建缺省翻转为表达式(主路径);存量归一化见 syncFromNode(勿动)
judgeMode: "EXPRESSION" as JudgeMode,
}); });
function syncFromNode(): void { function syncFromNode(): void {
@@ -134,6 +135,11 @@ function onRemove(): void {
<div v-else class="hint"> <div v-else class="hint">
判断逻辑由业务方实现:引擎运行到该节点时同步调用判断接口,按返回出口名走对应分支 判断逻辑由业务方实现:引擎运行到该节点时同步调用判断接口,按返回出口名走对应分支
</div> </div>
<div class="hint">
仅当判断依赖流程到达时点的实时业务数据时使用;简单条件请用"表达式"(apply 时 formData
自带判断数据)。判断端点与登记由
<code>yh-flow-callback-spring-boot-starter</code>(流程中心接入件)自动完成,见《子应用接入流程中心规范》§6.6。
</div>
</div> </div>
<div v-else class="fld"> <div v-else class="fld">
@@ -20,7 +20,7 @@ function onOpen(e: MouseEvent): void {
</script> </script>
<template> <template>
<div class="node-card" :class="{ error: hasError }" @click="onOpen"> <div :id="'nd-' + node.uid" class="node-card" :class="{ error: hasError }" @click="onOpen">
<div class="nc-head"> <div class="nc-head">
<span class="nc-icon">审</span> <span class="nc-icon">审</span>
<span class="nc-name">{{ node.name || "未命名节点" }}</span> <span class="nc-name">{{ node.name || "未命名节点" }}</span>
@@ -19,7 +19,7 @@ function onOpen(e: MouseEvent): void {
</script> </script>
<template> <template>
<div class="notify-card" :class="{ error: hasError }" @click="onOpen"> <div :id="'nd-' + node.uid" class="notify-card" :class="{ error: hasError }" @click="onOpen">
<div class="nc-head"> <div class="nc-head">
<span class="nc-icon">抄</span> <span class="nc-icon">抄</span>
<span class="nc-name">{{ node.name || "未命名节点" }}</span> <span class="nc-name">{{ node.name || "未命名节点" }}</span>
+364 -73
View File
@@ -55,6 +55,8 @@ const baseGuid = ref("");
/** 载入源定义的状态(已发布/停用版本进入编辑器时提示保存会新建草稿版本) */ /** 载入源定义的状态(已发布/停用版本进入编辑器时提示保存会新建草稿版本) */
const sourceStatus = ref(0); const sourceStatus = ref(0);
const sourceVersion = ref(0); const sourceVersion = ref(0);
/** 源流程线 flowGuid(新建态为空,右面板显示「保存后生成」) */
const sourceFlowGuid = ref("");
/** 归属应用(flw_def.app_key;按应用筛选维度,空=未分配) */ /** 归属应用(flw_def.app_key;按应用筛选维度,空=未分配) */
const appKey = ref(""); const appKey = ref("");
/** 应用清单下拉(应用设置白名单 flw_app enabled=1,显示应用名),拉取失败降级为仅当前值 */ /** 应用清单下拉(应用设置白名单 flw_app enabled=1,显示应用名),拉取失败降级为仅当前值 */
@@ -71,6 +73,44 @@ const routers = reactive<RouterOption[]>([]);
const issues = computed<Issue[]>(() => validateModel(model, depts, routers)); const issues = computed<Issue[]>(() => validateModel(model, depts, routers));
const defJson = computed(() => toContentJson(model)); 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 排序):卡片树投影与画布序列化表示不同但内容等价,脏比较必须同表示 */ /** 归一化(去 layout 坐标、节点按 id 排序):卡片树投影与画布序列化表示不同但内容等价,脏比较必须同表示 */
function canonicalJson(content: any): string { function canonicalJson(content: any): string {
const copy: any = JSON.parse(JSON.stringify(content)); const copy: any = JSON.parse(JSON.stringify(content));
@@ -100,6 +140,10 @@ const conditionDrawer = ref(false);
const settingsDrawer = ref(false); const settingsDrawer = ref(false);
const jsonDrawer = ref(false); const jsonDrawer = ref(false);
const editingUid = ref(""); const editingUid = ref("");
// 议题二③:升版本改动说明弹窗(仅「本次保存将创建新版本」时出现,可跳过)
const logModal = ref(false);
const logText = ref("");
const LOG_CHIPS = ["修复审批人缺失", "调整审批链", "新增抄送对象", "修改条件表达式", "调整流程设置"];
const editingNode = computed<ApprovalNode | null>(() => { const editingNode = computed<ApprovalNode | null>(() => {
const item = editingUid.value ? findItem(model, editingUid.value) : null; const item = editingUid.value ? findItem(model, editingUid.value) : null;
@@ -197,6 +241,7 @@ async function load(): Promise<void> {
const d: any = await flowApi.defGet(queryId).send(); const d: any = await flowApi.defGet(queryId).send();
sourceStatus.value = d?.status ?? 0; sourceStatus.value = d?.status ?? 0;
sourceVersion.value = d?.version ?? 0; sourceVersion.value = d?.version ?? 0;
sourceFlowGuid.value = d?.flowGuid || queryGuid || "";
// 非草稿进入提示(2026-10-10 议题2):结构改动保存将升版本;仅改名称/归属应用请回列表用「编辑信息」 // 非草稿进入提示(2026-10-10 议题2):结构改动保存将升版本;仅改名称/归属应用请回列表用「编辑信息」
if (d?.status === 1 || d?.status === 2) { if (d?.status === 1 || d?.status === 2) {
Message.info("结构改动保存将升版本;仅改名称/归属应用请返回列表用「编辑信息」(不升版本)"); Message.info("结构改动保存将升版本;仅改名称/归属应用请返回列表用「编辑信息」(不升版本)");
@@ -399,6 +444,28 @@ async function onSave(): Promise<void> {
} }
async function doSave(): Promise<void> { 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)); const content = JSON.stringify(toContentJson(model));
saving.value = true; saving.value = true;
try { try {
@@ -410,9 +477,12 @@ async function doSave(): Promise<void> {
contentJson: content, contentJson: content,
baseGuid: baseGuid.value || undefined, baseGuid: baseGuid.value || undefined,
appKey: (appKey.value || "").trim() || undefined, appKey: (appKey.value || "").trim() || undefined,
changeLog: changeLog || undefined,
}).send(); }).send();
savedSnapshot.value = currentSnapshot.value; savedSnapshot.value = currentSnapshot.value;
Message.success("已保存草稿"); Message.success(willCreateVersion()
? (changeLog ? "已保存草稿 · 改动说明已留存" : "已保存草稿(未填写改动说明)")
: "已保存草稿");
router.push("/admin/defs"); router.push("/admin/defs");
} finally { } finally {
saving.value = false; saving.value = false;
@@ -480,60 +550,128 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<Tag v-else-if="baseGuid" color="blue">升版本 · 基于 v{{ sourceVersion }}({{ DEF_STATUS[sourceStatus] }})</Tag> <Tag v-else-if="baseGuid" color="blue">升版本 · 基于 v{{ sourceVersion }}({{ DEF_STATUS[sourceStatus] }})</Tag>
<Tag v-else color="green">新建</Tag> <Tag v-else color="green">新建</Tag>
</div> </div>
<div class="hd-mid">
<label>名称 <Input v-model="model.name" placeholder="流程名称" style="width: 200px" :maxlength="30" /></label>
<label>
编码
<Input
v-model="model.code"
placeholder="内部管理键,发布后不可改"
style="width: 220px"
:disabled="!!editId || !!baseGuid"
:maxlength="40"
/>
</label>
<label>
应用 <span style="color: #ed4014">*</span>
<Select v-model="appKey" placeholder="归属应用(必选)" style="width: 170px" filterable>
<Option v-for="a in appOptions" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
</Select>
</label>
</div>
<div class="hd-right"> <div class="hd-right">
<Button @click="settingsDrawer = true">流程设置</Button>
<Button @click="jsonDrawer = true">源码</Button>
<Button :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
<Button type="primary" :loading="saving" @click="onSave">保存草稿</Button> <Button type="primary" :loading="saving" @click="onSave">保存草稿</Button>
</div> </div>
</header> </header>
<main v-if="!loading" class="ed-canvas"> <div class="ed-body">
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)</div> <main v-if="!loading" class="ed-canvas">
<div class="ed-flow"> <div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)</div>
<div class="capsule start">发起开始</div> <div class="ed-flow">
<NodeList :items="model.list" container="root" /> <div class="capsule start">发起开始</div>
<div class="capsule end">流程结束</div> <NodeList :items="model.list" container="root" />
</div> <div class="capsule end">流程结束</div>
</main> </div>
<main v-else-if="loading" class="ed-canvas loading"> </main>
<Spin size="large" fix /> <main v-else class="ed-canvas loading">
</main> <Spin size="large" fix />
</main>
<footer class="ed-status" :class="{ bad: countErrors(issues).length }"> <!-- 议题一:右信息面板(五分区,规格对齐原型 E1) -->
<span v-if="countErrors(issues).length" class="st-bad"> <aside class="ed-info">
✗ {{ countErrors(issues).length }} 个问题:{{ countErrors(issues).map((i) => i.text).join(";") }} <section class="ip-sec">
</span> <h4>流程信息<span class="ip-mini">可编辑 · 保存走现有 defSave</span></h4>
<span v-else class="st-ok"> <div class="ip-fld">
✓ 校验通过<span v-if="countWarns(issues).length">({{ countWarns(issues).length }} 个警告)</span> <label>名称</label>
</span> <Input v-model="model.name" placeholder="流程名称" :maxlength="30" />
<span class="st-right">保存仅生成草稿;发布校验以后端为准</span> </div>
</footer> <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" /> <ApprovalDrawer v-model="nodeDrawer" :node="editingNode" @removed="nodeDrawer = false" />
<NotifyDrawer v-model="notifyDrawer" :node="editingNotify" @removed="notifyDrawer = false" /> <NotifyDrawer v-model="notifyDrawer" :node="editingNotify" @removed="notifyDrawer = false" />
<ConditionDrawer v-model="conditionDrawer" :node="editingCondition" @removed="conditionDrawer = false" /> <ConditionDrawer v-model="conditionDrawer" :node="editingCondition" @removed="conditionDrawer = 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="onJsonApplied" /> <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> </div>
</template> </template>
@@ -573,31 +711,18 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
.hd-title { .hd-title {
font-size: 15px; font-size: 15px;
} }
.hd-mid {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
justify-content: center;
}
.hd-mid label {
display: flex;
align-items: center;
gap: 6px;
color: #808695;
font-size: 13px;
white-space: nowrap;
}
.hd-right { .hd-right {
display: flex; display: flex;
gap: 8px; gap: 8px;
white-space: nowrap; white-space: nowrap;
margin-left: auto;
} }
.ed-canvas { .ed-canvas {
flex: 1; flex: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: flex-start;
padding: 20px 16px 60px; padding: 20px 16px 60px;
overflow: auto; overflow: auto;
position: relative; position: relative;
@@ -636,28 +761,194 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: #808695; color: #808695;
border: 1.5px solid #808695; border: 1.5px solid #808695;
} }
.ed-status { /* 升版本改动说明弹窗(议题二③,规格对齐原型 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; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
padding: 8px 16px; }
background: #fff; .ip-mono {
border-top: 1px solid #eee; font-family: Consolas, Monaco, "Courier New", monospace;
font-size: 12px; font-size: 12px;
z-index: 9; color: #515a6e;
word-break: break-all;
} }
.ed-status.bad { .ip-tip {
background: #fff5f5; margin-top: 6px;
font-size: 12px;
color: #a5abb8;
line-height: 1.6;
} }
.st-ok { .ip-ok {
font-size: 12.5px;
color: #19be6b; color: #19be6b;
} }
.st-bad { .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; color: #ed4014;
} }
.st-right { .ip-issue.warn {
margin-left: auto; background: #fff9f0;
color: #b0b6c1; color: #ff9900;
white-space: nowrap; }
.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> </style>
+2 -2
View File
@@ -177,14 +177,14 @@ export function createParallel(): ParallelNode {
return { uid: newUid(), kind: "parallel", branches: [[createApproval()], [createApproval()]] }; return { uid: newUid(), kind: "parallel", branches: [[createApproval()], [createApproval()]] };
} }
/** 条件分支:默认 2 分支占位出口名,插入后弹配置抽屉选判断方式(缺省服务) */ /** 条件分支:默认 2 分支占位出口名,插入后弹配置抽屉选判断方式(2026-10-12 收口:缺省表达式) */
export function createCondition(): ConditionNode { export function createCondition(): ConditionNode {
return { return {
uid: newUid(), uid: newUid(),
kind: "condition", kind: "condition",
name: "条件判断", name: "条件判断",
routerKey: "", routerKey: "",
judgeMode: "SERVICE", judgeMode: "EXPRESSION",
conditions: ["", ""], conditions: ["", ""],
outlets: ["出口一", "出口二"], outlets: ["出口一", "出口二"],
branches: [[createApproval()], [createApproval()]], branches: [[createApproval()], [createApproval()]],
+428
View File
@@ -0,0 +1,428 @@
<script setup lang="ts">
// 表达式调试弹窗(议题三,原型 S1-S5):三模式取变量 + 与运行时同一求值口径(/admin/router/eval)。
// 抽成共享组件供条件路由页与(B5)编辑器出口试算共用:外部只传 modelValue / presetExpression。
import { computed, ref, watch } from "vue";
import { Message, Tag } from "view-ui-plus";
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR, flowApi } from "@/api/module/flow";
const props = defineProps<{ modelValue: boolean; presetExpression?: string }>();
const emit = defineEmits<{ (e: "update:modelValue", v: boolean): void }>();
type Mode = "instance" | "routelog" | "manual";
const mode = ref<Mode>("instance");
const expr = ref("");
const varsJson = ref("");
const varsParseError = ref(false);
const instances = ref<any[]>([]);
const instLoading = ref(false);
const pickedId = ref<any>(null);
const bizTypeFilter = ref("");
const traceRows = ref<any[]>([]);
const traceLoading = ref(false);
const traceInstanceId = ref<any>(null);
const result = ref<{ result: boolean; value?: string; error?: string } | null>(null);
const evaluating = ref(false);
const exprBox = ref<any>(null);
/** 内置变量(线上由引擎注入;schId 属管理端上下文不展示,nodeId/nodeName 需按调试目标自填) */
const BUILTIN_KEYS = ["instanceId", "instanceVersion", "bizType", "bizId", "submitterId", "nodeId", "nodeName"];
const BUILTIN_SET = new Set(BUILTIN_KEYS);
const picked = computed(() => instances.value.find((r) => r.id === pickedId.value) || null);
const builtinRows = computed(() => {
const p = picked.value;
if (!p) return [];
return [
{ k: "instanceId", v: p.id },
{ k: "instanceVersion", v: p.version },
{ k: "bizType", v: p.bizType },
{ k: "bizId", v: p.bizId },
{ k: "submitterId", v: p.submitterId },
{ k: "nodeId", v: "", todo: true },
{ k: "nodeName", v: "", todo: true },
];
});
/** 当前变量 JSON 的顶层键(解析失败为空,不阻塞手动编辑) */
const formKeys = computed<string[]>(() => {
try {
const v = JSON.parse(varsJson.value || "{}");
return v && typeof v === "object" && !Array.isArray(v) ? Object.keys(v) : [];
} catch {
return [];
}
});
/** 手动模式的键来源:最近 50 条办件 formData 聚合出现过的键(去重后截 40) */
const manualKeys = computed<string[]>(() => {
const set = new Set<string>();
instances.value.forEach((r) => {
try {
const v = JSON.parse(r.formDataJson || "{}");
if (v && typeof v === "object") Object.keys(v).forEach((k) => set.add(k));
} catch {
/* 脏 formData 跳过,不影响其它办件的键聚合 */
}
});
return Array.from(set).slice(0, 40);
});
/** chips:formData 键(手动模式取历史办件聚合键)+ 内置键;与键名重复时去重,内置键单独置灰标注 */
const chips = computed(() => Array.from(new Set([
...(mode.value === "manual" ? manualKeys.value : formKeys.value),
...BUILTIN_KEYS,
])));
/** bizType 筛选 chips(取当前页 distinct) */
const bizTypes = computed(() => Array.from(new Set(instances.value.map((r) => r.bizType).filter(Boolean))));
const shownInstances = computed(() =>
bizTypeFilter.value ? instances.value.filter((r) => r.bizType === bizTypeFilter.value) : instances.value
);
async function loadInstances(): Promise<void> {
instLoading.value = true;
try {
const res: any = await flowApi.adminInstances({ page: 1, size: 50 }).send();
instances.value = res?.content || [];
} catch (e: any) {
instances.value = [];
Message.error(e?.message || "办件列表拉取失败");
} finally {
instLoading.value = false;
}
}
/** 选中办件 → formData 解析后缩进填入;解析失败填原文并红字提示 */
function fillFromInstance(id: any): void {
const p = instances.value.find((r) => r.id === id);
if (!p) return;
result.value = null;
varsParseError.value = false;
try {
varsJson.value = JSON.stringify(JSON.parse(p.formDataJson || "{}"), null, 2);
} catch {
varsJson.value = String(p.formDataJson || "");
varsParseError.value = true;
}
if (mode.value === "routelog") void loadTrace(p.id);
}
async function loadTrace(instanceId: any): Promise<void> {
if (!instanceId) return;
traceLoading.value = true;
traceInstanceId.value = instanceId;
try {
const d: any = await flowApi.detail(instanceId).send();
traceRows.value = (d?.trace || []).filter((r: any) => r.kind === "ROUTE");
} catch (e: any) {
traceRows.value = [];
Message.error(e?.message || "办件详情拉取失败");
} finally {
traceLoading.value = false;
}
}
/** 载入该办件变量并复现(保留当前表达式) */
async function replayFromLog(): Promise<void> {
if (!traceInstanceId.value) {
Message.warning("请先选择办件");
return;
}
fillFromInstance(traceInstanceId.value);
await doEval();
}
function outletText(r: any): string {
return r.outlet ? r.outlet : "未命中";
}
/** chips 点击插入表达式光标处 */
function insertChip(key: string): void {
const input: HTMLInputElement | null = exprBox.value?.$el?.querySelector?.("input") || null;
const cur = expr.value;
if (!input) {
expr.value = cur ? `${cur} ${key}` : key;
return;
}
const at = input.selectionStart ?? cur.length;
const end = input.selectionEnd ?? at;
expr.value = cur.slice(0, at) + key + cur.slice(end);
requestAnimationFrame(() => {
input.focus();
input.setSelectionRange(at + key.length, at + key.length);
});
}
async function doEval(): Promise<void> {
if (!expr.value.trim()) {
Message.warning("请填写表达式");
return;
}
evaluating.value = true;
result.value = null;
try {
result.value = (await flowApi.routerEval({
expression: expr.value.trim(),
variablesJson: varsJson.value.trim(),
}).send()) || null;
} finally {
evaluating.value = false;
}
}
function reset(): void {
mode.value = "instance";
expr.value = props.presetExpression || "";
varsJson.value = "";
varsParseError.value = false;
pickedId.value = null;
bizTypeFilter.value = "";
traceRows.value = [];
traceInstanceId.value = null;
result.value = null;
void loadInstances();
}
watch(() => props.modelValue, (v) => {
if (v) reset();
});
</script>
<template>
<Modal :model-value="modelValue" title="表达式调试" :mask-closable="false" :width="700"
@update:model-value="(v: boolean) => emit('update:modelValue', v)">
<p class="em-note">
与 CONDITION 节点 EXPRESSION 模式同一套求值(AviatorScript 沙箱)。变量 = 样例 JSON 的键 +
线上另注入 schId/bizType/bizId/submitterId/instanceId/instanceVersion/nodeId/nodeName
(内置变量条仅展示,不参与求值,需在表达式里用到时请自行写入样例 JSON)。
变量未提供会报错——线上同口径(宁挂起不误放行,不按 nil 求值)。
</p>
<RadioGroup v-model="mode" type="button" button-style="solid" class="em-seg">
<Radio label="instance">从办件导入</Radio>
<Radio label="routelog">从路由日志导入</Radio>
<Radio label="manual">手动编写</Radio>
</RadioGroup>
<div class="em-fld">
<label>表达式<span class="em-sub">AviatorScript 沙箱 · 与运行时同一求值口径</span></label>
<Input ref="exprBox" v-model="expr" class="em-mono" placeholder="表达式,如 days >= 3" :maxlength="200" />
<div v-if="chips.length" class="em-chips">
<span v-for="k in chips" :key="k" class="em-chip" :class="{ bt: BUILTIN_SET.has(k) }"
@click="insertChip(k)">{{ k }}</span>
</div>
<div v-if="chips.length" class="em-sub">↑ 可用变量(点击插入表达式光标处;灰色=内置变量,由运行时自动注入)</div>
</div>
<!-- 模式1/2 共用办件选择器;路由日志模式选中即拉该办件 trace -->
<div v-if="mode !== 'manual'" class="em-fld">
<label>选择办件
<span class="em-sub">变量 = 该办件 formData 原样 + 内置变量;选完自动填充,可再手工改</span>
</label>
<Select v-model="pickedId" filterable clearable :loading="instLoading"
placeholder="按标题/业务键搜索最近 50 条办件" @on-change="fillFromInstance">
<Option v-for="r in shownInstances" :key="r.id" :value="r.id"
:label="`#${r.id} ${r.bizTitle || r.bizId || ''}`">
#{{ r.id }} · {{ r.bizType || "-" }}/{{ r.bizId || "-" }} · {{ r.bizTitle || "(无标题)" }}
· {{ r.submitterName || r.submitterId || "-" }} ·
{{ (r.submitTime || "").replace("T", " ").slice(0, 16) || "-" }}
<Tag :color="INSTANCE_STATUS_COLOR[r.status] || 'gray'" style="margin-left: 6px">
{{ INSTANCE_STATUS[r.status] || r.status }}
</Tag>
</Option>
</Select>
<div v-if="bizTypes.length" class="em-chips">
<span class="em-chip flt" :class="{ on: !bizTypeFilter }" @click="bizTypeFilter = ''">全部类型</span>
<span v-for="t in bizTypes" :key="t" class="em-chip flt" :class="{ on: bizTypeFilter === t }"
@click="bizTypeFilter = t">{{ t }}</span>
</div>
<div v-if="picked" class="em-bi">
<span class="em-bi-t">内置变量(线上由引擎注入,只读展示):</span>
<span v-for="b in builtinRows" :key="b.k" class="em-bi-i">
{{ b.k }}={{ b.todo ? "(按调试目标自填)" : b.v }}
</span>
</div>
</div>
<div v-if="mode === 'routelog'" class="em-fld">
<div v-if="traceLoading" class="em-sub">正在读取该办件的路由判断留痕…</div>
<template v-else-if="traceRows && traceRows.length">
<Table :columns="[
{ title: '节点名', key: 'nodeName', width: 140 },
{ title: '注册键', key: 'routerKey', width: 150 },
{ title: '命中出口', width: 120, render: (h, p) => outletText(p.row) },
{ title: 'attempt', key: 'attempt', width: 80 },
{ title: '时间', width: 140, render: (h, p) => (p.row.time || '').replace('T', ' ').slice(0, 19) || '-' },
]" :data="traceRows" size="small" :max-height="180" no-data-text="暂无数据" />
<div v-for="(r, i) in traceRows" :key="`raw${i}`" class="em-raw">
<span class="em-sub">{{ r.nodeName }} rawResponse({{ (r.rawResponse || "").length }} 字):</span>
<pre :title="r.rawResponse || ''">{{ (r.rawResponse || "(无留痕)").slice(0, 200)
}}{{ (r.rawResponse || "").length > 200 ? "…" : "" }}</pre>
</div>
<Button size="small" :loading="evaluating" @click="replayFromLog">载入该办件变量并复现</Button>
<div class="em-sub">复现后可改表达式定位问题,再到办件台账对该办件重跑路由。</div>
</template>
<div v-else class="em-sub">
{{ picked ? "该办件无条件判断记录" : "选择办件后展示其 flw_route_log 逐边留痕" }}
</div>
</div>
<div class="em-fld">
<label>样例变量 JSON(formData)
<span v-if="varsParseError" style="color: #ed4014">该办件 formData 不是合法 JSON,已填原文,请手工修正</span>
<span v-else-if="mode === 'manual'" class="em-sub">粘贴应用实际传递的 formData JSON;不知道传什么?切「从办件导入」取真实变量</span>
</label>
<Input v-model="varsJson" type="textarea" :rows="8" :class="{ 'em-bad': varsParseError }"
placeholder='如 {"days": 5, "bizType": "leave"}' />
</div>
<hr class="em-divider">
<div class="em-fld">
<label>求值结果</label>
<div v-if="!result" class="em-sub">点击「求值」查看结果(与 CONDITION 节点 EXPRESSION 模式同一求值口径)。</div>
<template v-else-if="result.error">
<div class="em-err">✗ 求值失败:{{ result.error }}</div>
<div class="em-sub">宁挂起不误放行——变量缺失/类型不符都会在此暴露,线上同口径挂起而非按 nil 放行</div>
</template>
<template v-else-if="result.result">
<div class="em-ok">✓ true(命中该出口)
<span v-if="result.value !== undefined" class="em-sub">求值值:{{ result.value }}</span>
</div>
<div class="em-sub">线上行为:条件节点按出口顺序取首个为真者走该分支</div>
</template>
<template v-else>
<div class="em-warn">✗ false(不命中)
<span v-if="result.value !== undefined" class="em-sub">求值值:{{ result.value }}</span>
</div>
<div class="em-sub">线上行为:该出口不走;全部出口不命中将挂起等管理端重跑路由</div>
</template>
</div>
<template #footer>
<span class="em-hint">变量缺失即报错——线上同口径(宁挂起不误放行,不按 nil 求值)</span>
<Button @click="emit('update:modelValue', false)">关闭</Button>
<Button type="primary" :loading="evaluating" @click="doEval">求值</Button>
</template>
</Modal>
</template>
<style scoped>
.em-note {
color: #808695;
font-size: 12px;
line-height: 1.7;
margin-bottom: 10px;
}
.em-seg {
margin-bottom: 12px;
}
.em-fld {
margin-bottom: 12px;
}
.em-fld > label {
display: block;
font-size: 12px;
color: #515a6e;
margin-bottom: 4px;
}
.em-sub {
color: #a5abb8;
font-size: 12px;
line-height: 1.6;
}
.em-fld > label .em-sub {
margin-left: 6px;
}
.em-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0 4px;
}
.em-chip {
border: 1px solid #e3e6eb;
border-radius: 12px;
padding: 2px 10px;
font-size: 12px;
color: #0b7d72;
background: #f2fbf9;
cursor: pointer;
}
.em-chip.flt {
color: #515a6e;
background: #fff;
}
/* 内置变量 chip:与 formData 键区分(对齐原型 .chip.bt 口径) */
.em-chip.bt {
border-color: #dcdee2;
color: #515a6e;
background: #f5f7fa;
}
.em-chip.bt:hover {
color: #fff;
background: #515a6e;
border-color: #515a6e;
}
.em-chip.flt.on {
border-color: var(--brand);
color: var(--brand);
}
.em-bi {
margin-top: 8px;
background: #f7f9fc;
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.8;
}
.em-bi-t {
color: #0b7d72;
font-weight: 600;
}
.em-bi-i {
margin-right: 10px;
font-family: Consolas, Monaco, monospace;
color: #515a6e;
}
.em-mono :deep(input) {
font-family: Consolas, Monaco, monospace;
}
.em-bad :deep(.ivu-input) {
border-color: #ed4014;
}
.em-raw {
margin: 6px 0;
}
.em-raw pre {
margin: 2px 0 0;
padding: 6px 8px;
background: #fafbfd;
border: 1px solid #eef0f3;
border-radius: 4px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.em-divider {
border: none;
border-top: 1px solid #eef0f3;
margin: 12px 0;
}
.em-ok {
color: #19be6b;
font-size: 13px;
}
.em-warn {
color: #ff9900;
font-size: 13px;
}
.em-err {
color: #ed4014;
font-size: 13px;
}
.em-hint {
float: left;
color: #b0b6c1;
font-size: 12px;
line-height: 32px;
}
</style>