diff --git a/docs/test/acceptance.md b/docs/test/acceptance.md index 2888656..bc76fe8 100644 --- a/docs/test/acceptance.md +++ b/docs/test/acceptance.md @@ -1,6 +1,6 @@ # yys-editor 验收测试点(手工) -目标:覆盖“用户素材上传/管理、资产引用、Dynamic Group 规则提示、性能优化”等需求。 +目标:覆盖“用户素材上传/管理、资产引用、Dynamic Group 规则提示、规则管理(DSL/变量导入导出)、性能优化”等需求。 ## 0. 基础启动与构建 @@ -101,18 +101,39 @@ 步骤: - 在同一分组中放入: - “辉夜姬” 与 “破势” - - “千姬” 与 “腹肌清姬/蝮骨清姬” - - 只有式神但没有供火式神 + - 只有式神但没有供火式神(不含供火名单) - 观察右侧/控制区的规则提示列表。 预期: -- 出现对应警告提示。 +- 出现对应警告提示(当前默认预制规则): + - `TEAM_KAGUYA_POSHI_CONFLICT` + - `TEAM_MISSING_FIRE_SHIKIGAMI` - 取消分组、移除节点后提示实时更新/消失。 排查点: -- `src/utils/groupRules.ts`、`src/configs/groupRules.ts`。 +- `src/configs/groupRules.ts`(预制规则与变量) +- `src/utils/groupRules.ts`(按 expressionRules 解析) - `src/components/flow/FlowEditor.vue` 的 `scheduleGroupRuleValidation(...)` 调度时机。 +## 6.1 规则管理(表格化 + 导入导出) + +步骤: +- 点击顶部工具栏“规则管理”。 +- 在“规则”tab 验证单行表格展示(启用勾选、级别、规则ID、条件、提示)。 +- 点击“编辑”打开单独弹窗修改规则并保存。 +- 在“变量”tab 修改变量后点击“应用并生效”。 +- 点击“导出规则变量”导出 JSON,再点击“导入规则变量”导入。 + +预期: +- 规则列表为单行表格,不再是大块卡片编辑。 +- 规则编辑在弹窗中完成,保存后回填列表。 +- 导入后提示“请点击应用并生效”,应用后 Problems 立即刷新。 +- 导出文件包含:`version`、`expressionRules`、`ruleVariables`。 + +排查点: +- `src/components/Toolbar.vue`(规则管理 UI 与导入导出) +- `src/utils/groupRulesConfigSource.ts`(配置写入与广播) + ## 7. 性能回归(矢量节点快速缩放) 步骤: @@ -173,6 +194,7 @@ - [ ] 缺失资产降级策略通过(不阻断导出/渲染)。 - [x] Dynamic Group 分组基础行为通过(分组信息写入 `meta.groupId`,复制分组会携带组内节点)。 - [ ] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。 +- [ ] 规则管理通过(规则列表表格化、弹窗编辑、导入导出可用)。 - [ ] 矢量节点快速缩放性能回归通过(无明显卡顿/卡死)。 - [ ] 导出到 wiki 数据兼容通过(wiki 侧可 normalize 与预览)。 - [ ] 跨项目素材互通通过(同 origin 可复用素材,跨 origin 不互通)。 @@ -182,7 +204,7 @@ 当前状态(2026-02-27): - 已通过:5 项(基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、导出图片时隐藏 Dynamic Group)。 - 部分通过:1 项(跨项目规则互通方案确认)。 -- 未通过/待验证:6 项(其余项待完整手测或跨仓联调)。 +- 未通过/待验证:7 项(其余项待完整手测或跨仓联调)。 逐项状态: - 基础启动与构建:已通过 @@ -192,6 +214,7 @@ - 缺失资产降级策略:未通过(待手测) - Dynamic Group 分组基础行为:已通过 - 分组规则静态检查:未通过(待手测) +- 规则管理(表格化/导入导出):未通过(待手测) - 矢量节点快速缩放性能回归:未通过(待手测) - 导出到 wiki 数据兼容:未通过(待跨仓联测) - 跨项目素材互通:未通过(待同 origin 联测) diff --git a/src/__tests__/groupRules.expression.test.ts b/src/__tests__/groupRules.expression.test.ts new file mode 100644 index 0000000..8c8cdeb --- /dev/null +++ b/src/__tests__/groupRules.expression.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest' +import { validateGraphGroupRules } from '@/utils/groupRules' +import { DEFAULT_GROUP_RULES_CONFIG } from '@/configs/groupRules' + +const baseGraph = { + nodes: [ + { + id: 'team-1', + type: 'dynamic-group', + children: ['s1', 's2'], + properties: { + children: ['s1', 's2'], + groupMeta: { + groupKind: 'team', + groupName: '一队', + ruleEnabled: true + } + } + }, + { + id: 's1', + type: 'assetSelector', + properties: { + assetLibrary: 'shikigami', + selectedAsset: { assetId: 'a1', name: '辉夜姬', library: 'shikigami' } + } + }, + { + id: 's2', + type: 'assetSelector', + properties: { + assetLibrary: 'shikigami', + selectedAsset: { assetId: 'a2', name: '千姬', library: 'shikigami' } + } + } + ], + edges: [] +} + +const graphWithoutFireShikigami = { + ...baseGraph, + nodes: baseGraph.nodes.map((node) => { + if (node.id === 's1') { + return { + ...node, + properties: { + ...node.properties, + selectedAsset: { assetId: 'a1', name: '阿修罗', library: 'shikigami' } + } + } + } + if (node.id === 's2') { + return { + ...node, + properties: { + ...node.properties, + selectedAsset: { assetId: 'a2', name: '不知火', library: 'shikigami' } + } + } + } + return node + }) +} + +const graphWithKaguyaPoshi = { + ...baseGraph, + nodes: [ + ...baseGraph.nodes, + { + id: 'y1', + type: 'assetSelector', + properties: { + assetLibrary: 'yuhun', + selectedAsset: { assetId: 'y1', name: '破势', library: 'yuhun' } + } + }, + { + id: 'y2', + type: 'assetSelector', + properties: { + assetLibrary: 'yuhun', + selectedAsset: { assetId: 'y2', name: '招财猫', library: 'yuhun' } + } + } + ].map((node) => { + if (node.id !== 'team-1') return node + return { + ...node, + children: ['s1', 's2', 'y1', 'y2'], + properties: { + ...node.properties, + children: ['s1', 's2', 'y1', 'y2'] + } + } + }) +} + +describe('groupRules expression integration', () => { + it('支持自定义 expressionRules 产出告警', () => { + const warnings = validateGraphGroupRules(baseGraph, { + ...DEFAULT_GROUP_RULES_CONFIG, + shikigamiYuhunBlacklist: [], + shikigamiConflictPairs: [], + fireShikigamiWhitelist: ['座敷童子'], + ruleVariables: [ + { + key: '供火式神', + value: '辉夜姬,座敷童子' + } + ], + expressionRules: [ + { + id: 'team-has-kaguya', + condition: 'count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) > 0', + message: '命中:包含辉夜姬', + severity: 'info', + code: 'CUSTOM_HAS_KAGUYA' + } + ] + }) + + const custom = warnings.find((item) => item.ruleId === 'team-has-kaguya') + expect(custom).toBeTruthy() + expect(custom?.severity).toBe('info') + expect(custom?.code).toBe('CUSTOM_HAS_KAGUYA') + expect(custom?.nodeIds).toEqual(['s1', 's2']) + }) + + it('当 expressionRules 为空时不再产出 legacy 告警', () => { + const warnings = validateGraphGroupRules(baseGraph, { + ...DEFAULT_GROUP_RULES_CONFIG, + shikigamiYuhunBlacklist: [], + shikigamiConflictPairs: [], + fireShikigamiWhitelist: ['座敷童子'], + ruleVariables: [], + expressionRules: [] + }) + + expect(warnings).toHaveLength(0) + }) + + it('默认预制规则可命中“辉夜姬不能带破势”', () => { + const warnings = validateGraphGroupRules(graphWithKaguyaPoshi, DEFAULT_GROUP_RULES_CONFIG) + expect(warnings.some((item) => item.code === 'TEAM_KAGUYA_POSHI_CONFLICT')).toBe(true) + }) + + it('默认预制规则可命中“队伍需要供火式神”', () => { + const warnings = validateGraphGroupRules(graphWithoutFireShikigami, DEFAULT_GROUP_RULES_CONFIG) + expect(warnings.some((item) => item.code === 'TEAM_MISSING_FIRE_SHIKIGAMI')).toBe(true) + }) +}) diff --git a/src/__tests__/problemTarget.test.ts b/src/__tests__/problemTarget.test.ts new file mode 100644 index 0000000..0548a44 --- /dev/null +++ b/src/__tests__/problemTarget.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import type { GroupRuleWarning } from '@/utils/groupRules' +import { getProblemTargetCandidateIds } from '@/utils/problemTarget' + +const createWarning = (overrides: Partial): GroupRuleWarning => ({ + id: 'w1', + ruleId: 'rule-1', + code: 'SHIKIGAMI_CONFLICT', + severity: 'warning', + groupId: 'team-1', + message: 'test', + nodeIds: ['node-a', 'node-b'], + ...overrides +}) + +describe('getProblemTargetCandidateIds', () => { + it('所有告警优先跳转到触发规则的 dynamic group', () => { + const warning = createWarning({ + code: 'SHIKIGAMI_YUHUN_BLACKLIST', + nodeIds: ['shiki-1', 'yuhun-1'], + groupId: 'team-1' + }) + + expect(getProblemTargetCandidateIds(warning)).toEqual(['team-1', 'shiki-1', 'yuhun-1']) + }) + + it('队伍类告警优先跳转到队伍节点', () => { + const warning = createWarning({ + code: 'MISSING_FIRE_SHIKIGAMI', + nodeIds: ['shiki-1', 'shiki-2'], + groupId: 'team-1' + }) + + expect(getProblemTargetCandidateIds(warning)).toEqual(['team-1', 'shiki-1', 'shiki-2']) + }) +}) diff --git a/src/__tests__/ruleExpression.test.ts b/src/__tests__/ruleExpression.test.ts new file mode 100644 index 0000000..b57645b --- /dev/null +++ b/src/__tests__/ruleExpression.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { evaluateRuleExpressionAsBoolean } from '@/utils/ruleExpression' + +describe('ruleExpression', () => { + it('支持集合交集计数表达式', () => { + const scope = { + ctx: { + members: { + shikigamiNames: ['辉夜姬', '千姬'] + } + }, + shared: { + fireShikigamiWhitelist: ['辉夜姬', '座敷童子'] + } + } + + const result = evaluateRuleExpressionAsBoolean( + 'count(intersect(ctx.members.shikigamiNames, shared.fireShikigamiWhitelist)) > 0', + scope + ) + expect(result).toBe(true) + }) + + it('支持 map + contains 表达式', () => { + const scope = { + ctx: { + members: { + shikigami: [ + { name: '辉夜姬', role: 'fire' }, + { name: '千姬', role: 'support' } + ] + } + } + } + + const result = evaluateRuleExpressionAsBoolean( + 'contains(map(ctx.members.shikigami, "name"), "辉夜姬")', + scope + ) + expect(result).toBe(true) + }) + + it('遇到非法函数名时抛错', () => { + expect(() => evaluateRuleExpressionAsBoolean('unknownFn(1, 2)', {})).toThrowError('不支持的函数调用') + }) + + it('支持 getVar 从共享变量映射读取集合', () => { + const scope = { + shared: { + vars: { + 火系式神: ['辉夜姬', '座敷童子'] + } + } + } + const result = evaluateRuleExpressionAsBoolean( + 'contains(getVar("火系式神"), "辉夜姬")', + scope + ) + expect(result).toBe(true) + }) + + it('getVar 仅允许传入变量 key', () => { + const scope = { + shared: { + vars: { + 火系式神: ['辉夜姬'] + } + } + } + expect(() => evaluateRuleExpressionAsBoolean('contains(getVar(shared.vars, "火系式神"), "辉夜姬")', scope)) + .toThrowError('getVar 仅支持一个参数') + }) +}) diff --git a/src/components/Toolbar.vue b/src/components/Toolbar.vue index 4e707bd..244c727 100644 --- a/src/components/Toolbar.vue +++ b/src/components/Toolbar.vue @@ -7,6 +7,7 @@ {{ t('prepareCapture') }} {{ t('setWatermark') }} 素材管理 + 规则管理 {{ t('loadExample') }} {{ t('updateLog') }} {{ t('feedback') }} @@ -163,6 +164,158 @@ + + +
+ 新增规则 + 新增变量 + 导出规则变量 + 导入规则变量 + 重载当前配置 + 应用并生效 + 恢复默认 + +
+ + + +
+ + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + + + + + 删除 +
+ +
+
+ + +
+

作用域约定

+
{{ ruleScopeDoc }}
+

可用上下文

+
{{ ruleContextDoc }}
+

支持语法

+
{{ ruleSyntaxDoc }}
+

支持函数

+
{{ ruleFunctionDoc }}
+

表达式示例

+
{{ ruleExamplesDoc }}
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -185,6 +338,16 @@ import { subscribeCustomAssetStore, type CustomAssetItem } from '@/utils/customAssets'; +import { + readSharedGroupRulesConfig, + writeSharedGroupRulesConfig +} from '@/utils/groupRulesConfigSource'; +import { + DEFAULT_GROUP_RULES_CONFIG, + type GroupRulesConfig, + type ExpressionRuleDefinition, + type RuleVariableDefinition +} from '@/configs/groupRules'; const props = withDefaults(defineProps<{ isEmbed?: boolean; @@ -217,6 +380,7 @@ const state = reactive({ showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态 showDataPreviewDialog: false, // 控制数据预览对话框的显示状态 showAssetManagerDialog: false, // 控制素材管理对话框的显示状态 + showRuleManagerDialog: false, // 控制规则管理对话框的显示状态 previewDataContent: '', // 存储预览的数据内容 }); const assetLibraries = ASSET_LIBRARIES.map((item) => ({ @@ -225,12 +389,186 @@ const assetLibraries = ASSET_LIBRARIES.map((item) => ({ })); const assetManagerLibrary = ref(assetLibraries[0]?.id || 'shikigami'); const assetUploadInputRef = ref(null); +const ruleBundleImportInputRef = ref(null); const managedAssets = reactive>({}); assetLibraries.forEach((item) => { managedAssets[item.id] = []; }); let unsubscribeAssetStore: (() => void) | null = null; +const ruleManagerTab = ref<'rules' | 'variables' | 'docs'>('rules'); + +const cloneRuleConfig = (config: GroupRulesConfig): GroupRulesConfig => ({ + version: config.version, + fireShikigamiWhitelist: [...config.fireShikigamiWhitelist], + shikigamiYuhunBlacklist: config.shikigamiYuhunBlacklist.map((rule) => ({ ...rule })), + shikigamiConflictPairs: config.shikigamiConflictPairs.map((rule) => ({ ...rule })), + expressionRules: config.expressionRules.map((rule) => ({ ...rule })), + ruleVariables: config.ruleVariables.map((item) => ({ ...item })) +}); + +const createExpressionRule = (): ExpressionRuleDefinition => ({ + id: `rule_${Date.now()}`, + enabled: true, + severity: 'warning', + code: 'CUSTOM_EXPRESSION', + condition: 'false', + message: '请补充规则提示文案' +}); + +const createRuleVariable = (): RuleVariableDefinition => ({ + key: `var_${Date.now()}`, + value: '' +}); + +const ruleConfigDraft = ref(cloneRuleConfig(readSharedGroupRulesConfig())); +const ruleEditorVisible = ref(false); +const editingRuleIndex = ref(null); +const ruleEditorDraft = ref(null); + +const cloneExpressionRule = (rule: ExpressionRuleDefinition): ExpressionRuleDefinition => ({ + id: rule.id || `rule_${Date.now()}`, + enabled: rule.enabled !== false, + severity: rule.severity || 'warning', + code: rule.code || 'CUSTOM_EXPRESSION', + condition: rule.condition || 'false', + message: rule.message || '请补充规则提示文案' +}); + +const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); +const normalizeSeverity = (value: unknown): 'warning' | 'error' | 'info' => { + return value === 'error' || value === 'info' ? value : 'warning'; +}; +const normalizeImportedExpressionRules = (value: unknown): ExpressionRuleDefinition[] => { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (!item || typeof item !== 'object') return null; + const raw = item as Record; + const id = normalizeText(raw.id); + const condition = normalizeText(raw.condition); + const message = normalizeText(raw.message); + if (!id || !condition || !message) return null; + const code = normalizeText(raw.code); + return { + id, + condition, + message, + enabled: raw.enabled !== false, + severity: normalizeSeverity(raw.severity), + ...(code ? { code } : {}) + }; + }) + .filter((item): item is ExpressionRuleDefinition => !!item); +}; +const normalizeImportedRuleVariables = (value: unknown): RuleVariableDefinition[] => { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (!item || typeof item !== 'object') return null; + const raw = item as Record; + const key = normalizeText(raw.key); + if (!key) return null; + const variableValue = typeof raw.value === 'string' ? raw.value : String(raw.value ?? ''); + return { key, value: variableValue }; + }) + .filter((item): item is RuleVariableDefinition => !!item); +}; + +const ruleScopeDoc = `规则新增字段(建议): scopeKind +- team: 在队伍组(dynamic-group: team)上执行(当前已生效) +- shikigami: 在式神组(dynamic-group: shikigami)上执行(规划中,当前未生效) + +注意 +- scopeKind 决定“规则运行上下文” +- 告警点击定位固定为:触发该规则的 dynamic-group`; + +const ruleContextDoc = `当 scopeKind = "team"(队伍规则) +ctx.team.shikigamis: 式神数组 +- 单项示例: { nodeId: "n1", assetId: "sp_kaguya", name: "辉夜姬", library: "shikigami" } + +ctx.team.yuhuns: 御魂数组 +- 单项示例: { nodeId: "n2", assetId: "p4_poshi", name: "破势", library: "yuhun" } + +ctx.group.id / ctx.group.name +- 示例: "team-1" / "冲榜队A" + +当 scopeKind = "shikigami"(式神规则,规划中,当前未生效) +ctx.unit.shikigami: 当前式神对象(单个) +- 示例: { nodeId: "n1", assetId: "sp_kaguya", name: "辉夜姬", library: "shikigami" } + +ctx.unit.yuhuns: 当前式神关联御魂数组 +- 单项示例: { nodeId: "n2", assetId: "p4_poshi", name: "破势", library: "yuhun" } + +通用共享变量 +shared.vars(变量 tab 配置后的 key/value 映射) +- 示例: + shared.vars["供火式神"] = ["辉夜姬", "座敷童子"] + shared.vars["输出式神"] = ["阿修罗", "茨木童子"]`; + +const ruleFunctionDoc = `count(value) +- 用途: 计算数量(数组长度 / 字符串长度) +- team 示例: count(ctx.team.shikigamis) >= 5 +- shikigami 示例: count(ctx.unit.yuhuns) >= 1 + +contains(collection, target) +- 用途: 判断集合或字符串是否包含目标值 +- team 示例: contains(map(ctx.team.shikigamis, "name"), "辉夜姬") + +intersect(leftArray, rightArray) +- 用途: 取数组交集(去重) +- team 示例: count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) > 0 + +map(collection, "fieldName") +- 用途: 提取对象数组字段 +- team 示例: map(ctx.team.shikigamis, "name") +- shikigami 示例: map(ctx.unit.yuhuns, "name") + +unique(collection) +- 用途: 数组去重 +- team 示例: count(unique(map(ctx.team.yuhuns, "name"))) >= 2 + +exists(value) +- 用途: 判断值是否存在(非 null/空串;数组需长度>0) +- 示例: exists(getVar("核心式神")) + +lower(value) / upper(value) +- 用途: 字符串大小写转换 +- 示例: contains(lower("PoShi"), "poshi") + +getVar("变量Key") +- 用途: 获取变量 tab 里配置的值(通常返回字符串数组) +- 示例: getVar("供火式神")`; + +const ruleSyntaxDoc = `支持 +- 字面量: "文本" / 数字 / true / false / null +- 数组: ["辉夜姬", "座敷童子"] +- 路径: ctx.team.shikigamis / ctx.unit.shikigami / shared.vars +- 函数调用: count(...), contains(...), intersect(...), map(...) +- 逻辑运算: && || ! +- 比较运算: == != > >= < <= +- 算术运算: + - * / +- 括号: ( ... ) + +不支持 +- index 语法(如 getIndexOf / arr[0]) +- 自定义遍历语法(for/while/foreach) +- 自定义函数定义 +- 赋值语句 +- eval/new Function`; + +const ruleExamplesDoc = `1) [team] 队伍至少有一个供火式神 +count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) > 0 + +2) [team] 队伍里不能同时出现千姬和腹肌清姬 +contains(map(ctx.team.shikigamis, "name"), "千姬") && contains(map(ctx.team.shikigamis, "name"), "腹肌清姬") + +3) [team] 队伍御魂至少 2 种(避免全员同御魂) +count(unique(map(ctx.team.yuhuns, "name"))) >= 2 + +4) [规划中的 shikigami scope] 当前式神是辉夜姬且其关联御魂包含破势 +ctx.unit.shikigami.name == "辉夜姬" && contains(map(ctx.unit.yuhuns, "name"), "破势")`; + const refreshManagedAssets = (library?: string) => { if (library) { managedAssets[library] = listCustomAssets(library); @@ -246,6 +584,166 @@ const openAssetManager = () => { state.showAssetManagerDialog = true; }; +const reloadRuleManagerDraft = () => { + ruleConfigDraft.value = cloneRuleConfig(readSharedGroupRulesConfig()); + cancelRuleEditor(); +}; + +const openRuleManager = () => { + reloadRuleManagerDraft(); + ruleManagerTab.value = 'rules'; + state.showRuleManagerDialog = true; + ruleEditorVisible.value = false; + editingRuleIndex.value = null; + ruleEditorDraft.value = null; +}; + +const exportRuleBundle = () => { + try { + const payload = { + version: ruleConfigDraft.value.version, + expressionRules: ruleConfigDraft.value.expressionRules, + ruleVariables: ruleConfigDraft.value.ruleVariables + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `rule-bundle-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + URL.revokeObjectURL(url); + showMessage('success', '规则变量已导出'); + } catch (error) { + console.error('导出规则变量失败:', error); + showMessage('error', '导出失败'); + } +}; + +const triggerRuleBundleImport = () => { + ruleBundleImportInputRef.value?.click(); +}; + +const handleRuleBundleImport = async (event: Event) => { + const target = event.target as HTMLInputElement | null; + const file = target?.files?.[0]; + if (!file) { + if (target) target.value = ''; + return; + } + + try { + const rawText = await file.text(); + const parsed = JSON.parse(rawText) as Record; + const importedRules = normalizeImportedExpressionRules(parsed.expressionRules); + const importedVariables = normalizeImportedRuleVariables(parsed.ruleVariables); + if (!importedRules.length && !importedVariables.length) { + showMessage('warning', '导入文件中没有可用的规则或变量'); + return; + } + ruleConfigDraft.value = { + ...ruleConfigDraft.value, + expressionRules: importedRules, + ruleVariables: importedVariables + }; + cancelRuleEditor(); + showMessage('success', '规则变量已导入,请点击“应用并生效”'); + } catch (error) { + console.error('导入规则变量失败:', error); + showMessage('error', '导入失败,文件格式错误'); + } finally { + if (target) target.value = ''; + } +}; + +const addExpressionRule = () => { + const newRule = createExpressionRule(); + ruleConfigDraft.value.expressionRules.push(newRule); + openExpressionRuleEditor(ruleConfigDraft.value.expressionRules.length - 1); + ruleManagerTab.value = 'rules'; +}; + +const removeExpressionRule = (index: number) => { + if (editingRuleIndex.value === index) { + cancelRuleEditor(); + } + ruleConfigDraft.value.expressionRules.splice(index, 1); + if (editingRuleIndex.value != null && editingRuleIndex.value > index) { + editingRuleIndex.value -= 1; + } +}; + +const openExpressionRuleEditor = (index: number) => { + const target = ruleConfigDraft.value.expressionRules[index]; + if (!target) return; + editingRuleIndex.value = index; + ruleEditorDraft.value = cloneExpressionRule(target); + ruleEditorVisible.value = true; +}; + +const cancelRuleEditor = () => { + ruleEditorVisible.value = false; + editingRuleIndex.value = null; + ruleEditorDraft.value = null; +}; + +const saveRuleEditor = () => { + const index = editingRuleIndex.value; + const draft = ruleEditorDraft.value; + if (index == null || !draft) { + return; + } + const ruleId = draft.id?.trim(); + const condition = draft.condition?.trim(); + const message = draft.message?.trim(); + if (!ruleId || !condition || !message) { + showMessage('warning', '规则 ID、条件表达式、提示文案不能为空'); + return; + } + const normalized = cloneExpressionRule({ + ...draft, + id: ruleId, + condition, + message + }); + ruleConfigDraft.value.expressionRules[index] = normalized; + cancelRuleEditor(); +}; + +const addRuleVariable = () => { + ruleConfigDraft.value.ruleVariables.push(createRuleVariable()); + ruleManagerTab.value = 'variables'; +}; + +const removeRuleVariable = (index: number) => { + ruleConfigDraft.value.ruleVariables.splice(index, 1); +}; + +const applyRuleManagerConfig = () => { + try { + const normalized = writeSharedGroupRulesConfig(ruleConfigDraft.value); + ruleConfigDraft.value = cloneRuleConfig(normalized); + showMessage('success', '规则配置已生效'); + } catch (error) { + console.error('应用规则配置失败:', error); + showMessage('error', '规则配置应用失败'); + } +}; + +const restoreDefaultRuleConfig = () => { + ElMessageBox.confirm('恢复默认会覆盖当前规则和变量,是否继续?', '提示', { + confirmButtonText: '恢复默认', + cancelButtonText: '取消', + type: 'warning', + }).then(() => { + const normalized = writeSharedGroupRulesConfig(DEFAULT_GROUP_RULES_CONFIG); + ruleConfigDraft.value = cloneRuleConfig(normalized); + cancelRuleEditor(); + showMessage('success', '已恢复默认规则配置'); + }).catch(() => { + // 用户取消 + }); +}; + const getManagedAssets = (libraryId: string) => { return managedAssets[libraryId] || []; }; @@ -763,4 +1261,118 @@ const handleClose = (done) => { color: #303133; word-break: break-all; } + +.rule-manager-actions { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.rule-manager-tabs { + min-height: 420px; +} + +.rule-table-wrap, +.variable-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.variable-item { + border: 1px solid #e4e7ed; + border-radius: 6px; + padding: 12px; + background: #fafafa; +} + +.rule-table :deep(.el-table__cell) { + padding-top: 6px; + padding-bottom: 6px; +} + +.rule-inline-select { + width: 100%; +} + +.severity-select--warning :deep(.el-input__wrapper) { + background: #fff7ed; + box-shadow: inset 0 0 0 1px #fed7aa; +} + +.severity-select--warning :deep(.el-input__inner) { + color: #9a3412; +} + +.severity-select--error :deep(.el-input__wrapper) { + background: #fef2f2; + box-shadow: inset 0 0 0 1px #fecaca; +} + +.severity-select--error :deep(.el-input__inner) { + color: #b91c1c; +} + +.severity-select--info :deep(.el-input__wrapper) { + background: #eff6ff; + box-shadow: inset 0 0 0 1px #bfdbfe; +} + +.severity-select--info :deep(.el-input__inner) { + color: #1d4ed8; +} + +.rule-cell-ellipsis { + display: inline-block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rule-editor-form :deep(.el-form-item) { + margin-bottom: 14px; +} + +.variable-item { + display: grid; + grid-template-columns: 220px 1fr auto; + gap: 12px; + align-items: start; +} + +.variable-key, +.variable-value { + margin-bottom: 0; +} + +.rule-docs { + max-height: 460px; + overflow-y: auto; + padding-right: 4px; +} + +.rule-docs h4 { + margin: 6px 0; + color: #303133; +} + +.rule-docs pre { + margin: 0 0 12px; + padding: 10px; + border-radius: 6px; + background: #f5f7fa; + color: #606266; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 900px) { + .variable-item { + grid-template-columns: 1fr; + } +} diff --git a/src/components/flow/ComponentsPanel.vue b/src/components/flow/ComponentsPanel.vue index 2b6abfc..098567c 100644 --- a/src/components/flow/ComponentsPanel.vue +++ b/src/components/flow/ComponentsPanel.vue @@ -39,9 +39,7 @@ const componentGroups = [ groupMeta: { version: 1, groupKind: 'team', - groupName: '', - ruleEnabled: true, - ruleScope: ['shikigami-yuhun', 'shikigami-shikigami'] + ruleEnabled: true }, collapsible: true, isCollapsed: false, diff --git a/src/components/flow/FlowEditor.vue b/src/components/flow/FlowEditor.vue index 2a801a7..0319b25 100644 --- a/src/components/flow/FlowEditor.vue +++ b/src/components/flow/FlowEditor.vue @@ -123,6 +123,7 @@ import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@ import { useCanvasSettings } from '@/ts/useCanvasSettings'; import { validateGraphGroupRules, type GroupRuleWarning } from '@/utils/groupRules'; import { subscribeSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource'; +import { getProblemTargetCandidateIds } from '@/utils/problemTarget'; type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter'; type DistributeType = 'horizontal' | 'vertical'; @@ -732,7 +733,7 @@ function locateProblemNode(warning: GroupRuleWarning) { const lfInstance = lf.value as any; if (!lfInstance) return; - const candidateIds = [...(warning.nodeIds || []), warning.groupId].filter((id) => !!id); + const candidateIds = getProblemTargetCandidateIds(warning); const targetId = candidateIds.find((id) => !!lfInstance.getNodeModelById(id)); if (!targetId) { showMessage('warning', '未找到告警对应节点,可能已被删除'); diff --git a/src/components/flow/panels/DynamicGroupPanel.vue b/src/components/flow/panels/DynamicGroupPanel.vue index 9bd842e..f12684e 100644 --- a/src/components/flow/panels/DynamicGroupPanel.vue +++ b/src/components/flow/panels/DynamicGroupPanel.vue @@ -2,7 +2,6 @@ import { reactive, watch } from 'vue'; import { getLogicFlowInstance } from '@/ts/useLogicFlow'; import { - DEFAULT_GROUP_RULE_SCOPE, GROUP_META_VERSION, normalizeDynamicGroupMeta } from '@/utils/graphSchema'; @@ -13,40 +12,19 @@ const props = defineProps<{ type DynamicGroupMeta = { groupKind: 'team' | 'shikigami'; - groupName: string; ruleEnabled: boolean; - ruleScope: string[]; }; -const DEFAULT_SCOPE_OPTIONS = [ - { value: 'shikigami-yuhun', label: '式神-御魂关系' }, - { value: 'shikigami-shikigami', label: '式神-式神关系' } -]; - const form = reactive({ groupKind: 'team', - groupName: '', - ruleEnabled: true, - ruleScope: [...DEFAULT_GROUP_RULE_SCOPE] + ruleEnabled: true }); -const normalizeRuleScope = (value: unknown): string[] => { - if (!Array.isArray(value)) { - return [...DEFAULT_GROUP_RULE_SCOPE]; - } - const normalized = value - .map((item) => (typeof item === 'string' ? item.trim() : '')) - .filter((item) => !!item); - return normalized.length ? normalized : [...DEFAULT_GROUP_RULE_SCOPE]; -}; - const syncFromNode = (node?: any) => { if (!node) return; const groupMeta = normalizeDynamicGroupMeta(node.properties?.groupMeta); form.groupKind = groupMeta.groupKind; - form.groupName = groupMeta.groupName; form.ruleEnabled = groupMeta.ruleEnabled; - form.ruleScope = normalizeRuleScope(groupMeta.ruleScope); }; watch( @@ -69,9 +47,7 @@ const applyGroupMeta = () => { groupMeta: { version: GROUP_META_VERSION, groupKind: form.groupKind, - groupName: form.groupName.trim(), - ruleEnabled: form.ruleEnabled, - ruleScope: normalizeRuleScope(form.ruleScope) + ruleEnabled: form.ruleEnabled } }); }; @@ -89,50 +65,12 @@ const applyGroupMeta = () => { -
-
分组名称
- -
-
启用规则检查
- -
-
规则范围
- - - -
可扩展:后续新增规则域时可直接添加 scope。
-
diff --git a/src/configs/groupRules.ts b/src/configs/groupRules.ts index 625d4f7..01715e5 100644 --- a/src/configs/groupRules.ts +++ b/src/configs/groupRules.ts @@ -15,37 +15,61 @@ export type GroupRulesConfig = { fireShikigamiWhitelist: string[] shikigamiYuhunBlacklist: ShikigamiYuhunBlacklistRule[] shikigamiConflictPairs: ShikigamiConflictRule[] + expressionRules: ExpressionRuleDefinition[] + ruleVariables: RuleVariableDefinition[] +} + +export type ExpressionRuleDefinition = { + id: string + condition: string + message: string + enabled?: boolean + severity?: 'warning' | 'error' | 'info' + code?: string +} + +export type RuleVariableDefinition = { + key: string + value: string } export const DEFAULT_GROUP_RULES_CONFIG: GroupRulesConfig = { - version: 1, + version: 3, fireShikigamiWhitelist: [ '辉夜姬', '因幡辉夜姬', '追月神', '座敷童子', - '千姬', - '帝释天', - '不见岳', - '食灵' + '千姬' ], - shikigamiYuhunBlacklist: [ + shikigamiYuhunBlacklist: [], + shikigamiConflictPairs: [], + expressionRules: [ { - shikigami: '辉夜姬', - yuhun: '破势', - message: '规则冲突:辉夜姬通常不建议携带破势。' - } - ], - shikigamiConflictPairs: [ - { - left: '千姬', - right: '腹肌清姬', - message: '规则冲突:千姬与腹肌清姬不建议同队。' + id: 'team-require-fire-shikigami', + condition: 'count(intersect(ctx.team.shikigamiNames, getVar("供火式神"))) == 0', + message: '规则提示:当前队伍缺少供火式神。', + severity: 'warning', + code: 'TEAM_MISSING_FIRE_SHIKIGAMI', + enabled: true }, { - left: '千姬', - right: '蝮骨清姬', - message: '规则冲突:千姬与蝮骨清姬不建议同队。' + id: 'team-kaguya-no-poshi', + condition: 'contains(ctx.team.shikigamiNames, "辉夜姬") && contains(ctx.team.yuhunNames, "破势")', + message: '规则冲突:辉夜姬不建议携带破势。', + severity: 'warning', + code: 'TEAM_KAGUYA_POSHI_CONFLICT', + enabled: true + } + ], + ruleVariables: [ + { + key: '供火式神', + value: '辉夜姬,因幡辉夜姬,追月神,座敷童子,千姬' + }, + { + key: '输出御魂', + value: '破势,狂骨,针女,海月火玉' } ] } diff --git a/src/utils/groupRules.ts b/src/utils/groupRules.ts index 719ff97..5c9e3a8 100644 --- a/src/utils/groupRules.ts +++ b/src/utils/groupRules.ts @@ -1,6 +1,7 @@ import type { GroupRulesConfig } from '@/configs/groupRules' import { readSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource' import { getDynamicGroupChildIds, normalizeGraphRawDataSchema } from '@/utils/graphSchema' +import { evaluateRuleExpressionAsBoolean } from '@/utils/ruleExpression' type GraphData = { nodes: any[] @@ -25,7 +26,7 @@ type TeamAssetSnapshot = { export type GroupRuleWarning = { id: string ruleId: string - code: 'SHIKIGAMI_YUHUN_BLACKLIST' | 'SHIKIGAMI_CONFLICT' | 'MISSING_FIRE_SHIKIGAMI' + code: string severity: 'warning' | 'error' | 'info' groupId: string groupName?: string @@ -33,6 +34,32 @@ export type GroupRuleWarning = { nodeIds: string[] } +type TeamExpressionScope = { + ctx: { + group: { + id: string + name: string + } + team: { + shikigamis: TeamAsset[] + yuhuns: TeamAsset[] + shikigamiNames: string[] + yuhunNames: string[] + } + members: { + shikigami: TeamAsset[] + yuhun: TeamAsset[] + shikigamiNames: string[] + yuhunNames: string[] + } + nodeIds: string[] + } + shared: { + fireShikigamiWhitelist: string[] + vars: Record + } +} + const normalizeText = (value: unknown): string => { if (typeof value !== 'string') { return '' @@ -69,11 +96,25 @@ const inferLibrary = (node: any): string => { return '' } -const includesName = (list: string[], target: string): boolean => { - return list.some((item) => item === target) +const dedupeNodeIds = (ids: string[]): string[] => Array.from(new Set(ids)) + +const parseVariableValue = (value: string): string[] => { + return value + .split(/[\n,,]/g) + .map((item) => item.trim()) + .filter((item) => !!item) } -const dedupeNodeIds = (ids: string[]): string[] => Array.from(new Set(ids)) +const createSharedVariableMap = (config: GroupRulesConfig): Record => { + const map: Record = {} + config.ruleVariables.forEach((item) => { + const key = normalizeText(item.key) + if (!key) return + map[key] = dedupeNodeIds(parseVariableValue(item.value)) + }) + map.fireShikigamiWhitelist = [...config.fireShikigamiWhitelist] + return map +} const collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] => { const nodes = Array.isArray(graphData?.nodes) ? graphData.nodes : [] @@ -113,7 +154,6 @@ const collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] => if (isDynamicGroupNode(node)) { const childKind = normalizeText(node?.properties?.groupMeta?.groupKind) - // 嵌套 team 视为独立边界,不纳入当前队伍聚合 if (childKind === 'team') { continue } @@ -161,6 +201,45 @@ const collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] => const createWarningId = (groupId: string, ruleId: string): string => `${groupId}::${ruleId}` +const createTeamScope = (team: TeamAssetSnapshot, config: GroupRulesConfig): TeamExpressionScope => { + const shikigamiNames = team.shikigamiAssets.map((item) => item.name) + const yuhunNames = team.yuhunAssets.map((item) => item.name) + return { + ctx: { + group: { + id: team.groupId, + name: team.groupName + }, + team: { + shikigamis: team.shikigamiAssets.map((item) => ({ ...item })), + yuhuns: team.yuhunAssets.map((item) => ({ ...item })), + shikigamiNames: [...shikigamiNames], + yuhunNames: [...yuhunNames] + }, + members: { + shikigami: team.shikigamiAssets.map((item) => ({ ...item })), + yuhun: team.yuhunAssets.map((item) => ({ ...item })), + shikigamiNames, + yuhunNames + }, + nodeIds: [...team.nodeIds] + }, + shared: { + fireShikigamiWhitelist: [...config.fireShikigamiWhitelist], + vars: createSharedVariableMap(config) + } + } +} + +const evaluateCondition = (expression: string, scope: TeamExpressionScope): boolean => { + try { + return evaluateRuleExpressionAsBoolean(expression, scope) + } catch (error) { + console.warn('[groupRules] 表达式执行失败:', expression, error) + return false + } +} + export const validateGraphGroupRules = ( graphData: GraphData, config?: GroupRulesConfig @@ -171,67 +250,26 @@ export const validateGraphGroupRules = ( const warnings: GroupRuleWarning[] = [] teams.forEach((team) => { - const shikigamiNames = team.shikigamiAssets.map((item) => item.name) - const yuhunNames = team.yuhunAssets.map((item) => item.name) + const scope = createTeamScope(team, effectiveConfig) - effectiveConfig.shikigamiYuhunBlacklist.forEach((rule) => { - if (includesName(shikigamiNames, rule.shikigami) && includesName(yuhunNames, rule.yuhun)) { - const ruleId = `blacklist:${rule.shikigami}:${rule.yuhun}` - const nodeIds = dedupeNodeIds([ - ...team.shikigamiAssets.filter((item) => item.name === rule.shikigami).map((item) => item.nodeId), - ...team.yuhunAssets.filter((item) => item.name === rule.yuhun).map((item) => item.nodeId) - ]) - - warnings.push({ - id: createWarningId(team.groupId, ruleId), - ruleId, - code: 'SHIKIGAMI_YUHUN_BLACKLIST', - severity: 'warning', - groupId: team.groupId, - groupName: team.groupName || undefined, - nodeIds, - message: rule.message || `规则冲突:${rule.shikigami} 不建议携带 ${rule.yuhun}。` - }) + effectiveConfig.expressionRules.forEach((rule) => { + if (rule.enabled === false) { + return } + if (!evaluateCondition(rule.condition, scope)) { + return + } + warnings.push({ + id: createWarningId(team.groupId, `expr:${rule.id}`), + ruleId: rule.id, + code: rule.code || 'CUSTOM_EXPRESSION', + severity: rule.severity || 'warning', + groupId: team.groupId, + groupName: team.groupName || undefined, + nodeIds: [...team.nodeIds], + message: rule.message + }) }) - - effectiveConfig.shikigamiConflictPairs.forEach((rule) => { - if (includesName(shikigamiNames, rule.left) && includesName(shikigamiNames, rule.right)) { - const ruleId = `conflict:${rule.left}:${rule.right}` - const nodeIds = dedupeNodeIds([ - ...team.shikigamiAssets.filter((item) => item.name === rule.left).map((item) => item.nodeId), - ...team.shikigamiAssets.filter((item) => item.name === rule.right).map((item) => item.nodeId) - ]) - warnings.push({ - id: createWarningId(team.groupId, ruleId), - ruleId, - code: 'SHIKIGAMI_CONFLICT', - severity: 'warning', - groupId: team.groupId, - groupName: team.groupName || undefined, - nodeIds, - message: rule.message || `规则冲突:${rule.left} 与 ${rule.right} 不建议同队。` - }) - } - }) - - const hasShikigami = shikigamiNames.length > 0 - if (hasShikigami) { - const hasFireShikigami = shikigamiNames.some((name) => effectiveConfig.fireShikigamiWhitelist.includes(name)) - if (!hasFireShikigami) { - const ruleId = 'missing-fire-shikigami' - warnings.push({ - id: createWarningId(team.groupId, ruleId), - ruleId, - code: 'MISSING_FIRE_SHIKIGAMI', - severity: 'warning', - groupId: team.groupId, - groupName: team.groupName || undefined, - nodeIds: dedupeNodeIds(team.shikigamiAssets.map((item) => item.nodeId)), - message: '规则提示:当前队伍未检测到鬼火式神,建议补充供火位。' - }) - } - } }) return warnings diff --git a/src/utils/groupRulesConfigSource.ts b/src/utils/groupRulesConfigSource.ts index a0d378a..e5158ba 100644 --- a/src/utils/groupRulesConfigSource.ts +++ b/src/utils/groupRulesConfigSource.ts @@ -1,6 +1,8 @@ import { DEFAULT_GROUP_RULES_CONFIG, + type ExpressionRuleDefinition, type GroupRulesConfig, + type RuleVariableDefinition, type ShikigamiConflictRule, type ShikigamiYuhunBlacklistRule } from '@/configs/groupRules' @@ -21,7 +23,9 @@ const cloneDefaultGroupRulesConfig = (): GroupRulesConfig => ({ version: DEFAULT_GROUP_RULES_CONFIG.version, fireShikigamiWhitelist: [...DEFAULT_GROUP_RULES_CONFIG.fireShikigamiWhitelist], shikigamiYuhunBlacklist: DEFAULT_GROUP_RULES_CONFIG.shikigamiYuhunBlacklist.map((rule) => ({ ...rule })), - shikigamiConflictPairs: DEFAULT_GROUP_RULES_CONFIG.shikigamiConflictPairs.map((rule) => ({ ...rule })) + shikigamiConflictPairs: DEFAULT_GROUP_RULES_CONFIG.shikigamiConflictPairs.map((rule) => ({ ...rule })), + expressionRules: DEFAULT_GROUP_RULES_CONFIG.expressionRules.map((rule) => ({ ...rule })), + ruleVariables: DEFAULT_GROUP_RULES_CONFIG.ruleVariables.map((item) => ({ ...item })) }) const normalizeStringList = (value: unknown, fallback: string[]): string[] => { @@ -89,6 +93,64 @@ const normalizeConflictRules = ( .filter((item): item is ShikigamiConflictRule => !!item) } +const normalizeExpressionRules = ( + value: unknown, + fallback: ExpressionRuleDefinition[] +): ExpressionRuleDefinition[] => { + if (!Array.isArray(value)) { + return fallback.map((rule) => ({ ...rule })) + } + return value + .map((item) => { + if (!item || typeof item !== 'object') { + return null + } + const raw = item as Record + const id = normalizeText(raw.id) + const condition = normalizeText(raw.condition) + const message = normalizeText(raw.message) + if (!id || !condition || !message) { + return null + } + const enabled = raw.enabled !== false + const severityRaw = normalizeText(raw.severity) + const severity = severityRaw === 'error' || severityRaw === 'info' ? severityRaw : 'warning' + const code = normalizeText(raw.code) + return { + id, + condition, + message, + enabled, + severity, + ...(code ? { code } : {}) + } + }) + .filter((item): item is ExpressionRuleDefinition => !!item) +} + +const normalizeRuleVariables = ( + value: unknown, + fallback: RuleVariableDefinition[] +): RuleVariableDefinition[] => { + if (!Array.isArray(value)) { + return fallback.map((item) => ({ ...item })) + } + return value + .map((item) => { + if (!item || typeof item !== 'object') { + return null + } + const raw = item as Record + const key = normalizeText(raw.key) + if (!key) { + return null + } + const value = typeof raw.value === 'string' ? raw.value : String(raw.value ?? '') + return { key, value } + }) + .filter((item): item is RuleVariableDefinition => !!item) +} + const normalizeGroupRulesConfig = (input: unknown): GroupRulesConfig | null => { if (!input || typeof input !== 'object') { return null @@ -105,7 +167,9 @@ const normalizeGroupRulesConfig = (input: unknown): GroupRulesConfig | null => { version, fireShikigamiWhitelist: normalizeStringList(raw.fireShikigamiWhitelist, fallback.fireShikigamiWhitelist), shikigamiYuhunBlacklist: normalizeBlacklistRules(raw.shikigamiYuhunBlacklist, fallback.shikigamiYuhunBlacklist), - shikigamiConflictPairs: normalizeConflictRules(raw.shikigamiConflictPairs, fallback.shikigamiConflictPairs) + shikigamiConflictPairs: normalizeConflictRules(raw.shikigamiConflictPairs, fallback.shikigamiConflictPairs), + expressionRules: normalizeExpressionRules(raw.expressionRules, fallback.expressionRules), + ruleVariables: normalizeRuleVariables(raw.ruleVariables, fallback.ruleVariables) } } diff --git a/src/utils/problemTarget.ts b/src/utils/problemTarget.ts new file mode 100644 index 0000000..e743df2 --- /dev/null +++ b/src/utils/problemTarget.ts @@ -0,0 +1,19 @@ +import type { GroupRuleWarning } from '@/utils/groupRules' + +const normalizeIds = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return [] + } + return value + .filter((item): item is string => typeof item === 'string' && !!item.trim()) + .map((item) => item.trim()) +} + +const dedupeIds = (ids: string[]): string[] => Array.from(new Set(ids)) + +export const getProblemTargetCandidateIds = (warning: GroupRuleWarning): string[] => { + const nodeIds = normalizeIds(warning.nodeIds) + const groupId = typeof warning.groupId === 'string' ? warning.groupId.trim() : '' + const groupIds = groupId ? [groupId] : [] + return dedupeIds([...groupIds, ...nodeIds]) +} diff --git a/src/utils/ruleExpression.ts b/src/utils/ruleExpression.ts new file mode 100644 index 0000000..506817b --- /dev/null +++ b/src/utils/ruleExpression.ts @@ -0,0 +1,539 @@ +type TokenType = 'identifier' | 'number' | 'string' | 'boolean' | 'null' | 'operator' | 'punctuation' | 'eof' + +type Token = { + type: TokenType + value: string + position: number +} + +type ExpressionNode = + | { type: 'Literal'; value: unknown } + | { type: 'ArrayExpression'; elements: ExpressionNode[] } + | { type: 'IdentifierPath'; path: string[] } + | { type: 'CallExpression'; callee: string; args: ExpressionNode[] } + | { type: 'UnaryExpression'; operator: '!' | '-'; argument: ExpressionNode } + | { + type: 'BinaryExpression' + operator: '||' | '&&' | '==' | '!=' | '>' | '>=' | '<' | '<=' | '+' | '-' | '*' | '/' + left: ExpressionNode + right: ExpressionNode + } + +export type RuleExpressionScope = Record + +const DEFAULT_MAX_STEPS = 20_000 +const astCache = new Map() + +const isWhitespace = (char: string) => /\s/.test(char) +const isDigit = (char: string) => /[0-9]/.test(char) +const isIdentifierStart = (char: string) => /[A-Za-z_$\u4E00-\u9FFF]/.test(char) +const isIdentifierPart = (char: string) => /[A-Za-z0-9_$\u4E00-\u9FFF]/.test(char) + +const tokenize = (source: string): Token[] => { + const tokens: Token[] = [] + let index = 0 + + const readString = (quote: string): Token => { + const start = index + index += 1 + let value = '' + while (index < source.length) { + const current = source[index] + if (current === '\\') { + const next = source[index + 1] + if (!next) { + throw new Error(`字符串转义不完整(位置 ${index})`) + } + value += next + index += 2 + continue + } + if (current === quote) { + index += 1 + return { type: 'string', value, position: start } + } + value += current + index += 1 + } + throw new Error(`字符串缺少结束引号(位置 ${start})`) + } + + const readNumber = (): Token => { + const start = index + let value = '' + let dotSeen = false + while (index < source.length) { + const current = source[index] + if (current === '.') { + if (dotSeen) break + dotSeen = true + value += current + index += 1 + continue + } + if (!isDigit(current)) { + break + } + value += current + index += 1 + } + return { type: 'number', value, position: start } + } + + const readIdentifier = (): Token => { + const start = index + let value = '' + while (index < source.length && isIdentifierPart(source[index])) { + value += source[index] + index += 1 + } + if (value === 'true' || value === 'false') { + return { type: 'boolean', value, position: start } + } + if (value === 'null') { + return { type: 'null', value, position: start } + } + return { type: 'identifier', value, position: start } + } + + const readOperatorOrPunctuation = (): Token => { + const start = index + const twoChars = source.slice(index, index + 2) + const twoCharOperators = ['&&', '||', '==', '!=', '>=', '<='] + if (twoCharOperators.includes(twoChars)) { + index += 2 + return { type: 'operator', value: twoChars, position: start } + } + + const oneChar = source[index] + const oneCharOperators = ['>', '<', '!', '+', '-', '*', '/'] + if (oneCharOperators.includes(oneChar)) { + index += 1 + return { type: 'operator', value: oneChar, position: start } + } + + const punctuations = ['(', ')', '[', ']', ',', '.'] + if (punctuations.includes(oneChar)) { + index += 1 + return { type: 'punctuation', value: oneChar, position: start } + } + + throw new Error(`无法识别的字符 "${oneChar}"(位置 ${start})`) + } + + while (index < source.length) { + const current = source[index] + if (isWhitespace(current)) { + index += 1 + continue + } + if (current === '"' || current === "'") { + tokens.push(readString(current)) + continue + } + if (isDigit(current)) { + tokens.push(readNumber()) + continue + } + if (isIdentifierStart(current)) { + tokens.push(readIdentifier()) + continue + } + tokens.push(readOperatorOrPunctuation()) + } + + tokens.push({ type: 'eof', value: '', position: source.length }) + return tokens +} + +class Parser { + private readonly tokens: Token[] + private cursor = 0 + + constructor(source: string) { + this.tokens = tokenize(source) + } + + parse(): ExpressionNode { + const expression = this.parseOrExpression() + this.expect('eof') + return expression + } + + private current(): Token { + return this.tokens[this.cursor] + } + + private consume(): Token { + const token = this.tokens[this.cursor] + this.cursor += 1 + return token + } + + private match(type: TokenType, value?: string): boolean { + const token = this.current() + if (token.type !== type) return false + if (value != null && token.value !== value) return false + return true + } + + private expect(type: TokenType, value?: string): Token { + const token = this.current() + if (!this.match(type, value)) { + const expected = value == null ? type : `${type}:${value}` + throw new Error(`表达式解析失败,期望 ${expected},实际 ${token.type}:${token.value}(位置 ${token.position})`) + } + return this.consume() + } + + private parseOrExpression(): ExpressionNode { + let left = this.parseAndExpression() + while (this.match('operator', '||')) { + const operator = this.consume().value as '||' + const right = this.parseAndExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseAndExpression(): ExpressionNode { + let left = this.parseEqualityExpression() + while (this.match('operator', '&&')) { + const operator = this.consume().value as '&&' + const right = this.parseEqualityExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseEqualityExpression(): ExpressionNode { + let left = this.parseComparisonExpression() + while (this.match('operator', '==') || this.match('operator', '!=')) { + const operator = this.consume().value as '==' | '!=' + const right = this.parseComparisonExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseComparisonExpression(): ExpressionNode { + let left = this.parseAdditiveExpression() + while ( + this.match('operator', '>') || + this.match('operator', '>=') || + this.match('operator', '<') || + this.match('operator', '<=') + ) { + const operator = this.consume().value as '>' | '>=' | '<' | '<=' + const right = this.parseAdditiveExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseAdditiveExpression(): ExpressionNode { + let left = this.parseMultiplicativeExpression() + while (this.match('operator', '+') || this.match('operator', '-')) { + const operator = this.consume().value as '+' | '-' + const right = this.parseMultiplicativeExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseMultiplicativeExpression(): ExpressionNode { + let left = this.parseUnaryExpression() + while (this.match('operator', '*') || this.match('operator', '/')) { + const operator = this.consume().value as '*' | '/' + const right = this.parseUnaryExpression() + left = { type: 'BinaryExpression', operator, left, right } + } + return left + } + + private parseUnaryExpression(): ExpressionNode { + if (this.match('operator', '!') || this.match('operator', '-')) { + const operator = this.consume().value as '!' | '-' + const argument = this.parseUnaryExpression() + return { type: 'UnaryExpression', operator, argument } + } + return this.parsePrimaryExpression() + } + + private parsePrimaryExpression(): ExpressionNode { + const token = this.current() + if (token.type === 'number') { + this.consume() + return { type: 'Literal', value: Number(token.value) } + } + if (token.type === 'string') { + this.consume() + return { type: 'Literal', value: token.value } + } + if (token.type === 'boolean') { + this.consume() + return { type: 'Literal', value: token.value === 'true' } + } + if (token.type === 'null') { + this.consume() + return { type: 'Literal', value: null } + } + if (this.match('punctuation', '[')) { + return this.parseArrayExpression() + } + if (this.match('punctuation', '(')) { + this.consume() + const expression = this.parseOrExpression() + this.expect('punctuation', ')') + return expression + } + if (token.type === 'identifier') { + return this.parseIdentifierPathOrCall() + } + throw new Error(`表达式解析失败,无法识别 token ${token.type}:${token.value}(位置 ${token.position})`) + } + + private parseArrayExpression(): ExpressionNode { + this.expect('punctuation', '[') + const elements: ExpressionNode[] = [] + if (!this.match('punctuation', ']')) { + while (true) { + elements.push(this.parseOrExpression()) + if (!this.match('punctuation', ',')) { + break + } + this.consume() + } + } + this.expect('punctuation', ']') + return { type: 'ArrayExpression', elements } + } + + private parseIdentifierPathOrCall(): ExpressionNode { + const head = this.expect('identifier').value + if (this.match('punctuation', '(')) { + this.consume() + const args: ExpressionNode[] = [] + if (!this.match('punctuation', ')')) { + while (true) { + args.push(this.parseOrExpression()) + if (!this.match('punctuation', ',')) { + break + } + this.consume() + } + } + this.expect('punctuation', ')') + return { type: 'CallExpression', callee: head, args } + } + + const path = [head] + while (this.match('punctuation', '.')) { + this.consume() + path.push(this.expect('identifier').value) + } + return { type: 'IdentifierPath', path } + } +} + +type BuiltinFunction = (...args: unknown[]) => unknown + +const toArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []) +const toString = (value: unknown): string => (typeof value === 'string' ? value : String(value ?? '')) + +const builtins: Record = { + count(value) { + if (Array.isArray(value) || typeof value === 'string') return value.length + if (value && typeof value === 'object') return Object.keys(value).length + return 0 + }, + contains(collection, target) { + if (Array.isArray(collection)) { + return collection.includes(target) + } + if (typeof collection === 'string') { + return collection.includes(toString(target)) + } + return false + }, + intersect(left, right) { + const rightSet = new Set(toArray(right)) + const result: unknown[] = [] + toArray(left).forEach((item) => { + if (rightSet.has(item) && !result.includes(item)) { + result.push(item) + } + }) + return result + }, + map(collection, key) { + const keyName = typeof key === 'string' ? key : '' + if (!keyName) return [] + return toArray(collection).map((item: any) => item?.[keyName]) + }, + unique(collection) { + return Array.from(new Set(toArray(collection))) + }, + exists(value) { + if (Array.isArray(value)) return value.length > 0 + return value != null && value !== '' + }, + lower(value) { + return toString(value).toLowerCase() + }, + upper(value) { + return toString(value).toUpperCase() + } +} + +const isTruthy = (value: unknown): boolean => !!value + +const resolveIdentifierPath = (scope: RuleExpressionScope, path: string[]): unknown => { + let current: unknown = scope + for (const segment of path) { + if (current == null || typeof current !== 'object') { + return undefined + } + current = (current as Record)[segment] + } + return current +} + +const resolveVarFromScope = (scope: RuleExpressionScope, key: unknown): unknown[] => { + const keyName = typeof key === 'string' ? key.trim() : '' + if (!keyName) { + return [] + } + const vars = resolveIdentifierPath(scope, ['shared', 'vars']) + if (!vars || typeof vars !== 'object') { + return [] + } + const value = (vars as Record)[keyName] + if (Array.isArray(value)) { + return value + } + if (value == null || value === '') { + return [] + } + return [toString(value)] +} + +type EvaluationState = { + steps: number + maxSteps: number +} + +const evaluateNode = (node: ExpressionNode, scope: RuleExpressionScope, state: EvaluationState): unknown => { + state.steps += 1 + if (state.steps > state.maxSteps) { + throw new Error('表达式执行超出步骤限制') + } + + switch (node.type) { + case 'Literal': + return node.value + case 'ArrayExpression': + return node.elements.map((item) => evaluateNode(item, scope, state)) + case 'IdentifierPath': + return resolveIdentifierPath(scope, node.path) + case 'CallExpression': { + if (node.callee === 'getVar') { + if (node.args.length !== 1) { + throw new Error('getVar 仅支持一个参数:变量 key') + } + const key = evaluateNode(node.args[0], scope, state) + return resolveVarFromScope(scope, key) + } + const fn = builtins[node.callee] + if (!fn) { + throw new Error(`不支持的函数调用: ${node.callee}`) + } + const args = node.args.map((arg) => evaluateNode(arg, scope, state)) + return fn(...args) + } + case 'UnaryExpression': { + const value = evaluateNode(node.argument, scope, state) + if (node.operator === '!') return !isTruthy(value) + return -Number(value) + } + case 'BinaryExpression': { + if (node.operator === '&&') { + const left = evaluateNode(node.left, scope, state) + if (!isTruthy(left)) return false + return isTruthy(evaluateNode(node.right, scope, state)) + } + if (node.operator === '||') { + const left = evaluateNode(node.left, scope, state) + if (isTruthy(left)) return true + return isTruthy(evaluateNode(node.right, scope, state)) + } + + const left = evaluateNode(node.left, scope, state) + const right = evaluateNode(node.right, scope, state) + + switch (node.operator) { + case '==': + return left === right + case '!=': + return left !== right + case '>': + return Number(left) > Number(right) + case '>=': + return Number(left) >= Number(right) + case '<': + return Number(left) < Number(right) + case '<=': + return Number(left) <= Number(right) + case '+': + return Number(left) + Number(right) + case '-': + return Number(left) - Number(right) + case '*': + return Number(left) * Number(right) + case '/': + return Number(left) / Number(right) + default: + return undefined + } + } + default: + return undefined + } +} + +export const parseRuleExpression = (source: string): ExpressionNode => { + const key = source.trim() + if (!key) { + throw new Error('表达式不能为空') + } + const cached = astCache.get(key) + if (cached) { + return cached + } + const parser = new Parser(key) + const ast = parser.parse() + astCache.set(key, ast) + return ast +} + +export const evaluateRuleExpression = ( + source: string, + scope: RuleExpressionScope, + options?: { maxSteps?: number } +): unknown => { + const ast = parseRuleExpression(source) + const state: EvaluationState = { + steps: 0, + maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS + } + return evaluateNode(ast, scope, state) +} + +export const evaluateRuleExpressionAsBoolean = ( + source: string, + scope: RuleExpressionScope, + options?: { maxSteps?: number } +): boolean => { + const result = evaluateRuleExpression(source, scope, options) + return isTruthy(result) +}