fix: normalize asset urls for subpath deployment

This commit is contained in:
2026-02-26 14:04:59 +08:00
parent dc21ea6b3b
commit cfccdeb246
7 changed files with 213 additions and 9 deletions

View File

@@ -64,6 +64,7 @@ import {
type FlowNodeRegistration, type FlowNodeRegistration,
type FlowPlugin type FlowPlugin
} from './flowRuntime' } from './flowRuntime'
import { rewriteAssetUrlsDeep, setAssetBaseUrl } from '@/utils/assetUrl'
// 类型定义 // 类型定义
export interface GraphData { export interface GraphData {
@@ -121,7 +122,7 @@ const sanitizeGraphData = (input?: GraphData | null): GraphData => {
const nextNode: NodeData = { ...node } const nextNode: NodeData = { ...node }
const nextProperties = sanitizeLabelProperty(nextNode.properties) const nextProperties = sanitizeLabelProperty(nextNode.properties)
if (nextProperties) { if (nextProperties) {
nextNode.properties = nextProperties nextNode.properties = rewriteAssetUrlsDeep(nextProperties)
} }
return nextNode return nextNode
}) })
@@ -132,7 +133,7 @@ const sanitizeGraphData = (input?: GraphData | null): GraphData => {
const nextEdge: EdgeData = { ...edge } const nextEdge: EdgeData = { ...edge }
const nextProperties = sanitizeLabelProperty(nextEdge.properties) const nextProperties = sanitizeLabelProperty(nextEdge.properties)
if (nextProperties) { if (nextProperties) {
nextEdge.properties = nextProperties nextEdge.properties = rewriteAssetUrlsDeep(nextProperties)
} }
return nextEdge return nextEdge
}) })
@@ -161,6 +162,7 @@ const props = withDefaults(defineProps<{
config?: EditorConfig config?: EditorConfig
plugins?: FlowPlugin[] plugins?: FlowPlugin[]
nodeRegistrations?: FlowNodeRegistration[] nodeRegistrations?: FlowNodeRegistration[]
assetBaseUrl?: string
}>(), { }>(), {
mode: 'edit', mode: 'edit',
width: '100%', width: '100%',
@@ -384,6 +386,14 @@ watch(() => props.data, (newData) => {
} }
}, { deep: true }) }, { deep: true })
watch(
() => props.assetBaseUrl,
(value) => {
setAssetBaseUrl(value)
},
{ immediate: true }
)
// 监听模式变化 // 监听模式变化
watch(() => props.mode, (newMode) => { watch(() => props.mode, (newMode) => {
if (newMode === 'preview') { if (newMode === 'preview') {

View File

@@ -52,7 +52,7 @@
<el-dialog v-if="!props.isEmbed" v-model="state.showFeedbackFormDialog" title="更新日志" width="60%"> <el-dialog v-if="!props.isEmbed" v-model="state.showFeedbackFormDialog" title="更新日志" width="60%">
<span style="font-size: 24px;">备注阴阳师</span> <span style="font-size: 24px;">备注阴阳师</span>
<br/> <br/>
<img src="/assets/Other/Contact.png" <img :src="contactImageUrl"
style="cursor: pointer; vertical-align: bottom; width: 200px; height: auto;"/> style="cursor: pointer; vertical-align: bottom; width: 200px; height: auto;"/>
</el-dialog> </el-dialog>
@@ -124,6 +124,7 @@ 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 type { Pinia } from 'pinia'; import type { Pinia } from 'pinia';
import { resolveAssetUrl } from '@/utils/assetUrl';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
isEmbed?: boolean; isEmbed?: boolean;
@@ -133,6 +134,7 @@ const props = withDefaults(defineProps<{
}); });
const filesStore = props.piniaInstance ? useFilesStore(props.piniaInstance) : useFilesStore(); const filesStore = props.piniaInstance ? useFilesStore(props.piniaInstance) : useFilesStore();
const contactImageUrl = resolveAssetUrl('/assets/Other/Contact.png') as string;
const { showMessage } = useGlobalMessage(); const { showMessage } = useGlobalMessage();
const { selectionEnabled, snapGridEnabled, snaplineEnabled } = useCanvasSettings(); const { selectionEnabled, snapGridEnabled, snaplineEnabled } = useCanvasSettings();

View File

@@ -39,7 +39,7 @@
> >
<span <span
class="selector-image-frame" class="selector-image-frame"
:style="`width: ${imageSize - 1}px; height: ${imageSize - 1}px; background-image: url('${item[config.itemRender.imageField]}');`" :style="`width: ${imageSize - 1}px; height: ${imageSize - 1}px; background-image: url('${getItemImageUrl(item)}');`"
/> />
</el-button> </el-button>
<span style="text-align: center; display: block;"> <span style="text-align: center; display: block;">
@@ -56,6 +56,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { SelectorConfig, GroupConfig } from '@/types/selector' import type { SelectorConfig, GroupConfig } from '@/types/selector'
import { resolveAssetUrl } from '@/utils/assetUrl'
const props = defineProps<{ const props = defineProps<{
config: SelectorConfig config: SelectorConfig
@@ -75,6 +76,7 @@ const show = computed({
const searchText = ref('') 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 filteredItems = (group: GroupConfig) => { const filteredItems = (group: GroupConfig) => {
@@ -107,10 +109,17 @@ const filteredItems = (group: GroupConfig) => {
} }
const handleSelect = (item: any) => { const handleSelect = (item: any) => {
emit('select', item) const field = imageField.value
const normalizedItem = {
...item,
[field]: resolveAssetUrl(item?.[field])
}
emit('select', normalizedItem)
searchText.value = '' searchText.value = ''
activeTab.value = 'ALL' activeTab.value = 'ALL'
} }
const getItemImageUrl = (item: any) => resolveAssetUrl(item?.[imageField.value]) as string
</script> </script>
<style scoped> <style scoped>

View File

@@ -2,6 +2,7 @@
import { computed, ref, inject, onMounted, onBeforeUnmount } from 'vue'; import { computed, ref, inject, onMounted, onBeforeUnmount } from 'vue';
import { toTextStyle } from '@/ts/nodeStyle'; import { toTextStyle } from '@/ts/nodeStyle';
import { useNodeAppearance } from '@/ts/useNodeAppearance'; import { useNodeAppearance } from '@/ts/useNodeAppearance';
import { resolveAssetUrl } from '@/utils/assetUrl';
const currentAsset = ref({ name: '未选择资产', avatar: '', library: 'shikigami' }); const currentAsset = ref({ name: '未选择资产', avatar: '', library: 'shikigami' });
const getNode = inject('getNode') as (() => any) | undefined; const getNode = inject('getNode') as (() => any) | undefined;
@@ -47,6 +48,7 @@ const { containerStyle, textStyle } = useNodeAppearance({
}); });
const mergedContainerStyle = computed(() => ({ ...containerStyle.value, boxSizing: 'border-box' })); const mergedContainerStyle = computed(() => ({ ...containerStyle.value, boxSizing: 'border-box' }));
const normalizedAvatar = computed(() => resolveAssetUrl(currentAsset.value.avatar) as string);
</script> </script>
<template> <template>
@@ -54,7 +56,7 @@ const mergedContainerStyle = computed(() => ({ ...containerStyle.value, boxSizin
<div class="zindex-badge">{{ zIndex }}</div> <div class="zindex-badge">{{ zIndex }}</div>
<img <img
v-if="currentAsset.avatar" v-if="currentAsset.avatar"
:src="currentAsset.avatar" :src="normalizedAvatar"
:alt="currentAsset.name" :alt="currentAsset.name"
class="asset-image" class="asset-image"
draggable="false" draggable="false"

View File

@@ -4,6 +4,7 @@ import { useDialogs } from '@/ts/useDialogs';
import { getLogicFlowInstance } from '@/ts/useLogicFlow'; 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';
const props = defineProps<{ const props = defineProps<{
node: any; node: any;
@@ -30,15 +31,32 @@ const handleOpenSelector = () => {
return; return;
} }
const imageField = preset.itemRender.imageField;
const selectedAsset = node.properties?.selectedAsset || null;
const normalizedSelectedAsset = selectedAsset && typeof selectedAsset === 'object'
? {
...selectedAsset,
[imageField]: resolveAssetUrl(selectedAsset?.[imageField])
}
: selectedAsset;
const config: SelectorConfig = { const config: SelectorConfig = {
...preset, ...preset,
currentItem: node.properties?.selectedAsset || null dataSource: resolveAssetUrlsInDataSource(preset.dataSource as any[], imageField),
currentItem: normalizedSelectedAsset
}; };
openGenericSelector(config, (selectedItem) => { openGenericSelector(config, (selectedItem) => {
const normalizedSelected = selectedItem && typeof selectedItem === 'object'
? {
...selectedItem,
[imageField]: resolveAssetUrl(selectedItem?.[imageField])
}
: selectedItem;
lf.setProperties(node.id, { lf.setProperties(node.id, {
...node.properties, ...node.properties,
selectedAsset: selectedItem, selectedAsset: normalizedSelected,
assetLibrary: library assetLibrary: library
}); });
}); });

View File

@@ -2,6 +2,7 @@
import 'element-plus/dist/index.css' 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 { YysEditorEmbed } export { YysEditorEmbed }

162
src/utils/assetUrl.ts Normal file
View File

@@ -0,0 +1,162 @@
const ASSET_PREFIX = '/assets/'
const PROTOCOL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//
let explicitAssetBaseUrl: string | null = null
let inferredAssetBaseUrl: string | null = null
const ensureTrailingSlash = (value: string): string => (value.endsWith('/') ? value : `${value}/`)
const normalizeBaseUrl = (baseUrl: string): string => {
const trimmed = baseUrl.trim()
if (!trimmed) {
return '/'
}
if (PROTOCOL_RE.test(trimmed)) {
return ensureTrailingSlash(trimmed)
}
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
return ensureTrailingSlash(withLeadingSlash)
}
const inferFromNuxtRuntime = (): string | null => {
if (typeof globalThis === 'undefined') {
return null
}
const runtimeBase = (globalThis as any)?.__NUXT__?.config?.app?.baseURL
if (typeof runtimeBase === 'string' && runtimeBase.trim()) {
return normalizeBaseUrl(runtimeBase)
}
return null
}
const inferFromScripts = (): string | null => {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return null
}
const scripts = Array.from(document.querySelectorAll('script[src]'))
.map((script) => script.getAttribute('src') || '')
.filter(Boolean)
.reverse()
for (const src of scripts) {
try {
const pathname = new URL(src, window.location.origin).pathname
const markers = ['/_nuxt/', '/assets/', '/dist/']
for (const marker of markers) {
const markerIndex = pathname.indexOf(marker)
if (markerIndex >= 0) {
const prefix = pathname.slice(0, markerIndex)
return prefix ? ensureTrailingSlash(prefix) : '/'
}
}
} catch {
// 忽略非法 src
}
}
return null
}
const inferFromLocation = (): string => {
if (typeof window === 'undefined') {
return '/'
}
const segments = window.location.pathname.split('/').filter(Boolean)
if (segments.length === 0) {
return '/'
}
return `/${segments[0]}/`
}
export const setAssetBaseUrl = (baseUrl?: string | null) => {
if (!baseUrl) {
explicitAssetBaseUrl = null
inferredAssetBaseUrl = null
return
}
explicitAssetBaseUrl = normalizeBaseUrl(baseUrl)
}
export const getAssetBaseUrl = (): string => {
if (explicitAssetBaseUrl) {
return explicitAssetBaseUrl
}
if (inferredAssetBaseUrl) {
return inferredAssetBaseUrl
}
const nuxtBase = inferFromNuxtRuntime()
if (nuxtBase) {
inferredAssetBaseUrl = nuxtBase
return inferredAssetBaseUrl
}
const scriptBase = inferFromScripts()
if (scriptBase) {
inferredAssetBaseUrl = normalizeBaseUrl(scriptBase)
return inferredAssetBaseUrl
}
inferredAssetBaseUrl = normalizeBaseUrl(inferFromLocation())
return inferredAssetBaseUrl
}
export const resolveAssetUrl = (value: unknown): unknown => {
if (typeof value !== 'string') {
return value
}
if (!value.startsWith(ASSET_PREFIX)) {
return value
}
const baseUrl = getAssetBaseUrl()
if (baseUrl === '/') {
return value
}
return `${baseUrl}${value.slice(1)}`
}
const isPlainObject = (value: unknown): value is Record<string, any> => (
Object.prototype.toString.call(value) === '[object Object]'
)
export const rewriteAssetUrlsDeep = <T>(input: T): T => {
if (typeof input === 'string') {
return resolveAssetUrl(input) as T
}
if (Array.isArray(input)) {
return input.map((item) => rewriteAssetUrlsDeep(item)) as T
}
if (isPlainObject(input)) {
const output: Record<string, any> = {}
Object.keys(input).forEach((key) => {
output[key] = rewriteAssetUrlsDeep((input as Record<string, any>)[key])
})
return output as T
}
return input
}
export const resolveAssetUrlsInDataSource = <T extends Record<string, any>>(
dataSource: T[],
imageField: string
): T[] => dataSource.map((item) => {
const imageValue = item?.[imageField]
if (typeof imageValue !== 'string') {
return item
}
return {
...item,
[imageField]: resolveAssetUrl(imageValue)
}
})