feat(condition): 条件路由变量契约收口 + 子应用对接健康度面板(议题一 P0/P1 + 议题二 P2)
build / build (push) Successful in 2m51s

议题一(条件路由体验收口):
- 编辑器动作入口上移顶栏与「保存草稿」同排,右栏「快捷入口」整块删除(流程图痛点 7)
- 发布期变量引用静态校验:validateModel 增加 knownVars 参数,出边表达式引用未在已知变量中的标识符给 warn 级提示。只提示不阻断——拼错与「运行时才有的动态变量」在静态期无法区分,宁可放过
- CONDITION 判断方式文案业务语言化:「服务(外部判断接口)/表达式」→「到达该节点时才能确定/提交时就已确定」,各补一行说明;syncFromNode 取值归一化逻辑未动,零兼容风险
- 内置 8 变量与表达式变量提取抽到 def-editor/model.ts 单一来源(EXPR_BUILTIN_VARS/extractExprVars/unknownExprVars),ConditionBox 与 ExprEvalModal 改为消费,去掉两份本地重复实现

议题二(子应用接入健康度):
- AppSetting.vue 新增 tab3「对接健康度」:每应用一行(评分色块/结论/六项检查 chips/结论说明),问题应用 bad>warn>ok 置顶;首次切入该 tab 才请求,避免进页面就多打一次聚合查询
- Callbacks.vue 新增 appKey 筛选框 + query.appKey 预填,承接健康度面板「查推送日志」跳转
- api 层新增 callbackHealth()

数据来源为已有资产(flw_callback_reg / flw_callback_log),零新增表。对齐《子应用接入流程中心规范.md》§4.2/§7 与《条件路由与子应用接入-痛点调研-20261014.md》。vite build --mode base 通过,产物已构建进 front/dist/。
This commit is contained in:
chenxing
2026-10-15 14:51:04 +08:00
parent 0542279636
commit 1369b103ee
9 changed files with 305 additions and 50 deletions
+4
View File
@@ -252,6 +252,10 @@ class FlowApi {
callbackRepush(ids: number[]) {
return post(`${ROOT}/admin/callback/repush`, ids);
}
/** 对接健康度体检(2026-10-15 P2):每应用一行六项检查 + 综合评分,零新增表 */
callbackHealth() {
return get(`${ROOT}/admin/callback/health`);
}
// ==================== 管理面:条件路由注册 / 重跑 ====================
+139 -2
View File
@@ -2,8 +2,9 @@
// 应用设置(2026-10-10 四议题):tab1 应用管理(flw_app 白名单 + 学校池同步 + 开关);
// tab2 科室管理(flw_dept 组+成员模型,取代旧「办理人映射」页——deptKey=本地自增 id,
// 切源后旧流程定义引用的 UserBase 组 id 需重建科室后重新配置)。
import { ref, onMounted } from "vue";
import { Message, Tag } from "view-ui-plus";
import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { Message, Tag, Button } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import UserCheckModal from "@/components/UserCheckModal.vue";
import { flowApi } from "@/api/module/flow";
@@ -11,6 +12,7 @@ import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
const tab = ref("apps");
const router = useRouter();
// ==================== tab1 应用管理 ====================
const loading = ref(false);
@@ -343,6 +345,101 @@ onMounted(() => {
load();
loadDepts();
});
// ==================== tab3 对接健康度(2026-10-15 议题二 P2) ====================
// 每应用一行六项检查 + 综合评分;数据全部来自已有接口(/callback/health 聚合),零新增表。
// 应用无注册行 = 未接入(由接入向导负责),不参与体检。
const healthLoading = ref(false);
const healthRows = ref<any[]>([]);
const healthColumns = [
{
title: "应用",
key: "appName",
minWidth: 180,
render: (h: any, p: any) =>
h("div", { class: "hc-app" }, [
h("span", { class: "hc-app-n" }, p.row.appName),
h("span", { class: "hc-app-k" }, p.row.appKey),
]),
},
{
title: "评分",
width: 96,
align: "center",
render: (h: any, p: any) =>
h("span", { class: ["hc-score", "is-" + p.row.level] }, String(p.row.score)),
},
{
title: "结论",
width: 90,
align: "center",
render: (h: any, p: any) => {
const map: Record<string, [string, string]> = {
ok: ["正常", "green"],
warn: ["关注", "orange"],
bad: ["异常", "red"],
};
const [txt, color] = map[p.row.level] || ["未知", "default"];
return h(Tag, { color }, () => txt);
},
},
{
title: "检查项",
minWidth: 460,
render: (h: any, p: any) =>
h("div", { class: "hc-checks" }, (p.row.checks || []).map((c: any) =>
h("span", {
class: ["hc-chip", "is-" + c.level],
attrs: { title: (c.advice ? c.advice + "\n" : "") + "实测:" + (c.value || "-") },
}, [
h("i", { class: ["hc-dot", "is-" + c.level] }),
h("span", { class: "hc-chip-n" }, c.name),
c.value ? h("em", { class: "hc-chip-v" }, c.value) : null,
]))),
},
{
title: "结论说明",
key: "summary",
minWidth: 220,
render: (h: any, p: any) => h("span", { class: "hc-sum" }, p.row.summary || "-"),
},
{
title: "操作",
width: 110,
align: "center",
render: (h: any, p: any) =>
h(Button, {
props: { type: "text", size: "small" },
on: { click: () => onViewLogs(p.row) },
}, () => "查推送日志"),
},
];
function onViewLogs(row: any) {
router.push({ path: "/admin/callbacks", query: { appKey: row.appKey } });
}
async function loadHealth() {
healthLoading.value = true;
try {
healthRows.value = ((await (flowApi as any).callbackHealth().send()) as any[]) || [];
} catch (e: any) {
Message.error(e?.message || "健康度加载失败");
healthRows.value = [];
} finally {
healthLoading.value = false;
}
}
// 首次切到「对接健康度」时按需加载(避免进页面就多打一次聚合查询)
let healthLoaded = false;
watch(tab, (t) => {
if (t === "health" && !healthLoaded) {
healthLoaded = true;
loadHealth();
}
});
</script>
<template>
@@ -351,6 +448,7 @@ onMounted(() => {
<Tabs class="tabs-panel" v-model="tab">
<TabPane label="应用管理" name="apps"></TabPane>
<TabPane label="科室管理" name="depts"></TabPane>
<TabPane label="对接健康度" name="health"></TabPane>
</Tabs>
<!-- 应用管理 -->
@@ -385,6 +483,21 @@ onMounted(() => {
</div>
</div>
<!-- 对接健康度(2026-10-15 P2):每应用一行六项检查,问题应用置顶 -->
<div class="tab-body" v-show="tab === 'health'">
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="loadHealth">重新体检</Button>
<span class="fl" style="color: #808695; font-size: 12px; line-height: 40px; margin-left: 10px">
一屏定位「为什么收不到回调」:登记地址形态、base_path、推送成功率、挂起/死信、注册启用态。
数据来自回调注册与推送日志,无注册行的应用视为「未接入」,请走接入向导
</span>
</div>
<div class="table-region">
<Table :columns="healthColumns" :data="healthRows" :loading="healthLoading"
class="link-table table-bottom-box" no-data-text="暂无可体检应用(尚无任何回调注册,请先走接入向导)" />
</div>
</div>
<Modal v-model="createVisible" title="新建科室" :mask-closable="false">
<Input v-model="createName" placeholder="科室名称,如 人事处" :maxlength="30" />
<template #footer>
@@ -432,3 +545,27 @@ onMounted(() => {
</div>
</div>
</template>
<style scoped>
/* 对接健康度面板:一屏一行,检查项紧凑成 chip */
.hc-app { display: flex; flex-direction: column; line-height: 1.35; }
.hc-app-n { color: #17233d; font-weight: 500; }
.hc-app-k { color: #98a1b3; font-size: 12px; }
.hc-score { display: inline-block; min-width: 34px; padding: 1px 6px; border-radius: 4px; font-weight: 600; font-variant-numeric: tabular-nums; }
.hc-score.is-ok { background: #e8f7ee; color: #19a35a; }
.hc-score.is-warn { background: #fff5e6; color: #d98106; }
.hc-score.is-bad { background: #fdecec; color: #e03e3e; }
.hc-checks { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0; }
.hc-chip { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; padding: 1px 7px; border-radius: 3px; font-size: 12px; line-height: 18px; background: #f4f6f9; color: #515a6e; }
.hc-chip.is-warn { background: #fff5e6; color: #b26a00; }
.hc-chip.is-bad { background: #fdecec; color: #c0392b; }
.hc-dot { width: 6px; height: 6px; border-radius: 50%; background: #19a35a; flex: none; }
.hc-dot.is-warn { background: #f0a020; }
.hc-dot.is-bad { background: #e03e3e; }
.hc-chip-n { flex: none; }
.hc-chip-v { font-style: normal; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.85; }
.hc-sum { color: #515a6e; font-size: 12px; }
</style>
+26 -2
View File
@@ -2,6 +2,7 @@
// 回调管理(概览 §9.2):注册列表/启停/secret 重置 + 推送日志 + 挂起(持续重试可视/标记死信)+ 死信重推。
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区;「重推选中」落在分页栏左侧批量操作位
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { Message, Tag } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
@@ -15,6 +16,7 @@ const EVENT_OPTIONS = ["flow.submitted", "flow.task.created", "flow.task.handled
const tab = ref("regs");
const loading = ref(false);
const route = useRoute();
// 无分页 tab 的表格高度:实测各自 .table-region(flex:1),替代 useTableHeight 常量减法
const regsRegionRef = ref<HTMLElement | null>(null);
@@ -24,6 +26,13 @@ const hungH = useElementHeight(hungRegionRef);
// ---- 注册列表 ----
const regs = ref<any[]>([]);
// appKey 筛选(2026-10-15):健康度面板「查推送日志」跳转带入 query.appKey 预填
const regFilter = ref("");
const filteredRegs = () => {
const k = regFilter.value.trim().toLowerCase();
if (!k) return regs.value;
return regs.value.filter((r: any) => String(r.appKey || "").toLowerCase().includes(k));
};
const regColumns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "应用 appKey", key: "appKey", width: 110 },
@@ -208,7 +217,15 @@ function switchTab(name: string) {
}
}
onMounted(loadRegs);
onMounted(() => {
// 从健康度面板跳转时带 appKey,预填注册列表筛选并切到「注册列表」tab
const q = route.query.appKey;
if (q) {
regFilter.value = String(q);
tab.value = "regs";
}
loadRegs();
});
</script>
<template>
@@ -222,8 +239,15 @@ onMounted(loadRegs);
<!-- 回调注册:无分页 -->
<div class="tab-body" v-show="tab === 'regs'">
<div class="fix mb-12">
<Input class="fl" v-model="regFilter" placeholder="按 appKey 筛选" clearable
style="width: 220px" suffix="ios-search" />
<span class="fl" style="color: #808695; font-size: 12px; line-height: 32px; margin-left: 10px">
每行一个 (应用 × 流程) 登记;「探测」同步发一条 flow.ping 验证可达与验签
</span>
</div>
<div class="table-region" ref="regsRegionRef">
<Table :columns="regColumns" :data="regs" :loading="loading" class="link-table table-bottom-box"
<Table :columns="regColumns" :data="filteredRegs()" :loading="loading" class="link-table table-bottom-box"
:height="regsH || undefined" no-data-text="暂无数据" />
</div>
</div>
@@ -4,7 +4,15 @@
// 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约);
// EXPRESSION 模式(2026-10-10)下分支头部再就地编辑出边 condition,序号即求值优先级。
import { computed, inject, nextTick, ref } from "vue";
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ConditionNode } from "../model";
import {
BRANCH_LABELS,
EDITOR_CTX,
EXPR_BUILTIN_SET,
ConditionNode,
MAX_BRANCHES,
MIN_BRANCHES,
extractExprVars,
} from "../model";
import NodeList from "./NodeList.vue";
import ExprEvalModal from "@/views/flow/components/ExprEvalModal.vue";
@@ -49,16 +57,7 @@ function openTry(i: number): void {
// ==================== 可用变量 chips(2026-10-14 议题一 P0):内联提示 + 点击插入 ====================
/** 可用变量(表单字段聚合键 + 内置),来自编辑器上下文;未拉到为空数组则不展示 chips */
const availableVars = computed(() => ctx.availableVars || []);
const BUILTIN_VARS = new Set([
"schId", "instanceId", "instanceVersion", "bizType", "bizId", "nodeId", "nodeName", "submitterId",
]);
const isBuiltin = (k: string): boolean => BUILTIN_VARS.has(k);
/** Aviator 关键字/字面量:不参与"未知变量"提示 */
const EXPR_KEYWORDS = new Set([
"true", "false", "nil", "null", "and", "or", "not", "if", "else", "elsif", "let", "while",
"for", "return", "in", "like", "new", "end", "break", "continue", "seq", "lambda",
]);
const isBuiltin = (k: string): boolean => EXPR_BUILTIN_SET.has(k);
const condInputs = ref<Record<number, HTMLInputElement | null>>({});
function setCondInput(el: any, i: number): void {
@@ -83,18 +82,8 @@ function insertChip(i: number, key: string): void {
/** 该出口表达式里不在可用列表中的标识符(疑似拼错提示,仅提示不拦截) */
function unknownVars(i: number): string[] {
const cond = (props.node.conditions || [])[i] || "";
if (!cond.trim()) return [];
const stripped = cond
.replace(/'[^']*'|"[^"]*"/g, " ") // 字符串字面量
.replace(/[A-Za-z_][A-Za-z0-9_]*\s*\(/g, " ") // 函数名
.replace(/\.\s*[A-Za-z_][A-Za-z0-9_]*/g, " "); // 命名空间/属性访问
const known = new Set<string>([...availableVars.value, ...BUILTIN_VARS]);
const out = new Set<string>();
(stripped.match(/[A-Za-z_][A-Za-z0-9_]*/g) || []).forEach((t) => {
if (!EXPR_KEYWORDS.has(t) && !known.has(t)) out.add(t);
});
return Array.from(out);
const known = new Set<string>([...availableVars.value, ...EXPR_BUILTIN_SET]);
return extractExprVars((props.node.conditions || [])[i] || "").filter((v) => !known.has(v));
}
</script>
@@ -114,13 +114,20 @@ function onRemove(): void {
</div>
<div class="fld">
<label>判断方式</label>
<label>判断依据数据什么时候能拿到</label>
<RadioGroup :model-value="form.judgeMode" @on-change="switchJudgeMode">
<Radio label="SERVICE">服务(外部判断接口)</Radio>
<Radio label="EXPRESSION">表达式</Radio>
<Radio label="EXPRESSION">
<span class="rd-t">提交时就已确定</span>
<span class="rd-d">用表达式判断(推荐)。变量随单据一起提交,编辑器内闭环,零对接。</span>
</Radio>
<Radio label="SERVICE">
<span class="rd-t">到达该节点时才能确定</span>
<span class="rd-d">需实时查库 / 问外部系统时使用。由子应用的判断服务返回出口。</span>
</Radio>
</RadioGroup>
<div class="hint">
简单条件用表达式在编辑器内闭环;复杂判断(查库/调外部系统)沿用注册的判断服务
原措辞为「EXPRESSION / SERVICE」——引擎实现细节,业务管理员难以判断,选错即返工;
改按「数据何时可知」提问,配置者无需理解引擎实现
</div>
</div>
@@ -193,6 +200,19 @@ function onRemove(): void {
.hint.warn {
color: #ff9900;
}
/* 判断方式业务语言化(2026-10-15):标签 + 说明两行式,配置者按"数据何时可知"作答 */
.rd-t {
display: block;
font-weight: 600;
}
.rd-d {
display: block;
font-size: 12px;
color: #808695;
line-height: 1.5;
margin-top: 2px;
white-space: normal;
}
.dw-foot {
position: sticky;
bottom: -16px;
+14 -16
View File
@@ -72,7 +72,7 @@ const routers = reactive<RouterOption[]>([]);
/** 可用变量 chips:近期办件 formData 顶层键聚合(去重截 40)+ 内置变量;拉取失败仅内置 */
const availableVars = reactive<string[]>([]);
const issues = computed<Issue[]>(() => validateModel(model, depts, routers));
const issues = computed<Issue[]>(() => validateModel(model, depts, routers, availableVars));
const defJson = computed(() => toContentJson(model));
/** 结构统计(右面板分区4):walkItems 已递归条件/并行分支的子树 */
@@ -576,6 +576,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<Tag v-else color="green">新建</Tag>
</div>
<div class="hd-right">
<!-- 快捷入口上移顶栏(2026-10-15 痛点 7):右栏「快捷入口」区删除,动作类入口统一收敛到顶栏 -->
<Button size="small" :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
<Button size="small" @click="jsonDrawer = true">源码</Button>
<Button size="small" @click="settingsDrawer = true">流程设置</Button>
<span class="hd-divider" />
<Button type="primary" :loading="saving" @click="onSave">保存草稿</Button>
</div>
</header>
@@ -660,16 +665,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<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>
@@ -738,10 +733,18 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
}
.hd-right {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
margin-left: auto;
}
/* 顶栏工具栏分组竖线(2026-10-15 痛点 7):左侧「查看/配置类」与右侧「保存草稿」主操作拉开层级 */
.hd-divider {
width: 1px;
height: 18px;
background: #e5e6eb;
margin: 0 4px;
}
.ed-canvas {
flex: 1;
min-width: 0;
@@ -967,11 +970,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
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;
+54
View File
@@ -160,6 +160,60 @@ export const BRANCH_LABELS = ["①", "②", "③", "④", "⑤", "⑥"];
export const DEFAULT_URGE: UrgeSetting = { enabled: true, intervalHours: 24, maxTimes: 3 };
// ==================== 表达式变量契约(2026-10-15 抽共享,消除三处漂移) ====================
/**
* 表达式内置变量白名单。
* 唯一权威来源是后端 `ConditionRouteService.builtinEnv`(运行时注入的同名集)。
* 前端两处消费者(ConditionBox 的 chips、validate 的未知变量比对)必须共用此常量,
* 改动时需同步后端 `DefValidator.EXPRESSION_BUILTINS` 与 `ConditionRouteService.builtinEnv`。
*/
export const EXPR_BUILTIN_VARS = [
"schId",
"instanceId",
"instanceVersion",
"bizType",
"bizId",
"nodeId",
"nodeName",
"submitterId",
] as const;
/** 内置变量快速判定集合 */
export const EXPR_BUILTIN_SET: ReadonlySet<string> = new Set<string>(EXPR_BUILTIN_VARS);
/**
* AviatorScript 关键字/字面量:不参与"未知变量"提示。
* 与后端表达式语法(AviatorScript)对齐,仅用于静态提取时的剔除。
*/
export const EXPR_KEYWORDS: ReadonlySet<string> = new Set([
"true", "false", "nil", "null", "and", "or", "not", "if", "else", "elsif", "let", "while",
"for", "return", "in", "like", "new", "end", "break", "continue", "seq", "lambda",
]);
/**
* 从表达式里静态提取"疑似变量名"(剔除字符串字面量、函数名、属性访问、关键字)。
* 与后端 `ExpressionEvaluator.variableNames()` 同口径的前端简化实现:
* 后端解析 AST 更精确,前端只做词法级粗筛——因此结果**只用于 warn 提示,绝不用于阻断**。
*/
export function extractExprVars(expression: string): string[] {
const cond = (expression || "").trim();
if (!cond) return [];
const stripped = cond
.replace(/'[^']*'|"[^"]*"/g, " ") // 字符串字面量
.replace(/[A-Za-z_][A-Za-z0-9_]*\s*\(/g, " ") // 函数名
.replace(/\.\s*[A-Za-z_][A-Za-z0-9_]*/g, " "); // 命名空间/属性访问
const out = new Set<string>();
(stripped.match(/[A-Za-z_][A-Za-z0-9_]*/g) || []).forEach((t) => {
if (!EXPR_KEYWORDS.has(t)) out.add(t);
});
return Array.from(out);
}
/** 表达式中不在「已知变量集合」里的标识符(疑似拼错;仅提示不拦截) */
export function unknownExprVars(expression: string, known: ReadonlySet<string>): string[] {
return extractExprVars(expression).filter((v) => !known.has(v));
}
// ==================== 工厂 ====================
let uidSeq = 0;
+25 -1
View File
@@ -8,11 +8,13 @@
import {
BRANCH_LABELS,
DeptOption,
EXPR_BUILTIN_SET,
EditorItem,
EditorModel,
MAX_BRANCHES,
MIN_BRANCHES,
RouterOption,
unknownExprVars,
} from "./model";
export type IssueLevel = "error" | "warn";
@@ -28,11 +30,23 @@ export interface Issue {
text: string;
}
export function validateModel(model: EditorModel, depts: DeptOption[], routers: RouterOption[] = []): Issue[] {
/**
* @param availableVars 该归属应用近期办件 formData 顶层键聚合(编辑器已拉取)。
* 仅用于表达式变量的"疑似拼错"**警告**(2026-10-15 P0),不参与任何 error 级判定——
* 为空数组时退化为仅比对内置变量,宁少报不误杀(动态变量场景)。
*/
export function validateModel(
model: EditorModel,
depts: DeptOption[],
routers: RouterOption[] = [],
availableVars: string[] = []
): Issue[] {
const issues: Issue[] = [];
const push = (uid: string, level: IssueLevel, path: string, msg: string): void => {
issues.push({ uid, level, path, msg, text: `${path}:${msg}` });
};
/** 已知变量集:formData 聚合键 + 内置变量(与 ConditionBox chips 同口径) */
const knownVars: ReadonlySet<string> = new Set<string>([...availableVars, ...EXPR_BUILTIN_SET]);
const rec = (items: EditorItem[], path: string): void => {
items.forEach((item) => {
@@ -114,6 +128,16 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
if (!cond.trim()) {
push(item.uid, "error", path,
`出口「${o || i + 1}」未配条件表达式(表达式模式下每个出口必须配置,发布会被拦截)`);
return;
}
// 变量引用静态校验(2026-10-15 P0):疑似拼错的键给 warn,**不阻断**发布。
// 理由:formData 键由子应用在运行期决定,动态变量场景误杀代价高于漏报;
// 服务端 DefValidator.warnBuiltinMisspelling 是权威口径(含 AST 级解析)。
const unknown = unknownExprVars(cond, knownVars);
if (unknown.length) {
push(item.uid, "warn", path,
`出口「${o || i + 1}」表达式引用了未在已知变量中的 ${unknown
.map((v) => `「${v}」`).join("、")},疑似拼错(若为动态变量可忽略)`);
}
});
} else if (!item.routerKey) {
+7 -2
View File
@@ -4,6 +4,7 @@
import { computed, ref, watch } from "vue";
import { Message, Tag } from "view-ui-plus";
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR, flowApi } from "@/api/module/flow";
import { EXPR_BUILTIN_VARS } from "@/views/flow/admin/def-editor/model";
const props = defineProps<{ modelValue: boolean; presetExpression?: string }>();
const emit = defineEmits<{ (e: "update:modelValue", v: boolean): void }>();
@@ -25,9 +26,13 @@ const result = ref<{ result: boolean; value?: string; error?: string } | null>(n
const evaluating = ref(false);
const exprBox = ref<any>(null);
/** 内置变量(线上由引擎注入;schId 属管理端上下文不展示,nodeId/nodeName 需按调试目标自填) */
/**
* 内置变量(2026-10-15 抽共享:与 ConditionBox chips、后端 builtinEnv 同源)——
* 线上由引擎注入;schId 属管理端上下文不展示,nodeId/nodeName 需按调试目标自填。
* 展示顺序沿用原口径(schId 置末),故此处按名过滤而非直接复用数组顺序。
*/
const BUILTIN_KEYS = ["instanceId", "instanceVersion", "bizType", "bizId", "submitterId", "nodeId", "nodeName"];
const BUILTIN_SET = new Set(BUILTIN_KEYS);
const BUILTIN_SET: ReadonlySet<string> = new Set<string>(EXPR_BUILTIN_VARS);
const picked = computed(() => instances.value.find((r) => r.id === pickedId.value) || null);
const builtinRows = computed(() => {
const p = picked.value;