Explorar o código

feat(photography): 重构生成页为V8工作台并支持上传删除模特

- 场景/模特/详情三列工作台对齐V8布局,模板分类页签隐藏
- 模特库与我的上传分栏,支持上传、筛选、删除及OSS缩略图
- 网格按屏高自适应行数,右侧露出下一项提示可横滑
- HTTP请求超时统一调整为60秒
panqiuyao hai 14 horas
pai
achega
74b28115ab

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

@@ -8,10 +8,19 @@ export async function getCompanyTemplatesApi(){
 }
 
 // 获取模特列表
-export async function getShoesModelTemplateApi(params: { status: number }){
+export async function getShoesModelTemplateApi(params: { status?: number, is_my?: number, keywords?: string }){
     return GET('/api/ai_image/main/shoes_model_template', params)
 }
 
+// 添加模特(用户上传 status:0;平台推荐 status:2)
+export async function addShoesModelTemplate(params: { status?: number, image_url: string, keywords: string }){
+    return POST('/api/ai_image/main/add_shoes_model_template', params)
+}
+
+export async function delShoesModelTemplate(params: { id: number | string }){
+    return POST('/api/ai_image/main/del_shoes_model_template', params)
+}
+
 // AI 扩写相机场景提示词
 export async function expandCameraWordsApi(params: { words: string }){
     return POST('/api/ai_image/main/expand_camera_words', params)

+ 1 - 1
frontend/src/utils/http.ts

@@ -71,7 +71,7 @@ async function handleTokenExpiry() {
  * 配置了请求拦截器和响应拦截器,支持加载动画和错误提示
  */
 const service = axios.create({
-    timeout: 10000, // 设置请求超时时间
+    timeout: 60000, // 设置请求超时时间
    // baseURL: '__API__',
 });
 console.log('__API__');

+ 872 - 0
frontend/src/views/Photography/components/ModelCard.vue

@@ -0,0 +1,872 @@
+<template>
+  <div class="model-card">
+    <div class="tabs-row">
+      <nav class="tabs">
+        <button type="button" class="tab" :class="{ on: pane === 'lib' }" @click="pane = 'lib'">模特库选择</button>
+        <button type="button" class="tab" :class="{ on: pane === 'upm' }" @click="pane = 'upm'">我的上传</button>
+      </nav>
+    </div>
+
+    <div v-if="pane === 'lib'" class="lib-pane">
+      <div class="chips-row">
+        <div class="chips">
+          <button type="button" class="chip" :class="{ on: gender === 'all' }" @click="gender = 'all'">全部</button>
+          <button type="button" class="chip" :class="{ on: gender === 'male' }" @click="gender = 'male'">男</button>
+          <button type="button" class="chip" :class="{ on: gender === 'female' }" @click="gender = 'female'">女</button>
+          <button type="button" class="chip" :class="{ on: gender === 'child' }" @click="gender = 'child'">儿童</button>
+        </div>
+        <span class="hbtns">
+          <button class="hbtn" type="button" title="向左" @click="scrollGrid(libScrollRef, -1)"><svg><use href="#i-left"/></svg></button>
+          <button class="hbtn" type="button" title="向右" @click="scrollGrid(libScrollRef, 1)"><svg><use href="#i-right"/></svg></button>
+        </span>
+      </div>
+      <div class="hscroll" ref="libScrollRef">
+        <div
+          v-for="model in visibleLibModels"
+          :key="model.id"
+          class="model"
+          :class="{ sel: isSelected(model) }"
+          @click="selectModel(model)"
+        >
+          <el-image
+            v-if="imagesReady"
+            :src="thumbUrl(model.image_url)"
+            :alt="model.name"
+            fit="cover"
+            lazy
+            :scroll-container="libScrollRef || undefined"
+            class="model-img"
+          >
+            <template #placeholder>
+              <div class="model-ph" />
+            </template>
+          </el-image>
+          <div v-else class="model-ph" />
+          <span class="g-tag">{{ genderLabel(model) }}</span>
+          <span class="ck">✓</span>
+        </div>
+        <div v-if="!visibleLibModels.length" class="empty">暂无模特</div>
+      </div>
+    </div>
+
+    <div v-if="pane === 'upm'" class="up-pane">
+      <div
+        v-if="!pendingPreview"
+        class="up"
+        :class="hasMine ? 'bar' : 'fill'"
+        @click="pickImage"
+        @dragover.prevent
+        @drop.prevent="onDrop"
+      >
+        <span class="uic"><svg><use href="#i-upl"/></svg></span>
+        <span class="up-copy">
+          <span class="t">上传我的模特图</span>
+          <span class="s">{{ hasMine ? '继续添加,点击即可作为对应性别模特' : '不满意现有模特?上传即可更换,尺寸不限' }}</span>
+        </span>
+      </div>
+      <div v-else class="up-form" :class="hasMine ? 'bar' : 'fill'">
+        <div class="up-preview">
+          <el-image :src="pendingPreview" fit="cover" class="up-preview-img" />
+        </div>
+        <div class="up-form-side">
+          <div class="up-label">选择类别后确认,将用于对应性别生成</div>
+          <div class="chips">
+            <button type="button" class="chip" :class="{ on: pendingKeywords === '男性' }" @click="pendingKeywords = '男性'">男模特</button>
+            <button type="button" class="chip" :class="{ on: pendingKeywords === '女性' }" @click="pendingKeywords = '女性'">女模特</button>
+            <button type="button" class="chip" :class="{ on: pendingKeywords === '儿童' }" @click="pendingKeywords = '儿童'">儿童</button>
+          </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 v-show="hasMine" class="chips-row">
+        <div class="chips">
+          <button type="button" class="chip" :class="{ on: gender === 'all' }" @click="gender = 'all'">全部</button>
+          <button type="button" class="chip" :class="{ on: gender === 'male' }" @click="gender = 'male'">男</button>
+          <button type="button" class="chip" :class="{ on: gender === 'female' }" @click="gender = 'female'">女</button>
+          <button type="button" class="chip" :class="{ on: gender === 'child' }" @click="gender = 'child'">儿童</button>
+        </div>
+        <span class="hbtns">
+          <button class="hbtn" type="button" title="向左" @click="scrollGrid(mineScrollRef, -1)"><svg><use href="#i-left"/></svg></button>
+          <button class="hbtn" type="button" title="向右" @click="scrollGrid(mineScrollRef, 1)"><svg><use href="#i-right"/></svg></button>
+        </span>
+      </div>
+      <div v-show="hasMine" class="hscroll" ref="mineScrollRef">
+        <div
+          v-for="model in visibleMineModels"
+          :key="model.id"
+          class="model"
+          :class="{ sel: isSelected(model) }"
+          @click="selectModel(model)"
+        >
+          <el-image
+            v-if="imagesReady"
+            :src="thumbUrl(model.image_url)"
+            :alt="model.name"
+            fit="cover"
+            lazy
+            :scroll-container="mineScrollRef || undefined"
+            class="model-img"
+          >
+            <template #placeholder>
+              <div class="model-ph" />
+            </template>
+          </el-image>
+          <div v-else class="model-ph" />
+          <span class="g-tag">{{ genderLabel(model) }}</span>
+          <span class="ck">✓</span>
+          <button
+            type="button"
+            class="del"
+            title="删除"
+            :disabled="deletingId === model.id"
+            @click.stop="deleteMine(model)"
+          >×</button>
+        </div>
+        <div v-if="!visibleMineModels.length" class="empty">该类别暂无上传模特</div>
+      </div>
+    </div>
+    <input
+      ref="fileInput"
+      type="file"
+      accept=".jpg,.jpeg,.png"
+      hidden
+      @change="onFilePicked"
+    >
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, watch, nextTick } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getShoesModelTemplateApi, addShoesModelTemplate, delShoesModelTemplate, 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 ModelData {
+  id: number
+  name: string
+  image_url: string
+  gender?: 'male' | 'female' | 'child'
+  keywords: string
+  status: number
+}
+
+const props = defineProps<{
+  modelValue: { female: any; male: any; child?: any } | null
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: { female: any; male: any; child?: any }]
+}>()
+
+const clientStore = client()
+const pane = ref<'lib' | 'upm'>('lib')
+const gender = ref<'all' | 'male' | 'female' | 'child'>('all')
+const femaleModels = ref<ModelData[]>([])
+const maleModels = ref<ModelData[]>([])
+const childModels = ref<ModelData[]>([])
+const myModels = ref<ModelData[]>([])
+const libScrollRef = ref<HTMLElement | null>(null)
+const mineScrollRef = ref<HTMLElement | null>(null)
+const fileInput = ref<HTMLInputElement | null>(null)
+
+const pendingPreview = ref('')
+const pendingKeywords = ref<'男性' | '女性' | '儿童' | ''>('')
+const uploading = ref(false)
+const deletingId = ref<number | null>(null)
+const imagesReady = ref(false)
+
+const hasMine = computed(() => myModels.value.length > 0)
+
+function filterByGender(list: ModelData[]) {
+  if (gender.value === 'female') return list.filter((model) => model.keywords === '女性')
+  if (gender.value === 'male') return list.filter((model) => model.keywords === '男性')
+  if (gender.value === 'child') return list.filter((model) => model.keywords === '儿童')
+  return list
+}
+
+const visibleLibModels = computed(() => {
+  if (gender.value === 'female') return femaleModels.value
+  if (gender.value === 'male') return maleModels.value
+  if (gender.value === 'child') return childModels.value
+  return [...maleModels.value, ...femaleModels.value, ...childModels.value]
+})
+
+const visibleMineModels = computed(() => filterByGender(myModels.value))
+
+const gridOpts = {
+  minItemWidth: 84,
+  minItemHeight: 108,
+  minRows: 1,
+  maxRows: 4,
+  cols: 2,
+  gap: 11,
+}
+
+const { measure: measureLib } = useAdaptiveGrid(libScrollRef, {
+  ...gridOpts,
+  getItemCount: () => visibleLibModels.value.length,
+})
+const { measure: measureMine } = useAdaptiveGrid(mineScrollRef, {
+  ...gridOpts,
+  getItemCount: () => visibleMineModels.value.length,
+})
+
+function measureActive() {
+  if (pane.value === 'lib') measureLib()
+  else measureMine()
+}
+
+function modelKind(model: ModelData): 'female' | 'male' | 'child' {
+  if (model.keywords === '女性') return 'female'
+  if (model.keywords === '儿童') return 'child'
+  return 'male'
+}
+
+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 })
+}
+
+function genderLabel(model: ModelData) {
+  if (model.keywords === '女性') return '女'
+  if (model.keywords === '儿童') return '童'
+  return '男'
+}
+
+function pickFields(model: ModelData | null) {
+  if (!model) return null
+  return {
+    id: model.id,
+    name: model.name,
+    image_url: model.image_url,
+    gender: model.gender,
+    keywords: model.keywords,
+    status: model.status,
+  }
+}
+
+function isSelected(model: ModelData) {
+  const kind = modelKind(model)
+  return props.modelValue?.[kind]?.id === model.id
+}
+
+function clearIfSelected(model: ModelData) {
+  const kind = modelKind(model)
+  if (props.modelValue?.[kind]?.id !== model.id) return
+  emit('update:modelValue', {
+    female: props.modelValue?.female || null,
+    male: props.modelValue?.male || null,
+    child: props.modelValue?.child || null,
+    [kind]: null,
+  })
+}
+
+async function deleteMine(model: ModelData) {
+  if (deletingId.value) return
+  try {
+    await ElMessageBox.confirm('是否删除该模特?', '提示', {
+      confirmButtonText: '删除',
+      cancelButtonText: '取消',
+      type: 'warning',
+    })
+  } catch {
+    return
+  }
+  deletingId.value = model.id
+  try {
+    await delShoesModelTemplate({ id: model.id })
+    clearIfSelected(model)
+    ElMessage.success('删除成功')
+    await fetchModelList()
+  } catch {
+    // http 拦截器已提示
+  } finally {
+    deletingId.value = null
+  }
+}
+
+function selectModel(model: ModelData) {
+  const next = {
+    female: props.modelValue?.female || null,
+    male: props.modelValue?.male || null,
+    child: props.modelValue?.child || null,
+  }
+  const kind = modelKind(model)
+  next[kind] = pickFields(model)
+  emit('update:modelValue', next)
+}
+
+function scrollGrid(el: HTMLElement | null, dir: number) {
+  if (!el) return
+  const delta = Math.max(el.clientWidth * 0.9, 1) * dir
+  el.scrollBy({ left: delta, behavior: 'smooth' })
+}
+
+function splitByKeywords(list: ModelData[]) {
+  return {
+    female: list.filter((model) => model.keywords === '女性'),
+    male: list.filter((model) => model.keywords === '男性'),
+    child: list.filter((model) => model.keywords === '儿童'),
+  }
+}
+
+async function fetchModelList() {
+  try {
+    const [recommendRes, myRes] = await Promise.all([
+      getShoesModelTemplateApi({ status: 2 }),
+      getShoesModelTemplateApi({ is_my: 1 }),
+    ])
+    const recommend = (recommendRes && recommendRes.data) || []
+    const mine = (myRes && myRes.data) || []
+    const grouped = splitByKeywords(recommend)
+    femaleModels.value = grouped.female
+    maleModels.value = grouped.male
+    childModels.value = grouped.child
+    myModels.value = Array.isArray(mine) ? mine : []
+    nextTick(() => measureActive())
+  } 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 = ''
+  pendingKeywords.value = ''
+}
+
+async function confirmUpload() {
+  if (!pendingPreview.value) {
+    ElMessage.error('请上传模特图')
+    return
+  }
+  if (!pendingKeywords.value) {
+    ElMessage.error('请选择类别')
+    return
+  }
+  uploading.value = true
+  const keywords = pendingKeywords.value
+  try {
+    const res: any = await uploadBaseImg({ image: pendingPreview.value })
+    const imageUrl = res?.data?.url || res?.url
+    if (!imageUrl) {
+      ElMessage.error('图片上传失败')
+      return
+    }
+    await addShoesModelTemplate({
+      status: 0,
+      image_url: imageUrl,
+      keywords,
+    })
+    ElMessage.success('上传成功')
+    resetPending()
+    await fetchModelList()
+    gender.value = keywords === '女性' ? 'female' : keywords === '儿童' ? 'child' : 'male'
+    pane.value = 'upm'
+    const found =
+      myModels.value.find((model) => model.image_url === imageUrl) ||
+      myModels.value.find((model) => model.keywords === keywords)
+    if (found) selectModel(found)
+  } catch {
+    // http 拦截器已提示
+  } finally {
+    uploading.value = false
+  }
+}
+
+async function revealImages() {
+  imagesReady.value = false
+  await nextTick()
+  requestAnimationFrame(() => {
+    imagesReady.value = true
+    measureActive()
+  })
+}
+
+watch(gender, () => {
+  libScrollRef.value?.scrollTo({ left: 0 })
+  mineScrollRef.value?.scrollTo({ left: 0 })
+  nextTick(measureActive)
+})
+watch(pane, () => revealImages())
+watch(() => visibleLibModels.value.length, () => nextTick(measureLib))
+watch(() => visibleMineModels.value.length, () => nextTick(measureMine))
+watch(hasMine, () => nextTick(measureMine))
+
+onMounted(() => {
+  revealImages()
+  fetchModelList()
+})
+</script>
+
+<style scoped lang="scss">
+.model-card {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  min-width: 0;
+  min-height: 0;
+  flex: 1 1 0;
+  overflow: hidden;
+}
+
+.tabs-row {
+  display: flex;
+  align-items: flex-end;
+  gap: 12px;
+  border-bottom: 1.5px solid #e7ebf4;
+  flex: none;
+}
+.tabs {
+  display: flex;
+  gap: 22px;
+  flex: 1;
+  min-width: 0;
+  flex-wrap: wrap;
+}
+.tab {
+  background: none;
+  border: 0;
+  padding: 2px 1px 10px;
+  font-size: 15px;
+  font-weight: 700;
+  color: #46516a;
+  position: relative;
+  white-space: nowrap;
+  cursor: pointer;
+  outline: none;
+  &:focus,
+  &:focus-visible { outline: none; }
+  &.on {
+    color: #3d63f2;
+    &::after {
+      content: "";
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: -1.5px;
+      height: 3px;
+      border-radius: 3px;
+      background: linear-gradient(100deg, #3e7bfa, #7a5af8);
+    }
+  }
+}
+
+.lib-pane,
+.up-pane {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  min-width: 0;
+  min-height: 0;
+  flex: 1 1 0;
+  overflow: hidden;
+}
+
+.chips-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex: none;
+}
+.chips {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  flex: 1;
+  min-width: 0;
+}
+.chip {
+  appearance: none;
+  padding: 5px 14px;
+  border-radius: 999px;
+  border: 1.5px solid #d9dfec;
+  background: #fff;
+  font-size: 13.5px;
+  font-weight: 700;
+  color: #46516a;
+  cursor: pointer;
+  outline: none;
+  box-sizing: border-box;
+  &:hover {
+    border-color: #3d63f2;
+    color: #3d63f2;
+  }
+  &:focus,
+  &:focus-visible {
+    outline: none;
+  }
+  &.on,
+  &.on:hover,
+  &.on:focus,
+  &.on:active {
+    background: #3d63f2;
+    border-color: #3d63f2;
+    color: #fff;
+    box-shadow: 0 4px 12px rgba(61, 99, 242, 0.28);
+  }
+}
+.hbtns { display: flex; gap: 6px; flex: none; }
+.hbtn {
+  width: 27px;
+  height: 27px;
+  border-radius: 50%;
+  border: 1.5px solid #d9dfec;
+  background: #fff;
+  color: #46516a;
+  cursor: pointer;
+  display: grid;
+  place-items: center;
+  padding: 0;
+  svg { width: 13px; height: 13px; display: block; }
+  &:hover {
+    border-color: #3d63f2;
+    color: #3d63f2;
+    background: #f5f8ff;
+  }
+}
+.hscroll {
+  --rows: 2;
+  --cols: 2;
+  --row-h: 140px;
+  display: grid;
+  grid-template-rows: repeat(var(--rows), minmax(0, 1fr));
+  gap: 11px;
+  overflow-x: auto;
+  overflow-y: hidden;
+  overscroll-behavior-x: contain;
+  scroll-snap-type: x mandatory;
+  padding-bottom: 2px;
+  scrollbar-width: none;
+  flex: 1 1 0;
+  min-height: 0;
+  &::-webkit-scrollbar { display: none; }
+  > * { scroll-snap-align: start; min-width: 0; min-height: 0; }
+
+  &[data-flow="row"] {
+    align-content: start;
+    grid-auto-flow: row;
+    grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
+    grid-template-rows: repeat(var(--rows), var(--row-h));
+  }
+  &[data-flow="column"],
+  &:not([data-flow]) {
+    align-content: start;
+    grid-auto-flow: column;
+    grid-auto-columns: calc(100% / var(--cols) - 20px);
+    grid-template-rows: repeat(var(--rows), var(--row-h));
+  }
+}
+.model {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  min-height: 0;
+  border-radius: 11px;
+  overflow: hidden;
+  border: 2px solid transparent;
+  cursor: pointer;
+  background: #edf2fa;
+  transition: all 0.15s;
+  .model-img {
+    display: block;
+    width: 100%;
+    height: 100%;
+    :deep(.el-image__inner),
+    :deep(.el-image__placeholder),
+    :deep(.el-image__error),
+    :deep(.model-ph) {
+      width: 100%;
+      height: 100%;
+    }
+  }
+  .ck {
+    position: absolute;
+    top: 6px;
+    right: 6px;
+    width: 21px;
+    height: 21px;
+    border-radius: 50%;
+    background: #3d63f2;
+    color: #fff;
+    font-size: 12px;
+    display: none;
+    place-items: center;
+    box-shadow: 0 2px 6px rgba(23, 43, 99, 0.35);
+    z-index: 2;
+  }
+  &:hover {
+    transform: translateY(-2px);
+    box-shadow: 0 8px 18px rgba(23, 43, 99, 0.14);
+  }
+  &.sel {
+    border-color: #3d63f2;
+    box-shadow: 0 6px 16px rgba(61, 99, 242, 0.24);
+  }
+  &.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 > .model:only-child {
+  height: auto;
+  aspect-ratio: 84 / 108;
+  align-self: start;
+}
+.model-ph {
+  width: 100%;
+  height: 100%;
+  min-height: 100%;
+  background: linear-gradient(90deg, #e7ebf4 0%, #f5f7fb 45%, #e7ebf4 100%);
+  background-size: 200% 100%;
+  animation: model-ph 1.2s ease infinite;
+}
+.model > .model-ph {
+  position: absolute;
+  inset: 0;
+}
+@keyframes model-ph {
+  0% { background-position: 100% 0; }
+  100% { background-position: -100% 0; }
+}
+.g-tag {
+  position: absolute;
+  left: 6px;
+  top: 6px;
+  z-index: 2;
+  padding: 1px 6px;
+  border-radius: 6px;
+  background: rgba(26, 34, 51, 0.55);
+  color: #fff;
+  font-size: 11px;
+  font-weight: 700;
+  line-height: 16px;
+}
+.empty {
+  grid-column: 1 / -1;
+  color: #8b95ab;
+  font-size: 13px;
+  padding: 24px 0;
+  text-align: center;
+}
+
+.up {
+  border: 1.8px dashed #d9dfec;
+  border-radius: 13px;
+  background: #fafbfe;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  padding: 14px 12px;
+  text-align: center;
+  cursor: pointer;
+  transition: all 0.15s;
+  width: 100%;
+  box-sizing: border-box;
+  &:hover {
+    border-color: #3d63f2;
+    background: #f5f8ff;
+  }
+  .uic {
+    width: 40px;
+    height: 40px;
+    border-radius: 11px;
+    display: grid;
+    place-items: center;
+    background: linear-gradient(135deg, #eef2ff, #e3e9ff);
+    color: #3d63f2;
+    flex: none;
+    svg { width: 21px; height: 21px; display: block; }
+  }
+  .up-copy {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    min-width: 0;
+  }
+  .t { font-size: 14px; font-weight: 700; color: #46516a; }
+  .s { font-size: 12px; color: #8b95ab; }
+  &.fill {
+    flex: 1 1 0;
+    min-height: 0;
+    flex-direction: column;
+  }
+  &.bar {
+    flex: none;
+    min-height: 68px;
+    padding: 12px 14px;
+    flex-direction: row;
+    justify-content: flex-start;
+    text-align: left;
+  }
+}
+
+.up-form {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  min-height: 0;
+  width: 100%;
+  box-sizing: border-box;
+  &.fill {
+    flex: 1 1 0;
+    flex-direction: column;
+    .up-preview { flex: 1 1 0; min-height: 0; }
+    .up-form-side { flex: none; }
+  }
+  &.bar {
+    flex: none;
+    align-items: stretch;
+    .up-preview {
+      width: 92px;
+      height: 118px;
+      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: 36px;
+  border-radius: 11px;
+  font-size: 13.5px;
+  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>

+ 340 - 0
frontend/src/views/Photography/components/SceneCard.vue

@@ -0,0 +1,340 @@
+<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>
+    </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>
+      </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="ph" :style="{ background: item.bg }">
+          <span class="ph-emoji">{{ item.emoji }}</span>
+          <span class="ck">✓</span>
+        </div>
+        <figcaption>{{ item.name }}</figcaption>
+      </figure>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { expandCameraWordsApi } from '@/apis/other'
+import { useAdaptiveGrid } from '../composables/useAdaptiveGrid'
+
+const props = defineProps<{
+  modelValue: string
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: string]
+}>()
+
+const aiLoading = ref(false)
+const scrollRef = ref<HTMLElement | null>(null)
+useAdaptiveGrid(scrollRef, {
+  minItemWidth: 96,
+  minItemHeight: 92,
+  minRows: 1,
+  maxRows: 4,
+  cols: 2,
+  gap: 11,
+  getItemCount: () => presets.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 fillExample() {
+  emit('update:modelValue', EXAMPLE_TEXT)
+}
+
+function pickPreset(item: (typeof presets)[number]) {
+  emit('update:modelValue', item.desc)
+}
+
+function scrollScenes(dir: number) {
+  const el = scrollRef.value
+  if (!el) return
+  el.scrollBy({ left: el.clientWidth * 0.9 * dir, behavior: 'smooth' })
+}
+
+async function handleAIHelp() {
+  if (aiLoading.value) return
+  aiLoading.value = true
+  try {
+    const res: any = await expandCameraWordsApi()
+    const result = res?.data?.result || ''
+    if (result) {
+      emit('update:modelValue', result)
+      ElMessage.success('AI 已为您生成场景提示词')
+    } else {
+      ElMessage.error('AI 未返回结果,请稍后重试')
+    }
+  } catch {
+    ElMessage.error('AI 生成失败,请重试')
+  } finally {
+    aiLoading.value = false
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.scene-card {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  min-width: 0;
+  min-height: 0;
+  flex: 1;
+  overflow: hidden;
+}
+.mode-row {
+  display: flex;
+  align-items: center;
+  gap: 22px;
+  border-bottom: 1.5px solid #e7ebf4;
+  padding-bottom: 10px;
+}
+.mrad {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  font-size: 14.5px;
+  font-weight: 700;
+  color: #46516a;
+  input {
+    appearance: none;
+    width: 18px;
+    height: 18px;
+    border-radius: 50%;
+    border: 1.8px solid #d9dfec;
+    background: #fff;
+    flex: 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;
+  b {
+    font-size: 15px;
+    font-weight: 800;
+    small {
+      font-weight: 600;
+      color: #8b95ab;
+      font-size: 12px;
+    }
+  }
+}
+.flab-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.lk {
+  background: none;
+  border: 0;
+  color: #3d63f2;
+  font-size: 13px;
+  font-weight: 700;
+  padding: 0;
+  cursor: pointer;
+  &:hover { text-decoration: underline; }
+}
+.ta {
+  position: relative;
+  textarea {
+    width: 100%;
+    min-height: 86px;
+    resize: vertical;
+    border: 1.5px solid #d9dfec;
+    border-radius: 11px;
+    padding: 10px 13px 26px;
+    font-size: 14px;
+    line-height: 1.6;
+    color: #1a2233;
+    background: #fbfcfe;
+    box-sizing: border-box;
+    font-family: inherit;
+    &:focus {
+      outline: none;
+      border-color: #3d63f2;
+      box-shadow: 0 0 0 3px rgba(61, 99, 242, 0.13);
+      background: #fff;
+    }
+  }
+  .cnt {
+    position: absolute;
+    right: 12px;
+    bottom: 9px;
+    font-size: 12px;
+    color: #8b95ab;
+    pointer-events: none;
+  }
+}
+.hbtns { display: flex; gap: 6px; flex: none; }
+.hbtn {
+  width: 27px;
+  height: 27px;
+  border-radius: 50%;
+  border: 1.5px solid #d9dfec;
+  background: #fff;
+  color: #46516a;
+  cursor: pointer;
+  display: grid;
+  place-items: center;
+  padding: 0;
+  svg { width: 13px; height: 13px; display: block; }
+  &:hover {
+    border-color: #3d63f2;
+    color: #3d63f2;
+    background: #f5f8ff;
+  }
+}
+.hscroll {
+  --rows: 2;
+  --cols: 2;
+  --row-h: 120px;
+  display: grid;
+  grid-template-rows: repeat(var(--rows), minmax(0, 1fr));
+  gap: 11px;
+  overflow-x: auto;
+  overflow-y: hidden;
+  overscroll-behavior-x: contain;
+  scroll-snap-type: x mandatory;
+  padding-bottom: 2px;
+  scrollbar-width: none;
+  min-height: 0;
+  flex: 1;
+  &::-webkit-scrollbar { display: none; }
+  > * { scroll-snap-align: start; min-width: 0; min-height: 0; }
+
+  &[data-flow="row"] {
+    align-content: start;
+    grid-auto-flow: row;
+    grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
+    grid-template-rows: repeat(var(--rows), var(--row-h));
+  }
+  &[data-flow="column"],
+  &:not([data-flow]) {
+    align-content: start;
+    grid-auto-flow: column;
+    grid-auto-columns: calc(100% / var(--cols) - 20px);
+    grid-template-rows: repeat(var(--rows), var(--row-h));
+  }
+}
+.scene {
+  position: relative;
+  cursor: pointer;
+  margin: 0;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+  .ph {
+    position: relative;
+    flex: 1;
+    min-height: 0;
+    border-radius: 11px;
+    overflow: hidden;
+    border: 2px solid transparent;
+    display: grid;
+    place-items: center;
+    transition: all 0.15s;
+    background: #e9edf5;
+  }
+  .ph-emoji { font-size: 28px; }
+  .ck {
+    position: absolute;
+    top: 5px;
+    right: 5px;
+    width: 21px;
+    height: 21px;
+    border-radius: 50%;
+    background: #3d63f2;
+    color: #fff;
+    font-size: 11px;
+    display: none;
+    place-items: center;
+    box-shadow: 0 2px 6px rgba(23, 43, 99, 0.35);
+  }
+  figcaption {
+    flex: none;
+    text-align: center;
+    font-size: 13px;
+    font-weight: 600;
+    color: #46516a;
+    margin-top: 4px;
+    line-height: 1.2;
+  }
+  &:hover .ph {
+    transform: translateY(-2px);
+    box-shadow: 0 8px 18px rgba(23, 43, 99, 0.14);
+  }
+  &.sel .ph {
+    border-color: #3d63f2;
+    box-shadow: 0 6px 16px rgba(61, 99, 242, 0.22);
+  }
+  &.sel .ck { display: grid; }
+}
+.hscroll > .scene:only-child {
+  height: auto;
+  align-self: start;
+}
+.hscroll > .scene:only-child .ph {
+  flex: none;
+  width: 100%;
+  aspect-ratio: 96 / 72;
+}
+</style>

+ 81 - 0
frontend/src/views/Photography/composables/useAdaptiveGrid.ts

@@ -0,0 +1,81 @@
+import { nextTick, onBeforeUnmount, onMounted, watch, type Ref } from 'vue'
+
+interface AdaptiveGridOptions {
+  minItemWidth: number
+  minItemHeight: number
+  minRows?: number
+  maxRows?: number
+  cols?: number
+  gap?: number
+  getItemCount?: () => number
+}
+
+export function useAdaptiveGrid(elRef: Ref<HTMLElement | null>, options: AdaptiveGridOptions) {
+  const {
+    minItemWidth,
+    minItemHeight,
+    minRows = 1,
+    maxRows = 4,
+    cols = 2,
+    gap = 11,
+    getItemCount,
+  } = options
+
+  let observer: ResizeObserver | null = null
+
+  function measure() {
+    const el = elRef.value
+    if (!el) return
+    const h = el.clientHeight
+    if (h < 20) return
+
+    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 n = getItemCount ? getItemCount() : 0
+    const capacity = rows * cols
+
+    // 数量填不满当前行列时,改成从左到右排,并收行数,避免第一列竖着堆
+    const flow = n > 0 && n <= capacity ? 'row' : 'column'
+    if (flow === 'row' && n > 0) {
+      rows = Math.max(1, Math.min(rows, Math.ceil(n / cols)))
+    }
+    const filledRowH = (h - gap * (rows - 1)) / rows
+    // 铺满一页时用满高度;图少时不把单张拉得过高
+    const rowH = flow === 'row'
+      ? Math.max(minItemHeight, Math.min(filledRowH, naturalRowH))
+      : Math.max(minItemHeight, filledRowH)
+    el.style.setProperty('--row-h', `${Math.round(rowH)}px`)
+
+    el.style.setProperty('--rows', String(rows))
+    el.style.setProperty('--cols', String(cols))
+    el.setAttribute('data-flow', flow)
+  }
+
+  onMounted(() => {
+    observer = new ResizeObserver(() => measure())
+    if (elRef.value) observer.observe(elRef.value)
+    measure()
+    nextTick(() => {
+      measure()
+      requestAnimationFrame(measure)
+    })
+    window.addEventListener('resize', measure)
+  })
+
+  watch(elRef, (el, prev) => {
+    if (observer) {
+      if (prev) observer.unobserve(prev)
+      if (el) observer.observe(el)
+    }
+    nextTick(measure)
+  })
+
+  onBeforeUnmount(() => {
+    observer?.disconnect()
+    window.removeEventListener('resize', measure)
+  })
+
+  return { measure }
+}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1011 - 421
frontend/src/views/Photography/detail.vue


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio