ソースを参照

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

This reverts commit 74b28115ab06c7425b230754900144b6ecd1b40b.

冲突文件 detail.vue 已还原为 V8 之前的生成页,并保留 feat(setting) 超级权限开关的 isHLM 判断。
panqiuyao 1 ヶ月 前
親
コミット
b0369f8cab

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

@@ -8,19 +8,10 @@ export async function getCompanyTemplatesApi(){
 }
 
 // 获取模特列表
-export async function getShoesModelTemplateApi(params: { status?: number, is_my?: number, keywords?: string }){
+export async function getShoesModelTemplateApi(params: { status: number }){
     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: 60000, // 设置请求超时时间
+    timeout: 10000, // 设置请求超时时间
    // baseURL: '__API__',
 });
 console.log('__API__');

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

@@ -1,872 +0,0 @@
-<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>

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

@@ -1,340 +0,0 @@
-<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>

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

@@ -1,81 +0,0 @@
-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 }
-}

ファイルの差分が大きいため隠しています
+ 421 - 1011
frontend/src/views/Photography/detail.vue


この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません