mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-03-05 15:05:27 +00:00
feat: unify asset/rule interop and add toolbar asset manager
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
**目标:** 作为独立编辑器和可嵌入组件,支持在 onmyoji-wiki 中作为块插件使用
|
**目标:** 作为独立编辑器和可嵌入组件,支持在 onmyoji-wiki 中作为块插件使用
|
||||||
|
|
||||||
**当前状态:** ✅ 阶段 1 完成(独立编辑器)+ ✅ 阶段 2 完成(组件化改造)+ 🔄 阶段 3 进行中(wiki 集成稳定化)
|
**当前状态:** ✅ 阶段 1 完成(独立编辑器)+ ✅ 阶段 2 完成(组件化改造)+ 🔄 阶段 3 进行中(wiki 集成稳定化)
|
||||||
**总体完成度:** 92%(核心功能完成,集成与质量收尾中)
|
**总体完成度:** 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统一、日文覆盖 |
|
||||||
@@ -223,6 +223,7 @@
|
|||||||
- [x] 优化模式切换体验
|
- [x] 优化模式切换体验
|
||||||
- [x] 优化数据同步
|
- [x] 优化数据同步
|
||||||
- [x] 优化错误处理
|
- [x] 优化错误处理
|
||||||
|
- [x] 新增顶部“素材管理”入口并统一素材分类来源(与资产选择器一致)
|
||||||
- [ ] 优化加载性能
|
- [ ] 优化加载性能
|
||||||
|
|
||||||
**验收标准:**
|
**验收标准:**
|
||||||
@@ -428,6 +429,11 @@ const handleCancel = () => {
|
|||||||
|
|
||||||
## 📝 更新日志
|
## 📝 更新日志
|
||||||
|
|
||||||
|
### 2026-02-27
|
||||||
|
- ✅ 完成素材管理入口可见性优化:Toolbar 新增“素材管理”按钮
|
||||||
|
- ✅ 完成素材分类统一:素材管理与资产选择器统一使用同一分类源(4 类)
|
||||||
|
- ✅ 完成跨项目互通基础落地:素材同源存储稳定化、规则共享配置源读取与默认回退
|
||||||
|
|
||||||
### 2026-02-26
|
### 2026-02-26
|
||||||
- ✅ 修复嵌入式编辑器在 wiki 弹层中的画布高度与边界占满问题(多次 resize + 容器高度链路修正)
|
- ✅ 修复嵌入式编辑器在 wiki 弹层中的画布高度与边界占满问题(多次 resize + 容器高度链路修正)
|
||||||
- ✅ 修复编辑已有资产后立即保存时数据偶发不刷新的问题(保存前 flush + 预览强制 key 更新)
|
- ✅ 修复编辑已有资产后立即保存时数据偶发不刷新的问题(保存前 flush + 预览强制 key 更新)
|
||||||
@@ -458,7 +464,7 @@ const handleCancel = () => {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**最后更新:** 2026-02-26
|
**最后更新:** 2026-02-27
|
||||||
**文档版本:** v2.2.0(wiki 集成稳定化进行中)
|
**文档版本:** v2.2.1(wiki 集成稳定化进行中)
|
||||||
**文档版本:** v2.1.0(组件化改造完成)
|
**文档版本:** v2.1.0(组件化改造完成)
|
||||||
**文档版本:** v2.0.0(重新规划)
|
**文档版本:** v2.0.0(重新规划)
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
## 2. 用户素材上传与使用(我的素材)
|
## 2. 用户素材上传与使用(我的素材)
|
||||||
|
|
||||||
步骤:
|
步骤:
|
||||||
- 打开素材选择面板(AssetSelector)。
|
- 点击顶部工具栏“素材管理”,切到对应分类上传素材。
|
||||||
|
- 在画布添加一个 `assetSelector` 节点并选中,打开素材选择面板(AssetSelector)。
|
||||||
- 点击“上传我的素材”,选择一张图片。
|
- 点击“上传我的素材”,选择一张图片。
|
||||||
- 在列表中找到该素材,点击选中。
|
- 在列表中找到该素材,点击选中。
|
||||||
|
|
||||||
@@ -114,3 +115,64 @@
|
|||||||
预期:
|
预期:
|
||||||
- wiki 侧能正常 normalize 并预览(节点 off-canvas 会自动平移回可视区)。
|
- 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/...` 在宿主子路径下可正确解析)。
|
||||||
|
- [ ] 用户素材上传与使用通过(我的素材可新增并可用于节点)。
|
||||||
|
- [ ] 用户素材删除与持久化通过(删除后刷新不复活)。
|
||||||
|
- [ ] 缺失资产降级策略通过(不阻断导出/渲染)。
|
||||||
|
- [ ] Dynamic Group 分组基础行为通过(分组信息写入 `meta.groupId`)。
|
||||||
|
- [ ] 分组规则静态检查通过(冲突与供火提示正确且可实时更新)。
|
||||||
|
- [ ] 矢量节点快速缩放性能回归通过(无明显卡顿/卡死)。
|
||||||
|
- [ ] 导出到 wiki 数据兼容通过(wiki 侧可 normalize 与预览)。
|
||||||
|
- [ ] 跨项目素材互通通过(同 origin 可复用素材,跨 origin 不互通)。
|
||||||
|
- [ ] 跨项目规则互通方案确认(共享配置源定义、两侧读取一致)。
|
||||||
|
|
||||||
|
当前状态(2026-02-27):
|
||||||
|
- 已通过:1 项(基础启动与构建)。
|
||||||
|
- 部分通过:3 项(用户素材上传与使用、用户素材删除与持久化、跨项目规则互通方案确认)。
|
||||||
|
- 未通过/待验证:7 项(其余项待完整手测或跨仓联调)。
|
||||||
|
|
||||||
|
逐项状态:
|
||||||
|
- 基础启动与构建:已通过
|
||||||
|
- 资产基路径与引用一致性:未通过(待手测)
|
||||||
|
- 用户素材上传与使用:部分通过(实现已就绪,待手测)
|
||||||
|
- 用户素材删除与持久化:部分通过(实现已修复,待手测)
|
||||||
|
- 缺失资产降级策略:未通过(待手测)
|
||||||
|
- Dynamic Group 分组基础行为:未通过(待手测)
|
||||||
|
- 分组规则静态检查:未通过(待手测)
|
||||||
|
- 矢量节点快速缩放性能回归:未通过(待手测)
|
||||||
|
- 导出到 wiki 数据兼容:未通过(待跨仓联测)
|
||||||
|
- 跨项目素材互通:未通过(待同 origin 联测)
|
||||||
|
- 跨项目规则互通方案确认:部分通过(yys-editor 已落地,wiki 待读取同源配置)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<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 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 +112,62 @@
|
|||||||
</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>
|
||||||
|
|
||||||
</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 +175,16 @@ 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';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
isEmbed?: boolean;
|
isEmbed?: boolean;
|
||||||
@@ -156,8 +216,68 @@ const state = reactive({
|
|||||||
showUpdateLogDialog: false, // 控制更新日志对话框的显示状态
|
showUpdateLogDialog: false, // 控制更新日志对话框的显示状态
|
||||||
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态
|
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态
|
||||||
showDataPreviewDialog: false, // 控制数据预览对话框的显示状态
|
showDataPreviewDialog: false, // 控制数据预览对话框的显示状态
|
||||||
|
showAssetManagerDialog: 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 managedAssets = reactive<Record<string, CustomAssetItem[]>>({});
|
||||||
|
assetLibraries.forEach((item) => {
|
||||||
|
managedAssets[item.id] = [];
|
||||||
|
});
|
||||||
|
let unsubscribeAssetStore: (() => void) | null = null;
|
||||||
|
|
||||||
|
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 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 +344,11 @@ const showUpdateLog = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
refreshManagedAssets();
|
||||||
|
unsubscribeAssetStore = subscribeCustomAssetStore(() => {
|
||||||
|
refreshManagedAssets();
|
||||||
|
});
|
||||||
|
|
||||||
if (props.isEmbed) {
|
if (props.isEmbed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -238,6 +363,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unsubscribeAssetStore?.();
|
||||||
|
unsubscribeAssetStore = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const showFeedbackForm = () => {
|
const showFeedbackForm = () => {
|
||||||
state.showFeedbackFormDialog = !state.showFeedbackFormDialog;
|
state.showFeedbackFormDialog = !state.showFeedbackFormDialog;
|
||||||
@@ -541,4 +671,51 @@ 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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -74,10 +74,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } 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 } from '@/utils/customAssets'
|
import { createCustomAssetFromFile, listCustomAssets, subscribeCustomAssetStore } from '@/utils/customAssets'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
config: SelectorConfig
|
config: SelectorConfig
|
||||||
@@ -100,15 +100,50 @@ 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 uploadInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const dataSource = ref<any[]>([])
|
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(
|
watch(
|
||||||
() => props.config.dataSource,
|
() => [props.config.dataSource, props.config.assetLibrary],
|
||||||
(value) => {
|
() => {
|
||||||
dataSource.value = Array.isArray(value) ? [...value] : []
|
refreshDataSource()
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ 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 = dataSource.value
|
let items = dataSource.value
|
||||||
@@ -168,8 +203,8 @@ const handleUploadAsset = async (event: Event) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const createdAsset = await createCustomAssetFromFile(props.config.assetLibrary, file)
|
const createdAsset = await createCustomAssetFromFile(props.config.assetLibrary, file)
|
||||||
dataSource.value = [createdAsset, ...dataSource.value]
|
|
||||||
props.config.onUserAssetUploaded?.(createdAsset)
|
props.config.onUserAssetUploaded?.(createdAsset)
|
||||||
|
refreshDataSource()
|
||||||
} finally {
|
} finally {
|
||||||
if (target) {
|
if (target) {
|
||||||
target.value = ''
|
target.value = ''
|
||||||
@@ -178,11 +213,8 @@ const handleUploadAsset = async (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeUserAsset = (item: any) => {
|
const removeUserAsset = (item: any) => {
|
||||||
if (!item?.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
props.config.onDeleteUserAsset?.(item)
|
props.config.onDeleteUserAsset?.(item)
|
||||||
dataSource.value = dataSource.value.filter((entry) => entry.id !== item.id)
|
refreshDataSource()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
@@ -57,6 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
@@ -90,6 +97,7 @@ import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlo
|
|||||||
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 { validateGraphGroupRules, type GroupRuleWarning } from '@/utils/groupRules';
|
||||||
|
import { subscribeSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource';
|
||||||
|
|
||||||
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
|
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
|
||||||
type DistributeType = 'horizontal' | 'vertical';
|
type DistributeType = 'horizontal' | 'vertical';
|
||||||
@@ -128,9 +136,11 @@ 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[]>([]);
|
const groupRuleWarnings = ref<GroupRuleWarning[]>([]);
|
||||||
|
const flowControlsCollapsed = ref(true);
|
||||||
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;
|
let groupRuleValidationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let unsubscribeSharedGroupRules: (() => void) | null = null;
|
||||||
|
|
||||||
const resolveResizeHost = () => {
|
const resolveResizeHost = () => {
|
||||||
const container = containerRef.value;
|
const container = containerRef.value;
|
||||||
@@ -1074,6 +1084,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', handleWindowResize);
|
window.addEventListener('resize', handleWindowResize);
|
||||||
|
unsubscribeSharedGroupRules = subscribeSharedGroupRulesConfig(() => {
|
||||||
|
scheduleGroupRuleValidation(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(selectionEnabled, (enabled) => {
|
watch(selectionEnabled, (enabled) => {
|
||||||
@@ -1114,6 +1127,8 @@ onBeforeUnmount(() => {
|
|||||||
clearTimeout(groupRuleValidationTimer);
|
clearTimeout(groupRuleValidationTimer);
|
||||||
groupRuleValidationTimer = null;
|
groupRuleValidationTimer = null;
|
||||||
}
|
}
|
||||||
|
unsubscribeSharedGroupRules?.();
|
||||||
|
unsubscribeSharedGroupRules = null;
|
||||||
lf.value?.destroy();
|
lf.value?.destroy();
|
||||||
lf.value = null;
|
lf.value = null;
|
||||||
destroyLogicFlowInstance();
|
destroyLogicFlowInstance();
|
||||||
@@ -1158,6 +1173,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;
|
||||||
@@ -1165,6 +1184,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ const currentAssetLibrary = computed({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!hasNodeSelected" class="no-selection">
|
<div v-if="!hasNodeSelected" class="no-selection">
|
||||||
<p>请选择一个节点以编辑其属性</p>
|
<div class="no-selection-text">
|
||||||
|
<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 +158,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;
|
||||||
|
|||||||
@@ -59,10 +59,7 @@ const handleOpenSelector = () => {
|
|||||||
assetLibrary: library,
|
assetLibrary: library,
|
||||||
allowUserAssetUpload: true,
|
allowUserAssetUpload: true,
|
||||||
onDeleteUserAsset: (item: any) => {
|
onDeleteUserAsset: (item: any) => {
|
||||||
if (!item?.id) {
|
deleteCustomAsset(library, item);
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteCustomAsset(library, item.id);
|
|
||||||
},
|
},
|
||||||
onUserAssetUploaded: () => {
|
onUserAssetUploaded: () => {
|
||||||
// 上传后的数据刷新由选择器内部完成,这里保留扩展钩子。
|
// 上传后的数据刷新由选择器内部完成,这里保留扩展钩子。
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ 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 { DEFAULT_GROUP_RULES_CONFIG } from './configs/groupRules'
|
||||||
export { validateGraphGroupRules } from './utils/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 }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const STORAGE_KEY = 'yys-editor.custom-assets.v1'
|
export const CUSTOM_ASSET_STORAGE_KEY = 'yys-editor.custom-assets.v1'
|
||||||
|
const CUSTOM_ASSET_UPDATED_EVENT = 'yys-editor.custom-assets.updated'
|
||||||
|
|
||||||
export type CustomAssetItem = {
|
export type CustomAssetItem = {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,20 +13,95 @@ export type CustomAssetItem = {
|
|||||||
type CustomAssetStore = Record<string, CustomAssetItem[]>
|
type CustomAssetStore = Record<string, CustomAssetItem[]>
|
||||||
|
|
||||||
const isClient = () => typeof window !== 'undefined' && typeof localStorage !== 'undefined'
|
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 => {
|
const readStore = (): CustomAssetStore => {
|
||||||
if (!isClient()) {
|
if (!isClient()) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(CUSTOM_ASSET_STORAGE_KEY)
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
if (parsed && typeof parsed === 'object') {
|
const normalized = normalizeStore(parsed)
|
||||||
return parsed as CustomAssetStore
|
const normalizedRaw = JSON.stringify(normalized)
|
||||||
|
if (normalizedRaw !== raw) {
|
||||||
|
localStorage.setItem(CUSTOM_ASSET_STORAGE_KEY, normalizedRaw)
|
||||||
}
|
}
|
||||||
|
return normalized
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -36,7 +112,8 @@ const writeStore = (store: CustomAssetStore) => {
|
|||||||
if (!isClient()) {
|
if (!isClient()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
|
localStorage.setItem(CUSTOM_ASSET_STORAGE_KEY, JSON.stringify(store))
|
||||||
|
notifyStoreUpdated()
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeFileName = (fileName: string): string => {
|
const normalizeFileName = (fileName: string): string => {
|
||||||
@@ -52,37 +129,98 @@ const readFileAsDataUrl = (file: File): Promise<string> => new Promise((resolve,
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const listCustomAssets = (library: string): CustomAssetItem[] => {
|
export const listCustomAssets = (library: string): CustomAssetItem[] => {
|
||||||
|
const normalizedLibrary = normalizeLibraryKey(library)
|
||||||
|
if (!normalizedLibrary) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
const store = readStore()
|
const store = readStore()
|
||||||
return Array.isArray(store[library]) ? store[library] : []
|
return Array.isArray(store[normalizedLibrary]) ? store[normalizedLibrary] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export const saveCustomAsset = (library: string, asset: CustomAssetItem) => {
|
export const saveCustomAsset = (library: string, asset: CustomAssetItem) => {
|
||||||
|
const normalizedLibrary = normalizeLibraryKey(library)
|
||||||
|
if (!normalizedLibrary) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const store = readStore()
|
const store = readStore()
|
||||||
const assets = Array.isArray(store[library]) ? store[library] : []
|
const normalizedAsset = normalizeAssetItem(normalizedLibrary, asset)
|
||||||
store[library] = [asset, ...assets]
|
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)
|
writeStore(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteCustomAsset = (library: string, assetId: string) => {
|
export const deleteCustomAsset = (
|
||||||
|
library: string,
|
||||||
|
assetRef: string | Pick<Partial<CustomAssetItem>, 'id' | 'name' | 'avatar'>
|
||||||
|
) => {
|
||||||
|
const normalizedLibrary = normalizeLibraryKey(library)
|
||||||
|
if (!normalizedLibrary) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const store = readStore()
|
const store = readStore()
|
||||||
const assets = Array.isArray(store[library]) ? store[library] : []
|
const assets = Array.isArray(store[normalizedLibrary]) ? store[normalizedLibrary] : []
|
||||||
store[library] = assets.filter((item) => item.id !== assetId)
|
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)
|
writeStore(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createCustomAssetFromFile = async (library: string, file: File): Promise<CustomAssetItem> => {
|
export const createCustomAssetFromFile = async (library: string, file: File): Promise<CustomAssetItem> => {
|
||||||
const avatar = await readFileAsDataUrl(file)
|
const avatar = await readFileAsDataUrl(file)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const id = `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
const normalizedLibrary = normalizeLibraryKey(library)
|
||||||
|
const id = buildCustomAssetId()
|
||||||
const asset: CustomAssetItem = {
|
const asset: CustomAssetItem = {
|
||||||
id,
|
id,
|
||||||
name: normalizeFileName(file.name),
|
name: normalizeFileName(file.name),
|
||||||
avatar,
|
avatar,
|
||||||
library,
|
library: normalizedLibrary,
|
||||||
__userAsset: true,
|
__userAsset: true,
|
||||||
createdAt: now
|
createdAt: now
|
||||||
}
|
}
|
||||||
saveCustomAsset(library, asset)
|
saveCustomAsset(normalizedLibrary, asset)
|
||||||
return 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DEFAULT_GROUP_RULES_CONFIG, type GroupRulesConfig } from '@/configs/groupRules'
|
import type { GroupRulesConfig } from '@/configs/groupRules'
|
||||||
|
import { readSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource'
|
||||||
|
|
||||||
type GraphData = {
|
type GraphData = {
|
||||||
nodes: any[]
|
nodes: any[]
|
||||||
@@ -101,13 +102,14 @@ const includesName = (list: string[], target: string): boolean => {
|
|||||||
|
|
||||||
export const validateGraphGroupRules = (
|
export const validateGraphGroupRules = (
|
||||||
graphData: GraphData,
|
graphData: GraphData,
|
||||||
config: GroupRulesConfig = DEFAULT_GROUP_RULES_CONFIG
|
config?: GroupRulesConfig
|
||||||
): GroupRuleWarning[] => {
|
): GroupRuleWarning[] => {
|
||||||
|
const effectiveConfig = config || readSharedGroupRulesConfig()
|
||||||
const groups = collectGroupAssets(graphData)
|
const groups = collectGroupAssets(graphData)
|
||||||
const warnings: GroupRuleWarning[] = []
|
const warnings: GroupRuleWarning[] = []
|
||||||
|
|
||||||
groups.forEach((group) => {
|
groups.forEach((group) => {
|
||||||
config.shikigamiYuhunBlacklist.forEach((rule) => {
|
effectiveConfig.shikigamiYuhunBlacklist.forEach((rule) => {
|
||||||
if (includesName(group.shikigamiNames, rule.shikigami) && includesName(group.yuhunNames, rule.yuhun)) {
|
if (includesName(group.shikigamiNames, rule.shikigami) && includesName(group.yuhunNames, rule.yuhun)) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
code: 'SHIKIGAMI_YUHUN_BLACKLIST',
|
code: 'SHIKIGAMI_YUHUN_BLACKLIST',
|
||||||
@@ -118,7 +120,7 @@ export const validateGraphGroupRules = (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
config.shikigamiConflictPairs.forEach((rule) => {
|
effectiveConfig.shikigamiConflictPairs.forEach((rule) => {
|
||||||
if (includesName(group.shikigamiNames, rule.left) && includesName(group.shikigamiNames, rule.right)) {
|
if (includesName(group.shikigamiNames, rule.left) && includesName(group.shikigamiNames, rule.right)) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
code: 'SHIKIGAMI_CONFLICT',
|
code: 'SHIKIGAMI_CONFLICT',
|
||||||
@@ -131,7 +133,7 @@ export const validateGraphGroupRules = (
|
|||||||
|
|
||||||
const hasShikigami = group.shikigamiNames.length > 0
|
const hasShikigami = group.shikigamiNames.length > 0
|
||||||
if (hasShikigami) {
|
if (hasShikigami) {
|
||||||
const hasFireShikigami = group.shikigamiNames.some((name) => config.fireShikigamiWhitelist.includes(name))
|
const hasFireShikigami = group.shikigamiNames.some((name) => effectiveConfig.fireShikigamiWhitelist.includes(name))
|
||||||
if (!hasFireShikigami) {
|
if (!hasFireShikigami) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
code: 'MISSING_FIRE_SHIKIGAMI',
|
code: 'MISSING_FIRE_SHIKIGAMI',
|
||||||
@@ -145,4 +147,3 @@ export const validateGraphGroupRules = (
|
|||||||
|
|
||||||
return warnings
|
return warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
168
src/utils/groupRulesConfigSource.ts
Normal file
168
src/utils/groupRulesConfigSource.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_GROUP_RULES_CONFIG,
|
||||||
|
type GroupRulesConfig,
|
||||||
|
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 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user