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/。
202 lines
10 KiB
TypeScript
202 lines
10 KiB
TypeScript
/**
|
||
* 编辑器实时校验(方案 §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");
|
||
}
|