|
|
@@ -183,9 +183,6 @@
|
|
|
class="canvas"
|
|
|
:style="canvasFitCanvasStyle"
|
|
|
@mousedown="handleCanvasMouseDown"
|
|
|
- @mousemove="handleCanvasMouseMove"
|
|
|
- @mouseup="handleCanvasMouseUp"
|
|
|
- @mouseleave="handleCanvasMouseUp"
|
|
|
@wheel="handleCanvasWheel"
|
|
|
@contextmenu.prevent
|
|
|
>
|
|
|
@@ -264,6 +261,16 @@
|
|
|
</div>
|
|
|
<!-- 图层管理 -->
|
|
|
<div v-show="rightPanelTab === 'layer'" class="right-section">
|
|
|
+ <div class="page-prompt-block">
|
|
|
+ <div class="page-prompt-label">第 {{ currentPageIndex + 1 }} 页 · 提示词</div>
|
|
|
+ <el-input
|
|
|
+ v-model="currentPageChineseDescription"
|
|
|
+ type="textarea"
|
|
|
+ :rows="8"
|
|
|
+ maxlength="2000"
|
|
|
+ show-word-limit
|
|
|
+ />
|
|
|
+ </div>
|
|
|
<div class="layer-actions">
|
|
|
<el-button size="small" @click="moveLayerUp" :disabled="!canMoveUp">
|
|
|
<el-icon><ArrowUp /></el-icon>
|
|
|
@@ -295,63 +302,69 @@
|
|
|
@dragenter="handleDragEnter($event, layer)"
|
|
|
@dragleave="handleDragLeave"
|
|
|
>
|
|
|
- <el-icon class="layer-drag-handle" @click.stop><Rank /></el-icon>
|
|
|
- <template v-if="layer.type === 'text'">
|
|
|
- <div class="layer-card-thumb text-thumb">T</div>
|
|
|
- </template>
|
|
|
- <template v-else-if="layer.type === 'shape'">
|
|
|
- <div class="layer-card-thumb shape-thumb" :class="{ 'shape-thumb-no-fill': layer.fillMode === 'none' }" :style="{ backgroundColor: layer.fillMode === 'none' ? '#f5f7fa' : (layer.fillColor || '#e0e0e0'), border: `1px solid ${layer.strokeColor || '#606266'}` }">
|
|
|
- <span class="shape-thumb-icon">{{ layer.shapeType === 'rect' ? '□' : layer.shapeType === 'circle' ? '○' : layer.shapeType === 'ellipse' ? '◯' : '—' }}</span>
|
|
|
+ <div class="layer-item-card-upper">
|
|
|
+ <el-icon class="layer-drag-handle" @click.stop><Rank /></el-icon>
|
|
|
+ <template v-if="layer.type === 'text'">
|
|
|
+ <div class="layer-card-thumb text-thumb">T</div>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="layer.type === 'shape'">
|
|
|
+ <div class="layer-card-thumb shape-thumb" :class="{ 'shape-thumb-no-fill': layer.fillMode === 'none' }" :style="{ backgroundColor: layer.fillMode === 'none' ? '#f5f7fa' : (layer.fillColor || '#e0e0e0'), border: `1px solid ${layer.strokeColor || '#606266'}` }">
|
|
|
+ <span class="shape-thumb-icon">{{ layer.shapeType === 'rect' ? '□' : layer.shapeType === 'circle' ? '○' : layer.shapeType === 'ellipse' ? '◯' : '—' }}</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <img v-if="layer.url" :src="getImageLoadUrl(layer.url)" class="layer-card-thumb" alt="" loading="lazy" />
|
|
|
+ <div v-else class="layer-card-thumb img-placeholder"><el-icon><Picture /></el-icon></div>
|
|
|
+ </template>
|
|
|
+ <div class="layer-item-card-toolbar">
|
|
|
+ <el-icon class="layer-card-action" :title="layer.visible ? '隐藏' : '显示'" @click.stop="toggleLayerVisibility(layer.id)">
|
|
|
+ <View v-if="layer.visible" />
|
|
|
+ <Hide v-else />
|
|
|
+ </el-icon>
|
|
|
+ <el-icon class="layer-card-action" :title="layer.locked ? '解锁' : '锁定'" @click.stop="toggleLayerLock(layer.id)">
|
|
|
+ <Lock v-if="layer.locked" />
|
|
|
+ <Unlock v-else />
|
|
|
+ </el-icon>
|
|
|
+ <el-icon class="layer-card-action" title="删除" @click.stop="deleteLayerById(layer.id)">
|
|
|
+ <Delete />
|
|
|
+ </el-icon>
|
|
|
</div>
|
|
|
- </template>
|
|
|
- <template v-else>
|
|
|
- <img v-if="layer.url" :src="getImageLoadUrl(layer.url)" class="layer-card-thumb" alt="" loading="lazy" />
|
|
|
- <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>
|
|
|
- <div class="layer-card-name-trigger">
|
|
|
- <el-popover
|
|
|
- :visible="layerNameEditingId === layer.id"
|
|
|
- @update:visible="(v) => !v && (layerNameEditingId = null)"
|
|
|
- trigger="manual"
|
|
|
- placement="bottom-start"
|
|
|
- :width="260"
|
|
|
- popper-class="layer-name-edit-popover"
|
|
|
- >
|
|
|
- <template #reference>
|
|
|
- <span
|
|
|
- class="layer-card-name"
|
|
|
- :title="layer.name || (layer.type === 'text' ? '文字' : layer.type === 'shape' ? '形状' : '图片')"
|
|
|
- @click.stop="openLayerNameEdit(layer)"
|
|
|
- >{{ layer.name || (layer.type === 'text' ? '文字' : layer.type === 'shape' ? '形状' : '图片') }}</span>
|
|
|
- </template>
|
|
|
- <div class="layer-name-edit-inner" @click.stop>
|
|
|
- <el-input
|
|
|
- :ref="el => setLayerNameInputRef(layer.id, el)"
|
|
|
- v-model="layer.name"
|
|
|
- size="small"
|
|
|
- :placeholder="layer.type === 'text' ? '文字' : layer.type === 'shape' ? '形状' : '图片'"
|
|
|
- style="width: 100%; min-width: 200px;"
|
|
|
- @blur="layerNameEditingId = null"
|
|
|
- @keydown.enter.prevent="layerNameEditingId = null"
|
|
|
- />
|
|
|
+ </div>
|
|
|
+ <div class="layer-item-card-lower" draggable="false" @dragstart.stop>
|
|
|
+ <div class="layer-card-name-wrap">
|
|
|
+ <div class="layer-card-name-trigger">
|
|
|
+ <el-popover
|
|
|
+ :visible="layerNameEditingId === layer.id"
|
|
|
+ @update:visible="(v) => !v && (layerNameEditingId = null)"
|
|
|
+ trigger="manual"
|
|
|
+ placement="bottom-start"
|
|
|
+ :width="280"
|
|
|
+ popper-class="layer-name-edit-popover"
|
|
|
+ >
|
|
|
+ <template #reference>
|
|
|
+ <span
|
|
|
+ class="layer-card-name"
|
|
|
+ :title="layer.name || (layer.type === 'text' ? '文字' : layer.type === 'shape' ? '形状' : '图片')"
|
|
|
+ @click.stop="openLayerNameEdit(layer)"
|
|
|
+ >{{ layer.name || (layer.type === 'text' ? '文字' : layer.type === 'shape' ? '形状' : '图片') }}</span>
|
|
|
+ </template>
|
|
|
+ <div class="layer-name-edit-inner" @click.stop>
|
|
|
+ <el-input
|
|
|
+ :ref="el => setLayerNameInputRef(layer.id, el)"
|
|
|
+ v-model="layer.name"
|
|
|
+ size="small"
|
|
|
+ :placeholder="layer.type === 'text' ? '文字' : layer.type === 'shape' ? '形状' : '图片'"
|
|
|
+ style="width: 100%; min-width: 200px;"
|
|
|
+ @blur="layerNameEditingId = null"
|
|
|
+ @keydown.enter.prevent="layerNameEditingId = null"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </el-popover>
|
|
|
+ <el-icon class="layer-card-name-edit-icon" title="编辑名称" @click.stop="openLayerNameEdit(layer)"><EditPen /></el-icon>
|
|
|
</div>
|
|
|
- </el-popover>
|
|
|
- <el-icon class="layer-card-name-edit-icon" title="编辑名称" @click.stop="openLayerNameEdit(layer)"><EditPen /></el-icon>
|
|
|
- <div v-if="layer.type !== 'text' && layer.fileSize" class="layer-card-size">{{ formatFileSize(layer.fileSize) }}</div>
|
|
|
+ <div v-if="layer.type !== 'text' && layer.fileSize" class="layer-card-size">{{ formatFileSize(layer.fileSize) }}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <el-icon class="layer-card-action" :title="layer.visible ? '隐藏' : '显示'" @click.stop="toggleLayerVisibility(layer.id)">
|
|
|
- <View v-if="layer.visible" />
|
|
|
- <Hide v-else />
|
|
|
- </el-icon>
|
|
|
- <el-icon class="layer-card-action" :title="layer.locked ? '解锁' : '锁定'" @click.stop="toggleLayerLock(layer.id)">
|
|
|
- <Lock v-if="layer.locked" />
|
|
|
- <Unlock v-else />
|
|
|
- </el-icon>
|
|
|
- <el-icon class="layer-card-action" title="删除" @click.stop="deleteLayerById(layer.id)">
|
|
|
- <Delete />
|
|
|
- </el-icon>
|
|
|
</div>
|
|
|
<div v-if="layers.length === 0" class="empty-tip">暂无图层,请上传素材</div>
|
|
|
</div>
|
|
|
@@ -668,63 +681,67 @@
|
|
|
<el-dialog
|
|
|
v-model="previewDialogVisible"
|
|
|
title="预览模版"
|
|
|
- width="560px"
|
|
|
+ width="480px"
|
|
|
align-center
|
|
|
class="template-preview-dialog"
|
|
|
+ append-to-body
|
|
|
destroy-on-close
|
|
|
>
|
|
|
- <div class="template-preview-dialog-body">
|
|
|
- <div class="template-preview-dom-body">
|
|
|
- <div
|
|
|
- v-for="(page, pIdx) in pages"
|
|
|
- :key="page.id"
|
|
|
- class="template-preview-page-block"
|
|
|
- >
|
|
|
- <div class="template-preview-page-title">第 {{ pIdx + 1 }} 页</div>
|
|
|
+ <!-- 外壳与 TemplateDesign「预览模版」弹层同构:inner → viewport(clamp 高) → scroller,多页 DOM 在 scroller 内纵向排布 -->
|
|
|
+ <div class="template-strip-preview-inner">
|
|
|
+ <div class="template-strip-preview-viewport">
|
|
|
+ <div class="template-strip-preview-scroller">
|
|
|
<div
|
|
|
- class="template-preview-page-scale"
|
|
|
- :style="{
|
|
|
- width: previewScaledSize.w + 'px',
|
|
|
- height: previewScaledSize.h + 'px'
|
|
|
- }"
|
|
|
+ v-for="(page, pIdx) in pages"
|
|
|
+ :key="page.id"
|
|
|
+ class="template-preview-page-block"
|
|
|
>
|
|
|
+ <div class="template-preview-page-title">第 {{ pIdx + 1 }} 页</div>
|
|
|
<div
|
|
|
- class="template-preview-page-canvas"
|
|
|
+ class="template-preview-page-scale"
|
|
|
:style="{
|
|
|
- width: canvasWidth + 'px',
|
|
|
- height: canvasHeight + 'px',
|
|
|
- transform: `scale(${previewDialogScale})`,
|
|
|
- transformOrigin: 'top left'
|
|
|
+ width: previewScaledSize.w + 'px',
|
|
|
+ height: previewScaledSize.h + 'px'
|
|
|
}"
|
|
|
>
|
|
|
<div
|
|
|
- v-for="layer in page.layers"
|
|
|
- :key="layer.id"
|
|
|
- v-show="layer.visible"
|
|
|
- class="layer template-preview-layer"
|
|
|
- :class="{
|
|
|
- 'text-layer': layer.type === 'text',
|
|
|
- 'shape-layer-wrap': layer.type === 'shape'
|
|
|
+ class="template-preview-page-canvas"
|
|
|
+ :style="{
|
|
|
+ width: canvasWidth + 'px',
|
|
|
+ height: canvasHeight + 'px',
|
|
|
+ transform: `scale(${previewDialogScale})`,
|
|
|
+ transformOrigin: 'top left'
|
|
|
}"
|
|
|
- :style="getLayerStyle(layer)"
|
|
|
>
|
|
|
- <template v-if="layer.type === 'image' || (layer.type !== 'text' && layer.type !== 'shape' && layer.url)">
|
|
|
- <img
|
|
|
- :src="getImageLoadUrl(layer.url)"
|
|
|
- :alt="layer.name"
|
|
|
- draggable="false"
|
|
|
- decoding="async"
|
|
|
- referrerpolicy="no-referrer"
|
|
|
- />
|
|
|
- </template>
|
|
|
- <template v-else-if="layer.type === 'shape'">
|
|
|
- <div class="shape-layer" :style="getShapeStyle(layer)" />
|
|
|
- </template>
|
|
|
- <template v-else>
|
|
|
- <div class="text-content" :style="getTextStyle(layer)">
|
|
|
- {{ layer.text }}
|
|
|
- </div>
|
|
|
- </template>
|
|
|
+ <div
|
|
|
+ v-for="layer in page.layers"
|
|
|
+ :key="layer.id"
|
|
|
+ v-show="layer.visible"
|
|
|
+ class="layer template-preview-layer"
|
|
|
+ :class="{
|
|
|
+ 'text-layer': layer.type === 'text',
|
|
|
+ 'shape-layer-wrap': layer.type === 'shape'
|
|
|
+ }"
|
|
|
+ :style="getLayerStyle(layer)"
|
|
|
+ >
|
|
|
+ <template v-if="layer.type === 'image' || (layer.type !== 'text' && layer.type !== 'shape' && layer.url)">
|
|
|
+ <img
|
|
|
+ :src="getImageLoadUrl(layer.url)"
|
|
|
+ :alt="layer.name"
|
|
|
+ draggable="false"
|
|
|
+ decoding="async"
|
|
|
+ referrerpolicy="no-referrer"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ <template v-else-if="layer.type === 'shape'">
|
|
|
+ <div class="shape-layer" :style="getShapeStyle(layer)" />
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <div class="text-content" :style="getTextStyle(layer)">
|
|
|
+ {{ layer.text }}
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -765,7 +782,8 @@ import {
|
|
|
MATERIAL_NAME_MAX_LEN,
|
|
|
DEFAULT_CANVAS_RATIO,
|
|
|
DEFAULT_CANVAS_WIDTH,
|
|
|
- DEFAULT_CANVAS_HEIGHT
|
|
|
+ DEFAULT_CANVAS_HEIGHT,
|
|
|
+ DEFAULT_PAGE_CHINESE_DESCRIPTION
|
|
|
} from './constants.js'
|
|
|
import {
|
|
|
resolveMaterialUrl,
|
|
|
@@ -777,6 +795,7 @@ import {
|
|
|
tryGetCanvasDomDrawableForLayer,
|
|
|
canvasLayerImgAttrs
|
|
|
} from './utils.js'
|
|
|
+import { inferPromptKeyFromTextLayerName, mergePromptFromTextLayers } from './promptFieldSync.js'
|
|
|
import AddTabPane from './components/AddTabPane.vue'
|
|
|
import AiTabPane from './components/AiTabPane.vue'
|
|
|
import MaterialTabPane from './components/MaterialTabPane.vue'
|
|
|
@@ -872,11 +891,13 @@ const canvasFitCanvasStyle = computed(() => ({
|
|
|
transformOrigin: 'top left'
|
|
|
}))
|
|
|
|
|
|
-/** 预览弹窗 DOM 复刻用:整页等比缩小,宽度适配对话框(与左侧页缩略图一致不依赖 canvas 截图) */
|
|
|
+/** 弹窗内整页等比缩放的显示宽度(与侧栏页缩、列表缩略同量级,不改变画布 9:16 比例) */
|
|
|
+const previewListMaxWidthPx = 360
|
|
|
+
|
|
|
+/** 预览弹窗:按该宽度对画布等比 scale,不套列表的 4/3.5 裁切 */
|
|
|
const previewDialogScale = computed(() => {
|
|
|
- const maxW = 520
|
|
|
const w = canvasWidth.value || 1
|
|
|
- return Math.min(1, maxW / w)
|
|
|
+ return Math.min(1, previewListMaxWidthPx / w)
|
|
|
})
|
|
|
const previewScaledSize = computed(() => {
|
|
|
const s = previewDialogScale.value
|
|
|
@@ -944,9 +965,19 @@ console.log('获取用户名称',userStore.userInfo.nickName)
|
|
|
// 多页面:每页独立图层,类似 PowerPoint
|
|
|
let pageIdCounter = 0
|
|
|
const pages = ref([
|
|
|
- { id: ++pageIdCounter, layers: [] }
|
|
|
+ { id: ++pageIdCounter, layers: [], chineseDescription: DEFAULT_PAGE_CHINESE_DESCRIPTION }
|
|
|
])
|
|
|
const currentPageIndex = ref(0)
|
|
|
+// 当前画布页「提示词」,与第几页一一对应,保存时随 pages 顺序写入 chinese_description
|
|
|
+const currentPageChineseDescription = computed({
|
|
|
+ get() {
|
|
|
+ return pages.value[currentPageIndex.value]?.chineseDescription ?? ''
|
|
|
+ },
|
|
|
+ set(v) {
|
|
|
+ const p = pages.value[currentPageIndex.value]
|
|
|
+ if (p) p.chineseDescription = v == null ? '' : String(v)
|
|
|
+ }
|
|
|
+})
|
|
|
// layers 始终指向当前页的 layers 数组引用,切换页时同步
|
|
|
const layers = ref(pages.value[0].layers)
|
|
|
const selectedLayerId = ref(null)
|
|
|
@@ -1025,7 +1056,9 @@ function mapRowToLayer(row) {
|
|
|
originalHeight: height,
|
|
|
zIndex: Number(row.z_index || 0) || 0,
|
|
|
materialId: row.material_id,
|
|
|
- templateId: row.template_id
|
|
|
+ templateId: row.template_id,
|
|
|
+ promptSyncKey:
|
|
|
+ row.layer_type === 'text' ? (inferPromptKeyFromTextLayerName(row.layer_name) || undefined) : undefined
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -1037,7 +1070,7 @@ const syncLayersToCurrentPage = () => {
|
|
|
|
|
|
// 添加新页面
|
|
|
const addPage = () => {
|
|
|
- pages.value.push({ id: ++pageIdCounter, layers: [] })
|
|
|
+ pages.value.push({ id: ++pageIdCounter, layers: [], chineseDescription: DEFAULT_PAGE_CHINESE_DESCRIPTION })
|
|
|
currentPageIndex.value = pages.value.length - 1
|
|
|
syncLayersToCurrentPage()
|
|
|
selectedLayerId.value = null
|
|
|
@@ -1069,21 +1102,48 @@ const clearDesign = () => {
|
|
|
editingTemplateId.value = null
|
|
|
templateName.value = '未命名模版'
|
|
|
pageIdCounter = 0
|
|
|
- pages.value = [{ id: ++pageIdCounter, layers: [] }]
|
|
|
+ pages.value = [{ id: ++pageIdCounter, layers: [], chineseDescription: DEFAULT_PAGE_CHINESE_DESCRIPTION }]
|
|
|
currentPageIndex.value = 0
|
|
|
syncLayersToCurrentPage()
|
|
|
selectedLayerId.value = null
|
|
|
}
|
|
|
|
|
|
+/** 从接口页对象上取每页提示词 */
|
|
|
+function pickPageChineseDescription(p) {
|
|
|
+ if (p == null) return ''
|
|
|
+ const v = p.chinese_description ?? p.chineseDescription
|
|
|
+ return typeof v === 'string' ? v : v != null ? String(v) : ''
|
|
|
+}
|
|
|
+
|
|
|
// 解析接口数据为 pages 结构:支持 data.pages、layers 带 page_index、或平铺单页
|
|
|
function parseDataToPages(data) {
|
|
|
+ const zipDescriptions = (out) => {
|
|
|
+ const arr = data?.chinese_description ?? data?.chineseDescription
|
|
|
+ if (!Array.isArray(arr) || !out.length) return out
|
|
|
+ out.forEach((p, i) => {
|
|
|
+ if (p.chineseDescription && String(p.chineseDescription).trim()) return
|
|
|
+ const d = arr[i]
|
|
|
+ if (d == null || d === '') return
|
|
|
+ p.chineseDescription = typeof d === 'string' ? d : String(d)
|
|
|
+ })
|
|
|
+ return out
|
|
|
+ }
|
|
|
// 1) 后端直接返回 pages 数组
|
|
|
if (Array.isArray(data?.pages) && data.pages.length) {
|
|
|
pageIdCounter = 0
|
|
|
- return data.pages.map(p => ({
|
|
|
- id: ++pageIdCounter,
|
|
|
- layers: (Array.isArray(p?.layers) ? p.layers : []).map(mapRowToLayer)
|
|
|
- }))
|
|
|
+ const topDesc = data?.chinese_description ?? data?.chineseDescription
|
|
|
+ return data.pages.map((p, i) => {
|
|
|
+ let desc = pickPageChineseDescription(p)
|
|
|
+ if ((!desc || !String(desc).trim()) && Array.isArray(topDesc) && topDesc[i] != null && topDesc[i] !== '') {
|
|
|
+ const d = topDesc[i]
|
|
|
+ desc = typeof d === 'string' ? d : String(d)
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ id: ++pageIdCounter,
|
|
|
+ layers: (Array.isArray(p?.layers) ? p.layers : []).map(mapRowToLayer),
|
|
|
+ chineseDescription: desc || ''
|
|
|
+ }
|
|
|
+ })
|
|
|
}
|
|
|
let rows = Array.isArray(data) ? data : (data?.layers ?? data?.data ?? [])
|
|
|
if (!Array.isArray(rows) || !rows.length) return null
|
|
|
@@ -1099,15 +1159,31 @@ function parseDataToPages(data) {
|
|
|
}
|
|
|
const sorted = [...byPage.entries()].sort((a, b) => a[0] - b[0])
|
|
|
pageIdCounter = 0
|
|
|
- return sorted.map(([, pRows]) => ({
|
|
|
+ const out = sorted.map(([, pRows]) => ({
|
|
|
id: ++pageIdCounter,
|
|
|
- layers: pRows.map(mapRowToLayer)
|
|
|
+ layers: pRows.map(mapRowToLayer),
|
|
|
+ chineseDescription: ''
|
|
|
}))
|
|
|
+ return zipDescriptions(out)
|
|
|
}
|
|
|
|
|
|
// 3) 平铺单页
|
|
|
pageIdCounter = 0
|
|
|
- return [{ id: ++pageIdCounter, layers: rows.map(mapRowToLayer) }]
|
|
|
+ let desc0 = ''
|
|
|
+ const top = data?.chinese_description ?? data?.chineseDescription
|
|
|
+ if (Array.isArray(top) && top.length && top[0] != null) {
|
|
|
+ desc0 = typeof top[0] === 'string' ? top[0] : String(top[0])
|
|
|
+ } else if (typeof data?.chinese_description === 'string') {
|
|
|
+ desc0 = data.chinese_description
|
|
|
+ } else if (typeof data?.chineseDescription === 'string') {
|
|
|
+ desc0 = data.chineseDescription
|
|
|
+ }
|
|
|
+ const one = {
|
|
|
+ id: ++pageIdCounter,
|
|
|
+ layers: rows.map(mapRowToLayer),
|
|
|
+ chineseDescription: desc0
|
|
|
+ }
|
|
|
+ return [one]
|
|
|
}
|
|
|
|
|
|
// 使用模板:通过模板 id 获取模板关联的图层数据并还原到画布(预览后可编辑并保存修改)
|
|
|
@@ -1403,6 +1479,20 @@ const addShapeLayer = (preset) => {
|
|
|
selectedLayerId.value = newLayer.id
|
|
|
}
|
|
|
|
|
|
+/** 根据当前页文字图层内容,更新右侧「提示词」中 产品名称/标题/副标题/内容/背景图 对应行 */
|
|
|
+const syncCurrentPagePromptFromTextLayers = () => {
|
|
|
+ const page = pages.value[currentPageIndex.value]
|
|
|
+ if (!page) return
|
|
|
+ const next = mergePromptFromTextLayers(
|
|
|
+ page.chineseDescription,
|
|
|
+ layers.value,
|
|
|
+ DEFAULT_PAGE_CHINESE_DESCRIPTION
|
|
|
+ )
|
|
|
+ if (next !== page.chineseDescription) {
|
|
|
+ page.chineseDescription = next
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// 添加文字图层,preset 可选 'title' | 'subtitle' | 'body'(TEXT_PRESETS 来自 constants.js)
|
|
|
const addTextLayer = (preset) => {
|
|
|
const def = preset ? TEXT_PRESETS[preset] : { fontSize: 16, fontWeight: 'normal', text: '双击编辑文字', name: '文字' }
|
|
|
@@ -1433,12 +1523,31 @@ const addTextLayer = (preset) => {
|
|
|
lineHeight: 1.5,
|
|
|
letterSpacing: 0
|
|
|
}
|
|
|
+ if (preset === 'title') newLayer.promptSyncKey = 'title'
|
|
|
+ else if (preset === 'subtitle') newLayer.promptSyncKey = 'subtitle'
|
|
|
+ else if (preset === 'body') newLayer.promptSyncKey = 'content'
|
|
|
|
|
|
layers.value.push(newLayer)
|
|
|
selectedLayerId.value = newLayer.id
|
|
|
textLayerCount.value++
|
|
|
+ nextTick(() => {
|
|
|
+ syncCurrentPagePromptFromTextLayers()
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
+watch(
|
|
|
+ layers,
|
|
|
+ () => {
|
|
|
+ syncCurrentPagePromptFromTextLayers()
|
|
|
+ },
|
|
|
+ { deep: true }
|
|
|
+)
|
|
|
+watch(currentPageIndex, () => {
|
|
|
+ nextTick(() => {
|
|
|
+ syncCurrentPagePromptFromTextLayers()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
const textLayerCount = ref(0)
|
|
|
|
|
|
// 素材库状态
|
|
|
@@ -1998,6 +2107,7 @@ onDeactivated(() => {
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
teardownDesignSurface()
|
|
|
+ unbindWindowCanvasInteraction()
|
|
|
uploadPreviewObjectUrls.value.forEach((u) => URL.revokeObjectURL(u))
|
|
|
uploadPreviewObjectUrls.value = []
|
|
|
})
|
|
|
@@ -2053,15 +2163,32 @@ const saveTemplate = async () => {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- // 封面 previewImage(Base64):由离屏 canvas + generateCanvasPreview 生成,不是「预览模版」弹窗的截图。
|
|
|
- // 弹窗用 DOM+img 仅展示,不要求 OSS CORS;导出需未污染像素:OSS 配 CORS + 可选 VITE_CANVAS_IMG_CROSS_ORIGIN=1,或 VITE_PREVIEW_IMAGE_PROXY,否则 fetch/DOM 均无法带齐素材。
|
|
|
- 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)
|
|
|
+ syncTemplateNameFromEl()
|
|
|
+ const nameTrim = (templateName.value || '').trim()
|
|
|
+ if (!nameTrim || nameTrim === '未命名模版') {
|
|
|
+ ElMessage.warning('请先为模版命名,不能保存为「未命名模版」')
|
|
|
+ focusTemplateNameEl()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 多页:每页一张 data URL,与 pages / chinese_description 下标一一对应;主封面 previewImage=第1页,失败时再按「首屏有图」重导一次。
|
|
|
+ const previewImages = await Promise.all(
|
|
|
+ pages.value.map(p =>
|
|
|
+ generateCanvasPreview(
|
|
|
+ p?.layers && p.layers.length ? p.layers : []
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+ let previewImage = previewImages[0] ?? null
|
|
|
+ if (!previewImage) {
|
|
|
+ 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 ?? []
|
|
|
+ })()
|
|
|
+ previewImage = await generateCanvasPreview(previewLayers.length ? previewLayers : undefined)
|
|
|
+ }
|
|
|
|
|
|
// 收集所有页的新上传素材图
|
|
|
const uploadedMaterials = flatLayers
|
|
|
@@ -2076,12 +2203,17 @@ const saveTemplate = async () => {
|
|
|
}))
|
|
|
|
|
|
const templateData = {
|
|
|
- template_name: templateName.value || '未命名模版',
|
|
|
+ template_name: nameTrim,
|
|
|
sys_id: userStore.userInfo.nickName,
|
|
|
canvasWidth: canvasWidth.value,
|
|
|
canvasHeight: canvasHeight.value,
|
|
|
canvasRatio: canvasRatio.value,
|
|
|
+ /** 主缩略/列表用:与第 1 页一致 */
|
|
|
previewImage,
|
|
|
+ /** 多页时与 pages 下标一一对应;后端可落盘为多条路径并 JSON 入新字段,或只存 0/全部由后端定 */
|
|
|
+ preview_images: previewImages,
|
|
|
+ /** 与 pages 下标对齐,每页一条提示词 */
|
|
|
+ chinese_description: pages.value.map(p => (p.chineseDescription != null ? String(p.chineseDescription) : '')),
|
|
|
uploaded_materials: uploadedMaterials,
|
|
|
layers: flatLayers.map(layer => layerToApiShape(layer, layer._pageIndex ?? 0))
|
|
|
}
|
|
|
@@ -2231,6 +2363,11 @@ const handleCanvasSizeChange = () => {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+ for (const p of pages.value || []) {
|
|
|
+ for (const l of p?.layers || []) {
|
|
|
+ clampLayerToCanvasBounds(l)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// 图片宽度变化时,如果锁定比例,自动调整高度
|
|
|
@@ -2414,6 +2551,32 @@ let startRotation = 0
|
|
|
let centerX = 0
|
|
|
let centerY = 0
|
|
|
|
|
|
+let windowCanvasInteractionBound = false
|
|
|
+const onWindowPointerMove = (e) => {
|
|
|
+ if (!isDragging && !isResizing && !isRotating) return
|
|
|
+ handleCanvasMouseMove(e)
|
|
|
+}
|
|
|
+const onWindowPointerUp = () => {
|
|
|
+ if (!windowCanvasInteractionBound) return
|
|
|
+ handleCanvasMouseUp()
|
|
|
+}
|
|
|
+function bindWindowCanvasInteraction() {
|
|
|
+ if (windowCanvasInteractionBound) return
|
|
|
+ windowCanvasInteractionBound = true
|
|
|
+ window.addEventListener('mousemove', onWindowPointerMove, true)
|
|
|
+ window.addEventListener('mouseup', onWindowPointerUp, true)
|
|
|
+ window.addEventListener('pointerup', onWindowPointerUp, true)
|
|
|
+ window.addEventListener('pointercancel', onWindowPointerUp, true)
|
|
|
+}
|
|
|
+function unbindWindowCanvasInteraction() {
|
|
|
+ if (!windowCanvasInteractionBound) return
|
|
|
+ windowCanvasInteractionBound = false
|
|
|
+ window.removeEventListener('mousemove', onWindowPointerMove, true)
|
|
|
+ window.removeEventListener('mouseup', onWindowPointerUp, true)
|
|
|
+ window.removeEventListener('pointerup', onWindowPointerUp, true)
|
|
|
+ window.removeEventListener('pointercancel', onWindowPointerUp, true)
|
|
|
+}
|
|
|
+
|
|
|
const handleLayerMouseDown = (e, layer) => {
|
|
|
if (currentTool.value !== 'select' && currentTool.value !== 'move') return
|
|
|
|
|
|
@@ -2430,7 +2593,7 @@ const handleLayerMouseDown = (e, layer) => {
|
|
|
dragStartY = e.clientY
|
|
|
layerStartX = layer.x
|
|
|
layerStartY = layer.y
|
|
|
-
|
|
|
+ bindWindowCanvasInteraction()
|
|
|
e.preventDefault()
|
|
|
}
|
|
|
|
|
|
@@ -2451,6 +2614,21 @@ const getCanvasScale = () => {
|
|
|
return { scaleX, scaleY }
|
|
|
}
|
|
|
|
|
|
+/** 将图层位置限制在画布范围内:小于画布时贴边;大于画布时允许平移使内容仍覆盖可视区 */
|
|
|
+const clampLayerToCanvasBounds = (layer) => {
|
|
|
+ if (!layer || layer.locked) return
|
|
|
+ const cw = canvasWidth.value
|
|
|
+ const ch = canvasHeight.value
|
|
|
+ const w = Math.max(1, layer.width || 0)
|
|
|
+ const h = Math.max(1, layer.height || 0)
|
|
|
+ const minX = Math.min(0, cw - w)
|
|
|
+ const maxX = Math.max(0, cw - w)
|
|
|
+ const minY = Math.min(0, ch - h)
|
|
|
+ const maxY = Math.max(0, ch - h)
|
|
|
+ layer.x = Math.round(Math.min(maxX, Math.max(minX, layer.x ?? 0)))
|
|
|
+ layer.y = Math.round(Math.min(maxY, Math.max(minY, layer.y ?? 0)))
|
|
|
+}
|
|
|
+
|
|
|
const handleCanvasMouseMove = (e) => {
|
|
|
if (!selectedLayer.value) return
|
|
|
|
|
|
@@ -2462,6 +2640,7 @@ const handleCanvasMouseMove = (e) => {
|
|
|
|
|
|
selectedLayer.value.x = Math.round(layerStartX + deltaX)
|
|
|
selectedLayer.value.y = Math.round(layerStartY + deltaY)
|
|
|
+ clampLayerToCanvasBounds(selectedLayer.value)
|
|
|
}
|
|
|
|
|
|
if (isResizing) {
|
|
|
@@ -2515,6 +2694,7 @@ const handleCanvasMouseMove = (e) => {
|
|
|
|
|
|
layer.width = Math.round(newWidth)
|
|
|
layer.height = Math.round(newHeight)
|
|
|
+ clampLayerToCanvasBounds(layer)
|
|
|
}
|
|
|
|
|
|
if (isRotating) {
|
|
|
@@ -2529,9 +2709,13 @@ const handleCanvasMouseMove = (e) => {
|
|
|
}
|
|
|
|
|
|
const handleCanvasMouseUp = () => {
|
|
|
+ if (isDragging || isResizing) {
|
|
|
+ if (selectedLayer.value) clampLayerToCanvasBounds(selectedLayer.value)
|
|
|
+ }
|
|
|
isDragging = false
|
|
|
isResizing = false
|
|
|
isRotating = false
|
|
|
+ unbindWindowCanvasInteraction()
|
|
|
}
|
|
|
|
|
|
const startResize = (e, direction) => {
|
|
|
@@ -2545,6 +2729,8 @@ const startResize = (e, direction) => {
|
|
|
startHeight = selectedLayer.value.height
|
|
|
layerStartX = selectedLayer.value.x
|
|
|
layerStartY = selectedLayer.value.y
|
|
|
+ bindWindowCanvasInteraction()
|
|
|
+ e.preventDefault()
|
|
|
}
|
|
|
|
|
|
const startRotate = (e) => {
|
|
|
@@ -2554,6 +2740,8 @@ const startRotate = (e) => {
|
|
|
centerX = selectedLayer.value.x + selectedLayer.value.width / 2
|
|
|
centerY = selectedLayer.value.y + selectedLayer.value.height / 2
|
|
|
startRotation = selectedLayer.value.rotation
|
|
|
+ bindWindowCanvasInteraction()
|
|
|
+ e.preventDefault()
|
|
|
}
|
|
|
|
|
|
const handleCanvasWheel = (e) => {
|