feat: add configurable DSL rule manager and simplify dynamic-group settings

This commit is contained in:
2026-02-27 22:13:04 +08:00
parent f5ae91df43
commit 271b722c97
13 changed files with 1674 additions and 158 deletions

View File

@@ -1,6 +1,6 @@
# yys-editor 验收测试点(手工) # yys-editor 验收测试点(手工)
目标:覆盖“用户素材上传/管理、资产引用、Dynamic Group 规则提示、性能优化”等需求。 目标:覆盖“用户素材上传/管理、资产引用、Dynamic Group 规则提示、规则管理DSL/变量导入导出)、性能优化”等需求。
## 0. 基础启动与构建 ## 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(...)` 调度时机。 - `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. 性能回归(矢量节点快速缩放) ## 7. 性能回归(矢量节点快速缩放)
步骤: 步骤:
@@ -173,6 +194,7 @@
- [ ] 缺失资产降级策略通过(不阻断导出/渲染)。 - [ ] 缺失资产降级策略通过(不阻断导出/渲染)。
- [x] Dynamic Group 分组基础行为通过(分组信息写入 `meta.groupId`,复制分组会携带组内节点)。 - [x] Dynamic Group 分组基础行为通过(分组信息写入 `meta.groupId`,复制分组会携带组内节点)。
- [ ] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。 - [ ] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。
- [ ] 规则管理通过(规则列表表格化、弹窗编辑、导入导出可用)。
- [ ] 矢量节点快速缩放性能回归通过(无明显卡顿/卡死)。 - [ ] 矢量节点快速缩放性能回归通过(无明显卡顿/卡死)。
- [ ] 导出到 wiki 数据兼容通过wiki 侧可 normalize 与预览)。 - [ ] 导出到 wiki 数据兼容通过wiki 侧可 normalize 与预览)。
- [ ] 跨项目素材互通通过(同 origin 可复用素材,跨 origin 不互通)。 - [ ] 跨项目素材互通通过(同 origin 可复用素材,跨 origin 不互通)。
@@ -182,7 +204,7 @@
当前状态2026-02-27 当前状态2026-02-27
- 已通过5 项基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、导出图片时隐藏 Dynamic Group - 已通过5 项基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、导出图片时隐藏 Dynamic Group
- 部分通过1 项(跨项目规则互通方案确认)。 - 部分通过1 项(跨项目规则互通方案确认)。
- 未通过/待验证:6 项(其余项待完整手测或跨仓联调)。 - 未通过/待验证:7 项(其余项待完整手测或跨仓联调)。
逐项状态: 逐项状态:
- 基础启动与构建:已通过 - 基础启动与构建:已通过
@@ -192,6 +214,7 @@
- 缺失资产降级策略:未通过(待手测) - 缺失资产降级策略:未通过(待手测)
- Dynamic Group 分组基础行为:已通过 - Dynamic Group 分组基础行为:已通过
- 分组规则静态检查:未通过(待手测) - 分组规则静态检查:未通过(待手测)
- 规则管理(表格化/导入导出):未通过(待手测)
- 矢量节点快速缩放性能回归:未通过(待手测) - 矢量节点快速缩放性能回归:未通过(待手测)
- 导出到 wiki 数据兼容:未通过(待跨仓联测) - 导出到 wiki 数据兼容:未通过(待跨仓联测)
- 跨项目素材互通:未通过(待同 origin 联测) - 跨项目素材互通:未通过(待同 origin 联测)

View File

@@ -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)
})
})

View File

@@ -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>): 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'])
})
})

View File

@@ -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 仅支持一个参数')
})
})

View File

@@ -7,6 +7,7 @@
<el-button icon="Share" type="primary" @click="prepareCapture">{{ t('prepareCapture') }}</el-button> <el-button icon="Share" type="primary" @click="prepareCapture">{{ t('prepareCapture') }}</el-button>
<el-button icon="Setting" type="primary" @click="state.showWatermarkDialog = true">{{ t('setWatermark') }}</el-button> <el-button icon="Setting" type="primary" @click="state.showWatermarkDialog = true">{{ t('setWatermark') }}</el-button>
<el-button icon="Picture" type="primary" plain @click="openAssetManager">素材管理</el-button> <el-button icon="Picture" type="primary" plain @click="openAssetManager">素材管理</el-button>
<el-button icon="EditPen" type="primary" plain @click="openRuleManager">规则管理</el-button>
<el-button v-if="!props.isEmbed" type="info" @click="loadExample">{{ t('loadExample') }}</el-button> <el-button v-if="!props.isEmbed" type="info" @click="loadExample">{{ t('loadExample') }}</el-button>
<el-button v-if="!props.isEmbed" type="info" @click="showUpdateLog">{{ t('updateLog') }}</el-button> <el-button v-if="!props.isEmbed" type="info" @click="showUpdateLog">{{ t('updateLog') }}</el-button>
<el-button v-if="!props.isEmbed" type="warning" @click="showFeedbackForm">{{ t('feedback') }}</el-button> <el-button v-if="!props.isEmbed" type="warning" @click="showFeedbackForm">{{ t('feedback') }}</el-button>
@@ -163,6 +164,158 @@
</el-tabs> </el-tabs>
</el-dialog> </el-dialog>
<!-- 规则管理对话框 -->
<el-dialog v-model="state.showRuleManagerDialog" title="规则管理" width="80%">
<div class="rule-manager-actions">
<el-button size="small" type="primary" @click="addExpressionRule">新增规则</el-button>
<el-button size="small" type="primary" plain @click="addRuleVariable">新增变量</el-button>
<el-button size="small" @click="exportRuleBundle">导出规则变量</el-button>
<el-button size="small" @click="triggerRuleBundleImport">导入规则变量</el-button>
<el-button size="small" @click="reloadRuleManagerDraft">重载当前配置</el-button>
<el-button size="small" type="success" @click="applyRuleManagerConfig">应用并生效</el-button>
<el-button size="small" type="warning" plain @click="restoreDefaultRuleConfig">恢复默认</el-button>
<input
ref="ruleBundleImportInputRef"
type="file"
accept=".json,application/json"
class="asset-upload-input"
@change="handleRuleBundleImport"
/>
</div>
<el-tabs v-model="ruleManagerTab" class="rule-manager-tabs">
<el-tab-pane label="规则" name="rules">
<div class="rule-table-wrap">
<el-table
v-if="ruleConfigDraft.expressionRules.length > 0"
:data="ruleConfigDraft.expressionRules"
size="small"
border
class="rule-table"
>
<el-table-column label="启用" width="70" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.enabled" />
</template>
</el-table-column>
<el-table-column label="级别" width="110" align="center">
<template #default="{ row }">
<el-select
v-model="row.severity"
size="small"
:class="['rule-inline-select', 'severity-select', `severity-select--${row.severity || 'warning'}`]"
>
<el-option label="warning" value="warning" />
<el-option label="error" value="error" />
<el-option label="info" value="info" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="id" label="规则 ID" min-width="180" show-overflow-tooltip />
<el-table-column label="条件" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<span class="rule-cell-ellipsis">{{ row.condition }}</span>
</template>
</el-table-column>
<el-table-column label="提示" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="rule-cell-ellipsis">{{ row.message }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ $index }">
<el-button size="small" text type="primary" @click="openExpressionRuleEditor($index)">编辑</el-button>
<el-button size="small" text type="danger" @click="removeExpressionRule($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无规则,点击“新增规则”创建" />
</div>
</el-tab-pane>
<el-tab-pane label="变量" name="variables">
<div class="variable-list">
<div
v-for="(item, index) in ruleConfigDraft.ruleVariables"
:key="`${item.key}-${index}`"
class="variable-item"
>
<el-form-item label="Key" class="variable-key">
<el-input v-model="item.key" placeholder="如: fire_supporters" />
</el-form-item>
<el-form-item label="Value" class="variable-value">
<el-input
v-model="item.value"
type="textarea"
:rows="2"
placeholder="逗号或换行分隔,如:辉夜姬,座敷童子"
/>
</el-form-item>
<el-button size="small" text type="danger" @click="removeRuleVariable(index)">删除</el-button>
</div>
<el-empty v-if="ruleConfigDraft.ruleVariables.length === 0" description="暂无变量,点击“新增变量”创建" />
</div>
</el-tab-pane>
<el-tab-pane label="文档说明" name="docs">
<div class="rule-docs">
<h4>作用域约定</h4>
<pre>{{ ruleScopeDoc }}</pre>
<h4>可用上下文</h4>
<pre>{{ ruleContextDoc }}</pre>
<h4>支持语法</h4>
<pre>{{ ruleSyntaxDoc }}</pre>
<h4>支持函数</h4>
<pre>{{ ruleFunctionDoc }}</pre>
<h4>表达式示例</h4>
<pre>{{ ruleExamplesDoc }}</pre>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
<el-dialog v-model="ruleEditorVisible" title="编辑规则" width="56%">
<el-form v-if="ruleEditorDraft" label-width="96px" class="rule-editor-form">
<el-form-item label="启用">
<el-switch v-model="ruleEditorDraft.enabled" />
</el-form-item>
<el-form-item label="规则 ID">
<el-input v-model="ruleEditorDraft.id" placeholder="unique_rule_id" />
</el-form-item>
<el-form-item label="级别">
<el-select
v-model="ruleEditorDraft.severity"
:class="['severity-select', `severity-select--${ruleEditorDraft.severity || 'warning'}`]"
style="width: 100%"
>
<el-option label="warning" value="warning" />
<el-option label="error" value="error" />
<el-option label="info" value="info" />
</el-select>
</el-form-item>
<el-form-item label="告警 code">
<el-input v-model="ruleEditorDraft.code" placeholder="CUSTOM_EXPRESSION" />
</el-form-item>
<el-form-item label="条件表达式">
<el-input
v-model="ruleEditorDraft.condition"
type="textarea"
:rows="3"
placeholder='示例count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) == 0'
/>
</el-form-item>
<el-form-item label="提示文案">
<el-input v-model="ruleEditorDraft.message" placeholder="规则提示文案" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="cancelRuleEditor">取消</el-button>
<el-button type="primary" @click="saveRuleEditor">保存</el-button>
</span>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -185,6 +338,16 @@ import {
subscribeCustomAssetStore, subscribeCustomAssetStore,
type CustomAssetItem type CustomAssetItem
} from '@/utils/customAssets'; } 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<{ const props = withDefaults(defineProps<{
isEmbed?: boolean; isEmbed?: boolean;
@@ -217,6 +380,7 @@ const state = reactive({
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态 showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态
showDataPreviewDialog: false, // 控制数据预览对话框的显示状态 showDataPreviewDialog: false, // 控制数据预览对话框的显示状态
showAssetManagerDialog: false, // 控制素材管理对话框的显示状态 showAssetManagerDialog: false, // 控制素材管理对话框的显示状态
showRuleManagerDialog: false, // 控制规则管理对话框的显示状态
previewDataContent: '', // 存储预览的数据内容 previewDataContent: '', // 存储预览的数据内容
}); });
const assetLibraries = ASSET_LIBRARIES.map((item) => ({ const assetLibraries = ASSET_LIBRARIES.map((item) => ({
@@ -225,12 +389,186 @@ const assetLibraries = ASSET_LIBRARIES.map((item) => ({
})); }));
const assetManagerLibrary = ref(assetLibraries[0]?.id || 'shikigami'); const assetManagerLibrary = ref(assetLibraries[0]?.id || 'shikigami');
const assetUploadInputRef = ref<HTMLInputElement | null>(null); const assetUploadInputRef = ref<HTMLInputElement | null>(null);
const ruleBundleImportInputRef = ref<HTMLInputElement | null>(null);
const managedAssets = reactive<Record<string, CustomAssetItem[]>>({}); const managedAssets = reactive<Record<string, CustomAssetItem[]>>({});
assetLibraries.forEach((item) => { assetLibraries.forEach((item) => {
managedAssets[item.id] = []; managedAssets[item.id] = [];
}); });
let unsubscribeAssetStore: (() => void) | null = null; 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<GroupRulesConfig>(cloneRuleConfig(readSharedGroupRulesConfig()));
const ruleEditorVisible = ref(false);
const editingRuleIndex = ref<number | null>(null);
const ruleEditorDraft = ref<ExpressionRuleDefinition | null>(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<string, unknown>;
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<string, unknown>;
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) => { const refreshManagedAssets = (library?: string) => {
if (library) { if (library) {
managedAssets[library] = listCustomAssets(library); managedAssets[library] = listCustomAssets(library);
@@ -246,6 +584,166 @@ const openAssetManager = () => {
state.showAssetManagerDialog = true; 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<string, unknown>;
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) => { const getManagedAssets = (libraryId: string) => {
return managedAssets[libraryId] || []; return managedAssets[libraryId] || [];
}; };
@@ -763,4 +1261,118 @@ const handleClose = (done) => {
color: #303133; color: #303133;
word-break: break-all; 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;
}
}
</style> </style>

View File

@@ -39,9 +39,7 @@ const componentGroups = [
groupMeta: { groupMeta: {
version: 1, version: 1,
groupKind: 'team', groupKind: 'team',
groupName: '', ruleEnabled: true
ruleEnabled: true,
ruleScope: ['shikigami-yuhun', 'shikigami-shikigami']
}, },
collapsible: true, collapsible: true,
isCollapsed: false, isCollapsed: false,

View File

@@ -123,6 +123,7 @@ import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@
import { useCanvasSettings } from '@/ts/useCanvasSettings'; import { useCanvasSettings } from '@/ts/useCanvasSettings';
import { validateGraphGroupRules, type GroupRuleWarning } from '@/utils/groupRules'; import { validateGraphGroupRules, type GroupRuleWarning } from '@/utils/groupRules';
import { subscribeSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource'; import { subscribeSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource';
import { getProblemTargetCandidateIds } from '@/utils/problemTarget';
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter'; type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
type DistributeType = 'horizontal' | 'vertical'; type DistributeType = 'horizontal' | 'vertical';
@@ -732,7 +733,7 @@ function locateProblemNode(warning: GroupRuleWarning) {
const lfInstance = lf.value as any; const lfInstance = lf.value as any;
if (!lfInstance) return; if (!lfInstance) return;
const candidateIds = [...(warning.nodeIds || []), warning.groupId].filter((id) => !!id); const candidateIds = getProblemTargetCandidateIds(warning);
const targetId = candidateIds.find((id) => !!lfInstance.getNodeModelById(id)); const targetId = candidateIds.find((id) => !!lfInstance.getNodeModelById(id));
if (!targetId) { if (!targetId) {
showMessage('warning', '未找到告警对应节点,可能已被删除'); showMessage('warning', '未找到告警对应节点,可能已被删除');

View File

@@ -2,7 +2,6 @@
import { reactive, watch } from 'vue'; import { reactive, watch } from 'vue';
import { getLogicFlowInstance } from '@/ts/useLogicFlow'; import { getLogicFlowInstance } from '@/ts/useLogicFlow';
import { import {
DEFAULT_GROUP_RULE_SCOPE,
GROUP_META_VERSION, GROUP_META_VERSION,
normalizeDynamicGroupMeta normalizeDynamicGroupMeta
} from '@/utils/graphSchema'; } from '@/utils/graphSchema';
@@ -13,40 +12,19 @@ const props = defineProps<{
type DynamicGroupMeta = { type DynamicGroupMeta = {
groupKind: 'team' | 'shikigami'; groupKind: 'team' | 'shikigami';
groupName: string;
ruleEnabled: boolean; ruleEnabled: boolean;
ruleScope: string[];
}; };
const DEFAULT_SCOPE_OPTIONS = [
{ value: 'shikigami-yuhun', label: '式神-御魂关系' },
{ value: 'shikigami-shikigami', label: '式神-式神关系' }
];
const form = reactive<DynamicGroupMeta>({ const form = reactive<DynamicGroupMeta>({
groupKind: 'team', groupKind: 'team',
groupName: '', ruleEnabled: true
ruleEnabled: true,
ruleScope: [...DEFAULT_GROUP_RULE_SCOPE]
}); });
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) => { const syncFromNode = (node?: any) => {
if (!node) return; if (!node) return;
const groupMeta = normalizeDynamicGroupMeta(node.properties?.groupMeta); const groupMeta = normalizeDynamicGroupMeta(node.properties?.groupMeta);
form.groupKind = groupMeta.groupKind; form.groupKind = groupMeta.groupKind;
form.groupName = groupMeta.groupName;
form.ruleEnabled = groupMeta.ruleEnabled; form.ruleEnabled = groupMeta.ruleEnabled;
form.ruleScope = normalizeRuleScope(groupMeta.ruleScope);
}; };
watch( watch(
@@ -69,9 +47,7 @@ const applyGroupMeta = () => {
groupMeta: { groupMeta: {
version: GROUP_META_VERSION, version: GROUP_META_VERSION,
groupKind: form.groupKind, groupKind: form.groupKind,
groupName: form.groupName.trim(), ruleEnabled: form.ruleEnabled
ruleEnabled: form.ruleEnabled,
ruleScope: normalizeRuleScope(form.ruleScope)
} }
}); });
}; };
@@ -89,50 +65,12 @@ const applyGroupMeta = () => {
</el-select> </el-select>
</div> </div>
<div class="property-item">
<div class="property-label">分组名称</div>
<el-input
v-model="form.groupName"
placeholder="例如队伍1、PVP阵容A"
clearable
@change="applyGroupMeta"
/>
</div>
<div class="property-item"> <div class="property-item">
<div class="property-label">启用规则检查</div> <div class="property-label">启用规则检查</div>
<el-switch v-model="form.ruleEnabled" @change="applyGroupMeta" /> <el-switch v-model="form.ruleEnabled" @change="applyGroupMeta" />
</div> </div>
<div class="property-item">
<div class="property-label">规则范围</div>
<el-select
v-model="form.ruleScope"
multiple
filterable
allow-create
default-first-option
style="width: 100%"
placeholder="选择或输入规则范围"
@change="applyGroupMeta"
>
<el-option
v-for="option in DEFAULT_SCOPE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<div class="property-tip">可扩展后续新增规则域时可直接添加 scope</div>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.property-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
</style> </style>

View File

@@ -15,37 +15,61 @@ export type GroupRulesConfig = {
fireShikigamiWhitelist: string[] fireShikigamiWhitelist: string[]
shikigamiYuhunBlacklist: ShikigamiYuhunBlacklistRule[] shikigamiYuhunBlacklist: ShikigamiYuhunBlacklistRule[]
shikigamiConflictPairs: ShikigamiConflictRule[] 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 = { export const DEFAULT_GROUP_RULES_CONFIG: GroupRulesConfig = {
version: 1, version: 3,
fireShikigamiWhitelist: [ fireShikigamiWhitelist: [
'辉夜姬', '辉夜姬',
'因幡辉夜姬', '因幡辉夜姬',
'追月神', '追月神',
'座敷童子', '座敷童子',
'千姬', '千姬'
'帝释天',
'不见岳',
'食灵'
], ],
shikigamiYuhunBlacklist: [ shikigamiYuhunBlacklist: [],
shikigamiConflictPairs: [],
expressionRules: [
{ {
shikigami: '辉夜姬', id: 'team-require-fire-shikigami',
yuhun: '破势', condition: 'count(intersect(ctx.team.shikigamiNames, getVar("供火式神"))) == 0',
message: '规则冲突:辉夜姬通常不建议携带破势。' message: '规则提示:当前队伍缺少供火式神。',
} severity: 'warning',
], code: 'TEAM_MISSING_FIRE_SHIKIGAMI',
shikigamiConflictPairs: [ enabled: true
{
left: '千姬',
right: '腹肌清姬',
message: '规则冲突:千姬与腹肌清姬不建议同队。'
}, },
{ {
left: '千姬', id: 'team-kaguya-no-poshi',
right: '蝮骨清姬', condition: 'contains(ctx.team.shikigamiNames, "辉夜姬") && contains(ctx.team.yuhunNames, "破势")',
message: '规则冲突:千姬与蝮骨清姬不建议同队。' message: '规则冲突:辉夜姬不建议携带破势。',
severity: 'warning',
code: 'TEAM_KAGUYA_POSHI_CONFLICT',
enabled: true
}
],
ruleVariables: [
{
key: '供火式神',
value: '辉夜姬,因幡辉夜姬,追月神,座敷童子,千姬'
},
{
key: '输出御魂',
value: '破势,狂骨,针女,海月火玉'
} }
] ]
} }

View File

@@ -1,6 +1,7 @@
import type { GroupRulesConfig } from '@/configs/groupRules' import type { GroupRulesConfig } from '@/configs/groupRules'
import { readSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource' import { readSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource'
import { getDynamicGroupChildIds, normalizeGraphRawDataSchema } from '@/utils/graphSchema' import { getDynamicGroupChildIds, normalizeGraphRawDataSchema } from '@/utils/graphSchema'
import { evaluateRuleExpressionAsBoolean } from '@/utils/ruleExpression'
type GraphData = { type GraphData = {
nodes: any[] nodes: any[]
@@ -25,7 +26,7 @@ type TeamAssetSnapshot = {
export type GroupRuleWarning = { export type GroupRuleWarning = {
id: string id: string
ruleId: string ruleId: string
code: 'SHIKIGAMI_YUHUN_BLACKLIST' | 'SHIKIGAMI_CONFLICT' | 'MISSING_FIRE_SHIKIGAMI' code: string
severity: 'warning' | 'error' | 'info' severity: 'warning' | 'error' | 'info'
groupId: string groupId: string
groupName?: string groupName?: string
@@ -33,6 +34,32 @@ export type GroupRuleWarning = {
nodeIds: string[] 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<string, string[]>
}
}
const normalizeText = (value: unknown): string => { const normalizeText = (value: unknown): string => {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return '' return ''
@@ -69,11 +96,25 @@ const inferLibrary = (node: any): string => {
return '' return ''
} }
const includesName = (list: string[], target: string): boolean => { const dedupeNodeIds = (ids: string[]): string[] => Array.from(new Set(ids))
return list.some((item) => item === target)
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<string, string[]> => {
const map: Record<string, string[]> = {}
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 collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] => {
const nodes = Array.isArray(graphData?.nodes) ? graphData.nodes : [] const nodes = Array.isArray(graphData?.nodes) ? graphData.nodes : []
@@ -113,7 +154,6 @@ const collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] =>
if (isDynamicGroupNode(node)) { if (isDynamicGroupNode(node)) {
const childKind = normalizeText(node?.properties?.groupMeta?.groupKind) const childKind = normalizeText(node?.properties?.groupMeta?.groupKind)
// 嵌套 team 视为独立边界,不纳入当前队伍聚合
if (childKind === 'team') { if (childKind === 'team') {
continue continue
} }
@@ -161,6 +201,45 @@ const collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] =>
const createWarningId = (groupId: string, ruleId: string): string => `${groupId}::${ruleId}` 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 = ( export const validateGraphGroupRules = (
graphData: GraphData, graphData: GraphData,
config?: GroupRulesConfig config?: GroupRulesConfig
@@ -171,67 +250,26 @@ export const validateGraphGroupRules = (
const warnings: GroupRuleWarning[] = [] const warnings: GroupRuleWarning[] = []
teams.forEach((team) => { teams.forEach((team) => {
const shikigamiNames = team.shikigamiAssets.map((item) => item.name) const scope = createTeamScope(team, effectiveConfig)
const yuhunNames = team.yuhunAssets.map((item) => item.name)
effectiveConfig.shikigamiYuhunBlacklist.forEach((rule) => { effectiveConfig.expressionRules.forEach((rule) => {
if (includesName(shikigamiNames, rule.shikigami) && includesName(yuhunNames, rule.yuhun)) { if (rule.enabled === false) {
const ruleId = `blacklist:${rule.shikigami}:${rule.yuhun}` return
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}`
})
} }
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 return warnings

View File

@@ -1,6 +1,8 @@
import { import {
DEFAULT_GROUP_RULES_CONFIG, DEFAULT_GROUP_RULES_CONFIG,
type ExpressionRuleDefinition,
type GroupRulesConfig, type GroupRulesConfig,
type RuleVariableDefinition,
type ShikigamiConflictRule, type ShikigamiConflictRule,
type ShikigamiYuhunBlacklistRule type ShikigamiYuhunBlacklistRule
} from '@/configs/groupRules' } from '@/configs/groupRules'
@@ -21,7 +23,9 @@ const cloneDefaultGroupRulesConfig = (): GroupRulesConfig => ({
version: DEFAULT_GROUP_RULES_CONFIG.version, version: DEFAULT_GROUP_RULES_CONFIG.version,
fireShikigamiWhitelist: [...DEFAULT_GROUP_RULES_CONFIG.fireShikigamiWhitelist], fireShikigamiWhitelist: [...DEFAULT_GROUP_RULES_CONFIG.fireShikigamiWhitelist],
shikigamiYuhunBlacklist: DEFAULT_GROUP_RULES_CONFIG.shikigamiYuhunBlacklist.map((rule) => ({ ...rule })), 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[] => { const normalizeStringList = (value: unknown, fallback: string[]): string[] => {
@@ -89,6 +93,64 @@ const normalizeConflictRules = (
.filter((item): item is ShikigamiConflictRule => !!item) .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<string, unknown>
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<string, unknown>
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 => { const normalizeGroupRulesConfig = (input: unknown): GroupRulesConfig | null => {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return null return null
@@ -105,7 +167,9 @@ const normalizeGroupRulesConfig = (input: unknown): GroupRulesConfig | null => {
version, version,
fireShikigamiWhitelist: normalizeStringList(raw.fireShikigamiWhitelist, fallback.fireShikigamiWhitelist), fireShikigamiWhitelist: normalizeStringList(raw.fireShikigamiWhitelist, fallback.fireShikigamiWhitelist),
shikigamiYuhunBlacklist: normalizeBlacklistRules(raw.shikigamiYuhunBlacklist, fallback.shikigamiYuhunBlacklist), 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)
} }
} }

View File

@@ -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])
}

539
src/utils/ruleExpression.ts Normal file
View File

@@ -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<string, unknown>
const DEFAULT_MAX_STEPS = 20_000
const astCache = new Map<string, ExpressionNode>()
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<string, BuiltinFunction> = {
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<string, unknown>)[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<string, unknown>)[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)
}