|
|
@@ -1,113 +1,408 @@
|
|
|
<template>
|
|
|
<div class="scene-card">
|
|
|
<div class="mode-row">
|
|
|
- <label class="mrad on"><input type="radio" checked disabled>文字描述生成</label>
|
|
|
- <label class="mrad disabled" title="后续版本开放"><input type="radio" disabled>上传背景图合成</label>
|
|
|
+ <label class="mrad" :class="{ on: mode === 'text' }" @click="setMode('text')">
|
|
|
+ <input type="radio" :checked="mode === 'text'" readonly>文字描述生成
|
|
|
+ </label>
|
|
|
+ <label class="mrad" :class="{ on: mode === 'upload' }" @click="setMode('upload')">
|
|
|
+ <input type="radio" :checked="mode === 'upload'" readonly>上传背景图合成
|
|
|
+ </label>
|
|
|
</div>
|
|
|
- <div class="flab">
|
|
|
- <b>场景描述</b>
|
|
|
- <div class="flab-actions">
|
|
|
- <button class="lk" type="button" @click="fillExample" v-log="{ describe: { action: '点击场景示例' } }">示例 ›</button>
|
|
|
- <button class="lk" type="button" :disabled="aiLoading" @click="handleAIHelp" v-log="{ describe: { action: 'AI帮我写场景描述' } }">
|
|
|
- {{ aiLoading ? '生成中…' : 'AI帮我写' }}
|
|
|
- </button>
|
|
|
+
|
|
|
+ <template v-if="mode === 'text'">
|
|
|
+ <div class="flab">
|
|
|
+ <b>场景描述</b>
|
|
|
+ <div class="flab-actions">
|
|
|
+ <button class="lk" type="button" @click="fillExample" v-log="{ describe: { action: '点击场景示例' } }">示例 ›</button>
|
|
|
+ <button class="lk" type="button" :disabled="aiLoading" @click="handleAIHelp" v-log="{ describe: { action: 'AI帮我写场景描述' } }">
|
|
|
+ {{ aiLoading ? '生成中…' : 'AI帮我写' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- <div class="ta">
|
|
|
- <textarea
|
|
|
- :value="modelValue"
|
|
|
- maxlength="500"
|
|
|
- placeholder="请输入场景描述,如:海边沙滩,阳光明媚,清新自然"
|
|
|
- @input="onInput"
|
|
|
- />
|
|
|
- <span class="cnt">{{ (modelValue || '').length }}/500</span>
|
|
|
- </div>
|
|
|
- <div class="flab" style="margin-top: 10px">
|
|
|
- <b>常见场景示意 <small>(点击填入描述)</small></b>
|
|
|
- <span class="hbtns">
|
|
|
- <button class="hbtn" type="button" title="向左" @click="scrollScenes(-1)"><svg><use href="#i-left"/></svg></button>
|
|
|
- <button class="hbtn" type="button" title="向右" @click="scrollScenes(1)"><svg><use href="#i-right"/></svg></button>
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <div class="hscroll" ref="scrollRef">
|
|
|
- <figure
|
|
|
- v-for="item in presets"
|
|
|
- :key="item.key"
|
|
|
- class="scene"
|
|
|
- :class="{ sel: modelValue === item.desc }"
|
|
|
- @click="pickPreset(item)"
|
|
|
+ <div class="ta">
|
|
|
+ <textarea
|
|
|
+ :value="modelValue"
|
|
|
+ maxlength="500"
|
|
|
+ placeholder="请输入场景描述,如:海边沙滩,阳光明媚,清新自然"
|
|
|
+ @input="onInput"
|
|
|
+ />
|
|
|
+ <span class="cnt">{{ (modelValue || '').length }}/500</span>
|
|
|
+ </div>
|
|
|
+ <div class="flab" style="margin-top: 10px">
|
|
|
+ <b>常见场景示意 <small>(点击填入描述)</small></b>
|
|
|
+ <span class="hbtns">
|
|
|
+ <button class="hbtn" type="button" title="向左" @click="scrollGrid(tplScrollRef, -1)"><svg><use href="#i-left"/></svg></button>
|
|
|
+ <button class="hbtn" type="button" title="向右" @click="scrollGrid(tplScrollRef, 1)"><svg><use href="#i-right"/></svg></button>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="hscroll hscroll-portrait" ref="tplScrollRef">
|
|
|
+ <figure
|
|
|
+ v-for="item in templates"
|
|
|
+ :key="item.id"
|
|
|
+ class="scene scene-tpl"
|
|
|
+ :class="{ sel: modelValue === item.keywords }"
|
|
|
+ @click="pickTemplate(item)"
|
|
|
+ >
|
|
|
+ <div class="ph">
|
|
|
+ <el-image :src="thumbUrl(item.pre_image_url, true)" fit="cover" lazy class="ph-img" />
|
|
|
+ <span class="ck">✓</span>
|
|
|
+ </div>
|
|
|
+ <figcaption>{{ item.material_name || item.keywords || '场景' }}</figcaption>
|
|
|
+ </figure>
|
|
|
+ <div v-if="!templates.length" class="empty">暂无场景模板</div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <div
|
|
|
+ v-if="!pendingPreview"
|
|
|
+ class="up bar"
|
|
|
+ @click="pickImage"
|
|
|
+ @dragover.prevent
|
|
|
+ @drop.prevent="onDrop"
|
|
|
>
|
|
|
- <div class="ph" :style="{ background: item.bg }">
|
|
|
- <span class="ph-emoji">{{ item.emoji }}</span>
|
|
|
- <span class="ck">✓</span>
|
|
|
+ <span class="uic"><svg><use href="#i-upl"/></svg></span>
|
|
|
+ <span class="up-copy">
|
|
|
+ <span class="t">上传背景图</span>
|
|
|
+ <span class="s">支持 PNG、JPG,商品将融入场景</span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div v-else class="up-form bar">
|
|
|
+ <div class="up-preview">
|
|
|
+ <el-image :src="pendingPreview" fit="cover" class="up-preview-img" />
|
|
|
</div>
|
|
|
- <figcaption>{{ item.name }}</figcaption>
|
|
|
- </figure>
|
|
|
- </div>
|
|
|
+ <div class="up-form-side">
|
|
|
+ <div class="up-label">确认后将保存到历史上传,并用于场景图生成</div>
|
|
|
+ <div class="up-actions">
|
|
|
+ <button class="gbtn ghost" type="button" :disabled="uploading" @click="resetPending">重新选择</button>
|
|
|
+ <button class="gbtn solid" type="button" :disabled="uploading" @click="confirmUpload">
|
|
|
+ {{ uploading ? '上传中…' : '确认上传' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flab">
|
|
|
+ <b>场景描述 <small>(可选)</small></b>
|
|
|
+ </div>
|
|
|
+ <div class="ta">
|
|
|
+ <textarea
|
|
|
+ :value="bgPrompt"
|
|
|
+ maxlength="500"
|
|
|
+ placeholder="不填则默认:把鞋子融合到背景图中"
|
|
|
+ @input="onBgPromptInput"
|
|
|
+ />
|
|
|
+ <span class="cnt">{{ (bgPrompt || '').length }}/500</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flab">
|
|
|
+ <b>历史上传背景图 <small>(点击选用)</small></b>
|
|
|
+ <span class="hbtns">
|
|
|
+ <button class="hbtn" type="button" title="向左" @click="scrollGrid(histScrollRef, -1)"><svg><use href="#i-left"/></svg></button>
|
|
|
+ <button class="hbtn" type="button" title="向右" @click="scrollGrid(histScrollRef, 1)"><svg><use href="#i-right"/></svg></button>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="hscroll hscroll-portrait" ref="histScrollRef">
|
|
|
+ <figure
|
|
|
+ v-for="item in history"
|
|
|
+ :key="item.id"
|
|
|
+ class="scene"
|
|
|
+ :class="{ sel: isHistorySelected(item) }"
|
|
|
+ @click="pickHistory(item)"
|
|
|
+ >
|
|
|
+ <div class="ph">
|
|
|
+ <el-image :src="thumbUrl(item.pre_image_url || item.image_url, true)" fit="cover" lazy class="ph-img" />
|
|
|
+ <span class="ck">✓</span>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="del"
|
|
|
+ title="删除"
|
|
|
+ :disabled="deletingId === item.id"
|
|
|
+ @click.stop="deleteHistory(item)"
|
|
|
+ >×</button>
|
|
|
+ </div>
|
|
|
+ <figcaption>{{ historyCaption(item) }}</figcaption>
|
|
|
+ </figure>
|
|
|
+ <div v-if="!history.length" class="empty">暂无历史上传</div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <input ref="fileInput" type="file" accept=".jpg,.jpeg,.png" hidden @change="onFilePicked">
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { ref } from 'vue'
|
|
|
-import { ElMessage } from 'element-plus'
|
|
|
-import { expandCameraWordsApi } from '@/apis/other'
|
|
|
+import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
+import {
|
|
|
+ expandCameraWordsApi,
|
|
|
+ getImageMaterialList,
|
|
|
+ uploadImageMaterials,
|
|
|
+ imageMaterialDelete,
|
|
|
+ uploadBaseImg,
|
|
|
+} from '@/apis/other'
|
|
|
+import { ossResize } from '@/utils/oss'
|
|
|
+import { imagesUpload } from '@/utils/appconfig'
|
|
|
+import client from '@/stores/modules/client'
|
|
|
+import icpList from '@/utils/ipc'
|
|
|
import { useAdaptiveGrid } from '../composables/useAdaptiveGrid'
|
|
|
|
|
|
+interface MaterialItem {
|
|
|
+ id: number
|
|
|
+ keywords?: string
|
|
|
+ material_name?: string
|
|
|
+ pre_image_url?: string
|
|
|
+ image_url?: string
|
|
|
+ created_at?: string | number
|
|
|
+ add_time?: string | number
|
|
|
+ create_time?: string | number
|
|
|
+}
|
|
|
+
|
|
|
const props = defineProps<{
|
|
|
modelValue: string
|
|
|
+ bgPrompt?: string
|
|
|
+ image?: string
|
|
|
+ mode?: 'text' | 'upload'
|
|
|
}>()
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
'update:modelValue': [value: string]
|
|
|
+ 'update:bgPrompt': [value: string]
|
|
|
+ 'update:image': [value: string]
|
|
|
+ 'update:mode': [value: 'text' | 'upload']
|
|
|
}>()
|
|
|
|
|
|
+const clientStore = client()
|
|
|
+const mode = ref<'text' | 'upload'>(props.mode || 'text')
|
|
|
+
|
|
|
+function setMode(next: 'text' | 'upload') {
|
|
|
+ mode.value = next
|
|
|
+ emit('update:mode', next)
|
|
|
+}
|
|
|
const aiLoading = ref(false)
|
|
|
-const scrollRef = ref<HTMLElement | null>(null)
|
|
|
-useAdaptiveGrid(scrollRef, {
|
|
|
- minItemWidth: 96,
|
|
|
- minItemHeight: 92,
|
|
|
+const templates = ref<MaterialItem[]>([])
|
|
|
+const history = ref<MaterialItem[]>([])
|
|
|
+const tplScrollRef = ref<HTMLElement | null>(null)
|
|
|
+const histScrollRef = ref<HTMLElement | null>(null)
|
|
|
+const fileInput = ref<HTMLInputElement | null>(null)
|
|
|
+const pendingPreview = ref('')
|
|
|
+const uploading = ref(false)
|
|
|
+const deletingId = ref<number | null>(null)
|
|
|
+
|
|
|
+const sceneGridOpts = {
|
|
|
+ minItemWidth: 84,
|
|
|
+ minItemHeight: 134,
|
|
|
minRows: 1,
|
|
|
- maxRows: 4,
|
|
|
+ maxRows: 3,
|
|
|
cols: 2,
|
|
|
gap: 11,
|
|
|
- getItemCount: () => presets.length,
|
|
|
+}
|
|
|
+
|
|
|
+const { measure: measureTpl } = useAdaptiveGrid(tplScrollRef, {
|
|
|
+ ...sceneGridOpts,
|
|
|
+ getItemCount: () => templates.value.length,
|
|
|
+})
|
|
|
+const { measure: measureHist } = useAdaptiveGrid(histScrollRef, {
|
|
|
+ ...sceneGridOpts,
|
|
|
+ getItemCount: () => history.value.length,
|
|
|
})
|
|
|
|
|
|
const EXAMPLE_TEXT = '海边沙滩,阳光明媚,清新自然,商品置于细软沙滩上'
|
|
|
|
|
|
-const presets = [
|
|
|
- { key: 'beach', name: '海边沙滩', emoji: '🏖️', bg: 'linear-gradient(180deg,#BFE3FF,#F2DFB4)', desc: '海边沙滩,阳光明媚,浪花轻拍,清新度假风' },
|
|
|
- { key: 'forest', name: '森林溪流', emoji: '🌲', bg: 'linear-gradient(180deg,#8CC79F,#35754E)', desc: '森林溪流旁,绿意盎然,光影斑驳,自然清新' },
|
|
|
- { key: 'home', name: '简约家居', emoji: '🛋️', bg: 'linear-gradient(180deg,#F5F0E8,#E7DBC8)', desc: '简约家居客厅,暖色木地板,柔和室内光线' },
|
|
|
- { key: 'bath', name: '浴室场景', emoji: '🚿', bg: 'linear-gradient(180deg,#EAF0F2,#DCE5E8)', desc: '浴室场景,浅色瓷砖,干净通透,生活质感' },
|
|
|
- { key: 'street', name: 'ins风街拍', emoji: '🏙️', bg: 'linear-gradient(180deg,#DED9D2,#9B958E)', desc: 'ins风城市街拍,浅色建筑街景,时尚生活感' },
|
|
|
- { key: 'pool', name: '泳池夏日', emoji: '🏊', bg: 'linear-gradient(180deg,#4EC3E8,#90DFF3)', desc: '夏日泳池边,清凉水面波光,度假氛围' },
|
|
|
- { key: 'villa', name: '度假露台', emoji: '☀️', bg: 'linear-gradient(180deg,#CFE7F7,#D9BC90)', desc: '度假民宿露台,木质地板,绿植与阳光' },
|
|
|
-]
|
|
|
-
|
|
|
function onInput(e: Event) {
|
|
|
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
|
|
|
}
|
|
|
|
|
|
+function onBgPromptInput(e: Event) {
|
|
|
+ emit('update:bgPrompt', (e.target as HTMLTextAreaElement).value)
|
|
|
+}
|
|
|
+
|
|
|
function fillExample() {
|
|
|
emit('update:modelValue', EXAMPLE_TEXT)
|
|
|
}
|
|
|
|
|
|
-function pickPreset(item: (typeof presets)[number]) {
|
|
|
- emit('update:modelValue', item.desc)
|
|
|
+function thumbUrl(url?: string, portrait = false) {
|
|
|
+ if (!url) return ''
|
|
|
+ if (/^(data:|blob:|file:)/i.test(url)) return url
|
|
|
+ return ossResize(url, portrait ? { m: 'fill', w: 280, h: 374 } : { m: 'fill', w: 280, h: 210 })
|
|
|
+}
|
|
|
+
|
|
|
+function pickTemplate(item: MaterialItem) {
|
|
|
+ emit('update:modelValue', item.keywords || item.material_name || '')
|
|
|
+}
|
|
|
+
|
|
|
+function historyImage(item: MaterialItem) {
|
|
|
+ return item.image_url || item.pre_image_url || ''
|
|
|
+}
|
|
|
+
|
|
|
+function isHistorySelected(item: MaterialItem) {
|
|
|
+ const url = historyImage(item)
|
|
|
+ return !!url && props.image === url
|
|
|
}
|
|
|
|
|
|
-function scrollScenes(dir: number) {
|
|
|
- const el = scrollRef.value
|
|
|
+function pickHistory(item: MaterialItem) {
|
|
|
+ emit('update:image', historyImage(item))
|
|
|
+}
|
|
|
+
|
|
|
+function historyCaption(item: MaterialItem) {
|
|
|
+ const t = item.created_at || item.add_time || item.create_time
|
|
|
+ if (!t) return item.material_name || '上传'
|
|
|
+ const d = new Date(typeof t === 'number' ? (t < 1e12 ? t * 1000 : t) : t)
|
|
|
+ if (Number.isNaN(d.getTime())) return '上传'
|
|
|
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
|
|
|
+ const dd = String(d.getDate()).padStart(2, '0')
|
|
|
+ return `${mm}-${dd} 上传`
|
|
|
+}
|
|
|
+
|
|
|
+function scrollGrid(el: HTMLElement | null, dir: number) {
|
|
|
if (!el) return
|
|
|
el.scrollBy({ left: el.clientWidth * 0.9 * dir, behavior: 'smooth' })
|
|
|
}
|
|
|
|
|
|
+function parseList(res: any): MaterialItem[] {
|
|
|
+ const raw = res?.data?.list || res?.data || []
|
|
|
+ return Array.isArray(raw) ? raw : []
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchTemplates() {
|
|
|
+ try {
|
|
|
+ const res: any = await getImageMaterialList({ status: 2, type: 26 })
|
|
|
+ templates.value = parseList(res)
|
|
|
+ nextTick(() => measureTpl())
|
|
|
+ } catch {
|
|
|
+ ElMessage.error('获取场景模板失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchHistory() {
|
|
|
+ try {
|
|
|
+ const res: any = await getImageMaterialList({ status: 0, type: 27 })
|
|
|
+ history.value = parseList(res)
|
|
|
+ nextTick(() => measureHist())
|
|
|
+ } catch {
|
|
|
+ ElMessage.error('获取历史上传失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function isOversize(dataUrl: string) {
|
|
|
+ const b64 = dataUrl.split(',')[1] || ''
|
|
|
+ return (b64.length * 3) / 4 / 1024 > imagesUpload.fileSize
|
|
|
+}
|
|
|
+
|
|
|
+function applyPickedImage(dataUrl: string) {
|
|
|
+ if (!dataUrl) return
|
|
|
+ if (isOversize(dataUrl)) {
|
|
|
+ ElMessage.error('上传文件大小不能超过 20MB')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ pendingPreview.value = dataUrl
|
|
|
+}
|
|
|
+
|
|
|
+function readLocalFile(file: File) {
|
|
|
+ if (!/\.(jpe?g|png)$/i.test(file.name)) {
|
|
|
+ ElMessage.error('请上传 jpg/png 格式图片')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (file.size / 1024 > imagesUpload.fileSize) {
|
|
|
+ ElMessage.error('上传文件大小不能超过 20MB')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const reader = new FileReader()
|
|
|
+ reader.onload = () => applyPickedImage(String(reader.result || ''))
|
|
|
+ reader.readAsDataURL(file)
|
|
|
+}
|
|
|
+
|
|
|
+function pickImage() {
|
|
|
+ if (uploading.value) return
|
|
|
+ if (clientStore.isClient && clientStore.ipc) {
|
|
|
+ clientStore.ipc.removeAllListeners(icpList.utils.openImage)
|
|
|
+ clientStore.ipc.send(icpList.utils.openImage)
|
|
|
+ clientStore.ipc.on(icpList.utils.openImage, (_event: unknown, result: { filePath?: string; base64Image?: string }) => {
|
|
|
+ clientStore.ipc.removeAllListeners(icpList.utils.openImage)
|
|
|
+ if (result?.base64Image) applyPickedImage(result.base64Image)
|
|
|
+ else if (!result?.filePath) return
|
|
|
+ else ElMessage.error('无法读取图片,请重试')
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ fileInput.value?.click()
|
|
|
+}
|
|
|
+
|
|
|
+function onFilePicked(e: Event) {
|
|
|
+ const input = e.target as HTMLInputElement
|
|
|
+ const file = input.files?.[0]
|
|
|
+ input.value = ''
|
|
|
+ if (file) readLocalFile(file)
|
|
|
+}
|
|
|
+
|
|
|
+function onDrop(e: DragEvent) {
|
|
|
+ const file = e.dataTransfer?.files?.[0]
|
|
|
+ if (file) readLocalFile(file)
|
|
|
+}
|
|
|
+
|
|
|
+function resetPending() {
|
|
|
+ pendingPreview.value = ''
|
|
|
+}
|
|
|
+
|
|
|
+async function confirmUpload() {
|
|
|
+ if (!pendingPreview.value) {
|
|
|
+ ElMessage.error('请上传背景图')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ uploading.value = true
|
|
|
+ try {
|
|
|
+ const res: any = await uploadBaseImg({ image: pendingPreview.value })
|
|
|
+ const imageUrl = res?.data?.url || res?.url
|
|
|
+ if (!imageUrl) {
|
|
|
+ ElMessage.error('图片上传失败')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await uploadImageMaterials({
|
|
|
+ site: 1,
|
|
|
+ image_url: imageUrl,
|
|
|
+ pre_image_url: imageUrl,
|
|
|
+ status: 0,
|
|
|
+ type: 27,
|
|
|
+ })
|
|
|
+ ElMessage.success('上传成功')
|
|
|
+ resetPending()
|
|
|
+ emit('update:image', imageUrl)
|
|
|
+ await fetchHistory()
|
|
|
+ } catch {
|
|
|
+ // http 拦截器已提示
|
|
|
+ } finally {
|
|
|
+ uploading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteHistory(item: MaterialItem) {
|
|
|
+ if (deletingId.value) return
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm('是否删除该场景图?', '提示', {
|
|
|
+ confirmButtonText: '删除',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning',
|
|
|
+ })
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ deletingId.value = item.id
|
|
|
+ try {
|
|
|
+ await imageMaterialDelete({ site: 1, id: item.id })
|
|
|
+ if (isHistorySelected(item)) emit('update:image', '')
|
|
|
+ ElMessage.success('删除成功')
|
|
|
+ await fetchHistory()
|
|
|
+ } catch {
|
|
|
+ // http 拦截器已提示
|
|
|
+ } finally {
|
|
|
+ deletingId.value = null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
async function handleAIHelp() {
|
|
|
if (aiLoading.value) return
|
|
|
aiLoading.value = true
|
|
|
try {
|
|
|
- const res: any = await expandCameraWordsApi()
|
|
|
+ const res: any = await expandCameraWordsApi({ words: props.modelValue || EXAMPLE_TEXT })
|
|
|
const result = res?.data?.result || ''
|
|
|
if (result) {
|
|
|
emit('update:modelValue', result)
|
|
|
@@ -121,6 +416,23 @@ async function handleAIHelp() {
|
|
|
aiLoading.value = false
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+watch(() => props.mode, (next) => {
|
|
|
+ if (next && next !== mode.value) mode.value = next
|
|
|
+})
|
|
|
+watch(mode, (next) => {
|
|
|
+ nextTick(() => {
|
|
|
+ if (next === 'text') measureTpl()
|
|
|
+ else measureHist()
|
|
|
+ })
|
|
|
+})
|
|
|
+watch(() => templates.value.length, () => nextTick(measureTpl))
|
|
|
+watch(() => history.value.length, () => nextTick(measureHist))
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ fetchTemplates()
|
|
|
+ fetchHistory()
|
|
|
+})
|
|
|
</script>
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
@@ -147,6 +459,7 @@ async function handleAIHelp() {
|
|
|
font-size: 14.5px;
|
|
|
font-weight: 700;
|
|
|
color: #46516a;
|
|
|
+ cursor: pointer;
|
|
|
input {
|
|
|
appearance: none;
|
|
|
width: 18px;
|
|
|
@@ -155,18 +468,19 @@ async function handleAIHelp() {
|
|
|
border: 1.8px solid #d9dfec;
|
|
|
background: #fff;
|
|
|
flex: none;
|
|
|
+ pointer-events: none;
|
|
|
}
|
|
|
&.on {
|
|
|
color: #3d63f2;
|
|
|
input { border: 5.5px solid #3d63f2; }
|
|
|
}
|
|
|
- &.disabled { opacity: 0.4; cursor: not-allowed; }
|
|
|
}
|
|
|
.flab {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: space-between;
|
|
|
gap: 10px;
|
|
|
+ flex: none;
|
|
|
b {
|
|
|
font-size: 15px;
|
|
|
font-weight: 800;
|
|
|
@@ -294,7 +608,11 @@ async function handleAIHelp() {
|
|
|
transition: all 0.15s;
|
|
|
background: #e9edf5;
|
|
|
}
|
|
|
- .ph-emoji { font-size: 28px; }
|
|
|
+ .ph-img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: block;
|
|
|
+ }
|
|
|
.ck {
|
|
|
position: absolute;
|
|
|
top: 5px;
|
|
|
@@ -308,6 +626,7 @@ async function handleAIHelp() {
|
|
|
display: none;
|
|
|
place-items: center;
|
|
|
box-shadow: 0 2px 6px rgba(23, 43, 99, 0.35);
|
|
|
+ z-index: 2;
|
|
|
}
|
|
|
figcaption {
|
|
|
flex: none;
|
|
|
@@ -317,6 +636,9 @@ async function handleAIHelp() {
|
|
|
color: #46516a;
|
|
|
margin-top: 4px;
|
|
|
line-height: 1.2;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
}
|
|
|
&:hover .ph {
|
|
|
transform: translateY(-2px);
|
|
|
@@ -327,6 +649,28 @@ async function handleAIHelp() {
|
|
|
box-shadow: 0 6px 16px rgba(61, 99, 242, 0.22);
|
|
|
}
|
|
|
&.sel .ck { display: grid; }
|
|
|
+ .del {
|
|
|
+ position: absolute;
|
|
|
+ right: 6px;
|
|
|
+ bottom: 6px;
|
|
|
+ z-index: 3;
|
|
|
+ width: 22px;
|
|
|
+ height: 22px;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(26, 34, 51, 0.62);
|
|
|
+ color: #fff;
|
|
|
+ font-size: 16px;
|
|
|
+ line-height: 20px;
|
|
|
+ cursor: pointer;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ padding: 0;
|
|
|
+ opacity: 0;
|
|
|
+ &:hover { background: #f5303d; }
|
|
|
+ &:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
|
+ }
|
|
|
+ &:hover .del { opacity: 1; }
|
|
|
}
|
|
|
.hscroll > .scene:only-child {
|
|
|
height: auto;
|
|
|
@@ -337,4 +681,147 @@ async function handleAIHelp() {
|
|
|
width: 100%;
|
|
|
aspect-ratio: 96 / 72;
|
|
|
}
|
|
|
+.hscroll-portrait {
|
|
|
+ align-content: start;
|
|
|
+}
|
|
|
+.hscroll-portrait .scene {
|
|
|
+ height: 100%;
|
|
|
+ min-height: 0;
|
|
|
+}
|
|
|
+.hscroll-portrait .scene .ph,
|
|
|
+.hscroll-portrait > .scene:only-child .ph {
|
|
|
+ flex: 1;
|
|
|
+ width: 100%;
|
|
|
+ min-height: 0;
|
|
|
+ aspect-ratio: auto;
|
|
|
+}
|
|
|
+.hscroll-portrait .ph-img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ background: #e9edf5;
|
|
|
+}
|
|
|
+.hscroll-portrait :deep(.el-image__inner) {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+}
|
|
|
+.empty {
|
|
|
+ grid-column: 1 / -1;
|
|
|
+ color: #8b95ab;
|
|
|
+ font-size: 13px;
|
|
|
+ padding: 24px 0;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.up {
|
|
|
+ border: 1.8px dashed #d9dfec;
|
|
|
+ border-radius: 11px;
|
|
|
+ background: #fafbfe;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: flex-start;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ text-align: left;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.15s;
|
|
|
+ width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+ flex: none;
|
|
|
+ min-height: 52px;
|
|
|
+ &:hover {
|
|
|
+ border-color: #3d63f2;
|
|
|
+ background: #f5f8ff;
|
|
|
+ }
|
|
|
+ .uic {
|
|
|
+ width: 32px;
|
|
|
+ height: 32px;
|
|
|
+ border-radius: 8px;
|
|
|
+ display: grid;
|
|
|
+ place-items: center;
|
|
|
+ background: linear-gradient(135deg, #eef2ff, #e3e9ff);
|
|
|
+ color: #3d63f2;
|
|
|
+ flex: none;
|
|
|
+ svg { width: 16px; height: 16px; display: block; }
|
|
|
+ }
|
|
|
+ .up-copy {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 1px;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+ .t { font-size: 13px; font-weight: 700; color: #46516a; }
|
|
|
+ .s { font-size: 11px; color: #8b95ab; }
|
|
|
+}
|
|
|
+.up-form {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ min-width: 0;
|
|
|
+ width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+ flex: none;
|
|
|
+ align-items: stretch;
|
|
|
+ .up-preview {
|
|
|
+ width: 48px;
|
|
|
+ height: 64px;
|
|
|
+ flex: none;
|
|
|
+ }
|
|
|
+ .up-form-side { flex: 1; min-width: 0; }
|
|
|
+}
|
|
|
+.up-preview {
|
|
|
+ border-radius: 11px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #edf2fa;
|
|
|
+ border: 1.5px solid #e7ebf4;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+.up-preview-img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+.up-form-side {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+.up-label {
|
|
|
+ font-size: 12.5px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #46516a;
|
|
|
+ text-align: left;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+.up-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.gbtn {
|
|
|
+ flex: 1;
|
|
|
+ height: 32px;
|
|
|
+ border-radius: 9px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 800;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ &:disabled {
|
|
|
+ opacity: 0.55;
|
|
|
+ cursor: not-allowed;
|
|
|
+ }
|
|
|
+ &.ghost {
|
|
|
+ border: 1.5px solid #3d63f2;
|
|
|
+ color: #3d63f2;
|
|
|
+ background: #fff;
|
|
|
+ &:hover:not(:disabled) { background: #f3f6ff; }
|
|
|
+ }
|
|
|
+ &.solid {
|
|
|
+ border: 0;
|
|
|
+ color: #fff;
|
|
|
+ background: linear-gradient(100deg, #3e7bfa, #7a5af8);
|
|
|
+ &:hover:not(:disabled) { filter: brightness(1.05); }
|
|
|
+ }
|
|
|
+}
|
|
|
</style>
|