feat(condition): 条件路由变量契约收口 + 子应用对接健康度面板(议题一 P0/P1 + 议题二 P2)
build / build (push) Successful in 2m51s
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:
@@ -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`);
|
||||
}
|
||||
|
||||
// ==================== 管理面:条件路由注册 / 重跑 ====================
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user