|
|
@@ -0,0 +1,1981 @@
|
|
|
+<template>
|
|
|
+ <BlueHeaderBar />
|
|
|
+ <div class="poster-page">
|
|
|
+ <div class="poster-toolbar">
|
|
|
+ <div class="logo-box">
|
|
|
+ <span class="logo-box__label">LOGO</span>
|
|
|
+ <div class="logo-card" :class="{ active: !!logoPath }" @click="openLogoUpload">
|
|
|
+ <img v-if="logoPath" :src="toFileUrl(logoPath)" class="logo-card__img" @error="onLogoError" />
|
|
|
+ <div v-else class="logo-card__empty">上传</div>
|
|
|
+ </div>
|
|
|
+ <div class="logo-list" v-if="otherLogoList.length">
|
|
|
+ <div
|
|
|
+ v-for="item in otherLogoList"
|
|
|
+ :key="item"
|
|
|
+ class="logo-list__item"
|
|
|
+ @click="selectLogo(item)"
|
|
|
+ >
|
|
|
+ <img :src="toFileUrl(item)" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-button v-if="logoPath" type="danger" link @click="clearLogo">清除</el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="toolbar-actions">
|
|
|
+ <el-button v-if="submitting || progressTasks.length" @click="progressVisible = true">查看进度</el-button>
|
|
|
+ <el-tooltip
|
|
|
+ v-if="submitting"
|
|
|
+ content="生成过程中不能关掉湛江场景图窗口"
|
|
|
+ placement="bottom"
|
|
|
+ >
|
|
|
+ <span class="gen-warn-icon">
|
|
|
+ <el-icon><WarningFilled /></el-icon>
|
|
|
+ </span>
|
|
|
+ </el-tooltip>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-button class="gen-btn" type="primary" :loading="submitting" @click="submitGenerate">
|
|
|
+ 生成 {{ goodsCount }} 款货 · {{ posterCount }} 张
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="poster-main">
|
|
|
+ <section class="list-pane">
|
|
|
+ <div class="list-pane__head">
|
|
|
+ <div class="list-pane__title">
|
|
|
+ 待生成的货
|
|
|
+ <span class="list-pane__sub">共 {{ goodsCount }} 款 · 将出 {{ posterCount }} 张场景图</span>
|
|
|
+ </div>
|
|
|
+ <div class="list-pane__actions">
|
|
|
+ <el-button @click="openHistory">历史记录</el-button>
|
|
|
+ <el-button type="primary" @click="openAddGoods">
|
|
|
+ <el-icon class="el-icon--left"><Plus /></el-icon>
|
|
|
+ 加一款货
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="!goodsList.length" class="list-empty">
|
|
|
+ <div class="empty-card" @click="openAddGoods">
|
|
|
+ <div class="empty-card__plus">+</div>
|
|
|
+ <div class="empty-card__title">添加第一款货</div>
|
|
|
+ <div class="empty-card__desc">先选货号,再编辑样式、主图和配色</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="goods-grid">
|
|
|
+ <div
|
|
|
+ v-for="row in goodsList"
|
|
|
+ :key="row.id"
|
|
|
+ class="goods-card"
|
|
|
+ @click="openEditGoods(row)"
|
|
|
+ >
|
|
|
+ <div class="goods-card__cover">
|
|
|
+ <el-image v-if="row.hero.image_path" :src="toFileUrl(row.hero.image_path)" fit="cover" />
|
|
|
+ <span v-else>未选图</span>
|
|
|
+ </div>
|
|
|
+ <div class="goods-card__body">
|
|
|
+ <div class="goods-card__no">{{ row.hero.goods_art_no || '未选货号' }}</div>
|
|
|
+ <div class="goods-card__tags">
|
|
|
+ <span class="pill" :class="{ ok: !!row.hero.action_name }">{{ row.hero.action_name || '未选主图' }}</span>
|
|
|
+ <span class="pill" :class="{ ok: !!colorSummary(row) }">{{ colorSummary(row) || '未填配色' }}</span>
|
|
|
+ <span class="pill" :class="{ ok: row.templateIds.length > 0 }">{{ row.templateIds.length }} 套样式</span>
|
|
|
+ </div>
|
|
|
+ <div class="goods-card__ops" @click.stop>
|
|
|
+ <el-button type="primary" size="small" @click="openEditGoods(row)">编辑</el-button>
|
|
|
+ <el-button size="small" @click="removeGoods(row.id)">删除</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="goods-card goods-card--add" @click="openAddGoods">
|
|
|
+ <el-icon :size="28"><Plus /></el-icon>
|
|
|
+ <span>再加一款货</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-dialog v-model="addDialogVisible" title="加一款货" width="440px" :close-on-click-modal="false" destroy-on-close>
|
|
|
+ <div class="add-dialog">
|
|
|
+ <div class="field__label">选择货号</div>
|
|
|
+ <GoodsArtNoSearch
|
|
|
+ :model-value="addArtNo"
|
|
|
+ placeholder="搜索拍摄记录中的货号"
|
|
|
+ @update:model-value="onAddArtNoChange"
|
|
|
+ @select="onAddArtSelect"
|
|
|
+ />
|
|
|
+ <div v-if="addArtNo" class="add-dialog__picked">已选:{{ addArtNo }}</div>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="addDialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="confirmAddGoods">下一步</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <el-dialog
|
|
|
+ v-if="editDialogVisible"
|
|
|
+ v-model="editDialogVisible"
|
|
|
+ :title="currentGoods ? `编辑 ${currentGoods.hero.goods_art_no}` : '编辑'"
|
|
|
+ width="980px"
|
|
|
+ top="4vh"
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ destroy-on-close
|
|
|
+ class="edit-goods-dialog"
|
|
|
+ @closed="onEditDialogClosed"
|
|
|
+ >
|
|
|
+ <div v-if="currentGoods" class="edit-layout">
|
|
|
+ <div class="edit-col edit-col--styles">
|
|
|
+ <div class="block-title">
|
|
|
+ <span class="block-title__bar"></span>
|
|
|
+ 要出的样式
|
|
|
+ <span class="block-title__sub">已选 {{ currentGoods.templateIds.length }}</span>
|
|
|
+ </div>
|
|
|
+ <el-input
|
|
|
+ v-model="styleKeyword"
|
|
|
+ clearable
|
|
|
+ placeholder="搜索样式名称"
|
|
|
+ style="margin-bottom: 12px"
|
|
|
+ />
|
|
|
+ <div v-if="templatesLoading" class="hint">样式加载中…</div>
|
|
|
+ <div v-else-if="!filteredPanelTemplates.length" class="hint">暂无匹配样式</div>
|
|
|
+ <div v-else class="style-grid">
|
|
|
+ <div
|
|
|
+ v-for="tpl in filteredPanelTemplates"
|
|
|
+ :key="tpl.id"
|
|
|
+ class="style-item"
|
|
|
+ :class="{ on: currentGoods.templateIds.includes(tpl.id) }"
|
|
|
+ @click="toggleGoodsTemplate(currentGoods, tpl.id)"
|
|
|
+ @mouseenter="(e: MouseEvent) => showTplPreview(e, tpl)"
|
|
|
+ @mouseleave="hideTplPreview"
|
|
|
+ >
|
|
|
+ <el-image :src="tpl.preview_url" fit="cover" lazy class="style-item__img" />
|
|
|
+ <div class="style-item__name">{{ tpl.name }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="edit-col">
|
|
|
+ <div class="block">
|
|
|
+ <div class="block-title">
|
|
|
+ <span class="block-title__bar"></span>
|
|
|
+ 主图
|
|
|
+ <span class="locked-no">{{ currentGoods.hero.goods_art_no }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="field">
|
|
|
+ <div class="field__label">拍摄图</div>
|
|
|
+ <div v-if="!heroAngles.length" class="hint">该货号暂无已拍图片</div>
|
|
|
+ <div v-else class="shot-row">
|
|
|
+ <div
|
|
|
+ v-for="angle in heroAngles"
|
|
|
+ :key="angle.action_name + (angle.image_path || '')"
|
|
|
+ class="shot"
|
|
|
+ :class="{
|
|
|
+ on: currentGoods.hero.action_name === angle.action_name && currentGoods.hero.image_path === angle.image_path,
|
|
|
+ disabled: !angle.image_path
|
|
|
+ }"
|
|
|
+ @click="selectHeroAngle(angle)"
|
|
|
+ >
|
|
|
+ <el-image v-if="angle.image_path" :src="toFileUrl(angle.image_path)" fit="cover" lazy class="shot__img" />
|
|
|
+ <div v-else class="shot__empty">无图</div>
|
|
|
+ <span class="shot__cap">{{ angle.action_name || '未命名' }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="block">
|
|
|
+ <div class="block-title"><span class="block-title__bar"></span>背景关键词</div>
|
|
|
+ <el-input
|
|
|
+ v-model="currentGoods.keywords"
|
|
|
+ maxlength="100"
|
|
|
+ show-word-limit
|
|
|
+ placeholder="选填,用于调整背景样式,如 丝绸、粉色、清新"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="block">
|
|
|
+ <div class="block-title">
|
|
|
+ <span class="block-title__bar"></span>
|
|
|
+ 配色
|
|
|
+ <span class="block-title__sub">{{ filledColorCount }} / 8</span>
|
|
|
+ </div>
|
|
|
+ <div v-for="(slot, slotIndex) in currentGoods.colorSlots" :key="slotIndex" class="slot-card">
|
|
|
+ <div class="slot-card__head">
|
|
|
+ <span>颜色 {{ slotIndex + 1 }}</span>
|
|
|
+ <el-button
|
|
|
+ v-if="currentGoods.colorSlots.length > 1"
|
|
|
+ type="danger"
|
|
|
+ link
|
|
|
+ @click="removeColorSlot(slotIndex)"
|
|
|
+ >删除</el-button>
|
|
|
+ </div>
|
|
|
+ <div class="slot-top">
|
|
|
+ <span class="slot-top__label">货号</span>
|
|
|
+ <div class="artno-short">
|
|
|
+ <GoodsArtNoSearch
|
|
|
+ :model-value="slot.goods_art_no"
|
|
|
+ placeholder="搜索货号"
|
|
|
+ @update:model-value="(val) => onSlotGoodsChange(slotIndex, val)"
|
|
|
+ @select="(item) => onSlotGoodsSelect(slotIndex, item)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <span class="slot-top__label">颜色</span>
|
|
|
+ <el-input v-model="slot.color_name" class="color-input" placeholder="如 米白" maxlength="20" />
|
|
|
+ </div>
|
|
|
+ <div class="field" v-if="slot.goods_art_no">
|
|
|
+ <div class="field__label">拍摄图</div>
|
|
|
+ <div v-if="!getSlotAngles(slotIndex).length" class="hint">该货号暂无已拍图片</div>
|
|
|
+ <div v-else class="shot-row">
|
|
|
+ <div
|
|
|
+ v-for="angle in getSlotAngles(slotIndex)"
|
|
|
+ :key="angle.action_name + (angle.image_path || '')"
|
|
|
+ class="shot shot--sm"
|
|
|
+ :class="{
|
|
|
+ on: slot.action_name === angle.action_name && slot.image_path === angle.image_path,
|
|
|
+ disabled: !angle.image_path
|
|
|
+ }"
|
|
|
+ @click="selectSlotAngle(slotIndex, angle)"
|
|
|
+ >
|
|
|
+ <el-image v-if="angle.image_path" :src="toFileUrl(angle.image_path)" fit="cover" lazy class="shot__img" />
|
|
|
+ <div v-else class="shot__empty">无图</div>
|
|
|
+ <span class="shot__cap">{{ angle.action_name || '未命名' }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="currentGoods.colorSlots.length < 8" class="add-color-row">
|
|
|
+ <el-button plain @click="addColorSlot">添加颜色</el-button>
|
|
|
+ <el-select v-model="addColorCount" style="width: 92px">
|
|
|
+ <el-option
|
|
|
+ v-for="n in addColorOptions"
|
|
|
+ :key="n"
|
|
|
+ :label="n + '列'"
|
|
|
+ :value="n"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="block">
|
|
|
+ <div class="block-title"><span class="block-title__bar"></span>商品信息</div>
|
|
|
+ <div v-for="(row, rowIndex) in currentGoods.infoRows" :key="row.key" class="info-row">
|
|
|
+ <div class="info-row__fields">
|
|
|
+ <span class="info-row__label">{{ row.label }}</span>
|
|
|
+ <el-input v-model="row.value" class="info-row__value" :placeholder="row.placeholder || '请输入'" />
|
|
|
+ <el-button
|
|
|
+ v-if="row.key === 'number_mid' && currentGoods.hero.goods_art_no"
|
|
|
+ link
|
|
|
+ type="primary"
|
|
|
+ @click="fillInfoWithHero(rowIndex)"
|
|
|
+ >填入主图货号</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="editDialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="editDialogVisible = false">完成</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <el-dialog
|
|
|
+ v-model="progressVisible"
|
|
|
+ title="生成进度"
|
|
|
+ width="680px"
|
|
|
+ custom-class="poster-progress-dialog"
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ destroy-on-close
|
|
|
+ @close="onProgressClose"
|
|
|
+ >
|
|
|
+ <div v-if="createErrorMsg" class="pg-fail">
|
|
|
+ <div class="pg-fail__title">任务没有创建成功</div>
|
|
|
+ <div class="pg-fail__desc">{{ createErrorMsg }}</div>
|
|
|
+ <div class="pg-fail__hint">请检查登录状态或稍后重试。详细返回已打印在控制台。</div>
|
|
|
+ </div>
|
|
|
+ <template v-else>
|
|
|
+ <div class="pg-head">
|
|
|
+ <div class="pg-head__meta">
|
|
|
+ <span>{{ submitPhase || (submitting ? '正在生成中' : '生成结束') }}</span>
|
|
|
+ <span class="pg-head__count">{{ doneTaskCount }} / {{ progressTasks.length }} 完成</span>
|
|
|
+ </div>
|
|
|
+ <el-progress
|
|
|
+ :percentage="overallPercent"
|
|
|
+ :stroke-width="10"
|
|
|
+ :format="formatPercentText"
|
|
|
+ :color="overallPercent >= 100 ? '#67c23a' : '#2957ff'"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div v-if="submitting && !progressTasks.length" class="pg-submit">正在提交任务,请稍候…</div>
|
|
|
+ <div v-else class="pg-list">
|
|
|
+ <div v-for="task in progressTasks" :key="String(task.id)" class="pg-card">
|
|
|
+ <el-image
|
|
|
+ v-if="task.image"
|
|
|
+ :src="task.image"
|
|
|
+ fit="contain"
|
|
|
+ class="pg-card__img"
|
|
|
+ :preview-src-list="[task.image]"
|
|
|
+ />
|
|
|
+ <div v-else class="pg-card__img is-wait">{{ task.fail ? '失败' : '生成中' }}</div>
|
|
|
+ <div class="pg-card__body">
|
|
|
+ <div class="pg-card__top">
|
|
|
+ <div class="pg-card__name">{{ task.name }}</div>
|
|
|
+ <div class="pg-card__status" :class="task.statusClass">{{ task.statusText }}</div>
|
|
|
+ </div>
|
|
|
+ <el-progress
|
|
|
+ :percentage="displayPercent(task)"
|
|
|
+ :stroke-width="8"
|
|
|
+ :format="formatPercentText"
|
|
|
+ :status="task.fail ? 'exception' : (task.status === 2 && task.image ? 'success' : undefined)"
|
|
|
+ :color="task.fail ? '#f56c6c' : '#2957ff'"
|
|
|
+ />
|
|
|
+ <div class="pg-card__foot">
|
|
|
+ <span class="pg-card__time" v-if="task.pending && !task.fail">预计剩余 {{ formatRemain(task.remain) }}</span>
|
|
|
+ <el-button v-if="task.image" type="primary" link @click="saveOneImage(task.image, task.name)">保存到本地</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <template #footer>
|
|
|
+ <el-button
|
|
|
+ v-if="!createErrorMsg && doneTaskCount"
|
|
|
+ type="primary"
|
|
|
+ @click="saveDoneImages"
|
|
|
+ >保存已完成图片</el-button>
|
|
|
+ <el-button @click="progressVisible = false">{{ submitting && !createErrorMsg ? '后台生成' : '关闭' }}</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <el-dialog
|
|
|
+ v-model="historyVisible"
|
|
|
+ title="湛江场景图历史记录"
|
|
|
+ width="920px"
|
|
|
+ top="6vh"
|
|
|
+ destroy-on-close
|
|
|
+ custom-class="history-dialog"
|
|
|
+ @open="loadHistory(true)"
|
|
|
+ >
|
|
|
+ <div v-loading="historyLoading" class="his-wrap">
|
|
|
+ <el-empty v-if="!historyLoading && !historyList.length" description="暂无历史记录" />
|
|
|
+ <div v-else class="his-grid" @scroll="onHistoryScroll">
|
|
|
+ <div v-for="item in historyList" :key="item.id || historyImage(item)" class="his-card">
|
|
|
+ <div class="his-card__pic">
|
|
|
+ <el-image
|
|
|
+ :src="historyImage(item)"
|
|
|
+ fit="contain"
|
|
|
+ lazy
|
|
|
+ class="his-card__img"
|
|
|
+ :preview-src-list="historyImage(item) ? [historyImage(item)] : []"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="his-card__meta">
|
|
|
+ <span class="his-card__time">{{ historyTime(item) }}</span>
|
|
|
+ <el-button v-if="historyImage(item)" type="primary" link @click.stop="saveOneImage(historyImage(item), historyTime(item) || '湛江场景图')">保存</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="his-more">
|
|
|
+ <span v-if="historyLoadingMore">加载中…</span>
|
|
|
+ <span v-else-if="historyFinished && historyList.length">没有更多了</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-if="tplPreview"
|
|
|
+ class="tpl-hover-preview"
|
|
|
+ :style="{ left: tplPreview.x + 'px', top: tplPreview.y + 'px' }"
|
|
|
+ >
|
|
|
+ <img :src="tplPreview.src" alt="" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
+import { ElMessage, ElNotification } from 'element-plus'
|
|
|
+import { Plus, WarningFilled } from '@element-plus/icons-vue'
|
|
|
+import BlueHeaderBar from '@/components/header-bar/blue-header.vue'
|
|
|
+import GoodsArtNoSearch from './GoodsArtNoSearch.vue'
|
|
|
+import { defaultPosterInfoRows, fetchPosterHistory, fetchPosterTemplates, normalizePosterHistory, pickGenerateIds, searchPosterProgress } from '@/apis/poster'
|
|
|
+import type { PosterTemplate } from '@/apis/poster'
|
|
|
+import client from '@/stores/modules/client'
|
|
|
+import useUserInfo from '@/stores/modules/user'
|
|
|
+import icpList from '@/utils/ipc'
|
|
|
+
|
|
|
+interface AngleItem {
|
|
|
+ action_name: string
|
|
|
+ image_path: string
|
|
|
+}
|
|
|
+
|
|
|
+interface ColorSlot {
|
|
|
+ goods_art_no: string
|
|
|
+ action_name: string
|
|
|
+ image_path: string
|
|
|
+ color_name: string
|
|
|
+ color_hex: string
|
|
|
+}
|
|
|
+
|
|
|
+interface InfoRow {
|
|
|
+ key: 'number_mid' | 'size_mid' | 'number_big' | 'size_big' | 'box'
|
|
|
+ label: string
|
|
|
+ value: string
|
|
|
+ placeholder?: string
|
|
|
+}
|
|
|
+
|
|
|
+interface GoodsJob {
|
|
|
+ id: string
|
|
|
+ templateIds: string[]
|
|
|
+ keywords: string
|
|
|
+ hero: {
|
|
|
+ goods_art_no: string
|
|
|
+ action_name: string
|
|
|
+ image_path: string
|
|
|
+ }
|
|
|
+ colorSlots: ColorSlot[]
|
|
|
+ infoRows: InfoRow[]
|
|
|
+}
|
|
|
+
|
|
|
+interface ProgressTask {
|
|
|
+ id: string | number
|
|
|
+ name: string
|
|
|
+ remain: number
|
|
|
+ status: number
|
|
|
+ pending: boolean
|
|
|
+ fail: boolean
|
|
|
+ image: string
|
|
|
+ statusText: string
|
|
|
+ statusClass: string
|
|
|
+ startedAt: number
|
|
|
+ fakePercent: number
|
|
|
+}
|
|
|
+
|
|
|
+const clientStore = client()
|
|
|
+const userStore = useUserInfo()
|
|
|
+
|
|
|
+const templates = ref<PosterTemplate[]>([])
|
|
|
+const templatesLoading = ref(false)
|
|
|
+const templateKeywords = ref('')
|
|
|
+const tplPreview = ref<{ src: string; x: number; y: number } | null>(null)
|
|
|
+let keywordsTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
+const logoPath = ref('')
|
|
|
+const logoList = ref<string[]>([])
|
|
|
+const otherLogoList = computed(() => logoList.value.filter((item) => item && item !== logoPath.value))
|
|
|
+const goodsList = ref<GoodsJob[]>([])
|
|
|
+const activeGoodsId = ref('')
|
|
|
+const addDialogVisible = ref(false)
|
|
|
+const addArtNo = ref('')
|
|
|
+const addArtItem = ref<any>(null)
|
|
|
+const editDialogVisible = ref(false)
|
|
|
+const addColorCount = ref(1)
|
|
|
+const addColorOptions = computed(() => {
|
|
|
+ const remain = 8 - (currentGoods.value?.colorSlots.length || 0)
|
|
|
+ return Array.from({ length: Math.max(remain, 0) }, (_, i) => i + 1)
|
|
|
+})
|
|
|
+const submitting = ref(false)
|
|
|
+const progressVisible = ref(false)
|
|
|
+const submitPhase = ref('')
|
|
|
+const progressTasks = ref<ProgressTask[]>([])
|
|
|
+const historyVisible = ref(false)
|
|
|
+const historyLoading = ref(false)
|
|
|
+const historyLoadingMore = ref(false)
|
|
|
+const historyFinished = ref(false)
|
|
|
+const historyList = ref<any[]>([])
|
|
|
+const historyPage = ref(1)
|
|
|
+const historyPageSize = 20
|
|
|
+const historyTotal = ref(0)
|
|
|
+const createErrorMsg = ref('')
|
|
|
+const goodsAngleMap = ref<Record<string, AngleItem[]>>({})
|
|
|
+
|
|
|
+let countdownTimer: ReturnType<typeof setInterval> | null = null
|
|
|
+let pollTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
+let pollActive = false
|
|
|
+let pollBusy = false
|
|
|
+let generateIds: Array<string | number> = []
|
|
|
+let generateChannel = 'poster'
|
|
|
+
|
|
|
+const currentGoods = computed(() => goodsList.value.find((item) => item.id === activeGoodsId.value) || null)
|
|
|
+const heroAngles = computed(() => goodsAngleMap.value[currentGoods.value?.hero.goods_art_no] || [])
|
|
|
+const filledColorCount = computed(() => currentGoods.value?.colorSlots.filter((slot) => slot.goods_art_no).length || 0)
|
|
|
+const styleKeyword = ref('')
|
|
|
+const filteredPanelTemplates = computed(() => {
|
|
|
+ const keyword = styleKeyword.value.trim().toLowerCase()
|
|
|
+ if (!keyword) return templates.value
|
|
|
+ return templates.value.filter((item) => (item.name || '').toLowerCase().includes(keyword))
|
|
|
+})
|
|
|
+const goodsCount = computed(() => goodsList.value.length)
|
|
|
+const posterCount = computed(() => goodsList.value.reduce((sum, item) => sum + item.templateIds.length, 0))
|
|
|
+const doneTaskCount = computed(() => progressTasks.value.filter((task) => task.status === 2 && task.image).length)
|
|
|
+const overallPercent = computed(() => {
|
|
|
+ if (!progressTasks.value.length) return 0
|
|
|
+ const sum = progressTasks.value.reduce((total, task) => total + displayPercent(task), 0)
|
|
|
+ return Math.round((sum / progressTasks.value.length) * 10) / 10
|
|
|
+})
|
|
|
+const userId = computed(() => (userStore.userInfo as any)?.id || 'guest')
|
|
|
+const logoCacheKey = computed(() => `poster_logo_path_${userId.value}`)
|
|
|
+const draftCacheKey = computed(() => `poster_session_draft_v3_${userId.value}`)
|
|
|
+
|
|
|
+function uid() {
|
|
|
+ return `g_${Date.now()}_${Math.floor(Math.random() * 1e6)}`
|
|
|
+}
|
|
|
+
|
|
|
+function emptySlot(): ColorSlot {
|
|
|
+ return { goods_art_no: '', action_name: '', image_path: '', color_name: '', color_hex: '' }
|
|
|
+}
|
|
|
+
|
|
|
+function defaultInfoRows(): InfoRow[] {
|
|
|
+ return defaultPosterInfoRows()
|
|
|
+}
|
|
|
+
|
|
|
+function findPosterTemplate(id: string) {
|
|
|
+ return templates.value.find((item) => item.id === String(id)) || null
|
|
|
+}
|
|
|
+
|
|
|
+function createGoods(): GoodsJob {
|
|
|
+ return {
|
|
|
+ id: uid(),
|
|
|
+ templateIds: [],
|
|
|
+ keywords: '',
|
|
|
+ hero: { goods_art_no: '', action_name: '', image_path: '' },
|
|
|
+ colorSlots: [emptySlot()],
|
|
|
+ infoRows: defaultInfoRows(),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function ensureGoods(raw: any): GoodsJob {
|
|
|
+ const goods = createGoods()
|
|
|
+ if (!raw || typeof raw !== 'object') return goods
|
|
|
+ goods.id = raw.id || goods.id
|
|
|
+ const ids = Array.isArray(raw.templateIds)
|
|
|
+ ? raw.templateIds
|
|
|
+ : (raw.template_id ? [raw.template_id] : [])
|
|
|
+ goods.templateIds = ids.map((id: string | number) => String(id)).filter(Boolean)
|
|
|
+ goods.keywords = raw.keywords || ''
|
|
|
+ goods.hero = {
|
|
|
+ goods_art_no: raw.hero?.goods_art_no || '',
|
|
|
+ action_name: raw.hero?.action_name || '',
|
|
|
+ image_path: raw.hero?.image_path || '',
|
|
|
+ }
|
|
|
+ goods.colorSlots = Array.isArray(raw.colorSlots) && raw.colorSlots.length
|
|
|
+ ? raw.colorSlots.map((slot: any) => ({
|
|
|
+ goods_art_no: slot?.goods_art_no || '',
|
|
|
+ action_name: slot?.action_name || '',
|
|
|
+ image_path: slot?.image_path || '',
|
|
|
+ color_name: slot?.color_name || '',
|
|
|
+ color_hex: slot?.color_hex || '',
|
|
|
+ }))
|
|
|
+ : [emptySlot()]
|
|
|
+ if (goods.hero.goods_art_no) {
|
|
|
+ goods.colorSlots.forEach((slot) => {
|
|
|
+ if (!slot.goods_art_no) slot.goods_art_no = goods.hero.goods_art_no
|
|
|
+ })
|
|
|
+ }
|
|
|
+ const rawInfos = Array.isArray(raw.infoRows) ? raw.infoRows : []
|
|
|
+ const defaults = defaultInfoRows()
|
|
|
+ goods.infoRows = defaults.map((item) => {
|
|
|
+ const hit = rawInfos.find((row: any) => row?.key === item.key)
|
|
|
+ return {
|
|
|
+ ...item,
|
|
|
+ value: hit?.value || '',
|
|
|
+ }
|
|
|
+ })
|
|
|
+ return goods
|
|
|
+}
|
|
|
+
|
|
|
+function colorSummary(row: GoodsJob) {
|
|
|
+ const names = row.colorSlots.map((slot) => slot.color_name).filter(Boolean)
|
|
|
+ return names.join(' / ')
|
|
|
+}
|
|
|
+
|
|
|
+function infoSummary(row: GoodsJob) {
|
|
|
+ const values = row.infoRows.filter((item) => item.label.trim() && item.value.trim()).map((item) => item.value)
|
|
|
+ return values.slice(0, 3).join(' · ')
|
|
|
+}
|
|
|
+
|
|
|
+function selectedTemplateSummary(row: GoodsJob) {
|
|
|
+ if (!row.templateIds.length) return '未选,请在右侧勾选'
|
|
|
+ const names = row.templateIds
|
|
|
+ .map((id) => findPosterTemplate(id)?.name)
|
|
|
+ .filter(Boolean)
|
|
|
+ if (!names.length) return `已选 ${row.templateIds.length} 套`
|
|
|
+ if (names.length <= 2) return names.join('、')
|
|
|
+ return `${names.slice(0, 2).join('、')} 等${names.length}套`
|
|
|
+}
|
|
|
+
|
|
|
+function rowClassName({ row }: { row: GoodsJob }) {
|
|
|
+ return row.id === activeGoodsId.value ? 'is-current' : ''
|
|
|
+}
|
|
|
+
|
|
|
+function onRowClick(row: GoodsJob) {
|
|
|
+ activeGoodsId.value = row.id
|
|
|
+ ensureGoodsAngles(row.hero.goods_art_no)
|
|
|
+}
|
|
|
+
|
|
|
+function toggleGoodsTemplate(row: GoodsJob, templateId: string) {
|
|
|
+ const idx = row.templateIds.indexOf(templateId)
|
|
|
+ if (idx >= 0) row.templateIds.splice(idx, 1)
|
|
|
+ else row.templateIds.push(templateId)
|
|
|
+}
|
|
|
+
|
|
|
+function showTplPreview(e: MouseEvent, tpl: PosterTemplate) {
|
|
|
+ const src = tpl.preview_url || tpl.image_url
|
|
|
+ if (!src) {
|
|
|
+ tplPreview.value = null
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
|
+ const size = 200
|
|
|
+ let x = rect.right + 8
|
|
|
+ let y = rect.top
|
|
|
+ if (x + size > window.innerWidth - 8) x = rect.left - size - 8
|
|
|
+ if (y + size > window.innerHeight - 8) y = window.innerHeight - size - 8
|
|
|
+ if (y < 8) y = 8
|
|
|
+ tplPreview.value = { src, x, y }
|
|
|
+}
|
|
|
+
|
|
|
+function hideTplPreview() {
|
|
|
+ tplPreview.value = null
|
|
|
+}
|
|
|
+
|
|
|
+function toFileUrl(path: string) {
|
|
|
+ if (!path) return ''
|
|
|
+ if (/^(data:|https?:|file:)/i.test(path)) return path
|
|
|
+ return 'file:///' + path.replace(/\\/g, '/')
|
|
|
+}
|
|
|
+
|
|
|
+function pickAngles(item: any): AngleItem[] {
|
|
|
+ if (!item?.items) return []
|
|
|
+ return item.items.map((image: any) => ({
|
|
|
+ action_name: image.action_name || '',
|
|
|
+ image_path: image.PhotoRecord?.image_path || image.image_path || '',
|
|
|
+ }))
|
|
|
+}
|
|
|
+
|
|
|
+function firstValidAngle(angles: AngleItem[]) {
|
|
|
+ return angles.find((item) => item.image_path) || null
|
|
|
+}
|
|
|
+
|
|
|
+function cacheGoodsAngles(item: any) {
|
|
|
+ if (!item?.goods_art_no) return
|
|
|
+ goodsAngleMap.value = {
|
|
|
+ ...goodsAngleMap.value,
|
|
|
+ [item.goods_art_no]: pickAngles(item),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function fetchPhotoByArtNo(artNo: string) {
|
|
|
+ return new Promise<any>((resolve, reject) => {
|
|
|
+ if (!clientStore.ipc || !artNo) {
|
|
|
+ reject(new Error('无法查询拍摄记录'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const channel = icpList.takePhoto.getPhotoRecords
|
|
|
+ const timer = setTimeout(() => {
|
|
|
+ clientStore.ipc.removeListener(channel, handler)
|
|
|
+ reject(new Error('搜索超时'))
|
|
|
+ }, 15000)
|
|
|
+ const handler = (_event: any, result: any) => {
|
|
|
+ clearTimeout(timer)
|
|
|
+ clientStore.ipc.removeListener(channel, handler)
|
|
|
+ resolve(result)
|
|
|
+ }
|
|
|
+ clientStore.ipc.on(channel, handler)
|
|
|
+ clientStore.ipc.send(channel, {
|
|
|
+ page: 1,
|
|
|
+ size: 10,
|
|
|
+ goods_art_no: artNo,
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function ensureGoodsAngles(artNo: string) {
|
|
|
+ if (!artNo || (goodsAngleMap.value[artNo] && goodsAngleMap.value[artNo].length)) return
|
|
|
+ try {
|
|
|
+ const result = await fetchPhotoByArtNo(artNo)
|
|
|
+ const item = (result?.data?.list || []).find((row: any) => row.goods_art_no === artNo)
|
|
|
+ if (item) cacheGoodsAngles(item)
|
|
|
+ } catch { /* ignore */ }
|
|
|
+}
|
|
|
+
|
|
|
+function selectHeroAngle(angle: AngleItem) {
|
|
|
+ if (!angle.image_path || !currentGoods.value) return
|
|
|
+ currentGoods.value.hero.action_name = angle.action_name
|
|
|
+ currentGoods.value.hero.image_path = angle.image_path
|
|
|
+}
|
|
|
+
|
|
|
+function getSlotAngles(index: number) {
|
|
|
+ const artNo = currentGoods.value?.colorSlots[index]?.goods_art_no
|
|
|
+ if (!artNo) return []
|
|
|
+ return goodsAngleMap.value[artNo] || []
|
|
|
+}
|
|
|
+
|
|
|
+function onSlotGoodsChange(index: number, value: string) {
|
|
|
+ const slot = currentGoods.value?.colorSlots[index]
|
|
|
+ if (!slot) return
|
|
|
+ slot.goods_art_no = value
|
|
|
+ slot.action_name = ''
|
|
|
+ slot.image_path = ''
|
|
|
+}
|
|
|
+
|
|
|
+function onSlotGoodsSelect(index: number, item: any) {
|
|
|
+ const slot = currentGoods.value?.colorSlots[index]
|
|
|
+ if (!slot) return
|
|
|
+ if (!item) {
|
|
|
+ slot.action_name = ''
|
|
|
+ slot.image_path = ''
|
|
|
+ return
|
|
|
+ }
|
|
|
+ cacheGoodsAngles(item)
|
|
|
+ slot.goods_art_no = item.goods_art_no
|
|
|
+ const first = firstValidAngle(pickAngles(item))
|
|
|
+ slot.action_name = first?.action_name || ''
|
|
|
+ slot.image_path = first?.image_path || ''
|
|
|
+}
|
|
|
+
|
|
|
+function selectSlotAngle(index: number, angle: AngleItem) {
|
|
|
+ const slot = currentGoods.value?.colorSlots[index]
|
|
|
+ if (!slot || !angle.image_path) return
|
|
|
+ slot.action_name = angle.action_name
|
|
|
+ slot.image_path = angle.image_path
|
|
|
+}
|
|
|
+
|
|
|
+function addColorSlot() {
|
|
|
+ if (!currentGoods.value) return
|
|
|
+ const remain = 8 - currentGoods.value.colorSlots.length
|
|
|
+ if (remain <= 0) return
|
|
|
+ const count = Math.min(addColorCount.value || 1, remain)
|
|
|
+ for (let i = 0; i < count; i++) {
|
|
|
+ const slot = emptySlot()
|
|
|
+ slot.goods_art_no = currentGoods.value.hero.goods_art_no
|
|
|
+ currentGoods.value.colorSlots.push(slot)
|
|
|
+ }
|
|
|
+ if (addColorCount.value > remain - count) addColorCount.value = Math.max(remain - count, 1)
|
|
|
+}
|
|
|
+
|
|
|
+function openAddGoods() {
|
|
|
+ addArtNo.value = ''
|
|
|
+ addArtItem.value = null
|
|
|
+ addDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function onAddArtNoChange(value: string) {
|
|
|
+ addArtNo.value = value
|
|
|
+ if (!value) addArtItem.value = null
|
|
|
+}
|
|
|
+
|
|
|
+function onAddArtSelect(item: any) {
|
|
|
+ addArtItem.value = item
|
|
|
+ addArtNo.value = item?.goods_art_no || addArtNo.value
|
|
|
+}
|
|
|
+
|
|
|
+function fillGoodsFromItem(goods: GoodsJob, item: any) {
|
|
|
+ const artNo = item?.goods_art_no || addArtNo.value
|
|
|
+ goods.hero.goods_art_no = artNo
|
|
|
+ if (item) {
|
|
|
+ cacheGoodsAngles(item)
|
|
|
+ const first = firstValidAngle(pickAngles(item))
|
|
|
+ goods.hero.action_name = first?.action_name || ''
|
|
|
+ goods.hero.image_path = first?.image_path || ''
|
|
|
+ }
|
|
|
+ const info = goods.infoRows.find((row) => row.key === 'number_mid' && !row.value)
|
|
|
+ if (info) info.value = artNo
|
|
|
+ if (goods.colorSlots[0] && !goods.colorSlots[0].goods_art_no) {
|
|
|
+ goods.colorSlots[0].goods_art_no = artNo
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function confirmAddGoods() {
|
|
|
+ if (!addArtNo.value) {
|
|
|
+ ElMessage.warning('请选择货号')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (goodsList.value.some((item) => item.hero.goods_art_no === addArtNo.value)) {
|
|
|
+ ElMessage.warning('该货号已添加')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const goods = createGoods()
|
|
|
+ fillGoodsFromItem(goods, addArtItem.value)
|
|
|
+ goodsList.value.push(goods)
|
|
|
+ activeGoodsId.value = goods.id
|
|
|
+ addDialogVisible.value = false
|
|
|
+ editDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function onEditDialogClosed() {
|
|
|
+ hideTplPreview()
|
|
|
+ styleKeyword.value = ''
|
|
|
+}
|
|
|
+
|
|
|
+function openEditGoods(row: GoodsJob) {
|
|
|
+ activeGoodsId.value = row.id
|
|
|
+ ensureGoodsAngles(row.hero.goods_art_no)
|
|
|
+ row.colorSlots.forEach((slot) => {
|
|
|
+ if (slot.goods_art_no) ensureGoodsAngles(slot.goods_art_no)
|
|
|
+ })
|
|
|
+ editDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function removeGoods(id: string) {
|
|
|
+ goodsList.value = goodsList.value.filter((item) => item.id !== id)
|
|
|
+ if (activeGoodsId.value === id) activeGoodsId.value = goodsList.value[0]?.id || ''
|
|
|
+}
|
|
|
+
|
|
|
+function removeColorSlot(index: number) {
|
|
|
+ if (!currentGoods.value || currentGoods.value.colorSlots.length <= 1) return
|
|
|
+ currentGoods.value.colorSlots.splice(index, 1)
|
|
|
+}
|
|
|
+
|
|
|
+function fillInfoWithHero(index: number) {
|
|
|
+ if (!currentGoods.value) return
|
|
|
+ currentGoods.value.infoRows[index].value = currentGoods.value.hero.goods_art_no || ''
|
|
|
+}
|
|
|
+
|
|
|
+function saveLogoCache(path: string) {
|
|
|
+ try { localStorage.setItem(logoCacheKey.value, path || '') } catch { /* ignore */ }
|
|
|
+}
|
|
|
+
|
|
|
+function loadLogoCache() {
|
|
|
+ try { return localStorage.getItem(logoCacheKey.value) || '' } catch { return '' }
|
|
|
+}
|
|
|
+
|
|
|
+function hasGoodsNo(item: GoodsJob) {
|
|
|
+ return !!(item && item.hero && item.hero.goods_art_no)
|
|
|
+}
|
|
|
+
|
|
|
+function saveDraft() {
|
|
|
+ try {
|
|
|
+ const list = goodsList.value.filter(hasGoodsNo)
|
|
|
+ localStorage.setItem(draftCacheKey.value, JSON.stringify({
|
|
|
+ logoPath: logoPath.value,
|
|
|
+ goodsList: list,
|
|
|
+ activeGoodsId: list.some((item) => item.id === activeGoodsId.value) ? activeGoodsId.value : (list[0]?.id || ''),
|
|
|
+ }))
|
|
|
+ } catch { /* ignore */ }
|
|
|
+}
|
|
|
+
|
|
|
+function loadDraft() {
|
|
|
+ try {
|
|
|
+ const raw = localStorage.getItem(draftCacheKey.value)
|
|
|
+ if (!raw) return
|
|
|
+ const data = JSON.parse(raw)
|
|
|
+ const list = (data.goodsList || data.posters || []).map(ensureGoods).filter(hasGoodsNo)
|
|
|
+ goodsList.value = list
|
|
|
+ activeGoodsId.value = list.some((item) => item.id === (data.activeGoodsId || data.activePosterId))
|
|
|
+ ? (data.activeGoodsId || data.activePosterId)
|
|
|
+ : (list[0]?.id || '')
|
|
|
+ if (data.logoPath) logoPath.value = data.logoPath
|
|
|
+ goodsList.value.forEach((item) => ensureGoodsAngles(item.hero.goods_art_no))
|
|
|
+ } catch { /* ignore */ }
|
|
|
+}
|
|
|
+
|
|
|
+function selectLogo(path: string) {
|
|
|
+ logoPath.value = path
|
|
|
+ saveLogoCache(path)
|
|
|
+}
|
|
|
+
|
|
|
+function clearLogo() {
|
|
|
+ logoPath.value = ''
|
|
|
+ saveLogoCache('')
|
|
|
+}
|
|
|
+
|
|
|
+function onLogoError() {
|
|
|
+ logoPath.value = ''
|
|
|
+ saveLogoCache('')
|
|
|
+ ElMessage.warning('LOGO 文件不存在或无法加载,请重新上传')
|
|
|
+}
|
|
|
+
|
|
|
+function openLogoUpload() {
|
|
|
+ clientStore.ipc.removeAllListeners(icpList.utils.openImage)
|
|
|
+ clientStore.ipc.send(icpList.utils.openImage)
|
|
|
+ clientStore.ipc.on(icpList.utils.openImage, async (_event: any, result: any) => {
|
|
|
+ clientStore.ipc.removeAllListeners(icpList.utils.openImage)
|
|
|
+ const filePath = result?.filePath
|
|
|
+ if (!filePath) return
|
|
|
+ try {
|
|
|
+ const saved = await clientStore.ipc.invoke(icpList.generate.addLogo, { logo_path: filePath })
|
|
|
+ const path = saved?.data?.logo || filePath
|
|
|
+ logoPath.value = path
|
|
|
+ saveLogoCache(path)
|
|
|
+ await loadLogoList(false)
|
|
|
+ } catch {
|
|
|
+ logoPath.value = filePath
|
|
|
+ saveLogoCache(filePath)
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function loadLogoList(applyCache = true) {
|
|
|
+ try {
|
|
|
+ const result = await clientStore.ipc.invoke(icpList.generate.getLogoList)
|
|
|
+ logoList.value = result?.data || []
|
|
|
+ if (!applyCache) return
|
|
|
+ const cached = loadLogoCache()
|
|
|
+ if (cached) logoPath.value = cached
|
|
|
+ else if (!logoPath.value && logoList.value.length) {
|
|
|
+ logoPath.value = logoList.value[0]
|
|
|
+ saveLogoCache(logoPath.value)
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ logoList.value = []
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function goodsName(goods: GoodsJob, index: number) {
|
|
|
+ return goods.hero.goods_art_no || `货${index + 1}`
|
|
|
+}
|
|
|
+
|
|
|
+function validateGoods(goods: GoodsJob, name: string) {
|
|
|
+ if (!goods.hero.goods_art_no || !goods.hero.action_name || !goods.hero.image_path) {
|
|
|
+ return `${name}请完整填写主图货号和拍摄图`
|
|
|
+ }
|
|
|
+ const slots = goods.colorSlots.filter((slot) => slot.goods_art_no || slot.action_name || slot.image_path || slot.color_name)
|
|
|
+ if (!slots.length) return `${name}请至少完整填写 1 个配色`
|
|
|
+ for (let i = 0; i < slots.length; i++) {
|
|
|
+ const slot = slots[i]
|
|
|
+ if (!slot.goods_art_no || !slot.action_name || !slot.image_path || !slot.color_name) {
|
|
|
+ return `${name}第${i + 1}个配色未填写完整`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const infos = goods.infoRows.filter((row) => row.label.trim() || row.value.trim())
|
|
|
+ if (!infos.length) return `${name}请至少填写一行商品信息`
|
|
|
+ for (const row of infos) {
|
|
|
+ if (!row.label.trim() || !row.value.trim()) return `${name}商品信息需要同时填写名称和内容`
|
|
|
+ }
|
|
|
+ if (!goods.templateIds.length) return `${name}请至少勾选 1 套要出的样式`
|
|
|
+ for (const id of goods.templateIds) {
|
|
|
+ if (!findPosterTemplate(id)?.image_url) return `${name}所选样式缺少参考图,请重新勾选`
|
|
|
+ }
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+function validateForm() {
|
|
|
+ if (!logoPath.value) return '请先选择 LOGO'
|
|
|
+ if (!goodsList.value.length) return '请至少加一款货'
|
|
|
+ for (let i = 0; i < goodsList.value.length; i++) {
|
|
|
+ const msg = validateGoods(goodsList.value[i], goodsName(goodsList.value[i], i))
|
|
|
+ if (msg) return msg
|
|
|
+ }
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+function infoPair(goods: GoodsJob, key: InfoRow['key'], fallbackName: string) {
|
|
|
+ const row = goods.infoRows.find((item) => item.key === key)
|
|
|
+ return {
|
|
|
+ name: fallbackName,
|
|
|
+ value: (row?.value || '').trim(),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildShoesComposeData() {
|
|
|
+ const list: any[] = []
|
|
|
+ goodsList.value.forEach((goods) => {
|
|
|
+ const images = goods.colorSlots
|
|
|
+ .filter((slot) => slot.image_path && slot.color_name)
|
|
|
+ .map((slot) => ({
|
|
|
+ image_path: slot.image_path,
|
|
|
+ color: slot.color_name,
|
|
|
+ }))
|
|
|
+ goods.templateIds.forEach((templateId) => {
|
|
|
+ const tpl = findPosterTemplate(templateId)
|
|
|
+ if (!tpl?.image_url) return
|
|
|
+ const item: any = {
|
|
|
+ logo: logoPath.value,
|
|
|
+ reference_image: tpl.image_url,
|
|
|
+ number_mid: infoPair(goods, 'number_mid', '货号(中童)'),
|
|
|
+ size_mid: infoPair(goods, 'size_mid', '尺码 Size'),
|
|
|
+ number_big: infoPair(goods, 'number_big', '货号(大童)'),
|
|
|
+ size_big: infoPair(goods, 'size_big', '尺码 Size'),
|
|
|
+ box: infoPair(goods, 'box', '装箱 Box'),
|
|
|
+ image_path: goods.hero.image_path,
|
|
|
+ images,
|
|
|
+ }
|
|
|
+ const keywords = (goods.keywords || '').trim()
|
|
|
+ if (keywords) item.keywords = keywords
|
|
|
+ list.push(item)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ return list
|
|
|
+}
|
|
|
+
|
|
|
+function formatRemain(remain: number) {
|
|
|
+ const n = Math.max(0, remain || 0)
|
|
|
+ const m = Math.floor(n / 60)
|
|
|
+ const s = n % 60
|
|
|
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
|
+}
|
|
|
+
|
|
|
+function calcFakePercent(task: ProgressTask) {
|
|
|
+ if (task.fail || task.status === -1) return 0
|
|
|
+ if (task.status === 2 && task.image) return 100
|
|
|
+ const elapsed = Math.max(0, (Date.now() - (task.startedAt || Date.now())) / 1000)
|
|
|
+ let p = 2
|
|
|
+ if (elapsed <= 18) {
|
|
|
+ p = 2 + (elapsed / 18) * 78
|
|
|
+ } else if (elapsed <= 48) {
|
|
|
+ p = 80 + ((elapsed - 18) / 30) * 15
|
|
|
+ } else {
|
|
|
+ p = 95 + Math.floor((elapsed - 48) / 0.8) * 0.1
|
|
|
+ }
|
|
|
+ return Math.min(99.9, Math.round(p * 10) / 10)
|
|
|
+}
|
|
|
+
|
|
|
+function displayPercent(task: ProgressTask) {
|
|
|
+ if (task.fail || task.status === -1) return 0
|
|
|
+ if (task.status === 2 && task.image) return 100
|
|
|
+ return task.fakePercent ?? calcFakePercent(task)
|
|
|
+}
|
|
|
+
|
|
|
+function formatPercentText(val: number) {
|
|
|
+ if (val >= 100) return '100%'
|
|
|
+ return `${Number(val).toFixed(1)}%`
|
|
|
+}
|
|
|
+
|
|
|
+function taskStatusMeta(task: ProgressTask) {
|
|
|
+ if (task.fail || task.status === -1) return { statusText: '失败', statusClass: 'is-fail' }
|
|
|
+ if (task.status === 2 && task.image) return { statusText: '已完成', statusClass: 'is-done' }
|
|
|
+ if (task.remain <= 0) return { statusText: '即将完成…', statusClass: 'is-wait' }
|
|
|
+ if (task.status === 1) return { statusText: '生成中', statusClass: 'is-wait' }
|
|
|
+ return { statusText: '排队中', statusClass: 'is-wait' }
|
|
|
+}
|
|
|
+
|
|
|
+function refreshTaskMeta() {
|
|
|
+ progressTasks.value = progressTasks.value.map((task) => ({ ...task, ...taskStatusMeta(task) }))
|
|
|
+}
|
|
|
+
|
|
|
+function stopTimers() {
|
|
|
+ pollActive = false
|
|
|
+ pollBusy = false
|
|
|
+ if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
|
|
|
+ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null }
|
|
|
+}
|
|
|
+
|
|
|
+function startCountdown() {
|
|
|
+ if (countdownTimer) clearInterval(countdownTimer)
|
|
|
+ countdownTimer = setInterval(() => {
|
|
|
+ progressTasks.value = progressTasks.value.map((task) => {
|
|
|
+ if (task.fail || task.status === -1) return { ...task, fakePercent: 0, remain: 0 }
|
|
|
+ if (task.status === 2 && task.image) return { ...task, fakePercent: 100, remain: 0 }
|
|
|
+ const elapsed = Math.max(0, (Date.now() - (task.startedAt || Date.now())) / 1000)
|
|
|
+ return {
|
|
|
+ ...task,
|
|
|
+ fakePercent: calcFakePercent(task),
|
|
|
+ remain: Math.max(0, Math.ceil(30 - elapsed)),
|
|
|
+ }
|
|
|
+ })
|
|
|
+ refreshTaskMeta()
|
|
|
+ }, 200)
|
|
|
+}
|
|
|
+
|
|
|
+async function pollOnce() {
|
|
|
+ if (!generateIds.length) return
|
|
|
+ try {
|
|
|
+ const res: any = await searchPosterProgress({
|
|
|
+ generate_ids: generateIds,
|
|
|
+ type: generateChannel,
|
|
|
+ })
|
|
|
+ const list = Array.isArray(res?.data) ? res.data : (res?.data ? [res.data] : [])
|
|
|
+ const map: Record<string, any> = {}
|
|
|
+ list.forEach((item: any) => {
|
|
|
+ if (item && item.id != null) {
|
|
|
+ map[item.id] = item
|
|
|
+ map[String(item.id)] = item
|
|
|
+ }
|
|
|
+ })
|
|
|
+ progressTasks.value = progressTasks.value.map((task) => {
|
|
|
+ const item = map[task.id] ?? map[String(task.id)]
|
|
|
+ if (!item) return task
|
|
|
+ const st = Number(item.status)
|
|
|
+ if (st === 2) {
|
|
|
+ const urls = item.result_image_urls || []
|
|
|
+ const img = urls[0] || item.image || item.image_url || ''
|
|
|
+ if (!img) return { ...task, status: 1, pending: true, fakePercent: calcFakePercent(task) }
|
|
|
+ return { ...task, status: 2, pending: false, fail: false, image: img, remain: 0, fakePercent: 100 }
|
|
|
+ }
|
|
|
+ if (st === -1) return { ...task, status: -1, pending: false, fail: true, remain: 0, fakePercent: 0 }
|
|
|
+ return { ...task, status: Number.isNaN(st) ? task.status : st, pending: true }
|
|
|
+ })
|
|
|
+ refreshTaskMeta()
|
|
|
+ const pending = progressTasks.value.some((task) => task.status === 0 || task.status === 1 || task.pending)
|
|
|
+ const allFail = progressTasks.value.length > 0 && progressTasks.value.every((task) => task.fail || task.status === -1)
|
|
|
+ if (allFail) {
|
|
|
+ stopTimers()
|
|
|
+ submitting.value = false
|
|
|
+ submitPhase.value = ''
|
|
|
+ setPosterWindowClosable(true)
|
|
|
+ ElMessage.error('生成失败,请稍后重试')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!pending) {
|
|
|
+ stopTimers()
|
|
|
+ submitting.value = false
|
|
|
+ submitPhase.value = ''
|
|
|
+ setPosterWindowClosable(true)
|
|
|
+ const okCount = progressTasks.value.filter((task) => task.status === 2 && task.image).length
|
|
|
+ notifyGenerateDone(okCount)
|
|
|
+ }
|
|
|
+ } catch (e: any) {
|
|
|
+ const msg = e?.message || ''
|
|
|
+ const st = e?.status || e?.code
|
|
|
+ if (st === 401 || /未登录|登录状态已失效|请先登录/.test(msg)) {
|
|
|
+ stopTimers()
|
|
|
+ submitting.value = false
|
|
|
+ submitPhase.value = ''
|
|
|
+ setPosterWindowClosable(true)
|
|
|
+ ElMessage.error(msg || '登录已失效,请重新登录后再试')
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function startPoll() {
|
|
|
+ stopTimers()
|
|
|
+ pollActive = true
|
|
|
+ startCountdown()
|
|
|
+ const tick = async () => {
|
|
|
+ if (!pollActive) return
|
|
|
+ if (pollBusy) return
|
|
|
+ pollBusy = true
|
|
|
+ try {
|
|
|
+ await pollOnce()
|
|
|
+ } finally {
|
|
|
+ pollBusy = false
|
|
|
+ if (pollActive) pollTimer = setTimeout(tick, 2000)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ tick()
|
|
|
+}
|
|
|
+
|
|
|
+function flattenTaskNames() {
|
|
|
+ const names: string[] = []
|
|
|
+ goodsList.value.forEach((goods, index) => {
|
|
|
+ goods.templateIds.forEach((id) => {
|
|
|
+ const tpl = findPosterTemplate(id)
|
|
|
+ names.push(`${goodsName(goods, index)} · ${tpl?.name || '样式'}`)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ return names
|
|
|
+}
|
|
|
+
|
|
|
+async function submitGenerate() {
|
|
|
+ if (submitting.value) return
|
|
|
+ const error = validateForm()
|
|
|
+ if (error) {
|
|
|
+ console.log('[湛江场景图] 校验未通过', error)
|
|
|
+ ElMessage.warning(error)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ submitting.value = true
|
|
|
+ progressVisible.value = true
|
|
|
+ createErrorMsg.value = ''
|
|
|
+ setPosterWindowClosable(false)
|
|
|
+ submitPhase.value = '正在提交任务…'
|
|
|
+ progressTasks.value = []
|
|
|
+ try {
|
|
|
+ const payload = {
|
|
|
+ data: buildShoesComposeData(),
|
|
|
+ }
|
|
|
+ console.log('[湛江场景图] 提交 payload', JSON.parse(JSON.stringify(payload)))
|
|
|
+ if (!payload.data.length) throw new Error('没有可提交的湛江场景图任务')
|
|
|
+ const result = await clientStore.ipc.invoke(icpList.generate.generatePoster, payload)
|
|
|
+ console.log('[湛江场景图] Python 返回', result)
|
|
|
+ if (!result || result.code !== 0) throw new Error(result?.msg || result?.message || '湛江场景图任务创建失败')
|
|
|
+ const picked = pickGenerateIds(result.data)
|
|
|
+ console.log('[湛江场景图] 解析任务 ID', picked, 'data=', result.data)
|
|
|
+ generateIds = picked.ids
|
|
|
+ generateChannel = picked.channel || result.data?.generate_channel || 'poster'
|
|
|
+ if (!generateIds.length) throw new Error('未返回生成任务 ID')
|
|
|
+ const names = flattenTaskNames()
|
|
|
+ const now = Date.now()
|
|
|
+ progressTasks.value = generateIds.map((id: string | number, index: number) => ({
|
|
|
+ id,
|
|
|
+ name: names[index] || `任务 ${index + 1}`,
|
|
|
+ remain: 30,
|
|
|
+ status: 0,
|
|
|
+ pending: true,
|
|
|
+ fail: false,
|
|
|
+ image: '',
|
|
|
+ statusText: '排队中',
|
|
|
+ statusClass: 'is-wait',
|
|
|
+ startedAt: now,
|
|
|
+ fakePercent: 2,
|
|
|
+ }))
|
|
|
+ submitPhase.value = '正在生成中'
|
|
|
+ startPoll()
|
|
|
+ } catch (e: any) {
|
|
|
+ console.log('[湛江场景图] 创建失败', e, e?.message, e?.msg, e)
|
|
|
+ submitting.value = false
|
|
|
+ const msg = e?.message || e?.msg || '湛江场景图任务创建失败'
|
|
|
+ createErrorMsg.value = msg
|
|
|
+ submitPhase.value = ''
|
|
|
+ setPosterWindowClosable(true)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function setPosterWindowClosable(closable: boolean) {
|
|
|
+ try {
|
|
|
+ clientStore.ipc.invoke(icpList.utils.setWindowClosable, {
|
|
|
+ id: 'posterGenerate',
|
|
|
+ closable,
|
|
|
+ })
|
|
|
+ } catch { /* ignore */ }
|
|
|
+}
|
|
|
+
|
|
|
+function notifyGenerateDone(okCount: number) {
|
|
|
+ progressVisible.value = true
|
|
|
+ const title = '湛江场景图生成完成'
|
|
|
+ const message = `已完成 ${okCount} 张湛江场景图`
|
|
|
+ ElNotification({
|
|
|
+ title,
|
|
|
+ message,
|
|
|
+ type: 'success',
|
|
|
+ duration: 8000,
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ if (typeof Notification === 'undefined') return
|
|
|
+ if (Notification.permission === 'granted') {
|
|
|
+ new Notification(title, { body: message })
|
|
|
+ } else if (Notification.permission !== 'denied') {
|
|
|
+ Notification.requestPermission().then((permission) => {
|
|
|
+ if (permission === 'granted') new Notification(title, { body: message })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } catch { /* ignore */ }
|
|
|
+}
|
|
|
+
|
|
|
+function onProgressClose() {
|
|
|
+ if (!submitting.value) stopTimers()
|
|
|
+}
|
|
|
+
|
|
|
+function historyImage(item: any) {
|
|
|
+ const urls = item?.result_image_urls || item?.images || []
|
|
|
+ return urls[0] || item?.image || item?.image_url || item?.result_image || ''
|
|
|
+}
|
|
|
+
|
|
|
+function historyTime(item: any) {
|
|
|
+ return item.day || item.created_at || item.create_time || item.updated_at || ''
|
|
|
+}
|
|
|
+
|
|
|
+async function saveOneImage(url: string, name?: string) {
|
|
|
+ if (!url) return
|
|
|
+ try {
|
|
|
+ const result = await clientStore.ipc.invoke(icpList.utils.saveRemoteImage, {
|
|
|
+ url,
|
|
|
+ defaultName: `${String(name || '湛江场景图').replace(/[\\/:*?"<>|]/g, '_').slice(0, 40)}.png`,
|
|
|
+ })
|
|
|
+ if (result?.code === 0) ElMessage.success('已保存到 ' + result.path)
|
|
|
+ else if (result?.msg && result.msg !== '已取消保存') ElMessage.error(result.msg)
|
|
|
+ } catch (e: any) {
|
|
|
+ ElMessage.error(e?.message || '保存失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function saveDoneImages() {
|
|
|
+ const items = progressTasks.value
|
|
|
+ .filter((task) => task.image)
|
|
|
+ .map((task) => ({ url: task.image, name: task.name }))
|
|
|
+ if (!items.length) {
|
|
|
+ ElMessage.warning('还没有可保存的图片')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const result = await clientStore.ipc.invoke(icpList.utils.saveRemoteImages, { items })
|
|
|
+ if (result?.code === 0) ElMessage.success(`已保存 ${result.count || items.length} 张到 ${result.path}`)
|
|
|
+ else if (result?.msg && result.msg !== '已取消保存') ElMessage.error(result.msg)
|
|
|
+ } catch (e: any) {
|
|
|
+ ElMessage.error(e?.message || '保存失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function loadHistory(reset = false) {
|
|
|
+ if (!reset && (historyLoadingMore.value || historyFinished.value || historyLoading.value)) return
|
|
|
+ if (reset) {
|
|
|
+ historyPage.value = 1
|
|
|
+ historyFinished.value = false
|
|
|
+ historyList.value = []
|
|
|
+ historyLoading.value = true
|
|
|
+ } else {
|
|
|
+ historyLoadingMore.value = true
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res: any = await fetchPosterHistory({
|
|
|
+ current: historyPage.value,
|
|
|
+ size: historyPageSize,
|
|
|
+ }, false)
|
|
|
+ const parsed = normalizePosterHistory(res)
|
|
|
+ historyList.value = reset ? parsed.list : historyList.value.concat(parsed.list)
|
|
|
+ historyTotal.value = parsed.total
|
|
|
+ if (!parsed.list.length || parsed.list.length < historyPageSize || (parsed.total && historyList.value.length >= parsed.total)) {
|
|
|
+ historyFinished.value = true
|
|
|
+ } else {
|
|
|
+ historyPage.value += 1
|
|
|
+ }
|
|
|
+ } catch (e: any) {
|
|
|
+ if (reset) {
|
|
|
+ historyList.value = []
|
|
|
+ historyTotal.value = 0
|
|
|
+ }
|
|
|
+ ElMessage.error(e?.message || e?.msg || '历史记录加载失败')
|
|
|
+ } finally {
|
|
|
+ historyLoading.value = false
|
|
|
+ historyLoadingMore.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function onHistoryScroll(e: Event) {
|
|
|
+ const el = e.target as HTMLElement
|
|
|
+ if (!el) return
|
|
|
+ if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120) {
|
|
|
+ loadHistory(false)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function openHistory() {
|
|
|
+ historyVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+watch(() => currentGoods.value?.hero.goods_art_no, (artNo) => {
|
|
|
+ if (artNo) ensureGoodsAngles(artNo)
|
|
|
+})
|
|
|
+
|
|
|
+watch([logoPath, goodsList, activeGoodsId], () => { saveDraft() }, { deep: true })
|
|
|
+
|
|
|
+async function loadTemplates() {
|
|
|
+ templatesLoading.value = true
|
|
|
+ try {
|
|
|
+ templates.value = await fetchPosterTemplates(templateKeywords.value)
|
|
|
+ if (!templates.value.length) ElMessage.warning('暂无可用湛江场景图样式')
|
|
|
+ } catch (e: any) {
|
|
|
+ templates.value = []
|
|
|
+ ElMessage.error(e?.message || e?.msg || '湛江场景图样式加载失败')
|
|
|
+ } finally {
|
|
|
+ templatesLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function onTemplateKeywordsInput() {
|
|
|
+ if (keywordsTimer) clearTimeout(keywordsTimer)
|
|
|
+ keywordsTimer = setTimeout(() => { loadTemplates() }, 400)
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ loadDraft()
|
|
|
+ await Promise.all([loadLogoList(true), loadTemplates()])
|
|
|
+})
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ stopTimers()
|
|
|
+ hideTplPreview()
|
|
|
+ if (keywordsTimer) clearTimeout(keywordsTimer)
|
|
|
+ setPosterWindowClosable(true)
|
|
|
+ clientStore.ipc.removeAllListeners(icpList.utils.openImage)
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.poster-page {
|
|
|
+ height: calc(100vh - 50px);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ background:
|
|
|
+ radial-gradient(1200px 420px at 8% -10%, rgba(41, 87, 255, 0.08), transparent 55%),
|
|
|
+ #f4f6fb;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.poster-toolbar {
|
|
|
+ flex-shrink: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16px;
|
|
|
+ padding: 14px 24px;
|
|
|
+ background: rgba(255, 255, 255, 0.92);
|
|
|
+ border-bottom: 1px solid #e8edf5;
|
|
|
+ backdrop-filter: blur(8px);
|
|
|
+}
|
|
|
+.logo-box {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ min-width: 200px;
|
|
|
+}
|
|
|
+.logo-box__label {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #666;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+.logo-card {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border: 1px dashed #c9d3e8;
|
|
|
+ border-radius: 12px;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+ background: #f7f9ff;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ transition: border-color .15s, box-shadow .15s;
|
|
|
+}
|
|
|
+.logo-card:hover { border-color: #2957ff; }
|
|
|
+.logo-card.active { border-style: solid; border-color: #2957ff; box-shadow: 0 0 0 3px rgba(41,87,255,.12); }
|
|
|
+.logo-card__img { width: 100%; height: 100%; object-fit: contain; }
|
|
|
+.logo-card__empty { font-size: 12px; color: #999; }
|
|
|
+.logo-list { display: flex; gap: 6px; }
|
|
|
+.logo-list__item {
|
|
|
+ width: 36px;
|
|
|
+ height: 36px;
|
|
|
+ border: 1px solid #eee;
|
|
|
+ border-radius: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.logo-list__item.active { border-color: #2957ff; }
|
|
|
+.logo-list__item img { width: 100%; height: 100%; object-fit: contain; }
|
|
|
+.toolbar-actions { flex: 1; display: flex; gap: 8px; align-items: center; }
|
|
|
+.gen-warn-icon {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 18px;
|
|
|
+ color: #e6a23c;
|
|
|
+ cursor: default;
|
|
|
+ outline: none;
|
|
|
+}
|
|
|
+.poster-main { flex: 1; display: flex; min-height: 0; }
|
|
|
+.list-pane {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 1200px;
|
|
|
+ margin: 0 auto;
|
|
|
+ padding: 22px 24px 28px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+.list-pane__head {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 14px;
|
|
|
+}
|
|
|
+.list-pane__actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.list-pane__title {
|
|
|
+ display: flex;
|
|
|
+ align-items: baseline;
|
|
|
+ gap: 10px;
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #111827;
|
|
|
+ letter-spacing: -0.02em;
|
|
|
+}
|
|
|
+.list-pane__sub {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #6b7280;
|
|
|
+ font-weight: 400;
|
|
|
+}
|
|
|
+.list-empty {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+.empty-card {
|
|
|
+ width: 360px;
|
|
|
+ padding: 40px 28px;
|
|
|
+ text-align: center;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px dashed #c9d4ea;
|
|
|
+ border-radius: 20px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: border-color .15s, box-shadow .15s, transform .15s;
|
|
|
+}
|
|
|
+.empty-card:hover {
|
|
|
+ border-color: #2957ff;
|
|
|
+ box-shadow: 0 12px 32px rgba(41, 87, 255, 0.12);
|
|
|
+ transform: translateY(-2px);
|
|
|
+}
|
|
|
+.empty-card__plus {
|
|
|
+ width: 52px;
|
|
|
+ height: 52px;
|
|
|
+ margin: 0 auto 12px;
|
|
|
+ border-radius: 16px;
|
|
|
+ background: #eef2ff;
|
|
|
+ color: #2957ff;
|
|
|
+ font-size: 32px;
|
|
|
+ line-height: 48px;
|
|
|
+ font-weight: 300;
|
|
|
+}
|
|
|
+.empty-card__title { font-size: 16px; font-weight: 700; color: #111827; }
|
|
|
+.empty-card__desc { margin-top: 6px; font-size: 13px; color: #6b7280; }
|
|
|
+.goods-grid {
|
|
|
+ flex: 1;
|
|
|
+ overflow: auto;
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
|
+ gap: 16px;
|
|
|
+ align-content: start;
|
|
|
+}
|
|
|
+.goods-card {
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e8edf5;
|
|
|
+ border-radius: 16px;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+ box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
|
|
+ transition: border-color .15s, box-shadow .15s, transform .15s;
|
|
|
+}
|
|
|
+.goods-card:hover {
|
|
|
+ border-color: #c5d0ff;
|
|
|
+ box-shadow: 0 12px 28px rgba(41, 87, 255, 0.12);
|
|
|
+ transform: translateY(-2px);
|
|
|
+}
|
|
|
+.goods-card__cover {
|
|
|
+ height: 150px;
|
|
|
+ background: #eef1f6;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #9aa3b2;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+.goods-card__cover .el-image { width: 100%; height: 100%; }
|
|
|
+.goods-card__body { padding: 12px 14px 14px; text-align: left; }
|
|
|
+.goods-card__no { font-size: 16px; font-weight: 700; color: #111827; text-align: left; }
|
|
|
+.goods-card__tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
|
+.pill {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ height: 22px;
|
|
|
+ padding: 0 8px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: #f3f4f6;
|
|
|
+ color: #6b7280;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+.pill.ok { background: #eef2ff; color: #2957ff; }
|
|
|
+.goods-card__ops { display: flex; gap: 8px; margin-top: 12px; }
|
|
|
+.goods-card--add {
|
|
|
+ min-height: 260px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 8px;
|
|
|
+ border: 1px dashed #c9d4ea;
|
|
|
+ color: #2957ff;
|
|
|
+ background: rgba(255,255,255,.7);
|
|
|
+ box-shadow: none;
|
|
|
+}
|
|
|
+.goods-card--add:hover { background: #eef2ff; border-color: #2957ff; }
|
|
|
+.gen-btn { height: 40px; padding: 0 22px; border-radius: 10px; font-weight: 600; }
|
|
|
+.tpl-hover-preview {
|
|
|
+ position: fixed;
|
|
|
+ z-index: 4000;
|
|
|
+ width: 200px;
|
|
|
+ height: 200px;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
|
|
+ overflow: hidden;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+.tpl-hover-preview img {
|
|
|
+ width: 200px;
|
|
|
+ height: 200px;
|
|
|
+ object-fit: contain;
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+.is-empty { color: #c0c4cc; }
|
|
|
+.edit-layout {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 340px 1fr;
|
|
|
+ gap: 16px;
|
|
|
+ height: 68vh;
|
|
|
+ min-height: 0;
|
|
|
+}
|
|
|
+.edit-col {
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ height: 100%;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding-right: 6px;
|
|
|
+}
|
|
|
+.edit-col--styles {
|
|
|
+ background: #f7f8fc;
|
|
|
+ border-radius: 14px;
|
|
|
+ padding: 14px;
|
|
|
+}
|
|
|
+.style-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(2, 1fr);
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.style-item {
|
|
|
+ border: 1px solid #e6ebf4;
|
|
|
+ border-radius: 12px;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+ background: #fff;
|
|
|
+ transition: border-color .15s, transform .15s;
|
|
|
+}
|
|
|
+.style-item:hover { transform: translateY(-1px); }
|
|
|
+.style-item.on { border-color: #2957ff; box-shadow: 0 0 0 2px rgba(41,87,255,.18); }
|
|
|
+.style-item__img {
|
|
|
+ width: 100%;
|
|
|
+ height: 88px;
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+.style-item__name {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #374151;
|
|
|
+ padding: 6px 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.locked-no {
|
|
|
+ margin-left: auto;
|
|
|
+ height: 26px;
|
|
|
+ line-height: 26px;
|
|
|
+ padding: 0 10px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: #eef2ff;
|
|
|
+ color: #2957ff;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+.artno-short { width: 150px; flex: 0 0 150px; }
|
|
|
+.color-input { width: 96px; flex: 0 0 96px; }
|
|
|
+.slot-top {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: nowrap;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ text-align: left;
|
|
|
+}
|
|
|
+.slot-top__label {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #666;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.shot-row {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.shot {
|
|
|
+ position: relative;
|
|
|
+ width: 72px;
|
|
|
+ height: 72px;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+ background: #eef1f6;
|
|
|
+ flex: 0 0 72px;
|
|
|
+}
|
|
|
+.shot--sm { width: 64px; height: 64px; flex-basis: 64px; }
|
|
|
+.shot.on { box-shadow: 0 0 0 2px #2957ff; }
|
|
|
+.shot.disabled { cursor: not-allowed; opacity: .4; }
|
|
|
+.shot__img, .shot__empty {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #bbb;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+.shot__cap {
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ padding: 12px 6px 4px;
|
|
|
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.55));
|
|
|
+ color: #fff;
|
|
|
+ font-size: 11px;
|
|
|
+ text-align: left;
|
|
|
+ line-height: 1.2;
|
|
|
+}
|
|
|
+.add-dialog { padding: 4px 0 8px; }
|
|
|
+.add-dialog__picked { margin-top: 10px; font-size: 13px; color: #2957ff; }
|
|
|
+.block { background: #f7f8fc; border-radius: 14px; padding: 14px; margin-bottom: 12px; }
|
|
|
+.block-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #333;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.block-title__bar { width: 3px; height: 14px; background: #2957ff; border-radius: 2px; }
|
|
|
+.block-title__sub { font-size: 12px; color: #999; font-weight: 400; }
|
|
|
+.field { margin-bottom: 10px; text-align: left; }
|
|
|
+.field__label { font-size: 12px; color: #666; margin-bottom: 6px; text-align: left; }
|
|
|
+.field-row { display: flex; gap: 8px; align-items: flex-end; }
|
|
|
+.flex-item { flex: 1; min-width: 0; }
|
|
|
+.color-field { width: 110px; flex-shrink: 0; }
|
|
|
+.add-color-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.slot-card {
|
|
|
+ border: 1px solid #e8edf5;
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 12px;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ background: #fff;
|
|
|
+ text-align: left;
|
|
|
+}
|
|
|
+.slot-card__head {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+.edit-form { max-height: 68vh; overflow: auto; padding-right: 4px; }
|
|
|
+:deep(.edit-goods-dialog .el-dialog) { border-radius: 16px; }
|
|
|
+:deep(.edit-goods-dialog .el-dialog__body) {
|
|
|
+ padding-top: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.hint { font-size: 12px; color: #999; }
|
|
|
+.angle-list { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
|
|
+.angle-item {
|
|
|
+ width: 76px;
|
|
|
+ flex: 0 0 76px;
|
|
|
+ border: 1px solid #e6ebf4;
|
|
|
+ border-radius: 10px;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+ background: #fff;
|
|
|
+}
|
|
|
+.angle-item.active { border-color: #2957ff; box-shadow: 0 0 0 2px rgba(41,87,255,.16); }
|
|
|
+.angle-item.disabled { cursor: not-allowed; opacity: .5; }
|
|
|
+.angle-item__img, .angle-item__empty {
|
|
|
+ width: 100%;
|
|
|
+ height: 52px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #bbb;
|
|
|
+ font-size: 12px;
|
|
|
+ background: #f5f5f5;
|
|
|
+}
|
|
|
+.angle-item__name { font-size: 11px; text-align: center; padding: 3px; color: #666; }
|
|
|
+.slot-card { border: 1px solid #eee; border-radius: 8px; padding: 10px; margin-bottom: 10px; background: #fff; }
|
|
|
+.slot-card__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 13px; }
|
|
|
+.slot-card__body { display: flex; gap: 10px; }
|
|
|
+.slot-thumb {
|
|
|
+ width: 56px;
|
|
|
+ height: 56px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: #f5f6f7;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #bbb;
|
|
|
+ font-size: 11px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.slot-thumb__img { width: 100%; height: 100%; }
|
|
|
+.slot-card__fields { flex: 1; min-width: 0; }
|
|
|
+.info-row {
|
|
|
+ margin-bottom: 10px;
|
|
|
+}
|
|
|
+.info-row__fields {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ width: 100%;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.info-row__label {
|
|
|
+ width: 92px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #666;
|
|
|
+ line-height: 32px;
|
|
|
+}
|
|
|
+.info-row__value {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+.pg-fail {
|
|
|
+ padding: 36px 20px 20px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.pg-fail__title {
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #333;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+.pg-fail__desc {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #f56c6c;
|
|
|
+ line-height: 1.6;
|
|
|
+ word-break: break-all;
|
|
|
+}
|
|
|
+.pg-fail__hint {
|
|
|
+ margin-top: 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+}
|
|
|
+.pg-submit {
|
|
|
+ padding: 48px 0;
|
|
|
+ text-align: center;
|
|
|
+ color: #666;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+.pg-head { margin-bottom: 16px; }
|
|
|
+.pg-head__meta {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #666;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+.pg-head__count { color: #2957ff; font-weight: 600; }
|
|
|
+.pg-list { max-height: 420px; overflow: auto; }
|
|
|
+.pg-card {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 12px;
|
|
|
+ background: #f7f8fa;
|
|
|
+ border-radius: 10px;
|
|
|
+ margin-bottom: 10px;
|
|
|
+}
|
|
|
+.pg-card__img {
|
|
|
+ width: 72px;
|
|
|
+ height: 72px;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #fff;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.pg-card__img.is-wait {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #999;
|
|
|
+ font-size: 12px;
|
|
|
+ background: #eef1f6;
|
|
|
+}
|
|
|
+.pg-card__body { flex: 1; min-width: 0; }
|
|
|
+.pg-card__top {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.pg-card__name { font-size: 14px; color: #333; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+.pg-card__status { font-size: 12px; flex-shrink: 0; }
|
|
|
+.pg-card__status.is-wait { color: #409eff; }
|
|
|
+.pg-card__status.is-done { color: #67c23a; }
|
|
|
+.pg-card__status.is-fail { color: #f56c6c; }
|
|
|
+.pg-card__foot {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-top: 6px;
|
|
|
+ min-height: 22px;
|
|
|
+}
|
|
|
+.pg-card__time { font-size: 12px; color: #999; font-variant-numeric: tabular-nums; }
|
|
|
+:deep(.history-dialog) {
|
|
|
+ height: 82vh;
|
|
|
+ margin-top: 6vh !important;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+:deep(.history-dialog .el-dialog__header) { flex-shrink: 0; }
|
|
|
+:deep(.history-dialog .el-dialog__body) {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ padding: 8px 20px 16px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+.his-wrap {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.his-grid {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow-y: auto;
|
|
|
+ overflow-x: hidden;
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
+ grid-auto-rows: max-content;
|
|
|
+ align-content: start;
|
|
|
+ align-items: start;
|
|
|
+ gap: 14px;
|
|
|
+ padding-right: 6px;
|
|
|
+ padding-bottom: 12px;
|
|
|
+}
|
|
|
+.his-card {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e8edf5;
|
|
|
+ border-radius: 12px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.his-card__pic {
|
|
|
+ width: 100%;
|
|
|
+ aspect-ratio: 3 / 4;
|
|
|
+ background: #f4f6fa;
|
|
|
+ overflow: hidden;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.his-card__img,
|
|
|
+.his-card__pic :deep(.el-image),
|
|
|
+.his-card__pic :deep(img) {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: block;
|
|
|
+ object-fit: contain;
|
|
|
+}
|
|
|
+.his-card__meta {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ height: 40px;
|
|
|
+ padding: 0 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #666;
|
|
|
+ background: #fff;
|
|
|
+ flex-shrink: 0;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+.his-card__time {
|
|
|
+ min-width: 0;
|
|
|
+ flex: 1;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ text-align: left;
|
|
|
+}
|
|
|
+.his-more {
|
|
|
+ grid-column: 1 / -1;
|
|
|
+ text-align: center;
|
|
|
+ padding: 12px 0 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+}
|
|
|
+:deep(.is-current) { background: #f3f6ff !important; }
|
|
|
+</style>
|