diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 00f9c9d..8bfdb32 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,10 +17,10 @@ jobs: with: node-version: '22' - - name: Install dependencies and build + - name: Install dependencies and build web app run: | npm install - npm run build + VITE_APP_BASE_URL=/yys-editor/ npm run build:app - name: Deploy to temporary directory uses: appleboy/scp-action@v0.1.6 diff --git a/.github/workflows/deploy_dev.yml b/.github/workflows/deploy_dev.yml index 2d4d9a2..cb9f686 100644 --- a/.github/workflows/deploy_dev.yml +++ b/.github/workflows/deploy_dev.yml @@ -18,10 +18,10 @@ jobs: with: node-version: '22' - - name: Install dependencies and build + - name: Install dependencies and build web app run: | npm install - npm run build + VITE_APP_BASE_URL=/yys-editor-dev/ npm run build:app - name: Deploy to temporary directory uses: appleboy/scp-action@v0.1.6 diff --git a/docs/1management/plan.md b/docs/1management/plan.md index d2829f0..ccf07cc 100644 --- a/docs/1management/plan.md +++ b/docs/1management/plan.md @@ -6,8 +6,8 @@ **技术栈:** Vue 3 + LogicFlow + Element Plus + Pinia **目标:** 作为独立编辑器和可嵌入组件,支持在 onmyoji-wiki 中作为块插件使用 -**当前状态:** ✅ 阶段 1 完成(独立编辑器)+ ✅ 阶段 2 完成(组件化改造) -**总体完成度:** 100%(核心功能) +**当前状态:** ✅ 阶段 1 完成(独立编辑器)+ ✅ 阶段 2 完成(组件化改造)+ 🔄 阶段 3 进行中(wiki 集成稳定化) +**总体完成度:** 93%(核心功能完成,集成与质量收尾中) --- @@ -39,7 +39,7 @@ | 🎨 画布(LogicFlow) | 100% | ✅ 完美 | 无 | | 📦 左侧组件库 | 75% | ✅ 可用 | 缩略图、搜索 | | ⚙️ 右侧属性面板 | 100% | ✅ 完美 | 无 | -| 🔧 工具栏 | 85% | ✅ 良好 | 导出命名优化 | +| 🔧 工具栏 | 90% | ✅ 良好 | 导出命名优化 | | 💬 弹窗系统 | 75% | ✅ 可用 | i18n完善、性能优化 | | 💾 状态与持久化 | 90% | ✅ 优秀 | 重命名UI | | 🌐 数据与国际化 | 60% | ⚠️ 基础 | UTF-8统一、日文覆盖 | @@ -206,30 +206,31 @@ --- -### 🎨 阶段 3:wiki 集成测试(待开发) +### 🎨 阶段 3:wiki 集成测试(进行中) **目标:** 在 onmyoji-wiki 中测试集成效果 #### 步骤 5:本地引用测试(1-2 天) -- [ ] 在 wiki 中引用 yys-editor(file: 方式) -- [ ] 创建 YysEditorBlock 组件 -- [ ] 测试预览模式 -- [ ] 测试编辑模式 -- [ ] 测试数据保存 +- [x] 在 wiki 中引用 yys-editor(file: 方式) +- [x] 创建集成包装层(当前以 `/editor` 页面集成替代独立 `YysEditorBlock` 组件) +- [x] 测试预览模式 +- [x] 测试编辑模式 +- [x] 测试数据保存 #### 步骤 6:交互优化(2-3 天) -- [ ] 优化模式切换体验 -- [ ] 优化数据同步 -- [ ] 优化错误处理 +- [x] 优化模式切换体验 +- [x] 优化数据同步 +- [x] 优化错误处理 +- [x] 新增顶部“素材管理”入口并统一素材分类来源(与资产选择器一致) - [ ] 优化加载性能 **验收标准:** -- 在 wiki 中可以正常使用 -- 预览/编辑切换流畅 -- 数据保存正确 -- 体验类似 Notion 块 +- 在 wiki 中可以正常使用(已达成) +- 预览/编辑切换流畅(已达成) +- 数据保存正确(已达成) +- 体验类似 Notion 块(进行中,持续优化) --- @@ -301,12 +302,12 @@ wiki 文档 **完成时间:** 2026-02-20 -### Milestone 3:wiki 集成(待开发) -- [ ] 本地引用测试 -- [ ] 交互优化 +### Milestone 3:wiki 集成(进行中) +- [x] 本地引用测试 +- [~] 交互优化(已完成主要问题修复,继续打磨性能) - [ ] 文档完善 -**预计完成:** 与 wiki 同步 +**预计完成:** 2026-03 第 1 周(随 wiki 联调收尾) --- @@ -428,6 +429,16 @@ const handleCancel = () => { ## 📝 更新日志 +### 2026-02-27 +- ✅ 完成素材管理入口可见性优化:Toolbar 新增“素材管理”按钮 +- ✅ 完成素材分类统一:素材管理与资产选择器统一使用同一分类源(4 类) +- ✅ 完成跨项目互通基础落地:素材同源存储稳定化、规则共享配置源读取与默认回退 + +### 2026-02-26 +- ✅ 修复嵌入式编辑器在 wiki 弹层中的画布高度与边界占满问题(多次 resize + 容器高度链路修正) +- ✅ 修复编辑已有资产后立即保存时数据偶发不刷新的问题(保存前 flush + 预览强制 key 更新) +- ✅ 完成与 onmyoji-wiki 的本地库联调闭环(`build:lib` + `file:../yys-editor`) + ### 2026-02-25 - ✅ 修复嵌入编辑器在 onmyoji-wiki 弹层中的初始化尺寸异常 - 编辑区域高度改为基于容器测量后计算 @@ -453,6 +464,7 @@ const handleCancel = () => { --- -**最后更新:** 2026-02-20 +**最后更新:** 2026-02-27 +**文档版本:** v2.2.1(wiki 集成稳定化进行中) **文档版本:** v2.1.0(组件化改造完成) **文档版本:** v2.0.0(重新规划) diff --git a/docs/test/README.md b/docs/test/README.md new file mode 100644 index 0000000..93dbd60 --- /dev/null +++ b/docs/test/README.md @@ -0,0 +1,6 @@ +# 测试索引(yys-editor) + +本目录用于记录 yys-editor 的人工验收测试点(不包含自动化测试)。 + +- 主验收清单:`docs/test/acceptance.md` + diff --git a/docs/test/acceptance.md b/docs/test/acceptance.md new file mode 100644 index 0000000..6bf754d --- /dev/null +++ b/docs/test/acceptance.md @@ -0,0 +1,222 @@ +# yys-editor 验收测试点(手工) + +目标:覆盖“用户素材上传/管理、资产引用、Dynamic Group 规则提示、规则管理(DSL/变量导入导出)、性能优化”等需求。 + +## 0. 基础启动与构建 + +步骤: +- `npm install` +- `npm run dev` +- `npm run build` + +预期: +- dev 正常启动,页面可操作。 +- build 成功输出 `dist/`。 + +## 1. 资产基路径与引用一致性 + +步骤: +- 在编辑器中插入素材节点(式神/御魂等),保存。 +- 刷新页面或重新打开。 + +预期: +- 素材仍能正确显示。 +- 对于以 `/assets/...` 开头的资源,能够在宿主子路径部署时被正确改写(由宿主配置/注入决定)。 + +排查点: +- `src/utils/assetUrl.ts` 的 `setAssetBaseUrl/getAssetBaseUrl/resolveAssetUrl`。 + +## 2. 用户素材上传与使用(我的素材) + +步骤: +- 点击顶部工具栏“素材管理”,切到对应分类上传素材。 +- 在画布添加一个 `assetSelector` 节点并选中,打开素材选择面板(AssetSelector)。 +- 点击“上传我的素材”,选择一张图片。 +- 在列表中找到该素材,点击选中。 + +预期: +- 新素材出现在“我的素材”分组。 +- 选择后节点的 selectedAsset 生效并可渲染。 + +## 3. 用户素材删除与持久化 + +步骤: +- 上传 1 张素材。 +- 删除该素材(按钮“删除”)。 +- 刷新页面。 + +预期: +- 删除后不再出现在列表中。 +- 刷新后不会复活(localStorage 已同步)。 + +排查点: +- `src/utils/customAssets.ts` 的 `list/save/delete/createCustomAssetFromFile`。 +- `src/components/common/GenericImageSelector.vue` 的上传与删除逻辑。 + +## 4. 缺失资产的降级策略(本地自玩导出图) + +目的:验证“场景 1:仅 yys-editor 使用并导出图片时,缺失资产不应崩溃”。 + +步骤: +- 将某个节点的 avatar 修改为不存在路径或不可访问路径(用于测试)。 +- 尝试导出/渲染。 + +预期: +- 不出现阻断性异常(可降级为占位或提示)。 + +备注: +- 若目前仅实现 wiki 侧降级:记录为“待补 yys-editor 侧降级策略”。 + +## 5. Dynamic Group 分组(基础行为) + +步骤: +- 在画布上创建多个节点。 +- 创建动态分组(Dynamic Group),将节点加入/移出分组。 +- 仅选中 Dynamic Group 执行 `Ctrl+C` / `Ctrl+V`,观察粘贴结果。 + +预期: +- 分组操作成功。 +- 分组信息能写入节点 meta(用于规则检查)。 +- 复制分组时会自动携带组内节点(官方行为),新旧分组互不串联拖拽。 + +排查点: +- `src/components/flow/FlowEditor.vue` 使用 LogicFlow 默认快捷键复制粘贴(`shortcut.js -> lf.addElements`)。 + +## 11. 导出图片时隐藏 Dynamic Group(视觉优化) + +步骤: +- 在画布创建 Dynamic Group,并放入若干子节点。 +- 点击“准备截图”并下载图片。 + +预期: +- 导出的图片中不显示 Dynamic Group 容器边框。 +- 组内节点与其他节点正常显示。 +- 导出完成后,编辑器画布中的 Dynamic Group 仍可见(只在导出瞬间隐藏)。 + +排查点: +- `src/components/Toolbar.vue` 的 `captureLogicFlowSnapshot` 及临时隐藏/恢复逻辑。 + +## 6. 规则静态检查(分组内) + +步骤: +- 在同一分组中放入: + - “辉夜姬” 与 “破势” + - 只有式神但没有供火式神(不含供火名单) +- 观察右侧/控制区的规则提示列表。 + +预期: +- 出现对应警告提示(当前默认预制规则): + - `TEAM_KAGUYA_POSHI_CONFLICT` + - `TEAM_MISSING_FIRE_SHIKIGAMI` +- 取消分组、移除节点后提示实时更新/消失。 + +排查点: +- `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. 性能回归(矢量节点快速缩放) + +步骤: +- 放置矢量节点(VectorNode)。 +- 快速缩放、连续拖动缩放柄。 + +预期: +- 明显卡顿减少,不出现“缩放一下就卡死”的体验。 + +排查点: +- `src/components/flow/nodes/common/VectorNode.vue` 的 RAF 合并更新逻辑。 + +## 8. 导出给 wiki 的兼容性(数据结构) + +步骤: +- 生成一份包含分组、素材、文本等内容的 graphData。 +- 将 JSON 用于 wiki 的 FlowPreview/editor。 + +预期: +- wiki 侧能正常 normalize 并预览(节点 off-canvas 会自动平移回可视区)。 + +## 9. 跨项目互通验收(yys-editor <-> onmyoji-wiki/editor) + +目标:确认素材与规则在两个项目间的复用边界。 + +### 9.1 素材互通(同 origin) + +步骤: +- 在 yys-editor 上传“我的素材”。 +- 在同一浏览器、同一 origin 打开 `onmyoji-wiki/editor` 并检查素材选择。 + +预期(当前实现): +- 可直接复用“我的素材”,无需重复导入。 + +说明: +- 素材走 localStorage(`yys-editor.custom-assets.v1`)。 +- 仅同 origin 互通;跨 origin 默认不互通。 + +### 9.2 规则互通(同 origin) + +步骤: +- 在 yys-editor 写入共享规则配置(localStorage 键:`yys-editor.group-rules.v1`)。 +- 进入 `onmyoji-wiki/editor` 检查提示是否同步。 + +预期(当前实现): +- yys-editor:优先读取 `yys-editor.group-rules.v1`,解析失败/缺失时回退内置默认规则。 +- onmyoji-wiki:未对接共享规则配置源前,仍使用本仓默认规则。 + +结论: +- 共享规则配置源已在 yys-editor 落地;wiki 侧仍需按同键读取以完成双向一致。 + +## 10. 回归清单(状态跟踪) + +- [x] 基础启动与构建通过(`npm install` / `npm run dev` / `npm run build`)。 +- [ ] 资产基路径与引用一致性通过(`/assets/...` 在宿主子路径下可正确解析)。 +- [x] 用户素材上传与使用通过(我的素材可新增并可用于节点)。 +- [x] 用户素材删除与持久化通过(删除后刷新不复活)。 +- [ ] 缺失资产降级策略通过(不阻断导出/渲染)。 +- [x] Dynamic Group 分组基础行为通过(分组信息写入 `meta.groupId`,复制分组会携带组内节点)。 +- [x] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。 +- [x] 规则管理通过(规则列表表格化、弹窗编辑、导入导出可用)。 +- [ ] 矢量节点快速缩放性能回归通过(无明显卡顿/卡死)。 +- [x] 导出到 wiki 数据兼容通过(wiki 侧可 normalize 与预览)。 +- [ ] 跨项目素材互通通过(同 origin 可复用素材,跨 origin 不互通)。 +- [ ] 跨项目规则互通方案确认(共享配置源定义、两侧读取一致)。 +- [x] 导出图片时隐藏 Dynamic Group 通过(导出前隐藏,导出后恢复)。 + +当前状态(2026-02-27): +- 已通过:8 项(基础启动与构建、用户素材上传与使用、用户素材删除与持久化、Dynamic Group 分组基础行为、分组规则静态检查、规则管理、导出到 wiki 数据兼容、导出图片时隐藏 Dynamic Group)。 +- 部分通过:1 项(跨项目规则互通方案确认)。 +- 未通过/待验证:4 项(其余项待完整手测或跨仓联调)。 + +逐项状态: +- 基础启动与构建:已通过 +- 资产基路径与引用一致性:未通过(待手测) +- 用户素材上传与使用:已通过 +- 用户素材删除与持久化:已通过 +- 缺失资产降级策略:未通过(待手测) +- Dynamic Group 分组基础行为:已通过 +- 分组规则静态检查:已通过 +- 规则管理(表格化/导入导出):已通过 +- 矢量节点快速缩放性能回归:未通过(待手测) +- 导出到 wiki 数据兼容:已通过(2026-02-27 联测通过) +- 跨项目素材互通:未通过(待同 origin 联测) +- 跨项目规则互通方案确认:部分通过(yys-editor 已落地,wiki 待读取同源配置) +- 导出图片时隐藏 Dynamic Group:已通过 diff --git a/package.json b/package.json index aed4217..6c4b83c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yys-editor", - "version": "1.0.4", + "version": "1.0.6", "description": "阴阳师流程图编辑器 - 可嵌入式组件", "author": "yys-editor team", "license": "MIT", diff --git a/src/App.vue b/src/App.vue index 0dea7e4..f2346b3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -180,6 +180,7 @@ watch(
diff --git a/src/YysEditorEmbed.vue b/src/YysEditorEmbed.vue index 98497df..7a77580 100644 --- a/src/YysEditorEmbed.vue +++ b/src/YysEditorEmbed.vue @@ -111,12 +111,15 @@ const sanitizeLabelProperty = (properties: unknown): Record | undef return nextProperties } -const sanitizeGraphData = (input?: GraphData | null): GraphData => { +const sanitizeGraphData = ( + input?: GraphData | null, + options?: { hideDynamicGroups?: boolean } +): GraphData => { if (!input || !Array.isArray(input.nodes) || !Array.isArray(input.edges)) { return { nodes: [], edges: [] } } - const nodes = input.nodes + const rawNodes = input.nodes .filter((node): node is NodeData => isPlainObject(node)) .map((node) => { const nextNode: NodeData = { ...node } @@ -127,6 +130,12 @@ const sanitizeGraphData = (input?: GraphData | null): GraphData => { return nextNode }) + const hiddenDynamicGroup = options?.hideDynamicGroups === true + const nodes = hiddenDynamicGroup + ? rawNodes.filter((node) => node.type !== 'dynamic-group') + : rawNodes + const nodeIdSet = new Set(nodes.map((node) => node.id)) + const edges = input.edges .filter((edge): edge is EdgeData => isPlainObject(edge)) .map((edge) => { @@ -137,6 +146,7 @@ const sanitizeGraphData = (input?: GraphData | null): GraphData => { } return nextEdge }) + .filter((edge) => !hiddenDynamicGroup || (nodeIdSet.has(edge.sourceNodeId) && nodeIdSet.has(edge.targetNodeId))) return { nodes, edges } } @@ -328,7 +338,7 @@ const initPreviewMode = () => { // 渲染数据 if (props.data) { - previewLf.value.render(sanitizeGraphData(props.data)) + previewLf.value.render(sanitizeGraphData(props.data, { hideDynamicGroups: true })) } } @@ -362,7 +372,7 @@ const getGraphData = (): GraphData | null => { } const setGraphData = (data: GraphData) => { - const safeData = sanitizeGraphData(data) + const safeData = sanitizeGraphData(data, { hideDynamicGroups: props.mode === 'preview' }) if (props.mode === 'edit') { const lfInstance = getLogicFlowInstance() if (lfInstance) { 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 0a1d3be..0597202 100644 --- a/src/components/Toolbar.vue +++ b/src/components/Toolbar.vue @@ -6,6 +6,8 @@ 数据预览 {{ t('prepareCapture') }} {{ t('setWatermark') }} + 素材管理 + 规则管理 {{ t('loadExample') }} {{ t('updateLog') }} {{ t('feedback') }} @@ -111,11 +113,214 @@ + + +
+ + + 上传当前分类素材 + +
+ + + +
+
+
+
{{ item.name }}
+ + 删除 + +
+
+ + + + + + + +
+ 新增规则 + 新增变量 + 导出规则变量 + 导入规则变量 + 重载当前配置 + 应用并生效 + 恢复默认 + +
+ + + +
+ + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + + + + + 删除 +
+ +
+
+ + +
+

作用域约定

+
{{ ruleScopeDoc }}
+

可用上下文

+
{{ ruleContextDoc }}
+

支持语法

+
{{ ruleSyntaxDoc }}
+

支持函数

+
{{ ruleFunctionDoc }}
+

表达式示例

+
{{ ruleExamplesDoc }}
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/configs/groupRules.ts b/src/configs/groupRules.ts new file mode 100644 index 0000000..01715e5 --- /dev/null +++ b/src/configs/groupRules.ts @@ -0,0 +1,76 @@ +export type ShikigamiYuhunBlacklistRule = { + shikigami: string + yuhun: string + message?: string +} + +export type ShikigamiConflictRule = { + left: string + right: string + message?: string +} + +export type GroupRulesConfig = { + version: number + 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: 3, + fireShikigamiWhitelist: [ + '辉夜姬', + '因幡辉夜姬', + '追月神', + '座敷童子', + '千姬' + ], + shikigamiYuhunBlacklist: [], + shikigamiConflictPairs: [], + expressionRules: [ + { + id: 'team-require-fire-shikigami', + condition: 'count(intersect(ctx.team.shikigamiNames, getVar("供火式神"))) == 0', + message: '规则提示:当前队伍缺少供火式神。', + severity: 'warning', + code: 'TEAM_MISSING_FIRE_SHIKIGAMI', + enabled: true + }, + { + 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/configs/nodeRegistry.ts b/src/configs/nodeRegistry.ts index 4063a73..0362128 100644 --- a/src/configs/nodeRegistry.ts +++ b/src/configs/nodeRegistry.ts @@ -20,6 +20,13 @@ export const NODE_REGISTRY: Record = { description: '椭圆容器,可设置背景和边框' }, + [NodeType.DYNAMIC_GROUP]: { + type: NodeType.DYNAMIC_GROUP, + category: NodeCategory.LAYOUT, + label: '动态分组', + description: '支持折叠/收起与节点归组的容器' + }, + [NodeType.ASSET_SELECTOR]: { type: NodeType.ASSET_SELECTOR, category: NodeCategory.ASSET, diff --git a/src/flowRuntime.ts b/src/flowRuntime.ts index 5af7027..bd3d221 100644 --- a/src/flowRuntime.ts +++ b/src/flowRuntime.ts @@ -1,5 +1,5 @@ import type LogicFlow from '@logicflow/core' -import { Menu, Label, Snapshot, SelectionSelect, MiniMap, Control } from '@logicflow/extension' +import { Menu, Label, Snapshot, SelectionSelect, MiniMap, Control, DynamicGroup } from '@logicflow/extension' import { register } from '@logicflow/vue-node-registry' import ImageNode from './components/flow/nodes/common/ImageNode.vue' @@ -27,8 +27,9 @@ const DEFAULT_FLOW_NODES: FlowNodeRegistration[] = [ ] const FLOW_PLUGIN_PRESETS: Record = { - 'render-only': [Snapshot], - interactive: [Menu, Label, Snapshot, SelectionSelect, MiniMap, Control] + // 预览模式也需要 DynamicGroup,避免包含 dynamic-group 节点的图在只读渲染时报错 + 'render-only': [DynamicGroup, Snapshot], + interactive: [DynamicGroup, Menu, Label, Snapshot, SelectionSelect, MiniMap, Control] } export function getFlowPluginsByCapability(capability: FlowCapabilityLevel): FlowPlugin[] { @@ -60,4 +61,3 @@ export function registerFlowNodes(lfInstance: LogicFlow, nodes?: FlowNodeRegistr const registrations = resolveFlowNodes(nodes) registrations.forEach((registration) => register(registration, lfInstance)) } - diff --git a/src/index.js b/src/index.js index 8c659ac..f471e08 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,15 @@ import 'element-plus/dist/index.css' import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css' import YysEditorEmbed from './YysEditorEmbed.vue' export { setAssetBaseUrl, getAssetBaseUrl, resolveAssetUrl } from './utils/assetUrl' +export { DEFAULT_GROUP_RULES_CONFIG } from './configs/groupRules' +export { validateGraphGroupRules } from './utils/groupRules' +export { + GROUP_RULES_STORAGE_KEY, + readSharedGroupRulesConfig, + writeSharedGroupRulesConfig, + clearSharedGroupRulesConfig +} from './utils/groupRulesConfigSource' +export { CUSTOM_ASSET_STORAGE_KEY } from './utils/customAssets' // 导出组件 export { YysEditorEmbed } diff --git a/src/ts/schema.ts b/src/ts/schema.ts index abdb0e3..4572e36 100644 --- a/src/ts/schema.ts +++ b/src/ts/schema.ts @@ -56,6 +56,22 @@ export interface NodeMeta { export interface NodeProperties { style: NodeStyle; meta?: NodeMeta; + children?: string[]; + groupMeta?: { + version: number; + groupKind: 'team' | 'shikigami'; + groupName: string; + ruleEnabled: boolean; + ruleScope: string[]; + }; + assetLibrary?: string; + selectedAsset?: { + assetId: string; + library: string; + name?: string; + avatar?: string; + [key: string]: any; + } | null; image?: { url: string; fit?: 'fill'|'contain'|'cover' }; text?: { content: string; rich?: boolean }; vector?: { @@ -81,6 +97,7 @@ export interface GraphNode { y?: number; width?: number; height?: number; + children?: string[]; properties: NodeProperties; } diff --git a/src/ts/useStore.ts b/src/ts/useStore.ts index a40233e..614e14f 100644 --- a/src/ts/useStore.ts +++ b/src/ts/useStore.ts @@ -5,6 +5,7 @@ import {ElMessageBox} from "element-plus"; import {useGlobalMessage} from "./useGlobalMessage"; import {getLogicFlowInstance} from "./useLogicFlow"; import {CURRENT_SCHEMA_VERSION, migrateToV1, RootDocument} from "./schema"; +import { normalizeGraphRawDataSchema } from '@/utils/graphSchema'; const {showMessage} = useGlobalMessage(); @@ -117,7 +118,7 @@ export const useFilesStore = defineStore('files', () => { name: f?.name ?? f?.label ?? `File ${i + 1}`, visible: f?.visible ?? true, type: f?.type ?? 'FLOW', - graphRawData: (f?.graphRawData && typeof f.graphRawData === 'object') ? f.graphRawData : { nodes: [], edges: [] }, + graphRawData: normalizeGraphRawDataSchema(f?.graphRawData), transform: f?.transform ?? { SCALE_X: 1, SCALE_Y: 1, @@ -333,11 +334,12 @@ export const useFilesStore = defineStore('files', () => { }; }) }; + const normalizedGraphData = normalizeGraphRawDataSchema(enrichedGraphData); // 直接保存原始数据到 GraphRawData const file = findById(targetId); if (file) { - file.graphRawData = enrichedGraphData; + file.graphRawData = normalizedGraphData; file.transform = transform; } } diff --git a/src/types/nodeTypes.ts b/src/types/nodeTypes.ts index 588e059..3fa3484 100644 --- a/src/types/nodeTypes.ts +++ b/src/types/nodeTypes.ts @@ -13,6 +13,7 @@ export enum NodeType { // 布局容器类 RECT = 'rect', ELLIPSE = 'ellipse', + DYNAMIC_GROUP = 'dynamic-group', // 图形资产类(统一入口,内部切换资产库) ASSET_SELECTOR = 'assetSelector', diff --git a/src/types/selector.ts b/src/types/selector.ts index 1127134..afc4603 100644 --- a/src/types/selector.ts +++ b/src/types/selector.ts @@ -20,4 +20,8 @@ export interface SelectorConfig { searchable?: boolean searchFields?: string[] currentItem?: T | null -} \ No newline at end of file + assetLibrary?: string + allowUserAssetUpload?: boolean + onDeleteUserAsset?: (item: T) => void + onUserAssetUploaded?: (item: T) => void +} diff --git a/src/utils/customAssets.ts b/src/utils/customAssets.ts new file mode 100644 index 0000000..66059eb --- /dev/null +++ b/src/utils/customAssets.ts @@ -0,0 +1,226 @@ +export const CUSTOM_ASSET_STORAGE_KEY = 'yys-editor.custom-assets.v1' +const CUSTOM_ASSET_UPDATED_EVENT = 'yys-editor.custom-assets.updated' + +export type CustomAssetItem = { + id: string + name: string + avatar: string + library: string + __userAsset: true + createdAt: string +} + +type CustomAssetStore = Record + +const isClient = () => typeof window !== 'undefined' && typeof localStorage !== 'undefined' +const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +const normalizeLibraryKey = (library: string): string => normalizeText(library).toLowerCase() + +const createLegacyAssetId = (library: string, name: string, avatar: string): string => { + const seed = `${library}|${name}|${avatar}` + let hash = 0 + for (let index = 0; index < seed.length; index += 1) { + hash = ((hash << 5) - hash) + seed.charCodeAt(index) + hash |= 0 + } + return `custom_legacy_${Math.abs(hash).toString(36)}` +} + +const buildCustomAssetId = (): string => { + return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` +} + +const normalizeAssetItem = (library: string, input: unknown): CustomAssetItem | null => { + if (!input || typeof input !== 'object') { + return null + } + + const raw = input as Record + const name = normalizeText(raw.name) || '用户素材' + const avatar = normalizeText(raw.avatar) + if (!avatar) { + return null + } + + const normalizedLibrary = normalizeLibraryKey(library) + const id = normalizeText(raw.id) || createLegacyAssetId(normalizedLibrary, name, avatar) + const createdAt = normalizeText(raw.createdAt) || '1970-01-01T00:00:00.000Z' + + return { + id, + name, + avatar, + library: normalizedLibrary, + __userAsset: true, + createdAt + } +} + +const normalizeStore = (input: unknown): CustomAssetStore => { + if (!input || typeof input !== 'object') { + return {} + } + + const parsed = input as Record + const normalizedStore: CustomAssetStore = {} + Object.entries(parsed).forEach(([library, assets]) => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary || !Array.isArray(assets)) { + return + } + const normalizedAssets = assets + .map((item) => normalizeAssetItem(normalizedLibrary, item)) + .filter((item): item is CustomAssetItem => !!item) + if (normalizedAssets.length > 0) { + normalizedStore[normalizedLibrary] = normalizedAssets + } + }) + + return normalizedStore +} + +const notifyStoreUpdated = () => { + if (!isClient()) { + return + } + window.dispatchEvent(new CustomEvent(CUSTOM_ASSET_UPDATED_EVENT)) +} + +const readStore = (): CustomAssetStore => { + if (!isClient()) { + return {} + } + const raw = localStorage.getItem(CUSTOM_ASSET_STORAGE_KEY) + if (!raw) { + return {} + } + try { + const parsed = JSON.parse(raw) + const normalized = normalizeStore(parsed) + const normalizedRaw = JSON.stringify(normalized) + if (normalizedRaw !== raw) { + localStorage.setItem(CUSTOM_ASSET_STORAGE_KEY, normalizedRaw) + } + return normalized + } catch { + // ignore + } + return {} +} + +const writeStore = (store: CustomAssetStore) => { + if (!isClient()) { + return + } + localStorage.setItem(CUSTOM_ASSET_STORAGE_KEY, JSON.stringify(store)) + notifyStoreUpdated() +} + +const normalizeFileName = (fileName: string): string => { + const stripped = fileName.replace(/\.[a-z0-9]+$/i, '') + return stripped.trim() || '用户素材' +} + +const readFileAsDataUrl = (file: File): Promise => new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '') + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(file) +}) + +export const listCustomAssets = (library: string): CustomAssetItem[] => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary) { + return [] + } + const store = readStore() + return Array.isArray(store[normalizedLibrary]) ? store[normalizedLibrary] : [] +} + +export const saveCustomAsset = (library: string, asset: CustomAssetItem) => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary) { + return + } + const store = readStore() + const normalizedAsset = normalizeAssetItem(normalizedLibrary, asset) + if (!normalizedAsset) { + return + } + const assets = Array.isArray(store[normalizedLibrary]) ? store[normalizedLibrary] : [] + const dedupedAssets = assets.filter((item) => ( + item.id !== normalizedAsset.id + && !(item.avatar === normalizedAsset.avatar && item.name === normalizedAsset.name) + )) + store[normalizedLibrary] = [normalizedAsset, ...dedupedAssets] + writeStore(store) +} + +export const deleteCustomAsset = ( + library: string, + assetRef: string | Pick, 'id' | 'name' | 'avatar'> +) => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary) { + return + } + const store = readStore() + const assets = Array.isArray(store[normalizedLibrary]) ? store[normalizedLibrary] : [] + const targetId = typeof assetRef === 'string' ? normalizeText(assetRef) : normalizeText(assetRef?.id) + const targetAvatar = typeof assetRef === 'string' ? '' : normalizeText(assetRef?.avatar) + const targetName = typeof assetRef === 'string' ? '' : normalizeText(assetRef?.name) + + store[normalizedLibrary] = assets.filter((item) => { + if (targetId && item.id === targetId) { + return false + } + if (!targetAvatar) { + return true + } + if (item.avatar !== targetAvatar) { + return true + } + if (!targetName) { + return false + } + return item.name !== targetName + }) + writeStore(store) +} + +export const createCustomAssetFromFile = async (library: string, file: File): Promise => { + const avatar = await readFileAsDataUrl(file) + const now = new Date().toISOString() + const normalizedLibrary = normalizeLibraryKey(library) + const id = buildCustomAssetId() + const asset: CustomAssetItem = { + id, + name: normalizeFileName(file.name), + avatar, + library: normalizedLibrary, + __userAsset: true, + createdAt: now + } + saveCustomAsset(normalizedLibrary, asset) + return asset +} + +export const subscribeCustomAssetStore = (listener: () => void): (() => void) => { + if (!isClient()) { + return () => {} + } + const handleStorage = (event: StorageEvent) => { + if (event.key === CUSTOM_ASSET_STORAGE_KEY) { + listener() + } + } + const handleLocalUpdate = () => { + listener() + } + window.addEventListener('storage', handleStorage) + window.addEventListener(CUSTOM_ASSET_UPDATED_EVENT, handleLocalUpdate) + return () => { + window.removeEventListener('storage', handleStorage) + window.removeEventListener(CUSTOM_ASSET_UPDATED_EVENT, handleLocalUpdate) + } +} diff --git a/src/utils/graphSchema.ts b/src/utils/graphSchema.ts new file mode 100644 index 0000000..0abbeee --- /dev/null +++ b/src/utils/graphSchema.ts @@ -0,0 +1,155 @@ +export const GROUP_META_VERSION = 1 +export const DEFAULT_GROUP_RULE_SCOPE = ['shikigami-yuhun', 'shikigami-shikigami'] + +export type GroupKind = 'team' | 'shikigami' + +export type DynamicGroupMeta = { + version: number + groupKind: GroupKind + groupName: string + ruleEnabled: boolean + ruleScope: string[] +} + +const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +const normalizeLibrary = (value: unknown): string => normalizeText(value).toLowerCase() + +const inferLibraryFromAvatar = (avatar: string): string => { + if (!avatar) return '' + if (avatar.includes('/Yuhun/')) return 'yuhun' + if (avatar.includes('/Shikigami/')) return 'shikigami' + if (avatar.includes('/hero_')) return 'onmyoji' + return '' +} + +const createStableHash = (seed: string): string => { + let hash = 0 + for (let index = 0; index < seed.length; index += 1) { + hash = ((hash << 5) - hash) + seed.charCodeAt(index) + hash |= 0 + } + return Math.abs(hash).toString(36) +} + +const normalizeStringList = (value: unknown, fallback: string[]): string[] => { + if (!Array.isArray(value)) { + return [...fallback] + } + const normalized = value + .map((item) => normalizeText(item)) + .filter((item) => !!item) + return normalized.length ? Array.from(new Set(normalized)) : [...fallback] +} + +export const normalizeDynamicGroupMeta = (input: unknown, fallbackKind: GroupKind = 'team'): DynamicGroupMeta => { + const raw = input && typeof input === 'object' ? input as Record : {} + const versionCandidate = Number(raw.version) + const version = Number.isFinite(versionCandidate) && versionCandidate > 0 + ? Math.trunc(versionCandidate) + : GROUP_META_VERSION + const groupKind: GroupKind = raw.groupKind === 'shikigami' ? 'shikigami' : fallbackKind + const groupName = normalizeText(raw.groupName) + const ruleEnabled = raw.ruleEnabled !== false + const ruleScope = normalizeStringList(raw.ruleScope, DEFAULT_GROUP_RULE_SCOPE) + + return { + version, + groupKind, + groupName, + ruleEnabled, + ruleScope + } +} + +const normalizeChildren = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return [] + } + return Array.from( + new Set( + value + .map((item) => normalizeText(item)) + .filter((item) => !!item) + ) + ) +} + +export const getDynamicGroupChildIds = (node: any): string[] => { + const nodeChildren = normalizeChildren(node?.children) + const propertyChildren = normalizeChildren(node?.properties?.children) + return nodeChildren.length ? nodeChildren : propertyChildren +} + +export const normalizeSelectedAssetRecord = (input: unknown, preferredLibrary = ''): Record | null => { + if (!input || typeof input !== 'object') { + return null + } + + const raw = input as Record + const name = normalizeText(raw.name) + const avatar = normalizeText(raw.avatar) + const library = normalizeLibrary(raw.library) || normalizeLibrary(preferredLibrary) || inferLibraryFromAvatar(avatar) + const sourceId = normalizeText(raw.assetId) + || normalizeText(raw.id) + || normalizeText(raw.skillId) + || normalizeText(raw.onmyojiId) + const identitySeed = sourceId || `${name}|${avatar}|${library}` + const assetId = sourceId + ? `${library || 'asset'}:${sourceId}` + : `asset_${createStableHash(identitySeed || String(Date.now()))}` + + return { + ...raw, + ...(name ? { name } : {}), + ...(avatar ? { avatar } : {}), + library: library || 'shikigami', + assetId + } +} + +export const normalizeGraphRawDataSchema = (graphData: any): { nodes: any[]; edges: any[] } => { + const rawNodes = Array.isArray(graphData?.nodes) ? graphData.nodes : [] + const rawEdges = Array.isArray(graphData?.edges) ? graphData.edges : [] + + const nodes = rawNodes.map((node: any) => { + const properties = node?.properties && typeof node.properties === 'object' + ? { ...node.properties } + : {} + + if (node?.type === 'dynamic-group') { + const children = getDynamicGroupChildIds(node) + return { + ...node, + children, + properties: { + ...properties, + children, + groupMeta: normalizeDynamicGroupMeta(properties.groupMeta) + } + } + } + + if (node?.type === 'assetSelector') { + const currentLibrary = normalizeLibrary(properties.assetLibrary) || 'shikigami' + const selectedAsset = normalizeSelectedAssetRecord(properties.selectedAsset, currentLibrary) + return { + ...node, + properties: { + ...properties, + assetLibrary: selectedAsset?.library || currentLibrary, + selectedAsset + } + } + } + + return { + ...node, + properties + } + }) + + return { + nodes, + edges: rawEdges + } +} diff --git a/src/utils/groupRules.ts b/src/utils/groupRules.ts new file mode 100644 index 0000000..5c9e3a8 --- /dev/null +++ b/src/utils/groupRules.ts @@ -0,0 +1,276 @@ +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[] + edges: any[] +} + +type TeamAsset = { + nodeId: string + assetId: string + name: string + library: string +} + +type TeamAssetSnapshot = { + groupId: string + groupName: string + nodeIds: string[] + shikigamiAssets: TeamAsset[] + yuhunAssets: TeamAsset[] +} + +export type GroupRuleWarning = { + id: string + ruleId: string + code: string + severity: 'warning' | 'error' | 'info' + groupId: string + groupName?: string + message: 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 + } +} + +const normalizeText = (value: unknown): string => { + if (typeof value !== 'string') { + return '' + } + return value.trim() +} + +const isAssetSelectorNode = (node: any): boolean => { + return !!node && node.type === 'assetSelector' +} + +const isDynamicGroupNode = (node: any): boolean => { + return !!node && node.type === 'dynamic-group' +} + +const inferLibrary = (node: any): string => { + const assetLibrary = normalizeText(node?.properties?.assetLibrary).toLowerCase() + if (assetLibrary) { + return assetLibrary + } + + const selectedLibrary = normalizeText(node?.properties?.selectedAsset?.library).toLowerCase() + if (selectedLibrary) { + return selectedLibrary + } + + const avatar = normalizeText(node?.properties?.selectedAsset?.avatar) + if (avatar.includes('/Yuhun/')) { + return 'yuhun' + } + if (avatar.includes('/Shikigami/')) { + return 'shikigami' + } + return '' +} + +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 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 : [] + const nodeMap = new Map() + nodes.forEach((node) => { + const nodeId = normalizeText(node?.id) + if (!nodeId) return + nodeMap.set(nodeId, node) + }) + + const teamGroups = nodes.filter((node) => { + if (!isDynamicGroupNode(node)) return false + const groupKind = normalizeText(node?.properties?.groupMeta?.groupKind) + const ruleEnabled = node?.properties?.groupMeta?.ruleEnabled !== false + return groupKind === 'team' && ruleEnabled + }) + + return teamGroups.map((teamNode) => { + const teamId = normalizeText(teamNode?.id) || 'unknown-team' + const teamName = normalizeText(teamNode?.properties?.groupMeta?.groupName) + const queue = [...getDynamicGroupChildIds(teamNode)] + const visited = new Set() + const shikigamiAssets: TeamAsset[] = [] + const yuhunAssets: TeamAsset[] = [] + + while (queue.length > 0) { + const currentId = queue.shift() as string + if (!currentId || visited.has(currentId)) { + continue + } + visited.add(currentId) + + const node = nodeMap.get(currentId) + if (!node) { + continue + } + + if (isDynamicGroupNode(node)) { + const childKind = normalizeText(node?.properties?.groupMeta?.groupKind) + if (childKind === 'team') { + continue + } + queue.push(...getDynamicGroupChildIds(node)) + continue + } + + if (!isAssetSelectorNode(node)) { + continue + } + + const library = inferLibrary(node) + const name = normalizeText(node?.properties?.selectedAsset?.name) + const assetId = normalizeText(node?.properties?.selectedAsset?.assetId) + if (!name) { + continue + } + + const asset: TeamAsset = { + nodeId: normalizeText(node?.id), + assetId, + name, + library + } + + if (library === 'shikigami') { + shikigamiAssets.push(asset) + } else if (library === 'yuhun') { + yuhunAssets.push(asset) + } + } + + return { + groupId: teamId, + groupName: teamName, + nodeIds: dedupeNodeIds([ + ...shikigamiAssets.map((item) => item.nodeId), + ...yuhunAssets.map((item) => item.nodeId) + ]), + shikigamiAssets, + yuhunAssets + } + }) +} + +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 +): GroupRuleWarning[] => { + const effectiveConfig = config || readSharedGroupRulesConfig() + const normalizedGraphData = normalizeGraphRawDataSchema(graphData) + const teams = collectTeamAssetSnapshots(normalizedGraphData) + const warnings: GroupRuleWarning[] = [] + + teams.forEach((team) => { + const scope = createTeamScope(team, effectiveConfig) + + 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 + }) + }) + }) + + return warnings +} diff --git a/src/utils/groupRulesConfigSource.ts b/src/utils/groupRulesConfigSource.ts new file mode 100644 index 0000000..e5158ba --- /dev/null +++ b/src/utils/groupRulesConfigSource.ts @@ -0,0 +1,232 @@ +import { + DEFAULT_GROUP_RULES_CONFIG, + type ExpressionRuleDefinition, + type GroupRulesConfig, + type RuleVariableDefinition, + type ShikigamiConflictRule, + type ShikigamiYuhunBlacklistRule +} from '@/configs/groupRules' + +export const GROUP_RULES_STORAGE_KEY = 'yys-editor.group-rules.v1' +const GROUP_RULES_UPDATED_EVENT = 'yys-editor.group-rules.updated' + +const isClient = () => typeof window !== 'undefined' && typeof localStorage !== 'undefined' +const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +const notifyGroupRulesUpdated = () => { + if (!isClient()) { + return + } + window.dispatchEvent(new CustomEvent(GROUP_RULES_UPDATED_EVENT)) +} + +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 })), + 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[] => { + if (!Array.isArray(value)) { + return [...fallback] + } + return value + .map((item) => normalizeText(item)) + .filter((item) => !!item) +} + +const normalizeBlacklistRules = ( + value: unknown, + fallback: ShikigamiYuhunBlacklistRule[] +): ShikigamiYuhunBlacklistRule[] => { + 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 shikigami = normalizeText(raw.shikigami) + const yuhun = normalizeText(raw.yuhun) + if (!shikigami || !yuhun) { + return null + } + const message = normalizeText(raw.message) + return { + shikigami, + yuhun, + ...(message ? { message } : {}) + } + }) + .filter((item): item is ShikigamiYuhunBlacklistRule => !!item) +} + +const normalizeConflictRules = ( + value: unknown, + fallback: ShikigamiConflictRule[] +): ShikigamiConflictRule[] => { + 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 left = normalizeText(raw.left) + const right = normalizeText(raw.right) + if (!left || !right) { + return null + } + const message = normalizeText(raw.message) + return { + left, + right, + ...(message ? { message } : {}) + } + }) + .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 + } + + const raw = input as Record + const fallback = cloneDefaultGroupRulesConfig() + const versionCandidate = Number(raw.version) + const version = Number.isFinite(versionCandidate) && versionCandidate > 0 + ? Math.trunc(versionCandidate) + : fallback.version + + return { + version, + fireShikigamiWhitelist: normalizeStringList(raw.fireShikigamiWhitelist, fallback.fireShikigamiWhitelist), + shikigamiYuhunBlacklist: normalizeBlacklistRules(raw.shikigamiYuhunBlacklist, fallback.shikigamiYuhunBlacklist), + shikigamiConflictPairs: normalizeConflictRules(raw.shikigamiConflictPairs, fallback.shikigamiConflictPairs), + expressionRules: normalizeExpressionRules(raw.expressionRules, fallback.expressionRules), + ruleVariables: normalizeRuleVariables(raw.ruleVariables, fallback.ruleVariables) + } +} + +export const readSharedGroupRulesConfig = (): GroupRulesConfig => { + const fallback = cloneDefaultGroupRulesConfig() + if (!isClient()) { + return fallback + } + + const raw = localStorage.getItem(GROUP_RULES_STORAGE_KEY) + if (!raw) { + return fallback + } + + try { + const parsed = JSON.parse(raw) + return normalizeGroupRulesConfig(parsed) || fallback + } catch { + return fallback + } +} + +export const writeSharedGroupRulesConfig = (config: unknown): GroupRulesConfig => { + const normalized = normalizeGroupRulesConfig(config) || cloneDefaultGroupRulesConfig() + if (isClient()) { + localStorage.setItem(GROUP_RULES_STORAGE_KEY, JSON.stringify(normalized)) + notifyGroupRulesUpdated() + } + return normalized +} + +export const clearSharedGroupRulesConfig = () => { + if (!isClient()) { + return + } + localStorage.removeItem(GROUP_RULES_STORAGE_KEY) + notifyGroupRulesUpdated() +} + +export const subscribeSharedGroupRulesConfig = (listener: () => void): (() => void) => { + if (!isClient()) { + return () => {} + } + const handleStorage = (event: StorageEvent) => { + if (event.key === GROUP_RULES_STORAGE_KEY) { + listener() + } + } + const handleLocalUpdate = () => { + listener() + } + + window.addEventListener('storage', handleStorage) + window.addEventListener(GROUP_RULES_UPDATED_EVENT, handleLocalUpdate) + + return () => { + window.removeEventListener('storage', handleStorage) + window.removeEventListener(GROUP_RULES_UPDATED_EVENT, handleLocalUpdate) + } +} 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) +} diff --git a/vite.config.js b/vite.config.js index 5c45e1d..aba131c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,8 +3,15 @@ import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +const normalizeBase = (value) => { + if (!value || value === '/') return '/' + const withLeadingSlash = value.startsWith('/') ? value : `/${value}` + return withLeadingSlash.endsWith('/') ? withLeadingSlash : `${withLeadingSlash}/` +} + // https://vitejs.dev/config/ export default defineConfig({ + base: normalizeBase(process.env.VITE_APP_BASE_URL || '/'), plugins: [ vue(), ],