mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-03-05 15:05:27 +00:00
feat: custom assets + group rules + perf + docs
This commit is contained in:
@@ -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%(核心功能)
|
**总体完成度:** 92%(核心功能完成,集成与质量收尾中)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -206,30 +206,30 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🎨 阶段 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] 优化错误处理
|
||||||
- [ ] 优化加载性能
|
- [ ] 优化加载性能
|
||||||
|
|
||||||
**验收标准:**
|
**验收标准:**
|
||||||
- 在 wiki 中可以正常使用
|
- 在 wiki 中可以正常使用(已达成)
|
||||||
- 预览/编辑切换流畅
|
- 预览/编辑切换流畅(已达成)
|
||||||
- 数据保存正确
|
- 数据保存正确(已达成)
|
||||||
- 体验类似 Notion 块
|
- 体验类似 Notion 块(进行中,持续优化)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -301,12 +301,12 @@ wiki 文档
|
|||||||
|
|
||||||
**完成时间:** 2026-02-20
|
**完成时间:** 2026-02-20
|
||||||
|
|
||||||
### Milestone 3:wiki 集成(待开发)
|
### Milestone 3:wiki 集成(进行中)
|
||||||
- [ ] 本地引用测试
|
- [x] 本地引用测试
|
||||||
- [ ] 交互优化
|
- [~] 交互优化(已完成主要问题修复,继续打磨性能)
|
||||||
- [ ] 文档完善
|
- [ ] 文档完善
|
||||||
|
|
||||||
**预计完成:** 与 wiki 同步
|
**预计完成:** 2026-03 第 1 周(随 wiki 联调收尾)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -428,6 +428,11 @@ const handleCancel = () => {
|
|||||||
|
|
||||||
## 📝 更新日志
|
## 📝 更新日志
|
||||||
|
|
||||||
|
### 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 +458,7 @@ const handleCancel = () => {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**最后更新:** 2026-02-20
|
**最后更新:** 2026-02-26
|
||||||
|
**文档版本:** v2.2.0(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`
|
||||||
|
|
||||||
116
docs/test/acceptance.md
Normal file
116
docs/test/acceptance.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# yys-editor 验收测试点(手工)
|
||||||
|
|
||||||
|
目标:覆盖“用户素材上传/管理、资产引用、Dynamic Group 规则提示、性能优化”等需求。
|
||||||
|
|
||||||
|
## 0. 基础启动与构建
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
- `npm install`
|
||||||
|
- `npm run dev`
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
预期:
|
||||||
|
- dev 正常启动,页面可操作。
|
||||||
|
- build 成功输出 `dist/`。
|
||||||
|
|
||||||
|
## 1. 资产基路径与引用一致性
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
- 在编辑器中插入素材节点(式神/御魂等),保存。
|
||||||
|
- 刷新页面或重新打开。
|
||||||
|
|
||||||
|
预期:
|
||||||
|
- 素材仍能正确显示。
|
||||||
|
- 对于以 `/assets/...` 开头的资源,能够在宿主子路径部署时被正确改写(由宿主配置/注入决定)。
|
||||||
|
|
||||||
|
排查点:
|
||||||
|
- `src/utils/assetUrl.ts` 的 `setAssetBaseUrl/getAssetBaseUrl/resolveAssetUrl`。
|
||||||
|
|
||||||
|
## 2. 用户素材上传与使用(我的素材)
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
- 打开素材选择面板(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),将节点加入/移出分组。
|
||||||
|
|
||||||
|
预期:
|
||||||
|
- 分组操作成功。
|
||||||
|
- 分组信息能写入节点 meta(用于规则检查)。
|
||||||
|
|
||||||
|
## 6. 规则静态检查(分组内)
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
- 在同一分组中放入:
|
||||||
|
- “辉夜姬” 与 “破势”
|
||||||
|
- “千姬” 与 “腹肌清姬/蝮骨清姬”
|
||||||
|
- 只有式神但没有供火式神
|
||||||
|
- 观察右侧/控制区的规则提示列表。
|
||||||
|
|
||||||
|
预期:
|
||||||
|
- 出现对应警告提示。
|
||||||
|
- 取消分组、移除节点后提示实时更新/消失。
|
||||||
|
|
||||||
|
排查点:
|
||||||
|
- `src/utils/groupRules.ts`、`src/configs/groupRules.ts`。
|
||||||
|
- `src/components/flow/FlowEditor.vue` 的 `scheduleGroupRuleValidation(...)` 调度时机。
|
||||||
|
|
||||||
|
## 7. 性能回归(矢量节点快速缩放)
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
- 放置矢量节点(VectorNode)。
|
||||||
|
- 快速缩放、连续拖动缩放柄。
|
||||||
|
|
||||||
|
预期:
|
||||||
|
- 明显卡顿减少,不出现“缩放一下就卡死”的体验。
|
||||||
|
|
||||||
|
排查点:
|
||||||
|
- `src/components/flow/nodes/common/VectorNode.vue` 的 RAF 合并更新逻辑。
|
||||||
|
|
||||||
|
## 8. 导出给 wiki 的兼容性(数据结构)
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
- 生成一份包含分组、素材、文本等内容的 graphData。
|
||||||
|
- 将 JSON 用于 wiki 的 FlowPreview/editor。
|
||||||
|
|
||||||
|
预期:
|
||||||
|
- wiki 侧能正常 normalize 并预览(节点 off-canvas 会自动平移回可视区)。
|
||||||
|
|
||||||
@@ -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 } 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 } from '@/utils/customAssets'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
config: SelectorConfig
|
config: SelectorConfig
|
||||||
@@ -77,10 +98,20 @@ 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[]>([])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.config.dataSource,
|
||||||
|
(value) => {
|
||||||
|
dataSource.value = Array.isArray(value) ? [...value] : []
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
// 过滤逻辑
|
// 过滤逻辑
|
||||||
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 +151,52 @@ 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)
|
||||||
|
dataSource.value = [createdAsset, ...dataSource.value]
|
||||||
|
props.config.onUserAssetUploaded?.(createdAsset)
|
||||||
|
} finally {
|
||||||
|
if (target) {
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUserAsset = (item: any) => {
|
||||||
|
if (!item?.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
props.config.onDeleteUserAsset?.(item)
|
||||||
|
dataSource.value = dataSource.value.filter((entry) => entry.id !== item.id)
|
||||||
|
}
|
||||||
</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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="groupRuleWarnings.length" class="control-row rule-row">
|
||||||
|
<div class="control-label">规则检查</div>
|
||||||
|
<div class="rule-list">
|
||||||
|
<div v-for="(warning, index) in groupRuleWarnings" :key="`${warning.groupId}-${warning.code}-${index}`" class="rule-item">
|
||||||
|
[{{ warning.groupId }}] {{ warning.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container" ref="containerRef" :style="{ height: '100%' }"></div>
|
<div class="container" ref="containerRef" :style="{ height: '100%' }"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,6 +89,7 @@ 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';
|
||||||
|
|
||||||
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
|
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
|
||||||
type DistributeType = 'horizontal' | 'vertical';
|
type DistributeType = 'horizontal' | 'vertical';
|
||||||
@@ -118,8 +127,10 @@ const { showMessage } = useGlobalMessage();
|
|||||||
// 当前选中节点
|
// 当前选中节点
|
||||||
const selectedNode = ref<any>(null);
|
const selectedNode = ref<any>(null);
|
||||||
const copyBuffer = ref<GraphData | null>(null);
|
const copyBuffer = ref<GraphData | null>(null);
|
||||||
|
const groupRuleWarnings = ref<GroupRuleWarning[]>([]);
|
||||||
let nextPasteDistance = COPY_TRANSLATION;
|
let nextPasteDistance = COPY_TRANSLATION;
|
||||||
let containerResizeObserver: ResizeObserver | null = null;
|
let containerResizeObserver: ResizeObserver | null = null;
|
||||||
|
let groupRuleValidationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const resolveResizeHost = () => {
|
const resolveResizeHost = () => {
|
||||||
const container = containerRef.value;
|
const container = containerRef.value;
|
||||||
@@ -482,6 +493,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 +511,7 @@ function ungroupSelectedNodes(event?: KeyboardEvent) {
|
|||||||
return nextMeta;
|
return nextMeta;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +597,25 @@ 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 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;
|
||||||
@@ -967,6 +999,7 @@ onMounted(() => {
|
|||||||
// 标记这个节点是新创建的,避免被 normalizeAllNodes 重置
|
// 标记这个节点是新创建的,避免被 normalizeAllNodes 重置
|
||||||
(model as any)._isNewNode = true;
|
(model as any)._isNewNode = true;
|
||||||
}
|
}
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听 DND 添加节点事件
|
// 监听 DND 添加节点事件
|
||||||
@@ -978,10 +1011,12 @@ onMounted(() => {
|
|||||||
// 标记这个节点是新创建的
|
// 标记这个节点是新创建的
|
||||||
(model as any)._isNewNode = true;
|
(model as any)._isNewNode = true;
|
||||||
}
|
}
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
});
|
});
|
||||||
|
|
||||||
lfInstance.on(EventType.GRAPH_RENDERED, () => {
|
lfInstance.on(EventType.GRAPH_RENDERED, () => {
|
||||||
normalizeAllNodes();
|
normalizeAllNodes();
|
||||||
|
scheduleGroupRuleValidation(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听节点点击事件,更新选中节点
|
// 监听节点点击事件,更新选中节点
|
||||||
@@ -1008,6 +1043,17 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
const model = lfInstance.getNodeModelById(nodeId);
|
const model = lfInstance.getNodeModelById(nodeId);
|
||||||
if (model) normalizeNodeModel(model);
|
if (model) normalizeNodeModel(model);
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
|
});
|
||||||
|
|
||||||
|
lfInstance.on(EventType.NODE_DELETE, () => {
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
|
});
|
||||||
|
lfInstance.on(EventType.EDGE_ADD, () => {
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
|
});
|
||||||
|
lfInstance.on(EventType.EDGE_DELETE, () => {
|
||||||
|
scheduleGroupRuleValidation();
|
||||||
});
|
});
|
||||||
|
|
||||||
lfInstance.on('selection:selected', () => updateSelectedCount());
|
lfInstance.on('selection:selected', () => updateSelectedCount());
|
||||||
@@ -1064,6 +1110,10 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('resize', handleWindowResize);
|
window.removeEventListener('resize', handleWindowResize);
|
||||||
containerResizeObserver?.disconnect();
|
containerResizeObserver?.disconnect();
|
||||||
containerResizeObserver = null;
|
containerResizeObserver = null;
|
||||||
|
if (groupRuleValidationTimer) {
|
||||||
|
clearTimeout(groupRuleValidationTimer);
|
||||||
|
groupRuleValidationTimer = null;
|
||||||
|
}
|
||||||
lf.value?.destroy();
|
lf.value?.destroy();
|
||||||
lf.value = null;
|
lf.value = null;
|
||||||
destroyLogicFlowInstance();
|
destroyLogicFlowInstance();
|
||||||
@@ -1118,6 +1168,23 @@ onBeforeUnmount(() => {
|
|||||||
.control-row:last-child {
|
.control-row:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
.rule-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.rule-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.rule-item {
|
||||||
|
color: #9a3412;
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
.control-label {
|
.control-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
|
|||||||
@@ -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,7 @@ 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';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
node: any;
|
node: any;
|
||||||
@@ -40,10 +41,32 @@ const handleOpenSelector = () => {
|
|||||||
}
|
}
|
||||||
: selectedAsset;
|
: selectedAsset;
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if (!item?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteCustomAsset(library, item.id);
|
||||||
|
},
|
||||||
|
onUserAssetUploaded: () => {
|
||||||
|
// 上传后的数据刷新由选择器内部完成,这里保留扩展钩子。
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
openGenericSelector(config, (selectedItem) => {
|
openGenericSelector(config, (selectedItem) => {
|
||||||
|
|||||||
52
src/configs/groupRules.ts
Normal file
52
src/configs/groupRules.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_GROUP_RULES_CONFIG: GroupRulesConfig = {
|
||||||
|
version: 1,
|
||||||
|
fireShikigamiWhitelist: [
|
||||||
|
'辉夜姬',
|
||||||
|
'因幡辉夜姬',
|
||||||
|
'追月神',
|
||||||
|
'座敷童子',
|
||||||
|
'千姬',
|
||||||
|
'帝释天',
|
||||||
|
'不见岳',
|
||||||
|
'食灵'
|
||||||
|
],
|
||||||
|
shikigamiYuhunBlacklist: [
|
||||||
|
{
|
||||||
|
shikigami: '辉夜姬',
|
||||||
|
yuhun: '破势',
|
||||||
|
message: '规则冲突:辉夜姬通常不建议携带破势。'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
shikigamiConflictPairs: [
|
||||||
|
{
|
||||||
|
left: '千姬',
|
||||||
|
right: '腹肌清姬',
|
||||||
|
message: '规则冲突:千姬与腹肌清姬不建议同队。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
left: '千姬',
|
||||||
|
right: '蝮骨清姬',
|
||||||
|
message: '规则冲突:千姬与蝮骨清姬不建议同队。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,8 @@ 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 { YysEditorEmbed }
|
export { YysEditorEmbed }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
88
src/utils/customAssets.ts
Normal file
88
src/utils/customAssets.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
const STORAGE_KEY = 'yys-editor.custom-assets.v1'
|
||||||
|
|
||||||
|
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 readStore = (): CustomAssetStore => {
|
||||||
|
if (!isClient()) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
return parsed as CustomAssetStore
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeStore = (store: CustomAssetStore) => {
|
||||||
|
if (!isClient()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 store = readStore()
|
||||||
|
return Array.isArray(store[library]) ? store[library] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveCustomAsset = (library: string, asset: CustomAssetItem) => {
|
||||||
|
const store = readStore()
|
||||||
|
const assets = Array.isArray(store[library]) ? store[library] : []
|
||||||
|
store[library] = [asset, ...assets]
|
||||||
|
writeStore(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteCustomAsset = (library: string, assetId: string) => {
|
||||||
|
const store = readStore()
|
||||||
|
const assets = Array.isArray(store[library]) ? store[library] : []
|
||||||
|
store[library] = assets.filter((item) => item.id !== assetId)
|
||||||
|
writeStore(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCustomAssetFromFile = async (library: string, file: File): Promise<CustomAssetItem> => {
|
||||||
|
const avatar = await readFileAsDataUrl(file)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const id = `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
const asset: CustomAssetItem = {
|
||||||
|
id,
|
||||||
|
name: normalizeFileName(file.name),
|
||||||
|
avatar,
|
||||||
|
library,
|
||||||
|
__userAsset: true,
|
||||||
|
createdAt: now
|
||||||
|
}
|
||||||
|
saveCustomAsset(library, asset)
|
||||||
|
return asset
|
||||||
|
}
|
||||||
|
|
||||||
148
src/utils/groupRules.ts
Normal file
148
src/utils/groupRules.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { DEFAULT_GROUP_RULES_CONFIG, type GroupRulesConfig } from '@/configs/groupRules'
|
||||||
|
|
||||||
|
type GraphData = {
|
||||||
|
nodes: any[]
|
||||||
|
edges: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupAssetSnapshot = {
|
||||||
|
groupId: string
|
||||||
|
nodeIds: string[]
|
||||||
|
shikigamiNames: string[]
|
||||||
|
yuhunNames: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupRuleWarning = {
|
||||||
|
code: 'SHIKIGAMI_YUHUN_BLACKLIST' | 'SHIKIGAMI_CONFLICT' | 'MISSING_FIRE_SHIKIGAMI'
|
||||||
|
groupId: string
|
||||||
|
message: string
|
||||||
|
nodeIds: 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 inferLibrary = (node: any): string => {
|
||||||
|
const assetLibrary = normalizeText(node?.properties?.assetLibrary)
|
||||||
|
if (assetLibrary) {
|
||||||
|
return assetLibrary
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedLibrary = normalizeText(node?.properties?.selectedAsset?.library)
|
||||||
|
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 collectGroupAssets = (graphData: GraphData): GroupAssetSnapshot[] => {
|
||||||
|
const groupMap = new Map<string, GroupAssetSnapshot>()
|
||||||
|
|
||||||
|
const nodes = Array.isArray(graphData?.nodes) ? graphData.nodes : []
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (!isAssetSelectorNode(node)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = normalizeText(node?.properties?.meta?.groupId)
|
||||||
|
if (!groupId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetName = normalizeText(node?.properties?.selectedAsset?.name)
|
||||||
|
if (!assetName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupMap.has(groupId)) {
|
||||||
|
groupMap.set(groupId, {
|
||||||
|
groupId,
|
||||||
|
nodeIds: [],
|
||||||
|
shikigamiNames: [],
|
||||||
|
yuhunNames: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = groupMap.get(groupId)!
|
||||||
|
group.nodeIds.push(node.id)
|
||||||
|
|
||||||
|
const library = inferLibrary(node)
|
||||||
|
if (library === 'shikigami') {
|
||||||
|
group.shikigamiNames.push(assetName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (library === 'yuhun') {
|
||||||
|
group.yuhunNames.push(assetName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(groupMap.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
const includesName = (list: string[], target: string): boolean => {
|
||||||
|
return list.some((item) => item === target)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateGraphGroupRules = (
|
||||||
|
graphData: GraphData,
|
||||||
|
config: GroupRulesConfig = DEFAULT_GROUP_RULES_CONFIG
|
||||||
|
): GroupRuleWarning[] => {
|
||||||
|
const groups = collectGroupAssets(graphData)
|
||||||
|
const warnings: GroupRuleWarning[] = []
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
config.shikigamiYuhunBlacklist.forEach((rule) => {
|
||||||
|
if (includesName(group.shikigamiNames, rule.shikigami) && includesName(group.yuhunNames, rule.yuhun)) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'SHIKIGAMI_YUHUN_BLACKLIST',
|
||||||
|
groupId: group.groupId,
|
||||||
|
nodeIds: group.nodeIds,
|
||||||
|
message: rule.message || `规则冲突:${rule.shikigami} 不建议携带 ${rule.yuhun}。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
config.shikigamiConflictPairs.forEach((rule) => {
|
||||||
|
if (includesName(group.shikigamiNames, rule.left) && includesName(group.shikigamiNames, rule.right)) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'SHIKIGAMI_CONFLICT',
|
||||||
|
groupId: group.groupId,
|
||||||
|
nodeIds: group.nodeIds,
|
||||||
|
message: rule.message || `规则冲突:${rule.left} 与 ${rule.right} 不建议同队。`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasShikigami = group.shikigamiNames.length > 0
|
||||||
|
if (hasShikigami) {
|
||||||
|
const hasFireShikigami = group.shikigamiNames.some((name) => config.fireShikigamiWhitelist.includes(name))
|
||||||
|
if (!hasFireShikigami) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'MISSING_FIRE_SHIKIGAMI',
|
||||||
|
groupId: group.groupId,
|
||||||
|
nodeIds: group.nodeIds,
|
||||||
|
message: '规则提示:当前分组未检测到鬼火式神,建议补充供火位。'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user