@@ -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)
+ }
+}