mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-03-05 15:05:27 +00:00
Merge pull request #15 from Powerful-517/wip/editor-17req-20260226
Wip/editor 17req 20260226
This commit is contained in:
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -17,10 +17,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install dependencies and build
|
- name: Install dependencies and build web app
|
||||||
run: |
|
run: |
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
VITE_APP_BASE_URL=/yys-editor/ npm run build:app
|
||||||
|
|
||||||
- name: Deploy to temporary directory
|
- name: Deploy to temporary directory
|
||||||
uses: appleboy/scp-action@v0.1.6
|
uses: appleboy/scp-action@v0.1.6
|
||||||
|
|||||||
4
.github/workflows/deploy_dev.yml
vendored
4
.github/workflows/deploy_dev.yml
vendored
@@ -18,10 +18,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install dependencies and build
|
- name: Install dependencies and build web app
|
||||||
run: |
|
run: |
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
VITE_APP_BASE_URL=/yys-editor-dev/ npm run build:app
|
||||||
|
|
||||||
- name: Deploy to temporary directory
|
- name: Deploy to temporary directory
|
||||||
uses: appleboy/scp-action@v0.1.6
|
uses: appleboy/scp-action@v0.1.6
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
**技术栈:** Vue 3 + LogicFlow + Element Plus + Pinia
|
**技术栈:** Vue 3 + LogicFlow + Element Plus + Pinia
|
||||||
**目标:** 作为独立编辑器和可嵌入组件,支持在 onmyoji-wiki 中作为块插件使用
|
**目标:** 作为独立编辑器和可嵌入组件,支持在 onmyoji-wiki 中作为块插件使用
|
||||||
|
|
||||||
**当前状态:** ✅ 阶段 1 完成(独立编辑器)+ ✅ 阶段 2 完成(组件化改造)
|
**当前状态:** ✅ 阶段 1 完成(独立编辑器)+ ✅ 阶段 2 完成(组件化改造)+ 🔄 阶段 3 进行中(wiki 集成稳定化)
|
||||||
**总体完成度:** 100%(核心功能)
|
**总体完成度:** 93%(核心功能完成,集成与质量收尾中)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
| 🎨 画布(LogicFlow) | 100% | ✅ 完美 | 无 |
|
| 🎨 画布(LogicFlow) | 100% | ✅ 完美 | 无 |
|
||||||
| 📦 左侧组件库 | 75% | ✅ 可用 | 缩略图、搜索 |
|
| 📦 左侧组件库 | 75% | ✅ 可用 | 缩略图、搜索 |
|
||||||
| ⚙️ 右侧属性面板 | 100% | ✅ 完美 | 无 |
|
| ⚙️ 右侧属性面板 | 100% | ✅ 完美 | 无 |
|
||||||
| 🔧 工具栏 | 85% | ✅ 良好 | 导出命名优化 |
|
| 🔧 工具栏 | 90% | ✅ 良好 | 导出命名优化 |
|
||||||
| 💬 弹窗系统 | 75% | ✅ 可用 | i18n完善、性能优化 |
|
| 💬 弹窗系统 | 75% | ✅ 可用 | i18n完善、性能优化 |
|
||||||
| 💾 状态与持久化 | 90% | ✅ 优秀 | 重命名UI |
|
| 💾 状态与持久化 | 90% | ✅ 优秀 | 重命名UI |
|
||||||
| 🌐 数据与国际化 | 60% | ⚠️ 基础 | UTF-8统一、日文覆盖 |
|
| 🌐 数据与国际化 | 60% | ⚠️ 基础 | UTF-8统一、日文覆盖 |
|
||||||
@@ -206,30 +206,31 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🎨 阶段 3:wiki 集成测试(待开发)
|
### 🎨 阶段 3:wiki 集成测试(进行中)
|
||||||
|
|
||||||
**目标:** 在 onmyoji-wiki 中测试集成效果
|
**目标:** 在 onmyoji-wiki 中测试集成效果
|
||||||
|
|
||||||
#### 步骤 5:本地引用测试(1-2 天)
|
#### 步骤 5:本地引用测试(1-2 天)
|
||||||
|
|
||||||
- [ ] 在 wiki 中引用 yys-editor(file: 方式)
|
- [x] 在 wiki 中引用 yys-editor(file: 方式)
|
||||||
- [ ] 创建 YysEditorBlock 组件
|
- [x] 创建集成包装层(当前以 `/editor` 页面集成替代独立 `YysEditorBlock` 组件)
|
||||||
- [ ] 测试预览模式
|
- [x] 测试预览模式
|
||||||
- [ ] 测试编辑模式
|
- [x] 测试编辑模式
|
||||||
- [ ] 测试数据保存
|
- [x] 测试数据保存
|
||||||
|
|
||||||
#### 步骤 6:交互优化(2-3 天)
|
#### 步骤 6:交互优化(2-3 天)
|
||||||
|
|
||||||
- [ ] 优化模式切换体验
|
- [x] 优化模式切换体验
|
||||||
- [ ] 优化数据同步
|
- [x] 优化数据同步
|
||||||
- [ ] 优化错误处理
|
- [x] 优化错误处理
|
||||||
|
- [x] 新增顶部“素材管理”入口并统一素材分类来源(与资产选择器一致)
|
||||||
- [ ] 优化加载性能
|
- [ ] 优化加载性能
|
||||||
|
|
||||||
**验收标准:**
|
**验收标准:**
|
||||||
- 在 wiki 中可以正常使用
|
- 在 wiki 中可以正常使用(已达成)
|
||||||
- 预览/编辑切换流畅
|
- 预览/编辑切换流畅(已达成)
|
||||||
- 数据保存正确
|
- 数据保存正确(已达成)
|
||||||
- 体验类似 Notion 块
|
- 体验类似 Notion 块(进行中,持续优化)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -301,12 +302,12 @@ wiki 文档
|
|||||||
|
|
||||||
**完成时间:** 2026-02-20
|
**完成时间:** 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
|
### 2026-02-25
|
||||||
- ✅ 修复嵌入编辑器在 onmyoji-wiki 弹层中的初始化尺寸异常
|
- ✅ 修复嵌入编辑器在 onmyoji-wiki 弹层中的初始化尺寸异常
|
||||||
- 编辑区域高度改为基于容器测量后计算
|
- 编辑区域高度改为基于容器测量后计算
|
||||||
@@ -453,6 +464,7 @@ const handleCancel = () => {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**最后更新:** 2026-02-20
|
**最后更新:** 2026-02-27
|
||||||
|
**文档版本:** v2.2.1(wiki 集成稳定化进行中)
|
||||||
**文档版本:** v2.1.0(组件化改造完成)
|
**文档版本:** v2.1.0(组件化改造完成)
|
||||||
**文档版本:** v2.0.0(重新规划)
|
**文档版本:** v2.0.0(重新规划)
|
||||||
|
|||||||
6
docs/test/README.md
Normal file
6
docs/test/README.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 测试索引(yys-editor)
|
||||||
|
|
||||||
|
本目录用于记录 yys-editor 的人工验收测试点(不包含自动化测试)。
|
||||||
|
|
||||||
|
- 主验收清单:`docs/test/acceptance.md`
|
||||||
|
|
||||||
222
docs/test/acceptance.md
Normal file
222
docs/test/acceptance.md
Normal file
@@ -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:已通过
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "yys-editor",
|
"name": "yys-editor",
|
||||||
"version": "1.0.4",
|
"version": "1.0.6",
|
||||||
"description": "阴阳师流程图编辑器 - 可嵌入式组件",
|
"description": "阴阳师流程图编辑器 - 可嵌入式组件",
|
||||||
"author": "yys-editor team",
|
"author": "yys-editor team",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ watch(
|
|||||||
<div id="main-container" :style="{ height: contentHeight, overflow: 'auto' }">
|
<div id="main-container" :style="{ height: contentHeight, overflow: 'auto' }">
|
||||||
<FlowEditor
|
<FlowEditor
|
||||||
:height="contentHeight"
|
:height="contentHeight"
|
||||||
|
:enable-label="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,12 +111,15 @@ const sanitizeLabelProperty = (properties: unknown): Record<string, any> | undef
|
|||||||
return nextProperties
|
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)) {
|
if (!input || !Array.isArray(input.nodes) || !Array.isArray(input.edges)) {
|
||||||
return { nodes: [], edges: [] }
|
return { nodes: [], edges: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = input.nodes
|
const rawNodes = input.nodes
|
||||||
.filter((node): node is NodeData => isPlainObject(node))
|
.filter((node): node is NodeData => isPlainObject(node))
|
||||||
.map((node) => {
|
.map((node) => {
|
||||||
const nextNode: NodeData = { ...node }
|
const nextNode: NodeData = { ...node }
|
||||||
@@ -127,6 +130,12 @@ const sanitizeGraphData = (input?: GraphData | null): GraphData => {
|
|||||||
return nextNode
|
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
|
const edges = input.edges
|
||||||
.filter((edge): edge is EdgeData => isPlainObject(edge))
|
.filter((edge): edge is EdgeData => isPlainObject(edge))
|
||||||
.map((edge) => {
|
.map((edge) => {
|
||||||
@@ -137,6 +146,7 @@ const sanitizeGraphData = (input?: GraphData | null): GraphData => {
|
|||||||
}
|
}
|
||||||
return nextEdge
|
return nextEdge
|
||||||
})
|
})
|
||||||
|
.filter((edge) => !hiddenDynamicGroup || (nodeIdSet.has(edge.sourceNodeId) && nodeIdSet.has(edge.targetNodeId)))
|
||||||
|
|
||||||
return { nodes, edges }
|
return { nodes, edges }
|
||||||
}
|
}
|
||||||
@@ -328,7 +338,7 @@ const initPreviewMode = () => {
|
|||||||
|
|
||||||
// 渲染数据
|
// 渲染数据
|
||||||
if (props.data) {
|
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 setGraphData = (data: GraphData) => {
|
||||||
const safeData = sanitizeGraphData(data)
|
const safeData = sanitizeGraphData(data, { hideDynamicGroups: props.mode === 'preview' })
|
||||||
if (props.mode === 'edit') {
|
if (props.mode === 'edit') {
|
||||||
const lfInstance = getLogicFlowInstance()
|
const lfInstance = getLogicFlowInstance()
|
||||||
if (lfInstance) {
|
if (lfInstance) {
|
||||||
|
|||||||
151
src/__tests__/groupRules.expression.test.ts
Normal file
151
src/__tests__/groupRules.expression.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { validateGraphGroupRules } from '@/utils/groupRules'
|
||||||
|
import { DEFAULT_GROUP_RULES_CONFIG } from '@/configs/groupRules'
|
||||||
|
|
||||||
|
const baseGraph = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'team-1',
|
||||||
|
type: 'dynamic-group',
|
||||||
|
children: ['s1', 's2'],
|
||||||
|
properties: {
|
||||||
|
children: ['s1', 's2'],
|
||||||
|
groupMeta: {
|
||||||
|
groupKind: 'team',
|
||||||
|
groupName: '一队',
|
||||||
|
ruleEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
type: 'assetSelector',
|
||||||
|
properties: {
|
||||||
|
assetLibrary: 'shikigami',
|
||||||
|
selectedAsset: { assetId: 'a1', name: '辉夜姬', library: 'shikigami' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2',
|
||||||
|
type: 'assetSelector',
|
||||||
|
properties: {
|
||||||
|
assetLibrary: 'shikigami',
|
||||||
|
selectedAsset: { assetId: 'a2', name: '千姬', library: 'shikigami' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
edges: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphWithoutFireShikigami = {
|
||||||
|
...baseGraph,
|
||||||
|
nodes: baseGraph.nodes.map((node) => {
|
||||||
|
if (node.id === 's1') {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
properties: {
|
||||||
|
...node.properties,
|
||||||
|
selectedAsset: { assetId: 'a1', name: '阿修罗', library: 'shikigami' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.id === 's2') {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
properties: {
|
||||||
|
...node.properties,
|
||||||
|
selectedAsset: { assetId: 'a2', name: '不知火', library: 'shikigami' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphWithKaguyaPoshi = {
|
||||||
|
...baseGraph,
|
||||||
|
nodes: [
|
||||||
|
...baseGraph.nodes,
|
||||||
|
{
|
||||||
|
id: 'y1',
|
||||||
|
type: 'assetSelector',
|
||||||
|
properties: {
|
||||||
|
assetLibrary: 'yuhun',
|
||||||
|
selectedAsset: { assetId: 'y1', name: '破势', library: 'yuhun' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'y2',
|
||||||
|
type: 'assetSelector',
|
||||||
|
properties: {
|
||||||
|
assetLibrary: 'yuhun',
|
||||||
|
selectedAsset: { assetId: 'y2', name: '招财猫', library: 'yuhun' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].map((node) => {
|
||||||
|
if (node.id !== 'team-1') return node
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: ['s1', 's2', 'y1', 'y2'],
|
||||||
|
properties: {
|
||||||
|
...node.properties,
|
||||||
|
children: ['s1', 's2', 'y1', 'y2']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('groupRules expression integration', () => {
|
||||||
|
it('支持自定义 expressionRules 产出告警', () => {
|
||||||
|
const warnings = validateGraphGroupRules(baseGraph, {
|
||||||
|
...DEFAULT_GROUP_RULES_CONFIG,
|
||||||
|
shikigamiYuhunBlacklist: [],
|
||||||
|
shikigamiConflictPairs: [],
|
||||||
|
fireShikigamiWhitelist: ['座敷童子'],
|
||||||
|
ruleVariables: [
|
||||||
|
{
|
||||||
|
key: '供火式神',
|
||||||
|
value: '辉夜姬,座敷童子'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
expressionRules: [
|
||||||
|
{
|
||||||
|
id: 'team-has-kaguya',
|
||||||
|
condition: 'count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) > 0',
|
||||||
|
message: '命中:包含辉夜姬',
|
||||||
|
severity: 'info',
|
||||||
|
code: 'CUSTOM_HAS_KAGUYA'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const custom = warnings.find((item) => item.ruleId === 'team-has-kaguya')
|
||||||
|
expect(custom).toBeTruthy()
|
||||||
|
expect(custom?.severity).toBe('info')
|
||||||
|
expect(custom?.code).toBe('CUSTOM_HAS_KAGUYA')
|
||||||
|
expect(custom?.nodeIds).toEqual(['s1', 's2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('当 expressionRules 为空时不再产出 legacy 告警', () => {
|
||||||
|
const warnings = validateGraphGroupRules(baseGraph, {
|
||||||
|
...DEFAULT_GROUP_RULES_CONFIG,
|
||||||
|
shikigamiYuhunBlacklist: [],
|
||||||
|
shikigamiConflictPairs: [],
|
||||||
|
fireShikigamiWhitelist: ['座敷童子'],
|
||||||
|
ruleVariables: [],
|
||||||
|
expressionRules: []
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(warnings).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('默认预制规则可命中“辉夜姬不能带破势”', () => {
|
||||||
|
const warnings = validateGraphGroupRules(graphWithKaguyaPoshi, DEFAULT_GROUP_RULES_CONFIG)
|
||||||
|
expect(warnings.some((item) => item.code === 'TEAM_KAGUYA_POSHI_CONFLICT')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('默认预制规则可命中“队伍需要供火式神”', () => {
|
||||||
|
const warnings = validateGraphGroupRules(graphWithoutFireShikigami, DEFAULT_GROUP_RULES_CONFIG)
|
||||||
|
expect(warnings.some((item) => item.code === 'TEAM_MISSING_FIRE_SHIKIGAMI')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
36
src/__tests__/problemTarget.test.ts
Normal file
36
src/__tests__/problemTarget.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import type { GroupRuleWarning } from '@/utils/groupRules'
|
||||||
|
import { getProblemTargetCandidateIds } from '@/utils/problemTarget'
|
||||||
|
|
||||||
|
const createWarning = (overrides: Partial<GroupRuleWarning>): GroupRuleWarning => ({
|
||||||
|
id: 'w1',
|
||||||
|
ruleId: 'rule-1',
|
||||||
|
code: 'SHIKIGAMI_CONFLICT',
|
||||||
|
severity: 'warning',
|
||||||
|
groupId: 'team-1',
|
||||||
|
message: 'test',
|
||||||
|
nodeIds: ['node-a', 'node-b'],
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getProblemTargetCandidateIds', () => {
|
||||||
|
it('所有告警优先跳转到触发规则的 dynamic group', () => {
|
||||||
|
const warning = createWarning({
|
||||||
|
code: 'SHIKIGAMI_YUHUN_BLACKLIST',
|
||||||
|
nodeIds: ['shiki-1', 'yuhun-1'],
|
||||||
|
groupId: 'team-1'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getProblemTargetCandidateIds(warning)).toEqual(['team-1', 'shiki-1', 'yuhun-1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('队伍类告警优先跳转到队伍节点', () => {
|
||||||
|
const warning = createWarning({
|
||||||
|
code: 'MISSING_FIRE_SHIKIGAMI',
|
||||||
|
nodeIds: ['shiki-1', 'shiki-2'],
|
||||||
|
groupId: 'team-1'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getProblemTargetCandidateIds(warning)).toEqual(['team-1', 'shiki-1', 'shiki-2'])
|
||||||
|
})
|
||||||
|
})
|
||||||
73
src/__tests__/ruleExpression.test.ts
Normal file
73
src/__tests__/ruleExpression.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { evaluateRuleExpressionAsBoolean } from '@/utils/ruleExpression'
|
||||||
|
|
||||||
|
describe('ruleExpression', () => {
|
||||||
|
it('支持集合交集计数表达式', () => {
|
||||||
|
const scope = {
|
||||||
|
ctx: {
|
||||||
|
members: {
|
||||||
|
shikigamiNames: ['辉夜姬', '千姬']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shared: {
|
||||||
|
fireShikigamiWhitelist: ['辉夜姬', '座敷童子']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = evaluateRuleExpressionAsBoolean(
|
||||||
|
'count(intersect(ctx.members.shikigamiNames, shared.fireShikigamiWhitelist)) > 0',
|
||||||
|
scope
|
||||||
|
)
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('支持 map + contains 表达式', () => {
|
||||||
|
const scope = {
|
||||||
|
ctx: {
|
||||||
|
members: {
|
||||||
|
shikigami: [
|
||||||
|
{ name: '辉夜姬', role: 'fire' },
|
||||||
|
{ name: '千姬', role: 'support' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = evaluateRuleExpressionAsBoolean(
|
||||||
|
'contains(map(ctx.members.shikigami, "name"), "辉夜姬")',
|
||||||
|
scope
|
||||||
|
)
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('遇到非法函数名时抛错', () => {
|
||||||
|
expect(() => evaluateRuleExpressionAsBoolean('unknownFn(1, 2)', {})).toThrowError('不支持的函数调用')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('支持 getVar 从共享变量映射读取集合', () => {
|
||||||
|
const scope = {
|
||||||
|
shared: {
|
||||||
|
vars: {
|
||||||
|
火系式神: ['辉夜姬', '座敷童子']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = evaluateRuleExpressionAsBoolean(
|
||||||
|
'contains(getVar("火系式神"), "辉夜姬")',
|
||||||
|
scope
|
||||||
|
)
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getVar 仅允许传入变量 key', () => {
|
||||||
|
const scope = {
|
||||||
|
shared: {
|
||||||
|
vars: {
|
||||||
|
火系式神: ['辉夜姬']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(() => evaluateRuleExpressionAsBoolean('contains(getVar(shared.vars, "火系式神"), "辉夜姬")', scope))
|
||||||
|
.toThrowError('getVar 仅支持一个参数')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
<el-button icon="View" type="success" @click="handlePreviewData">数据预览</el-button>
|
<el-button icon="View" type="success" @click="handlePreviewData">数据预览</el-button>
|
||||||
<el-button icon="Share" type="primary" @click="prepareCapture">{{ t('prepareCapture') }}</el-button>
|
<el-button icon="Share" type="primary" @click="prepareCapture">{{ t('prepareCapture') }}</el-button>
|
||||||
<el-button icon="Setting" type="primary" @click="state.showWatermarkDialog = true">{{ t('setWatermark') }}</el-button>
|
<el-button icon="Setting" type="primary" @click="state.showWatermarkDialog = true">{{ t('setWatermark') }}</el-button>
|
||||||
|
<el-button icon="Picture" type="primary" plain @click="openAssetManager">素材管理</el-button>
|
||||||
|
<el-button icon="EditPen" type="primary" plain @click="openRuleManager">规则管理</el-button>
|
||||||
<el-button v-if="!props.isEmbed" type="info" @click="loadExample">{{ t('loadExample') }}</el-button>
|
<el-button v-if="!props.isEmbed" type="info" @click="loadExample">{{ t('loadExample') }}</el-button>
|
||||||
<el-button v-if="!props.isEmbed" type="info" @click="showUpdateLog">{{ t('updateLog') }}</el-button>
|
<el-button v-if="!props.isEmbed" type="info" @click="showUpdateLog">{{ t('updateLog') }}</el-button>
|
||||||
<el-button v-if="!props.isEmbed" type="warning" @click="showFeedbackForm">{{ t('feedback') }}</el-button>
|
<el-button v-if="!props.isEmbed" type="warning" @click="showFeedbackForm">{{ t('feedback') }}</el-button>
|
||||||
@@ -111,11 +113,214 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 素材管理对话框 -->
|
||||||
|
<el-dialog v-model="state.showAssetManagerDialog" title="素材管理" width="70%">
|
||||||
|
<div class="asset-manager-actions">
|
||||||
|
<input
|
||||||
|
ref="assetUploadInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="asset-upload-input"
|
||||||
|
@change="handleAssetManagerUpload"
|
||||||
|
/>
|
||||||
|
<el-button size="small" type="primary" @click="triggerAssetManagerUpload">
|
||||||
|
上传当前分类素材
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="assetManagerLibrary" class="asset-manager-tabs">
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="library in assetLibraries"
|
||||||
|
:key="library.id"
|
||||||
|
:label="library.label"
|
||||||
|
:name="library.id"
|
||||||
|
>
|
||||||
|
<div class="asset-manager-grid">
|
||||||
|
<div
|
||||||
|
v-for="item in getManagedAssets(library.id)"
|
||||||
|
:key="item.id || `${item.name}-${item.avatar}`"
|
||||||
|
class="asset-manager-item"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="asset-manager-image"
|
||||||
|
:style="{ backgroundImage: `url('${resolveAssetUrl(item.avatar)}')` }"
|
||||||
|
/>
|
||||||
|
<div class="asset-manager-name">{{ item.name }}</div>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
type="danger"
|
||||||
|
@click="removeManagedAsset(library.id, item)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-if="getManagedAssets(library.id).length === 0"
|
||||||
|
:description="`暂无${library.label}`"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 规则管理对话框 -->
|
||||||
|
<el-dialog v-model="state.showRuleManagerDialog" title="规则管理" width="80%">
|
||||||
|
<div class="rule-manager-actions">
|
||||||
|
<el-button size="small" type="primary" @click="addExpressionRule">新增规则</el-button>
|
||||||
|
<el-button size="small" type="primary" plain @click="addRuleVariable">新增变量</el-button>
|
||||||
|
<el-button size="small" @click="exportRuleBundle">导出规则变量</el-button>
|
||||||
|
<el-button size="small" @click="triggerRuleBundleImport">导入规则变量</el-button>
|
||||||
|
<el-button size="small" @click="reloadRuleManagerDraft">重载当前配置</el-button>
|
||||||
|
<el-button size="small" type="success" @click="applyRuleManagerConfig">应用并生效</el-button>
|
||||||
|
<el-button size="small" type="warning" plain @click="restoreDefaultRuleConfig">恢复默认</el-button>
|
||||||
|
<input
|
||||||
|
ref="ruleBundleImportInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
class="asset-upload-input"
|
||||||
|
@change="handleRuleBundleImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="ruleManagerTab" class="rule-manager-tabs">
|
||||||
|
<el-tab-pane label="规则" name="rules">
|
||||||
|
<div class="rule-table-wrap">
|
||||||
|
<el-table
|
||||||
|
v-if="ruleConfigDraft.expressionRules.length > 0"
|
||||||
|
:data="ruleConfigDraft.expressionRules"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
class="rule-table"
|
||||||
|
>
|
||||||
|
<el-table-column label="启用" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-checkbox v-model="row.enabled" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="级别" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select
|
||||||
|
v-model="row.severity"
|
||||||
|
size="small"
|
||||||
|
:class="['rule-inline-select', 'severity-select', `severity-select--${row.severity || 'warning'}`]"
|
||||||
|
>
|
||||||
|
<el-option label="warning" value="warning" />
|
||||||
|
<el-option label="error" value="error" />
|
||||||
|
<el-option label="info" value="info" />
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="id" label="规则 ID" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="条件" min-width="260" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="rule-cell-ellipsis">{{ row.condition }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="提示" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="rule-cell-ellipsis">{{ row.message }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
|
<template #default="{ $index }">
|
||||||
|
<el-button size="small" text type="primary" @click="openExpressionRuleEditor($index)">编辑</el-button>
|
||||||
|
<el-button size="small" text type="danger" @click="removeExpressionRule($index)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无规则,点击“新增规则”创建" />
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="变量" name="variables">
|
||||||
|
<div class="variable-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in ruleConfigDraft.ruleVariables"
|
||||||
|
:key="`${item.key}-${index}`"
|
||||||
|
class="variable-item"
|
||||||
|
>
|
||||||
|
<el-form-item label="Key" class="variable-key">
|
||||||
|
<el-input v-model="item.key" placeholder="如: fire_supporters" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Value" class="variable-value">
|
||||||
|
<el-input
|
||||||
|
v-model="item.value"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="逗号或换行分隔,如:辉夜姬,座敷童子"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button size="small" text type="danger" @click="removeRuleVariable(index)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="ruleConfigDraft.ruleVariables.length === 0" description="暂无变量,点击“新增变量”创建" />
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="文档说明" name="docs">
|
||||||
|
<div class="rule-docs">
|
||||||
|
<h4>作用域约定</h4>
|
||||||
|
<pre>{{ ruleScopeDoc }}</pre>
|
||||||
|
<h4>可用上下文</h4>
|
||||||
|
<pre>{{ ruleContextDoc }}</pre>
|
||||||
|
<h4>支持语法</h4>
|
||||||
|
<pre>{{ ruleSyntaxDoc }}</pre>
|
||||||
|
<h4>支持函数</h4>
|
||||||
|
<pre>{{ ruleFunctionDoc }}</pre>
|
||||||
|
<h4>表达式示例</h4>
|
||||||
|
<pre>{{ ruleExamplesDoc }}</pre>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="ruleEditorVisible" title="编辑规则" width="56%">
|
||||||
|
<el-form v-if="ruleEditorDraft" label-width="96px" class="rule-editor-form">
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="ruleEditorDraft.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="规则 ID">
|
||||||
|
<el-input v-model="ruleEditorDraft.id" placeholder="unique_rule_id" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="级别">
|
||||||
|
<el-select
|
||||||
|
v-model="ruleEditorDraft.severity"
|
||||||
|
:class="['severity-select', `severity-select--${ruleEditorDraft.severity || 'warning'}`]"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option label="warning" value="warning" />
|
||||||
|
<el-option label="error" value="error" />
|
||||||
|
<el-option label="info" value="info" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="告警 code">
|
||||||
|
<el-input v-model="ruleEditorDraft.code" placeholder="CUSTOM_EXPRESSION" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="条件表达式">
|
||||||
|
<el-input
|
||||||
|
v-model="ruleEditorDraft.condition"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder='示例:count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) == 0'
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="提示文案">
|
||||||
|
<el-input v-model="ruleEditorDraft.message" placeholder="规则提示文案" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="cancelRuleEditor">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveRuleEditor">保存</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, onMounted } from 'vue';
|
import { reactive, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||||
import updateLogs from "../data/updateLog.json"
|
import updateLogs from "../data/updateLog.json"
|
||||||
import { useFilesStore } from "@/ts/useStore";
|
import { useFilesStore } from "@/ts/useStore";
|
||||||
import { ElMessageBox } from "element-plus";
|
import { ElMessageBox } from "element-plus";
|
||||||
@@ -123,8 +328,26 @@ import { useGlobalMessage } from "@/ts/useGlobalMessage";
|
|||||||
import { getLogicFlowInstance } from "@/ts/useLogicFlow";
|
import { getLogicFlowInstance } from "@/ts/useLogicFlow";
|
||||||
import { useCanvasSettings } from '@/ts/useCanvasSettings';
|
import { useCanvasSettings } from '@/ts/useCanvasSettings';
|
||||||
import { useSafeI18n } from '@/ts/useSafeI18n';
|
import { useSafeI18n } from '@/ts/useSafeI18n';
|
||||||
|
import { ASSET_LIBRARIES } from '@/types/nodeTypes';
|
||||||
import type { Pinia } from 'pinia';
|
import type { Pinia } from 'pinia';
|
||||||
import { resolveAssetUrl } from '@/utils/assetUrl';
|
import { resolveAssetUrl } from '@/utils/assetUrl';
|
||||||
|
import {
|
||||||
|
createCustomAssetFromFile,
|
||||||
|
deleteCustomAsset,
|
||||||
|
listCustomAssets,
|
||||||
|
subscribeCustomAssetStore,
|
||||||
|
type CustomAssetItem
|
||||||
|
} from '@/utils/customAssets';
|
||||||
|
import {
|
||||||
|
readSharedGroupRulesConfig,
|
||||||
|
writeSharedGroupRulesConfig
|
||||||
|
} from '@/utils/groupRulesConfigSource';
|
||||||
|
import {
|
||||||
|
DEFAULT_GROUP_RULES_CONFIG,
|
||||||
|
type GroupRulesConfig,
|
||||||
|
type ExpressionRuleDefinition,
|
||||||
|
type RuleVariableDefinition
|
||||||
|
} from '@/configs/groupRules';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
isEmbed?: boolean;
|
isEmbed?: boolean;
|
||||||
@@ -156,8 +379,403 @@ const state = reactive({
|
|||||||
showUpdateLogDialog: false, // 控制更新日志对话框的显示状态
|
showUpdateLogDialog: false, // 控制更新日志对话框的显示状态
|
||||||
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态
|
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态
|
||||||
showDataPreviewDialog: false, // 控制数据预览对话框的显示状态
|
showDataPreviewDialog: false, // 控制数据预览对话框的显示状态
|
||||||
|
showAssetManagerDialog: false, // 控制素材管理对话框的显示状态
|
||||||
|
showRuleManagerDialog: false, // 控制规则管理对话框的显示状态
|
||||||
previewDataContent: '', // 存储预览的数据内容
|
previewDataContent: '', // 存储预览的数据内容
|
||||||
});
|
});
|
||||||
|
const assetLibraries = ASSET_LIBRARIES.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: `${item.label}素材`
|
||||||
|
}));
|
||||||
|
const assetManagerLibrary = ref(assetLibraries[0]?.id || 'shikigami');
|
||||||
|
const assetUploadInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const ruleBundleImportInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const managedAssets = reactive<Record<string, CustomAssetItem[]>>({});
|
||||||
|
assetLibraries.forEach((item) => {
|
||||||
|
managedAssets[item.id] = [];
|
||||||
|
});
|
||||||
|
let unsubscribeAssetStore: (() => void) | null = null;
|
||||||
|
|
||||||
|
const ruleManagerTab = ref<'rules' | 'variables' | 'docs'>('rules');
|
||||||
|
|
||||||
|
const cloneRuleConfig = (config: GroupRulesConfig): GroupRulesConfig => ({
|
||||||
|
version: config.version,
|
||||||
|
fireShikigamiWhitelist: [...config.fireShikigamiWhitelist],
|
||||||
|
shikigamiYuhunBlacklist: config.shikigamiYuhunBlacklist.map((rule) => ({ ...rule })),
|
||||||
|
shikigamiConflictPairs: config.shikigamiConflictPairs.map((rule) => ({ ...rule })),
|
||||||
|
expressionRules: config.expressionRules.map((rule) => ({ ...rule })),
|
||||||
|
ruleVariables: config.ruleVariables.map((item) => ({ ...item }))
|
||||||
|
});
|
||||||
|
|
||||||
|
const createExpressionRule = (): ExpressionRuleDefinition => ({
|
||||||
|
id: `rule_${Date.now()}`,
|
||||||
|
enabled: true,
|
||||||
|
severity: 'warning',
|
||||||
|
code: 'CUSTOM_EXPRESSION',
|
||||||
|
condition: 'false',
|
||||||
|
message: '请补充规则提示文案'
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRuleVariable = (): RuleVariableDefinition => ({
|
||||||
|
key: `var_${Date.now()}`,
|
||||||
|
value: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const ruleConfigDraft = ref<GroupRulesConfig>(cloneRuleConfig(readSharedGroupRulesConfig()));
|
||||||
|
const ruleEditorVisible = ref(false);
|
||||||
|
const editingRuleIndex = ref<number | null>(null);
|
||||||
|
const ruleEditorDraft = ref<ExpressionRuleDefinition | null>(null);
|
||||||
|
|
||||||
|
const cloneExpressionRule = (rule: ExpressionRuleDefinition): ExpressionRuleDefinition => ({
|
||||||
|
id: rule.id || `rule_${Date.now()}`,
|
||||||
|
enabled: rule.enabled !== false,
|
||||||
|
severity: rule.severity || 'warning',
|
||||||
|
code: rule.code || 'CUSTOM_EXPRESSION',
|
||||||
|
condition: rule.condition || 'false',
|
||||||
|
message: rule.message || '请补充规则提示文案'
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||||
|
const normalizeSeverity = (value: unknown): 'warning' | 'error' | 'info' => {
|
||||||
|
return value === 'error' || value === 'info' ? value : 'warning';
|
||||||
|
};
|
||||||
|
const normalizeImportedExpressionRules = (value: unknown): ExpressionRuleDefinition[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return null;
|
||||||
|
const raw = item as Record<string, unknown>;
|
||||||
|
const id = normalizeText(raw.id);
|
||||||
|
const condition = normalizeText(raw.condition);
|
||||||
|
const message = normalizeText(raw.message);
|
||||||
|
if (!id || !condition || !message) return null;
|
||||||
|
const code = normalizeText(raw.code);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
condition,
|
||||||
|
message,
|
||||||
|
enabled: raw.enabled !== false,
|
||||||
|
severity: normalizeSeverity(raw.severity),
|
||||||
|
...(code ? { code } : {})
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is ExpressionRuleDefinition => !!item);
|
||||||
|
};
|
||||||
|
const normalizeImportedRuleVariables = (value: unknown): RuleVariableDefinition[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return null;
|
||||||
|
const raw = item as Record<string, unknown>;
|
||||||
|
const key = normalizeText(raw.key);
|
||||||
|
if (!key) return null;
|
||||||
|
const variableValue = typeof raw.value === 'string' ? raw.value : String(raw.value ?? '');
|
||||||
|
return { key, value: variableValue };
|
||||||
|
})
|
||||||
|
.filter((item): item is RuleVariableDefinition => !!item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ruleScopeDoc = `规则新增字段(建议): scopeKind
|
||||||
|
- team: 在队伍组(dynamic-group: team)上执行(当前已生效)
|
||||||
|
- shikigami: 在式神组(dynamic-group: shikigami)上执行(规划中,当前未生效)
|
||||||
|
|
||||||
|
注意
|
||||||
|
- scopeKind 决定“规则运行上下文”
|
||||||
|
- 告警点击定位固定为:触发该规则的 dynamic-group`;
|
||||||
|
|
||||||
|
const ruleContextDoc = `当 scopeKind = "team"(队伍规则)
|
||||||
|
ctx.team.shikigamis: 式神数组
|
||||||
|
- 单项示例: { nodeId: "n1", assetId: "sp_kaguya", name: "辉夜姬", library: "shikigami" }
|
||||||
|
|
||||||
|
ctx.team.yuhuns: 御魂数组
|
||||||
|
- 单项示例: { nodeId: "n2", assetId: "p4_poshi", name: "破势", library: "yuhun" }
|
||||||
|
|
||||||
|
ctx.group.id / ctx.group.name
|
||||||
|
- 示例: "team-1" / "冲榜队A"
|
||||||
|
|
||||||
|
当 scopeKind = "shikigami"(式神规则,规划中,当前未生效)
|
||||||
|
ctx.unit.shikigami: 当前式神对象(单个)
|
||||||
|
- 示例: { nodeId: "n1", assetId: "sp_kaguya", name: "辉夜姬", library: "shikigami" }
|
||||||
|
|
||||||
|
ctx.unit.yuhuns: 当前式神关联御魂数组
|
||||||
|
- 单项示例: { nodeId: "n2", assetId: "p4_poshi", name: "破势", library: "yuhun" }
|
||||||
|
|
||||||
|
通用共享变量
|
||||||
|
shared.vars(变量 tab 配置后的 key/value 映射)
|
||||||
|
- 示例:
|
||||||
|
shared.vars["供火式神"] = ["辉夜姬", "座敷童子"]
|
||||||
|
shared.vars["输出式神"] = ["阿修罗", "茨木童子"]`;
|
||||||
|
|
||||||
|
const ruleFunctionDoc = `count(value)
|
||||||
|
- 用途: 计算数量(数组长度 / 字符串长度)
|
||||||
|
- team 示例: count(ctx.team.shikigamis) >= 5
|
||||||
|
- shikigami 示例: count(ctx.unit.yuhuns) >= 1
|
||||||
|
|
||||||
|
contains(collection, target)
|
||||||
|
- 用途: 判断集合或字符串是否包含目标值
|
||||||
|
- team 示例: contains(map(ctx.team.shikigamis, "name"), "辉夜姬")
|
||||||
|
|
||||||
|
intersect(leftArray, rightArray)
|
||||||
|
- 用途: 取数组交集(去重)
|
||||||
|
- team 示例: count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) > 0
|
||||||
|
|
||||||
|
map(collection, "fieldName")
|
||||||
|
- 用途: 提取对象数组字段
|
||||||
|
- team 示例: map(ctx.team.shikigamis, "name")
|
||||||
|
- shikigami 示例: map(ctx.unit.yuhuns, "name")
|
||||||
|
|
||||||
|
unique(collection)
|
||||||
|
- 用途: 数组去重
|
||||||
|
- team 示例: count(unique(map(ctx.team.yuhuns, "name"))) >= 2
|
||||||
|
|
||||||
|
exists(value)
|
||||||
|
- 用途: 判断值是否存在(非 null/空串;数组需长度>0)
|
||||||
|
- 示例: exists(getVar("核心式神"))
|
||||||
|
|
||||||
|
lower(value) / upper(value)
|
||||||
|
- 用途: 字符串大小写转换
|
||||||
|
- 示例: contains(lower("PoShi"), "poshi")
|
||||||
|
|
||||||
|
getVar("变量Key")
|
||||||
|
- 用途: 获取变量 tab 里配置的值(通常返回字符串数组)
|
||||||
|
- 示例: getVar("供火式神")`;
|
||||||
|
|
||||||
|
const ruleSyntaxDoc = `支持
|
||||||
|
- 字面量: "文本" / 数字 / true / false / null
|
||||||
|
- 数组: ["辉夜姬", "座敷童子"]
|
||||||
|
- 路径: ctx.team.shikigamis / ctx.unit.shikigami / shared.vars
|
||||||
|
- 函数调用: count(...), contains(...), intersect(...), map(...)
|
||||||
|
- 逻辑运算: && || !
|
||||||
|
- 比较运算: == != > >= < <=
|
||||||
|
- 算术运算: + - * /
|
||||||
|
- 括号: ( ... )
|
||||||
|
|
||||||
|
不支持
|
||||||
|
- index 语法(如 getIndexOf / arr[0])
|
||||||
|
- 自定义遍历语法(for/while/foreach)
|
||||||
|
- 自定义函数定义
|
||||||
|
- 赋值语句
|
||||||
|
- eval/new Function`;
|
||||||
|
|
||||||
|
const ruleExamplesDoc = `1) [team] 队伍至少有一个供火式神
|
||||||
|
count(intersect(map(ctx.team.shikigamis, "name"), getVar("供火式神"))) > 0
|
||||||
|
|
||||||
|
2) [team] 队伍里不能同时出现千姬和腹肌清姬
|
||||||
|
contains(map(ctx.team.shikigamis, "name"), "千姬") && contains(map(ctx.team.shikigamis, "name"), "腹肌清姬")
|
||||||
|
|
||||||
|
3) [team] 队伍御魂至少 2 种(避免全员同御魂)
|
||||||
|
count(unique(map(ctx.team.yuhuns, "name"))) >= 2
|
||||||
|
|
||||||
|
4) [规划中的 shikigami scope] 当前式神是辉夜姬且其关联御魂包含破势
|
||||||
|
ctx.unit.shikigami.name == "辉夜姬" && contains(map(ctx.unit.yuhuns, "name"), "破势")`;
|
||||||
|
|
||||||
|
const refreshManagedAssets = (library?: string) => {
|
||||||
|
if (library) {
|
||||||
|
managedAssets[library] = listCustomAssets(library);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assetLibraries.forEach((item) => {
|
||||||
|
managedAssets[item.id] = listCustomAssets(item.id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAssetManager = () => {
|
||||||
|
refreshManagedAssets();
|
||||||
|
state.showAssetManagerDialog = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadRuleManagerDraft = () => {
|
||||||
|
ruleConfigDraft.value = cloneRuleConfig(readSharedGroupRulesConfig());
|
||||||
|
cancelRuleEditor();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRuleManager = () => {
|
||||||
|
reloadRuleManagerDraft();
|
||||||
|
ruleManagerTab.value = 'rules';
|
||||||
|
state.showRuleManagerDialog = true;
|
||||||
|
ruleEditorVisible.value = false;
|
||||||
|
editingRuleIndex.value = null;
|
||||||
|
ruleEditorDraft.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportRuleBundle = () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
version: ruleConfigDraft.value.version,
|
||||||
|
expressionRules: ruleConfigDraft.value.expressionRules,
|
||||||
|
ruleVariables: ruleConfigDraft.value.ruleVariables
|
||||||
|
};
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `rule-bundle-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showMessage('success', '规则变量已导出');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出规则变量失败:', error);
|
||||||
|
showMessage('error', '导出失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerRuleBundleImport = () => {
|
||||||
|
ruleBundleImportInputRef.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRuleBundleImport = async (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement | null;
|
||||||
|
const file = target?.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
if (target) target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawText = await file.text();
|
||||||
|
const parsed = JSON.parse(rawText) as Record<string, unknown>;
|
||||||
|
const importedRules = normalizeImportedExpressionRules(parsed.expressionRules);
|
||||||
|
const importedVariables = normalizeImportedRuleVariables(parsed.ruleVariables);
|
||||||
|
if (!importedRules.length && !importedVariables.length) {
|
||||||
|
showMessage('warning', '导入文件中没有可用的规则或变量');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ruleConfigDraft.value = {
|
||||||
|
...ruleConfigDraft.value,
|
||||||
|
expressionRules: importedRules,
|
||||||
|
ruleVariables: importedVariables
|
||||||
|
};
|
||||||
|
cancelRuleEditor();
|
||||||
|
showMessage('success', '规则变量已导入,请点击“应用并生效”');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入规则变量失败:', error);
|
||||||
|
showMessage('error', '导入失败,文件格式错误');
|
||||||
|
} finally {
|
||||||
|
if (target) target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addExpressionRule = () => {
|
||||||
|
const newRule = createExpressionRule();
|
||||||
|
ruleConfigDraft.value.expressionRules.push(newRule);
|
||||||
|
openExpressionRuleEditor(ruleConfigDraft.value.expressionRules.length - 1);
|
||||||
|
ruleManagerTab.value = 'rules';
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExpressionRule = (index: number) => {
|
||||||
|
if (editingRuleIndex.value === index) {
|
||||||
|
cancelRuleEditor();
|
||||||
|
}
|
||||||
|
ruleConfigDraft.value.expressionRules.splice(index, 1);
|
||||||
|
if (editingRuleIndex.value != null && editingRuleIndex.value > index) {
|
||||||
|
editingRuleIndex.value -= 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openExpressionRuleEditor = (index: number) => {
|
||||||
|
const target = ruleConfigDraft.value.expressionRules[index];
|
||||||
|
if (!target) return;
|
||||||
|
editingRuleIndex.value = index;
|
||||||
|
ruleEditorDraft.value = cloneExpressionRule(target);
|
||||||
|
ruleEditorVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRuleEditor = () => {
|
||||||
|
ruleEditorVisible.value = false;
|
||||||
|
editingRuleIndex.value = null;
|
||||||
|
ruleEditorDraft.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRuleEditor = () => {
|
||||||
|
const index = editingRuleIndex.value;
|
||||||
|
const draft = ruleEditorDraft.value;
|
||||||
|
if (index == null || !draft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ruleId = draft.id?.trim();
|
||||||
|
const condition = draft.condition?.trim();
|
||||||
|
const message = draft.message?.trim();
|
||||||
|
if (!ruleId || !condition || !message) {
|
||||||
|
showMessage('warning', '规则 ID、条件表达式、提示文案不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = cloneExpressionRule({
|
||||||
|
...draft,
|
||||||
|
id: ruleId,
|
||||||
|
condition,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
ruleConfigDraft.value.expressionRules[index] = normalized;
|
||||||
|
cancelRuleEditor();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRuleVariable = () => {
|
||||||
|
ruleConfigDraft.value.ruleVariables.push(createRuleVariable());
|
||||||
|
ruleManagerTab.value = 'variables';
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRuleVariable = (index: number) => {
|
||||||
|
ruleConfigDraft.value.ruleVariables.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyRuleManagerConfig = () => {
|
||||||
|
try {
|
||||||
|
const normalized = writeSharedGroupRulesConfig(ruleConfigDraft.value);
|
||||||
|
ruleConfigDraft.value = cloneRuleConfig(normalized);
|
||||||
|
showMessage('success', '规则配置已生效');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('应用规则配置失败:', error);
|
||||||
|
showMessage('error', '规则配置应用失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreDefaultRuleConfig = () => {
|
||||||
|
ElMessageBox.confirm('恢复默认会覆盖当前规则和变量,是否继续?', '提示', {
|
||||||
|
confirmButtonText: '恢复默认',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}).then(() => {
|
||||||
|
const normalized = writeSharedGroupRulesConfig(DEFAULT_GROUP_RULES_CONFIG);
|
||||||
|
ruleConfigDraft.value = cloneRuleConfig(normalized);
|
||||||
|
cancelRuleEditor();
|
||||||
|
showMessage('success', '已恢复默认规则配置');
|
||||||
|
}).catch(() => {
|
||||||
|
// 用户取消
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getManagedAssets = (libraryId: string) => {
|
||||||
|
return managedAssets[libraryId] || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerAssetManagerUpload = () => {
|
||||||
|
assetUploadInputRef.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssetManagerUpload = async (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement | null;
|
||||||
|
const file = target?.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
if (target) target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createCustomAssetFromFile(assetManagerLibrary.value, file);
|
||||||
|
refreshManagedAssets(assetManagerLibrary.value);
|
||||||
|
showMessage('success', '素材上传成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('素材上传失败:', error);
|
||||||
|
showMessage('error', '素材上传失败');
|
||||||
|
} finally {
|
||||||
|
if (target) target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeManagedAsset = (libraryId: string, item: CustomAssetItem) => {
|
||||||
|
deleteCustomAsset(libraryId, item);
|
||||||
|
refreshManagedAssets(libraryId);
|
||||||
|
};
|
||||||
|
|
||||||
// 重新渲染 LogicFlow 画布的通用方法
|
// 重新渲染 LogicFlow 画布的通用方法
|
||||||
const refreshLogicFlowCanvas = (message?: string) => {
|
const refreshLogicFlowCanvas = (message?: string) => {
|
||||||
@@ -224,6 +842,11 @@ const showUpdateLog = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
refreshManagedAssets();
|
||||||
|
unsubscribeAssetStore = subscribeCustomAssetStore(() => {
|
||||||
|
refreshManagedAssets();
|
||||||
|
});
|
||||||
|
|
||||||
if (props.isEmbed) {
|
if (props.isEmbed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -238,6 +861,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unsubscribeAssetStore?.();
|
||||||
|
unsubscribeAssetStore = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const showFeedbackForm = () => {
|
const showFeedbackForm = () => {
|
||||||
state.showFeedbackFormDialog = !state.showFeedbackFormDialog;
|
state.showFeedbackFormDialog = !state.showFeedbackFormDialog;
|
||||||
@@ -300,7 +928,7 @@ const handleImport = () => {
|
|||||||
const target = e.target as FileReader;
|
const target = e.target as FileReader;
|
||||||
const data = JSON.parse(target.result as string);
|
const data = JSON.parse(target.result as string);
|
||||||
filesStore.importData(data);
|
filesStore.importData(data);
|
||||||
// refreshLogicFlowCanvas('LogicFlow 画布已重新渲染(导入数据)');
|
refreshLogicFlowCanvas('LogicFlow 画布已重新渲染(导入数据)');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to import file', error);
|
console.error('Failed to import file', error);
|
||||||
showMessage('error', '文件格式错误');
|
showMessage('error', '文件格式错误');
|
||||||
@@ -425,6 +1053,48 @@ const addWatermarkToImage = (base64: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const waitForNextPaint = () => {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const withDynamicGroupsHiddenForSnapshot = async <T>(
|
||||||
|
logicFlowInstance: any,
|
||||||
|
runner: () => Promise<T>,
|
||||||
|
): Promise<T> => {
|
||||||
|
const graphModel = logicFlowInstance?.graphModel;
|
||||||
|
const dynamicGroupModels = (graphModel?.nodes ?? []).filter(
|
||||||
|
(node: any) => node?.type === 'dynamic-group',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!dynamicGroupModels.length) {
|
||||||
|
return runner();
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousStates = dynamicGroupModels.map((model: any) => ({
|
||||||
|
model,
|
||||||
|
visible: model.visible,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
previousStates.forEach(({ model }) => {
|
||||||
|
model.visible = false;
|
||||||
|
});
|
||||||
|
await waitForNextPaint();
|
||||||
|
return await runner();
|
||||||
|
} finally {
|
||||||
|
previousStates.forEach(({ model, visible }) => {
|
||||||
|
model.visible = visible;
|
||||||
|
});
|
||||||
|
await waitForNextPaint();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const captureLogicFlowSnapshot = async () => {
|
const captureLogicFlowSnapshot = async () => {
|
||||||
const logicFlowInstance = getLogicFlowInstance() as any;
|
const logicFlowInstance = getLogicFlowInstance() as any;
|
||||||
if (!logicFlowInstance || typeof logicFlowInstance.getSnapshotBase64 !== 'function') {
|
if (!logicFlowInstance || typeof logicFlowInstance.getSnapshotBase64 !== 'function') {
|
||||||
@@ -432,7 +1102,9 @@ const captureLogicFlowSnapshot = async () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshotResult = await logicFlowInstance.getSnapshotBase64(
|
const snapshotResult = await withDynamicGroupsHiddenForSnapshot(
|
||||||
|
logicFlowInstance,
|
||||||
|
() => logicFlowInstance.getSnapshotBase64(
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
@@ -441,6 +1113,7 @@ const captureLogicFlowSnapshot = async () => {
|
|||||||
partial: false,
|
partial: false,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const base64 = typeof snapshotResult === 'string' ? snapshotResult : snapshotResult?.data;
|
const base64 = typeof snapshotResult === 'string' ? snapshotResult : snapshotResult?.data;
|
||||||
@@ -541,4 +1214,165 @@ const handleClose = (done) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.asset-manager-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-upload-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-manager-tabs {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-manager-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-manager-item {
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-manager-image {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-manager-name {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #303133;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-manager-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-manager-tabs {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-table-wrap,
|
||||||
|
.variable-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-item {
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-table :deep(.el-table__cell) {
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-inline-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-select--warning :deep(.el-input__wrapper) {
|
||||||
|
background: #fff7ed;
|
||||||
|
box-shadow: inset 0 0 0 1px #fed7aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-select--warning :deep(.el-input__inner) {
|
||||||
|
color: #9a3412;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-select--error :deep(.el-input__wrapper) {
|
||||||
|
background: #fef2f2;
|
||||||
|
box-shadow: inset 0 0 0 1px #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-select--error :deep(.el-input__inner) {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-select--info :deep(.el-input__wrapper) {
|
||||||
|
background: #eff6ff;
|
||||||
|
box-shadow: inset 0 0 0 1px #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-select--info :deep(.el-input__inner) {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-cell-ellipsis {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-editor-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-key,
|
||||||
|
.variable-value {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-docs {
|
||||||
|
max-height: 460px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-docs h4 {
|
||||||
|
margin: 6px 0;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-docs pre {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.variable-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,17 @@
|
|||||||
当前选择:{{ config.currentItem[config.itemRender.labelField] }}
|
当前选择:{{ config.currentItem[config.itemRender.labelField] }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<div v-if="config.allowUserAssetUpload" class="user-asset-actions">
|
||||||
|
<input
|
||||||
|
ref="uploadInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden-input"
|
||||||
|
@change="handleUploadAsset"
|
||||||
|
/>
|
||||||
|
<el-button size="small" type="primary" @click="triggerUpload">上传我的素材</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 搜索框 -->
|
<!-- 搜索框 -->
|
||||||
<div v-if="config.searchable !== false" style="display: flex; align-items: center;">
|
<div v-if="config.searchable !== false" style="display: flex; align-items: center;">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -29,7 +40,7 @@
|
|||||||
<el-space wrap size="large">
|
<el-space wrap size="large">
|
||||||
<div
|
<div
|
||||||
v-for="item in filteredItems(group)"
|
v-for="item in filteredItems(group)"
|
||||||
:key="item[config.itemRender.labelField]"
|
:key="item.id || item[config.itemRender.labelField]"
|
||||||
style="display: flex; flex-direction: column; justify-content: center"
|
style="display: flex; flex-direction: column; justify-content: center"
|
||||||
>
|
>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -45,6 +56,15 @@
|
|||||||
<span style="text-align: center; display: block;">
|
<span style="text-align: center; display: block;">
|
||||||
{{ item[config.itemRender.labelField] }}
|
{{ item[config.itemRender.labelField] }}
|
||||||
</span>
|
</span>
|
||||||
|
<el-button
|
||||||
|
v-if="item.__userAsset"
|
||||||
|
type="danger"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
@click.stop="removeUserAsset(item)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-space>
|
</el-space>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,9 +74,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import type { SelectorConfig, GroupConfig } from '@/types/selector'
|
import type { SelectorConfig, GroupConfig } from '@/types/selector'
|
||||||
import { resolveAssetUrl } from '@/utils/assetUrl'
|
import { resolveAssetUrl } from '@/utils/assetUrl'
|
||||||
|
import { createCustomAssetFromFile, listCustomAssets, subscribeCustomAssetStore } from '@/utils/customAssets'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
config: SelectorConfig
|
config: SelectorConfig
|
||||||
@@ -77,10 +98,55 @@ const searchText = ref('')
|
|||||||
const activeTab = ref('ALL')
|
const activeTab = ref('ALL')
|
||||||
const imageSize = computed(() => props.config.itemRender.imageSize || 100)
|
const imageSize = computed(() => props.config.itemRender.imageSize || 100)
|
||||||
const imageField = computed(() => props.config.itemRender.imageField)
|
const imageField = computed(() => props.config.itemRender.imageField)
|
||||||
|
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const dataSource = ref<any[]>([])
|
||||||
|
let unsubscribeCustomAssets: (() => void) | null = null
|
||||||
|
|
||||||
|
const refreshDataSource = () => {
|
||||||
|
const source = Array.isArray(props.config.dataSource) ? props.config.dataSource : []
|
||||||
|
const staticAssets = source.filter((item) => !item?.__userAsset)
|
||||||
|
const library = props.config.assetLibrary
|
||||||
|
if (!library) {
|
||||||
|
dataSource.value = [...source]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const customAssets = listCustomAssets(library)
|
||||||
|
dataSource.value = [...staticAssets, ...customAssets]
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.config.dataSource, props.config.assetLibrary],
|
||||||
|
() => {
|
||||||
|
refreshDataSource()
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
refreshDataSource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
unsubscribeCustomAssets = subscribeCustomAssetStore(() => {
|
||||||
|
if (props.modelValue) {
|
||||||
|
refreshDataSource()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unsubscribeCustomAssets?.()
|
||||||
|
unsubscribeCustomAssets = null
|
||||||
|
})
|
||||||
|
|
||||||
// 过滤逻辑
|
// 过滤逻辑
|
||||||
const filteredItems = (group: GroupConfig) => {
|
const filteredItems = (group: GroupConfig) => {
|
||||||
let items = props.config.dataSource
|
let items = dataSource.value
|
||||||
|
|
||||||
// 分组过滤
|
// 分组过滤
|
||||||
if (group.name !== 'ALL') {
|
if (group.name !== 'ALL') {
|
||||||
@@ -120,9 +186,49 @@ const handleSelect = (item: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getItemImageUrl = (item: any) => resolveAssetUrl(item?.[imageField.value]) as string
|
const getItemImageUrl = (item: any) => resolveAssetUrl(item?.[imageField.value]) as string
|
||||||
|
|
||||||
|
const triggerUpload = () => {
|
||||||
|
uploadInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadAsset = async (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement | null
|
||||||
|
const file = target?.files?.[0]
|
||||||
|
if (!file || !props.config.assetLibrary) {
|
||||||
|
if (target) {
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdAsset = await createCustomAssetFromFile(props.config.assetLibrary, file)
|
||||||
|
props.config.onUserAssetUploaded?.(createdAsset)
|
||||||
|
refreshDataSource()
|
||||||
|
} finally {
|
||||||
|
if (target) {
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUserAsset = (item: any) => {
|
||||||
|
props.config.onDeleteUserAsset?.(item)
|
||||||
|
refreshDataSource()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.user-asset-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.selector-button {
|
.selector-button {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
|
||||||
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
|
|
||||||
// 使用嵌套结构定义组件分组
|
// 使用嵌套结构定义组件分组
|
||||||
@@ -30,6 +29,31 @@ const componentGroups = [
|
|||||||
style: { background: '#fff', border: '2px solid black', borderRadius: '50%' }
|
style: { background: '#fff', border: '2px solid black', borderRadius: '50%' }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'dynamic-group',
|
||||||
|
name: '动态分组',
|
||||||
|
type: 'dynamic-group',
|
||||||
|
description: '可折叠的动态分组容器',
|
||||||
|
data: {
|
||||||
|
children: [],
|
||||||
|
groupMeta: {
|
||||||
|
version: 1,
|
||||||
|
groupKind: 'team',
|
||||||
|
ruleEnabled: true
|
||||||
|
},
|
||||||
|
collapsible: true,
|
||||||
|
isCollapsed: false,
|
||||||
|
width: 420,
|
||||||
|
height: 250,
|
||||||
|
collapsedWidth: 100,
|
||||||
|
collapsedHeight: 60,
|
||||||
|
radius: 6,
|
||||||
|
isRestrict: false,
|
||||||
|
autoResize: false,
|
||||||
|
transformWithContainer: false,
|
||||||
|
autoToFront: true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'image',
|
id: 'image',
|
||||||
name: '图片',
|
name: '图片',
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
<div class="editor-layout" :style="{ height }">
|
<div class="editor-layout" :style="{ height }">
|
||||||
<!-- 中间流程图区域 -->
|
<!-- 中间流程图区域 -->
|
||||||
<div ref="flowHostRef" class="flow-container" :class="{ 'snapline-disabled': !snaplineEnabled }">
|
<div ref="flowHostRef" class="flow-container" :class="{ 'snapline-disabled': !snaplineEnabled }">
|
||||||
<div class="flow-controls">
|
<div class="flow-controls" :class="{ 'flow-controls--collapsed': flowControlsCollapsed }">
|
||||||
|
<div class="control-row control-header">
|
||||||
|
<button class="control-button" type="button" @click="flowControlsCollapsed = !flowControlsCollapsed">
|
||||||
|
{{ flowControlsCollapsed ? `显示画布控制${groupRuleWarnings.length ? `(${groupRuleWarnings.length})` : ''}` : '收起画布控制' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="!flowControlsCollapsed">
|
||||||
<div class="control-row toggles">
|
<div class="control-row toggles">
|
||||||
<label class="control-toggle">
|
<label class="control-toggle">
|
||||||
<input type="checkbox" v-model="selectionEnabled" />
|
<input type="checkbox" v-model="selectionEnabled" />
|
||||||
@@ -49,8 +55,43 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="container" ref="containerRef" :style="{ height: '100%' }"></div>
|
<div class="container" ref="containerRef" :style="{ height: '100%' }"></div>
|
||||||
|
<div class="problems-dock" :class="{ 'problems-dock--open': problemsPanelOpen }">
|
||||||
|
<div class="problems-dock-bar">
|
||||||
|
<button class="problems-tab" type="button" @click="problemsPanelOpen = !problemsPanelOpen">
|
||||||
|
Problems
|
||||||
|
<span class="problems-badge">{{ groupRuleWarnings.length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="problemsPanelOpen" class="problems-panel">
|
||||||
|
<div class="problems-header">
|
||||||
|
<span>规则告警</span>
|
||||||
|
<span>{{ groupRuleWarnings.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!groupRuleWarnings.length" class="problems-empty">
|
||||||
|
当前没有告警
|
||||||
|
</div>
|
||||||
|
<div v-else class="problems-list">
|
||||||
|
<div
|
||||||
|
v-for="(warning, index) in groupRuleWarnings"
|
||||||
|
:key="warning.id || `${warning.groupId}-${warning.code}-${index}`"
|
||||||
|
class="problem-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="locateProblemNode(warning)"
|
||||||
|
@keydown.enter.prevent="locateProblemNode(warning)"
|
||||||
|
>
|
||||||
|
<div class="problem-severity">{{ warning.severity.toUpperCase() }}</div>
|
||||||
|
<div class="problem-content">
|
||||||
|
<div class="problem-message">{{ warning.message }}</div>
|
||||||
|
<div class="problem-meta">{{ warning.groupName || warning.groupId }} · {{ warning.ruleId }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 右侧属性面板 -->
|
<!-- 右侧属性面板 -->
|
||||||
<PropertyPanel :height="height" :node="selectedNode" :lf="lf" />
|
<PropertyPanel :height="height" :node="selectedNode" :lf="lf" />
|
||||||
@@ -62,11 +103,10 @@ import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
|||||||
import LogicFlow, { EventType } from '@logicflow/core';
|
import LogicFlow, { EventType } from '@logicflow/core';
|
||||||
import type { Position, NodeData, EdgeData, BaseNodeModel, GraphModel, GraphData } from '@logicflow/core';
|
import type { Position, NodeData, EdgeData, BaseNodeModel, GraphModel, GraphData } from '@logicflow/core';
|
||||||
import '@logicflow/core/lib/style/index.css';
|
import '@logicflow/core/lib/style/index.css';
|
||||||
import { Menu, Label, Snapshot, SelectionSelect, MiniMap, Control } from '@logicflow/extension';
|
import { Menu, Label, Snapshot, SelectionSelect, MiniMap, Control, DynamicGroup } from '@logicflow/extension';
|
||||||
import '@logicflow/extension/lib/style/index.css';
|
import '@logicflow/extension/lib/style/index.css';
|
||||||
import '@logicflow/core/es/index.css';
|
import '@logicflow/core/es/index.css';
|
||||||
import '@logicflow/extension/es/index.css';
|
import '@logicflow/extension/es/index.css';
|
||||||
import { translateEdgeData, translateNodeData } from '@logicflow/core/es/keyboard/shortcut';
|
|
||||||
|
|
||||||
import { register } from '@logicflow/vue-node-registry';
|
import { register } from '@logicflow/vue-node-registry';
|
||||||
import PropertySelectNode from './nodes/yys/PropertySelectNode.vue';
|
import PropertySelectNode from './nodes/yys/PropertySelectNode.vue';
|
||||||
@@ -81,19 +121,24 @@ import { useGlobalMessage } from '@/ts/useGlobalMessage';
|
|||||||
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
|
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@/ts/nodeStyle';
|
import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@/ts/nodeStyle';
|
||||||
import { useCanvasSettings } from '@/ts/useCanvasSettings';
|
import { useCanvasSettings } from '@/ts/useCanvasSettings';
|
||||||
|
import { validateGraphGroupRules, type GroupRuleWarning } from '@/utils/groupRules';
|
||||||
|
import { subscribeSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource';
|
||||||
|
import { getProblemTargetCandidateIds } from '@/utils/problemTarget';
|
||||||
|
|
||||||
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
|
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
|
||||||
type DistributeType = 'horizontal' | 'vertical';
|
type DistributeType = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
const MOVE_STEP = 2;
|
const MOVE_STEP = 2;
|
||||||
const MOVE_STEP_LARGE = 10;
|
const MOVE_STEP_LARGE = 10;
|
||||||
const COPY_TRANSLATION = 40;
|
const RIGHT_MOUSE_BUTTON = 2;
|
||||||
|
const RIGHT_DRAG_THRESHOLD = 2;
|
||||||
|
const RIGHT_DRAG_CONTEXTMENU_SUPPRESS_MS = 300;
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
height?: string;
|
height?: string;
|
||||||
enableLabel?: boolean;
|
enableLabel?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
enableLabel: true
|
enableLabel: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const flowHostRef = ref<HTMLElement | null>(null);
|
const flowHostRef = ref<HTMLElement | null>(null);
|
||||||
@@ -117,9 +162,34 @@ const { showMessage } = useGlobalMessage();
|
|||||||
|
|
||||||
// 当前选中节点
|
// 当前选中节点
|
||||||
const selectedNode = ref<any>(null);
|
const selectedNode = ref<any>(null);
|
||||||
const copyBuffer = ref<GraphData | null>(null);
|
const groupRuleWarnings = ref<GroupRuleWarning[]>([]);
|
||||||
let nextPasteDistance = COPY_TRANSLATION;
|
const flowControlsCollapsed = ref(true);
|
||||||
|
const problemsPanelOpen = ref(false);
|
||||||
let containerResizeObserver: ResizeObserver | null = null;
|
let containerResizeObserver: ResizeObserver | null = null;
|
||||||
|
let groupRuleValidationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let unsubscribeSharedGroupRules: (() => void) | null = null;
|
||||||
|
let isRightDragging = false;
|
||||||
|
let rightDragMoved = false;
|
||||||
|
let rightDragLastX = 0;
|
||||||
|
let rightDragLastY = 0;
|
||||||
|
let rightDragDistance = 0;
|
||||||
|
let suppressContextMenuUntil = 0;
|
||||||
|
|
||||||
|
function logClipboardDebug(stage: string, payload: Record<string, unknown> = {}) {
|
||||||
|
if (!import.meta.env.DEV) return;
|
||||||
|
const lfInstance = lf.value as any;
|
||||||
|
const graphModel = lfInstance?.graphModel;
|
||||||
|
const selectNodeIds: string[] = graphModel?.selectNodes?.map((node: BaseNodeModel) => node.id) ?? [];
|
||||||
|
const selectElementIds: string[] = graphModel?.selectElements
|
||||||
|
? Array.from(graphModel.selectElements.keys())
|
||||||
|
: [];
|
||||||
|
console.info('[FlowClipboardDebug]', stage, {
|
||||||
|
selectedCount: selectedCount.value,
|
||||||
|
selectNodeIds,
|
||||||
|
selectElementIds,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const resolveResizeHost = () => {
|
const resolveResizeHost = () => {
|
||||||
const container = containerRef.value;
|
const container = containerRef.value;
|
||||||
@@ -145,6 +215,71 @@ const handleWindowResize = () => {
|
|||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function handleRightDragMouseMove(event: MouseEvent) {
|
||||||
|
if (!isRightDragging) return;
|
||||||
|
|
||||||
|
const deltaX = event.clientX - rightDragLastX;
|
||||||
|
const deltaY = event.clientY - rightDragLastY;
|
||||||
|
rightDragLastX = event.clientX;
|
||||||
|
rightDragLastY = event.clientY;
|
||||||
|
|
||||||
|
if (deltaX === 0 && deltaY === 0) return;
|
||||||
|
|
||||||
|
rightDragDistance += Math.abs(deltaX) + Math.abs(deltaY);
|
||||||
|
if (!rightDragMoved && rightDragDistance >= RIGHT_DRAG_THRESHOLD) {
|
||||||
|
rightDragMoved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightDragMoved) {
|
||||||
|
lf.value?.translate(deltaX, deltaY);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRightDrag() {
|
||||||
|
if (!isRightDragging) return;
|
||||||
|
|
||||||
|
isRightDragging = false;
|
||||||
|
flowHostRef.value?.classList.remove('flow-container--panning');
|
||||||
|
window.removeEventListener('mousemove', handleRightDragMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleRightDragMouseUp);
|
||||||
|
|
||||||
|
if (rightDragMoved) {
|
||||||
|
suppressContextMenuUntil = Date.now() + RIGHT_DRAG_CONTEXTMENU_SUPPRESS_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRightDragMouseUp() {
|
||||||
|
stopRightDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCanvasMouseDown(event: MouseEvent) {
|
||||||
|
if (event.button !== RIGHT_MOUSE_BUTTON) return;
|
||||||
|
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target?.closest('.lf-menu')) return;
|
||||||
|
if (!containerRef.value?.contains(target)) return;
|
||||||
|
|
||||||
|
isRightDragging = true;
|
||||||
|
rightDragMoved = false;
|
||||||
|
rightDragDistance = 0;
|
||||||
|
rightDragLastX = event.clientX;
|
||||||
|
rightDragLastY = event.clientY;
|
||||||
|
suppressContextMenuUntil = 0;
|
||||||
|
|
||||||
|
flowHostRef.value?.classList.add('flow-container--panning');
|
||||||
|
window.addEventListener('mousemove', handleRightDragMouseMove);
|
||||||
|
window.addEventListener('mouseup', handleRightDragMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCanvasContextMenu(event: MouseEvent) {
|
||||||
|
if (Date.now() >= suppressContextMenuUntil) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
suppressContextMenuUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
function isInputLike(event?: KeyboardEvent) {
|
function isInputLike(event?: KeyboardEvent) {
|
||||||
const target = event?.target as HTMLElement | null;
|
const target = event?.target as HTMLElement | null;
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
@@ -252,6 +387,48 @@ function normalizeAllNodes() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeLabelInProperties(properties: Record<string, any> | undefined) {
|
||||||
|
if (!properties || !Object.prototype.hasOwnProperty.call(properties, '_label')) {
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
const currentLabel = properties._label;
|
||||||
|
if (!Array.isArray(currentLabel)) {
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
const cleaned = currentLabel.filter((item) => item && typeof item === 'object');
|
||||||
|
if (cleaned.length === currentLabel.length) {
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
if (!cleaned.length) {
|
||||||
|
const { _label, ...rest } = properties;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...properties,
|
||||||
|
_label: cleaned
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeGraphLabels() {
|
||||||
|
const graphModel = lf.value?.graphModel as any;
|
||||||
|
if (!graphModel) return;
|
||||||
|
|
||||||
|
const sanitizeModel = (model: any) => {
|
||||||
|
const props = model?.getProperties?.() ?? model?.properties;
|
||||||
|
if (!props) return;
|
||||||
|
const next = sanitizeLabelInProperties(props);
|
||||||
|
if (!next || next === props) return;
|
||||||
|
if (typeof model.setProperties === 'function') {
|
||||||
|
model.setProperties(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
model.properties = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
(graphModel.nodes ?? []).forEach((model: any) => sanitizeModel(model));
|
||||||
|
(graphModel.edges ?? []).forEach((model: any) => sanitizeModel(model));
|
||||||
|
}
|
||||||
|
|
||||||
function updateNodeMeta(model: BaseNodeModel, updater: (meta: Record<string, any>) => Record<string, any>) {
|
function updateNodeMeta(model: BaseNodeModel, updater: (meta: Record<string, any>) => Record<string, any>) {
|
||||||
const lfInstance = lf.value;
|
const lfInstance = lf.value;
|
||||||
if (!lfInstance) return;
|
if (!lfInstance) return;
|
||||||
@@ -482,6 +659,7 @@ function groupSelectedNodes(event?: KeyboardEvent) {
|
|||||||
models.forEach((model) => {
|
models.forEach((model) => {
|
||||||
updateNodeMeta(model, (meta) => ({ ...meta, groupId }));
|
updateNodeMeta(model, (meta) => ({ ...meta, groupId }));
|
||||||
});
|
});
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +677,7 @@ function ungroupSelectedNodes(event?: KeyboardEvent) {
|
|||||||
return nextMeta;
|
return nextMeta;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,59 +692,6 @@ function handleArrowMove(direction: 'left' | 'right' | 'up' | 'down', event?: Ke
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function remapGroupIds(nodes: GraphData['nodes']) {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
const seed = Date.now().toString(36);
|
|
||||||
nodes.forEach((node, index) => {
|
|
||||||
const meta = ensureMeta((node as any).properties?.meta);
|
|
||||||
if (meta.groupId) {
|
|
||||||
if (!map.has(meta.groupId)) {
|
|
||||||
map.set(meta.groupId, `group_${seed}_${index}`);
|
|
||||||
}
|
|
||||||
meta.groupId = map.get(meta.groupId);
|
|
||||||
}
|
|
||||||
(node as any).properties = { ...(node as any).properties, meta };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCopy(event?: KeyboardEvent) {
|
|
||||||
if (shouldSkipShortcut(event)) return true;
|
|
||||||
const lfInstance = lf.value;
|
|
||||||
if (!lfInstance) return true;
|
|
||||||
const elements = lfInstance.getSelectElements(false);
|
|
||||||
if (!elements.nodes.length && !elements.edges.length) {
|
|
||||||
copyBuffer.value = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const nodes = elements.nodes.map((node) => translateNodeData(JSON.parse(JSON.stringify(node)), COPY_TRANSLATION));
|
|
||||||
const edges = elements.edges.map((edge) => translateEdgeData(JSON.parse(JSON.stringify(edge)), COPY_TRANSLATION));
|
|
||||||
remapGroupIds(nodes);
|
|
||||||
copyBuffer.value = { nodes, edges };
|
|
||||||
nextPasteDistance = COPY_TRANSLATION;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePaste(event?: KeyboardEvent) {
|
|
||||||
if (shouldSkipShortcut(event)) return true;
|
|
||||||
const lfInstance = lf.value;
|
|
||||||
if (!lfInstance || !copyBuffer.value) return true;
|
|
||||||
|
|
||||||
lfInstance.clearSelectElements();
|
|
||||||
const added = lfInstance.addElements(copyBuffer.value, nextPasteDistance);
|
|
||||||
if (added) {
|
|
||||||
added.nodes.forEach((model) => {
|
|
||||||
normalizeNodeModel(model);
|
|
||||||
lfInstance.selectElementById(model.id, true);
|
|
||||||
});
|
|
||||||
added.edges.forEach((edge) => lfInstance.selectElementById(edge.id, true));
|
|
||||||
copyBuffer.value.nodes.forEach((node) => translateNodeData(node, COPY_TRANSLATION));
|
|
||||||
copyBuffer.value.edges.forEach((edge) => translateEdgeData(edge, COPY_TRANSLATION));
|
|
||||||
nextPasteDistance += COPY_TRANSLATION;
|
|
||||||
updateSelectedCount(lfInstance.graphModel);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNodeDrag(args: { data: NodeData; deltaX: number; deltaY: number }) {
|
function handleNodeDrag(args: { data: NodeData; deltaX: number; deltaY: number }) {
|
||||||
const { data, deltaX, deltaY } = args;
|
const { data, deltaX, deltaY } = args;
|
||||||
if (!deltaX && !deltaY) return;
|
if (!deltaX && !deltaY) return;
|
||||||
@@ -584,6 +710,50 @@ function updateSelectedCount(model?: GraphModel) {
|
|||||||
selectedCount.value = graphModel?.selectNodes.length ?? 0;
|
selectedCount.value = graphModel?.selectNodes.length ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshGroupRuleWarnings() {
|
||||||
|
const lfInstance = lf.value;
|
||||||
|
if (!lfInstance) {
|
||||||
|
groupRuleWarnings.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const graphData = lfInstance.getGraphRawData() as GraphData;
|
||||||
|
groupRuleWarnings.value = validateGraphGroupRules(graphData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleGroupRuleValidation(delay = 120) {
|
||||||
|
if (groupRuleValidationTimer) {
|
||||||
|
clearTimeout(groupRuleValidationTimer);
|
||||||
|
}
|
||||||
|
groupRuleValidationTimer = setTimeout(() => {
|
||||||
|
refreshGroupRuleWarnings();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function locateProblemNode(warning: GroupRuleWarning) {
|
||||||
|
const lfInstance = lf.value as any;
|
||||||
|
if (!lfInstance) return;
|
||||||
|
|
||||||
|
const candidateIds = getProblemTargetCandidateIds(warning);
|
||||||
|
const targetId = candidateIds.find((id) => !!lfInstance.getNodeModelById(id));
|
||||||
|
if (!targetId) {
|
||||||
|
showMessage('warning', '未找到告警对应节点,可能已被删除');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
lfInstance.clearSelectElements?.();
|
||||||
|
lfInstance.selectElementById?.(targetId, false, false);
|
||||||
|
lfInstance.focusOn?.(targetId);
|
||||||
|
const nodeData = lfInstance.getNodeDataById?.(targetId);
|
||||||
|
if (nodeData) {
|
||||||
|
selectedNode.value = nodeData;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('定位告警节点失败:', error);
|
||||||
|
showMessage('error', '定位节点失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applySelectionSelect(enabled: boolean) {
|
function applySelectionSelect(enabled: boolean) {
|
||||||
const lfInstance = lf.value as any;
|
const lfInstance = lf.value as any;
|
||||||
if (!lfInstance) return;
|
if (!lfInstance) return;
|
||||||
@@ -715,6 +885,7 @@ onMounted(() => {
|
|||||||
lf.value = new LogicFlow({
|
lf.value = new LogicFlow({
|
||||||
container: containerRef.value,
|
container: containerRef.value,
|
||||||
grid: { type: 'dot', size: 10 },
|
grid: { type: 'dot', size: 10 },
|
||||||
|
stopMoveGraph: true,
|
||||||
allowResize: true,
|
allowResize: true,
|
||||||
allowRotate: true,
|
allowRotate: true,
|
||||||
overlapMode: -1,
|
overlapMode: -1,
|
||||||
@@ -739,6 +910,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
DynamicGroup,
|
||||||
Menu,
|
Menu,
|
||||||
...(props.enableLabel ? [Label] : []),
|
...(props.enableLabel ? [Label] : []),
|
||||||
Snapshot,
|
Snapshot,
|
||||||
@@ -767,8 +939,6 @@ onMounted(() => {
|
|||||||
const lfInstance = lf.value;
|
const lfInstance = lf.value;
|
||||||
if (!lfInstance) return;
|
if (!lfInstance) return;
|
||||||
|
|
||||||
lfInstance.keyboard.off(['cmd + c', 'ctrl + c']);
|
|
||||||
lfInstance.keyboard.off(['cmd + v', 'ctrl + v']);
|
|
||||||
lfInstance.keyboard.off(['backspace']);
|
lfInstance.keyboard.off(['backspace']);
|
||||||
|
|
||||||
const bindShortcut = (keys: string | string[], handler: (event?: KeyboardEvent) => boolean | void) => {
|
const bindShortcut = (keys: string | string[], handler: (event?: KeyboardEvent) => boolean | void) => {
|
||||||
@@ -780,8 +950,6 @@ onMounted(() => {
|
|||||||
bindShortcut(['right'], (event) => handleArrowMove('right', event));
|
bindShortcut(['right'], (event) => handleArrowMove('right', event));
|
||||||
bindShortcut(['up'], (event) => handleArrowMove('up', event));
|
bindShortcut(['up'], (event) => handleArrowMove('up', event));
|
||||||
bindShortcut(['down'], (event) => handleArrowMove('down', event));
|
bindShortcut(['down'], (event) => handleArrowMove('down', event));
|
||||||
bindShortcut(['cmd + c', 'ctrl + c'], handleCopy);
|
|
||||||
bindShortcut(['cmd + v', 'ctrl + v'], handlePaste);
|
|
||||||
bindShortcut(['cmd + g', 'ctrl + g'], groupSelectedNodes);
|
bindShortcut(['cmd + g', 'ctrl + g'], groupSelectedNodes);
|
||||||
bindShortcut(['cmd + u', 'ctrl + u'], ungroupSelectedNodes);
|
bindShortcut(['cmd + u', 'ctrl + u'], ungroupSelectedNodes);
|
||||||
bindShortcut(['cmd + l', 'ctrl + l'], toggleLockSelected);
|
bindShortcut(['cmd + l', 'ctrl + l'], toggleLockSelected);
|
||||||
@@ -816,21 +984,6 @@ onMounted(() => {
|
|||||||
{
|
{
|
||||||
text: '---' // 分隔线
|
text: '---' // 分隔线
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: '复制 (Ctrl+C)',
|
|
||||||
callback() {
|
|
||||||
handleCopy();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: '粘贴 (Ctrl+V)',
|
|
||||||
callback() {
|
|
||||||
handlePaste();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: '---' // 分隔线
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: '组合 (Ctrl+G)',
|
text: '组合 (Ctrl+G)',
|
||||||
callback() {
|
callback() {
|
||||||
@@ -888,11 +1041,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: '粘贴 (Ctrl+V)',
|
text: '提示:使用 Ctrl+V 粘贴',
|
||||||
callback(data: Position) {
|
},
|
||||||
handlePaste();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -900,21 +1050,6 @@ onMounted(() => {
|
|||||||
lfInstance.extension.menu.setMenuByType({
|
lfInstance.extension.menu.setMenuByType({
|
||||||
type: 'lf:defaultSelectionMenu',
|
type: 'lf:defaultSelectionMenu',
|
||||||
menu: [
|
menu: [
|
||||||
{
|
|
||||||
text: '复制 (Ctrl+C)',
|
|
||||||
callback() {
|
|
||||||
handleCopy();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: '粘贴 (Ctrl+V)',
|
|
||||||
callback() {
|
|
||||||
handlePaste();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: '---' // 分隔线
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: '组合 (Ctrl+G)',
|
text: '组合 (Ctrl+G)',
|
||||||
callback() {
|
callback() {
|
||||||
@@ -956,9 +1091,18 @@ onMounted(() => {
|
|||||||
|
|
||||||
registerNodes(lfInstance);
|
registerNodes(lfInstance);
|
||||||
setLogicFlowInstance(lfInstance);
|
setLogicFlowInstance(lfInstance);
|
||||||
|
applySelectionSelect(selectionEnabled.value);
|
||||||
|
containerRef.value?.addEventListener('mousedown', handleCanvasMouseDown);
|
||||||
|
containerRef.value?.addEventListener('contextmenu', handleCanvasContextMenu, true);
|
||||||
|
|
||||||
// 监听所有可能的节点添加事件
|
// 监听所有可能的节点添加事件
|
||||||
lfInstance.on(EventType.NODE_ADD, ({ data }) => {
|
lfInstance.on(EventType.NODE_ADD, ({ data }) => {
|
||||||
|
if (!data?.id) {
|
||||||
|
logClipboardDebug('node:add-invalid-payload', {
|
||||||
|
payload: data ?? null
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const model = lfInstance.getNodeModelById(data.id);
|
const model = lfInstance.getNodeModelById(data.id);
|
||||||
if (model) {
|
if (model) {
|
||||||
normalizeNodeModel(model);
|
normalizeNodeModel(model);
|
||||||
@@ -967,10 +1111,12 @@ onMounted(() => {
|
|||||||
// 标记这个节点是新创建的,避免被 normalizeAllNodes 重置
|
// 标记这个节点是新创建的,避免被 normalizeAllNodes 重置
|
||||||
(model as any)._isNewNode = true;
|
(model as any)._isNewNode = true;
|
||||||
}
|
}
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听 DND 添加节点事件
|
// 监听 DND 添加节点事件
|
||||||
lfInstance.on('node:dnd-add', ({ data }) => {
|
lfInstance.on('node:dnd-add', ({ data }) => {
|
||||||
|
if (!data?.id) return;
|
||||||
const model = lfInstance.getNodeModelById(data.id);
|
const model = lfInstance.getNodeModelById(data.id);
|
||||||
if (model) {
|
if (model) {
|
||||||
// 设置新节点的 zIndex 为 1000
|
// 设置新节点的 zIndex 为 1000
|
||||||
@@ -978,10 +1124,14 @@ onMounted(() => {
|
|||||||
// 标记这个节点是新创建的
|
// 标记这个节点是新创建的
|
||||||
(model as any)._isNewNode = true;
|
(model as any)._isNewNode = true;
|
||||||
}
|
}
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
});
|
});
|
||||||
|
|
||||||
lfInstance.on(EventType.GRAPH_RENDERED, () => {
|
lfInstance.on(EventType.GRAPH_RENDERED, () => {
|
||||||
|
sanitizeGraphLabels();
|
||||||
|
applySelectionSelect(selectionEnabled.value);
|
||||||
normalizeAllNodes();
|
normalizeAllNodes();
|
||||||
|
scheduleGroupRuleValidation(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听节点点击事件,更新选中节点
|
// 监听节点点击事件,更新选中节点
|
||||||
@@ -1008,10 +1158,29 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
const model = lfInstance.getNodeModelById(nodeId);
|
const model = lfInstance.getNodeModelById(nodeId);
|
||||||
if (model) normalizeNodeModel(model);
|
if (model) normalizeNodeModel(model);
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
});
|
});
|
||||||
|
|
||||||
lfInstance.on('selection:selected', () => updateSelectedCount());
|
lfInstance.on(EventType.NODE_DELETE, () => {
|
||||||
lfInstance.on('selection:drop', () => updateSelectedCount());
|
scheduleGroupRuleValidation();
|
||||||
|
});
|
||||||
|
lfInstance.on(EventType.EDGE_ADD, () => {
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
|
});
|
||||||
|
lfInstance.on(EventType.EDGE_DELETE, () => {
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
|
});
|
||||||
|
|
||||||
|
lfInstance.on('selection:selected', () => {
|
||||||
|
sanitizeGraphLabels();
|
||||||
|
updateSelectedCount();
|
||||||
|
logClipboardDebug('selection:selected');
|
||||||
|
});
|
||||||
|
lfInstance.on('selection:drop', () => {
|
||||||
|
sanitizeGraphLabels();
|
||||||
|
updateSelectedCount();
|
||||||
|
logClipboardDebug('selection:drop');
|
||||||
|
});
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
queueCanvasResize();
|
queueCanvasResize();
|
||||||
@@ -1028,6 +1197,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', handleWindowResize);
|
window.addEventListener('resize', handleWindowResize);
|
||||||
|
unsubscribeSharedGroupRules = subscribeSharedGroupRulesConfig(() => {
|
||||||
|
scheduleGroupRuleValidation(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(selectionEnabled, (enabled) => {
|
watch(selectionEnabled, (enabled) => {
|
||||||
@@ -1064,6 +1236,15 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('resize', handleWindowResize);
|
window.removeEventListener('resize', handleWindowResize);
|
||||||
containerResizeObserver?.disconnect();
|
containerResizeObserver?.disconnect();
|
||||||
containerResizeObserver = null;
|
containerResizeObserver = null;
|
||||||
|
if (groupRuleValidationTimer) {
|
||||||
|
clearTimeout(groupRuleValidationTimer);
|
||||||
|
groupRuleValidationTimer = null;
|
||||||
|
}
|
||||||
|
unsubscribeSharedGroupRules?.();
|
||||||
|
unsubscribeSharedGroupRules = null;
|
||||||
|
containerRef.value?.removeEventListener('mousedown', handleCanvasMouseDown);
|
||||||
|
containerRef.value?.removeEventListener('contextmenu', handleCanvasContextMenu, true);
|
||||||
|
stopRightDrag();
|
||||||
lf.value?.destroy();
|
lf.value?.destroy();
|
||||||
lf.value = null;
|
lf.value = null;
|
||||||
destroyLogicFlowInstance();
|
destroyLogicFlowInstance();
|
||||||
@@ -1089,6 +1270,9 @@ onBeforeUnmount(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.flow-container--panning :deep(.lf-canvas-overlay) {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1108,6 +1292,10 @@ onBeforeUnmount(() => {
|
|||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.flow-controls--collapsed {
|
||||||
|
padding: 6px;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
.control-row {
|
.control-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1115,6 +1303,9 @@ onBeforeUnmount(() => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
.control-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
.control-row:last-child {
|
.control-row:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -1152,6 +1343,126 @@ onBeforeUnmount(() => {
|
|||||||
.control-hint {
|
.control-hint {
|
||||||
color: #909399;
|
color: #909399;
|
||||||
}
|
}
|
||||||
|
.problems-dock {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 11;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.problems-dock-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: rgba(250, 250, 250, 0.98);
|
||||||
|
border-top: 1px solid #dcdfe6;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.problems-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.problems-tab:hover {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
.problems-badge {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.problems-panel {
|
||||||
|
height: 220px;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
border-top: 1px solid #dcdfe6;
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.problems-header {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
.problems-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.problems-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.problem-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f2f3f5;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.problem-item:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.problem-item:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 1px #93c5fd;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
.problem-severity {
|
||||||
|
width: 56px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
color: #9a3412;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.problem-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.problem-message {
|
||||||
|
color: #303133;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.problem-meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
.context-menu {
|
.context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background: white;
|
background: white;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import TextPanel from './panels/TextPanel.vue';
|
|||||||
import StylePanel from './panels/StylePanel.vue';
|
import StylePanel from './panels/StylePanel.vue';
|
||||||
import AssetSelectorPanel from './panels/AssetSelectorPanel.vue';
|
import AssetSelectorPanel from './panels/AssetSelectorPanel.vue';
|
||||||
import VectorPanel from './panels/VectorPanel.vue';
|
import VectorPanel from './panels/VectorPanel.vue';
|
||||||
|
import DynamicGroupPanel from './panels/DynamicGroupPanel.vue';
|
||||||
import { ASSET_LIBRARIES } from '@/types/nodeTypes';
|
import { ASSET_LIBRARIES } from '@/types/nodeTypes';
|
||||||
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
|
|
||||||
@@ -35,7 +36,8 @@ const panelMap: Record<string, any> = {
|
|||||||
imageNode: ImagePanel,
|
imageNode: ImagePanel,
|
||||||
textNode: TextPanel,
|
textNode: TextPanel,
|
||||||
assetSelector: AssetSelectorPanel,
|
assetSelector: AssetSelectorPanel,
|
||||||
vectorNode: VectorPanel
|
vectorNode: VectorPanel,
|
||||||
|
'dynamic-group': DynamicGroupPanel
|
||||||
};
|
};
|
||||||
|
|
||||||
const panelComponent = computed(() => panelMap[nodeType.value] || null);
|
const panelComponent = computed(() => panelMap[nodeType.value] || null);
|
||||||
@@ -66,7 +68,10 @@ const currentAssetLibrary = computed({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!hasNodeSelected" class="no-selection">
|
<div v-if="!hasNodeSelected" class="no-selection">
|
||||||
|
<div class="no-selection-text">
|
||||||
<p>请选择一个节点以编辑其属性</p>
|
<p>请选择一个节点以编辑其属性</p>
|
||||||
|
<p class="no-selection-tip">素材入口:添加并选中 assetSelector 节点后,点击“选择资产”。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="property-content">
|
<div v-else class="property-content">
|
||||||
@@ -155,6 +160,18 @@ const currentAssetLibrary = computed({
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-selection-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-selection-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.property-content {
|
.property-content {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, onBeforeUnmount } from 'vue';
|
||||||
import { useNodeAppearance } from '@/ts/useNodeAppearance';
|
import { useNodeAppearance } from '@/ts/useNodeAppearance';
|
||||||
|
|
||||||
const vectorConfig = ref({
|
const vectorConfig = ref({
|
||||||
@@ -14,23 +14,57 @@ const vectorConfig = ref({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nodeSize = ref({ width: 200, height: 200 });
|
const nodeSize = ref({ width: 200, height: 200 });
|
||||||
|
let syncRafId: number | null = null;
|
||||||
|
let pendingVectorConfig: Record<string, any> | null = null;
|
||||||
|
let pendingNodeSize: { width: number; height: number } | null = null;
|
||||||
|
|
||||||
|
const flushPendingSync = () => {
|
||||||
|
if (pendingVectorConfig) {
|
||||||
|
Object.assign(vectorConfig.value, pendingVectorConfig);
|
||||||
|
pendingVectorConfig = null;
|
||||||
|
}
|
||||||
|
if (pendingNodeSize) {
|
||||||
|
nodeSize.value = pendingNodeSize;
|
||||||
|
pendingNodeSize = null;
|
||||||
|
}
|
||||||
|
syncRafId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleSync = () => {
|
||||||
|
if (typeof requestAnimationFrame === 'undefined') {
|
||||||
|
flushPendingSync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (syncRafId !== null) {
|
||||||
|
cancelAnimationFrame(syncRafId);
|
||||||
|
}
|
||||||
|
syncRafId = requestAnimationFrame(flushPendingSync);
|
||||||
|
};
|
||||||
|
|
||||||
const { containerStyle } = useNodeAppearance({
|
const { containerStyle } = useNodeAppearance({
|
||||||
onPropsChange(props, node) {
|
onPropsChange(props, node) {
|
||||||
// 同步矢量配置
|
|
||||||
if (props.vector) {
|
if (props.vector) {
|
||||||
Object.assign(vectorConfig.value, props.vector);
|
pendingVectorConfig = { ...props.vector };
|
||||||
}
|
}
|
||||||
// 同步节点尺寸
|
|
||||||
if (node) {
|
if (node) {
|
||||||
nodeSize.value.width = node.width;
|
pendingNodeSize = {
|
||||||
nodeSize.value.height = node.height;
|
width: node.width,
|
||||||
|
height: node.height
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
// 使用 requestAnimationFrame 防抖,减少快速缩放时的重复重绘
|
||||||
|
scheduleSync();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 生成唯一的 pattern ID
|
onBeforeUnmount(() => {
|
||||||
const patternId = computed(() => `vector-pattern-${Math.random().toString(36).substr(2, 9)}`);
|
if (syncRafId !== null && typeof cancelAnimationFrame !== 'undefined') {
|
||||||
|
cancelAnimationFrame(syncRafId);
|
||||||
|
syncRafId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const patternId = `vector-pattern-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
|
||||||
// 生成 SVG 内容
|
// 生成 SVG 内容
|
||||||
const svgContent = computed(() => {
|
const svgContent = computed(() => {
|
||||||
@@ -64,7 +98,7 @@ const svgContent = computed(() => {
|
|||||||
<svg width="${nodeSize.value.width}" height="${nodeSize.value.height}"
|
<svg width="${nodeSize.value.width}" height="${nodeSize.value.height}"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="${patternId.value}"
|
<pattern id="${patternId}"
|
||||||
x="0" y="0"
|
x="0" y="0"
|
||||||
width="${tileWidth}"
|
width="${tileWidth}"
|
||||||
height="${tileHeight}"
|
height="${tileHeight}"
|
||||||
@@ -72,7 +106,7 @@ const svgContent = computed(() => {
|
|||||||
${shapeElement}
|
${shapeElement}
|
||||||
</pattern>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="100%" height="100%" fill="url(#${patternId.value})" />
|
<rect width="100%" height="100%" fill="url(#${patternId})" />
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
|||||||
import { SELECTOR_PRESETS } from '@/configs/selectorPresets';
|
import { SELECTOR_PRESETS } from '@/configs/selectorPresets';
|
||||||
import type { SelectorConfig } from '@/types/selector';
|
import type { SelectorConfig } from '@/types/selector';
|
||||||
import { resolveAssetUrl, resolveAssetUrlsInDataSource } from '@/utils/assetUrl';
|
import { resolveAssetUrl, resolveAssetUrlsInDataSource } from '@/utils/assetUrl';
|
||||||
|
import { deleteCustomAsset, listCustomAssets } from '@/utils/customAssets';
|
||||||
|
import { normalizeSelectedAssetRecord } from '@/utils/graphSchema';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
node: any;
|
node: any;
|
||||||
@@ -33,26 +35,47 @@ const handleOpenSelector = () => {
|
|||||||
|
|
||||||
const imageField = preset.itemRender.imageField;
|
const imageField = preset.itemRender.imageField;
|
||||||
const selectedAsset = node.properties?.selectedAsset || null;
|
const selectedAsset = node.properties?.selectedAsset || null;
|
||||||
const normalizedSelectedAsset = selectedAsset && typeof selectedAsset === 'object'
|
const normalizedSelectedAssetRecord = normalizeSelectedAssetRecord(selectedAsset, library);
|
||||||
|
const normalizedSelectedAsset = normalizedSelectedAssetRecord
|
||||||
? {
|
? {
|
||||||
...selectedAsset,
|
...normalizedSelectedAssetRecord,
|
||||||
[imageField]: resolveAssetUrl(selectedAsset?.[imageField])
|
[imageField]: resolveAssetUrl((selectedAsset as any)?.[imageField])
|
||||||
}
|
}
|
||||||
: selectedAsset;
|
: null;
|
||||||
|
|
||||||
|
const customAssets = listCustomAssets(library);
|
||||||
|
const mergedDataSource = [
|
||||||
|
...(preset.dataSource as any[]),
|
||||||
|
...customAssets
|
||||||
|
];
|
||||||
|
const mergedGroups = [
|
||||||
|
...preset.groups,
|
||||||
|
{ label: '我的素材', name: '__CUSTOM__', filter: (item: any) => !!item?.__userAsset }
|
||||||
|
];
|
||||||
|
|
||||||
const config: SelectorConfig = {
|
const config: SelectorConfig = {
|
||||||
...preset,
|
...preset,
|
||||||
dataSource: resolveAssetUrlsInDataSource(preset.dataSource as any[], imageField),
|
groups: mergedGroups,
|
||||||
currentItem: normalizedSelectedAsset
|
dataSource: resolveAssetUrlsInDataSource(mergedDataSource, imageField),
|
||||||
|
currentItem: normalizedSelectedAsset,
|
||||||
|
assetLibrary: library,
|
||||||
|
allowUserAssetUpload: true,
|
||||||
|
onDeleteUserAsset: (item: any) => {
|
||||||
|
deleteCustomAsset(library, item);
|
||||||
|
},
|
||||||
|
onUserAssetUploaded: () => {
|
||||||
|
// 上传后的数据刷新由选择器内部完成,这里保留扩展钩子。
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
openGenericSelector(config, (selectedItem) => {
|
openGenericSelector(config, (selectedItem) => {
|
||||||
const normalizedSelected = selectedItem && typeof selectedItem === 'object'
|
const normalizedSelectedRecord = normalizeSelectedAssetRecord(selectedItem, library);
|
||||||
|
const normalizedSelected = normalizedSelectedRecord
|
||||||
? {
|
? {
|
||||||
...selectedItem,
|
...normalizedSelectedRecord,
|
||||||
[imageField]: resolveAssetUrl(selectedItem?.[imageField])
|
[imageField]: resolveAssetUrl((selectedItem as any)?.[imageField])
|
||||||
}
|
}
|
||||||
: selectedItem;
|
: null;
|
||||||
|
|
||||||
lf.setProperties(node.id, {
|
lf.setProperties(node.id, {
|
||||||
...node.properties,
|
...node.properties,
|
||||||
|
|||||||
76
src/components/flow/panels/DynamicGroupPanel.vue
Normal file
76
src/components/flow/panels/DynamicGroupPanel.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue';
|
||||||
|
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
|
import {
|
||||||
|
GROUP_META_VERSION,
|
||||||
|
normalizeDynamicGroupMeta
|
||||||
|
} from '@/utils/graphSchema';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
node: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type DynamicGroupMeta = {
|
||||||
|
groupKind: 'team' | 'shikigami';
|
||||||
|
ruleEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = reactive<DynamicGroupMeta>({
|
||||||
|
groupKind: 'team',
|
||||||
|
ruleEnabled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncFromNode = (node?: any) => {
|
||||||
|
if (!node) return;
|
||||||
|
const groupMeta = normalizeDynamicGroupMeta(node.properties?.groupMeta);
|
||||||
|
form.groupKind = groupMeta.groupKind;
|
||||||
|
form.ruleEnabled = groupMeta.ruleEnabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.node,
|
||||||
|
(node) => {
|
||||||
|
if (node) {
|
||||||
|
syncFromNode(node);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyGroupMeta = () => {
|
||||||
|
const lf = getLogicFlowInstance();
|
||||||
|
const node = props.node;
|
||||||
|
if (!lf || !node) return;
|
||||||
|
|
||||||
|
lf.setProperties(node.id, {
|
||||||
|
...(node.properties || {}),
|
||||||
|
groupMeta: {
|
||||||
|
version: GROUP_META_VERSION,
|
||||||
|
groupKind: form.groupKind,
|
||||||
|
ruleEnabled: form.ruleEnabled
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="property-section">
|
||||||
|
<div class="section-header">分组规则属性</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">分组类型</div>
|
||||||
|
<el-select v-model="form.groupKind" style="width: 100%" @change="applyGroupMeta">
|
||||||
|
<el-option label="队伍组(team)" value="team" />
|
||||||
|
<el-option label="式神组(shikigami)" value="shikigami" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">启用规则检查</div>
|
||||||
|
<el-switch v-model="form.ruleEnabled" @change="applyGroupMeta" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
76
src/configs/groupRules.ts
Normal file
76
src/configs/groupRules.ts
Normal file
@@ -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: '破势,狂骨,针女,海月火玉'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -20,6 +20,13 @@ export const NODE_REGISTRY: Record<NodeType, NodeTypeConfig> = {
|
|||||||
description: '椭圆容器,可设置背景和边框'
|
description: '椭圆容器,可设置背景和边框'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[NodeType.DYNAMIC_GROUP]: {
|
||||||
|
type: NodeType.DYNAMIC_GROUP,
|
||||||
|
category: NodeCategory.LAYOUT,
|
||||||
|
label: '动态分组',
|
||||||
|
description: '支持折叠/收起与节点归组的容器'
|
||||||
|
},
|
||||||
|
|
||||||
[NodeType.ASSET_SELECTOR]: {
|
[NodeType.ASSET_SELECTOR]: {
|
||||||
type: NodeType.ASSET_SELECTOR,
|
type: NodeType.ASSET_SELECTOR,
|
||||||
category: NodeCategory.ASSET,
|
category: NodeCategory.ASSET,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type LogicFlow from '@logicflow/core'
|
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 { register } from '@logicflow/vue-node-registry'
|
||||||
|
|
||||||
import ImageNode from './components/flow/nodes/common/ImageNode.vue'
|
import ImageNode from './components/flow/nodes/common/ImageNode.vue'
|
||||||
@@ -27,8 +27,9 @@ const DEFAULT_FLOW_NODES: FlowNodeRegistration[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const FLOW_PLUGIN_PRESETS: Record<FlowCapabilityLevel, FlowPlugin[]> = {
|
const FLOW_PLUGIN_PRESETS: Record<FlowCapabilityLevel, FlowPlugin[]> = {
|
||||||
'render-only': [Snapshot],
|
// 预览模式也需要 DynamicGroup,避免包含 dynamic-group 节点的图在只读渲染时报错
|
||||||
interactive: [Menu, Label, Snapshot, SelectionSelect, MiniMap, Control]
|
'render-only': [DynamicGroup, Snapshot],
|
||||||
|
interactive: [DynamicGroup, Menu, Label, Snapshot, SelectionSelect, MiniMap, Control]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFlowPluginsByCapability(capability: FlowCapabilityLevel): FlowPlugin[] {
|
export function getFlowPluginsByCapability(capability: FlowCapabilityLevel): FlowPlugin[] {
|
||||||
@@ -60,4 +61,3 @@ export function registerFlowNodes(lfInstance: LogicFlow, nodes?: FlowNodeRegistr
|
|||||||
const registrations = resolveFlowNodes(nodes)
|
const registrations = resolveFlowNodes(nodes)
|
||||||
registrations.forEach((registration) => register(registration, lfInstance))
|
registrations.forEach((registration) => register(registration, lfInstance))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import 'element-plus/dist/index.css'
|
|||||||
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css'
|
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css'
|
||||||
import YysEditorEmbed from './YysEditorEmbed.vue'
|
import YysEditorEmbed from './YysEditorEmbed.vue'
|
||||||
export { setAssetBaseUrl, getAssetBaseUrl, resolveAssetUrl } from './utils/assetUrl'
|
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 }
|
export { YysEditorEmbed }
|
||||||
|
|||||||
@@ -56,6 +56,22 @@ export interface NodeMeta {
|
|||||||
export interface NodeProperties {
|
export interface NodeProperties {
|
||||||
style: NodeStyle;
|
style: NodeStyle;
|
||||||
meta?: NodeMeta;
|
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' };
|
image?: { url: string; fit?: 'fill'|'contain'|'cover' };
|
||||||
text?: { content: string; rich?: boolean };
|
text?: { content: string; rich?: boolean };
|
||||||
vector?: {
|
vector?: {
|
||||||
@@ -81,6 +97,7 @@ export interface GraphNode {
|
|||||||
y?: number;
|
y?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
children?: string[];
|
||||||
properties: NodeProperties;
|
properties: NodeProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {ElMessageBox} from "element-plus";
|
|||||||
import {useGlobalMessage} from "./useGlobalMessage";
|
import {useGlobalMessage} from "./useGlobalMessage";
|
||||||
import {getLogicFlowInstance} from "./useLogicFlow";
|
import {getLogicFlowInstance} from "./useLogicFlow";
|
||||||
import {CURRENT_SCHEMA_VERSION, migrateToV1, RootDocument} from "./schema";
|
import {CURRENT_SCHEMA_VERSION, migrateToV1, RootDocument} from "./schema";
|
||||||
|
import { normalizeGraphRawDataSchema } from '@/utils/graphSchema';
|
||||||
|
|
||||||
const {showMessage} = useGlobalMessage();
|
const {showMessage} = useGlobalMessage();
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ export const useFilesStore = defineStore('files', () => {
|
|||||||
name: f?.name ?? f?.label ?? `File ${i + 1}`,
|
name: f?.name ?? f?.label ?? `File ${i + 1}`,
|
||||||
visible: f?.visible ?? true,
|
visible: f?.visible ?? true,
|
||||||
type: f?.type ?? 'FLOW',
|
type: f?.type ?? 'FLOW',
|
||||||
graphRawData: (f?.graphRawData && typeof f.graphRawData === 'object') ? f.graphRawData : { nodes: [], edges: [] },
|
graphRawData: normalizeGraphRawDataSchema(f?.graphRawData),
|
||||||
transform: f?.transform ?? {
|
transform: f?.transform ?? {
|
||||||
SCALE_X: 1,
|
SCALE_X: 1,
|
||||||
SCALE_Y: 1,
|
SCALE_Y: 1,
|
||||||
@@ -333,11 +334,12 @@ export const useFilesStore = defineStore('files', () => {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
const normalizedGraphData = normalizeGraphRawDataSchema(enrichedGraphData);
|
||||||
|
|
||||||
// 直接保存原始数据到 GraphRawData
|
// 直接保存原始数据到 GraphRawData
|
||||||
const file = findById(targetId);
|
const file = findById(targetId);
|
||||||
if (file) {
|
if (file) {
|
||||||
file.graphRawData = enrichedGraphData;
|
file.graphRawData = normalizedGraphData;
|
||||||
file.transform = transform;
|
file.transform = transform;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export enum NodeType {
|
|||||||
// 布局容器类
|
// 布局容器类
|
||||||
RECT = 'rect',
|
RECT = 'rect',
|
||||||
ELLIPSE = 'ellipse',
|
ELLIPSE = 'ellipse',
|
||||||
|
DYNAMIC_GROUP = 'dynamic-group',
|
||||||
|
|
||||||
// 图形资产类(统一入口,内部切换资产库)
|
// 图形资产类(统一入口,内部切换资产库)
|
||||||
ASSET_SELECTOR = 'assetSelector',
|
ASSET_SELECTOR = 'assetSelector',
|
||||||
|
|||||||
@@ -20,4 +20,8 @@ export interface SelectorConfig<T = any> {
|
|||||||
searchable?: boolean
|
searchable?: boolean
|
||||||
searchFields?: string[]
|
searchFields?: string[]
|
||||||
currentItem?: T | null
|
currentItem?: T | null
|
||||||
|
assetLibrary?: string
|
||||||
|
allowUserAssetUpload?: boolean
|
||||||
|
onDeleteUserAsset?: (item: T) => void
|
||||||
|
onUserAssetUploaded?: (item: T) => void
|
||||||
}
|
}
|
||||||
226
src/utils/customAssets.ts
Normal file
226
src/utils/customAssets.ts
Normal file
@@ -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<string, CustomAssetItem[]>
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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<string> => 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<Partial<CustomAssetItem>, '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<CustomAssetItem> => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/utils/graphSchema.ts
Normal file
155
src/utils/graphSchema.ts
Normal file
@@ -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<string, unknown> : {}
|
||||||
|
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<string, unknown> | null => {
|
||||||
|
if (!input || typeof input !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = input as Record<string, unknown>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
276
src/utils/groupRules.ts
Normal file
276
src/utils/groupRules.ts
Normal file
@@ -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<string, string[]>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string[]> => {
|
||||||
|
const map: Record<string, string[]> = {}
|
||||||
|
config.ruleVariables.forEach((item) => {
|
||||||
|
const key = normalizeText(item.key)
|
||||||
|
if (!key) return
|
||||||
|
map[key] = dedupeNodeIds(parseVariableValue(item.value))
|
||||||
|
})
|
||||||
|
map.fireShikigamiWhitelist = [...config.fireShikigamiWhitelist]
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectTeamAssetSnapshots = (graphData: GraphData): TeamAssetSnapshot[] => {
|
||||||
|
const nodes = Array.isArray(graphData?.nodes) ? graphData.nodes : []
|
||||||
|
const nodeMap = new Map<string, any>()
|
||||||
|
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<string>()
|
||||||
|
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
|
||||||
|
}
|
||||||
232
src/utils/groupRulesConfigSource.ts
Normal file
232
src/utils/groupRulesConfigSource.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
const id = normalizeText(raw.id)
|
||||||
|
const condition = normalizeText(raw.condition)
|
||||||
|
const message = normalizeText(raw.message)
|
||||||
|
if (!id || !condition || !message) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const enabled = raw.enabled !== false
|
||||||
|
const severityRaw = normalizeText(raw.severity)
|
||||||
|
const severity = severityRaw === 'error' || severityRaw === 'info' ? severityRaw : 'warning'
|
||||||
|
const code = normalizeText(raw.code)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
condition,
|
||||||
|
message,
|
||||||
|
enabled,
|
||||||
|
severity,
|
||||||
|
...(code ? { code } : {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is ExpressionRuleDefinition => !!item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeRuleVariables = (
|
||||||
|
value: unknown,
|
||||||
|
fallback: RuleVariableDefinition[]
|
||||||
|
): RuleVariableDefinition[] => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return fallback.map((item) => ({ ...item }))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const raw = item as Record<string, unknown>
|
||||||
|
const key = normalizeText(raw.key)
|
||||||
|
if (!key) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const value = typeof raw.value === 'string' ? raw.value : String(raw.value ?? '')
|
||||||
|
return { key, value }
|
||||||
|
})
|
||||||
|
.filter((item): item is RuleVariableDefinition => !!item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeGroupRulesConfig = (input: unknown): GroupRulesConfig | null => {
|
||||||
|
if (!input || typeof input !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = input as Record<string, unknown>
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/utils/problemTarget.ts
Normal file
19
src/utils/problemTarget.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { GroupRuleWarning } from '@/utils/groupRules'
|
||||||
|
|
||||||
|
const normalizeIds = (value: unknown): string[] => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === 'string' && !!item.trim())
|
||||||
|
.map((item) => item.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeIds = (ids: string[]): string[] => Array.from(new Set(ids))
|
||||||
|
|
||||||
|
export const getProblemTargetCandidateIds = (warning: GroupRuleWarning): string[] => {
|
||||||
|
const nodeIds = normalizeIds(warning.nodeIds)
|
||||||
|
const groupId = typeof warning.groupId === 'string' ? warning.groupId.trim() : ''
|
||||||
|
const groupIds = groupId ? [groupId] : []
|
||||||
|
return dedupeIds([...groupIds, ...nodeIds])
|
||||||
|
}
|
||||||
539
src/utils/ruleExpression.ts
Normal file
539
src/utils/ruleExpression.ts
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
type TokenType = 'identifier' | 'number' | 'string' | 'boolean' | 'null' | 'operator' | 'punctuation' | 'eof'
|
||||||
|
|
||||||
|
type Token = {
|
||||||
|
type: TokenType
|
||||||
|
value: string
|
||||||
|
position: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpressionNode =
|
||||||
|
| { type: 'Literal'; value: unknown }
|
||||||
|
| { type: 'ArrayExpression'; elements: ExpressionNode[] }
|
||||||
|
| { type: 'IdentifierPath'; path: string[] }
|
||||||
|
| { type: 'CallExpression'; callee: string; args: ExpressionNode[] }
|
||||||
|
| { type: 'UnaryExpression'; operator: '!' | '-'; argument: ExpressionNode }
|
||||||
|
| {
|
||||||
|
type: 'BinaryExpression'
|
||||||
|
operator: '||' | '&&' | '==' | '!=' | '>' | '>=' | '<' | '<=' | '+' | '-' | '*' | '/'
|
||||||
|
left: ExpressionNode
|
||||||
|
right: ExpressionNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuleExpressionScope = Record<string, unknown>
|
||||||
|
|
||||||
|
const DEFAULT_MAX_STEPS = 20_000
|
||||||
|
const astCache = new Map<string, ExpressionNode>()
|
||||||
|
|
||||||
|
const isWhitespace = (char: string) => /\s/.test(char)
|
||||||
|
const isDigit = (char: string) => /[0-9]/.test(char)
|
||||||
|
const isIdentifierStart = (char: string) => /[A-Za-z_$\u4E00-\u9FFF]/.test(char)
|
||||||
|
const isIdentifierPart = (char: string) => /[A-Za-z0-9_$\u4E00-\u9FFF]/.test(char)
|
||||||
|
|
||||||
|
const tokenize = (source: string): Token[] => {
|
||||||
|
const tokens: Token[] = []
|
||||||
|
let index = 0
|
||||||
|
|
||||||
|
const readString = (quote: string): Token => {
|
||||||
|
const start = index
|
||||||
|
index += 1
|
||||||
|
let value = ''
|
||||||
|
while (index < source.length) {
|
||||||
|
const current = source[index]
|
||||||
|
if (current === '\\') {
|
||||||
|
const next = source[index + 1]
|
||||||
|
if (!next) {
|
||||||
|
throw new Error(`字符串转义不完整(位置 ${index})`)
|
||||||
|
}
|
||||||
|
value += next
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (current === quote) {
|
||||||
|
index += 1
|
||||||
|
return { type: 'string', value, position: start }
|
||||||
|
}
|
||||||
|
value += current
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
throw new Error(`字符串缺少结束引号(位置 ${start})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const readNumber = (): Token => {
|
||||||
|
const start = index
|
||||||
|
let value = ''
|
||||||
|
let dotSeen = false
|
||||||
|
while (index < source.length) {
|
||||||
|
const current = source[index]
|
||||||
|
if (current === '.') {
|
||||||
|
if (dotSeen) break
|
||||||
|
dotSeen = true
|
||||||
|
value += current
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!isDigit(current)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value += current
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
return { type: 'number', value, position: start }
|
||||||
|
}
|
||||||
|
|
||||||
|
const readIdentifier = (): Token => {
|
||||||
|
const start = index
|
||||||
|
let value = ''
|
||||||
|
while (index < source.length && isIdentifierPart(source[index])) {
|
||||||
|
value += source[index]
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
if (value === 'true' || value === 'false') {
|
||||||
|
return { type: 'boolean', value, position: start }
|
||||||
|
}
|
||||||
|
if (value === 'null') {
|
||||||
|
return { type: 'null', value, position: start }
|
||||||
|
}
|
||||||
|
return { type: 'identifier', value, position: start }
|
||||||
|
}
|
||||||
|
|
||||||
|
const readOperatorOrPunctuation = (): Token => {
|
||||||
|
const start = index
|
||||||
|
const twoChars = source.slice(index, index + 2)
|
||||||
|
const twoCharOperators = ['&&', '||', '==', '!=', '>=', '<=']
|
||||||
|
if (twoCharOperators.includes(twoChars)) {
|
||||||
|
index += 2
|
||||||
|
return { type: 'operator', value: twoChars, position: start }
|
||||||
|
}
|
||||||
|
|
||||||
|
const oneChar = source[index]
|
||||||
|
const oneCharOperators = ['>', '<', '!', '+', '-', '*', '/']
|
||||||
|
if (oneCharOperators.includes(oneChar)) {
|
||||||
|
index += 1
|
||||||
|
return { type: 'operator', value: oneChar, position: start }
|
||||||
|
}
|
||||||
|
|
||||||
|
const punctuations = ['(', ')', '[', ']', ',', '.']
|
||||||
|
if (punctuations.includes(oneChar)) {
|
||||||
|
index += 1
|
||||||
|
return { type: 'punctuation', value: oneChar, position: start }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`无法识别的字符 "${oneChar}"(位置 ${start})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (index < source.length) {
|
||||||
|
const current = source[index]
|
||||||
|
if (isWhitespace(current)) {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (current === '"' || current === "'") {
|
||||||
|
tokens.push(readString(current))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isDigit(current)) {
|
||||||
|
tokens.push(readNumber())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isIdentifierStart(current)) {
|
||||||
|
tokens.push(readIdentifier())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tokens.push(readOperatorOrPunctuation())
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push({ type: 'eof', value: '', position: source.length })
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parser {
|
||||||
|
private readonly tokens: Token[]
|
||||||
|
private cursor = 0
|
||||||
|
|
||||||
|
constructor(source: string) {
|
||||||
|
this.tokens = tokenize(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(): ExpressionNode {
|
||||||
|
const expression = this.parseOrExpression()
|
||||||
|
this.expect('eof')
|
||||||
|
return expression
|
||||||
|
}
|
||||||
|
|
||||||
|
private current(): Token {
|
||||||
|
return this.tokens[this.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
private consume(): Token {
|
||||||
|
const token = this.tokens[this.cursor]
|
||||||
|
this.cursor += 1
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
private match(type: TokenType, value?: string): boolean {
|
||||||
|
const token = this.current()
|
||||||
|
if (token.type !== type) return false
|
||||||
|
if (value != null && token.value !== value) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private expect(type: TokenType, value?: string): Token {
|
||||||
|
const token = this.current()
|
||||||
|
if (!this.match(type, value)) {
|
||||||
|
const expected = value == null ? type : `${type}:${value}`
|
||||||
|
throw new Error(`表达式解析失败,期望 ${expected},实际 ${token.type}:${token.value}(位置 ${token.position})`)
|
||||||
|
}
|
||||||
|
return this.consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseOrExpression(): ExpressionNode {
|
||||||
|
let left = this.parseAndExpression()
|
||||||
|
while (this.match('operator', '||')) {
|
||||||
|
const operator = this.consume().value as '||'
|
||||||
|
const right = this.parseAndExpression()
|
||||||
|
left = { type: 'BinaryExpression', operator, left, right }
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAndExpression(): ExpressionNode {
|
||||||
|
let left = this.parseEqualityExpression()
|
||||||
|
while (this.match('operator', '&&')) {
|
||||||
|
const operator = this.consume().value as '&&'
|
||||||
|
const right = this.parseEqualityExpression()
|
||||||
|
left = { type: 'BinaryExpression', operator, left, right }
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseEqualityExpression(): ExpressionNode {
|
||||||
|
let left = this.parseComparisonExpression()
|
||||||
|
while (this.match('operator', '==') || this.match('operator', '!=')) {
|
||||||
|
const operator = this.consume().value as '==' | '!='
|
||||||
|
const right = this.parseComparisonExpression()
|
||||||
|
left = { type: 'BinaryExpression', operator, left, right }
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseComparisonExpression(): ExpressionNode {
|
||||||
|
let left = this.parseAdditiveExpression()
|
||||||
|
while (
|
||||||
|
this.match('operator', '>') ||
|
||||||
|
this.match('operator', '>=') ||
|
||||||
|
this.match('operator', '<') ||
|
||||||
|
this.match('operator', '<=')
|
||||||
|
) {
|
||||||
|
const operator = this.consume().value as '>' | '>=' | '<' | '<='
|
||||||
|
const right = this.parseAdditiveExpression()
|
||||||
|
left = { type: 'BinaryExpression', operator, left, right }
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAdditiveExpression(): ExpressionNode {
|
||||||
|
let left = this.parseMultiplicativeExpression()
|
||||||
|
while (this.match('operator', '+') || this.match('operator', '-')) {
|
||||||
|
const operator = this.consume().value as '+' | '-'
|
||||||
|
const right = this.parseMultiplicativeExpression()
|
||||||
|
left = { type: 'BinaryExpression', operator, left, right }
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseMultiplicativeExpression(): ExpressionNode {
|
||||||
|
let left = this.parseUnaryExpression()
|
||||||
|
while (this.match('operator', '*') || this.match('operator', '/')) {
|
||||||
|
const operator = this.consume().value as '*' | '/'
|
||||||
|
const right = this.parseUnaryExpression()
|
||||||
|
left = { type: 'BinaryExpression', operator, left, right }
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUnaryExpression(): ExpressionNode {
|
||||||
|
if (this.match('operator', '!') || this.match('operator', '-')) {
|
||||||
|
const operator = this.consume().value as '!' | '-'
|
||||||
|
const argument = this.parseUnaryExpression()
|
||||||
|
return { type: 'UnaryExpression', operator, argument }
|
||||||
|
}
|
||||||
|
return this.parsePrimaryExpression()
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePrimaryExpression(): ExpressionNode {
|
||||||
|
const token = this.current()
|
||||||
|
if (token.type === 'number') {
|
||||||
|
this.consume()
|
||||||
|
return { type: 'Literal', value: Number(token.value) }
|
||||||
|
}
|
||||||
|
if (token.type === 'string') {
|
||||||
|
this.consume()
|
||||||
|
return { type: 'Literal', value: token.value }
|
||||||
|
}
|
||||||
|
if (token.type === 'boolean') {
|
||||||
|
this.consume()
|
||||||
|
return { type: 'Literal', value: token.value === 'true' }
|
||||||
|
}
|
||||||
|
if (token.type === 'null') {
|
||||||
|
this.consume()
|
||||||
|
return { type: 'Literal', value: null }
|
||||||
|
}
|
||||||
|
if (this.match('punctuation', '[')) {
|
||||||
|
return this.parseArrayExpression()
|
||||||
|
}
|
||||||
|
if (this.match('punctuation', '(')) {
|
||||||
|
this.consume()
|
||||||
|
const expression = this.parseOrExpression()
|
||||||
|
this.expect('punctuation', ')')
|
||||||
|
return expression
|
||||||
|
}
|
||||||
|
if (token.type === 'identifier') {
|
||||||
|
return this.parseIdentifierPathOrCall()
|
||||||
|
}
|
||||||
|
throw new Error(`表达式解析失败,无法识别 token ${token.type}:${token.value}(位置 ${token.position})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseArrayExpression(): ExpressionNode {
|
||||||
|
this.expect('punctuation', '[')
|
||||||
|
const elements: ExpressionNode[] = []
|
||||||
|
if (!this.match('punctuation', ']')) {
|
||||||
|
while (true) {
|
||||||
|
elements.push(this.parseOrExpression())
|
||||||
|
if (!this.match('punctuation', ',')) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
this.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.expect('punctuation', ']')
|
||||||
|
return { type: 'ArrayExpression', elements }
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseIdentifierPathOrCall(): ExpressionNode {
|
||||||
|
const head = this.expect('identifier').value
|
||||||
|
if (this.match('punctuation', '(')) {
|
||||||
|
this.consume()
|
||||||
|
const args: ExpressionNode[] = []
|
||||||
|
if (!this.match('punctuation', ')')) {
|
||||||
|
while (true) {
|
||||||
|
args.push(this.parseOrExpression())
|
||||||
|
if (!this.match('punctuation', ',')) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
this.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.expect('punctuation', ')')
|
||||||
|
return { type: 'CallExpression', callee: head, args }
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = [head]
|
||||||
|
while (this.match('punctuation', '.')) {
|
||||||
|
this.consume()
|
||||||
|
path.push(this.expect('identifier').value)
|
||||||
|
}
|
||||||
|
return { type: 'IdentifierPath', path }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuiltinFunction = (...args: unknown[]) => unknown
|
||||||
|
|
||||||
|
const toArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
|
||||||
|
const toString = (value: unknown): string => (typeof value === 'string' ? value : String(value ?? ''))
|
||||||
|
|
||||||
|
const builtins: Record<string, BuiltinFunction> = {
|
||||||
|
count(value) {
|
||||||
|
if (Array.isArray(value) || typeof value === 'string') return value.length
|
||||||
|
if (value && typeof value === 'object') return Object.keys(value).length
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
contains(collection, target) {
|
||||||
|
if (Array.isArray(collection)) {
|
||||||
|
return collection.includes(target)
|
||||||
|
}
|
||||||
|
if (typeof collection === 'string') {
|
||||||
|
return collection.includes(toString(target))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
intersect(left, right) {
|
||||||
|
const rightSet = new Set(toArray(right))
|
||||||
|
const result: unknown[] = []
|
||||||
|
toArray(left).forEach((item) => {
|
||||||
|
if (rightSet.has(item) && !result.includes(item)) {
|
||||||
|
result.push(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
map(collection, key) {
|
||||||
|
const keyName = typeof key === 'string' ? key : ''
|
||||||
|
if (!keyName) return []
|
||||||
|
return toArray(collection).map((item: any) => item?.[keyName])
|
||||||
|
},
|
||||||
|
unique(collection) {
|
||||||
|
return Array.from(new Set(toArray(collection)))
|
||||||
|
},
|
||||||
|
exists(value) {
|
||||||
|
if (Array.isArray(value)) return value.length > 0
|
||||||
|
return value != null && value !== ''
|
||||||
|
},
|
||||||
|
lower(value) {
|
||||||
|
return toString(value).toLowerCase()
|
||||||
|
},
|
||||||
|
upper(value) {
|
||||||
|
return toString(value).toUpperCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTruthy = (value: unknown): boolean => !!value
|
||||||
|
|
||||||
|
const resolveIdentifierPath = (scope: RuleExpressionScope, path: string[]): unknown => {
|
||||||
|
let current: unknown = scope
|
||||||
|
for (const segment of path) {
|
||||||
|
if (current == null || typeof current !== 'object') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
current = (current as Record<string, unknown>)[segment]
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveVarFromScope = (scope: RuleExpressionScope, key: unknown): unknown[] => {
|
||||||
|
const keyName = typeof key === 'string' ? key.trim() : ''
|
||||||
|
if (!keyName) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const vars = resolveIdentifierPath(scope, ['shared', 'vars'])
|
||||||
|
if (!vars || typeof vars !== 'object') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const value = (vars as Record<string, unknown>)[keyName]
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value == null || value === '') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [toString(value)]
|
||||||
|
}
|
||||||
|
|
||||||
|
type EvaluationState = {
|
||||||
|
steps: number
|
||||||
|
maxSteps: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluateNode = (node: ExpressionNode, scope: RuleExpressionScope, state: EvaluationState): unknown => {
|
||||||
|
state.steps += 1
|
||||||
|
if (state.steps > state.maxSteps) {
|
||||||
|
throw new Error('表达式执行超出步骤限制')
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'Literal':
|
||||||
|
return node.value
|
||||||
|
case 'ArrayExpression':
|
||||||
|
return node.elements.map((item) => evaluateNode(item, scope, state))
|
||||||
|
case 'IdentifierPath':
|
||||||
|
return resolveIdentifierPath(scope, node.path)
|
||||||
|
case 'CallExpression': {
|
||||||
|
if (node.callee === 'getVar') {
|
||||||
|
if (node.args.length !== 1) {
|
||||||
|
throw new Error('getVar 仅支持一个参数:变量 key')
|
||||||
|
}
|
||||||
|
const key = evaluateNode(node.args[0], scope, state)
|
||||||
|
return resolveVarFromScope(scope, key)
|
||||||
|
}
|
||||||
|
const fn = builtins[node.callee]
|
||||||
|
if (!fn) {
|
||||||
|
throw new Error(`不支持的函数调用: ${node.callee}`)
|
||||||
|
}
|
||||||
|
const args = node.args.map((arg) => evaluateNode(arg, scope, state))
|
||||||
|
return fn(...args)
|
||||||
|
}
|
||||||
|
case 'UnaryExpression': {
|
||||||
|
const value = evaluateNode(node.argument, scope, state)
|
||||||
|
if (node.operator === '!') return !isTruthy(value)
|
||||||
|
return -Number(value)
|
||||||
|
}
|
||||||
|
case 'BinaryExpression': {
|
||||||
|
if (node.operator === '&&') {
|
||||||
|
const left = evaluateNode(node.left, scope, state)
|
||||||
|
if (!isTruthy(left)) return false
|
||||||
|
return isTruthy(evaluateNode(node.right, scope, state))
|
||||||
|
}
|
||||||
|
if (node.operator === '||') {
|
||||||
|
const left = evaluateNode(node.left, scope, state)
|
||||||
|
if (isTruthy(left)) return true
|
||||||
|
return isTruthy(evaluateNode(node.right, scope, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = evaluateNode(node.left, scope, state)
|
||||||
|
const right = evaluateNode(node.right, scope, state)
|
||||||
|
|
||||||
|
switch (node.operator) {
|
||||||
|
case '==':
|
||||||
|
return left === right
|
||||||
|
case '!=':
|
||||||
|
return left !== right
|
||||||
|
case '>':
|
||||||
|
return Number(left) > Number(right)
|
||||||
|
case '>=':
|
||||||
|
return Number(left) >= Number(right)
|
||||||
|
case '<':
|
||||||
|
return Number(left) < Number(right)
|
||||||
|
case '<=':
|
||||||
|
return Number(left) <= Number(right)
|
||||||
|
case '+':
|
||||||
|
return Number(left) + Number(right)
|
||||||
|
case '-':
|
||||||
|
return Number(left) - Number(right)
|
||||||
|
case '*':
|
||||||
|
return Number(left) * Number(right)
|
||||||
|
case '/':
|
||||||
|
return Number(left) / Number(right)
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseRuleExpression = (source: string): ExpressionNode => {
|
||||||
|
const key = source.trim()
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('表达式不能为空')
|
||||||
|
}
|
||||||
|
const cached = astCache.get(key)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
const parser = new Parser(key)
|
||||||
|
const ast = parser.parse()
|
||||||
|
astCache.set(key, ast)
|
||||||
|
return ast
|
||||||
|
}
|
||||||
|
|
||||||
|
export const evaluateRuleExpression = (
|
||||||
|
source: string,
|
||||||
|
scope: RuleExpressionScope,
|
||||||
|
options?: { maxSteps?: number }
|
||||||
|
): unknown => {
|
||||||
|
const ast = parseRuleExpression(source)
|
||||||
|
const state: EvaluationState = {
|
||||||
|
steps: 0,
|
||||||
|
maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS
|
||||||
|
}
|
||||||
|
return evaluateNode(ast, scope, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const evaluateRuleExpressionAsBoolean = (
|
||||||
|
source: string,
|
||||||
|
scope: RuleExpressionScope,
|
||||||
|
options?: { maxSteps?: number }
|
||||||
|
): boolean => {
|
||||||
|
const result = evaluateRuleExpression(source, scope, options)
|
||||||
|
return isTruthy(result)
|
||||||
|
}
|
||||||
@@ -3,8 +3,15 @@ import { fileURLToPath, URL } from 'node:url'
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
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/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: normalizeBase(process.env.VITE_APP_BASE_URL || '/'),
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user