Files
flow-center-front/src/views/flow/admin/def-editor/validate.ts
T
chenxing 1369b103ee
build / build (push) Successful in 2m51s
feat(condition): 条件路由变量契约收口 + 子应用对接健康度面板(议题一 P0/P1 + 议题二 P2)
议题一(条件路由体验收口):
- 编辑器动作入口上移顶栏与「保存草稿」同排,右栏「快捷入口」整块删除(流程图痛点 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/。
2026-10-15 14:51:04 +08:00

202 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 编辑器实时校验(方案 §3.5):镜像后端 DefValidator 中"可前端化"的业务规则。
*
* 分层:error 阻断保存(配置缺失类);warn 仅提示(映射缺失类,允许存草稿、发布时后端拦截)。
* 结构类规则(id 唯一/度约束/孤儿/环/START·END 唯一)由结构化生成保证,前端无需校验。
* 后端 DefValidator 仍是发布期权威,此处仅作体验前置。
*/
import {
BRANCH_LABELS,
DeptOption,
EXPR_BUILTIN_SET,
EditorItem,
EditorModel,
MAX_BRANCHES,
MIN_BRANCHES,
RouterOption,
unknownExprVars,
} from "./model";
export type IssueLevel = "error" | "warn";
export interface Issue {
/** 命中节点 uid(画布就地标注);空容器等无归属问题为空 */
uid: string;
level: IssueLevel;
/** 定位路径(主流程 / 主流程·分支①) */
path: string;
msg: string;
/** 状态栏/卡片展示用完整文案 */
text: string;
}
/**
* @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) => {
if (item.kind === "approval") {
if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空");
const a = item.assignee;
if (!a) {
push(item.uid, "error", path, "未配置审批人");
} else if (a.type === "DEPT_KEY") {
if (!a.deptKey) {
push(item.uid, "error", path, "未选择科室映射");
} else {
const d = depts.find((x) => x.deptKey === a.deptKey);
if (!d) {
push(item.uid, "warn", path, `科室映射「${a.deptKey}」不存在,发布会被拦截`);
} else if (!d.count) {
push(item.uid, "warn", path, `科室「${d.deptName || d.deptKey}」无启用办理人,发布会被拦截`);
}
}
} else if (!a.userIds.length) {
push(item.uid, "error", path, "指定用户不能为空");
}
if (item.outlets && item.outlets.length >= 2) {
if (item.outlets.length > MAX_BRANCHES) {
push(item.uid, "error", path, `出口数最多 ${MAX_BRANCHES} 个`);
}
item.outlets.forEach((o, i) => {
if (!o.trim()) {
push(item.uid, "error", path, `第 ${i + 1} 个出口的按钮文案不能为空`);
} else if (item.outlets!.indexOf(o) !== i) {
push(item.uid, "error", path, `出口文案重复:「${o}」(按钮文案必须唯一)`);
}
});
(item.branches || []).forEach((branch, i) => {
const p = `${path}·出口「${item.outlets![i] || i + 1}」`;
if (!branch.length) {
// 空出口泳道(2026-10-12):合法语义=该出口直达汇合点(直接通过),仅提示不阻断
push(item.uid, "warn", p, "出口泳道为空:该出口直达汇合点(直接通过)");
return;
}
rec(branch, p);
});
}
return;
}
if (item.kind === "notify") {
// 抄送节点(运行时宽容:解析不出人只跳过不阻塞,但发布期后端仍拦配置缺失)
if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空");
const r = item.receivers;
if (!r) {
push(item.uid, "error", path, "未配置抄送对象");
} else if (r.type === "DEPT_KEY") {
if (!r.deptKey) {
push(item.uid, "error", path, "未选择抄送科室");
} else {
const d = depts.find((x) => x.deptKey === r.deptKey);
if (!d) {
push(item.uid, "warn", path, `科室映射「${r.deptKey}」不存在,发布会被拦截`);
} else if (!d.count) {
push(item.uid, "warn", path, `科室「${d.deptName || r.deptKey}」无启用人员,抄送会落空`);
}
}
} else if (!r.userIds.length) {
push(item.uid, "error", path, "抄送用户不能为空");
}
return;
}
if (item.kind === "condition") {
// 条件分支(2026-09-24):判断服务必选(未注册仅 warn,发布期后端权威拦截);
// 出口名即判断接口 outlets 契约,非空唯一(镜像后端 CONDITION 校验口径)。
// EXPRESSION(2026-10-10):每出口条件非空(语法后端拦,前端不重复实现)
if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空");
const expression = item.judgeMode === "EXPRESSION";
if (expression) {
item.outlets.forEach((o, i) => {
const cond = (item.conditions || [])[i] || "";
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) {
push(item.uid, "error", path, "未选择判断服务(发布后运行到该节点会挂起)");
} else if (routers.length && !routers.some((r) => r.routerKey === item.routerKey && r.status === 1)) {
push(item.uid, "warn", path, `判断服务「${item.routerKey}」未注册或已停用,发布会被拦截`);
}
if (item.branches.length < MIN_BRANCHES) {
push(item.uid, "error", path, `条件分支至少保留 ${MIN_BRANCHES} 个`);
}
if (item.branches.length > MAX_BRANCHES) {
push(item.uid, "error", path, `条件分支最多 ${MAX_BRANCHES} 个`);
}
item.outlets.forEach((o, i) => {
if (!o.trim()) {
push(item.uid, "error", path, `第 ${i + 1} 条分支的出口名不能为空`);
} else if (item.outlets.indexOf(o) !== i) {
push(item.uid, "error", path, `出口名重复:「${o}」(出口名即判断依据,必须唯一)`);
}
});
item.branches.forEach((branch, i) => {
const branchPath = `${path}·出口「${item.outlets[i] || BRANCH_LABELS[i] || i + 1}」`;
if (!branch.length) {
// 空出口分支(2026-10-12):合法语义=该出口直达汇合点(直接通过),仅提示不阻断
push(item.uid, "warn", branchPath, "分支为空:该出口直达汇合点(直接通过)");
return;
}
rec(branch, branchPath);
});
return;
}
if (item.branches.length < MIN_BRANCHES) {
push(item.uid, "error", path, `并行分支至少保留 ${MIN_BRANCHES} 个`);
}
if (item.branches.length > MAX_BRANCHES) {
push(item.uid, "error", path, `并行分支最多 ${MAX_BRANCHES} 个`);
}
item.branches.forEach((branch, i) => {
const branchPath = `${path}·分支${BRANCH_LABELS[i] || i + 1}`;
if (!branch.length) {
// 结构化模型不允许空分支(删除最后一个节点会被父级拦截),此处仅兜底
push(item.uid, "error", branchPath, "分支为空,请添加审批节点");
return;
}
rec(branch, branchPath);
});
});
};
rec(model.list, "主流程");
return issues;
}
export function countErrors(issues: Issue[]): Issue[] {
return issues.filter((i) => i.level === "error");
}
export function countWarns(issues: Issue[]): Issue[] {
return issues.filter((i) => i.level === "warn");
}