ソースを参照

feat(photography): 添加场景图上传功能并优化界面布局

- 添加 SceneCard 组件的双向绑定属性 bgPrompt、image 和 mode
- 实现背景图上传和历史记录功能,支持拖拽上传和文件选择
- 集成图像素材管理 API,包括上传、列表获取和删除操作
- 添加场景图生成参数构建函数,支持文字描述和背景图两种模式
- 重构 SceneCard 组件 UI,实现双模式切换和预览功能
- 优化网格布局计算逻辑,支持按比例自适应显示图像
- 调整主界面结构,修复布局错位问题并增强响应式效果
- 更新模型卡片图像显示方式,统一使用 contain 模式展示
- 添加本地缓存机制,保存场景图相关设置和用户偏好
panqiuyao 4 週間 前
親
コミット
fabefe74b4

+ 37 - 1
frontend/src/apis/other.ts

@@ -1,6 +1,12 @@
 import { GET,POST,UPLOAD ,DOWNLOAD  } from "@/utils/http";
 // import type { UserRequest } from "@/apis/types/user";
 
+function defaultSceneMaterialName() {
+    const d = new Date()
+    const pad = (n: number) => String(n).padStart(2, '0')
+    return `场景图_${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
+}
+
 
 // 模板相关
 export async function getCompanyTemplatesApi(){
@@ -26,6 +32,35 @@ export async function expandCameraWordsApi(params: { words: string }){
     return POST('/api/ai_image/main/expand_camera_words', params)
 }
 
+export async function getImageMaterialList(params: { status?: number, type?: number, site?: number, current?: number, size?: number, has_count?: number }){
+    return GET('/api/ai_image/main/image_material_list', {
+        site: 1,
+        current: 1,
+        size: 40,
+        has_count: 1,
+        ...params,
+    }, { loading: false })
+}
+
+export async function uploadImageMaterials(params: { site?: number, image_url: string, material_name?: string, status?: number, type?: number, pre_image_url?: string }){
+    const material_name = (params.material_name || '').trim() || defaultSceneMaterialName()
+    return POST('/api/ai_image/main/upload_image_materials', {
+        site: 1,
+        status: 0,
+        type: 27,
+        ...params,
+        pre_image_url: params.pre_image_url || params.image_url,
+        material_name,
+    })
+}
+
+export async function imageMaterialDelete(params: { id: number | string, site?: number }){
+    return POST('/api/ai_image/main/image_material_delete', {
+        site: 1,
+        ...params,
+    })
+}
+
 
 
 
@@ -38,6 +73,7 @@ export function uploadBaseImg(data,loading=true) {
         loading: loading,
     })
 }
+
 // base64上传图片
 export function uploadImg(data,loading=true) {
     return UPLOAD('/api/upload',data,{
@@ -81,4 +117,4 @@ export async function getVersionDetailByVersion(params: {  role_type: number, ve
 // 获取使用手册
 export async function getCameraMachineDoc(params: { machine_type: number }){
     return GET('/api/index/get_camera_machine_doc', params)
-}
+}

+ 9 - 4
frontend/src/views/Photography/components/ModelCard.vue

@@ -32,7 +32,7 @@
             v-if="imagesReady"
             :src="thumbUrl(model.image_url)"
             :alt="model.name"
-            fit="cover"
+            fit="contain"
             lazy
             :scroll-container="libScrollRef || undefined"
             class="model-img"
@@ -108,7 +108,7 @@
             v-if="imagesReady"
             :src="thumbUrl(model.image_url)"
             :alt="model.name"
-            fit="cover"
+            fit="contain"
             lazy
             :scroll-container="mineScrollRef || undefined"
             class="model-img"
@@ -235,7 +235,7 @@ function modelKind(model: ModelData): 'female' | 'male' | 'child' {
 function thumbUrl(url?: string) {
   if (!url) return ''
   if (/^(data:|blob:|file:)/i.test(url)) return url
-  return ossResize(url, { m: 'fill', w: 280, h: 360 })
+  return ossResize(url, { m: 'lfit', w: 360 })
 }
 
 function genderLabel(model: ModelData) {
@@ -640,7 +640,12 @@ onMounted(() => {
     display: block;
     width: 100%;
     height: 100%;
-    :deep(.el-image__inner),
+    background: #edf2fa;
+    :deep(.el-image__inner) {
+      width: 100%;
+      height: 100%;
+      object-fit: contain;
+    }
     :deep(.el-image__placeholder),
     :deep(.el-image__error),
     :deep(.model-ph) {

+ 552 - 65
frontend/src/views/Photography/components/SceneCard.vue

@@ -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>

+ 7 - 4
frontend/src/views/Photography/composables/useAdaptiveGrid.ts

@@ -8,6 +8,8 @@ interface AdaptiveGridOptions {
   cols?: number
   gap?: number
   getItemCount?: () => number
+  /** 按卡片实际比例计行高,避免竖图被行高裁切 */
+  fitContent?: boolean
 }
 
 export function useAdaptiveGrid(elRef: Ref<HTMLElement | null>, options: AdaptiveGridOptions) {
@@ -19,6 +21,7 @@ export function useAdaptiveGrid(elRef: Ref<HTMLElement | null>, options: Adaptiv
     cols = 2,
     gap = 11,
     getItemCount,
+    fitContent = false,
   } = options
 
   let observer: ResizeObserver | null = null
@@ -31,8 +34,8 @@ export function useAdaptiveGrid(elRef: Ref<HTMLElement | null>, options: Adaptiv
 
     const colW = Math.max(minItemWidth, el.clientWidth / cols - 20)
     const naturalRowH = Math.max(minItemHeight, colW * (minItemHeight / Math.max(minItemWidth, 1)))
-    // 按最小行高尽量多排,把底部空位用成下一行(最多 maxRows)
-    let rows = Math.max(minRows, Math.min(maxRows, Math.floor((h + gap) / (minItemHeight + gap)) || minRows))
+    const rowUnit = fitContent ? naturalRowH : minItemHeight
+    let rows = Math.max(minRows, Math.min(maxRows, Math.floor((h + gap) / (rowUnit + gap)) || minRows))
     const n = getItemCount ? getItemCount() : 0
     const capacity = rows * cols
 
@@ -42,8 +45,8 @@ export function useAdaptiveGrid(elRef: Ref<HTMLElement | null>, options: Adaptiv
       rows = Math.max(1, Math.min(rows, Math.ceil(n / cols)))
     }
     const filledRowH = (h - gap * (rows - 1)) / rows
-    // 铺满一页时用满高度;图少时不把单张拉得过高
-    const rowH = flow === 'row'
+    // 铺满一页时用满高度;图少或需完整显示比例时不把卡片压扁
+    const rowH = (flow === 'row' || fitContent)
       ? Math.max(minItemHeight, Math.min(filledRowH, naturalRowH))
       : Math.max(minItemHeight, filledRowH)
     el.style.setProperty('--row-h', `${Math.round(rowH)}px`)

+ 72 - 16
frontend/src/views/Photography/detail.vue

@@ -40,7 +40,12 @@
             </div>
           </header>
           <div class="fbody">
-            <SceneCard v-model="scenePrompt" />
+            <SceneCard
+              v-model="scenePrompt"
+              v-model:bgPrompt="sceneBgPrompt"
+              v-model:image="sceneImage"
+              v-model:mode="sceneMode"
+            />
           </div>
           <footer class="ffoot"><svg class="ic"><use href="#i-info"/></svg>未选择则不生成场景图,使用白底图</footer>
         </section>
@@ -297,11 +302,10 @@
             <small>将为本次批量选择的所有商品生成</small>
           </button>
         </aside>
-
-        <div class="ybar">
-          <svg class="ic"><use href="#i-info"/></svg>
-          <span>批量生成说明:本次设置将应用于您勾选的<b>所有商品</b>,<b>未打开的模块将不生成相关内容</b></span>
-        </div>
+      </div>
+      <div class="ybar">
+        <svg class="ic"><use href="#i-info"/></svg>
+        <span>批量生成说明:本次设置将应用于您勾选的<b>所有商品</b>,<b>未打开的模块将不生成相关内容</b></span>
       </div>
     </div>
   <loading-dialog v-if="loadingDialogVisible" v-model="loadingDialogVisible" :requesting="requesting"
@@ -900,10 +904,16 @@ const modelDialogDefaultTab = ref<'female' | 'male'>('female')
 const scenePromptDialogVisible = ref(false)
 const selectedModels = ref<{ female: any; male: any; child?: any } | null>({ female: null, male: null, child: null })
 const scenePrompt = ref('')
+const sceneBgPrompt = ref('')
+const sceneImage = ref('')
+const sceneMode = ref<'text' | 'upload'>('text')
+const DEFAULT_BG_SCENE_PROMPT = '把鞋子融合到背景图中'
 
 // 本地缓存键(与弹窗组件保持一致)
 const DETAIL_MODEL_CACHE_KEY = 'model_selection_cache'
 const DETAIL_SCENE_PROMPT_CACHE_KEY = 'scene_prompt_cache'
+const DETAIL_SCENE_BG_PROMPT_CACHE_KEY = 'scene_bg_prompt_cache'
+const DETAIL_SCENE_IMAGE_CACHE_KEY = 'scene_image_cache'
 const detail_logo_cache_1_KEY = 'detail_logo_cache_1'
 const DETAIL_DATA_TYPE_CACHE_KEY = 'detail_data_type_cache'
 const DETAIL_SERVICES_CACHE_KEY = 'detail_services_cache'
@@ -931,6 +941,14 @@ const loadDetailCache = () => {
       console.log(scenePrompt.value);
     }
   } catch { }
+  try {
+    const img = localStorage.getItem(DETAIL_SCENE_IMAGE_CACHE_KEY)
+    if (img) sceneImage.value = img
+  } catch { }
+  try {
+    const bg = localStorage.getItem(DETAIL_SCENE_BG_PROMPT_CACHE_KEY)
+    if (bg) sceneBgPrompt.value = bg
+  } catch { }
 
   // 加载LOGO缓存
   try {
@@ -1004,6 +1022,17 @@ const saveScenePromptToCache = (prompt: string) => {
     if (v) localStorage.setItem(DETAIL_SCENE_PROMPT_CACHE_KEY, v)
   } catch { }
 }
+const saveSceneImageToCache = (url: string) => {
+  try {
+    if (url) localStorage.setItem(DETAIL_SCENE_IMAGE_CACHE_KEY, url)
+    else localStorage.removeItem(DETAIL_SCENE_IMAGE_CACHE_KEY)
+  } catch { }
+}
+const saveSceneBgPromptToCache = (prompt: string) => {
+  try {
+    localStorage.setItem(DETAIL_SCENE_BG_PROMPT_CACHE_KEY, prompt || '')
+  } catch { }
+}
 
 // 保存LOGO到缓存
 const saveLogoToCache = (logoPath: string) => {
@@ -1032,6 +1061,12 @@ const saveServicesToCache = (services: string[]) => {
 watch(scenePrompt, (v) => {
   saveScenePromptToCache(v)
 })
+watch(sceneImage, (v) => {
+  saveSceneImageToCache(v)
+})
+watch(sceneBgPrompt, (v) => {
+  saveSceneBgPromptToCache(v)
+})
 watch(selectedModels, (v) => {
   if (v) saveModelsToCache(v)
 }, { deep: true })
@@ -1271,6 +1306,18 @@ const openOutputDir = (path) => {
   }
 }
 
+function buildSceneSubmitParams() {
+  if (sceneMode.value === 'upload') {
+    return {
+      product_scene_image: sceneImage.value || '',
+      product_scene_prompt: (sceneBgPrompt.value || '').trim() || DEFAULT_BG_SCENE_PROMPT,
+    }
+  }
+  return {
+    product_scene_prompt: scenePrompt.value || '',
+  }
+}
+
 // 检测参数是否有效
 const checkParams = async function () {
   const useConfigInfoStore = configInfo();
@@ -1308,7 +1355,7 @@ const checkParams = async function () {
       women_id: selectedModels.value.female?.id || "",
       child_id: selectedModels.value.child?.id || ""
     } : {},
-    product_scene_prompt: scenePrompt.value || '',
+    ...buildSceneSubmitParams(),
     is_check: 1 // 仅检测,不生成
   }
 
@@ -1360,9 +1407,16 @@ const generate = async function () {
     }
   }
 
-  if (form.services.includes('is_product_scene') && !scenePrompt.value) {
-    ElMessage.error('请设置场景提示词')
-    return
+  if (form.services.includes('is_product_scene')) {
+    if (sceneMode.value === 'upload') {
+      if (!sceneImage.value) {
+        ElMessage.error('请上传或选择背景图')
+        return
+      }
+    } else if (!(scenePrompt.value || '').trim()) {
+      ElMessage.error('请填写场景描述')
+      return
+    }
   }
 
   if (form.services.includes('is_detail') || form.services.includes('is_upper_footer')) {
@@ -1433,7 +1487,7 @@ const generate = async function () {
       women_id: selectedModels.value.female?.id || "",
       child_id: selectedModels.value.child?.id || ""
     } : {},
-    product_scene_prompt: scenePrompt.value || '',
+    ...buildSceneSubmitParams(),
     is_check: 0 // 正式生成
   }
   // 开启进度弹窗
@@ -1807,7 +1861,7 @@ const selectFolder = () => {
 .aigc-board {
   display: grid;
   grid-template-columns: minmax(320px, 0.85fr) minmax(320px, 0.85fr) minmax(400px, 1.5fr) 330px;
-  grid-template-rows: minmax(0, 1fr) auto;
+  grid-template-rows: minmax(0, 1fr);
   gap: 14px;
   align-items: stretch;
   flex: 1;
@@ -2212,13 +2266,11 @@ const selectFolder = () => {
 }
 
 .side {
-  grid-row: 1;
   display: flex;
   flex-direction: column;
   gap: 12px;
   min-width: 330px;
   min-height: 0;
-  height: 100%;
   overflow: auto;
 }
 
@@ -2400,6 +2452,10 @@ const selectFolder = () => {
 
 .cta {
   margin-top: auto;
+  flex: none;
+  position: sticky;
+  bottom: 0;
+  z-index: 2;
   border: 0;
   border-radius: 15px;
   padding: 13px;
@@ -2430,8 +2486,8 @@ const selectFolder = () => {
 }
 
 .ybar {
-  grid-column: 1 / -1;
-  grid-row: 2;
+  flex: none;
+  margin-top: 14px;
   background: #fff6dc;
   border: 1px solid #f1e2ac;
   border-radius: 12px;

+ 1 - 1
public/dist/index.html

@@ -5,7 +5,7 @@
     <link rel="icon" type="image/svg+xml" href="./vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>智惠映AI自动拍照机</title>
-    <script type="module" crossorigin src="./assets/index-Dnby0yP_.js"></script>
+    <script type="module" crossorigin src="./assets/index-wGSb5LOF.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-CUtW5LTp.css">
   </head>
   <body>