liuhairui vor 1 Monat
Ursprung
Commit
747ff80acd

+ 0 - 0
public/config.js


+ 77 - 5
src/api/mes/job.js

@@ -1193,7 +1193,7 @@ export const getPreviewFolders = (params) => {
     params
   })
 }
-
+//获取视频列表
 export const Getvideolist = (params) => {
   return service({
     url: '/mes_server/work_order/Getvideolist',
@@ -1222,15 +1222,21 @@ export const GetHttpUrl = (params) => {
     params
   })
 }
-export const GetTxtToTxt = (params) => {
+export const GetProductList = (params) => {
   return service({
-    url: '/mes_server/work_order/GetTxtToTxt',
+    url: '/mes_server/work_order/GetProductList',
     method: 'get',
     params
   })
 }
-
-
+export const CallAIModelApi = (data, config = {}) => {
+  return service({
+    url: '/mes_server/work_order/CallAIModelApi',
+    method: 'post',
+    data,
+    ...config
+  })
+}
 
 
 //商铺列表
@@ -1409,6 +1415,37 @@ export const Template_Material_Relation = (params) => {
     params
   })
 }
+//素材图片删除
+export const materialDelete = (data) => {
+  return service({
+    url: '/mes_server/Material/materialDelete',
+    method: 'post',
+    data
+  })
+}
+
+// 素材修改
+export const Material_Update = (data) => {
+  return service({
+    url: '/mes_server/Material/Material_Update',
+    method: 'post',
+    data
+  })
+}
+
+/**
+ * 可选:单独上报素材使用次数(若已在 Template_Material_Add/Update 内按 layers 维护 count,可不再调用)
+ * 入参:{ material_ids: number[] }
+ */
+export const Material_RecordUse = (data) => {
+  return service({
+    url: '/mes_server/Material/Material_RecordUse',
+    method: 'post',
+    data,
+    donNotShowLoading: true,
+    skipErrorMessage: true
+  })
+}
 
 //新增模版(生成模版)
 export const Template_Material_Add = (data) => {
@@ -1464,6 +1501,15 @@ export const Material_Upload = (data) => {
   })
 }
 
+// 素材新增
+export const Material_Add = (data) => {
+  return service({
+    url: '/mes_server/Material/Material_Add',
+    method: 'post',
+    data
+  })
+}
+
 // 素材分类查询
 export const Material_Category_List = (params) => {
   return service({
@@ -1498,4 +1544,30 @@ export const Material_Category_Delete = (data) => {
     method: 'post',
     data
   })
+}
+
+
+//获取 AI 模型配置
+export const GetAIModel = (params) => {
+  return service({
+    url: '/mes_server/work_order/GetAIModel',
+    method: 'get',
+    params
+  })
+}
+//新增 AI 模型配置
+export const AddAIModel = (data) => {
+  return service({
+    url: '/mes_server/work_order/AddAIModel',
+    method: 'post',
+    data
+  })
+}
+//修改 AI 模型配置
+export const UpdateAIModel = (data) => {
+  return service({
+    url: '/mes_server/work_order/UpdateAIModel',
+    method: 'post',
+    data
+  })
 }

+ 14 - 0
src/utils/displayImageUrl.js

@@ -0,0 +1,14 @@
+/**
+ * 展示用图片地址:与接口 material_url 等字段一致,不做域名、端口、OSS 前缀拼接。
+ * 后端已返回完整阿里云 OSS 地址时直接使用;data/blob 同理。
+ * @param {string|null|undefined} path
+ * @returns {string}
+ */
+export function displayImageUrl(path) {
+  if (path == null || typeof path !== 'string') return ''
+  const p = path.trim()
+  if (!p) return ''
+  if (p.startsWith('data:') || p.startsWith('blob:')) return p
+  if (p.startsWith('http://') || p.startsWith('https://') || p.startsWith('//')) return p
+  return p
+}

+ 5 - 2
src/utils/image.js

@@ -1,3 +1,5 @@
+import { displayImageUrl } from './displayImageUrl.js'
+
 export default class ImageCompress {
   constructor(file, fileSize, maxWH = 1920) {
     this.file = file
@@ -91,8 +93,9 @@ export default class ImageCompress {
   }
 }
 
-const path = import.meta.env.VITE_FILE_API + '/'
-export const getUrl = (url) => url && url.slice(0, 4) !== 'http' ? path + url : url
+/** @deprecated 与 displayImageUrl 一致,不再拼接 VITE_FILE_API */
+export const getUrl = displayImageUrl
+export { displayImageUrl }
 
 export const isVideoExt = (url) => url.endsWith('.mp4') || url.endsWith('.mov') || url.endsWith('.webm') || url.endsWith('.ogg');
 

+ 16 - 5
src/utils/request.js

@@ -43,6 +43,15 @@ service.interceptors.request.use(
       'x-user-id': userStore.userInfo.ID,
       ...config.headers
     }
+    // 必须去掉 Content-Type,让浏览器自动带 multipart boundary;若被合并回 application/json,后端收不到 $_FILES
+    if (isFormData && config.headers) {
+      if (typeof config.headers.delete === 'function') {
+        config.headers.delete('Content-Type')
+      } else {
+        delete config.headers['Content-Type']
+        delete config.headers['content-type']
+      }
+    }
     return config
   },
   error => {
@@ -74,11 +83,13 @@ service.interceptors.response.use(
       }
       return response.data
     } else {
-      ElMessage({
-        showClose: true,
-        message: response.data.msg || decodeURI(response.headers.msg),
-        type: 'error'
-      })
+      if (!response.config.skipErrorMessage) {
+        ElMessage({
+          showClose: true,
+          message: response.data.msg || decodeURI(response.headers.msg),
+          type: 'error'
+        })
+      }
       if (response.data.data && response.data.data.reload) {
         userStore.token = ''
         localStorage.clear()

+ 685 - 0
src/view/MaterialManagement/Library.vue

@@ -0,0 +1,685 @@
+<template>
+  <div class="material-library">
+    <div class="gva-table-box">
+      <div class="gva-btn-list">
+        <el-button type="primary" icon="Plus" @click="openAdd">新增素材</el-button>
+        <el-button icon="Refresh" @click="getList">刷新</el-button>
+        <el-input
+          v-model="searchKeyword"
+          placeholder="搜索素材名称、分类"
+          style="width: 280px; margin-left: auto;"
+          @keyup.enter="handleSearch"
+        >
+          <template #prefix>
+            <el-icon><Search /></el-icon>
+          </template>
+        </el-input>
+        <el-button type="primary" @click="handleSearch">查询</el-button>
+      </div>
+      <div class="category-bar">
+        <div class="category-row">
+          <span class="category-label">分类:</span>
+          <span
+            class="category-chip"
+            :class="{ active: !selectedCategoryId }"
+            @click="selectCategory(null)"
+          >全部</span>
+          <span
+            v-for="c1 in categoryList"
+            :key="c1.id"
+            class="category-chip"
+            :class="{ active: selectedCategoryId === c1.id }"
+            @click="selectCategory(c1.id)"
+          >{{ c1.category_name }}</span>
+        </div>
+      </div>
+      <div v-loading="loading" class="material-waterfall">
+        <div v-for="item in tableData" :key="item.id" class="material-card">
+          <div class="material-preview" @click="previewImage(item)">
+            <span class="material-id-badge">素材 {{ item.id }}</span>
+            <el-image
+              :src="formatImageUrl(item.material_url)"
+              fit="cover"
+              class="material-img"
+            >
+              <template #error>
+                <div class="material-img-error">
+                  <el-icon><Picture /></el-icon>
+                  <span>加载失败</span>
+                </div>
+              </template>
+            </el-image>
+          </div>
+          <div class="material-info">
+            <div class="material-info-text">
+              <div class="material-name" :title="item.material_name">{{ item.material_name || '-' }}</div>
+              <div class="material-meta">{{ item.category_name || '未分类' }}</div>
+            </div>
+            <div class="material-card-actions" @click.stop>
+              <el-button type="primary" link icon="Edit" title="修改" @click="openEdit(item)" />
+              <el-button type="danger" link icon="Delete" title="删除" @click="handleDelete(item)" />
+            </div>
+          </div>
+        </div>
+      </div>
+      <div v-if="!loading && tableData.length === 0" class="material-empty">暂无素材</div>
+      <div v-if="total > 0" class="gva-pagination">
+        <el-pagination
+          v-model:current-page="page"
+          v-model:page-size="pageSize"
+          :page-sizes="[50, 100, 200, 300]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="getList"
+          @current-change="getList"
+        />
+      </div>
+    </div>
+    <el-image-viewer
+      v-if="previewVisible"
+      :url-list="previewList"
+      :initial-index="previewIndex"
+      @close="previewVisible = false"
+    />
+    <el-dialog v-model="addVisible" title="新增素材" width="760px" class="add-material-dialog" @closed="resetAddForm">
+      <el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="80px">
+        <el-form-item label="分类" prop="Category_id">
+          <el-select v-model="addForm.Category_id" placeholder="选择分类" style="width: 100%">
+            <el-option v-for="opt in categoryOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="图片上传">
+          <div class="add-upload-area add-upload-fixed">
+            <el-upload
+              v-if="addFileList.length < 10"
+              ref="addUploadRef"
+              class="add-upload-drag"
+              drag
+              :auto-upload="false"
+              :show-file-list="false"
+              accept="image/jpeg,image/png,image/webp,image/gif"
+              :on-change="onAddFileChange"
+              multiple
+            >
+              <el-icon class="add-upload-icon" :size="48"><UploadFilled /></el-icon>
+              <div class="add-upload-text">将图片拖到此处,或<em>点击上传</em></div>
+              <div class="add-upload-hint">最多10张,每张≤1MB</div>
+            </el-upload>
+            <div class="add-file-list">
+              <div v-for="(item, idx) in addFileList" :key="item.id" class="add-file-item">
+                <div class="add-file-preview">
+                  <el-image :src="item.url" fit="cover" />
+                  <span class="add-file-remove" @click.stop="removeAddFile(idx)">×</span>
+                </div>
+                <el-input
+                  v-model="item.material_name"
+                  size="small"
+                  class="add-file-name-input"
+                  placeholder="素材名称"
+                  maxlength="100"
+                />
+                <div class="add-file-size">{{ item.sizeStr }}</div>
+              </div>
+            </div>
+          </div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="addVisible = false">取消</el-button>
+        <el-button type="primary" :loading="addLoading" :disabled="addFileList.length === 0" @click="submitAdd">确定上传</el-button>
+      </template>
+    </el-dialog>
+    <el-dialog v-model="editVisible" title="修改素材" width="420px" @closed="editFormRef?.resetFields()">
+      <el-form ref="editFormRef" :model="editForm" :rules="editRules" label-width="80px">
+        <el-form-item label="素材名称" prop="material_name">
+          <el-input v-model="editForm.material_name" placeholder="素材名称" />
+        </el-form-item>
+        <el-form-item label="分类" prop="Category_id">
+          <el-select v-model="editForm.Category_id" placeholder="选择分类" style="width: 100%">
+            <el-option
+              v-for="opt in categoryOptions"
+              :key="opt.value"
+              :label="opt.label"
+              :value="opt.value"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="editVisible = false">取消</el-button>
+        <el-button type="primary" :loading="editLoading" @click="submitEdit">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { Material_List, Material_Category_List, materialDelete, Material_Update, Material_Add } from '@/api/mes/job'
+import { resolveMaterialUrl } from '@/view/TemplateManagement/utils'
+import { ref, computed, onMounted, toRaw } from 'vue'
+import { Search, Picture, Delete, Edit, Plus, UploadFilled } from '@element-plus/icons-vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useUserStore } from '@/pinia/modules/user'
+
+defineOptions({ name: 'MaterialLibrary' })
+
+const userStore = useUserStore()
+
+const loading = ref(false)
+const searchKeyword = ref('')
+const categoryList = ref([])
+const selectedCategoryId = ref(null)
+const tableData = ref([])
+const page = ref(1)
+const pageSize = ref(30)
+const total = ref(0)
+const previewVisible = ref(false)
+const previewList = ref([])
+const previewIndex = ref(0)
+const editVisible = ref(false)
+const editLoading = ref(false)
+const editFormRef = ref(null)
+const editForm = ref({ id: null, material_name: '', Category_id: null })
+const editRules = { material_name: [{ required: true, message: '请输入素材名称', trigger: 'blur' }] }
+
+const addVisible = ref(false)
+const addLoading = ref(false)
+const addFormRef = ref(null)
+const addUploadRef = ref(null)
+/** 与 addFileList 同步:每张待上传图的名称,提交时组成 material_name 数组 */
+const addForm = ref({ Category_id: null, material_name: [] })
+const addRules = { Category_id: [{ required: true, message: '请选择分类', trigger: 'change' }] }
+const addFileList = ref([])
+const addFileId = ref(0)
+const MAX_FILES = 10
+const MAX_SIZE = 1024 * 1024
+
+const formatFileSize = (bytes) => {
+  if (!bytes) return '0 B'
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+  return `${(bytes / 1024 / 1024).toFixed(2)} MB`
+}
+
+const categoryOptions = computed(() => {
+  const opts = []
+  for (const c1 of categoryList.value) {
+    opts.push({ label: c1.category_name, value: c1.id })
+    for (const c2 of c1.children || []) {
+      opts.push({ label: `${c1.category_name} / ${c2.category_name}`, value: c2.id })
+    }
+  }
+  return opts
+})
+
+const formatImageUrl = (path) => {
+  if (!path || typeof path !== 'string') return ''
+  return resolveMaterialUrl(path)
+}
+
+const selectCategory = (id) => {
+  selectedCategoryId.value = id
+  page.value = 1
+  getList()
+}
+
+const handleSearch = () => {
+  page.value = 1
+  getList()
+}
+
+const defaultMaterialNameFromFile = (file) => {
+  const n = file?.name || ''
+  return n.replace(/\.[^.]+$/, '') || n || '未命名'
+}
+
+const openAdd = () => {
+  addForm.value = { Category_id: null, material_name: [] }
+  addFileList.value = []
+  addVisible.value = true
+}
+
+const resetAddForm = () => {
+  addFileList.value.forEach((item) => URL.revokeObjectURL(item.url))
+  addFileList.value = []
+  addForm.value = { Category_id: null, material_name: [] }
+  addFormRef.value?.resetFields()
+}
+
+const onAddFileChange = (uploadFile) => {
+  const file = uploadFile?.raw
+  if (!file) return
+  if (addFileList.value.length >= MAX_FILES) {
+    ElMessage.warning(`最多上传 ${MAX_FILES} 张图片`)
+    return
+  }
+  if (file.size > MAX_SIZE) {
+    ElMessage.warning(`「${file.name}」超过 1MB 限制`)
+    return
+  }
+  const url = URL.createObjectURL(file)
+  addFileList.value.push({
+    id: ++addFileId.value,
+    file,
+    url,
+    sizeStr: formatFileSize(file.size),
+    material_name: defaultMaterialNameFromFile(file)
+  })
+  addUploadRef.value?.clearFiles()
+}
+
+const removeAddFile = (idx) => {
+  const item = addFileList.value[idx]
+  URL.revokeObjectURL(item.url)
+  addFileList.value.splice(idx, 1)
+}
+
+const submitAdd = async () => {
+  if (!addFormRef.value) return
+  try {
+    await addFormRef.value.validate()
+  } catch {
+    return
+  }
+  if (addFileList.value.length === 0) {
+    ElMessage.warning('请至少上传一张图片')
+    return
+  }
+  /** 与文件顺序一致的名称列表(用于表单状态) */
+  const material_name = addFileList.value.map((item) => {
+    const n = (item.material_name || '').trim()
+    return n || defaultMaterialNameFromFile(item.file)
+  })
+  addForm.value.material_name = material_name
+  addLoading.value = true
+  try {
+    /**
+     * 单次请求:每张图一对 —— img[] 二进制、material_name[] 名称(同序,便于 PHP 遍历)
+     */
+    const formData = new FormData()
+    formData.append('Category_id', String(addForm.value.Category_id ?? ''))
+    formData.append('sys_id', String(userStore.userInfo?.nickName ?? ''))
+    for (const item of addFileList.value) {
+      const rawFile = toRaw(item.file) || item.file
+      if (!rawFile || !(rawFile instanceof Blob)) {
+        ElMessage.error('存在无效文件,请移除后重新选择')
+        return
+      }
+      const name = (item.material_name || '').trim() || defaultMaterialNameFromFile(rawFile)
+      formData.append('img[]', rawFile, rawFile.name || 'image')
+      formData.append('material_name[]', name)
+    }
+    const res = await Material_Add(formData)
+    if (res.code === 0) {
+      const n = addFileList.value.length
+      ElMessage.success(res.msg || `成功上传 ${n} 张`)
+      addVisible.value = false
+      getList()
+    } else {
+      ElMessage.error(res.msg || '上传失败')
+    }
+  } catch (e) {
+    console.error('Material_Add error:', e)
+    ElMessage.error('上传失败')
+  } finally {
+    addLoading.value = false
+  }
+}
+
+const getCategoryList = async () => {
+  try {
+    const res = await Material_Category_List()
+    if (res.code === 0 && Array.isArray(res.data)) {
+      categoryList.value = res.data
+    }
+  } catch (e) {
+    console.error('Material_Category_List error:', e)
+  }
+}
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const params = {
+      search: searchKeyword.value?.trim() || '',
+      page: page.value,
+      pageSize: pageSize.value
+    }
+    if (selectedCategoryId.value != null && selectedCategoryId.value !== '') {
+      params.Category_id = selectedCategoryId.value
+    }
+    const res = await Material_List(params)
+    if (res.code === 0) {
+      tableData.value = Array.isArray(res.data) ? res.data : []
+      total.value = res.total ?? res.count ?? tableData.value.length
+    } else {
+      ElMessage.error(res.msg || '获取列表失败')
+      tableData.value = []
+      total.value = 0
+    }
+  } catch (e) {
+    console.error('Material_List error:', e)
+    ElMessage.error('获取列表失败')
+    tableData.value = []
+    total.value = 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const openEdit = (item) => {
+  editForm.value = {
+    id: item.id,
+    material_name: item.material_name ?? '',
+    Category_id: item.Category_id ?? item.category_id ?? null
+  }
+  editVisible.value = true
+}
+
+const submitEdit = async () => {
+  if (!editFormRef.value) return
+  try {
+    await editFormRef.value.validate()
+  } catch {
+    return
+  }
+  editLoading.value = true
+  try {
+    const payload = {
+      id: editForm.value.id,
+      material_name: editForm.value.material_name,
+      Category_id: editForm.value.Category_id ?? null
+    }
+    const res = await Material_Update(payload)
+    if (res.code === 0) {
+      ElMessage.success(res.msg || '修改成功')
+      editVisible.value = false
+      getList()
+    } else {
+      ElMessage.error(res.msg || '修改失败')
+    }
+  } catch (e) {
+    console.error('Material_Update error:', e)
+    ElMessage.error('修改失败')
+  } finally {
+    editLoading.value = false
+  }
+}
+
+const handleDelete = async (item) => {
+  try {
+    await ElMessageBox.confirm(`确定删除素材「${item.material_name || '未命名'}」?`, '提示', {
+      type: 'warning'
+    })
+  } catch {
+    return
+  }
+  try {
+    const res = await materialDelete({ id: item.id })
+    if (res.code === 0) {
+      ElMessage.success(res.msg || '删除成功')
+      getList()
+    }
+    // code !== 0(如已被模板使用):全局 axios 拦截器已弹出 res.msg,此处不再重复提示
+  } catch (e) {
+    console.error('materialDelete error:', e)
+    // 业务失败(code≠0)走 resolve,已由全局拦截器提示;此处仅兜底网络等异常
+    ElMessage.error('删除失败')
+  }
+}
+
+const previewImage = (item) => {
+  const url = formatImageUrl(item.material_url)
+  if (!url) return
+  const list = tableData.value.map((row) => formatImageUrl(row.material_url)).filter(Boolean)
+  const idx = list.indexOf(url)
+  previewList.value = list
+  previewIndex.value = idx >= 0 ? idx : 0
+  previewVisible.value = true
+}
+
+onMounted(async () => {
+  await getCategoryList()
+  getList()
+})
+</script>
+
+<style scoped>
+.material-library { padding: 0 12px; }
+.category-bar {
+  margin-bottom: 12px;
+  padding: 12px;
+  background: #fafafa;
+  border-radius: 8px;
+}
+.category-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 6px;
+}
+.category-row:last-child { margin-bottom: 0; }
+.category-label {
+  font-size: 13px;
+  color: #606266;
+  flex-shrink: 0;
+}
+.category-chip {
+  padding: 4px 12px;
+  font-size: 13px;
+  border-radius: 4px;
+  background: #fff;
+  border: 1px solid #dcdfe6;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+.category-chip:hover { border-color: #409eff; color: #409eff; }
+.category-chip.active { background: #409eff; border-color: #409eff; color: #fff; }
+/* 用 Grid 按行从左到右排;勿用 column-count,多列布局会先竖向填满一列再下一列 */
+.material-waterfall {
+  display: grid;
+  grid-template-columns: repeat(7, minmax(0, 1fr));
+  gap: 16px;
+  padding: 12px 0;
+  min-height: 200px;
+}
+@media (max-width: 1800px) { .material-waterfall { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
+@media (max-width: 1400px) { .material-waterfall { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
+@media (max-width: 900px) { .material-waterfall { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 600px) { .material-waterfall { grid-template-columns: minmax(0, 1fr); } }
+.material-card {
+  min-width: 0;
+  border: 1px solid #ebeef5;
+  border-radius: 8px;
+  overflow: hidden;
+  background: #fff;
+  transition: box-shadow 0.2s;
+}
+.material-card:hover { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); }
+.material-id-badge {
+  position: absolute;
+  top: 8px;
+  left: 8px;
+  z-index: 2;
+  padding: 2px 8px;
+  font-size: 12px;
+  line-height: 1.25;
+  color: #fff;
+  background: #333;
+  border-radius: 4px;
+  pointer-events: none;
+  max-width: calc(100% - 16px);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.material-card-actions {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  padding: 2px 0 2px 4px;
+}
+.material-card-actions .el-button { padding: 4px; }
+.material-preview {
+  position: relative;
+  aspect-ratio: 1;
+  background: #f5f7fa;
+  cursor: pointer;
+  overflow: hidden;
+}
+.material-img {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+.material-img-error {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  font-size: 12px;
+  gap: 4px;
+}
+.material-info {
+  display: flex;
+  flex-direction: row;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 8px;
+  min-height: 52px;
+  box-sizing: border-box;
+}
+.material-info-text {
+  min-width: 0;
+  flex: 1;
+}
+.material-name {
+  font-size: 13px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.material-meta {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 4px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.material-empty {
+  text-align: center;
+  padding: 60px;
+  color: #909399;
+  font-size: 14px;
+}
+.gva-pagination {
+  margin-top: 16px;
+  display: flex;
+  justify-content: flex-end;
+}
+.add-upload-area { margin-bottom: 0; width: 100%; }
+.add-upload-fixed {
+  width: 100%;
+  height: 420px;
+  min-height: 420px;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  box-sizing: border-box;
+}
+.add-upload-fixed .add-upload-drag {
+  flex-shrink: 0;
+  width: 100%;
+  height: 130px;
+  min-height: 130px;
+}
+.add-upload-fixed .add-file-list {
+  flex: 1;
+  min-height: 0;
+  min-width: 0;
+  width: 100%;
+  margin-top: 16px;
+  padding-top: 16px;
+  border-top: 1px solid #ebeef5;
+  overflow-y: auto !important;
+}
+:deep(.add-material-dialog .el-dialog__body) {
+  height: 520px;
+  min-height: 520px;
+  overflow: hidden;
+  width: 100%;
+  box-sizing: border-box;
+}
+:deep(.add-material-dialog .el-form-item__content) {
+  width: 100%;
+}
+.add-upload-drag :deep(.el-upload) { width: 100%; height: 100%; }
+.add-upload-drag :deep(.el-upload-dragger) {
+  width: 100%;
+  height: 100%;
+  padding: 20px 24px;
+  border-radius: 8px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+.add-upload-icon { color: #c0c4cc; margin-bottom: 12px; }
+.add-upload-text { font-size: 14px; color: #606266; }
+.add-upload-text em { color: #409eff; font-style: normal; }
+.add-upload-hint { font-size: 12px; margin-top: 8px; color: #c0c4cc; }
+.add-file-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-top: 12px;
+  width: 100%;
+  align-content: flex-start;
+  padding-bottom: 4px;
+}
+.add-file-item {
+  width: 128px;
+  text-align: center;
+}
+.add-file-name-input {
+  margin-top: 6px;
+  width: 100%;
+}
+.add-file-name-input :deep(.el-input__wrapper) {
+  padding: 2px 8px;
+}
+.add-file-preview {
+  position: relative;
+  aspect-ratio: 1;
+  border-radius: 6px;
+  overflow: hidden;
+  background: #f5f7fa;
+}
+.add-file-preview .el-image { width: 100%; height: 100%; display: block; }
+.add-file-remove {
+  position: absolute;
+  top: 4px;
+  right: 4px;
+  width: 22px;
+  height: 22px;
+  line-height: 20px;
+  text-align: center;
+  font-size: 18px;
+  color: #fff;
+  background: rgba(0, 0, 0, 0.5);
+  border-radius: 50%;
+  cursor: pointer;
+  opacity: 0;
+  transition: opacity 0.2s;
+}
+.add-file-item:hover .add-file-remove { opacity: 1; }
+.add-file-remove:hover { background: rgba(245, 108, 108, 0.9); }
+.add-file-size { font-size: 11px; color: #909399; margin-top: 4px; }
+</style>

+ 351 - 0
src/view/ModelConfiguration/aitoken.vue

@@ -0,0 +1,351 @@
+<template>
+  <div class="ai-model-config">
+    <div class="gva-table-box">
+      <div class="gva-btn-list">
+        <el-button type="primary" icon="plus" @click="openDialog">新增</el-button>
+        <el-button icon="Refresh" @click="getTableData">刷新</el-button>
+        <el-input
+          v-model="searchKeyword"
+          placeholder="搜索任意字段"
+          style="width: 320px; margin-left: auto;"
+        >
+          <template #prefix>
+            <el-icon><Search /></el-icon>
+          </template>
+        </el-input>
+      </div>
+      <el-table v-loading="loading" :data="filteredTableData" row-key="id" style="width: 100%">
+        <el-table-column align="left" label="ID" prop="id" width="40" />
+        <el-table-column align="left" label="状态" width="70">
+          <template #default="scope">
+            <el-tag :type="scope.row.status === '1' ? 'success' : 'info'">{{ scope.row.status === '1' ? '启用' : '禁用' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column align="left" label="API地址名称" prop="supplier" min-width="80" show-overflow-tooltip />
+        <el-table-column align="left" label="密钥" prop="api_key" min-width="55" show-overflow-tooltip>
+          <template #default="scope">{{ scope.row.api_key ? '***' + String(scope.row.api_key).slice(-4) : '-' }}</template>
+        </el-table-column>
+        <el-table-column align="left" label="接口地址" prop="api_url" min-width="200" show-overflow-tooltip />
+        <el-table-column align="left" label="分组" prop="model_group" width="100" show-overflow-tooltip />
+        <el-table-column align="left" label="模型" prop="model_name" min-width="160" show-overflow-tooltip />
+        <el-table-column align="left" label="模型别名" prop="model_alias" min-width="160" show-overflow-tooltip />
+        <el-table-column align="left" label="模型类型" prop="model_type" min-width="120" show-overflow-tooltip />
+        <el-table-column align="left" label="优先级" prop="sort" width="90">
+          <template #header>
+            <el-tooltip content="同一模型内的优先级,数值越小越优先" placement="top">
+              <span>优先级 <el-icon class="el-icon--right"><QuestionFilled /></el-icon></span>
+            </el-tooltip>
+          </template>
+        </el-table-column>
+        <el-table-column align="left" label="操作" width="80" fixed="right">
+          <template #default="scope">
+            <el-button type="primary" link icon="edit" @click="updateRow(scope.row)">修改</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <el-dialog
+      v-model="dialogVisible"
+      :title="type === 'create' ? '新增 AI 模型配置' : '修改 AI 模型配置'"
+      width="640px"
+      :before-close="closeDialog"
+    >
+      <el-form
+        ref="formRef"
+        :model="form"
+        :rules="formRules"
+        label-width="100px"
+      >
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="form.status">
+            <el-radio value="1">启用</el-radio>
+            <el-radio value="0">禁用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="API地址名称" prop="supplier">
+          <el-select
+            v-model="form.supplier"
+            filterable
+            allow-create
+            placeholder="选择或输入"
+            style="width: 100%"
+          >
+            <el-option v-for="item in supplierOptions" :key="item" :label="item" :value="item" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="密钥" prop="api_key">
+          <el-input
+            v-model="form.api_key"
+            type="password"
+            placeholder="必填"
+            show-password
+            autocomplete="new-password"
+          />
+        </el-form-item>
+        <el-form-item label="接口地址" prop="api_url">
+          <el-input
+            v-model="form.api_url"
+            placeholder="必填"
+          />
+        </el-form-item>
+        <el-form-item label="分组" prop="model_group">
+          <el-select
+            v-model="form.model_group"
+            filterable
+            allow-create
+            placeholder="选择或输入"
+            style="width: 100%"
+          >
+            <el-option v-for="item in modelGroupOptions" :key="item" :label="item" :value="item" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="模型" prop="model_name">
+          <el-select
+            v-model="form.model_name"
+            filterable
+            allow-create
+            placeholder="选择或输入"
+            style="width: 100%"
+          >
+            <el-option v-for="item in modelNameOptions" :key="item" :label="item" :value="item" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="模型别名" prop="model_alias">
+          <el-input
+            v-model="form.model_alias"
+            placeholder="默认与模型相同"
+          />
+        </el-form-item>
+        <el-form-item label="模型类型" prop="model_type">
+          <el-input
+            v-model="form.model_type"
+            placeholder="必填,多能力逗号分隔如:文生图,图生图"
+            @input="(v) => { if (v && v.includes(',')) form.model_type = v.replace(/,/g, ',') }"
+          />
+        </el-form-item>
+        <el-form-item label="优先级" prop="sort">
+          <el-tooltip content="同一模型内的优先级,数值越小越优先调用" placement="top">
+            <el-input-number
+              v-model="form.sort"
+              :min="1"
+              controls-position="right"
+            />
+          </el-tooltip>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="closeDialog">取消</el-button>
+        <el-button
+          type="primary"
+          :loading="submitLoading"
+          @click="enterDialog"
+        >确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { GetAIModel, AddAIModel, UpdateAIModel } from '@/api/mes/job'
+import { ref, reactive, computed, onMounted } from 'vue'
+import { QuestionFilled, Search } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+
+defineOptions({
+  name: 'ApiToken'
+})
+
+const formRef = ref(null)
+const tableData = ref([])
+const dialogVisible = ref(false)
+const type = ref('create')
+const submitLoading = ref(false)
+
+const form = reactive({
+  id: null,
+  status: '1',
+  supplier: '',
+  api_key: '',
+  api_url: '',
+  model_group: '',
+  model_name: '',
+  model_alias: '',
+  model_type: '',
+  sort: 1
+})
+
+const formRules = {
+  api_key: [{ required: true, message: 'KEY不能为空', trigger: 'blur' }],
+  api_url: [{ required: true, message: '接口地址不能为空', trigger: 'blur' }],
+  model_name: [{ required: true, message: '模型不能为空', trigger: 'blur' }],
+  model_type: [{ required: true, message: '模型类型不能为空', trigger: 'blur' }]
+}
+
+const loading = ref(false)
+const searchKeyword = ref('')
+
+const supplierOptions = computed(() => {
+  const set = new Set()
+  tableData.value.forEach((row) => {
+    const v = String(row.supplier ?? '').trim()
+    if (v) set.add(v)
+  })
+  return Array.from(set).sort()
+})
+
+const modelGroupOptions = computed(() => {
+  const set = new Set()
+  tableData.value.forEach((row) => {
+    const v = String(row.model_group ?? '').trim()
+    if (v) set.add(v)
+  })
+  return Array.from(set).sort()
+})
+
+const modelNameOptions = computed(() => {
+  const set = new Set()
+  tableData.value.forEach((row) => {
+    const v = String(row.model_name ?? '').trim()
+    if (v) set.add(v)
+  })
+  return Array.from(set).sort()
+})
+
+const filteredTableData = computed(() => {
+  const kw = (searchKeyword.value || '').trim().toLowerCase()
+  if (!kw) return tableData.value
+  return tableData.value.filter((row) => {
+    const statusText = row.status === '1' ? '启用' : '禁用'
+    const fields = [
+      row.id,
+      statusText,
+      row.supplier,
+      row.api_key,
+      row.api_url,
+      row.model_group,
+      row.model_name,
+      row.model_alias,
+      row.model_type,
+      row.sort
+    ]
+    return fields.some((v) => String(v ?? '').toLowerCase().includes(kw))
+  })
+})
+
+const getTableData = async () => {
+  loading.value = true
+  try {
+    const res = await GetAIModel({ manage: 1 })
+    if (res.code === 0) {
+      tableData.value = Array.isArray(res.data) ? res.data : []
+    } else {
+      ElMessage.error(res.msg || '获取列表失败')
+      tableData.value = []
+    }
+  } catch (e) {
+    console.error('GetAIModel error:', e)
+    ElMessage.error('获取列表失败,请检查网络或接口')
+    tableData.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+const resetForm = () => {
+  form.id = null
+  form.status = '1'
+  form.supplier = ''
+  form.api_key = ''
+  form.api_url = ''
+  form.model_group = ''
+  form.model_name = ''
+  form.model_alias = ''
+  form.model_type = ''
+  form.sort = 1
+}
+
+const openDialog = () => {
+  type.value = 'create'
+  resetForm()
+  dialogVisible.value = true
+}
+
+const updateRow = (row) => {
+  type.value = 'update'
+  form.id = row.id
+  form.status = String(row.status ?? '1')
+  form.supplier = row.supplier ?? ''
+  form.api_key = row.api_key ?? ''
+  form.api_url = row.api_url ?? ''
+  form.model_group = row.model_group ?? ''
+  form.model_name = row.model_name ?? ''
+  form.model_alias = row.model_alias ?? row.model_name ?? ''
+  form.model_type = row.model_type ?? ''
+  form.sort = Math.max(1, Number(row.sort) || 1)
+  dialogVisible.value = true
+}
+
+const closeDialog = () => {
+  dialogVisible.value = false
+  formRef.value?.resetFields()
+}
+
+const enterDialog = async () => {
+  if (!formRef.value) return
+  try {
+    await formRef.value.validate()
+  } catch {
+    return
+  }
+
+  submitLoading.value = true
+  try {
+    let res
+    if (type.value === 'create') {
+      res = await AddAIModel({
+        status: form.status,
+        supplier: form.supplier,
+        api_key: form.api_key,
+        api_url: form.api_url,
+        model_group: form.model_group,
+        model_name: form.model_name,
+        model_alias: form.model_alias || form.model_name,
+        model_type: form.model_type,
+        sort: form.sort
+      })
+    } else {
+      res = await UpdateAIModel({
+        id: form.id,
+        status: form.status,
+        supplier: form.supplier,
+        api_key: form.api_key,
+        api_url: form.api_url,
+        model_group: form.model_group,
+        model_name: form.model_name,
+        model_alias: form.model_alias || form.model_name,
+        model_type: form.model_type,
+        sort: form.sort
+      })
+    }
+
+    if (res.code === 0) {
+      ElMessage.success(res.msg || '操作成功')
+      closeDialog()
+      getTableData()
+    } else {
+      ElMessage.error(res.msg || '操作失败')
+    }
+  } catch (e) {
+    console.error(e)
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+onMounted(() => {
+  getTableData()
+})
+</script>
+
+<style scoped></style>

Datei-Diff unterdrückt, da er zu groß ist
+ 912 - 81
src/view/Product/ProductImageGeneration.vue


Datei-Diff unterdrückt, da er zu groß ist
+ 462 - 323
src/view/Product/ProductTemplateReplace.vue


Datei-Diff unterdrückt, da er zu groß ist
+ 549 - 138
src/view/TemplateManagement/CreateTemplate.vue


+ 82 - 39
src/view/TemplateManagement/TemplateDesign.vue

@@ -99,6 +99,7 @@
                   :src="formatImageUrl(template.thumbnail_image || template.template_image_url)"
                   :alt="template.template_name"
                   loading="lazy"
+                  decoding="async"
                   class="template-preview-img"
                   @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' }"
                 />
@@ -113,7 +114,15 @@
                 <span class="template-card-name" :title="template.template_name">{{ template.template_name || '未命名模版' }}</span>
                 <div class="template-card-actions">
                   <template v-if="libraryMenuActive === 'myWorks'">
-                    <button type="button" class="template-card-link template-card-link-publish" :class="{ disabled: publishLoading === template.id }" :disabled="publishLoading === template.id" @click.stop="isPublished(template) ? handleUnpublish(template) : handlePublish(template)">{{ isPublished(template) ? '取消发布' : '发布' }}</button>
+                    <span
+                      role="button"
+                      tabindex="0"
+                      class="template-card-link template-card-link-publish"
+                      :class="{ disabled: publishLoading === template.id }"
+                      :aria-disabled="publishLoading === template.id"
+                      @click="onPublishClick($event, template)"
+                      @keydown.enter.prevent="onPublishClick($event, template)"
+                    >{{ isPublished(template) ? '取消发布' : '发布' }}</span>
                     <button type="button" class="template-card-link template-card-link-edit" @click.stop="goEditTemplate(template)">编辑模版</button>
                     <el-dropdown trigger="click" :disabled="deleteLoading === template.id" @command="(cmd) => cmd === 'delete' && handleDelete(template)" class="template-card-more">
                       <button type="button" class="template-card-link template-card-dots" @click.stop :disabled="deleteLoading === template.id">
@@ -157,20 +166,23 @@
         />
       </div>
     </template>
-    <!-- 设计页:参数由列表页带入 -->
-    <div v-else class="create-template-wrap">
-      <CreateTemplate
-        :key="createTemplateKey"
-        :initial-template="editTemplate"
-        :mode="editMode"
-        @back="handleCreateBack"
-      />
+    <!-- 设计页:keep-alive 避免返回列表时整页卸载导致卡顿;新建用 newDesignSession 区分缓存 key -->
+    <div v-show="currentView === 'design'" class="create-template-wrap">
+      <keep-alive max="2">
+        <CreateTemplate
+          v-if="currentView === 'design'"
+          :key="createTemplateKey"
+          :initial-template="editTemplate"
+          :mode="editMode"
+          @back="handleCreateBack"
+        />
+      </keep-alive>
     </div>
   </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, watch, onBeforeUnmount } from 'vue'
+import { ref, computed, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
 import { useRoute } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import { Plus, Picture, Search, MoreFilled, Loading, Delete, ZoomIn } from '@element-plus/icons-vue'
@@ -178,6 +190,7 @@ 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 CreateTemplate from './CreateTemplate.vue'
+import { resolveMaterialUrl } from './utils.js'
 
 const currentView = ref('library')
 const templates = ref([])
@@ -186,7 +199,7 @@ const templateSearch = ref('')
 const libraryMenuActive = ref('myWorks')
 const publishFilter = ref('all') // 'all' | 'published' | 'unpublished',仅在「我的设计」中生效
 const page = ref(1)
-const pageSize = ref(100) // 每页 100 条
+const pageSize = ref(30) // 每页条数(默认 30)
 const total = ref(0)
 const publishLoading = ref(null)
 const deleteLoading = ref(null)
@@ -194,8 +207,15 @@ const previewVisible = ref(false)
 const previewImageUrl = ref('')
 const editTemplate = ref(null)
 const editMode = ref('create')
+/** 每次「创建模版」递增,避免与 keep-alive 下同 key 复用旧空白画布 */
+const newDesignSession = ref(0)
 // 用于强制 CreateTemplate 在切换不同模版/模式时重新初始化
-const createTemplateKey = computed(() => `${editMode.value}-${editTemplate.value?.id ?? 'new'}`)
+const createTemplateKey = computed(() => {
+  if (editMode.value === 'create') {
+    return `create-${newDesignSession.value}`
+  }
+  return `${editMode.value}-${editTemplate.value?.id ?? 'new'}`
+})
 
 const userStore = useUserStore()
 const route = useRoute()
@@ -215,30 +235,18 @@ const filteredTemplates = computed(() => {
 // 用于展示的列表(后端分页,直接使用筛选后的当前页数据)
 const displayedTemplates = computed(() => filteredTemplates.value)
 
-// 图片 URL:拼接 VITE_BASE_PATH + VITE_UPLOADS_PORT(如 http://IP:端口/uploads/xxx)
-const formatImageUrl = (path) => {
-  if (!path || typeof path !== 'string') return ''
-  const p = path.trim()
-  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 cleanPath = p.replace(/^public\//, '').replace(/^\//, '')
-  const pathNorm = cleanPath.startsWith('/') ? cleanPath : '/' + cleanPath
-  const base = import.meta.env.VITE_BASE_PATH || ''
-  const port = import.meta.env.VITE_UPLOADS_PORT || '8081'
-  const uploadsBase = base && port ? `${base.replace(/:(\d+)?$/, '')}:${port}` : ''
-  return uploadsBase ? `${uploadsBase.replace(/\/$/, '')}${pathNorm}` : pathNorm
-}
+// 与 CreateTemplate 共用:OSS、相对路径、可选 VITE_UPLOADS_PUBLIC_BASE
+const formatImageUrl = (path) => resolveMaterialUrl(path)
 
-const fetchTemplates = async () => {
+/**
+ * @param {{ silent?: boolean }} opts silent=true 时不展示整页骨架(用于从编辑页返回:先保留旧列表,后台刷新,避免闪白)
+ */
+const fetchTemplates = async (opts = {}) => {
+  const silent = !!opts.silent
   try {
-    templatesLoading.value = true
+    if (!silent) {
+      templatesLoading.value = true
+    }
     const params = {
       page: page.value,
       limit: pageSize.value
@@ -246,6 +254,11 @@ const fetchTemplates = async () => {
     if (libraryMenuActive.value === 'myWorks' && userStore.userInfo?.nickName) {
       params.sys_id = userStore.userInfo.nickName
     }
+    // 模版社区:仅已发布且审核通过
+    if (libraryMenuActive.value === 'moreTemplates') {
+      params.release = 1
+      params.toexamine = '审核通过'
+    }
     const searchVal = (templateSearch.value || '').trim()
     if (searchVal) params.search = searchVal
     const response = await product_template(params)
@@ -254,7 +267,8 @@ const fetchTemplates = async () => {
       const payload = data.data
       const list = Array.isArray(payload?.list) ? payload.list : (Array.isArray(payload) ? payload : [])
       templates.value = list
-      total.value = Number(payload?.total ?? data.total ?? list.length) || 0
+      // 后端在根级返回 count;部分接口在 data 内返回 total
+      total.value = Number(data.count ?? payload?.total ?? data.total ?? list.length) || 0
     } else {
       ElMessage.error(data?.msg || '获取模板库失败')
     }
@@ -262,7 +276,9 @@ const fetchTemplates = async () => {
     console.error('获取模板库失败:', error)
     ElMessage.error('获取模板库失败')
   } finally {
-    templatesLoading.value = false
+    if (!silent) {
+      templatesLoading.value = false
+    }
   }
 }
 
@@ -298,6 +314,7 @@ const openPreview = (template) => {
 }
 
 const startNewDesign = () => {
+  newDesignSession.value += 1
   editTemplate.value = null
   editMode.value = 'create'
   currentView.value = 'design'
@@ -317,7 +334,33 @@ const goUseAsTemplate = (template) => {
 
 const handleCreateBack = () => {
   currentView.value = 'library'
-  fetchTemplates()
+  // 设计页由 keep-alive 挂起,列表先渲染;空闲时再静默刷新,减轻点击返回当帧压力
+  nextTick(() => {
+    const run = () => fetchTemplates({ silent: true })
+    if (typeof requestIdleCallback !== 'undefined') {
+      requestIdleCallback(run, { timeout: 2000 })
+    } else {
+      requestAnimationFrame(run)
+    }
+  })
+}
+
+const onPublishClick = (e, template) => {
+  e.preventDefault()
+  e.stopPropagation()
+  if (publishLoading.value === template.id) return
+  if (isPublished(template)) {
+    handleUnpublish(template)
+  } else {
+    handlePublish(template)
+  }
+}
+
+const updateTemplateRelease = (templateId, release) => {
+  const item = templates.value.find((t) => t.id === templateId)
+  if (item) {
+    item.release = release
+  }
 }
 
 const handlePublish = async (template) => {
@@ -335,7 +378,7 @@ const handlePublish = async (template) => {
     const res = await Template_Material_Publish({ template_id: template.id })
     if (res && res.code === 0) {
       ElMessage.success('发布成功')
-      fetchTemplates()
+      updateTemplateRelease(template.id, 1)
     } else {
       ElMessage.error(res?.msg || '发布失败')
     }
@@ -354,7 +397,7 @@ const handleUnpublish = async (template) => {
     const res = await Template_Material_Unpublish({ template_id: template.id })
     if (res && res.code === 0) {
       ElMessage.success('已取消发布')
-      fetchTemplates()
+      updateTemplateRelease(template.id, 0)
     } else {
       ElMessage.error(res?.msg || '取消发布失败')
     }

+ 6 - 5
src/view/TemplateManagement/components/LayerPanel.vue

@@ -159,7 +159,7 @@
           </div>
           <div v-if="selectedLayer.type !== 'text'" class="property-item">
             <span>等比例缩放</span>
-            <el-switch v-model="maintainAspectRatio" />
+            <el-switch :model-value="maintainAspectRatio" @update:model-value="$emit('update:maintainAspectRatio', $event)" />
           </div>
         </template>
       </section>
@@ -317,6 +317,7 @@
 
 <script setup>
 import { Rank, ArrowUp, ArrowDown, Delete, View, Hide, Picture, EditPen, List, Setting } from '@element-plus/icons-vue'
+import { DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT, DEFAULT_CANVAS_RATIO } from '../constants.js'
 
 defineProps({
   rightPanelTab: { type: String, default: 'layer' },
@@ -346,9 +347,9 @@ defineProps({
   deleteLayerById: { type: Function, required: true },
   getImageLoadUrl: { type: Function, required: true },
   formatFileSize: { type: Function, required: true },
-  canvasWidth: { type: Number, default: 600 },
-  canvasHeight: { type: Number, default: 450 },
-  canvasRatio: { type: String, default: '4:3' },
+  canvasWidth: { type: Number, default: DEFAULT_CANVAS_WIDTH },
+  canvasHeight: { type: Number, default: DEFAULT_CANVAS_HEIGHT },
+  canvasRatio: { type: String, default: DEFAULT_CANVAS_RATIO },
   maintainAspectRatio: { type: Boolean, default: false },
   selectedLayer: Object,
   handleCanvasRatioChange: { type: Function, required: true },
@@ -357,5 +358,5 @@ defineProps({
   handleHeightChange: { type: Function, required: true }
 })
 
-const emit = defineEmits(['update:rightPanelTab', 'update:canvas-width', 'update:canvas-height', 'update:canvas-ratio'])
+const emit = defineEmits(['update:rightPanelTab', 'update:canvas-width', 'update:canvas-height', 'update:canvas-ratio', 'update:maintainAspectRatio'])
 </script>

+ 88 - 14
src/view/TemplateManagement/components/MaterialTabPane.vue

@@ -1,5 +1,6 @@
 <template>
-  <div class="toolbar-pane toolbar-pane-scroll">
+  <!-- 不再重复 toolbar-pane-scroll:由 CreateTemplate 外层统一滚动,避免双层滚动导致下方格子空白 -->
+  <div class="material-tab-pane-root">
     <div class="materials-panel">
       <el-input
         :model-value="materialSearch"
@@ -72,8 +73,10 @@
             v-for="material in materialsAfterSearch"
             :key="material.id"
             @click="addMaterialToCanvas(material)"
+            @mouseenter="onMaterialPreviewEnter(material)"
+            @mouseleave="onMaterialPreviewLeave"
           >
-            <img :src="resolveMaterialUrl(material.material_url)" :alt="material.id" loading="lazy" />
+            <img :src="resolveMaterialUrl(material.material_url)" :alt="material.id" decoding="async" />
             <div class="material-overlay">
               <el-icon><Plus /></el-icon>
               <span>添加</span>
@@ -99,8 +102,10 @@
               v-for="material in detailMaterials"
               :key="material.id"
               @click="addMaterialToCanvas(material)"
+              @mouseenter="onMaterialPreviewEnter(material)"
+              @mouseleave="onMaterialPreviewLeave"
             >
-              <img :src="resolveMaterialUrl(material.material_url)" :alt="material.id" loading="lazy" />
+              <img :src="resolveMaterialUrl(material.material_url)" :alt="material.id" decoding="async" />
               <div class="material-overlay">
                 <el-icon><Plus /></el-icon>
                 <span>添加</span>
@@ -133,21 +138,36 @@
                 v-for="material in (materialsByType.get(g.type) || []).slice(0, previewLimit)"
                 :key="material.id"
                 @click="addMaterialToCanvas(material)"
+                @mouseenter="onMaterialPreviewEnter(material)"
+                @mouseleave="onMaterialPreviewLeave"
               >
-                <img :src="resolveMaterialUrl(material.material_url)" :alt="material.id" loading="lazy" />
+                <img :src="resolveMaterialUrl(material.material_url)" :alt="material.id" decoding="async" />
               </div>
             </div>
           </div>
         </div>
       </template>
+
+      <div
+        v-if="!materialsLoading && materialTotal > materialPageSize"
+        class="materials-pagination-wrap"
+      >
+        <el-pagination
+          :current-page="materialPage"
+          :page-size="materialPageSize"
+          :total="materialTotal"
+          layout="prev, pager, next"
+          small
+          background
+          @current-change="emitMaterialPageChange"
+        />
+      </div>
     </div>
   </div>
 </template>
 
 <script setup>
 import { Search, ArrowLeft, ArrowRight, Plus } from '@element-plus/icons-vue'
-import { resolveMaterialUrl } from '../utils.js'
-
 const props = defineProps({
   materialSearch: String,
   materialsLoading: Boolean,
@@ -161,6 +181,9 @@ const props = defineProps({
   canScrollChipsRight: Boolean,
   chipsScrollRef: { type: Object, default: null },
   previewLimit: { type: Number, default: 3 },
+  materialPage: { type: Number, default: 1 },
+  materialPageSize: { type: Number, default: 30 },
+  materialTotal: { type: Number, default: 0 },
   addMaterialToCanvas: { type: Function, required: true },
   setActiveMaterialType: { type: Function, required: true },
   openMaterialTypeDetail: { type: Function, required: true },
@@ -169,24 +192,62 @@ const props = defineProps({
   handleChipsScroll: { type: Function, required: true },
   scrollChipsLeft: { type: Function, required: true },
   scrollChipsRight: { type: Function, required: true },
-  resolveMaterialUrl: { type: Function, required: true }
+  resolveMaterialUrl: { type: Function, required: true },
+  /** (url: string | null) => void 悬停缩略图显示大图预览,移开传 null */
+  materialHoverPreview: { type: Function, default: null }
 })
 
-const emit = defineEmits(['update:materialSearch'])
+const emit = defineEmits(['update:materialSearch', 'material-page-change'])
+
+const onMaterialPreviewEnter = (m) => {
+  props.materialHoverPreview?.(props.resolveMaterialUrl(m?.material_url))
+}
+const onMaterialPreviewLeave = () => {
+  props.materialHoverPreview?.(null)
+}
 
 const onSearchInput = (v) => {
   emit('update:materialSearch', v)
   props.handleMaterialSearchInput?.()
 }
+
+const emitMaterialPageChange = (p) => {
+  emit('material-page-change', p)
+}
 </script>
 
 <style scoped lang="scss">
+.material-tab-pane-root {
+  flex: 1 1 0;
+  min-height: 0;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  /* 由外层 .toolbar-pane-scroll 统一纵向滚动,避免双层滚动 + lazy 图在子区域不触发加载 */
+  overflow: visible;
+}
 .materials-panel {
   display: flex;
   flex-direction: column;
   gap: 8px;
-  height: 100%;
   min-height: 0;
+  min-width: 0;
+}
+.materials-pagination-wrap {
+  flex-shrink: 0;
+  padding-top: 4px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 100%;
+  min-width: 0;
+  overflow-x: auto;
+  overflow-y: hidden;
+  :deep(.el-pagination) {
+    flex-wrap: nowrap;
+    justify-content: center;
+    white-space: nowrap;
+  }
 }
 .materials-search { flex-shrink: 0; }
 .materials-search :deep(.el-input__wrapper) { border-radius: 16px; }
@@ -278,7 +339,12 @@ const onSearchInput = (v) => {
   color: #409eff;
 }
 
-.materials-groups { display: flex; flex-direction: column; gap: 10px; }
+.materials-groups {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  min-width: 0;
+}
 .materials-group-header { display: flex; align-items: center; justify-content: space-between; padding: 2px 2px 0; }
 .materials-group-title { font-size: 12px; color: #303133; font-weight: 600; }
 .materials-more {
@@ -309,7 +375,7 @@ const onSearchInput = (v) => {
 .materials-list-full {
   flex: 1;
   display: grid;
-  grid-template-columns: repeat(3, 1fr);
+  grid-template-columns: repeat(3, minmax(0, 1fr));
   gap: 6px;
   overflow-y: auto;
   padding: 4px;
@@ -319,19 +385,23 @@ const onSearchInput = (v) => {
 }
 .materials-list-preview {
   display: grid;
-  grid-template-columns: repeat(3, 1fr);
+  grid-template-columns: repeat(3, minmax(0, 1fr));
   gap: 6px;
   padding: 4px;
   background-color: #f9f9f9;
   border-radius: 4px;
+  align-content: start;
 }
 .material-item-full {
   position: relative;
   aspect-ratio: 1;
+  min-width: 0;
+  min-height: 0;
   cursor: pointer;
   border-radius: 8px;
   overflow: hidden;
   border: 1px solid #e5e5e5;
+  background: #f5f7fa;
   transition: all 0.2s;
 }
 .material-item-full:hover {
@@ -342,7 +412,8 @@ const onSearchInput = (v) => {
 .material-item-full img {
   width: 100%;
   height: 100%;
-  object-fit: cover;
+  object-fit: contain;
+  object-position: center center;
   display: block;
 }
 .material-item-full .material-overlay {
@@ -361,8 +432,11 @@ const onSearchInput = (v) => {
   color: white;
   gap: 4px;
   font-size: 12px;
+  pointer-events: none;
+}
+.material-item-full:hover .material-overlay {
+  opacity: 1;
 }
-.material-item-full:hover .material-overlay { opacity: 1; }
 .empty-materials {
   grid-column: 1 / -1;
   text-align: center;

+ 5 - 4
src/view/TemplateManagement/components/PageSidebar.vue

@@ -49,12 +49,13 @@
 
 <script setup>
 import { Plus, Close } from '@element-plus/icons-vue'
+import { DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT } from '../constants.js'
 
 const props = defineProps({
   pages: { type: Array, required: true },
   currentPageIndex: { type: Number, default: 0 },
-  canvasWidth: { type: Number, default: 600 },
-  canvasHeight: { type: Number, default: 450 },
+  canvasWidth: { type: Number, default: DEFAULT_CANVAS_WIDTH },
+  canvasHeight: { type: Number, default: DEFAULT_CANVAS_HEIGHT },
   switchPage: { type: Function, required: true },
   deletePage: { type: Function, required: true },
   addPage: { type: Function, required: true },
@@ -62,8 +63,8 @@ const props = defineProps({
 })
 
 const thumbLayerStyle = (layer) => {
-  const cw = props.canvasWidth || 600
-  const ch = props.canvasHeight || 450
+  const cw = props.canvasWidth || DEFAULT_CANVAS_WIDTH
+  const ch = props.canvasHeight || DEFAULT_CANVAS_HEIGHT
   return {
     left: (cw ? (layer.x / cw) * 100 : 0) + '%',
     top: (ch ? (layer.y / ch) * 100 : 0) + '%',

+ 98 - 61
src/view/TemplateManagement/composables/useCanvasLayers.js

@@ -2,17 +2,24 @@
  * useCanvasLayers - Canvas, page, layer state and methods for CreateTemplate
  */
 import { ref, computed, reactive, nextTick } from 'vue'
-import { resolveMaterialUrl, getImageLoadUrl, toStoragePath } from '../utils.js'
-import { SHAPE_PRESETS, TEXT_PRESETS, RATIO_CONFIG } from '../constants.js'
+import { resolveMaterialUrl, getImageLoadUrl, toStoragePath, loadImageForCanvasExport } from '../utils.js'
+import {
+  SHAPE_PRESETS,
+  TEXT_PRESETS,
+  RATIO_CONFIG,
+  DEFAULT_CANVAS_WIDTH,
+  DEFAULT_CANVAS_HEIGHT,
+  DEFAULT_CANVAS_RATIO
+} from '../constants.js'
 
 export function useCanvasLayers() {
   const canvasRef = ref(null)
   const canvasAreaRef = ref(null)
   const layerListRef = ref(null)
   const currentTool = ref('select')
-  const canvasWidth = ref(600)
-  const canvasHeight = ref(450)
-  const canvasRatio = ref('4:3')
+  const canvasWidth = ref(DEFAULT_CANVAS_WIDTH)
+  const canvasHeight = ref(DEFAULT_CANVAS_HEIGHT)
+  const canvasRatio = ref(DEFAULT_CANVAS_RATIO)
   const zoomLevel = ref(100)
   const maintainAspectRatio = ref(false)
 
@@ -160,7 +167,12 @@ export function useCanvasLayers() {
     return [{ id: ++pageIdCounter, layers: rows.map(mapRowToLayer) }]
   }
 
-  const processUploadedFile = (file, category) => {
+  const defaultMaterialNameFromFile = (file) => {
+    const n = file?.name || ''
+    return n.replace(/\.[^.]+$/, '') || n || '未命名素材'
+  }
+
+  const processUploadedFile = (file, categoryId, categoryLabel, materialName) => {
     return new Promise(resolve => {
       const reader = new FileReader()
       reader.onload = (e) => {
@@ -174,11 +186,16 @@ export function useCanvasLayers() {
             width = width * ratio
             height = height * ratio
           }
+          const mn = (materialName != null && String(materialName).trim() !== '')
+            ? String(materialName).trim()
+            : defaultMaterialNameFromFile(file)
           const newLayer = {
             id: ++layerIdCounter,
-            name: file.name,
+            name: mn,
+            material_name: mn,
             url: e.target.result,
-            materialType: category,
+            materialType: categoryLabel || '其他',
+            categoryId: categoryId != null ? Number(categoryId) : null,
             x: Math.round((canvasWidth.value - width) / 2),
             y: Math.round((canvasHeight.value - height) / 2),
             width: Math.round(width),
@@ -261,6 +278,8 @@ export function useCanvasLayers() {
 
   const addMaterialToCanvas = (material) => {
     const img = new Image()
+    const loadUrl = resolveMaterialUrl(material.material_url)
+    if (!loadUrl) return
     img.onload = function () {
       let width = img.width
       let height = img.height
@@ -289,7 +308,10 @@ export function useCanvasLayers() {
       layers.value.push(newLayer)
       selectedLayerId.value = newLayer.id
     }
-    img.src = resolveMaterialUrl(material.material_url)
+    img.onerror = () => {
+      console.warn('素材图片加载失败', material?.material_url)
+    }
+    img.src = loadUrl
   }
 
   const getLayerStyle = (layer) => {
@@ -608,62 +630,47 @@ export function useCanvasLayers() {
     zoomLevel.value = Math.max(10, Math.min(200, zoomLevel.value + delta))
   }
 
+  const isCanvasExportSecurityError = (e) => {
+    if (!e) return false
+    if (e.name === 'SecurityError') return true
+    const msg = String(e.message || e)
+    return /tainted|Canvas|insecure|read the content/i.test(msg)
+  }
+
   // generateCanvasPreview & layerToApiShape
   const generateCanvasPreview = async (layersOverride) => {
     const targetLayers = layersOverride ?? layers.value
     if (!canvasWidth.value || !canvasHeight.value) return null
-    const exportCanvas = document.createElement('canvas')
-    exportCanvas.width = canvasWidth.value
-    exportCanvas.height = canvasHeight.value
-    const ctx = exportCanvas.getContext('2d')
-    if (!ctx) return null
 
-    ctx.fillStyle = '#ffffff'
-    ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height)
+    const build = async (skipRaster) => {
+      const exportCanvas = document.createElement('canvas')
+      exportCanvas.width = canvasWidth.value
+      exportCanvas.height = canvasHeight.value
+      const ctx = exportCanvas.getContext('2d')
+      if (!ctx) return null
 
-    const imageLayers = targetLayers.filter(l => (l.type || 'image') !== 'text' && l.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
+      ctx.fillStyle = '#ffffff'
+      ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height)
+
+      const imageLayers = skipRaster ? [] : targetLayers.filter(l => (l.type || 'image') !== 'text' && l.url)
+      for (const layer of imageLayers) {
+        layer._previewImg = await loadImageForCanvasExport(layer.url)
       }
-      layer._previewImg = img
-    }
 
-    const layersToDraw = [...targetLayers]
-    for (const layer of layersToDraw) {
-      if (!layer.visible) continue
-      ctx.save()
-      ctx.beginPath()
-      ctx.globalAlpha = (layer.opacity ?? 100) / 100
-      const w = layer.width || 0
-      const h = layer.height || 0
-      const cx = (layer.x || 0) + w / 2
-      const cy = (layer.y || 0) + h / 2
-      ctx.translate(cx, cy)
-      ctx.rotate(((layer.rotation || 0) * Math.PI) / 180)
-
-      if ((layer.type || 'image') === 'text') {
+      const layersToDraw = [...targetLayers]
+      for (const layer of layersToDraw) {
+        if (!layer.visible) continue
+        ctx.save()
+        ctx.beginPath()
+        ctx.globalAlpha = (layer.opacity ?? 100) / 100
+        const w = layer.width || 0
+        const h = layer.height || 0
+        const cx = (layer.x || 0) + w / 2
+        const cy = (layer.y || 0) + h / 2
+        ctx.translate(cx, cy)
+        ctx.rotate(((layer.rotation || 0) * Math.PI) / 180)
+
+        if ((layer.type || 'image') === 'text') {
         const fontSize = layer.fontSize || 16
         const fontFamily = layer.fontFamily || 'Arial'
         const fontWeight = layer.fontWeight || 'normal'
@@ -722,15 +729,43 @@ export function useCanvasLayers() {
           if (!isLine) ctx.strokeRect(x, y, w, h)
         }
       } else {
-        const img = layer._previewImg
-        if (img) ctx.drawImage(img, -w / 2, -h / 2, w, h)
+        if (skipRaster) {
+          ctx.fillStyle = '#f2f3f5'
+          ctx.strokeStyle = '#dcdfe6'
+          ctx.lineWidth = 1
+          ctx.fillRect(-w / 2, -h / 2, w, h)
+          ctx.strokeRect(-w / 2, -h / 2, w, h)
+          ctx.fillStyle = '#909399'
+          ctx.font = '12px sans-serif'
+          ctx.textAlign = 'center'
+          ctx.textBaseline = 'middle'
+          ctx.fillText('图片', 0, 0)
+        } else {
+          const img = layer._previewImg
+          if (img) ctx.drawImage(img, -w / 2, -h / 2, w, h)
+        }
       }
       ctx.restore()
     }
-    for (const layer of imageLayers) delete layer._previewImg
+    for (const layer of imageLayers) {
+      const v = layer._previewImg
+      if (v && typeof v.close === 'function') v.close()
+      delete layer._previewImg
+    }
+    return exportCanvas.toDataURL('image/png')
+    }
+
     try {
-      return exportCanvas.toDataURL('image/png')
+      return await build(false)
     } catch (e) {
+      if (isCanvasExportSecurityError(e)) {
+        try {
+          return await build(true)
+        } catch (e2) {
+          console.warn('画布预览图生成失败(兼容模式仍失败):', e2)
+          return null
+        }
+      }
       console.warn('画布预览图生成失败:', e)
       return null
     }
@@ -751,7 +786,9 @@ export function useCanvasLayers() {
     visible: layer.visible,
     locked: layer.locked,
     material_id: layer.materialId ?? layer.material_id ?? '',
+    material_name: layer.material_name ?? layer.name ?? '',
     material_type: layer.materialType || (layer.url && String(layer.url).startsWith('data:') ? '其他' : ''),
+    Category_id: layer.categoryId ?? layer.Category_id ?? '',
     fontFamily: layer.fontFamily,
     fontSize: layer.fontSize,
     color: layer.color,

+ 14 - 2
src/view/TemplateManagement/composables/useTemplateData.js

@@ -164,11 +164,23 @@ export function useTemplateData(props, emit, canvasLayers, materials) {
       ElMessage.warning('请先添加图片或文字,再生成模版')
       return
     }
-    const previewLayers = pages.value[0]?.layers ?? []
+    const previewLayers = (() => {
+      const cur = pages.value[currentPageIndex.value]?.layers
+      if (cur?.length) return cur
+      const p = pages.value.find((pg) => pg?.layers?.length)
+      return p?.layers ?? pages.value[0]?.layers ?? []
+    })()
     const previewImage = await generateCanvasPreview(previewLayers.length ? previewLayers : undefined)
     const uploadedMaterials = flatLayers
       .filter(l => l.type !== 'text' && l.url && typeof l.url === 'string' && l.url.startsWith('data:'))
-      .map(l => ({ layer_id: l.id, name: l.name, data: l.url, type: l.materialType || '其他' }))
+      .map(l => ({
+        layer_id: l.id,
+        name: l.name,
+        material_name: l.material_name ?? l.name ?? '',
+        data: l.url,
+        type: l.materialType || '其他',
+        Category_id: l.categoryId ?? l.Category_id ?? null
+      }))
     const templateData = {
       template_name: templateName.value || '未命名模版',
       sys_id: userStore.userInfo.nickName,

+ 11 - 0
src/view/TemplateManagement/constants.js

@@ -6,6 +6,9 @@
 /** 模版名称最大字符数 */
 export const TEMPLATE_NAME_MAX_LEN = 12
 
+/** 上传素材图名称最大字符数(与分类弹窗一致) */
+export const MATERIAL_NAME_MAX_LEN = 12
+
 /** 上传图片最大尺寸 MB */
 export const MAX_IMAGE_SIZE_MB = 1
 
@@ -15,6 +18,9 @@ export const MATERIAL_CATEGORIES = ['化妆品', '酒类', '保健品', '食品'
 /** 素材库分类预览每个分类展示数量 */
 export const PREVIEW_LIMIT = 3
 
+/** 素材库列表分页(与 Material_List 接口一致) */
+export const MATERIAL_PAGE_SIZE = 30
+
 /** 画布比例配置 */
 export const RATIO_CONFIG = {
   '1:1': { width: 500, height: 500, ratio: 1 },
@@ -26,6 +32,11 @@ export const RATIO_CONFIG = {
   '2:3': { width: 400, height: 600, ratio: 2 / 3 }
 }
 
+/** 新建模版默认比例(与 RATIO_CONFIG['9:16'] 一致) */
+export const DEFAULT_CANVAS_RATIO = '9:16'
+export const DEFAULT_CANVAS_WIDTH = 360
+export const DEFAULT_CANVAS_HEIGHT = 640
+
 /** AI 工具列表 */
 export const AI_TOOL_LIST = [
   { key: 'hd', name: 'Ai高清', desc: '超清画质重生,告别渣画质。', iconClass: 'ai-icon-hd', iconText: '清' },

+ 284 - 15
src/view/TemplateManagement/styles/CreateTemplate.scss

@@ -468,7 +468,7 @@
 .materials-list-full {
   flex: 1;
   display: grid;
-  grid-template-columns: repeat(3, 1fr);
+  grid-template-columns: repeat(3, minmax(0, 1fr));
   gap: 6px;
   overflow-y: auto;
   padding: 4px;
@@ -479,20 +479,24 @@
 
 .materials-list-preview {
   display: grid;
-  grid-template-columns: repeat(3, 1fr);
+  grid-template-columns: repeat(3, minmax(0, 1fr));
   gap: 6px;
   padding: 4px;
   background-color: #f9f9f9;
   border-radius: 4px;
+  align-content: start;
 }
 
 .material-item-full {
   position: relative;
   aspect-ratio: 1;
+  min-width: 0;
+  min-height: 0;
   cursor: pointer;
   border-radius: 8px;
   overflow: hidden;
   border: 1px solid #e5e5e5;
+  background: #f5f7fa;
   transition: all 0.2s;
 }
 
@@ -505,7 +509,8 @@
 .material-item-full img {
   width: 100%;
   height: 100%;
-  object-fit: cover;
+  object-fit: contain;
+  object-position: center center;
   display: block;
 }
 
@@ -622,7 +627,85 @@
 .upload-category-dialog-tip {
   font-size: 13px;
   color: #606266;
-  margin-bottom: 16px;
+  margin-bottom: 12px;
+}
+.upload-category-previews-block {
+  margin-bottom: 18px;
+}
+.upload-category-preview-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
+  gap: 10px;
+}
+.upload-category-preview-cell {
+  position: relative;
+  aspect-ratio: 1;
+  border-radius: 8px;
+  overflow: hidden;
+  background: #f5f7fa;
+  border: 1px solid #ebeef5;
+  box-sizing: border-box;
+}
+.upload-category-preview-cell img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+.upload-category-preview-badge {
+  position: absolute;
+  right: 4px;
+  bottom: 4px;
+  min-width: 18px;
+  height: 18px;
+  padding: 0 5px;
+  font-size: 11px;
+  font-weight: 600;
+  color: #fff;
+  background: rgba(0, 0, 0, 0.55);
+  border-radius: 4px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+.upload-category-hint {
+  font-weight: 400;
+  color: #909399;
+  margin-left: 4px;
+}
+.upload-category-material-name {
+  margin-bottom: 0;
+}
+.upload-category-section-category {
+  margin-top: 22px;
+  padding-top: 18px;
+  border-top: 1px solid #ebeef5;
+}
+.upload-category-category-heading {
+  margin-bottom: 10px;
+}
+.upload-category-field-label {
+  font-size: 12px;
+  color: #606266;
+  margin-bottom: 8px;
+  font-weight: 500;
+}
+.upload-category-material-name :deep(.el-input__wrapper) {
+  border-radius: 8px;
+}
+.upload-category-section-category .upload-category-loading,
+.upload-category-section-category .upload-category-empty {
+  margin-top: 0;
+}
+.upload-category-loading,
+.upload-category-empty {
+  font-size: 13px;
+  color: #909399;
+  padding: 16px 0;
+  text-align: center;
+}
+.upload-category-empty {
+  color: #e6a23c;
 }
 .upload-category-dialog-chips {
   margin-bottom: 12px;
@@ -971,24 +1054,25 @@
   padding: 4px 0;
 }
 
-/* 左侧分页 + 右侧预览:贴合在一起的布局块 */
+/* 左侧分页 + 右侧预览:贴合在一起的布局块(中灰底,与白画布形成明显区分) */
 .page-preview-block {
   flex: 1 1 0;
   min-width: 0;
   min-height: 0;
   display: flex;
-  background: linear-gradient(135deg, #f5f6f8 0%, #ebeef3 100%);
+  background: linear-gradient(165deg, #e4e7ec 0%, #d8dce3 45%, #cdd2d9 100%);
   overflow: hidden;
   margin: 0;
   align-items: stretch;
+  border-right: 1px solid #b8bec8;
 }
 
 /* 页面缩略图栏(左侧,与预览区无缝贴合) */
 .page-sidebar {
   flex-shrink: 0;
   width: 72px;
-  background: rgba(255, 255, 255, 0.6);
-  border-right: 1px solid rgba(0, 0, 0, 0.06);
+  background: rgba(255, 255, 255, 0.88);
+  border-right: 1px solid rgba(0, 0, 0, 0.1);
   display: flex;
   flex-direction: column;
   padding: 12px 8px;
@@ -1116,16 +1200,121 @@
   color: #409eff;
 }
 
-/* 中间区域:可伸缩,不把左右两侧挤出视口 */
+/* 多页纵向拼接预览弹窗 */
+.template-preview-dialog :deep(.el-dialog) {
+  max-width: 92vw;
+}
+.template-preview-dialog-body {
+  min-height: 120px;
+  max-height: min(78vh, 900px);
+  overflow: auto;
+  text-align: left;
+}
+.template-preview-dom-body {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  align-items: center;
+  padding: 4px 0 8px;
+}
+.template-preview-page-block {
+  width: 100%;
+  max-width: 100%;
+}
+.template-preview-page-title {
+  font-size: 12px;
+  color: #606266;
+  margin-bottom: 8px;
+  text-align: center;
+}
+.template-preview-page-scale {
+  margin: 0 auto;
+  overflow: hidden;
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+.template-preview-page-canvas {
+  position: relative;
+  background: #fff;
+}
+.template-preview-layer {
+  pointer-events: none;
+  user-select: none;
+}
+.template-preview-layer img {
+  pointer-events: none;
+}
+
+/* 中间区域:可伸缩,不把左右两侧挤出视口(与中间灰底工作台统一) */
 .content-area {
+  position: relative;
   flex: 1 1 0;
   min-width: 0;
   min-height: 0;
   display: flex;
-  background-color: #f5f5f5;
+  background-color: #d1d5db;
   overflow: hidden;
 }
 
+/* 右下角浮动「预览模版」:贴画布区右下角、在图层面板左侧,玻璃胶囊+微动效,非 el-button */
+.template-preview-float {
+  position: absolute;
+  z-index: 40;
+  /* 与 .layer-panel 同宽 260px + 间距,避免压在属性栏上 */
+  right: calc(260px + 18px);
+  bottom: 22px;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 16px 10px 12px;
+  margin: 0;
+  border: none;
+  border-radius: 999px;
+  cursor: pointer;
+  color: #1d39c4;
+  font-size: 13px;
+  font-weight: 600;
+  letter-spacing: 0.02em;
+  background: linear-gradient(145deg, rgba(255, 255, 255, 0.92) 0%, rgba(232, 240, 255, 0.88) 55%, rgba(224, 236, 255, 0.9) 100%);
+  backdrop-filter: blur(10px);
+  -webkit-backdrop-filter: blur(10px);
+  box-shadow:
+    0 2px 4px rgba(29, 57, 196, 0.06),
+    0 12px 32px rgba(29, 57, 196, 0.12),
+    inset 0 1px 0 rgba(255, 255, 255, 0.9);
+  transition: transform 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
+}
+.template-preview-float:hover {
+  transform: translateY(-3px);
+  color: #409eff;
+  box-shadow:
+    0 4px 8px rgba(64, 158, 255, 0.12),
+    0 16px 40px rgba(64, 158, 255, 0.18),
+    inset 0 1px 0 rgba(255, 255, 255, 1);
+}
+.template-preview-float:active {
+  transform: translateY(-1px);
+}
+.template-preview-float-ring {
+  position: absolute;
+  inset: -3px;
+  border-radius: inherit;
+  pointer-events: none;
+  border: 1px solid rgba(64, 158, 255, 0.35);
+  opacity: 0.85;
+}
+.template-preview-float-icon {
+  font-size: 18px;
+  flex-shrink: 0;
+  position: relative;
+  z-index: 1;
+}
+.template-preview-float-text {
+  position: relative;
+  z-index: 1;
+  white-space: nowrap;
+}
+
 .content-area:has(.template-library-view) {
   flex-direction: column;
 }
@@ -1578,6 +1767,7 @@
 }
 
 .canvas-area {
+  position: relative;
   flex: 1 1 0;
   min-width: 0;
   min-height: 0;
@@ -1585,11 +1775,64 @@
   flex-direction: column;
   background: transparent;
   overflow: hidden;
-  padding: 16px;
+  padding: 20px;
   align-items: center;
   justify-content: center;
 }
 
+/* 素材库:鼠标悬停缩略图时,在画布区域左上角显示大图 + 底部原图尺寸,移开即隐藏 */
+.material-hover-preview-dock {
+  position: absolute;
+  top: 16px;
+  left: 16px;
+  z-index: 35;
+  width: min(280px, 42vw);
+  height: min(300px, min(46vh, 42vw));
+  padding: 0;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #e4e7ed;
+  border-radius: 10px;
+  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
+  pointer-events: none;
+}
+.material-hover-preview-img-wrap {
+  flex: 1 1 0;
+  min-height: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 10px;
+  box-sizing: border-box;
+}
+.material-hover-preview-dock .material-hover-preview-img-wrap img {
+  max-width: 100%;
+  max-height: 100%;
+  width: auto;
+  height: auto;
+  object-fit: contain;
+  object-position: center center;
+  display: block;
+}
+.material-hover-preview-meta {
+  flex-shrink: 0;
+  padding: 8px 10px;
+  font-size: 12px;
+  line-height: 1.4;
+  color: #606266;
+  text-align: center;
+  border-top: 1px solid #ebeef5;
+  background: #fafafa;
+  font-variant-numeric: tabular-nums;
+}
+.material-hover-preview-meta-muted {
+  color: #c0c4cc;
+  font-size: 12px;
+}
+
 .canvas-wrapper {
   position: relative;
   flex: 1;
@@ -1599,15 +1842,26 @@
   overflow: hidden;
   width: 100%;
   max-width: 100%;
+  min-height: 0;
 }
 
-/* 预览效果:画布作为文档预览,带纸张质感边框 */
+/* 包裹缩放后的占位尺寸,使 9:16 等高画布完整落在可视区内 */
+.canvas-fit-host {
+  flex-shrink: 0;
+}
+
+/* 预览效果:白画布 + 清晰描边与投影,与灰工作区分界明显 */
 .canvas {
   position: relative;
   background-color: #fff;
-  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
+  border: 1px solid #9ca3af;
+  border-radius: 4px;
+  box-shadow:
+    0 0 0 1px rgba(255, 255, 255, 0.85) inset,
+    0 1px 2px rgba(0, 0, 0, 0.06),
+    0 8px 28px rgba(0, 0, 0, 0.14),
+    0 2px 8px rgba(0, 0, 0, 0.08);
   overflow: hidden;
-  border-radius: 2px;
 }
 
 .layer {
@@ -1626,6 +1880,8 @@
   height: 100%;
   display: block;
   pointer-events: none;
+  object-fit: contain;
+  object-position: center center;
 }
 
 .text-content {
@@ -1637,8 +1893,21 @@
   box-sizing: border-box;
 }
 
+/* 文字在选框内垂直居中,避免「贴在顶端」难以对准;多行超出时在框内滚动 */
 .text-layer {
   border: 1px solid transparent;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  justify-content: center;
+  box-sizing: border-box;
+}
+.text-layer .text-content {
+  flex: 0 1 auto;
+  height: auto;
+  max-height: 100%;
+  min-height: 0;
+  overflow: auto;
 }
 
 .text-layer.selected {
@@ -1712,7 +1981,7 @@
   width: 260px;
   min-width: 260px;
   background: #f5f6f8;
-  border-left: 1px solid #e8e8e8;
+  border-left: 1px solid #b8bec8;
   padding: 12px;
   display: flex;
   flex-direction: column;

+ 328 - 22
src/view/TemplateManagement/utils.js

@@ -1,68 +1,374 @@
 /**
+
  * CreateTemplate 模版设计页工具函数
+
  * 纯函数,无副作用,便于单元测试与复用
+
  */
 
+
+
+import { displayImageUrl } from '@/utils/displayImageUrl.js'
+
+
+
 /**
- * 将 material_url 转为可请求 URL
+
+ * 将 material_url 转为可请求 URL(与接口返回一致,不拼接域名)
+
  * @param {string} path - 素材路径
+
  * @returns {string}
+
  */
+
 export function resolveMaterialUrl(path) {
-  if (!path || typeof path !== 'string') return ''
-  let p = path.trim()
-  if (!p) return ''
-  if (p.startsWith('data:')) return p
-  if (p.startsWith('http://') || p.startsWith('https://')) {
+
+  return displayImageUrl(path)
+
+}
+
+
+
+/** getImageLoadUrl 的别名,用于画布与预览图 */
+
+export function getImageLoadUrl(url) {
+
+  return displayImageUrl(url)
+
+}
+
+
+
+/**
+ * 画布导出用 URL 列表:
+ * - 优先同源代理(VITE_PREVIEW_IMAGE_PROXY),由后端拉 OSS 再返回,浏览器 fetch 同源不依赖 OSS CORS。
+ * - 其次直连 OSS(需 Bucket 配置 CORS 或 fetch 会失败)。
+ * - HTTPS 页面将 http 图链升为 https,避免混合内容。
+ */
+function resolveCanvasExportUrlCandidates(rawUrl) {
+  const u = getImageLoadUrl(rawUrl.trim())
+  if (!u || u.startsWith('data:')) return [u]
+
+  let out = u
+  if (out.startsWith('//')) out = 'https:' + out
+
+  try {
+    const parsed = new URL(out)
+    if (
+      typeof window !== 'undefined' &&
+      window.isSecureContext &&
+      window.location.protocol === 'https:' &&
+      parsed.protocol === 'http:'
+    ) {
+      parsed.protocol = 'https:'
+      out = parsed.toString()
+    }
+  } catch {
+    /* ignore */
+  }
+
+  const list = []
+  const push = (x) => {
+    if (x && !list.includes(x)) list.push(x)
+  }
+
+  const proxy = typeof import.meta !== 'undefined' && import.meta.env?.VITE_PREVIEW_IMAGE_PROXY
+  if (proxy && String(proxy).trim() && /^https?:\/\//i.test(out)) {
+    const base = String(proxy).trim().replace(/\/$/, '')
+    push(`${base}?url=${encodeURIComponent(out)}`)
+  }
+  push(out)
+
+  return list
+}
+
+/**
+ * 导出用 URL 加一次性查询参数,避免命中「仅展示用」的 img 缓存(无 CORS 头),否则后续 fetch/crossOrigin 会异常或 304 仍不可用。
+ */
+function addExportCacheBust(url) {
+  if (!url || url.startsWith('data:')) return url
+  try {
+    const u = new URL(url, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
+    u.searchParams.set('_exportcb', `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`)
+    return u.toString()
+  } catch {
+    const sep = url.includes('?') ? '&' : '?'
+    return `${url}${sep}_exportcb=${Date.now()}`
+  }
+}
+
+async function blobToDrawable(blob) {
+  if (!blob || !blob.size) return null
+  if (typeof createImageBitmap !== 'undefined') {
     try {
-      const u = new URL(p)
-      if (u.port === '9090') return `${u.protocol}//${u.hostname}:9093${u.pathname}${u.search}`
+      return await createImageBitmap(blob)
     } catch {
-      /* ignore */
+      /* 走 Image */
     }
-    return p
   }
-  const pathNorm = (p.startsWith('/') ? p : '/' + p).replace(/^public\//, '')
-  const base = import.meta.env.VITE_BASE_PATH || ''
-  const port = import.meta.env.VITE_UPLOADS_PORT || '8081'
-  const uploadsBase = base && port ? `${base.replace(/:(\d+)?$/, '')}:${port}` : ''
-  return uploadsBase ? `${uploadsBase.replace(/\/$/, '')}${pathNorm}` : pathNorm
+  const objUrl = URL.createObjectURL(blob)
+  const img = new Image()
+  try {
+    await new Promise((resolve, reject) => {
+      img.onload = () => resolve()
+      img.onerror = () => reject(new Error('decode'))
+      img.src = objUrl
+    })
+  } finally {
+    URL.revokeObjectURL(objUrl)
+  }
+  return img.complete && img.naturalWidth ? img : null
 }
 
-/** getImageLoadUrl 的别名,用于画布与预览图 */
-export function getImageLoadUrl(url) {
-  return resolveMaterialUrl(url)
+/**
+ * 离屏导出 previewImage(base64)时加载位图:须得到「未污染 canvas」的像素。
+ * 1) fetch 直连 OSS:需 OSS 配置 CORS(允许管理端 Origin、GET)。
+ * 2) 或配置 VITE_PREVIEW_IMAGE_PROXY 同源接口,由服务端拉 OSS,浏览器 fetch 同源 blob。
+ * 3) fetch 失败后尝试 Image+crossOrigin(同样依赖 OSS CORS)。
+ * 4) 最后普通 Image(会污染 canvas,上层 generateCanvasPreview 会回退占位图)。
+ * @param {string} rawUrl - layer.url(含 OSS 完整地址或 data:)
+ * @returns {Promise<ImageBitmap|HTMLImageElement|null>}
+ */
+export async function loadImageForCanvasExport(rawUrl) {
+  if (!rawUrl || typeof rawUrl !== 'string') return null
+  const u = rawUrl.trim()
+  if (!u) return null
+
+  if (u.startsWith('data:')) {
+    const img = new Image()
+    await new Promise((resolve) => {
+      img.onload = () => resolve()
+      img.onerror = () => resolve()
+      img.src = u
+    })
+    return img.complete && img.naturalWidth ? img : null
+  }
+
+  const candidates = resolveCanvasExportUrlCandidates(u)
+  const fetchOpts = {
+    mode: 'cors',
+    credentials: 'omit',
+    /** 禁止用「首次无 CORS 的展示请求」留下的缓存,否则易出现 304/内存缓存但无 ACAO,画布仍污染 */
+    cache: 'no-store',
+    referrerPolicy: 'no-referrer'
+  }
+
+  for (const url of candidates) {
+    if (!url || url.startsWith('data:')) continue
+    const busted = addExportCacheBust(url)
+    try {
+      const res = await fetch(busted, fetchOpts)
+      if (res.ok) {
+        const drawable = await blobToDrawable(await res.blob())
+        if (drawable) return drawable
+      }
+    } catch {
+      /* 下一候选 */
+    }
+  }
+
+  for (const url of candidates) {
+    if (!url || url.startsWith('data:')) continue
+    const img = new Image()
+    img.crossOrigin = 'anonymous'
+    const busted = addExportCacheBust(url)
+    await new Promise((resolve) => {
+      img.onload = () => resolve()
+      img.onerror = () => resolve()
+      img.src = busted
+    })
+    if (img.complete && img.naturalWidth) return img
+  }
+
+  for (const url of candidates) {
+    if (!url || url.startsWith('data:')) continue
+    const img2 = new Image()
+    const busted2 = addExportCacheBust(url)
+    await new Promise((resolve) => {
+      img2.onload = () => resolve()
+      img2.onerror = () => resolve()
+      img2.src = busted2
+    })
+    if (img2.complete && img2.naturalWidth) return img2
+  }
+
+  return null
+}
+
+/**
+ * 若画布上已存在同 layer.id 的图片节点,且像素可导出(未污染 canvas),则复用,避免对 OSS 二次 fetch。
+ * 仅在「预览图层」与当前 `.canvas` DOM 一致时有效(保存时预览层通常即当前页)。
+ */
+export function tryGetCanvasDomDrawableForLayer(layerId, canvasRoot) {
+  if (layerId == null || !canvasRoot || typeof canvasRoot.querySelector !== 'function') return null
+  const el = canvasRoot.querySelector(`.layer[data-layer-id="${String(layerId)}"] img`)
+  if (!el || el.tagName !== 'IMG' || !el.complete || !el.naturalWidth) return null
+  try {
+    const t = document.createElement('canvas')
+    t.width = 1
+    t.height = 1
+    const c = t.getContext('2d')
+    if (!c) return null
+    c.drawImage(el, 0, 0, 1, 1)
+    t.toDataURL()
+    return el
+  } catch {
+    return null
+  }
+}
+
+/**
+ * 画布图层图片节点可选属性:设环境变量 VITE_CANVAS_IMG_CROSS_ORIGIN=1 且 OSS 已配置 CORS 后,
+ * 与 tryGetCanvasDomDrawableForLayer / loadImageForCanvasExport 配合可导出完整 previewImage。
+ */
+export function canvasLayerImgAttrs(layer) {
+  const on = typeof import.meta !== 'undefined' && import.meta.env?.VITE_CANVAS_IMG_CROSS_ORIGIN
+  if (on !== '1' && on !== 'true') return {}
+  const u = layer?.url
+  if (!u || typeof u !== 'string') return {}
+  const x = getImageLoadUrl(u)
+  if (!x || x.startsWith('data:') || x.startsWith('blob:')) return {}
+  if (x.startsWith('http://') || x.startsWith('https://') || x.startsWith('//')) {
+    return { crossorigin: 'anonymous' }
+  }
+  return {}
 }
 
 /**
- * 保存时:把 layer.url 转为仅路径(不要 http 前缀)
+
+ * 保存时:把 layer.url 转为仅相对路径(如 uploads/material/...),不含域名。
+
+ * 支持 https/http 完整 OSS/CDN 地址,避免仅写了 http 导致 https 整条被误当时相对路径。
+
  * @param {string} url
+
  * @returns {string}
+
  */
+
 export 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://')) {
+
+  if (u.startsWith('http://') || u.startsWith('https://')) {
+
     try {
+
       const parsed = new URL(u)
-      return parsed.pathname || '/' + u.replace(/^\/+/, '')
+
+      let path = (parsed.pathname || '').replace(/^\/+/, '')
+
+      path = path.replace(/^public\//, '')
+
+      return path
+
     } catch {
+
       return u
+
     }
+
   }
-  return u.startsWith('/') ? u : '/' + u
+
+  let path = u.replace(/^\/+/, '').replace(/^public\//, '')
+
+  return path
+
 }
 
+
+
 /**
+
+ * 调用后端接口时的路径:相对存储路径,如 uploads/merchant/...(不要前导 /;完整 OSS URL 会先经 toStoragePath 剥域名)
+
+ * @param {string} url
+
+ * @returns {string}
+
+ */
+
+export function toApiStoragePath(url) {
+
+  if (!url || typeof url !== 'string') return ''
+
+  const u = url.trim()
+
+  if (!u) return ''
+
+  if (u.startsWith('data:') || u.startsWith('blob:')) return u
+
+  const p = toStoragePath(u)
+
+  if (!p) return ''
+
+  if (p.startsWith('data:')) return p
+
+  return p.replace(/^\/+/, '')
+
+}
+
+
+
+/**
+
  * 格式化文件大小
+
  * @param {number} bytes
+
  * @returns {string}
+
  */
+
 export function formatFileSize(bytes) {
+
   if (!bytes) return ''
+
   if (bytes < 1024) return `${bytes} B`
+
   if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+
   return `${(bytes / 1024 / 1024).toFixed(2)} MB`
+
+}
+
+
+
+/**
+
+ * 从扁平图层列表中收集素材库素材 id(可重复:同一素材占多图层则多次计入),用于上报使用次数。
+
+ * 仅统计带 materialId / material_id 的图片类图层(来自素材库点击添加)。
+
+ */
+
+export function collectMaterialIdsFromLayers(flatLayers) {
+
+  const ids = []
+
+  for (const l of flatLayers || []) {
+
+    if ((l.type || 'image') === 'text') continue
+
+    const raw = l.materialId ?? l.material_id
+
+    if (raw == null || raw === '') continue
+
+    const id = typeof raw === 'number' ? raw : Number(String(raw).trim())
+
+    if (Number.isFinite(id) && id > 0) ids.push(id)
+
+  }
+
+  return ids
+
 }
+
+

+ 1 - 1
src/view/layout/index.vue

@@ -23,7 +23,7 @@
             v-if="isSider"
             class="inline-flex font-bold text-1xl"
             :style="{color:textColor}"
-          >AI云图像包装设计系统</div>
+          >PackAI云图像包装设计系统</div>
         </div>
         <Aside class="aside" />
       </el-aside>

+ 1 - 1
src/view/login/index.vue

@@ -19,7 +19,7 @@
             </div>
             <div class="mb-9">
               <p class="text-center text-3xl font-bold">
-                AI云图像包装设计系统
+                PackAI云图像包装设计系统
               </p>
             </div>
             <el-form

+ 3 - 3
src/view/performance/QualityAssessment/TemplateManage.vue

@@ -173,7 +173,7 @@ import {ref,reactive,computed,watch} from 'vue'
 import {ElMessage,ElMessageBox,ElEmpty,ElImage} from 'element-plus'
 import {Loading} from '@element-plus/icons-vue'
 import {useUserStore} from '@/pinia/modules/user'
-import {GetTxtToTxt,imageToText} from '@/api/mes/job'
+import {CallAIModelApi,imageToText} from '@/api/mes/job'
 
 defineOptions({name: 'TemplateManage'})
 
@@ -410,7 +410,7 @@ const expandText = async () => {
       
       try {
         // 使用async/await调用API接口
-        const res = await GetTxtToTxt(params)
+        const res = await CallAIModelApi(params)
         formData.prompt = res.data.content
         ElMessage({ message: '文本扩写成功', type: 'success' })
       } catch (error) {
@@ -466,7 +466,7 @@ const generateImage = async () => {
       
       try {
         // 使用async/await调用API接口
-        const res = await GetTxtToTxt(params)
+        const res = await CallAIModelApi(params)
         
         // 处理API返回结果
         if (res && res.data && res.data.code === 200) {

+ 2 - 4
src/view/performance/QualityAssessment/TestTemplate.vue

@@ -248,6 +248,7 @@ import { ElMessage } from 'element-plus'
 import { getTable, imageToText, Template_ids,txttoimg_moxing,GetHttpUrl,txttoimg_update, getSide } from '@/api/mes/job'
 import { useUserStore } from '@/pinia/modules/user'
 import { ArrowDown } from '@element-plus/icons-vue'
+import { displayImageUrl } from '@/utils/displayImageUrl.js'
 
 //获取登录用户信息
 const userStore = useUserStore()
@@ -310,10 +311,7 @@ const form = reactive({
   chinese_description: ''
 })
 
-const formatImageUrl = (path) => {
-  if (!path) return ''
-  return `${full_url.value}/${path.replace(/^public\//, '')}`
-}
+const formatImageUrl = displayImageUrl
 
 const txttoimg_modelList = ref([]); // 存储所有模型数据
 const txtimgselectedModel = ref(''); // 当前选中的模型名称

+ 5 - 13
src/view/performance/QualityAssessment/excessive.vue

@@ -679,7 +679,10 @@ updatetemplate,getPreviewFolders,
 setActiveTemplate,GetHttpUrl,
 packImagess,getUploadPath,get_queue_logs,getTaskProgress
 } from '@/api/mes/job'
-import { useUserStore } from '@/pinia/modules/user';
+import { useUserStore } from '@/pinia/modules/user'
+import { displayImageUrl } from '@/utils/displayImageUrl.js'
+
+const formatImageUrl = displayImageUrl
 
 //获取登录用户信息
 const userStore = useUserStore()
@@ -708,18 +711,7 @@ const fetchServerUrl = async () => {
     console.error('获取服务器地址失败:', error)
   }
 }
-fetchServerUrl();
-
-const formatImageUrl = (path) => {
-  if (!path) return ''
-  // 如果path已经是完整URL,直接返回
-  if (path.startsWith('http://') || path.startsWith('https://')) {
-    return path
-  }
-  // 如果path是相对路径,拼接baseUrl
-  return `${full_url.value}/${path.replace(/^public\//, '')}`
-}
-
+fetchServerUrl()
 
 // --------------------- 图片上传 ---------------------
 const tableData = ref([])

+ 3 - 17
src/view/performance/QualityAssessment/texttoimage.vue

@@ -136,7 +136,8 @@
 import { ref, reactive } from 'vue'
 import { ElMessage } from 'element-plus'
 import { txttoimg_moxing, txttoimg_update,imageToText } from '@/api/mes/job'
-import { useUserStore } from '@/pinia/modules/user';
+import { useUserStore } from '@/pinia/modules/user'
+import { displayImageUrl } from '@/utils/displayImageUrl.js'
 
 //获取登录用户信息
 const userStore = useUserStore()
@@ -174,22 +175,7 @@ const txttoimg_modelList = ref([]);
 const usedIds = ref({});
 const selectedIds = ref({});
 
-// 格式化图片URL
-const formatImageUrl = (path) => {
-  if (!path) return ''
-  
-  // 避免重复拼接http://
-  if (path.startsWith('http://') || path.startsWith('https://')) {
-    return path
-  }
-  
-  const base = 'http://20.0.16.128:9093'
-  // 避免重复拼接斜杠
-  const normalizedPath = path.startsWith('/') ? path.substring(1) : path
-  const normalizedBase = base.endsWith('/') ? base : `${base}/`
-  
-  return `${normalizedBase}${normalizedPath}`
-}
+const formatImageUrl = displayImageUrl
 
 const loadImageHistory = async () => {
   // 获取模型列表和历史记录

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.