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 }}
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+ 新增规则
+ 新增变量
+ 导出规则变量
+ 导入规则变量
+ 重载当前配置
+ 应用并生效
+ 恢复默认
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.condition }}
+
+
+
+
+ {{ row.message }}
+
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
作用域约定
+
{{ 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(),
],