/** * 编辑器实时校验(方案 §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 = new Set([...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"); }