liuhairui пре 1 недеља
родитељ
комит
9c07d251b3

+ 127 - 25
src/view/TemplateManagement/CreateTemplate.vue

@@ -289,7 +289,7 @@
             >
               <!-- 图片图层 -->
               <template v-if="layer.type !== 'text'">
-                <img :src="layer.url" :alt="layer.name" draggable="false" />
+                <img :src="getImageLoadUrl(layer.url)" :alt="layer.name" draggable="false" />
               </template>
               
               <!-- 文字图层 -->
@@ -366,7 +366,7 @@
                 <div class="layer-card-thumb text-thumb">T</div>
               </template>
               <template v-else>
-                <img v-if="layer.url" :src="layer.url" class="layer-card-thumb" alt="" />
+                <img v-if="layer.url" :src="getImageLoadUrl(layer.url)" class="layer-card-thumb" alt="" />
                 <div v-else class="layer-card-thumb img-placeholder"><el-icon><Picture /></el-icon></div>
               </template>
               <div class="layer-card-name-wrap" draggable="false" @dragstart.stop>
@@ -553,7 +553,7 @@ import { ElMessage } from 'element-plus'
 import { Rank, ArrowUp, ArrowDown, Delete, View, Hide, Plus, Picture, Upload, Search, ArrowLeft, EditPen, Document, Box, MagicStick, InfoFilled, Lock, Unlock, List, Setting } from '@element-plus/icons-vue'
 import { emitter } from '@/utils/bus.js'
 import { useUserStore } from '@/pinia/modules/user'
-import { Material_List, Template_Material_Add, Template_Material_Update, Template_Material_Relation } from '@/api/mes/job'
+import { Material_List, Template_Material_Add, Template_Material_Update, Template_Material_Relation, GetHttpUrl } from '@/api/mes/job'
 
 const props = defineProps({
   /** 列表页传入的模版对象(编辑/做同款时必传) */
@@ -695,12 +695,82 @@ const canMoveDown = computed(() => {
 
 let layerIdCounter = 0
 
-// 将 material_url 转为前端可请求的完整地址(相对路径加根路径,避免图片 404)
+// 获取服务器地址(用于拼接素材/图层图片 URL,显示用)
+const full_url = ref('')
+const fetchServerUrl = async () => {
+  try {
+    const res = await GetHttpUrl()
+    if (res.code === 0 && res.data && res.data.full_url) {
+      full_url.value = res.data.full_url || ''
+    }
+  } catch (error) {
+    console.error('获取服务器地址失败:', error)
+  }
+}
+
+// 显示用:上传/素材的完整 base,必须用 9093 端口,不要用 9090(CLI 端口)
+function getDisplayBaseUrl() {
+  const base = import.meta.env.VITE_BASE_PATH || ''
+  const port = import.meta.env.VITE_UPLOADS_PORT || '9093'
+  const uploadsBase = base && port ? `${base.replace(/:(\d+)?$/, '')}:${port}` : ''
+  const fromApi = (full_url.value || '').replace(/\/$/, '')
+  if (fromApi && !fromApi.match(/:9090(?:$|\/)/)) return fromApi
+  return uploadsBase || ''
+}
+
+// 将 material_url 转为前端可请求的完整地址(用于显示;后端返回 9090 时替换为 9093)
 function resolveMaterialUrl(path) {
   if (!path || typeof path !== 'string') return ''
   const p = path.trim()
-  if (p.startsWith('http://') || p.startsWith('https://') || p.startsWith('data:')) return p
-  return p.startsWith('/') ? p : '/' + p
+  if (!p) return ''
+  if (p.startsWith('data:')) return p
+  if (p.startsWith('http://') || p.startsWith('https://')) {
+    try {
+      const u = new URL(p)
+      if (u.port === '9090') return `${u.protocol}//${u.hostname}:9093${u.pathname}${u.search}`
+    } catch { /* ignore */ }
+    return p
+  }
+  const baseUrl = getDisplayBaseUrl()
+  const cleanPath = p.replace(/^public\//, '').replace(/^\//, '')
+  if (!baseUrl) return '/' + cleanPath
+  return `${baseUrl}/${cleanPath}`
+}
+
+// 图片加载用 URL:开发环境用 path 走 Vite 代理(同源),生产用 9093 完整 URL;画布与 generateCanvasPreview 共用
+function getImageLoadUrl(url) {
+  if (!url || typeof url !== 'string') return ''
+  const u = url.trim()
+  if (!u || u.startsWith('data:')) return u
+  let path = u
+  if (u.startsWith('http://')) {
+    try {
+      path = new URL(u).pathname
+    } catch {
+      return u
+    }
+  }
+  path = path.startsWith('/') ? path : '/' + path.replace(/^\/+/, '')
+  if (import.meta.env.DEV) return path
+  const base = getDisplayBaseUrl()
+  return base ? `${base.replace(/\/$/, '')}${path}` : path
+}
+
+// 保存/修改时:把 layer.url 转为仅路径(不要 http 前缀),data: 和已是路径的保持不变
+function toStoragePath(url) {
+  if (!url || typeof url !== 'string') return ''
+  const u = url.trim()
+  if (!u) return ''
+  if (u.startsWith('data:')) return u
+  if (u.startsWith('http://')) {
+    try {
+      const parsed = new URL(u)
+      return parsed.pathname || ('/' + u.replace(/^\/+/, ''))
+    } catch {
+      return u
+    }
+  }
+  return u.startsWith('/') ? u : '/' + u
 }
 
 // 清空画布,进入新建流程(本组件内部或由 props.mode='create' 触发)
@@ -1042,7 +1112,7 @@ const fetchMaterials = async () => {
     materialsLoading.value = true
     console.log('开始获取素材库数据')
     const response = await Material_List({
-      search: (materialSearch.value || '').trim() || undefined
+      search: (materialSearch.value || '').trim()
     })
     console.log('素材库数据:', response)
     if (response.code === 0) {
@@ -1075,22 +1145,47 @@ const generateCanvasPreview = async () => {
   ctx.fillStyle = '#ffffff'
   ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height)
 
-  // 预加载图片图层
+  // 预加载图片图层:与画布共用 getImageLoadUrl,确保 URL 解析一致
   const imageLayers = layers.value.filter(l => (l.type || 'image') !== 'text' && l.url)
-  await Promise.all(
-    imageLayers.map(layer => {
-      return new Promise(resolve => {
-        const img = new Image()
-        img.crossOrigin = 'anonymous'
-        img.onload = () => {
-          layer._previewImg = img
-          resolve()
-        }
-        img.onerror = () => resolve()
-        img.src = layer.url
+  for (const layer of imageLayers) {
+    const url = layer.url
+    if (url.startsWith('data:')) {
+      const img = new Image()
+      await new Promise((r) => {
+        img.onload = () => r()
+        img.onerror = () => r()
+        img.src = url
       })
-    })
-  )
+      layer._previewImg = img.complete && img.naturalWidth ? img : null
+      continue
+    }
+    const fetchUrl = getImageLoadUrl(url)
+    let img = null
+    try {
+      const res = await fetch(fetchUrl)
+      if (res.ok) {
+        const blob = await res.blob()
+        const objUrl = URL.createObjectURL(blob)
+        img = new Image()
+        await new Promise((r, reject) => {
+          img.onload = () => r()
+          img.onerror = () => reject()
+          img.src = objUrl
+        })
+        URL.revokeObjectURL(objUrl)
+      }
+    } catch {
+      img = new Image()
+      img.crossOrigin = 'anonymous'
+      await new Promise((r) => {
+        img.onload = () => r()
+        img.onerror = () => r()
+        img.src = fetchUrl
+      })
+      if (!img.complete || !img.naturalWidth) img = null
+    }
+    layer._previewImg = img
+  }
 
   // 按当前顺序绘制所有可见图层
   for (const layer of layers.value) {
@@ -1147,7 +1242,13 @@ const generateCanvasPreview = async () => {
     delete layer._previewImg
   }
 
-  return exportCanvas.toDataURL('image/png')
+  try {
+    return exportCanvas.toDataURL('image/png')
+  } catch (e) {
+    // 跨域图片可能导致 canvas 污染,toDataURL 抛错
+    console.warn('画布预览图生成失败(可能因跨域图片):', e)
+    return null
+  }
 }
 
 let materialSearchTimer = null
@@ -1250,6 +1351,7 @@ const handleGlobalClick = (e) => {
 watch(templateName, (v) => setTemplateNameElContent(v))
 
 onMounted(() => {
+  fetchServerUrl()
   fetchMaterials()
   loadByMode()
   nextTick(() => setTemplateNameElContent(templateName.value))
@@ -1281,16 +1383,16 @@ const saveTemplate = async () => {
 
   const templateData = {
     template_name: templateName.value || '未命名模版',
-    sys_id:userStore.userInfo.nickName,
+    sys_id: userStore.userInfo.nickName,
     canvasWidth: canvasWidth.value,
     canvasHeight: canvasHeight.value,
     canvasRatio: canvasRatio.value,
-    previewImage, // 画布整体预览图(base64)
+    previewImage, 
     layers: layers.value.map(layer => ({
       id: layer.id,
       name: layer.name,
       type: layer.type || 'image',
-      url: layer.url,
+      url: toStoragePath(layer.url),
       text: layer.text,
       x: layer.x,
       y: layer.y,

+ 41 - 2
src/view/TemplateManagement/TemplateDesign.vue

@@ -66,7 +66,11 @@
               class="template-item"
             >
               <div class="template-preview">
-                <img :src="template.thumbnail_image" :alt="template.template_name" />
+                <img
+                  :src="formatImageUrl(template.thumbnail_image || template.template_image_url)"
+                  :alt="template.template_name"
+                  @error="(e) => { e.target.onerror = null; e.target.src = 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22150%22%3E%3Crect fill=%22%23f5f5f5%22 width=%22200%22 height=%22150%22/%3E%3C/svg%3E' }"
+                />
               <span v-if="libraryMenuActive === 'myWorks'" class="template-release-tag" :class="{ published: isPublished(template) }">
                   {{ isPublished(template) ? '已发布' : '未发布' }}
                 </span>
@@ -118,7 +122,7 @@ import { ElMessage } from 'element-plus'
 import { Plus, Picture, Search, MoreFilled, Loading, Delete } from '@element-plus/icons-vue'
 import { ElMessageBox } from 'element-plus'
 import { useUserStore } from '@/pinia/modules/user'
-import { Template_Material_Delete, Template_Material_Publish, Template_Material_Unpublish, product_template } from '@/api/mes/job'
+import { Template_Material_Delete, Template_Material_Publish, Template_Material_Unpublish, product_template, GetHttpUrl } from '@/api/mes/job'
 import CreateTemplate from './CreateTemplate.vue'
 
 const currentView = ref('library')
@@ -137,6 +141,40 @@ const userStore = useUserStore()
 
 const isPublished = (template) => template?.release === 1 || template?.release === '1'
 
+// 获取服务器地址(用于拼接图片 URL,显示用)
+const full_url = ref('')
+const fetchServerUrl = async () => {
+  try {
+    const res = await GetHttpUrl()
+    if (res.code === 0 && res.data && res.data.full_url) {
+      full_url.value = res.data.full_url || ''
+    }
+  } catch (error) {
+    console.error('获取服务器地址失败:', error)
+  }
+}
+
+// 显示用 base,必须用 9093 端口,不要用 9090(CLI 端口)
+function getDisplayBaseUrl() {
+  const base = import.meta.env.VITE_BASE_PATH || ''
+  const port = import.meta.env.VITE_UPLOADS_PORT || '9093'
+  const uploadsBase = base && port ? `${base.replace(/:(\d+)?$/, '')}:${port}` : ''
+  const fromApi = (full_url.value || '').replace(/\/$/, '')
+  if (fromApi && !fromApi.match(/:9090(?:$|\/)/)) return fromApi
+  return uploadsBase || ''
+}
+
+const formatImageUrl = (path) => {
+  if (!path || typeof path !== 'string') return ''
+  const p = path.trim()
+  if (!p) return ''
+  if (p.startsWith('http://') || p.startsWith('https://') || p.startsWith('data:')) return p
+  const baseUrl = getDisplayBaseUrl()
+  const cleanPath = p.replace(/^public\//, '').replace(/^\//, '')
+  if (!baseUrl) return '/' + cleanPath
+  return `${baseUrl}/${cleanPath}`
+}
+
 const fetchTemplates = async () => {
   try {
     templatesLoading.value = true
@@ -254,6 +292,7 @@ const handleDelete = async (template) => {
 }
 
 onMounted(() => {
+  fetchServerUrl()
   fetchTemplates()
 })