feat(condition): 条件表达式内联变量 chips(议题一 P0)

编辑器出口表达式下展示可用变量(近期 50 条办件 formData 顶层键聚合去重截 40 + 内置,口径同调试弹窗),点击插入光标处;未知标识符内联提示疑似拼错(仅提示不拦截,去字符串/函数名/属性访问后比对)。EditorCtx 增 availableVars,index.vue 载入一次(拉取失败退化为仅内置,不阻塞编辑)。vite build:base 通过。
This commit is contained in:
chenxing
2026-10-14 18:18:42 +08:00
parent fbd428b913
commit 0542279636
3 changed files with 143 additions and 1 deletions
@@ -3,7 +3,7 @@
// 分支按出口名互斥选路(运行时调判断服务/表达式求值),分支尾隐式汇合到容器后继。
// 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约);
// EXPRESSION 模式(2026-10-10)下分支头部再就地编辑出边 condition,序号即求值优先级。
import { computed, inject, ref } from "vue";
import { computed, inject, nextTick, ref } from "vue";
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ConditionNode } from "../model";
import NodeList from "./NodeList.vue";
import ExprEvalModal from "@/views/flow/components/ExprEvalModal.vue";
@@ -45,6 +45,57 @@ function openTry(i: number): void {
tryExpr.value = (props.node.conditions || [])[i] || "";
tryVisible.value = true;
}
// ==================== 可用变量 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 condInputs = ref<Record<number, HTMLInputElement | null>>({});
function setCondInput(el: any, i: number): void {
condInputs.value[i] = (el as HTMLInputElement) || null;
}
/** chips 点击:插入到该出口表达式光标处(无输入框时追加到末尾) */
function insertChip(i: number, key: string): void {
const cur = (props.node.conditions || [])[i] || "";
const el = condInputs.value[i];
const s = el?.selectionStart ?? cur.length;
const e = el?.selectionEnd ?? cur.length;
editCondition(i, cur.slice(0, s) + key + cur.slice(e));
nextTick(() => {
const input = condInputs.value[i];
if (input) {
input.focus();
input.setSelectionRange(s + key.length, s + key.length);
}
});
}
/** 该出口表达式里不在可用列表中的标识符(疑似拼错提示,仅提示不拦截) */
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);
}
</script>
<template>
@@ -81,6 +132,7 @@ function openTry(i: number): void {
</div>
<div v-if="expression" class="cb-cond-row">
<input
:ref="(el) => setCondInput(el, i)"
class="cb-cond"
:value="(node.conditions || [])[i] || ''"
placeholder="条件表达式,如 amount > 1000"
@@ -90,6 +142,23 @@ function openTry(i: number): void {
<!-- B5 内联试算:用真实办件变量跑该出口表达式(同「条件路由」页弹窗,预填表达式) -->
<span class="cb-try" title="用真实办件变量试算该表达式" @click.stop="openTry(i)">试算</span>
</div>
<!-- 议题一 P0:可用变量 chips(表单键 + 内置),点击插入到表达式光标处 -->
<div v-if="expression && availableVars.length" class="cb-vars">
<span
v-for="k in availableVars.slice(0, 14)"
:key="k"
class="cb-var"
:class="{ bt: isBuiltin(k) }"
:title="isBuiltin(k) ? '内置变量(引擎运行时注入)' : '表单变量(来自近期办件 formData)'"
@click.stop="insertChip(i, k)"
>{{ k }}</span>
<span v-if="availableVars.length > 14" class="cb-vars-more" :title="availableVars.join('、')">
+{{ availableVars.length - 14 }}
</span>
</div>
<div v-for="(v, vi) in unknownVars(i)" :key="'u' + vi" class="cb-unknown">
⚠ 「{{ v }}」不在可用变量列表,若为动态变量可忽略
</div>
<NodeList :items="branch" :container="`${node.uid}:${i}`" />
<div v-if="!branch.length" class="cb-empty">该出口直达汇合点(直接通过)</div>
</div>
@@ -231,6 +300,52 @@ function openTry(i: number): void {
background: #f2fbf9;
border-color: #0b7d72;
}
.cb-vars {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin: -2px 0 8px;
}
.cb-var {
font-size: 11px;
line-height: 16px;
padding: 0 8px;
border-radius: 999px;
border: 1px solid #b7e3dc;
background: #f2fbf9;
color: #0b7d72;
cursor: pointer;
user-select: none;
font-family: Consolas, Monaco, monospace;
}
.cb-var:hover {
background: #0b7d72;
color: #fff;
border-color: #0b7d72;
}
.cb-var.bt {
border-color: #dcdee2;
background: #f5f7fa;
color: #4e5969;
}
.cb-var.bt:hover {
background: #4e5969;
color: #fff;
border-color: #4e5969;
}
.cb-vars-more {
font-size: 11px;
line-height: 16px;
padding: 0 8px;
color: #86909c;
cursor: default;
}
.cb-unknown {
text-align: center;
font-size: 11px;
color: #ff9900;
margin: 0 0 8px;
}
.cb-col-del {
flex: none;
cursor: pointer;
+25
View File
@@ -69,6 +69,8 @@ const depts = reactive<DeptOption[]>([]);
const userNames = reactive<Record<number, string>>({});
/** 条件分支的判断服务注册(flw_router;拉取失败为空数组,校验降级提示) */
const routers = reactive<RouterOption[]>([]);
/** 可用变量 chips:近期办件 formData 顶层键聚合(去重截 40)+ 内置变量;拉取失败仅内置 */
const availableVars = reactive<string[]>([]);
const issues = computed<Issue[]>(() => validateModel(model, depts, routers));
const defJson = computed(() => toContentJson(model));
@@ -201,6 +203,27 @@ async function loadRouters(): Promise<void> {
}
}
/** 表达式可用变量:近期 50 条办件 formData 顶层键聚合(去重截 40)+ 内置,口径同调试弹窗;失败仅内置 */
const BUILTIN_VARS = ["schId", "instanceId", "instanceVersion", "bizType", "bizId", "nodeId", "nodeName", "submitterId"];
async function loadVars(): Promise<void> {
const keys = new Set<string>();
try {
const rows: any[] = (await flowApi.adminInstances({ page: 1, size: 50 }).send())?.content || [];
rows.forEach((r) => {
try {
const v = JSON.parse(r.formDataJson || "{}");
if (v && typeof v === "object" && !Array.isArray(v)) Object.keys(v).forEach((k) => keys.add(k));
} catch {
/* 脏 formData 跳过,不影响其它办件的键聚合 */
}
});
} catch {
/* 办件列表拉取失败:退化为仅内置变量,不阻塞编辑 */
}
const merged = Array.from(new Set([...Array.from(keys).slice(0, 40), ...BUILTIN_VARS]));
availableVars.splice(0, availableVars.length, ...merged);
}
function applyParsed(parsed: ParsedDef): void {
model.code = parsed.code || model.code;
model.name = parsed.name || model.name;
@@ -217,6 +240,7 @@ async function load(): Promise<void> {
try {
await loadDepts();
await loadRouters();
void loadVars();
try {
const rows: any[] = (await flowApi.appList().send()) || [];
appOptions.value = rows
@@ -385,6 +409,7 @@ provide<EditorCtx>(EDITOR_CTX, {
depts,
userNames,
routers,
availableVars,
issueOf,
openNode,
removeNode,
+2
View File
@@ -363,6 +363,8 @@ export interface EditorCtx {
userNames: Record<number, string>;
/** 条件分支的判断服务注册下拉(flw_router 启用项;拉取失败为空数组,校验降级提示) */
routers: RouterOption[];
/** 可用变量 chips:近期办件 formData 顶层键聚合 + 内置变量(供条件表达式内联提示/插入) */
availableVars: string[];
issueOf: (uid: string) => { level: "error" | "warn"; text: string }[];
openNode: (uid: string) => void;
removeNode: (uid: string) => void;