|
|
@@ -0,0 +1,2590 @@
|
|
|
+<template>
|
|
|
+ <BlueHeaderBar />
|
|
|
+ <div class="ss-page">
|
|
|
+ <div class="ss-top">
|
|
|
+ <div class="ss-top__title">
|
|
|
+ <b>卖点套图</b>
|
|
|
+ <span>用已经抠好的图,一次出一整套带卖点文字的场景图</span>
|
|
|
+ </div>
|
|
|
+ <div class="ss-steps">
|
|
|
+ <template v-for="(item, index) in steps" :key="item.no">
|
|
|
+ <div
|
|
|
+ class="ss-step"
|
|
|
+ :class="{ 'is-active': step === item.no, 'is-done': step > item.no, 'is-clickable': step !== item.no && canEnter(item.no) }"
|
|
|
+ @click="goStep(item.no)"
|
|
|
+ >
|
|
|
+ <span class="ss-step__no">
|
|
|
+ <el-icon v-if="step > item.no"><Check /></el-icon>
|
|
|
+ <template v-else>{{ item.no }}</template>
|
|
|
+ </span>
|
|
|
+ <span class="ss-step__text">
|
|
|
+ <b>{{ item.title }}</b>
|
|
|
+ <small>{{ item.desc }}</small>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <span v-if="index < steps.length - 1" class="ss-step__line" :class="{ 'is-done': step > item.no }" />
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div ref="mainRef" class="ss-main">
|
|
|
+ <div class="ss-inner">
|
|
|
+ <div v-show="step === 1" class="ss-stack">
|
|
|
+ <section class="ss-card">
|
|
|
+ <div class="ss-card__head">
|
|
|
+ <div class="ss-card__title">选择货号</div>
|
|
|
+ <div class="ss-card__desc">从拍摄记录里选。已经抠好的图会直接用;没有的话可以在这里重新抠图。</div>
|
|
|
+ </div>
|
|
|
+ <div v-if="categoryState === 'loading'" class="ss-muted ss-inline">
|
|
|
+ <el-icon class="is-loading"><Loading /></el-icon>正在加载品类…
|
|
|
+ </div>
|
|
|
+ <el-alert v-else-if="categoryState === 'error'" type="error" :closable="false" show-icon title="品类没有加载出来">
|
|
|
+ <div class="alert-body">
|
|
|
+ <div>{{ categoryError }}</div>
|
|
|
+ <div class="alert-actions"><el-button size="small" @click="loadCategories">重试</el-button></div>
|
|
|
+ </div>
|
|
|
+ </el-alert>
|
|
|
+ <el-alert
|
|
|
+ v-else-if="!categories.length"
|
|
|
+ type="warning"
|
|
|
+ :closable="false"
|
|
|
+ show-icon
|
|
|
+ title="暂时没有可用的品类"
|
|
|
+ description="服务端还没有给这个账号配置卖点套图品类,请联系管理员。"
|
|
|
+ />
|
|
|
+ <template v-else>
|
|
|
+ <div class="goods-row">
|
|
|
+ <div class="goods-row__search">
|
|
|
+ <GoodsArtNoSearch v-model="goodsArtNo" placeholder="输入货号搜索,或点开从最近拍摄里选" @select="onGoods" />
|
|
|
+ </div>
|
|
|
+ <el-select v-if="categories.length > 1" v-model="categoryId" class="goods-row__category" @change="onCategory">
|
|
|
+ <el-option v-for="item in categories" :key="item.id" :label="`品类:${item.name}`" :value="item.id" />
|
|
|
+ </el-select>
|
|
|
+ <span v-else class="ss-pill">品类:{{ currentCategory?.name }}</span>
|
|
|
+ <span v-if="goodsMeta" class="ss-muted">{{ goodsMeta }}</span>
|
|
|
+ </div>
|
|
|
+ <template v-if="goodsArtNo">
|
|
|
+ <div v-if="cutting" class="ss-muted ss-inline">
|
|
|
+ <el-icon class="is-loading"><Loading /></el-icon>正在用这个货号的拍摄图抠图,抠完会自动读进来…
|
|
|
+ </div>
|
|
|
+ <div v-else-if="preparing" class="ss-muted ss-inline">
|
|
|
+ <el-icon class="is-loading"><Loading /></el-icon>正在读取这个货号的抠图…
|
|
|
+ </div>
|
|
|
+ <el-alert
|
|
|
+ v-else-if="!imagePath"
|
|
|
+ type="warning"
|
|
|
+ :closable="false"
|
|
|
+ show-icon
|
|
|
+ title="这个货号没有找到拍摄图"
|
|
|
+ description="请换一个货号,或者先完成拍摄。"
|
|
|
+ />
|
|
|
+ <el-alert v-else-if="prepareError" type="warning" :closable="false" show-icon :title="prepareErrorView.title">
|
|
|
+ <div class="alert-body">
|
|
|
+ <div>{{ prepareErrorView.desc }}</div>
|
|
|
+ <div v-if="prepareErrorView.desc !== prepareError" class="alert-raw">原因:{{ prepareError }}</div>
|
|
|
+ <div class="alert-actions">
|
|
|
+ <el-button v-if="missingCutout" size="small" type="primary" :disabled="cutting" @click="recut">重新抠图</el-button>
|
|
|
+ <el-button size="small" @click="loadCutouts">重新读取</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-alert>
|
|
|
+ <el-alert v-else-if="outputExists" type="warning" :closable="false" show-icon title="这个货号已经做过卖点套图">
|
|
|
+ <div class="alert-body">
|
|
|
+ <div>结果在货号文件夹的「场景套图」里。要重新出图,先把这个文件夹删掉或改名,再点「重新检查」。</div>
|
|
|
+ <div class="alert-actions">
|
|
|
+ <el-button size="small" @click="openDir(outputDir)">打开结果文件夹</el-button>
|
|
|
+ <el-button size="small" @click="loadCutouts">重新检查</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-alert>
|
|
|
+ <div v-else-if="cutouts.length" class="goods-ok">
|
|
|
+ <el-icon><CircleCheckFilled /></el-icon>读到 {{ cutouts.length }} 张抠图,下面确认一下商品大小和机位
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section v-if="currentCategory" class="ss-card">
|
|
|
+ <div class="ss-card__head">
|
|
|
+ <div class="ss-card__title">商品有多大</div>
|
|
|
+ <div class="ss-card__desc">决定能用哪些场景,以及商品在画面里放多大。拿不准就选中号。</div>
|
|
|
+ </div>
|
|
|
+ <div class="tier-grid">
|
|
|
+ <div
|
|
|
+ v-for="item in tierOptions"
|
|
|
+ :key="item.value"
|
|
|
+ class="tier-card"
|
|
|
+ :class="{ 'is-on': tier === item.value }"
|
|
|
+ @click="pickTier(item.value)"
|
|
|
+ >
|
|
|
+ <div class="tier-card__top">
|
|
|
+ <span class="tier-card__badge">{{ item.value }}</span>
|
|
|
+ <span class="tier-card__name">{{ item.name }}</span>
|
|
|
+ <span class="tier-card__range">{{ item.range }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="tier-card__desc">{{ item.desc }}</div>
|
|
|
+ <div class="tier-card__scenes">场景:{{ sceneNames(item.value) || '暂无' }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="height-row">
|
|
|
+ <span>知道实测高度可以填上:</span>
|
|
|
+ <el-input
|
|
|
+ :model-value="heightCm"
|
|
|
+ class="height-row__input"
|
|
|
+ placeholder="可不填"
|
|
|
+ clearable
|
|
|
+ @update:model-value="onHeightInput"
|
|
|
+ >
|
|
|
+ <template #append>cm</template>
|
|
|
+ </el-input>
|
|
|
+ <span class="ss-muted">
|
|
|
+ {{ heightValue ? `已按 ${heightValue}cm 选了${tierInfo.name}` : '填了会自动选大小,也能用在尺寸图上;不填就按上面选的出图,不会编高度' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section v-if="cutouts.length && !prepareError && !outputExists" class="ss-card">
|
|
|
+ <div class="ss-card__head ss-card__head--row">
|
|
|
+ <div class="ss-card__grow">
|
|
|
+ <div class="ss-card__title">确认抠图</div>
|
|
|
+ <div class="ss-card__desc">已按拍摄顺序自动对应机位,一般不用改。哪张不对,点那张图修改。</div>
|
|
|
+ </div>
|
|
|
+ <div class="layout-switch">
|
|
|
+ <span>拍摄方式</span>
|
|
|
+ <el-radio-group v-model="layout" size="small" @change="onLayout">
|
|
|
+ <el-radio-button v-for="item in layoutOptions" :key="item.value" :label="item.value">{{ item.label }}</el-radio-button>
|
|
|
+ </el-radio-group>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="cutout-grid">
|
|
|
+ <div
|
|
|
+ v-for="item in cutouts"
|
|
|
+ :key="item.path"
|
|
|
+ class="cutout-card"
|
|
|
+ :class="{ 'is-editing': editingPath === item.path, 'is-off': isUnused(item) }"
|
|
|
+ :title="item.file"
|
|
|
+ @click="toggleEdit(item)"
|
|
|
+ >
|
|
|
+ <div class="cutout-card__img">
|
|
|
+ <img :src="toFileUrl(item.path)" :style="{ transform: `rotate(${item.rotation || 0}deg)` }" alt="" />
|
|
|
+ <span class="cutout-card__index">{{ item.index }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="cutout-card__name">
|
|
|
+ {{ item.name }}
|
|
|
+ <em v-if="duplicateSlots.has(item.slot_id)">机位重复</em>
|
|
|
+ </div>
|
|
|
+ <div class="cutout-card__tags">
|
|
|
+ <span v-for="tag in cutoutTags(item)" :key="tag.text" class="cut-tag" :class="`cut-tag--${tag.type}`">{{ tag.text }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="editingCutout" class="cutout-edit">
|
|
|
+ <div class="cutout-edit__title">第 {{ editingCutout.index }} 张 · {{ editingCutout.file }}</div>
|
|
|
+ <div class="cutout-edit__row">
|
|
|
+ <span class="cutout-edit__label">机位</span>
|
|
|
+ <el-select v-model="editingCutout.slot_id" size="small" class="cutout-edit__select" @change="onSlot(editingCutout)">
|
|
|
+ <el-option label="不使用这张" :value="0" />
|
|
|
+ <el-option v-for="slot in layoutSlots" :key="slot.id" :label="slot.name" :value="slot.id" />
|
|
|
+ </el-select>
|
|
|
+ <span class="cutout-edit__sep" />
|
|
|
+ <span class="cutout-edit__label">方向</span>
|
|
|
+ <el-button size="small" :icon="RefreshLeft" @click="rotate(editingCutout, -90)">左转</el-button>
|
|
|
+ <el-button size="small" :icon="RefreshRight" @click="rotate(editingCutout, 90)">右转</el-button>
|
|
|
+ <span class="ss-muted">现在 {{ editingCutout.rotation || 0 }}°</span>
|
|
|
+ <span class="cutout-edit__sep" />
|
|
|
+ <span v-if="isPlate(editingCutout)" class="cut-tag cut-tag--plate">已是场景底图</span>
|
|
|
+ <el-button v-else size="small" type="primary" plain @click="setPlate(editingCutout)">用这张做场景底图</el-button>
|
|
|
+ <el-button size="small" type="text" class="cutout-edit__done" @click="editingPath = ''">完成</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="cutout-legend">
|
|
|
+ <span><i class="cut-tag cut-tag--plate">场景底图</i>所有场景图都用这张生成</span>
|
|
|
+ <span><i class="cut-tag cut-tag--view">三视图</i><i class="cut-tag cut-tag--detail">细节图</i>本机用实拍排版,不扣金币</span>
|
|
|
+ <span><i class="cut-tag cut-tag--off">不使用</i>这套图里不用这张</span>
|
|
|
+ </div>
|
|
|
+ <el-alert
|
|
|
+ v-if="!plateCutout"
|
|
|
+ type="error"
|
|
|
+ :closable="false"
|
|
|
+ show-icon
|
|
|
+ title="还没有场景底图"
|
|
|
+ description="点一张角度好看的抠图,选「用这张做场景底图」。"
|
|
|
+ />
|
|
|
+ <div v-else-if="threeViewMissing.length" class="ss-muted">缺{{ threeViewMissing.join('、') }},这套不出三视图。</div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-show="step === 2" class="ss-stack">
|
|
|
+ <section class="ss-card">
|
|
|
+ <div class="ss-card__head">
|
|
|
+ <div class="ss-card__title">场景图怎么挑</div>
|
|
|
+ <div class="ss-card__desc">场景图由 AI 生成,按张扣金币;三视图、细节图在本机排版,不扣金币。</div>
|
|
|
+ </div>
|
|
|
+ <div class="basis-grid">
|
|
|
+ <div class="basis-card" :class="{ 'is-on': basis === 'selling_point' }" @click="basis = 'selling_point'">
|
|
|
+ <span class="basis-card__radio" />
|
|
|
+ <div>
|
|
|
+ <div class="basis-card__title">按卖点自动挑<span class="ss-pill ss-pill--blue">推荐</span></div>
|
|
|
+ <div class="basis-card__desc">根据下面写的卖点,从{{ tierInfo.name }}的场景里挑 {{ autoCountText }}。没写卖点也能出。</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="basis-card" :class="{ 'is-on': basis === 'scene' }" @click="basis = 'scene'">
|
|
|
+ <span class="basis-card__radio" />
|
|
|
+ <div>
|
|
|
+ <div class="basis-card__title">我自己选场景</div>
|
|
|
+ <div class="basis-card__desc">想要哪几个场景就勾哪几个,每个场景出一张。</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <template v-if="basis === 'scene'">
|
|
|
+ <div v-if="tierScenes.length" class="scene-grid">
|
|
|
+ <div
|
|
|
+ v-for="item in tierScenes"
|
|
|
+ :key="item.tag"
|
|
|
+ class="scene-card"
|
|
|
+ :class="{ 'is-on': sceneTags.includes(item.tag) }"
|
|
|
+ @click="toggleScene(item.tag)"
|
|
|
+ >
|
|
|
+ <span class="scene-card__check"><el-icon><Check /></el-icon></span>
|
|
|
+ <div class="scene-card__name">{{ item.name }}</div>
|
|
|
+ <div v-if="item.title" class="scene-card__sub">图上标题:{{ item.title }}</div>
|
|
|
+ <div v-if="item.expresses?.length" class="scene-card__sub">适合表达:{{ item.expresses.join('、') }}</div>
|
|
|
+ <div v-if="item.requires?.length" class="scene-card__need">商品要有{{ item.requires.join('、') }}才适合</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="ss-muted">{{ tierInfo.name }}还没有配置场景,请换一个大小。</div>
|
|
|
+ <div v-if="tierScenes.length" class="ss-muted">已选 {{ sceneTags.length }} 个,出 {{ sceneTags.length }} 张场景图。</div>
|
|
|
+ </template>
|
|
|
+ <div v-else class="scene-pool">
|
|
|
+ <span class="ss-muted">{{ tierInfo.name }}可用场景:</span>
|
|
|
+ <span v-for="item in tierScenes" :key="item.tag" class="pool-chip">
|
|
|
+ {{ item.name }}<em v-if="item.requires?.length">(卖点写到{{ item.requires.join('、') }}才会用)</em>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="ss-card">
|
|
|
+ <div class="ss-card__head">
|
|
|
+ <div class="ss-card__title">卖点文字<span class="ss-optional">可不填</span></div>
|
|
|
+ <div class="ss-card__desc">
|
|
|
+ 一行写一条,会原样印在场景图上,不会改写。{{ basis === 'selling_point' ? '也用来决定挑哪些场景。' : '' }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-input v-model="sellingPoints" type="textarea" :rows="5" resize="none" :placeholder="sellingPlaceholder" />
|
|
|
+ <div class="ss-muted">带数字的参数(比如续航 12 小时、40W、IPX6)不印在场景图上,会单独出一张参数图。</div>
|
|
|
+ <div v-if="!sellingLines.length && visibleFeatures.length" class="visible-box">
|
|
|
+ <div v-if="hasMaterials" class="ss-muted">没写卖点,会从「更多资料」里整理;资料里整理不出时用通用场景,不再用画面特征。</div>
|
|
|
+ <template v-else>
|
|
|
+ <div class="visible-box__head">
|
|
|
+ <span>没写卖点时,用图上看得到的特征当卖点(看不到的参数不会写):</span>
|
|
|
+ <el-button v-if="layout === 'handheld' && verifiedKeys.length" size="small" type="text" @click="fillVerified">
|
|
|
+ 勾选横放手提款已核对的 {{ verifiedKeys.length }} 项
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div class="chip-row">
|
|
|
+ <span
|
|
|
+ v-for="item in visibleFeatures"
|
|
|
+ :key="item.key"
|
|
|
+ class="chip"
|
|
|
+ :class="{ 'is-on': visibleKeys.includes(item.key) }"
|
|
|
+ @click="toggleVisible(item.key)"
|
|
|
+ >
|
|
|
+ <el-icon v-if="visibleKeys.includes(item.key)"><Check /></el-icon>{{ item.label }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ <el-collapse v-model="moreOpen" class="more">
|
|
|
+ <el-collapse-item name="more">
|
|
|
+ <template #title>
|
|
|
+ <span class="more__title">更多资料</span>
|
|
|
+ <span class="ss-muted">可选:尺寸、参数、资料 Excel</span>
|
|
|
+ </template>
|
|
|
+ <div v-if="sizeFields.length" class="more-group">
|
|
|
+ <div class="more-group__title">尺寸<span class="ss-muted">宽度、深度、重量,加上第 1 步的高度都填了,会多出一张尺寸图</span></div>
|
|
|
+ <div class="material-grid">
|
|
|
+ <label v-for="key in sizeFields" :key="key" class="material-item">
|
|
|
+ <span>{{ key }}</span>
|
|
|
+ <el-input v-model="materialValues[key]" size="small" clearable :placeholder="fieldHints[key] || '可不填'" />
|
|
|
+ </label>
|
|
|
+ <div class="material-item">
|
|
|
+ <span>高度</span>
|
|
|
+ <span class="material-item__value">{{ heightValue ? `${heightValue}cm(第 1 步填的)` : '在第 1 步填' }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="otherFields.length" class="more-group">
|
|
|
+ <div class="more-group__title">参数<span class="ss-muted">没写卖点时,会从这些资料里整理卖点和参数图</span></div>
|
|
|
+ <div class="material-grid">
|
|
|
+ <label v-for="key in otherFields" :key="key" class="material-item">
|
|
|
+ <span>{{ key }}</span>
|
|
|
+ <el-input v-model="materialValues[key]" size="small" clearable :placeholder="fieldHints[key] || '可不填'" />
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="more-group">
|
|
|
+ <div class="more-group__title">资料 Excel<span class="ss-muted">上面空着的项会从表里补;没写卖点时也会用表里的卖点</span></div>
|
|
|
+ <div class="excel-row">
|
|
|
+ <el-button size="small" @click="pickExcel">选择 Excel</el-button>
|
|
|
+ <template v-if="excelPath">
|
|
|
+ <span class="excel-row__name" :title="excelPath">{{ fileName(excelPath) }}</span>
|
|
|
+ <el-button size="small" type="text" @click="excelPath = ''">移除</el-button>
|
|
|
+ </template>
|
|
|
+ <span v-else class="ss-muted">两列「字段 / 内容」,或者一行一个货号、表头带「货号」</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-collapse-item>
|
|
|
+ </el-collapse>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="ss-card">
|
|
|
+ <div class="ss-card__head">
|
|
|
+ <div class="ss-card__title">文字放在哪</div>
|
|
|
+ <div class="ss-card__desc">这一套所有带字的图都用同一个位置,选不挡商品的那个。</div>
|
|
|
+ </div>
|
|
|
+ <div class="anchor-grid">
|
|
|
+ <div
|
|
|
+ v-for="item in anchorOptions"
|
|
|
+ :key="item.value"
|
|
|
+ class="anchor-card"
|
|
|
+ :class="{ 'is-on': copyAnchor === item.value }"
|
|
|
+ @click="copyAnchor = item.value"
|
|
|
+ >
|
|
|
+ <div class="anchor-frame">
|
|
|
+ <span class="anchor-frame__goods" :class="`is-${layout}`" />
|
|
|
+ <span class="anchor-frame__text" :class="`at-${item.value}`"><i /><i /></span>
|
|
|
+ </div>
|
|
|
+ <div class="anchor-card__label">{{ item.label }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-show="step === 3" class="ss-stack">
|
|
|
+ <section v-if="phase === 'idle'" class="ss-card">
|
|
|
+ <div class="ss-card__head">
|
|
|
+ <div class="ss-card__title">确认一下,没问题就开始生成</div>
|
|
|
+ <div class="ss-card__desc">生成要几分钟,期间不要关闭这个窗口,做完会自动保存。</div>
|
|
|
+ </div>
|
|
|
+ <div class="confirm-list">
|
|
|
+ <div class="confirm-row"><span>货号</span><b>{{ goodsArtNo }}</b></div>
|
|
|
+ <div class="confirm-row">
|
|
|
+ <span>大小</span>
|
|
|
+ <b>{{ tierInfo.name }}({{ tierInfo.range }})· {{ layoutLabel }}拍法{{ heightValue ? ` · 实测 ${heightValue}cm` : '' }}</b>
|
|
|
+ </div>
|
|
|
+ <div class="confirm-row"><span>场景图</span><b>{{ sceneSummary }}</b></div>
|
|
|
+ <div class="confirm-row"><span>卖点</span><b>{{ pointSummary }}</b></div>
|
|
|
+ <div class="confirm-row">
|
|
|
+ <span>本机排版</span>
|
|
|
+ <b>{{ freeItems.join('、') }}<small v-if="threeViewMissing.length">(缺{{ threeViewMissing.join('、') }},不出三视图)</small></b>
|
|
|
+ </div>
|
|
|
+ <div class="confirm-row"><span>文字位置</span><b>{{ anchorLabel }}</b></div>
|
|
|
+ <div class="confirm-row confirm-row--coin">
|
|
|
+ <span>金币</span>
|
|
|
+ <b>{{ coinInfo.text }}<small v-if="coinInfo.detail">{{ coinInfo.detail }}</small></b>
|
|
|
+ </div>
|
|
|
+ <div class="confirm-row"><span>保存到</span><b>货号文件夹里的「场景套图」</b></div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section v-else class="ss-card">
|
|
|
+ <div class="run-head">
|
|
|
+ <span class="run-head__icon" :class="`is-${phaseView.tone}`">
|
|
|
+ <el-icon v-if="phaseView.tone === 'ok'"><CircleCheckFilled /></el-icon>
|
|
|
+ <el-icon v-else-if="phaseView.tone === 'bad'"><CircleCloseFilled /></el-icon>
|
|
|
+ <el-icon v-else-if="phaseView.tone === 'warn'"><WarningFilled /></el-icon>
|
|
|
+ <el-icon v-else class="is-loading"><Loading /></el-icon>
|
|
|
+ </span>
|
|
|
+ <div class="run-head__text">
|
|
|
+ <div class="run-head__title">{{ phaseView.title }}</div>
|
|
|
+ <div class="run-head__desc">{{ phaseView.desc }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-progress
|
|
|
+ v-if="phase !== 'skipped'"
|
|
|
+ :percentage="percent"
|
|
|
+ :stroke-width="10"
|
|
|
+ :status="progressStatus"
|
|
|
+ :color="phase === 'done' ? '#67c23a' : '#2957ff'"
|
|
|
+ />
|
|
|
+ <div v-if="pollError && busy" class="ss-muted">{{ pollError }}</div>
|
|
|
+ <el-alert v-if="resumed && busy" type="info" :closable="false" show-icon title="接着上次没做完的任务">
|
|
|
+ <div class="alert-body">
|
|
|
+ <div>这个货号上次的任务还没做完,现在接着做,不会重复扣金币。</div>
|
|
|
+ <div class="alert-actions"><el-button size="small" @click="confirmForce">放弃上次,重新生成</el-button></div>
|
|
|
+ </div>
|
|
|
+ </el-alert>
|
|
|
+
|
|
|
+ <div v-if="phase === 'done' && resultImages.length" class="result-grid">
|
|
|
+ <div v-for="(path, index) in resultImages" :key="path" class="result-card">
|
|
|
+ <el-image :src="resultUrls[index]" fit="contain" :preview-src-list="resultUrls" :initial-index="index" preview-teleported />
|
|
|
+ <div class="result-card__name">{{ fileStem(path) }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="sortedItems.length" class="run-grid">
|
|
|
+ <div v-for="item in sortedItems" :key="item.id || item.scene_name" class="run-card">
|
|
|
+ <div class="run-card__img">
|
|
|
+ <el-image
|
|
|
+ v-if="item.result_url"
|
|
|
+ :src="item.result_url"
|
|
|
+ fit="cover"
|
|
|
+ :preview-src-list="progressUrls"
|
|
|
+ :initial-index="Math.max(0, progressUrls.indexOf(item.result_url))"
|
|
|
+ preview-teleported
|
|
|
+ />
|
|
|
+ <div v-else class="run-card__placeholder" :class="`is-${itemTone(item)}`">
|
|
|
+ <el-icon v-if="[0, 1].includes(Number(item.status))" class="is-loading"><Loading /></el-icon>
|
|
|
+ <span>{{ itemPlaceholder(item) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="run-card__name">{{ item.scene_name || item.kind }}</div>
|
|
|
+ <div class="run-card__status" :class="`is-${itemTone(item)}`" :title="item.error_msg || ''">{{ statusText(item) }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="displayNotes.length" class="run-notes">
|
|
|
+ <div class="run-notes__title">说明</div>
|
|
|
+ <div v-for="note in displayNotes" :key="note">· {{ note }}</div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="ss-footer">
|
|
|
+ <div class="ss-footer__side">
|
|
|
+ <el-button v-if="canBack" @click="goStep(step - 1)">上一步</el-button>
|
|
|
+ </div>
|
|
|
+ <div class="ss-footer__info">
|
|
|
+ <span v-if="currentBlock" class="ss-footer__warn">
|
|
|
+ <el-icon><WarningFilled /></el-icon>{{ currentBlock }}
|
|
|
+ </span>
|
|
|
+ <span v-else>{{ footerSummary }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="ss-footer__side ss-footer__side--right">
|
|
|
+ <el-button v-if="step < 3" type="primary" :disabled="!!currentBlock" @click="goStep(step + 1)">下一步</el-button>
|
|
|
+ <template v-else>
|
|
|
+ <el-button v-if="phase === 'idle'" type="primary" @click="submitGenerate">开始生成</el-button>
|
|
|
+ <el-button v-else-if="busy" type="primary" loading>生成中,请不要关闭窗口</el-button>
|
|
|
+ <template v-else-if="phase === 'failed'">
|
|
|
+ <el-button v-if="failedStage === 'save'" type="primary" @click="retrySave">重新保存</el-button>
|
|
|
+ <el-button v-else type="primary" @click="retryGenerate">重新生成</el-button>
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <el-button v-if="resultDir" :icon="FolderOpened" @click="openDir(resultDir)">打开结果文件夹</el-button>
|
|
|
+ <el-button type="primary" @click="restart">再做一个货号</el-button>
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
|
|
+import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
|
|
+import {
|
|
|
+ Check,
|
|
|
+ CircleCheckFilled,
|
|
|
+ CircleCloseFilled,
|
|
|
+ FolderOpened,
|
|
|
+ Loading,
|
|
|
+ RefreshLeft,
|
|
|
+ RefreshRight,
|
|
|
+ WarningFilled,
|
|
|
+} from '@element-plus/icons-vue'
|
|
|
+import BlueHeaderBar from '@/components/header-bar/blue-header.vue'
|
|
|
+import GoodsArtNoSearch from '@/views/Photography/poster/GoodsArtNoSearch.vue'
|
|
|
+import client from '@/stores/modules/client'
|
|
|
+import tokenInfo from '@/stores/modules/token'
|
|
|
+import icpList from '@/utils/ipc'
|
|
|
+import { fetchSceneSetCategories, fetchSceneSetJobs } from '@/apis/sceneSet'
|
|
|
+
|
|
|
+type Phase = 'idle' | 'submitting' | 'running' | 'saving' | 'done' | 'skipped' | 'failed'
|
|
|
+
|
|
|
+const steps = [
|
|
|
+ { no: 1, title: '选商品', desc: '货号、大小、抠图' },
|
|
|
+ { no: 2, title: '定内容', desc: '场景、卖点、文字位置' },
|
|
|
+ { no: 3, title: '生成', desc: '确认金币,开始出图' },
|
|
|
+]
|
|
|
+const tierOptions = [
|
|
|
+ { value: 'S', name: '小号', range: '高度 25cm 以下', desc: '桌面、随身大小', layout: 'handheld' },
|
|
|
+ { value: 'M', name: '中号', range: '高度 25~55cm', desc: '手提大小,横放着拍', layout: 'handheld' },
|
|
|
+ { value: 'L', name: '大号', range: '高度 55cm 以上', desc: '落地大小,立着拍', layout: 'floor' },
|
|
|
+]
|
|
|
+const defaultAnchors = [
|
|
|
+ { value: 'top-left', label: '左上' },
|
|
|
+ { value: 'top-right', label: '右上' },
|
|
|
+ { value: 'bottom-left', label: '左下' },
|
|
|
+ { value: 'bottom-right', label: '右下' },
|
|
|
+ { value: 'center', label: '中间' },
|
|
|
+]
|
|
|
+const defaultLayouts = [
|
|
|
+ { value: 'handheld', label: '横放扁手提' },
|
|
|
+ { value: 'floor', label: '落地' },
|
|
|
+]
|
|
|
+const sizeKeys = ['宽度', '深度', '重量']
|
|
|
+const hiddenKeys = ['卖点', '卖点文案', '货号', '高度', '高', '宽', '深', '尺寸']
|
|
|
+const verifiedLabels = ['品牌', '横放造型', '顶部提手', '橙色两端', '黑色织布', '顶面按键']
|
|
|
+const threeViewRoles = [['front', '正面'], ['side', '侧面'], ['top', '顶面']]
|
|
|
+const fieldHints: Record<string, string> = {
|
|
|
+ 宽度: '如 32cm',
|
|
|
+ 深度: '如 15cm',
|
|
|
+ 重量: '如 2.5kg',
|
|
|
+ 续航: '如 12小时',
|
|
|
+ 蓝牙: '如 5.3',
|
|
|
+ 功率: '如 40W',
|
|
|
+ 防水: '如 IPX6',
|
|
|
+}
|
|
|
+const sellingPlaceholder = '例如:\n重低音,聚会更带劲\n七彩灯效,夜里更有氛围\n顶部提手,拎着就走'
|
|
|
+
|
|
|
+const clientStore = client()
|
|
|
+const tokenStore = tokenInfo()
|
|
|
+const mainRef = ref<HTMLElement | null>(null)
|
|
|
+const step = ref(1)
|
|
|
+
|
|
|
+const categoryState = ref<'loading' | 'ready' | 'error'>('loading')
|
|
|
+const categoryError = ref('')
|
|
|
+const categories = ref<any[]>([])
|
|
|
+const anchors = ref<any[]>([])
|
|
|
+const layouts = ref<any[]>([])
|
|
|
+const categoryId = ref<number | null>(null)
|
|
|
+
|
|
|
+const goodsArtNo = ref('')
|
|
|
+const goodsMeta = ref('')
|
|
|
+const imagePath = ref('')
|
|
|
+const goodsDir = ref('')
|
|
|
+const preparing = ref(false)
|
|
|
+const cutting = ref(false)
|
|
|
+const prepareError = ref('')
|
|
|
+const prepareCode = ref(0)
|
|
|
+const outputExists = ref(false)
|
|
|
+const outputDir = ref('')
|
|
|
+const cutouts = ref<any[]>([])
|
|
|
+const editingPath = ref('')
|
|
|
+
|
|
|
+const tier = ref('M')
|
|
|
+const heightCm = ref('')
|
|
|
+const layout = ref('handheld')
|
|
|
+const layoutTouched = ref(false)
|
|
|
+
|
|
|
+const basis = ref('selling_point')
|
|
|
+const sceneTags = ref<string[]>([])
|
|
|
+const sellingPoints = ref('')
|
|
|
+const visibleKeys = ref<string[]>([])
|
|
|
+const materialValues = reactive<Record<string, string>>({})
|
|
|
+const excelPath = ref('')
|
|
|
+const moreOpen = ref<string[]>([])
|
|
|
+const copyAnchor = ref('top-left')
|
|
|
+
|
|
|
+const phase = ref<Phase>('idle')
|
|
|
+const failedStage = ref<'' | 'create' | 'scenes' | 'save'>('')
|
|
|
+const errorText = ref('')
|
|
|
+const skipReason = ref('')
|
|
|
+const resumed = ref(false)
|
|
|
+const progressItems = ref<any[]>([])
|
|
|
+const jobNotes = ref<string[]>([])
|
|
|
+const jobTier = ref('')
|
|
|
+const pollError = ref('')
|
|
|
+const savedFiles = ref<string[]>([])
|
|
|
+const savedNotes = ref<string[]>([])
|
|
|
+const resultDir = ref('')
|
|
|
+const savedAt = ref(0)
|
|
|
+
|
|
|
+let pollTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
+let polling = false
|
|
|
+let jobId = 0
|
|
|
+let forceNext = 0
|
|
|
+let runToken = 0
|
|
|
+let prepareSeq = 0
|
|
|
+let cutSeq = 0
|
|
|
+let lastJob: any = null
|
|
|
+
|
|
|
+const currentCategory = computed(() => categories.value.find((item) => item.id === categoryId.value) || null)
|
|
|
+const sceneCoin = computed(() => Number(currentCategory.value?.scene_coin || 0))
|
|
|
+const analyzeCoin = computed(() => Number(currentCategory.value?.analyze_coin || 0))
|
|
|
+const visibleFeatures = computed<any[]>(() => currentCategory.value?.visible_features || [])
|
|
|
+const materialFields = computed<string[]>(() => currentCategory.value?.material_fields || [])
|
|
|
+const sizeFields = computed(() => sizeKeys.filter((key) => materialFields.value.includes(key)))
|
|
|
+const otherFields = computed(() => materialFields.value.filter((key) => !hiddenKeys.includes(key) && !sizeKeys.includes(key)))
|
|
|
+const editableFields = computed(() => [...sizeFields.value, ...otherFields.value])
|
|
|
+const anchorOptions = computed(() => (anchors.value.length ? anchors.value : defaultAnchors))
|
|
|
+const anchorLabel = computed(() => anchorOptions.value.find((item: any) => item.value === copyAnchor.value)?.label || copyAnchor.value)
|
|
|
+const layoutSlots = computed(() => (currentCategory.value?.slots || [])
|
|
|
+ .filter((slot: any) => slot.layout === layout.value)
|
|
|
+ .sort((a: any, b: any) => Number(a.seq) - Number(b.seq)))
|
|
|
+const layoutOptions = computed(() => {
|
|
|
+ const list = layouts.value.length ? layouts.value : defaultLayouts
|
|
|
+ const slots = currentCategory.value?.slots || []
|
|
|
+ return list.map((item: any) => {
|
|
|
+ const count = slots.filter((slot: any) => slot.layout === item.value).length
|
|
|
+ return { value: item.value, label: count ? `${item.label}(${count} 个机位)` : item.label }
|
|
|
+ })
|
|
|
+})
|
|
|
+const layoutLabel = computed(() => {
|
|
|
+ const list = layouts.value.length ? layouts.value : defaultLayouts
|
|
|
+ return list.find((item: any) => item.value === layout.value)?.label || layout.value
|
|
|
+})
|
|
|
+const tierInfo = computed(() => tierOptions.find((item) => item.value === tier.value) || tierOptions[1])
|
|
|
+const tierScenes = computed(() => scenesOf(tier.value))
|
|
|
+const heightValue = computed(() => {
|
|
|
+ const value = Number(heightCm.value)
|
|
|
+ return heightCm.value !== '' && value > 0 && value < 1000 ? value : 0
|
|
|
+})
|
|
|
+
|
|
|
+const plateCutout = computed(() => cutouts.value.find((item) => Number(item.use_for_scene) && Number(item.use_long_scene)) || null)
|
|
|
+const editingCutout = computed(() => cutouts.value.find((item) => item.path === editingPath.value) || null)
|
|
|
+const duplicateSlots = computed(() => {
|
|
|
+ const count: Record<string, number> = {}
|
|
|
+ cutouts.value.forEach((item) => {
|
|
|
+ if (item.slot_id) count[item.slot_id] = (count[item.slot_id] || 0) + 1
|
|
|
+ })
|
|
|
+ return new Set(cutouts.value.map((item) => item.slot_id).filter((id) => id && count[id] > 1))
|
|
|
+})
|
|
|
+const threeViewMissing = computed(() => {
|
|
|
+ const roles = new Set(cutouts.value.filter((item) => Number(item.use_three_view)).map((item) => item.role))
|
|
|
+ return threeViewRoles.filter(([role]) => !roles.has(role)).map(([, label]) => label)
|
|
|
+})
|
|
|
+
|
|
|
+const sellingLines = computed(() => sellingPoints.value.split(/\r\n|\n|\r|;|;/).map((text) => text.trim()).filter(Boolean))
|
|
|
+const hasMaterials = computed(() => !!excelPath.value || editableFields.value.some((key) => (materialValues[key] || '').trim()))
|
|
|
+const verifiedKeys = computed(() => visibleFeatures.value.filter((item) => verifiedLabels.includes(item.label)).map((item) => item.key))
|
|
|
+const sizeReady = computed(() => !!heightValue.value
|
|
|
+ && sizeFields.value.length === sizeKeys.length
|
|
|
+ && sizeKeys.every((key) => /\d/.test(materialValues[key] || '')))
|
|
|
+
|
|
|
+const autoRange = computed(() => {
|
|
|
+ const list = tierScenes.value
|
|
|
+ const max = Number(currentCategory.value?.max_scene_count || 8)
|
|
|
+ const fallback = Number(currentCategory.value?.default_scene_count || 6)
|
|
|
+ const open = list.filter((item: any) => !(item.requires || []).length).length
|
|
|
+ const high = Math.min(max, list.length)
|
|
|
+ return { low: Math.min(fallback, open, high), high, max }
|
|
|
+})
|
|
|
+const autoCountText = computed(() => {
|
|
|
+ const { low, high } = autoRange.value
|
|
|
+ return low === high ? `${high} 张` : `${low}~${high} 张`
|
|
|
+})
|
|
|
+const coinInfo = computed(() => {
|
|
|
+ const unit = sceneCoin.value
|
|
|
+ if (basis.value === 'scene') {
|
|
|
+ const count = sceneTags.value.length
|
|
|
+ if (!unit) return { text: '不扣金币', detail: '' }
|
|
|
+ return { text: `${count * unit} 金币`, detail: `${count} 张场景图 × ${unit} 金币,没出来的会退回` }
|
|
|
+ }
|
|
|
+ const { low, high, max } = autoRange.value
|
|
|
+ const analyze = analyzeCoin.value
|
|
|
+ const least = analyze + low * unit
|
|
|
+ const most = analyze + high * unit
|
|
|
+ if (!most) return { text: '不扣金币', detail: '' }
|
|
|
+ return {
|
|
|
+ text: least === most ? `约 ${most} 金币` : `约 ${least}~${most} 金币`,
|
|
|
+ detail: `每张场景图 ${unit} 金币${analyze ? `,整理卖点 ${analyze} 金币` : ''},按实际张数扣,没出来的会退回。开始时账户里至少要有 ${analyze + max * unit} 金币`,
|
|
|
+ }
|
|
|
+})
|
|
|
+const sceneSummary = computed(() => {
|
|
|
+ if (basis.value === 'selling_point') return `按卖点自动挑 ${autoCountText.value}`
|
|
|
+ const names = tierScenes.value.filter((item: any) => sceneTags.value.includes(item.tag)).map((item: any) => item.name)
|
|
|
+ return `${names.length} 张:${names.join('、')}`
|
|
|
+})
|
|
|
+const pointSummary = computed(() => {
|
|
|
+ if (sellingLines.value.length) return `照你写的 ${sellingLines.value.length} 条印在图上`
|
|
|
+ if (hasMaterials.value) return '从填的资料里整理'
|
|
|
+ if (visibleKeys.value.length) return `用图上看得到的 ${visibleKeys.value.length} 项特征`
|
|
|
+ return '没有卖点,按通用场景出图'
|
|
|
+})
|
|
|
+const freeItems = computed(() => {
|
|
|
+ const list: string[] = []
|
|
|
+ if (!threeViewMissing.value.length) list.push('三视图')
|
|
|
+ if (cutouts.value.some((item) => Number(item.use_detail))) list.push('细节图')
|
|
|
+ if (sizeReady.value) list.push('尺寸图')
|
|
|
+ list.push('卖点说明(卖点.txt)')
|
|
|
+ return list
|
|
|
+})
|
|
|
+
|
|
|
+const busy = computed(() => ['submitting', 'running', 'saving'].includes(phase.value))
|
|
|
+const locked = computed(() => busy.value
|
|
|
+ || phase.value === 'done'
|
|
|
+ || phase.value === 'skipped'
|
|
|
+ || (phase.value === 'failed' && failedStage.value === 'save'))
|
|
|
+const currentBlock = computed(() => (step.value < 3 ? blockFor(step.value) : ''))
|
|
|
+const canBack = computed(() => step.value > 1 && !locked.value)
|
|
|
+const footerSummary = computed(() => {
|
|
|
+ if (step.value === 3) {
|
|
|
+ if (phase.value === 'idle') return `${coinInfo.value.text},确认没问题就点「开始生成」`
|
|
|
+ if (busy.value) return '可以把窗口最小化,做完会自动保存'
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+ if (!goodsArtNo.value) return ''
|
|
|
+ return [
|
|
|
+ goodsArtNo.value,
|
|
|
+ tierInfo.value.name,
|
|
|
+ basis.value === 'scene' ? `已选 ${sceneTags.value.length} 个场景` : `自动挑 ${autoCountText.value}`,
|
|
|
+ coinInfo.value.text,
|
|
|
+ ].join(' · ')
|
|
|
+})
|
|
|
+
|
|
|
+const sortedItems = computed(() => [...progressItems.value].sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0)))
|
|
|
+const sceneItems = computed(() => progressItems.value.filter((item) => item.kind === 'scene'))
|
|
|
+const finishedCount = computed(() => sceneItems.value.filter((item) => [2, -1].includes(Number(item.status))).length)
|
|
|
+const failedCount = computed(() => sceneItems.value.filter((item) => Number(item.status) === -1).length)
|
|
|
+const progressUrls = computed(() => sortedItems.value.filter((item) => item.result_url).map((item) => item.result_url))
|
|
|
+const resultImages = computed(() => savedFiles.value.filter((path) => /\.png$/i.test(path)))
|
|
|
+const resultUrls = computed(() => resultImages.value.map((path) => toFileUrl(path, savedAt.value)))
|
|
|
+const percent = computed(() => {
|
|
|
+ if (phase.value === 'done') return 100
|
|
|
+ if (phase.value === 'saving') return 95
|
|
|
+ const total = sceneItems.value.length
|
|
|
+ if (!total) return phase.value === 'running' ? 5 : 0
|
|
|
+ return Math.max(5, Math.round((finishedCount.value / total) * 90))
|
|
|
+})
|
|
|
+const progressStatus = computed(() => {
|
|
|
+ if (phase.value === 'done') return 'success'
|
|
|
+ if (phase.value === 'failed') return 'exception'
|
|
|
+ return ''
|
|
|
+})
|
|
|
+const displayNotes = computed(() => {
|
|
|
+ const notes = [...(phase.value === 'done' ? savedNotes.value : jobNotes.value)]
|
|
|
+ if (jobTier.value && jobTier.value !== tier.value) {
|
|
|
+ notes.unshift(`资料里的高度对应${tierName(jobTier.value)},实际按${tierName(jobTier.value)}出图`)
|
|
|
+ }
|
|
|
+ return Array.from(new Set(notes.filter(Boolean)))
|
|
|
+})
|
|
|
+const phaseView = computed(() => {
|
|
|
+ const total = sceneItems.value.length
|
|
|
+ switch (phase.value) {
|
|
|
+ case 'submitting':
|
|
|
+ return { tone: 'run', title: '正在准备', desc: '正在用场景底图做底板、上传,并创建任务…' }
|
|
|
+ case 'running':
|
|
|
+ return { tone: 'run', title: '正在生成场景图', desc: `${finishedCount.value}/${total} 张已处理。可以把窗口最小化,做完会自动保存到货号文件夹。` }
|
|
|
+ case 'saving':
|
|
|
+ return { tone: 'run', title: '正在保存', desc: '正在给场景图叠文字,并在本机排三视图、细节图…' }
|
|
|
+ case 'done':
|
|
|
+ return {
|
|
|
+ tone: 'ok',
|
|
|
+ title: '做好了',
|
|
|
+ desc: `共 ${resultImages.value.length} 张图,已保存到:${resultDir.value}${failedCount.value ? `。有 ${failedCount.value} 张场景图没出来,金币已退回` : ''}`,
|
|
|
+ }
|
|
|
+ case 'skipped':
|
|
|
+ return { tone: 'warn', title: '没有生成', desc: skipReason.value }
|
|
|
+ case 'failed':
|
|
|
+ if (failedStage.value === 'scenes') {
|
|
|
+ return { tone: 'bad', title: '场景图没有生成成功', desc: '场景图的金币已经退回。点「重新生成」可以再试一次,会重新扣金币。' }
|
|
|
+ }
|
|
|
+ if (failedStage.value === 'save') {
|
|
|
+ return { tone: 'bad', title: '图已经生成,但保存失败', desc: `${errorText.value}。点「重新保存」就行,不会再扣金币。` }
|
|
|
+ }
|
|
|
+ return { tone: 'bad', title: '没有开始生成', desc: errorText.value }
|
|
|
+ default:
|
|
|
+ return { tone: 'run', title: '', desc: '' }
|
|
|
+ }
|
|
|
+})
|
|
|
+const prepareErrorView = computed(() => {
|
|
|
+ if (prepareCode.value === 999) {
|
|
|
+ return { title: '本机服务没有响应', desc: '请确认客户端已经完整启动,再点「重新读取」。' }
|
|
|
+ }
|
|
|
+ if (prepareError.value.includes('抠图')) {
|
|
|
+ return {
|
|
|
+ title: '这个货号还没有抠好的图',
|
|
|
+ desc: '点「重新抠图」,会用这个货号已经拍好的图抠一遍,完成后自动读进来。',
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return { title: '读取抠图失败', desc: prepareError.value }
|
|
|
+})
|
|
|
+const missingCutout = computed(() => prepareCode.value !== 999 && prepareError.value.includes('抠图'))
|
|
|
+
|
|
|
+watch(heightCm, () => {
|
|
|
+ if (heightValue.value) applyTier(tierFromHeight(heightValue.value))
|
|
|
+})
|
|
|
+
|
|
|
+function scenesOf(value: string) {
|
|
|
+ return (currentCategory.value?.scenes || []).filter((item: any) => item.tier === value)
|
|
|
+}
|
|
|
+
|
|
|
+function sceneNames(value: string) {
|
|
|
+ return scenesOf(value).map((item: any) => item.name).join('、')
|
|
|
+}
|
|
|
+
|
|
|
+function tierName(value: string) {
|
|
|
+ const item = tierOptions.find((row) => row.value === value)
|
|
|
+ return item ? `${item.name}(${item.value})` : value
|
|
|
+}
|
|
|
+
|
|
|
+function tierFromHeight(height: number) {
|
|
|
+ if (height < 25) return 'S'
|
|
|
+ if (height < 55) return 'M'
|
|
|
+ return 'L'
|
|
|
+}
|
|
|
+
|
|
|
+function toFileUrl(path: string, version = 0) {
|
|
|
+ if (!path) return ''
|
|
|
+ if (/^(data:|https?:|file:)/i.test(path)) return path
|
|
|
+ const url = 'file:///' + encodeURI(path.replace(/\\/g, '/')).replace(/#/g, '%23').replace(/\?/g, '%3F')
|
|
|
+ return version ? `${url}?t=${version}` : url
|
|
|
+}
|
|
|
+
|
|
|
+function fileName(path: string) {
|
|
|
+ return (path || '').split(/[\\/]/).pop() || ''
|
|
|
+}
|
|
|
+
|
|
|
+function fileStem(path: string) {
|
|
|
+ return fileName(path).replace(/\.[^.]+$/, '')
|
|
|
+}
|
|
|
+
|
|
|
+function plain<T>(value: T): T {
|
|
|
+ return JSON.parse(JSON.stringify(value))
|
|
|
+}
|
|
|
+
|
|
|
+function errorMessage(error: any, fallback: string) {
|
|
|
+ const status = error?.response?.status
|
|
|
+ if (status) return `服务端返回 ${status},请稍后重试`
|
|
|
+ return error?.message || error?.msg || fallback
|
|
|
+}
|
|
|
+
|
|
|
+function firstImagePath(item: any) {
|
|
|
+ const rows = item?.items || []
|
|
|
+ for (const row of rows) {
|
|
|
+ const path = row?.PhotoRecord?.image_path || row?.image_path
|
|
|
+ if (path) return path
|
|
|
+ }
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+function scrollTop() {
|
|
|
+ nextTick(() => mainRef.value?.scrollTo({ top: 0 }))
|
|
|
+}
|
|
|
+
|
|
|
+async function loadCategories() {
|
|
|
+ categoryState.value = 'loading'
|
|
|
+ categoryError.value = ''
|
|
|
+ try {
|
|
|
+ const res: any = await fetchSceneSetCategories()
|
|
|
+ const data = res?.data || {}
|
|
|
+ categories.value = data.categories || []
|
|
|
+ anchors.value = data.copy_anchors || []
|
|
|
+ layouts.value = data.layouts || []
|
|
|
+ if (!categories.value.some((item) => item.id === categoryId.value)) {
|
|
|
+ categoryId.value = categories.value[0]?.id ?? null
|
|
|
+ }
|
|
|
+ categoryState.value = 'ready'
|
|
|
+ if (goodsArtNo.value && imagePath.value && !cutouts.value.length) loadCutouts()
|
|
|
+ } catch (error: any) {
|
|
|
+ categoryState.value = 'error'
|
|
|
+ categoryError.value = errorMessage(error, '请确认已经登录,再点重试')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function onCategory() {
|
|
|
+ sceneTags.value = []
|
|
|
+ visibleKeys.value = []
|
|
|
+ loadCutouts()
|
|
|
+}
|
|
|
+
|
|
|
+function resetGoods() {
|
|
|
+ prepareSeq += 1
|
|
|
+ cutSeq += 1
|
|
|
+ preparing.value = false
|
|
|
+ if (cutting.value && !locked.value) setClosable(true)
|
|
|
+ cutting.value = false
|
|
|
+ goodsDir.value = ''
|
|
|
+ cutouts.value = []
|
|
|
+ outputExists.value = false
|
|
|
+ outputDir.value = ''
|
|
|
+ prepareError.value = ''
|
|
|
+ prepareCode.value = 0
|
|
|
+ editingPath.value = ''
|
|
|
+}
|
|
|
+
|
|
|
+function onGoods(item: any) {
|
|
|
+ imagePath.value = firstImagePath(item)
|
|
|
+ goodsMeta.value = item ? [`${item.items?.length || 0} 张拍摄图`, item.action_time].filter(Boolean).join(' · ') : ''
|
|
|
+ resetGoods()
|
|
|
+ if (item) loadCutouts()
|
|
|
+}
|
|
|
+
|
|
|
+async function loadCutouts() {
|
|
|
+ if (!goodsArtNo.value || !imagePath.value || !currentCategory.value) return
|
|
|
+ const seq = ++prepareSeq
|
|
|
+ preparing.value = true
|
|
|
+ prepareError.value = ''
|
|
|
+ prepareCode.value = 0
|
|
|
+ editingPath.value = ''
|
|
|
+ try {
|
|
|
+ const result = await clientStore.ipc.invoke(icpList.generate.sceneSetPrepare, plain({
|
|
|
+ goods_art_no: goodsArtNo.value,
|
|
|
+ image_path: imagePath.value,
|
|
|
+ layout: layout.value,
|
|
|
+ slots: currentCategory.value.slots || [],
|
|
|
+ }))
|
|
|
+ if (seq !== prepareSeq) return
|
|
|
+ if (!result || result.code !== 0) {
|
|
|
+ cutouts.value = []
|
|
|
+ prepareCode.value = Number(result?.code || 1)
|
|
|
+ prepareError.value = result?.msg || '读取抠图失败'
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const data = result.data || {}
|
|
|
+ goodsDir.value = data.goods_dir || ''
|
|
|
+ outputDir.value = data.output_dir || ''
|
|
|
+ outputExists.value = !!data.output_exists
|
|
|
+ cutouts.value = (data.cutouts || []).map((item: any) => ({ ...item }))
|
|
|
+ } catch (error: any) {
|
|
|
+ if (seq !== prepareSeq) return
|
|
|
+ cutouts.value = []
|
|
|
+ prepareCode.value = 1
|
|
|
+ prepareError.value = errorMessage(error, '读取抠图失败')
|
|
|
+ } finally {
|
|
|
+ if (seq === prepareSeq) preparing.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function recut() {
|
|
|
+ if (!goodsArtNo.value || cutting.value) return
|
|
|
+ const token = tokenStore.getToken
|
|
|
+ if (!token) {
|
|
|
+ ElMessage.error('请先登录,再重新抠图')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const seq = ++cutSeq
|
|
|
+ cutting.value = true
|
|
|
+ setClosable(false)
|
|
|
+ try {
|
|
|
+ const result = await clientStore.ipc.invoke(icpList.generate.sceneSetCutout, plain({
|
|
|
+ goods_art_nos: [goodsArtNo.value],
|
|
|
+ token,
|
|
|
+ }))
|
|
|
+ if (seq !== cutSeq) return
|
|
|
+ if (!result || result.code !== 0) {
|
|
|
+ ElMessage.error(result?.msg || result?.message || '抠图失败')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await loadCutouts()
|
|
|
+ } catch (error: any) {
|
|
|
+ if (seq !== cutSeq) return
|
|
|
+ ElMessage.error(errorMessage(error, '抠图失败'))
|
|
|
+ } finally {
|
|
|
+ if (seq === cutSeq) {
|
|
|
+ cutting.value = false
|
|
|
+ if (!locked.value) setClosable(true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function applyTier(value: string) {
|
|
|
+ if (tier.value === value) return
|
|
|
+ tier.value = value
|
|
|
+ const tags = new Set(scenesOf(value).map((item: any) => item.tag))
|
|
|
+ sceneTags.value = sceneTags.value.filter((tag) => tags.has(tag))
|
|
|
+ const next = tierOptions.find((item) => item.value === value)?.layout || 'handheld'
|
|
|
+ if (!layoutTouched.value && layout.value !== next) {
|
|
|
+ layout.value = next
|
|
|
+ loadCutouts()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function pickTier(value: string) {
|
|
|
+ if (heightValue.value && tierFromHeight(heightValue.value) !== value) {
|
|
|
+ heightCm.value = ''
|
|
|
+ ElMessage.info('已清空实测高度,按你选的大小出图')
|
|
|
+ }
|
|
|
+ applyTier(value)
|
|
|
+}
|
|
|
+
|
|
|
+function onHeightInput(value: string) {
|
|
|
+ const text = String(value || '').replace(/[^\d.]/g, '')
|
|
|
+ const [whole, ...rest] = text.split('.')
|
|
|
+ heightCm.value = (rest.length ? `${whole}.${rest.join('')}` : whole).slice(0, 6)
|
|
|
+}
|
|
|
+
|
|
|
+function onLayout() {
|
|
|
+ layoutTouched.value = true
|
|
|
+ loadCutouts()
|
|
|
+}
|
|
|
+
|
|
|
+function isPlate(item: any) {
|
|
|
+ return !!plateCutout.value && plateCutout.value.path === item.path
|
|
|
+}
|
|
|
+
|
|
|
+function isUnused(item: any) {
|
|
|
+ return cutoutTags(item)[0].type === 'off'
|
|
|
+}
|
|
|
+
|
|
|
+function cutoutTags(item: any) {
|
|
|
+ const tags: Array<{ text: string; type: string }> = []
|
|
|
+ if (isPlate(item)) tags.push({ text: '场景底图', type: 'plate' })
|
|
|
+ if (Number(item.use_three_view) && threeViewRoles.some(([role]) => role === item.role)) tags.push({ text: '三视图', type: 'view' })
|
|
|
+ if (Number(item.use_detail)) tags.push({ text: '细节图', type: 'detail' })
|
|
|
+ if (!tags.length) tags.push({ text: '不使用', type: 'off' })
|
|
|
+ return tags
|
|
|
+}
|
|
|
+
|
|
|
+function toggleEdit(item: any) {
|
|
|
+ editingPath.value = editingPath.value === item.path ? '' : item.path
|
|
|
+}
|
|
|
+
|
|
|
+function onSlot(item: any) {
|
|
|
+ const slot = layoutSlots.value.find((row: any) => row.id === item.slot_id)
|
|
|
+ if (!slot) {
|
|
|
+ Object.assign(item, {
|
|
|
+ slot_id: 0,
|
|
|
+ name: '不使用',
|
|
|
+ role: '',
|
|
|
+ use_long_scene: 0,
|
|
|
+ use_three_view: 0,
|
|
|
+ use_detail: 0,
|
|
|
+ use_for_scene: 0,
|
|
|
+ crops: [],
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ Object.assign(item, {
|
|
|
+ name: slot.name,
|
|
|
+ role: slot.role,
|
|
|
+ rotation: slot.rotation,
|
|
|
+ use_long_scene: slot.use_long_scene,
|
|
|
+ use_three_view: slot.use_three_view,
|
|
|
+ use_detail: slot.use_detail,
|
|
|
+ use_for_scene: slot.use_for_scene,
|
|
|
+ crops: plain(slot.crops || []),
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function rotate(item: any, delta: number) {
|
|
|
+ item.rotation = (((Number(item.rotation) || 0) + delta) % 360 + 360) % 360
|
|
|
+}
|
|
|
+
|
|
|
+function setPlate(item: any) {
|
|
|
+ cutouts.value.forEach((row) => {
|
|
|
+ row.use_for_scene = row.path === item.path ? 1 : 0
|
|
|
+ })
|
|
|
+ item.use_long_scene = 1
|
|
|
+}
|
|
|
+
|
|
|
+function toggleScene(tag: string) {
|
|
|
+ sceneTags.value = sceneTags.value.includes(tag)
|
|
|
+ ? sceneTags.value.filter((item) => item !== tag)
|
|
|
+ : [...sceneTags.value, tag]
|
|
|
+}
|
|
|
+
|
|
|
+function toggleVisible(key: string) {
|
|
|
+ visibleKeys.value = visibleKeys.value.includes(key)
|
|
|
+ ? visibleKeys.value.filter((item) => item !== key)
|
|
|
+ : [...visibleKeys.value, key]
|
|
|
+}
|
|
|
+
|
|
|
+function fillVerified() {
|
|
|
+ visibleKeys.value = Array.from(new Set([...visibleKeys.value, ...verifiedKeys.value]))
|
|
|
+}
|
|
|
+
|
|
|
+async function pickExcel() {
|
|
|
+ try {
|
|
|
+ const file = await clientStore.ipc.invoke(icpList.utils.openFile, {
|
|
|
+ title: '选择资料 Excel',
|
|
|
+ filters: [{ name: 'Excel', extensions: ['xlsx', 'xls'] }],
|
|
|
+ })
|
|
|
+ const path = Array.isArray(file) ? file[0] || '' : file || ''
|
|
|
+ if (path) excelPath.value = path
|
|
|
+ } catch (error) {
|
|
|
+ // 取消选择时主进程会直接抛错
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function selectedVisible() {
|
|
|
+ return visibleFeatures.value.filter((item: any) => visibleKeys.value.includes(item.key))
|
|
|
+}
|
|
|
+
|
|
|
+function blockFor(no: number) {
|
|
|
+ if (no === 1) {
|
|
|
+ if (categoryState.value === 'loading') return '正在加载品类…'
|
|
|
+ if (categoryState.value === 'error') return '品类没有加载出来,点「重试」'
|
|
|
+ if (!currentCategory.value) return '暂时没有可用的品类'
|
|
|
+ if (!goodsArtNo.value) return '先选一个货号'
|
|
|
+ if (!imagePath.value) return '这个货号没有拍摄图,换一个货号'
|
|
|
+ if (cutting.value) return '正在抠图…'
|
|
|
+ if (preparing.value) return '正在读取抠图…'
|
|
|
+ if (prepareError.value) return prepareErrorView.value.title
|
|
|
+ if (outputExists.value) return '这个货号已经做过,要重做先删掉「场景套图」文件夹'
|
|
|
+ if (!cutouts.value.length) return '没有读到抠图'
|
|
|
+ if (!plateCutout.value) return '还没有场景底图,点一张抠图设为场景底图'
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+ if (no === 2) {
|
|
|
+ if (basis.value === 'scene' && !sceneTags.value.length) return '至少选一个场景'
|
|
|
+ }
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+function canEnter(no: number) {
|
|
|
+ if (locked.value) return no === step.value
|
|
|
+ for (let index = 1; index < no; index += 1) {
|
|
|
+ if (blockFor(index)) return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+function goStep(no: number) {
|
|
|
+ if (no < 1 || no > steps.length || no === step.value || !canEnter(no)) return
|
|
|
+ if (step.value === 3 && phase.value === 'failed') resetRun()
|
|
|
+ editingPath.value = ''
|
|
|
+ step.value = no
|
|
|
+ scrollTop()
|
|
|
+}
|
|
|
+
|
|
|
+function setClosable(closable: boolean) {
|
|
|
+ clientStore.ipc?.invoke(icpList.utils.setWindowClosable, { id: 'sceneSet', closable })?.catch(() => {})
|
|
|
+}
|
|
|
+
|
|
|
+function resetRun() {
|
|
|
+ runToken += 1
|
|
|
+ stopPoll()
|
|
|
+ phase.value = 'idle'
|
|
|
+ failedStage.value = ''
|
|
|
+ errorText.value = ''
|
|
|
+ skipReason.value = ''
|
|
|
+ resumed.value = false
|
|
|
+ progressItems.value = []
|
|
|
+ jobNotes.value = []
|
|
|
+ jobTier.value = ''
|
|
|
+ pollError.value = ''
|
|
|
+ savedFiles.value = []
|
|
|
+ savedNotes.value = []
|
|
|
+ resultDir.value = ''
|
|
|
+ lastJob = null
|
|
|
+ jobId = 0
|
|
|
+}
|
|
|
+
|
|
|
+function buildFields() {
|
|
|
+ const fields: Record<string, string> = {}
|
|
|
+ editableFields.value.forEach((key) => {
|
|
|
+ const value = (materialValues[key] || '').trim()
|
|
|
+ if (value) fields[key] = value
|
|
|
+ })
|
|
|
+ if (heightValue.value && !fields['高度'] && (Object.keys(fields).length || excelPath.value)) {
|
|
|
+ fields['高度'] = `${heightValue.value}cm`
|
|
|
+ }
|
|
|
+ return fields
|
|
|
+}
|
|
|
+
|
|
|
+function runErrorText(result: any) {
|
|
|
+ if (result?.code === 999) return '本机服务没有响应,请确认客户端已经完整启动后再试'
|
|
|
+ const msg = result?.msg || '创建任务失败'
|
|
|
+ if (msg.includes('没有可用于场景的机位')) return '没有场景底图。回到第 1 步,点一张抠图设为场景底图'
|
|
|
+ if (msg.includes('金币不足')) return `金币不足。这次开始时账户里至少要有 ${basis.value === 'scene' ? sceneTags.value.length * sceneCoin.value : analyzeCoin.value + autoRange.value.max * sceneCoin.value} 金币`
|
|
|
+ return msg
|
|
|
+}
|
|
|
+
|
|
|
+async function submitGenerate() {
|
|
|
+ if (busy.value) return
|
|
|
+ const block = blockFor(1) || blockFor(2)
|
|
|
+ if (block) {
|
|
|
+ ElMessage.warning(block)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ resetRun()
|
|
|
+ const token = runToken
|
|
|
+ phase.value = 'submitting'
|
|
|
+ setClosable(false)
|
|
|
+ try {
|
|
|
+ const result = await clientStore.ipc.invoke(icpList.generate.sceneSetRun, plain({
|
|
|
+ goods_art_no: goodsArtNo.value,
|
|
|
+ image_path: imagePath.value,
|
|
|
+ goods_dir: goodsDir.value,
|
|
|
+ layout: layout.value,
|
|
|
+ basis: basis.value,
|
|
|
+ copy_anchor: copyAnchor.value,
|
|
|
+ category_id: categoryId.value,
|
|
|
+ scene_tags: basis.value === 'scene' ? sceneTags.value : [],
|
|
|
+ selling_points: sellingPoints.value.trim(),
|
|
|
+ materials: buildFields(),
|
|
|
+ visible_features: selectedVisible(),
|
|
|
+ height_cm: heightValue.value ? String(heightValue.value) : '',
|
|
|
+ missing_height: 'default',
|
|
|
+ default_tier: tier.value,
|
|
|
+ cutouts: cutouts.value,
|
|
|
+ excel_path: excelPath.value,
|
|
|
+ force: forceNext,
|
|
|
+ }))
|
|
|
+ if (token !== runToken) return
|
|
|
+ if (!result || result.code !== 0) throw new Error(runErrorText(result))
|
|
|
+ const data = result.data || {}
|
|
|
+ if (data.skipped) {
|
|
|
+ forceNext = 0
|
|
|
+ phase.value = 'skipped'
|
|
|
+ skipReason.value = data.reason || '已跳过'
|
|
|
+ resultDir.value = data.output_dir || ''
|
|
|
+ setClosable(true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!data.job_id) throw new Error(data.reason || '没有创建任务')
|
|
|
+ forceNext = 0
|
|
|
+ jobId = data.job_id
|
|
|
+ goodsDir.value = data.goods_dir || goodsDir.value
|
|
|
+ resumed.value = !!data.resumed
|
|
|
+ progressItems.value = data.images || []
|
|
|
+ jobNotes.value = data.progress_notes || []
|
|
|
+ jobTier.value = data.tier || ''
|
|
|
+ phase.value = 'running'
|
|
|
+ startPoll(token)
|
|
|
+ } catch (error: any) {
|
|
|
+ if (token !== runToken) return
|
|
|
+ phase.value = 'failed'
|
|
|
+ failedStage.value = 'create'
|
|
|
+ errorText.value = errorMessage(error, '创建任务失败')
|
|
|
+ setClosable(true)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function startPoll(token: number) {
|
|
|
+ stopPoll()
|
|
|
+ polling = true
|
|
|
+ const tick = async () => {
|
|
|
+ if (!polling || token !== runToken) return
|
|
|
+ try {
|
|
|
+ const res: any = await fetchSceneSetJobs([jobId])
|
|
|
+ if (!polling || token !== runToken) return
|
|
|
+ pollError.value = ''
|
|
|
+ const job = (res?.data?.list || [])[0]
|
|
|
+ if (job) {
|
|
|
+ progressItems.value = job.images || []
|
|
|
+ jobNotes.value = job.progress_notes || []
|
|
|
+ jobTier.value = job.tier || jobTier.value
|
|
|
+ const status = Number(job.status)
|
|
|
+ if (status === 2 || status === 3) {
|
|
|
+ polling = false
|
|
|
+ await saveJob(job, token)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (status === -1) {
|
|
|
+ polling = false
|
|
|
+ forceNext = 1
|
|
|
+ phase.value = 'failed'
|
|
|
+ failedStage.value = 'scenes'
|
|
|
+ setClosable(true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (token !== runToken) return
|
|
|
+ pollError.value = '查询进度失败,正在重试…'
|
|
|
+ }
|
|
|
+ if (polling && token === runToken) pollTimer = setTimeout(tick, 3000)
|
|
|
+ }
|
|
|
+ tick()
|
|
|
+}
|
|
|
+
|
|
|
+function stopPoll() {
|
|
|
+ polling = false
|
|
|
+ if (pollTimer) clearTimeout(pollTimer)
|
|
|
+ pollTimer = null
|
|
|
+}
|
|
|
+
|
|
|
+async function saveJob(job: any, token: number) {
|
|
|
+ lastJob = job
|
|
|
+ phase.value = 'saving'
|
|
|
+ failedStage.value = ''
|
|
|
+ setClosable(false)
|
|
|
+ try {
|
|
|
+ const result = await clientStore.ipc.invoke(icpList.generate.sceneSetSave, plain({
|
|
|
+ goods_dir: goodsDir.value,
|
|
|
+ copy_anchor: copyAnchor.value,
|
|
|
+ job,
|
|
|
+ cutouts: cutouts.value,
|
|
|
+ }))
|
|
|
+ if (token !== runToken) return
|
|
|
+ if (!result || result.code !== 0) {
|
|
|
+ throw new Error(result?.code === 999 ? '本机服务没有响应' : (result?.msg || '保存失败'))
|
|
|
+ }
|
|
|
+ const data = result.data || {}
|
|
|
+ resultDir.value = data.output_dir || ''
|
|
|
+ savedFiles.value = data.files || []
|
|
|
+ savedNotes.value = data.notes || []
|
|
|
+ savedAt.value = Date.now()
|
|
|
+ outputExists.value = true
|
|
|
+ phase.value = 'done'
|
|
|
+ setClosable(true)
|
|
|
+ ElNotification({
|
|
|
+ title: '卖点套图已保存',
|
|
|
+ message: `${goodsArtNo.value} 共 ${resultImages.value.length} 张图`,
|
|
|
+ type: 'success',
|
|
|
+ duration: 6000,
|
|
|
+ })
|
|
|
+ } catch (error: any) {
|
|
|
+ if (token !== runToken) return
|
|
|
+ phase.value = 'failed'
|
|
|
+ failedStage.value = 'save'
|
|
|
+ errorText.value = errorMessage(error, '保存失败')
|
|
|
+ setClosable(true)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function retrySave() {
|
|
|
+ if (lastJob) saveJob(lastJob, runToken)
|
|
|
+}
|
|
|
+
|
|
|
+function retryGenerate() {
|
|
|
+ resetRun()
|
|
|
+ scrollTop()
|
|
|
+}
|
|
|
+
|
|
|
+async function confirmForce() {
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm('会重新创建任务,并重新扣金币;上次任务的图不再保存。确定吗?', '放弃上次的任务', {
|
|
|
+ confirmButtonText: '重新生成',
|
|
|
+ cancelButtonText: '再等等',
|
|
|
+ type: 'warning',
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ resetRun()
|
|
|
+ forceNext = 1
|
|
|
+ submitGenerate()
|
|
|
+}
|
|
|
+
|
|
|
+function restart() {
|
|
|
+ resetRun()
|
|
|
+ forceNext = 0
|
|
|
+ goodsArtNo.value = ''
|
|
|
+ goodsMeta.value = ''
|
|
|
+ imagePath.value = ''
|
|
|
+ resetGoods()
|
|
|
+ heightCm.value = ''
|
|
|
+ layoutTouched.value = false
|
|
|
+ layout.value = tierInfo.value.layout
|
|
|
+ sceneTags.value = []
|
|
|
+ sellingPoints.value = ''
|
|
|
+ visibleKeys.value = []
|
|
|
+ excelPath.value = ''
|
|
|
+ Object.keys(materialValues).forEach((key) => {
|
|
|
+ materialValues[key] = ''
|
|
|
+ })
|
|
|
+ moreOpen.value = []
|
|
|
+ step.value = 1
|
|
|
+ setClosable(true)
|
|
|
+ scrollTop()
|
|
|
+}
|
|
|
+
|
|
|
+function itemTone(item: any) {
|
|
|
+ const status = Number(item.status)
|
|
|
+ if (status === 2) return 'ok'
|
|
|
+ if (status === -1) return 'bad'
|
|
|
+ if (status === 4) return 'local'
|
|
|
+ return 'wait'
|
|
|
+}
|
|
|
+
|
|
|
+function itemPlaceholder(item: any) {
|
|
|
+ const status = Number(item.status)
|
|
|
+ if (status === 4) return '出完场景图后在本机排版'
|
|
|
+ if (status === -1) return item.error_msg || '没有生成'
|
|
|
+ if (status === 1) return '生成中'
|
|
|
+ return '排队中'
|
|
|
+}
|
|
|
+
|
|
|
+function statusText(item: any) {
|
|
|
+ const status = Number(item.status)
|
|
|
+ if (status === 4) return phase.value === 'done' ? '已保存' : '本机排版'
|
|
|
+ if (status === 2) return phase.value === 'done' ? '已保存' : '已出图'
|
|
|
+ if (status === 1) return '生成中'
|
|
|
+ if (status === 0) return '排队中'
|
|
|
+ if (status === -1) return '失败,已退金币'
|
|
|
+ return '处理中'
|
|
|
+}
|
|
|
+
|
|
|
+function openDir(path: string) {
|
|
|
+ if (!path) return
|
|
|
+ clientStore.ipc.removeAllListeners(icpList.utils.shellFun)
|
|
|
+ clientStore.ipc.send(icpList.utils.shellFun, {
|
|
|
+ action: 'openMkPath',
|
|
|
+ params: path.replace(/\//g, '\\'),
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+loadCategories()
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ runToken += 1
|
|
|
+ stopPoll()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.ss-page {
|
|
|
+ height: calc(100vh - 50px);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ background: #f4f6fb;
|
|
|
+ color: #1f2a44;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.ss-top {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 24px;
|
|
|
+ padding: 12px 28px;
|
|
|
+ background: #fff;
|
|
|
+ border-bottom: 1px solid #e8edf5;
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 2px;
|
|
|
+
|
|
|
+ b {
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+
|
|
|
+ span {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #8a94a6;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.ss-steps {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.ss-step {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 6px 10px;
|
|
|
+ border-radius: 10px;
|
|
|
+ color: #8a94a6;
|
|
|
+ user-select: none;
|
|
|
+
|
|
|
+ &__no {
|
|
|
+ width: 28px;
|
|
|
+ height: 28px;
|
|
|
+ flex: none;
|
|
|
+ border-radius: 50%;
|
|
|
+ border: 2px solid #d3d9e6;
|
|
|
+ background: #fff;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__text {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ line-height: 1.35;
|
|
|
+
|
|
|
+ b {
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ small {
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__line {
|
|
|
+ width: 36px;
|
|
|
+ height: 2px;
|
|
|
+ border-radius: 1px;
|
|
|
+ background: #e1e6f0;
|
|
|
+
|
|
|
+ &.is-done {
|
|
|
+ background: #2957ff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-active {
|
|
|
+ color: #1f2a44;
|
|
|
+
|
|
|
+ .ss-step__no {
|
|
|
+ border-color: #2957ff;
|
|
|
+ background: #2957ff;
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+
|
|
|
+ small {
|
|
|
+ color: #5b6b8c;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-done {
|
|
|
+ color: #1f2a44;
|
|
|
+
|
|
|
+ .ss-step__no {
|
|
|
+ border-color: #2957ff;
|
|
|
+ color: #2957ff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-clickable {
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: #f0f4ff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.ss-main {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.ss-inner {
|
|
|
+ max-width: 1120px;
|
|
|
+ margin: 0 auto;
|
|
|
+ padding: 20px 24px 28px;
|
|
|
+}
|
|
|
+
|
|
|
+.ss-stack {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.ss-card {
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e8edf5;
|
|
|
+ border-radius: 14px;
|
|
|
+ padding: 20px 22px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 14px;
|
|
|
+
|
|
|
+ &__head {
|
|
|
+ position: relative;
|
|
|
+ padding-left: 12px;
|
|
|
+
|
|
|
+ &::before {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ top: 3px;
|
|
|
+ width: 4px;
|
|
|
+ height: 16px;
|
|
|
+ border-radius: 2px;
|
|
|
+ background: #2957ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &--row {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 16px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__grow {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 22px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__desc {
|
|
|
+ margin-top: 4px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #6b778c;
|
|
|
+ line-height: 1.6;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.ss-muted {
|
|
|
+ color: #8a94a6;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: normal;
|
|
|
+ line-height: 1.6;
|
|
|
+}
|
|
|
+
|
|
|
+.ss-inline {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.ss-optional {
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: normal;
|
|
|
+ color: #8a94a6;
|
|
|
+}
|
|
|
+
|
|
|
+.ss-pill {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ height: 32px;
|
|
|
+ padding: 0 14px;
|
|
|
+ border-radius: 16px;
|
|
|
+ background: #f2f4f8;
|
|
|
+ color: #4a5875;
|
|
|
+ font-size: 13px;
|
|
|
+ white-space: nowrap;
|
|
|
+
|
|
|
+ &--blue {
|
|
|
+ height: 20px;
|
|
|
+ padding: 0 8px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #eef2ff;
|
|
|
+ color: #2957ff;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.alert-body {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+ line-height: 1.6;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-raw {
|
|
|
+ color: #8a94a6;
|
|
|
+}
|
|
|
+
|
|
|
+.alert-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ margin-top: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.goods-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+
|
|
|
+ &__search {
|
|
|
+ width: 380px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__category {
|
|
|
+ width: 200px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.goods-ok {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ color: #2f9d62;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.tier-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.tier-card {
|
|
|
+ border: 1.5px solid #e1e6f0;
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 14px 16px;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+ transition: border-color 0.15s, background-color 0.15s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #a9bbff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-on {
|
|
|
+ border-color: #2957ff;
|
|
|
+ background: #f5f8ff;
|
|
|
+ box-shadow: 0 0 0 3px rgba(41, 87, 255, 0.08);
|
|
|
+ }
|
|
|
+
|
|
|
+ &__top {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__badge {
|
|
|
+ width: 26px;
|
|
|
+ height: 26px;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #f2f4f8;
|
|
|
+ color: #4a5875;
|
|
|
+ font-weight: 700;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-on &__badge {
|
|
|
+ background: #2957ff;
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__name {
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__range {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6b778c;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__desc {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #4a5875;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__scenes {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #8a94a6;
|
|
|
+ line-height: 1.6;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.height-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #4a5875;
|
|
|
+
|
|
|
+ &__input {
|
|
|
+ width: 150px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.layout-switch {
|
|
|
+ flex: none;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #6b778c;
|
|
|
+}
|
|
|
+
|
|
|
+.cutout-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.cutout-card {
|
|
|
+ border: 1.5px solid #e8edf5;
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 8px;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+ transition: border-color 0.15s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #a9bbff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-editing {
|
|
|
+ border-color: #2957ff;
|
|
|
+ box-shadow: 0 0 0 3px rgba(41, 87, 255, 0.08);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-off img {
|
|
|
+ opacity: 0.4;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__img {
|
|
|
+ position: relative;
|
|
|
+ aspect-ratio: 1;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: repeating-conic-gradient(#eceff5 0% 25%, #f7f8fb 0% 50%) 50% / 16px 16px;
|
|
|
+
|
|
|
+ img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: contain;
|
|
|
+ transition: transform 0.2s;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__index {
|
|
|
+ position: absolute;
|
|
|
+ left: 6px;
|
|
|
+ top: 6px;
|
|
|
+ min-width: 20px;
|
|
|
+ height: 20px;
|
|
|
+ padding: 0 6px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(31, 42, 68, 0.72);
|
|
|
+ color: #fff;
|
|
|
+ font-size: 12px;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__name {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+
|
|
|
+ em {
|
|
|
+ font-style: normal;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: normal;
|
|
|
+ color: #d14343;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__tags {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 4px;
|
|
|
+ min-height: 20px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.cut-tag {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ height: 20px;
|
|
|
+ padding: 0 6px;
|
|
|
+ border-radius: 4px;
|
|
|
+ font-size: 11px;
|
|
|
+ font-style: normal;
|
|
|
+ white-space: nowrap;
|
|
|
+
|
|
|
+ &--plate {
|
|
|
+ background: #2957ff;
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &--view {
|
|
|
+ background: #e8f6ee;
|
|
|
+ color: #2f9d62;
|
|
|
+ }
|
|
|
+
|
|
|
+ &--detail {
|
|
|
+ background: #fff4e5;
|
|
|
+ color: #c77700;
|
|
|
+ }
|
|
|
+
|
|
|
+ &--off {
|
|
|
+ background: #f2f4f8;
|
|
|
+ color: #8a94a6;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.cutout-edit {
|
|
|
+ border: 1px solid #d6e0ff;
|
|
|
+ background: #f7f9ff;
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 12px 14px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__label {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #6b778c;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__select {
|
|
|
+ width: 160px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__sep {
|
|
|
+ width: 1px;
|
|
|
+ height: 18px;
|
|
|
+ background: #dde3ee;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__done {
|
|
|
+ margin-left: auto;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.cutout-legend {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 8px 20px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6b778c;
|
|
|
+
|
|
|
+ span {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.basis-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.basis-card {
|
|
|
+ border: 1.5px solid #e1e6f0;
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 14px 16px;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 12px;
|
|
|
+ transition: border-color 0.15s, background-color 0.15s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #a9bbff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-on {
|
|
|
+ border-color: #2957ff;
|
|
|
+ background: #f5f8ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__radio {
|
|
|
+ position: relative;
|
|
|
+ flex: none;
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ margin-top: 3px;
|
|
|
+ border-radius: 50%;
|
|
|
+ border: 1.5px solid #c3cbdb;
|
|
|
+ background: #fff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-on &__radio {
|
|
|
+ border-color: #2957ff;
|
|
|
+
|
|
|
+ &::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ inset: 3px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #2957ff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__desc {
|
|
|
+ margin-top: 4px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #6b778c;
|
|
|
+ line-height: 1.6;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.scene-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.scene-card {
|
|
|
+ position: relative;
|
|
|
+ border: 1.5px solid #e1e6f0;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 12px 14px 12px 40px;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #a9bbff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-on {
|
|
|
+ border-color: #2957ff;
|
|
|
+ background: #f5f8ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__check {
|
|
|
+ position: absolute;
|
|
|
+ left: 12px;
|
|
|
+ top: 13px;
|
|
|
+ width: 18px;
|
|
|
+ height: 18px;
|
|
|
+ border-radius: 5px;
|
|
|
+ border: 1.5px solid #c3cbdb;
|
|
|
+ background: #fff;
|
|
|
+ color: transparent;
|
|
|
+ font-size: 12px;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-on &__check {
|
|
|
+ background: #2957ff;
|
|
|
+ border-color: #2957ff;
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__name {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__sub {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6b778c;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__need {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #c77700;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.scene-pool {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.pool-chip {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ height: 26px;
|
|
|
+ padding: 0 10px;
|
|
|
+ border-radius: 13px;
|
|
|
+ background: #f2f4f8;
|
|
|
+ color: #4a5875;
|
|
|
+ font-size: 12px;
|
|
|
+
|
|
|
+ em {
|
|
|
+ font-style: normal;
|
|
|
+ color: #c77700;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.visible-box {
|
|
|
+ border: 1px dashed #d6dce8;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 12px 14px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+
|
|
|
+ &__head {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #4a5875;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.chip-row {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.chip {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ height: 30px;
|
|
|
+ padding: 0 14px;
|
|
|
+ border-radius: 15px;
|
|
|
+ border: 1px solid #dde3ee;
|
|
|
+ background: #fff;
|
|
|
+ color: #4a5875;
|
|
|
+ font-size: 13px;
|
|
|
+ cursor: pointer;
|
|
|
+ user-select: none;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #a9bbff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-on {
|
|
|
+ border-color: #2957ff;
|
|
|
+ background: #eef2ff;
|
|
|
+ color: #2957ff;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.more {
|
|
|
+ border-top: none;
|
|
|
+ border-bottom: none;
|
|
|
+
|
|
|
+ :deep(.el-collapse-item__header) {
|
|
|
+ height: 40px;
|
|
|
+ gap: 8px;
|
|
|
+ border-bottom: none;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-collapse-item__wrap) {
|
|
|
+ border-bottom: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-collapse-item__content) {
|
|
|
+ padding-bottom: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1f2a44;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.more-group {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+
|
|
|
+ & + & {
|
|
|
+ margin-top: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.material-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(4, 1fr);
|
|
|
+ gap: 10px 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.material-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6b778c;
|
|
|
+
|
|
|
+ &__value {
|
|
|
+ height: 24px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ color: #4a5875;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.excel-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+
|
|
|
+ &__name {
|
|
|
+ max-width: 420px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #4a5875;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.anchor-grid {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.anchor-card {
|
|
|
+ width: 100px;
|
|
|
+ border: 1.5px solid #e1e6f0;
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 10px;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #a9bbff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-on {
|
|
|
+ border-color: #2957ff;
|
|
|
+ background: #f5f8ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__label {
|
|
|
+ font-size: 13px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.anchor-frame {
|
|
|
+ position: relative;
|
|
|
+ width: 64px;
|
|
|
+ height: 96px;
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: linear-gradient(180deg, #e3eaf6 0%, #cdd7ea 62%, #b7c2d8 100%);
|
|
|
+
|
|
|
+ &__goods {
|
|
|
+ position: absolute;
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ border-radius: 4px;
|
|
|
+ background: #4a5875;
|
|
|
+
|
|
|
+ &.is-handheld {
|
|
|
+ width: 66%;
|
|
|
+ height: 20%;
|
|
|
+ bottom: 10%;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-floor {
|
|
|
+ width: 42%;
|
|
|
+ height: 44%;
|
|
|
+ bottom: 2%;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__text {
|
|
|
+ position: absolute;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 3px;
|
|
|
+
|
|
|
+ i {
|
|
|
+ display: block;
|
|
|
+ width: 30px;
|
|
|
+ height: 5px;
|
|
|
+ border-radius: 2px;
|
|
|
+ background: #2957ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ i + i {
|
|
|
+ width: 20px;
|
|
|
+ height: 4px;
|
|
|
+ opacity: 0.55;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.at-top-left {
|
|
|
+ top: 7%;
|
|
|
+ left: 8%;
|
|
|
+ align-items: flex-start;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.at-top-right {
|
|
|
+ top: 7%;
|
|
|
+ right: 8%;
|
|
|
+ align-items: flex-end;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.at-bottom-left {
|
|
|
+ bottom: 6%;
|
|
|
+ left: 8%;
|
|
|
+ align-items: flex-start;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.at-bottom-right {
|
|
|
+ bottom: 6%;
|
|
|
+ right: 8%;
|
|
|
+ align-items: flex-end;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.at-center {
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ align-items: center;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.anchor-card.is-on .anchor-frame__text i {
|
|
|
+ background: #ff7a00;
|
|
|
+}
|
|
|
+
|
|
|
+.confirm-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ border: 1px solid #edf1f7;
|
|
|
+ border-radius: 12px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.confirm-row {
|
|
|
+ display: flex;
|
|
|
+ gap: 16px;
|
|
|
+ padding: 12px 16px;
|
|
|
+ font-size: 14px;
|
|
|
+
|
|
|
+ & + & {
|
|
|
+ border-top: 1px solid #edf1f7;
|
|
|
+ }
|
|
|
+
|
|
|
+ span {
|
|
|
+ flex: none;
|
|
|
+ width: 72px;
|
|
|
+ color: #8a94a6;
|
|
|
+ }
|
|
|
+
|
|
|
+ b {
|
|
|
+ flex: 1;
|
|
|
+ font-weight: 500;
|
|
|
+ line-height: 1.6;
|
|
|
+ }
|
|
|
+
|
|
|
+ small {
|
|
|
+ display: block;
|
|
|
+ margin-top: 2px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: normal;
|
|
|
+ color: #8a94a6;
|
|
|
+ }
|
|
|
+
|
|
|
+ &--coin b {
|
|
|
+ color: #2957ff;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.run-head {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 12px;
|
|
|
+
|
|
|
+ &__icon {
|
|
|
+ flex: none;
|
|
|
+ width: 36px;
|
|
|
+ height: 36px;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 20px;
|
|
|
+
|
|
|
+ &.is-run {
|
|
|
+ background: #eef2ff;
|
|
|
+ color: #2957ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-ok {
|
|
|
+ background: #e8f6ee;
|
|
|
+ color: #2f9d62;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-warn {
|
|
|
+ background: #fff4e5;
|
|
|
+ color: #c77700;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-bad {
|
|
|
+ background: #fdecec;
|
|
|
+ color: #d14343;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__text {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ font-size: 17px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__desc {
|
|
|
+ margin-top: 2px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #6b778c;
|
|
|
+ line-height: 1.6;
|
|
|
+ word-break: break-all;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.run-grid,
|
|
|
+.result-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.run-card,
|
|
|
+.result-card {
|
|
|
+ border: 1px solid #e8edf5;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 8px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.run-card {
|
|
|
+ &__img {
|
|
|
+ aspect-ratio: 2 / 3;
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #f2f4f8;
|
|
|
+
|
|
|
+ :deep(.el-image) {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: block;
|
|
|
+ cursor: zoom-in;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__placeholder {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ padding: 10px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 6px;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #8a94a6;
|
|
|
+
|
|
|
+ .el-icon {
|
|
|
+ font-size: 20px;
|
|
|
+ color: #2957ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-bad {
|
|
|
+ color: #d14343;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__name {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__status {
|
|
|
+ font-size: 12px;
|
|
|
+
|
|
|
+ &.is-ok {
|
|
|
+ color: #2f9d62;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-bad {
|
|
|
+ color: #d14343;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-local,
|
|
|
+ &.is-wait {
|
|
|
+ color: #8a94a6;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.result-card {
|
|
|
+ :deep(.el-image) {
|
|
|
+ width: 100%;
|
|
|
+ aspect-ratio: 2 / 3;
|
|
|
+ display: block;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: #f2f4f8;
|
|
|
+ cursor: zoom-in;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__name {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #4a5875;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.run-notes {
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #f7f8fb;
|
|
|
+ padding: 12px 14px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6b778c;
|
|
|
+ line-height: 1.8;
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #4a5875;
|
|
|
+ margin-bottom: 2px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.ss-footer {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16px;
|
|
|
+ padding: 12px 28px;
|
|
|
+ background: #fff;
|
|
|
+ border-top: 1px solid #e8edf5;
|
|
|
+
|
|
|
+ &__side {
|
|
|
+ flex: none;
|
|
|
+ min-width: 120px;
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+
|
|
|
+ &--right {
|
|
|
+ justify-content: flex-end;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__info {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #6b778c;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__warn {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ color: #c77700;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|