feat(web): 前端 SPA 化 + 工具页任务面板统一与历史批量删除

- SPA 化:22 个 MPA html 入口与 *-main.ts 合并为 index.html + vue-router(URL 无 .html 后缀),
  页面跳转全部 router-link,/new_web_source/xxx.html 旧路径归一为 /xxx
- 任务面板统一:共享 TaskCenterPanel/TaskItemCard/TaskStatCards/HistoryTaskLayer,
  16 个工具页右侧统一为统计卡 + 当前任务 + 历史任务弹层(任务ID/开始/结束/状态必展示)
- 历史记录支持单条删除 + 批量勾选删除(确认框/全选/失败提示)
- Java 7 模块(dedupe/convert/split/productrisk/shopmatch/pricetrack/deletebrand)
  history 接口补齐任务时间字段(VO+Service,复用 biz_file_task 列)
- 图片工作台/API 层(brand/permission/user)既有未提交改动一并提交
This commit is contained in:
2026-09-08 10:02:55 +08:00
parent abcfa5bef7
commit e248b6e43a
125 changed files with 7482 additions and 4304 deletions
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import './index.css'
import { STORAGE_API_KEY, STORAGE_AUTO_SAVE_PATH } from './workbench-shared'
@@ -12,6 +13,8 @@ import ClothingDetailPanel from './components/panels/ClothingDetailPanel.vue'
import ExtremeDetailPanel from './components/panels/ExtremeDetailPanel.vue'
import CloneDetailPanel from './components/panels/CloneDetailPanel.vue'
const router = useRouter()
interface BuildResult {
params?: Record<string, unknown>
error?: string
@@ -446,7 +449,7 @@ async function callGenerate(rawParams: Record<string, unknown>) {
signal: currentAbortController.signal,
})
if (resp.status === 401) {
window.location.href = '/login'
router.replace('/login')
return
}
result = await resp.json()
@@ -595,7 +598,7 @@ async function loadHistoryPage(append: boolean) {
try {
const resp = await fetch(url, { credentials: 'same-origin' })
if (resp.status === 401) {
window.location.href = '/login'
router.replace('/login')
return
}
const data = await resp.json()
@@ -1182,7 +1185,7 @@ loadHistoryPage(false)
<div class="logo-area">
<img class="logo" :src="logoUrl" alt="logo" />
<span class="app-name">数富AI</span>
<a href="/home" class="btn-home" title="返回首页"> 返回首页</a>
<router-link to="/home" class="btn-home" title="返回首页"> 返回首页</router-link>
</div>
<nav class="nav-tabs">
<span class="nav-tab-group">
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref } from 'vue'
import {
ALLOWED_IMAGE_TYPES,
pasteClipboardImages,
readFilesAsDataUrls,
} from '../image-utils'
const props = withDefaults(
defineProps<{
max: number
title?: string
hint?: string
zoneHint?: string
zonePrefix?: string
/** none=无粘贴 | row=标题行右侧粘贴按钮 | below=标题下独立粘贴按钮 */
pastePos?: 'none' | 'row' | 'below'
}>(),
{
hint: '',
zoneHint: '支持多选/拖拽上传',
zonePrefix: '点击上传图片',
pastePos: 'none',
},
)
const model = defineModel<string[]>({ required: true })
const fileInputRef = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
function choose() {
fileInputRef.value?.click()
}
function onChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files?.length) void addFiles(input.files)
input.value = ''
}
async function addFiles(files: Blob[] | FileList) {
const allowed = Array.from(files).filter((f) => ALLOWED_IMAGE_TYPES.includes(f.type))
if (!allowed.length) return
const remaining = Math.max(0, props.max - model.value.length)
if (!remaining) return
const urls = await readFilesAsDataUrls(allowed.slice(0, remaining))
model.value = model.value.concat(urls).slice(0, props.max)
}
function removeAt(i: number) {
const next = model.value.slice()
next.splice(i, 1)
model.value = next
}
async function pasteImages() {
const images = await pasteClipboardImages()
if (images.length) void addFiles(images)
}
function onDrop(e: DragEvent) {
dragging.value = false
if (e.dataTransfer?.files?.length) void addFiles(e.dataTransfer.files)
}
</script>
<template>
<div class="option-group">
<div v-if="title" class="section-title-row">
<span class="section-title">{{ title }}</span>
<button
v-if="pastePos === 'row'"
type="button"
class="btn-paste-alone btn-paste-right"
@click="pasteImages"
>
<span>📋</span>
粘贴图片
</button>
</div>
<button
v-if="pastePos === 'below'"
type="button"
class="btn-paste-alone"
@click="pasteImages"
>
<span>📋</span>
粘贴图片
</button>
<div v-if="hint" class="hint-text">{{ hint }}</div>
<input
ref="fileInputRef"
type="file"
accept=".jpg,.jpeg,.png,.bmp"
multiple
hidden
@change="onChange"
/>
<div
v-if="model.length < max"
class="upload-zone"
:class="{ 'drag-over': dragging }"
@click="choose"
@dragover.prevent
@dragenter.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<div class="upload-zone-icon"></div>
<div class="upload-zone-text">{{ zonePrefix }} ({{ model.length }}/{{ max }})</div>
<div v-if="zoneHint" class="upload-zone-hint">{{ zoneHint }}</div>
</div>
<div v-if="model.length > 0" class="upload-preview product-detail-preview">
<div v-for="(u, i) in model" :key="i" class="upload-preview-item">
<img :src="u" :alt="'预览' + (i + 1)" />
<button class="remove-btn" type="button" @click.stop="removeAt(i)">×</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import ImageListField from './ImageListField.vue'
import { modelImages } from '../workbench-shared'
</script>
<template>
<ImageListField
v-model="modelImages"
:max="5"
title="多模特图上传(最多5张)"
hint="支持JPG PNG BMP JPEG"
:paste-pos="'none'"
:zone-hint="''"
zone-prefix="点击上传"
/>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ALLOWED_IMAGE_TYPES, readFilesAsDataUrls } from '../image-utils'
const props = withDefaults(
defineProps<{
title?: string
hint?: string
zoneText?: string
accept?: string
}>(),
{
title: '',
hint: '',
zoneText: '点击上传',
accept: '.jpg,.jpeg,.png,.bmp',
},
)
const model = defineModel<string | null>({ default: null })
const fileInputRef = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
function choose() {
fileInputRef.value?.click()
}
function onChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) void setFile(file)
input.value = ''
}
async function setFile(file: File) {
if (props.accept.startsWith('video')) {
if (!file.type.startsWith('video/')) return
} else if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
return
}
const urls = await readFilesAsDataUrls([file])
model.value = urls[0] ?? null
}
function clearFile() {
model.value = null
}
function onDrop(e: DragEvent) {
dragging.value = false
const file = e.dataTransfer?.files?.[0]
if (file) void setFile(file)
}
</script>
<template>
<div class="option-group">
<div v-if="title" class="section-title">{{ title }}</div>
<div v-if="hint" class="hint-text">{{ hint }}</div>
<input
ref="fileInputRef"
type="file"
:accept="accept"
hidden
@change="onChange"
/>
<div
v-if="!model"
class="upload-zone"
:class="{ 'drag-over': dragging }"
@click="choose"
@dragover.prevent
@dragenter.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<div class="upload-zone-icon"></div>
<div class="upload-zone-text">{{ zoneText }}</div>
</div>
<div v-else class="upload-preview product-detail-preview">
<div v-if="accept.startsWith('video')" class="upload-preview-item" style="grid-column: 1/-1;">
<span style="color: #888;">已选视频</span>
<button class="remove-btn" type="button" @click="clearFile">×</button>
</div>
<div v-else class="upload-preview-item">
<img :src="model" alt="预览" />
<button class="remove-btn" type="button" @click="clearFile">×</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { watch } from 'vue'
const props = defineProps<{ count: number }>()
const model = defineModel<string[]>({ default: [] })
function resize(n: number) {
const safeN = Math.max(0, Math.min(50, Math.max(1, Number(n) || 1)))
const vals = Array.isArray(model.value) ? model.value : []
const next: string[] = []
for (let i = 0; i < safeN; i++) next.push(i < vals.length ? String(vals[i] ?? '') : '')
model.value = next
}
watch(
() => props.count,
(n) => resize(n),
{ immediate: true },
)
</script>
<template>
<div class="specify-screen-list">
<div v-for="(_, i) in model" :key="i" class="specify-screen-item">
<label>{{ i + 1 }}屏文案 (可选)</label>
<input
v-model="model[i]"
type="text"
class="form-input"
:data-screen-index="i + 1"
placeholder="如:高颜值外观,一眼心动"
/>
</div>
</div>
</template>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import SpecifyTextList from '../SpecifyTextList.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { RATIO_OPTIONS_AUTO, RES_OPTIONS, collectSpecify } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'CloneDetailPanel' })
const productName = ref('')
const features = ref('')
const cloneMode = ref<'domestic' | 'amazon' | 'specify'>('domestic')
const ratio = ref('auto')
const res = ref('2k')
const language = ref('')
const procImages = ref<string[]>([])
const refImages = ref<string[]>([])
const specify = ref<string[]>([])
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (!productName.value.trim()) return { error: '请输入产品名称' }
if (procImages.value.length === 0) return { error: '请上传产品实拍图' }
if (refImages.value.length === 0) return { error: '请上传克隆参考图' }
const modeMap = { domestic: '1', amazon: '2', specify: '3' } as const
const params: Record<string, unknown> = {
menu: 10,
name: productName.value.trim(),
desc: features.value.trim(),
ratio: ratio.value,
resolution: res.value,
language: language.value.trim() || '中文',
mode: modeMap[cloneMode.value],
proc_images: procImages.value.slice(),
ref_images: refImages.value.slice(),
}
if (modelImages.value.length) params.model_images = modelImages.value.slice()
if (cloneMode.value === 'specify') params.text = collectSpecify(specify.value)
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">产品名称 (必填)</label>
<input v-model="productName" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
<textarea
v-model="features"
class="form-textarea"
rows="4"
placeholder="如: 1:保湿效果好 2:适合敏感肌 3:价格实惠"
></textarea>
</div>
<div class="option-group">
<div class="section-title">克隆模式</div>
<div class="btn-group">
<button class="opt-btn" :class="{ active: cloneMode === 'domestic' }" @click="cloneMode = 'domestic'">国内模式</button>
<button class="opt-btn" :class="{ active: cloneMode === 'amazon' }" @click="cloneMode = 'amazon'">亚马逊模式</button>
<button class="opt-btn" :class="{ active: cloneMode === 'specify' }" @click="cloneMode = 'specify'">指定文案</button>
</div>
</div>
<div v-if="cloneMode === 'specify'" class="option-group">
<label class="section-title">每张参考图对应文案 (可选)</label>
<SpecifyTextList v-model="specify" :count="refImages.length" />
</div>
<div class="option-group">
<div class="section-title">画幅比例</div>
<div class="btn-group btn-group-ratio-grid">
<button
v-for="r in RATIO_OPTIONS_AUTO"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
<div class="hint-text">💡 克隆模式强烈建议使用auto自动比例</div>
</div>
<div class="option-group">
<div class="section-title">分辨率 (RESOLUTION)</div>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
<input v-model="language" type="text" class="form-input" placeholder="例如: 中文输出、英文输出、中英混合" />
</div>
<ImageListField v-model="procImages" :max="6" title="产品实拍图" :paste-pos="'row'" />
<ImageListField v-model="refImages" :max="14" title="克隆参考图" :paste-pos="'row'" />
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import SingleImageField from '../SingleImageField.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'ClonePosterPanel' })
const mainTitle = ref('')
const subtitle = ref('')
const ratio = ref('16:9')
const res = ref('2k')
const layoutImage = ref<string | null>(null)
const images = ref<string[]>([])
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (!layoutImage.value) return { error: '请上传版式图片(1张)' }
if (images.value.length === 0) return { error: '请上传图片(最多6张)' }
const params: Record<string, unknown> = {
menu: 4,
name: mainTitle.value.trim() || '产品',
desc: subtitle.value.trim(),
ratio: ratio.value,
resolution: res.value,
count: 1,
layout_image: layoutImage.value,
ref_images: images.value.slice(),
}
if (modelImages.value.length) params.model_images = modelImages.value.slice()
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">主标题</label>
<input v-model="mainTitle" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">副标题</label>
<textarea
v-model="subtitle"
class="form-textarea"
rows="4"
placeholder="如: 1: 保湿效果好 2: 适合敏感肌 3: 价格实惠"
></textarea>
</div>
<div class="option-group">
<label class="section-title">画幅比例</label>
<div class="btn-group btn-group-ratio-grid btn-group-ratio-4col">
<button
v-for="r in RATIO_OPTIONS"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">分辨率 (RESOLUTION)</label>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<SingleImageField v-model="layoutImage" title="上传版式 (1张)" zone-text="上传版式" />
<ImageListField v-model="images" :max="6" title="上传图片 (最多6张)" />
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { COUNT_DETAIL, RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'ClothingDetailPanel' })
const productName = ref('')
const features = ref('')
const ratio = ref('16:9')
const res = ref('2k')
const style = ref('')
const language = ref('')
const count = ref(9)
const images = ref<string[]>([])
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (!productName.value.trim()) return { error: '请输入产品名称' }
if (images.value.length === 0) return { error: '请上传服装细节图片' }
const params: Record<string, unknown> = {
menu: 5,
name: productName.value.trim(),
desc: features.value.trim(),
ratio: ratio.value,
resolution: res.value,
count: count.value,
style: style.value || '极简高级',
language: language.value.trim() || '中文',
ref_images: images.value.slice(),
}
if (modelImages.value.length) params.model_images = modelImages.value.slice()
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">产品名称(必填)</label>
<input v-model="productName" type="text" class="form-input" placeholder="如:口红吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
<textarea
v-model="features"
class="form-textarea"
rows="4"
placeholder="如:1:保湿效果好 2:适合敏感肌 3:价格实惠"
></textarea>
</div>
<div class="option-group">
<label class="section-title">画幅比例</label>
<div class="btn-group">
<button
v-for="r in RATIO_OPTIONS"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">分辨率 (RESOLUTION)</label>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">风格选择 <span class="title-icon"></span></label>
<input v-model="style" class="form-select" list="styleList_clothes" placeholder="-- 选择或输入风格 --" />
<datalist id="styleList_clothes">
<option value="极简高级"></option>
<option value="时尚潮流"></option>
<option value="复古颗粒"></option>
<option value="网红外拍"></option>
<option value="手机自拍"></option>
</datalist>
</div>
<div class="option-group">
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
<input v-model="language" type="text" class="form-input" placeholder="例如:中文输出、英文输出、中英混合" />
</div>
<div class="option-group">
<label class="section-title">生成张数 <span class="title-icon">🖼</span></label>
<div class="btn-group">
<button
v-for="c in COUNT_DETAIL"
:key="c"
class="opt-btn"
:class="{ active: count === c }"
@click="count = c"
>
{{ c }}
</button>
</div>
</div>
<ImageListField
v-model="images"
:max="8"
title="服装细节(至少1张)"
:paste-pos="'below'"
/>
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import SpecifyTextList from '../SpecifyTextList.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { COUNT_DETAIL, RATIO_OPTIONS, RES_OPTIONS, collectSpecify } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'ExtremeDetailPanel' })
const DEFAULT_MANUAL = `1. A类母婴级纯棉: 软糯透气, 呵护娇嫩敏感肌
2. 加宽腰头无痕边: 不勒肚不卡档, 自在无束缚
3. 0荧光0甲醛: 严守安全标准, 贴身穿着才安心
4. 萌趣卡通印花: 色彩清新柔和, 孩子一眼就爱
5. 立体透气抑菌档: 吸湿排汗强, 干爽舒适不闷
6. 高弹面料不松垮: 耐洗不易变形, 久穿不起球
7. 2-12岁全尺码: 剪裁贴合身形, 舒适贴合不紧
8. 弹力平角不卡腿: 穿脱顺畅方便, 孩子自己穿
9. 多种花色随心选: 满足日常替换, 天天不重样`
const productName = ref('')
const featureMode = ref<'auto' | 'manual' | 'specify'>('manual')
const manualText = ref(DEFAULT_MANUAL)
const ratio = ref('3:4')
const res = ref('2k')
const styleDesc = ref('')
const language = ref('')
const count = ref(9)
const images = ref<string[]>([])
const specify = ref<string[]>([])
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (!productName.value.trim()) return { error: '请输入产品名称' }
if (images.value.length === 0) return { error: '请上传参考图片' }
const modeMap = { auto: '1', manual: '1', specify: '3' } as const
let desc = ''
if (featureMode.value === 'manual') desc = manualText.value.trim()
const params: Record<string, unknown> = {
menu: 8,
name: productName.value.trim(),
desc,
style: styleDesc.value.trim() || '极简高级',
language: language.value.trim() || '中文',
ratio: ratio.value,
resolution: res.value,
count: count.value,
mode: modeMap[featureMode.value],
ref_images: images.value.slice(),
}
if (modelImages.value.length) params.model_images = modelImages.value.slice()
if (featureMode.value === 'specify') params.text = collectSpecify(specify.value)
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">产品名称(必填)</label>
<input v-model="productName" type="text" class="form-input" placeholder="如: 口红 吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
<div class="feature-mode-tabs btn-group">
<button class="opt-btn" :class="{ active: featureMode === 'auto' }" @click="featureMode = 'auto'">自动模式</button>
<button class="opt-btn" :class="{ active: featureMode === 'manual' }" @click="featureMode = 'manual'">手动模式</button>
<button class="opt-btn" :class="{ active: featureMode === 'specify' }" @click="featureMode = 'specify'">指定文案</button>
</div>
<div v-if="featureMode === 'manual'" class="feature-mode-content">
<div class="feature-example-label">示例2:</div>
<textarea
v-model="manualText"
class="form-textarea"
rows="10"
placeholder="每行一个特点,格式如:1. A类母婴级纯棉: 软糯透气, 呵护娇嫩敏感肌"
></textarea>
</div>
<div v-if="featureMode === 'specify'" class="feature-mode-content">
<SpecifyTextList v-model="specify" :count="count" />
</div>
</div>
<div class="option-group">
<label class="section-title">画幅比例 (ASPECT RATIO)</label>
<div class="btn-group">
<button
v-for="r in RATIO_OPTIONS"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">风格和功能卖点描述</label>
<input v-model="styleDesc" type="text" class="form-input" placeholder="例如:高级质感、极简主义、奢华轻奢" />
</div>
<div class="option-group">
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
<input v-model="language" type="text" class="form-input" placeholder="例如:使用中文输出、使用英文输出" />
</div>
<div class="option-group">
<label class="section-title">分辨率 (RESOLUTION)</label>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">生成张数</label>
<div class="btn-group">
<button
v-for="c in COUNT_DETAIL"
:key="c"
class="opt-btn"
:class="{ active: count === c }"
@click="count = c"
>
{{ c }}
</button>
</div>
</div>
<ImageListField
v-model="images"
:max="8"
title="参考图片 (最多8张)"
hint="建议包含正面及多角度图"
:paste-pos="'row'"
/>
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import SpecifyTextList from '../SpecifyTextList.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { COUNT_MAIN, RATIO_OPTIONS_MAIN, RES_OPTIONS, collectSpecify } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'MainImagePanel' })
const props = defineProps<{ panelId: 'productMainImage' | 'buyerShow' }>()
const productName = ref('')
const featureMode = ref<'auto' | 'specify'>('auto')
const ratio = ref('3:4')
const res = ref('2k')
const count = ref(1)
const styleDesc = ref('')
const language = ref('')
const images = ref<string[]>([])
const specify = ref<string[]>([])
const menu = computed(() => (props.panelId === 'buyerShow' ? 12 : 9))
function collectModelImages(): string[] | null {
return modelImages.value.length > 0 ? modelImages.value.slice() : null
}
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (!productName.value.trim()) return { error: '请输入产品名称' }
if (images.value.length === 0) return { error: '请上传产品图片' }
const params: Record<string, unknown> = {
menu: menu.value,
name: productName.value.trim(),
desc: '',
style: styleDesc.value.trim() || '白底图',
language: language.value.trim() || '中文',
ratio: ratio.value,
resolution: res.value,
count: count.value,
mode: featureMode.value === 'specify' ? '3' : '1',
ref_images: images.value.slice(),
}
const modelImages = collectModelImages()
if (modelImages) params.model_images = modelImages
if (featureMode.value === 'specify') params.text = collectSpecify(specify.value)
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">产品名称(必填)</label>
<input v-model="productName" type="text" class="form-input" placeholder="如:口红 吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">功能特点组 (FEATURE GROUP)</label>
<div class="feature-mode-tabs btn-group">
<button class="opt-btn" :class="{ active: featureMode === 'auto' }" @click="featureMode = 'auto'">自动模式</button>
<button class="opt-btn" :class="{ active: featureMode === 'specify' }" @click="featureMode = 'specify'">指定文案</button>
</div>
<div v-if="featureMode === 'specify'" class="feature-mode-content">
<SpecifyTextList v-model="specify" :count="count" />
</div>
</div>
<div class="option-group">
<label class="section-title">画幅比例</label>
<div class="btn-group">
<button
v-for="r in RATIO_OPTIONS_MAIN"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">分辨率 (RESOLUTION)</label>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">风格描述 (可选)</label>
<input v-model="styleDesc" type="text" class="form-input" placeholder="如:白底图 浅色调 墨绿色调 户外 居家" />
</div>
<div class="option-group">
<label class="section-title">输出文案语言 (OUTPUT LANGUAGE)</label>
<input v-model="language" type="text" class="form-input" placeholder="例如:中文输出、英文输出、中英混合" />
</div>
<div class="option-group">
<label class="section-title">生成张数</label>
<div class="btn-group">
<button
v-for="c in COUNT_MAIN"
:key="c"
class="opt-btn"
:class="{ active: count === c }"
@click="count = c"
>
{{ c }}
</button>
</div>
</div>
<ImageListField
v-model="images"
:max="8"
title="产品图片 (最多8张)"
hint="建议包含正面及多角度图"
:paste-pos="'row'"
/>
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import ModelImagesUploader from '../ModelImagesUploader.vue'
import { RATIO_OPTIONS, RES_OPTIONS } from '../../panel-options'
import { modelImages } from '../../workbench-shared'
defineOptions({ name: 'ProductPosterPanel' })
const mainTitle = ref('')
const subtitle = ref('')
const brandName = ref('')
const ingredients = ref('')
const activity = ref('')
const ratio = ref('16:9')
const res = ref('2k')
const images = ref<string[]>([])
function buildParams(): { params?: Record<string, unknown>; error?: string } {
if (images.value.length === 0) return { error: '请上传图片(最多6张)' }
const params: Record<string, unknown> = {
menu: 6,
name: mainTitle.value.trim() || '产品',
desc: subtitle.value.trim(),
brand_name: brandName.value.trim(),
Ingredients: ingredients.value.trim(),
activity: activity.value.trim(),
ratio: ratio.value,
resolution: res.value,
count: 1,
ref_images: images.value.slice(),
}
if (modelImages.value.length) params.model_images = modelImages.value.slice()
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<label class="section-title">主标题</label>
<input v-model="mainTitle" type="text" class="form-input" placeholder="如: 口红吹风机 美容仪 美妆包" />
</div>
<div class="option-group">
<label class="section-title">副标题</label>
<textarea
v-model="subtitle"
class="form-textarea"
rows="4"
placeholder="如: 1:保湿效果好 2: 适合敏感肌&#10;3:价格实惠"
></textarea>
</div>
<div class="option-group">
<label class="section-title">品牌名 (可选)</label>
<input v-model="brandName" type="text" class="form-input" placeholder="(可选)" />
</div>
<div class="option-group">
<label class="section-title">成分 (INGREDIENTS)</label>
<input v-model="ingredients" type="text" class="form-input" placeholder="例如: 纯棉、透明质酸...(可选)" />
</div>
<div class="option-group">
<label class="section-title">活动 (ACTIVITY)</label>
<input v-model="activity" type="text" class="form-input" placeholder="例如: 买一送一、限时折扣...(可选)" />
</div>
<div class="option-group">
<label class="section-title">画幅比例</label>
<div class="btn-group">
<button
v-for="r in RATIO_OPTIONS"
:key="r"
class="opt-btn"
:class="{ active: ratio === r }"
@click="ratio = r"
>
{{ r }}
</button>
</div>
</div>
<div class="option-group">
<label class="section-title">分辨率 (RESOLUTION)</label>
<div class="btn-group">
<button
v-for="opt in RES_OPTIONS"
:key="opt.value"
class="opt-btn"
:class="{ active: res === opt.value }"
@click="res = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<ImageListField
v-model="images"
:max="6"
title="上传图片 (最多6张)"
hint="支持多选与拖拽排序"
:paste-pos="'row'"
/>
<ModelImagesUploader />
</div>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { ref } from 'vue'
import ImageListField from '../ImageListField.vue'
import SingleImageField from '../SingleImageField.vue'
defineOptions({ name: 'TextToImagePanel' })
const prompt = ref('')
const refImages = ref<string[]>([])
const video = ref<string | null>(null)
function buildParams(): { params?: Record<string, unknown>; error?: string } {
const params: Record<string, unknown> = {
menu: 1,
prompt: prompt.value.trim() || '',
ref_images: refImages.value.slice(),
}
if (video.value) params.video = video.value
return { params }
}
defineExpose({ buildParams })
</script>
<template>
<div class="panel-content">
<div class="option-group">
<div class="section-title">提示词</div>
<div class="textarea-wrap">
<textarea
v-model="prompt"
class="prompt-textarea prompt-textarea-large"
placeholder="输入自定义指令,用于反推提示词..."
></textarea>
<span class="char-count char-count-top-right">{{ prompt.length }} 字符</span>
</div>
</div>
<ImageListField
v-model="refImages"
:max="8"
title="上传图片(最多8张)"
hint="支持JPG PNG BMP JPEG"
:zone-hint="'支持多选/拖拽上传'"
/>
<SingleImageField
v-model="video"
title="上传视频(只支持1个)"
hint="支持 MP4 等视频格式"
zone-text="点击上传视频"
accept="video/*,.mp4,.webm"
/>
</div>
</template>
@@ -0,0 +1,36 @@
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp']
/** 读取多个文件为 data URL,返回解析完的数组。 */
export function readFilesAsDataUrls(files: Blob[] | FileList): Promise<string[]> {
const list = Array.from(files)
return Promise.all(
list.map(
(file) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error || new Error('读取文件失败'))
reader.readAsDataURL(file)
}),
),
)
}
/** 从剪贴板读取图片文件。 */
export async function pasteClipboardImages(): Promise<Blob[]> {
try {
const items = await navigator.clipboard.read()
const images: Blob[] = []
for (const item of items) {
if (item.types.includes('image/png')) {
images.push(await item.getType('image/png'))
} else if (item.types.includes('image/jpeg')) {
images.push(await item.getType('image/jpeg'))
}
}
return images
} catch (err) {
console.warn('粘贴图片失败:', err)
return []
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
export const RES_OPTIONS = [
{ label: '2K', value: '2k' },
{ label: '4K', value: '4k' },
]
/** 不含 auto 的常规画幅比例 */
export const RATIO_OPTIONS = ['3:4', '1:1', '16:9', '9:16', '4:3', '2:3', '3:2', '21:9']
/** 含 auto 的画幅比例 */
export const RATIO_OPTIONS_AUTO = ['auto', ...RATIO_OPTIONS]
/** 产品主图/买家秀仅三种比例,默认 3:4 */
export const RATIO_OPTIONS_MAIN = ['1:1', '3:4', '4:3']
export const COUNT_MAIN = [1, 3, 5, 7, 9]
export const COUNT_DETAIL = [3, 5, 9, 14]
export function collectSpecify(list: string[]): string[] {
return (Array.isArray(list) ? list : []).map((s) => String(s || '').trim()).filter(Boolean)
}
@@ -0,0 +1,7 @@
import { ref } from 'vue'
/** 多模特图:除反推词外所有栏目共享同一组上传 */
export const modelImages = ref<string[]>([])
export const STORAGE_API_KEY = 'maixiang_api_key'
export const STORAGE_AUTO_SAVE_PATH = 'maixiang_auto_save_path'