From 324865edc5b97c83a2eac05cfd0e8e8363db301c Mon Sep 17 00:00:00 2001 From: rookie4show Date: Fri, 27 Feb 2026 13:34:23 +0800 Subject: [PATCH] feat: unify asset/rule interop and add toolbar asset manager --- docs/1management/plan.md | 14 +- docs/test/acceptance.md | 64 ++++++- src/components/Toolbar.vue | 179 +++++++++++++++++- .../common/GenericImageSelector.vue | 52 ++++- src/components/flow/FlowEditor.vue | 24 ++- src/components/flow/PropertyPanel.vue | 17 +- .../flow/panels/AssetSelectorPanel.vue | 5 +- src/index.js | 7 + src/utils/customAssets.ts | 166 ++++++++++++++-- src/utils/groupRules.ts | 13 +- src/utils/groupRulesConfigSource.ts | 168 ++++++++++++++++ 11 files changed, 667 insertions(+), 42 deletions(-) create mode 100644 src/utils/groupRulesConfigSource.ts diff --git a/docs/1management/plan.md b/docs/1management/plan.md index aea6711..ccf07cc 100644 --- a/docs/1management/plan.md +++ b/docs/1management/plan.md @@ -7,7 +7,7 @@ **目标:** 作为独立编辑器和可嵌入组件,支持在 onmyoji-wiki 中作为块插件使用 **当前状态:** ✅ 阶段 1 完成(独立编辑器)+ ✅ 阶段 2 完成(组件化改造)+ 🔄 阶段 3 进行中(wiki 集成稳定化) -**总体完成度:** 92%(核心功能完成,集成与质量收尾中) +**总体完成度:** 93%(核心功能完成,集成与质量收尾中) --- @@ -39,7 +39,7 @@ | 🎨 画布(LogicFlow) | 100% | ✅ 完美 | 无 | | 📦 左侧组件库 | 75% | ✅ 可用 | 缩略图、搜索 | | ⚙️ 右侧属性面板 | 100% | ✅ 完美 | 无 | -| 🔧 工具栏 | 85% | ✅ 良好 | 导出命名优化 | +| 🔧 工具栏 | 90% | ✅ 良好 | 导出命名优化 | | 💬 弹窗系统 | 75% | ✅ 可用 | i18n完善、性能优化 | | 💾 状态与持久化 | 90% | ✅ 优秀 | 重命名UI | | 🌐 数据与国际化 | 60% | ⚠️ 基础 | UTF-8统一、日文覆盖 | @@ -223,6 +223,7 @@ - [x] 优化模式切换体验 - [x] 优化数据同步 - [x] 优化错误处理 +- [x] 新增顶部“素材管理”入口并统一素材分类来源(与资产选择器一致) - [ ] 优化加载性能 **验收标准:** @@ -428,6 +429,11 @@ const handleCancel = () => { ## 📝 更新日志 +### 2026-02-27 +- ✅ 完成素材管理入口可见性优化:Toolbar 新增“素材管理”按钮 +- ✅ 完成素材分类统一:素材管理与资产选择器统一使用同一分类源(4 类) +- ✅ 完成跨项目互通基础落地:素材同源存储稳定化、规则共享配置源读取与默认回退 + ### 2026-02-26 - ✅ 修复嵌入式编辑器在 wiki 弹层中的画布高度与边界占满问题(多次 resize + 容器高度链路修正) - ✅ 修复编辑已有资产后立即保存时数据偶发不刷新的问题(保存前 flush + 预览强制 key 更新) @@ -458,7 +464,7 @@ const handleCancel = () => { --- -**最后更新:** 2026-02-26 -**文档版本:** v2.2.0(wiki 集成稳定化进行中) +**最后更新:** 2026-02-27 +**文档版本:** v2.2.1(wiki 集成稳定化进行中) **文档版本:** v2.1.0(组件化改造完成) **文档版本:** v2.0.0(重新规划) diff --git a/docs/test/acceptance.md b/docs/test/acceptance.md index c5a4304..7135bca 100644 --- a/docs/test/acceptance.md +++ b/docs/test/acceptance.md @@ -29,7 +29,8 @@ ## 2. 用户素材上传与使用(我的素材) 步骤: -- 打开素材选择面板(AssetSelector)。 +- 点击顶部工具栏“素材管理”,切到对应分类上传素材。 +- 在画布添加一个 `assetSelector` 节点并选中,打开素材选择面板(AssetSelector)。 - 点击“上传我的素材”,选择一张图片。 - 在列表中找到该素材,点击选中。 @@ -114,3 +115,64 @@ 预期: - 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 待读取同源配置) diff --git a/src/components/Toolbar.vue b/src/components/Toolbar.vue index 0a1d3be..2a9c3a8 100644 --- a/src/components/Toolbar.vue +++ b/src/components/Toolbar.vue @@ -6,6 +6,7 @@ 数据预览 {{ t('prepareCapture') }} {{ t('setWatermark') }} + 素材管理 {{ t('loadExample') }} {{ t('updateLog') }} {{ t('feedback') }} @@ -111,11 +112,62 @@ + + +
+ + + 上传当前分类素材 + +
+ + + +
+
+
+
{{ item.name }}
+ + 删除 + +
+
+ + + + +
diff --git a/src/components/flow/FlowEditor.vue b/src/components/flow/FlowEditor.vue index 1a5c29d..b9c46a3 100644 --- a/src/components/flow/FlowEditor.vue +++ b/src/components/flow/FlowEditor.vue @@ -2,7 +2,13 @@
-
+
+
+ +
+
@@ -90,6 +97,7 @@ import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlo import { normalizePropertiesWithStyle, normalizeNodeStyle, styleEquals } from '@/ts/nodeStyle'; import { useCanvasSettings } from '@/ts/useCanvasSettings'; import { validateGraphGroupRules, type GroupRuleWarning } from '@/utils/groupRules'; +import { subscribeSharedGroupRulesConfig } from '@/utils/groupRulesConfigSource'; type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter'; type DistributeType = 'horizontal' | 'vertical'; @@ -128,9 +136,11 @@ const { showMessage } = useGlobalMessage(); const selectedNode = ref(null); const copyBuffer = ref(null); const groupRuleWarnings = ref([]); +const flowControlsCollapsed = ref(true); let nextPasteDistance = COPY_TRANSLATION; let containerResizeObserver: ResizeObserver | null = null; let groupRuleValidationTimer: ReturnType | null = null; +let unsubscribeSharedGroupRules: (() => void) | null = null; const resolveResizeHost = () => { const container = containerRef.value; @@ -1074,6 +1084,9 @@ onMounted(() => { } } window.addEventListener('resize', handleWindowResize); + unsubscribeSharedGroupRules = subscribeSharedGroupRulesConfig(() => { + scheduleGroupRuleValidation(0); + }); }); watch(selectionEnabled, (enabled) => { @@ -1114,6 +1127,8 @@ onBeforeUnmount(() => { clearTimeout(groupRuleValidationTimer); groupRuleValidationTimer = null; } + unsubscribeSharedGroupRules?.(); + unsubscribeSharedGroupRules = null; lf.value?.destroy(); lf.value = null; destroyLogicFlowInstance(); @@ -1158,6 +1173,10 @@ onBeforeUnmount(() => { max-width: 460px; font-size: 12px; } +.flow-controls--collapsed { + padding: 6px; + max-width: 220px; +} .control-row { display: flex; align-items: center; @@ -1165,6 +1184,9 @@ onBeforeUnmount(() => { gap: 8px; margin-bottom: 6px; } +.control-header { + margin-bottom: 0; +} .control-row:last-child { margin-bottom: 0; } diff --git a/src/components/flow/PropertyPanel.vue b/src/components/flow/PropertyPanel.vue index b2cd785..3ee901d 100644 --- a/src/components/flow/PropertyPanel.vue +++ b/src/components/flow/PropertyPanel.vue @@ -66,7 +66,10 @@ const currentAssetLibrary = computed({
-

请选择一个节点以编辑其属性

+
+

请选择一个节点以编辑其属性

+

素材入口:添加并选中 assetSelector 节点后,点击“选择资产”。

+
@@ -155,6 +158,18 @@ const currentAssetLibrary = computed({ 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 { padding: 10px; flex: 1; diff --git a/src/components/flow/panels/AssetSelectorPanel.vue b/src/components/flow/panels/AssetSelectorPanel.vue index 31c6125..a79b104 100644 --- a/src/components/flow/panels/AssetSelectorPanel.vue +++ b/src/components/flow/panels/AssetSelectorPanel.vue @@ -59,10 +59,7 @@ const handleOpenSelector = () => { assetLibrary: library, allowUserAssetUpload: true, onDeleteUserAsset: (item: any) => { - if (!item?.id) { - return; - } - deleteCustomAsset(library, item.id); + deleteCustomAsset(library, item); }, onUserAssetUploaded: () => { // 上传后的数据刷新由选择器内部完成,这里保留扩展钩子。 diff --git a/src/index.js b/src/index.js index d370a8b..f471e08 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,13 @@ import YysEditorEmbed from './YysEditorEmbed.vue' export { setAssetBaseUrl, getAssetBaseUrl, resolveAssetUrl } from './utils/assetUrl' export { DEFAULT_GROUP_RULES_CONFIG } from './configs/groupRules' export { validateGraphGroupRules } from './utils/groupRules' +export { + GROUP_RULES_STORAGE_KEY, + readSharedGroupRulesConfig, + writeSharedGroupRulesConfig, + clearSharedGroupRulesConfig +} from './utils/groupRulesConfigSource' +export { CUSTOM_ASSET_STORAGE_KEY } from './utils/customAssets' // 导出组件 export { YysEditorEmbed } diff --git a/src/utils/customAssets.ts b/src/utils/customAssets.ts index 9bc9d5f..66059eb 100644 --- a/src/utils/customAssets.ts +++ b/src/utils/customAssets.ts @@ -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 = { id: string @@ -12,20 +13,95 @@ export type CustomAssetItem = { type CustomAssetStore = Record const isClient = () => typeof window !== 'undefined' && typeof localStorage !== 'undefined' +const normalizeText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +const normalizeLibraryKey = (library: string): string => normalizeText(library).toLowerCase() + +const createLegacyAssetId = (library: string, name: string, avatar: string): string => { + const seed = `${library}|${name}|${avatar}` + let hash = 0 + for (let index = 0; index < seed.length; index += 1) { + hash = ((hash << 5) - hash) + seed.charCodeAt(index) + hash |= 0 + } + return `custom_legacy_${Math.abs(hash).toString(36)}` +} + +const buildCustomAssetId = (): string => { + return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` +} + +const normalizeAssetItem = (library: string, input: unknown): CustomAssetItem | null => { + if (!input || typeof input !== 'object') { + return null + } + + const raw = input as Record + const name = normalizeText(raw.name) || '用户素材' + const avatar = normalizeText(raw.avatar) + if (!avatar) { + return null + } + + const normalizedLibrary = normalizeLibraryKey(library) + const id = normalizeText(raw.id) || createLegacyAssetId(normalizedLibrary, name, avatar) + const createdAt = normalizeText(raw.createdAt) || '1970-01-01T00:00:00.000Z' + + return { + id, + name, + avatar, + library: normalizedLibrary, + __userAsset: true, + createdAt + } +} + +const normalizeStore = (input: unknown): CustomAssetStore => { + if (!input || typeof input !== 'object') { + return {} + } + + const parsed = input as Record + const normalizedStore: CustomAssetStore = {} + Object.entries(parsed).forEach(([library, assets]) => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary || !Array.isArray(assets)) { + return + } + const normalizedAssets = assets + .map((item) => normalizeAssetItem(normalizedLibrary, item)) + .filter((item): item is CustomAssetItem => !!item) + if (normalizedAssets.length > 0) { + normalizedStore[normalizedLibrary] = normalizedAssets + } + }) + + return normalizedStore +} + +const notifyStoreUpdated = () => { + if (!isClient()) { + return + } + window.dispatchEvent(new CustomEvent(CUSTOM_ASSET_UPDATED_EVENT)) +} const readStore = (): CustomAssetStore => { if (!isClient()) { return {} } - const raw = localStorage.getItem(STORAGE_KEY) + const raw = localStorage.getItem(CUSTOM_ASSET_STORAGE_KEY) if (!raw) { return {} } try { const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object') { - return parsed as CustomAssetStore + const normalized = normalizeStore(parsed) + const normalizedRaw = JSON.stringify(normalized) + if (normalizedRaw !== raw) { + localStorage.setItem(CUSTOM_ASSET_STORAGE_KEY, normalizedRaw) } + return normalized } catch { // ignore } @@ -36,7 +112,8 @@ const writeStore = (store: CustomAssetStore) => { if (!isClient()) { return } - localStorage.setItem(STORAGE_KEY, JSON.stringify(store)) + localStorage.setItem(CUSTOM_ASSET_STORAGE_KEY, JSON.stringify(store)) + notifyStoreUpdated() } const normalizeFileName = (fileName: string): string => { @@ -52,37 +129,98 @@ const readFileAsDataUrl = (file: File): Promise => new Promise((resolve, }) export const listCustomAssets = (library: string): CustomAssetItem[] => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary) { + return [] + } const store = readStore() - return Array.isArray(store[library]) ? store[library] : [] + 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 assets = Array.isArray(store[library]) ? store[library] : [] - store[library] = [asset, ...assets] + 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, assetId: string) => { +export const deleteCustomAsset = ( + library: string, + assetRef: string | Pick, 'id' | 'name' | 'avatar'> +) => { + const normalizedLibrary = normalizeLibraryKey(library) + if (!normalizedLibrary) { + return + } const store = readStore() - const assets = Array.isArray(store[library]) ? store[library] : [] - store[library] = assets.filter((item) => item.id !== assetId) + const assets = Array.isArray(store[normalizedLibrary]) ? store[normalizedLibrary] : [] + const targetId = typeof assetRef === 'string' ? normalizeText(assetRef) : normalizeText(assetRef?.id) + const targetAvatar = typeof assetRef === 'string' ? '' : normalizeText(assetRef?.avatar) + const targetName = typeof assetRef === 'string' ? '' : normalizeText(assetRef?.name) + + store[normalizedLibrary] = assets.filter((item) => { + if (targetId && item.id === targetId) { + return false + } + if (!targetAvatar) { + return true + } + if (item.avatar !== targetAvatar) { + return true + } + if (!targetName) { + return false + } + return item.name !== targetName + }) writeStore(store) } export const createCustomAssetFromFile = async (library: string, file: File): Promise => { const avatar = await readFileAsDataUrl(file) const now = new Date().toISOString() - const id = `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + const normalizedLibrary = normalizeLibraryKey(library) + const id = buildCustomAssetId() const asset: CustomAssetItem = { id, name: normalizeFileName(file.name), avatar, - library, + library: normalizedLibrary, __userAsset: true, createdAt: now } - saveCustomAsset(library, asset) + saveCustomAsset(normalizedLibrary, asset) return asset } +export const subscribeCustomAssetStore = (listener: () => void): (() => void) => { + if (!isClient()) { + return () => {} + } + const handleStorage = (event: StorageEvent) => { + if (event.key === CUSTOM_ASSET_STORAGE_KEY) { + listener() + } + } + const handleLocalUpdate = () => { + listener() + } + window.addEventListener('storage', handleStorage) + window.addEventListener(CUSTOM_ASSET_UPDATED_EVENT, handleLocalUpdate) + return () => { + window.removeEventListener('storage', handleStorage) + window.removeEventListener(CUSTOM_ASSET_UPDATED_EVENT, handleLocalUpdate) + } +} diff --git a/src/utils/groupRules.ts b/src/utils/groupRules.ts index 137a565..92cc702 100644 --- a/src/utils/groupRules.ts +++ b/src/utils/groupRules.ts @@ -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 = { nodes: any[] @@ -101,13 +102,14 @@ const includesName = (list: string[], target: string): boolean => { export const validateGraphGroupRules = ( graphData: GraphData, - config: GroupRulesConfig = DEFAULT_GROUP_RULES_CONFIG + config?: GroupRulesConfig ): GroupRuleWarning[] => { + const effectiveConfig = config || readSharedGroupRulesConfig() const groups = collectGroupAssets(graphData) const warnings: GroupRuleWarning[] = [] groups.forEach((group) => { - config.shikigamiYuhunBlacklist.forEach((rule) => { + effectiveConfig.shikigamiYuhunBlacklist.forEach((rule) => { if (includesName(group.shikigamiNames, rule.shikigami) && includesName(group.yuhunNames, rule.yuhun)) { warnings.push({ 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)) { warnings.push({ code: 'SHIKIGAMI_CONFLICT', @@ -131,7 +133,7 @@ export const validateGraphGroupRules = ( const hasShikigami = group.shikigamiNames.length > 0 if (hasShikigami) { - const hasFireShikigami = group.shikigamiNames.some((name) => config.fireShikigamiWhitelist.includes(name)) + const hasFireShikigami = group.shikigamiNames.some((name) => effectiveConfig.fireShikigamiWhitelist.includes(name)) if (!hasFireShikigami) { warnings.push({ code: 'MISSING_FIRE_SHIKIGAMI', @@ -145,4 +147,3 @@ export const validateGraphGroupRules = ( return warnings } - diff --git a/src/utils/groupRulesConfigSource.ts b/src/utils/groupRulesConfigSource.ts new file mode 100644 index 0000000..a0d378a --- /dev/null +++ b/src/utils/groupRulesConfigSource.ts @@ -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 + const shikigami = normalizeText(raw.shikigami) + const yuhun = normalizeText(raw.yuhun) + if (!shikigami || !yuhun) { + return null + } + const message = normalizeText(raw.message) + return { + shikigami, + yuhun, + ...(message ? { message } : {}) + } + }) + .filter((item): item is ShikigamiYuhunBlacklistRule => !!item) +} + +const normalizeConflictRules = ( + value: unknown, + fallback: ShikigamiConflictRule[] +): ShikigamiConflictRule[] => { + if (!Array.isArray(value)) { + return fallback.map((rule) => ({ ...rule })) + } + return value + .map((item) => { + if (!item || typeof item !== 'object') { + return null + } + const raw = item as Record + const left = normalizeText(raw.left) + const right = normalizeText(raw.right) + if (!left || !right) { + return null + } + const message = normalizeText(raw.message) + return { + left, + right, + ...(message ? { message } : {}) + } + }) + .filter((item): item is ShikigamiConflictRule => !!item) +} + +const normalizeGroupRulesConfig = (input: unknown): GroupRulesConfig | null => { + if (!input || typeof input !== 'object') { + return null + } + + const raw = input as Record + const fallback = cloneDefaultGroupRulesConfig() + const versionCandidate = Number(raw.version) + const version = Number.isFinite(versionCandidate) && versionCandidate > 0 + ? Math.trunc(versionCandidate) + : fallback.version + + return { + version, + fireShikigamiWhitelist: normalizeStringList(raw.fireShikigamiWhitelist, fallback.fireShikigamiWhitelist), + shikigamiYuhunBlacklist: normalizeBlacklistRules(raw.shikigamiYuhunBlacklist, fallback.shikigamiYuhunBlacklist), + shikigamiConflictPairs: normalizeConflictRules(raw.shikigamiConflictPairs, fallback.shikigamiConflictPairs) + } +} + +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) + } +}