处理修复BUG

This commit is contained in:
super
2026-06-14 16:48:20 +08:00
parent 8672668c33
commit ac19637ad6
18 changed files with 908 additions and 283 deletions

View File

@@ -799,7 +799,9 @@ async function runMatch() {
countryCodes: resolveCountryCodesForRequest(),
})
const batch = res.items || []
matchAsinRowsByCountry.value = res.asinRowsByCountry || {}
// 移除:不再使用后端返回的 ASIN 数据
// matchAsinRowsByCountry.value = res.asinRowsByCountry || {}
matchAsinRowsByCountry.value = {}
const nextByShop = new Map(
matchedItems.value.map((item) => [((item.shopName || '').trim()), item] as const),
)
@@ -989,9 +991,6 @@ async function pushToPythonQueueLegacy() {
function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) {
const taskItem = taskVo.items?.[0]
const shopName = (row.shopName || '').trim()
const skipAsinsByCountry = row.skipAsins || taskVo.skipAsinsByCountry || {}
const skipAsinDetailsByCountry = taskVo.skipAsinDetailsByCountry || {}
const minimumPriceByCountryAndAsin = taskVo.minimumPriceByCountryAndAsin || {}
const skipAsinDeletePolicy = buildSkipAsinDeletePolicy()
const resultId = taskItem?.resultId ?? null
const loopRunId = taskItem?.loopRunId ?? null
@@ -1001,6 +1000,7 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
const error = taskItem?.error || null
const outputFilename = taskItem?.outputFilename || null
const downloadUrl = taskItem?.downloadUrl || null
return {
type: 'price-track-run',
ts: Date.now(),
@@ -1024,16 +1024,14 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
round_index: roundIndex,
country_codes: resolveCountryCodesForRequest(),
mode: statusModeEnabled.value ? 'status' : 'asin',
skip_asins: skipAsinsByCountry,
skip_asins_by_country: skipAsinsByCountry,
skip_asin_details_by_country: skipAsinDetailsByCountry,
minimum_price_by_country_and_asin: minimumPriceByCountryAndAsin,
// 分页拉取配置Python 端统一处理)
use_paginated_skip_asins: true,
skip_asin_page_size: 1000,
// 移除:不再传递任何 ASIN 数据Python 端统一分页拉取
skip_asin_delete_policy: skipAsinDeletePolicy,
delete_skip_asin_when_price_below_minimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum,
deleteSkipAsinWhenPriceBelowMinimum: skipAsinDeletePolicy.deleteWhenPriceBelowMinimum,
asin_rows_by_country: Object.keys(matchAsinRowsByCountry.value).length
? matchAsinRowsByCountry.value
: (taskVo.asinRowsByCountry || {}),
// 兼容字段
taskId: taskVo.taskId,
resultId: resultId,
shopName,

View File

@@ -59,7 +59,7 @@
<p class="hint listing-filter-hint">推送到 Python 队列一并下发 Python 区分处理场景</p>
<div class="listing-filter-row">
<el-select v-model="productRiskListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
<el-option v-for="opt in LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
<el-option v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
:value="opt.value" />
</el-select>
</div>
@@ -235,6 +235,12 @@ const autoQueueEnabled = ref(false)
const queuePushResult = ref('')
const queuePayloadText = ref('')
const PRODUCT_RISK_LISTING_FILTER_OPTIONS = [
...LISTING_FILTER_OPTIONS,
{ value: 'AccountStatus', label: '账户状态处理' },
] as const
type ProductRiskListingFilterValue = ListingFilterValue | 'AccountStatus'
/** 与后端 ProductRiskCountryCode / 默认顺序 DE→UK→FR→IT→ES 一致 */
const COUNTRY_OPTIONS = [
{ code: 'DE', label: '德国' },
@@ -245,7 +251,7 @@ const COUNTRY_OPTIONS = [
] as const
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
const productRiskListingFilter = ref<ListingFilterValue>('SearchSuppressed')
const productRiskListingFilter = ref<ProductRiskListingFilterValue>('SearchSuppressed')
const dragCountryIndex = ref<number | null>(null)
const countryPrefSaving = ref(false)
/** 用户已改过顺序/勾选后,忽略晚到的 GET避免把界面打回全选 */
@@ -527,8 +533,8 @@ async function removeMatchedRow(row: ProductRiskShopQueueItem) {
/** 历史列表中已无该 taskId 时,停止轮询并清本地快照 */
function syncPollingIdsWithHistory() {
for (const taskId of [...pollingTaskIds.value]) {
const any = historyItems.value.some((r) => r.taskId === taskId)
if (!any) {
const rows = historyItems.value.filter((r) => r.taskId === taskId)
if (!rows.length || rows.some((row) => isHistoryItemTerminal(row))) {
removePollingTask(taskId)
}
}
@@ -640,9 +646,22 @@ function taskStatusOf(taskId?: number) {
return taskDetails.value[taskId] || ''
}
function isTerminalStatus(status?: string) {
return status === 'SUCCESS' || status === 'FAILED'
}
function isTaskTerminalById(taskId?: number) {
const s = taskStatusOf(taskId)
return s === 'SUCCESS' || s === 'FAILED'
return isTerminalStatus(s)
}
function isHistoryItemTerminal(item: ProductRiskHistoryItem) {
if (isTerminalStatus(item.taskStatus)) return true
if (item.success === true) return true
if (item.fileStatus === 'SUCCESS' && item.fileReady) return true
if (item.fileStatus === 'FAILED') return true
if (item.success === false && !!item.error) return true
return false
}
const currentSectionItems = computed(() => {
@@ -650,7 +669,8 @@ const currentSectionItems = computed(() => {
(row) =>
row.taskId &&
pollingTaskIds.value.includes(row.taskId) &&
!isTaskTerminalById(row.taskId),
!isTaskTerminalById(row.taskId) &&
!isHistoryItemTerminal(row),
)
const existingTaskIds = new Set(out.map((row) => row.taskId).filter((id): id is number => typeof id === 'number'))
@@ -669,7 +689,8 @@ const historySectionItems = computed(() =>
(row) =>
!row.taskId ||
!pollingTaskIds.value.includes(row.taskId) ||
isTaskTerminalById(row.taskId),
isTaskTerminalById(row.taskId) ||
isHistoryItemTerminal(row),
),
)
@@ -953,6 +974,9 @@ async function runMatch() {
}
function resolvedTaskStatus(item: ProductRiskHistoryItem) {
if (isTerminalStatus(item.taskStatus)) return item.taskStatus || ''
if (item.success === true || (item.fileStatus === 'SUCCESS' && item.fileReady)) return 'SUCCESS'
if (item.fileStatus === 'FAILED' || (item.success === false && !!item.error)) return 'FAILED'
const tid = item.taskId
if (!tid) return item.taskStatus || ''
return taskStatusOf(tid) || item.taskStatus || ''

View File

@@ -25,6 +25,19 @@
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
{{ statusText }}
</div>
<section v-if="launching || launchProgress.stage" class="launch-progress" :class="{ error: launchProgress.stage === 'failed' }">
<div class="launch-progress__head">
<span>{{ launchProgress.message || '正在准备数字人程序...' }}</span>
<strong>{{ launchProgress.percent }}%</strong>
</div>
<div class="launch-progress__track">
<div class="launch-progress__bar" :style="{ width: `${launchProgress.percent}%` }"></div>
</div>
<div v-if="launchProgress.total > 0" class="launch-progress__meta">
{{ formatBytes(launchProgress.downloaded) }} / {{ formatBytes(launchProgress.total) }}
</div>
</section>
</div>
<PageShell v-else theme="dark">
@@ -50,7 +63,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeUnmount, ref } from 'vue'
import PageShell from '@/components/layout/PageShell.vue'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
@@ -63,7 +76,15 @@ const currentView = ref<ViewMode>('menu')
const launching = ref(false)
const statusText = ref('')
const statusType = ref<'normal' | 'error'>('normal')
const launchProgress = ref({
stage: '',
message: '',
percent: 0,
downloaded: 0,
total: 0,
})
let statusTimer: number | undefined
let launchProgressTimer: number | undefined
function showStatus(message: string, type: 'normal' | 'error' = 'normal') {
statusText.value = message
@@ -84,6 +105,59 @@ function openDeliveryWorkspace() {
currentView.value = 'delivery'
}
function formatBytes(value: number) {
const size = Number(value || 0)
if (!Number.isFinite(size) || size <= 0) return '未知大小'
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`
}
function resetLaunchProgress() {
launchProgress.value = {
stage: '',
message: '',
percent: 0,
downloaded: 0,
total: 0,
}
}
function handleLaunchProgress(event: Event) {
const detail = (event as CustomEvent<{
stage?: string
message?: string
percent?: number
downloaded?: number
total?: number
}>).detail
if (!detail) return
launchProgress.value = {
stage: detail.stage || '',
message: detail.message || '',
percent: Math.max(0, Math.min(100, Math.round(Number(detail.percent || 0)))),
downloaded: Number(detail.downloaded || 0),
total: Number(detail.total || 0),
}
if (launchProgressTimer) {
window.clearTimeout(launchProgressTimer)
}
if (detail.stage === 'success') {
launchProgressTimer = window.setTimeout(resetLaunchProgress, 1800)
}
}
if (typeof window !== 'undefined') {
window.addEventListener('pywebview-yaoayanui-progress', handleLaunchProgress)
}
onBeforeUnmount(() => {
if (statusTimer) window.clearTimeout(statusTimer)
if (launchProgressTimer) window.clearTimeout(launchProgressTimer)
window.removeEventListener('pywebview-yaoayanui-progress', handleLaunchProgress)
})
async function launchDesktop() {
const api = getPywebviewApi()
if (!api?.launch_yaoayanui) {
@@ -92,15 +166,45 @@ async function launchDesktop() {
}
launching.value = true
launchProgress.value = {
stage: 'checking',
message: '正在检查本地数字人程序...',
percent: 0,
downloaded: 0,
total: 0,
}
try {
const result = await api.launch_yaoayanui()
if (result?.success) {
showStatus('已发送启动请求')
if (!launchProgress.value.stage || launchProgress.value.stage === 'checking') {
launchProgress.value = {
stage: 'success',
message: '数字人程序已启动',
percent: 100,
downloaded: 0,
total: 0,
}
launchProgressTimer = window.setTimeout(resetLaunchProgress, 1800)
}
return
}
launchProgress.value = {
...launchProgress.value,
stage: 'failed',
message: result?.error || '启动失败',
percent: 0,
}
showStatus(result?.error || '启动失败', 'error')
} catch (error) {
showStatus(error instanceof Error ? error.message : String(error), 'error')
const message = error instanceof Error ? error.message : String(error)
launchProgress.value = {
...launchProgress.value,
stage: 'failed',
message,
percent: 0,
}
showStatus(message, 'error')
} finally {
launching.value = false
}
@@ -212,6 +316,74 @@ async function launchDesktop() {
background: rgba(176, 42, 55, 0.92);
}
.launch-progress {
position: fixed;
left: 50%;
bottom: 96px;
width: min(520px, calc(100vw - 48px));
padding: 14px 16px;
border: 1px solid rgba(102, 126, 234, 0.28);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10px 28px rgba(20, 32, 55, 0.16);
transform: translateX(-50%);
}
.launch-progress.error {
border-color: rgba(176, 42, 55, 0.34);
}
.launch-progress__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #2d3748;
font-size: 14px;
}
.launch-progress__head span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.launch-progress__head strong {
color: #4456c9;
font-size: 13px;
}
.launch-progress.error .launch-progress__head strong {
color: #b02a37;
}
.launch-progress__track {
height: 8px;
margin-top: 10px;
overflow: hidden;
border-radius: 999px;
background: #e6ebf5;
}
.launch-progress__bar {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #667eea, #52c2ff);
transition: width 0.2s ease;
}
.launch-progress.error .launch-progress__bar {
background: #b02a37;
}
.launch-progress__meta {
margin-top: 7px;
color: #68758a;
font-size: 12px;
text-align: right;
}
.delivery-page {
min-height: 100vh;
background: #0f0f0f;

View File

@@ -23,7 +23,10 @@
:file-name="currentWorkspace.primaryAsset.fileName"
:preview-url="currentWorkspace.primaryAsset.previewUrl"
:preview-kind="currentWorkspace.primaryAsset.previewKind"
:source-url="currentWorkspace.primaryAsset.sourceUrl"
allow-url
@select="(file) => setAsset(activeTab, 'primaryAsset', file)"
@url-change="(url) => setAssetUrl(activeTab, 'primaryAsset', url, currentTabCopy.primaryAssetAccept)"
@clear="clearAsset(activeTab, 'primaryAsset')"
/>
@@ -36,7 +39,10 @@
:file-name="currentWorkspace.secondaryAsset.fileName"
:preview-url="currentWorkspace.secondaryAsset.previewUrl"
:preview-kind="currentWorkspace.secondaryAsset.previewKind"
:source-url="currentWorkspace.secondaryAsset.sourceUrl"
allow-url
@select="(file) => setAsset(activeTab, 'secondaryAsset', file)"
@url-change="(url) => setAssetUrl(activeTab, 'secondaryAsset', url, currentTabCopy.secondaryAssetAccept)"
@clear="clearAsset(activeTab, 'secondaryAsset')"
/>
@@ -51,9 +57,36 @@
<AiSectionCard
step="02"
title="标题标签关键词"
description="类目和卖点一起配置方便后续拆投放版本"
class="delivery-card delivery-card--step-02"
>
<div class="delivery-field">
<span class="delivery-field__label">细分类目</span>
<AiChoicePills
v-model="currentWorkspace.categories"
:options="categoryOptions"
multiple
/>
</div>
<div class="delivery-field">
<span class="delivery-field__label">核心卖点</span>
<el-input
v-model="currentWorkspace.productFocus"
type="textarea"
:rows="2"
resize="none"
placeholder="例如上脚显瘦脚感轻百搭直播间主推款"
/>
</div>
</AiSectionCard>
<AiSectionCard
step="03"
title="音视频生成"
description="先锁定投放规格再决定整体风格时长和画面清晰度"
class="delivery-card delivery-card--step-02"
class="delivery-card delivery-card--step-03"
>
<div class="delivery-form-grid delivery-form-grid--two">
<div class="delivery-field">
@@ -88,10 +121,174 @@
</AiSectionCard>
<AiSectionCard
step="03"
step="04"
title="人脸和口播"
description="控制性别人脸图语气和语音来源"
class="delivery-card delivery-card--step-04"
>
<div class="delivery-form-grid delivery-form-grid--two">
<div class="delivery-field">
<span class="delivery-field__label">性别</span>
<AiChoicePills v-model="currentWorkspace.gender" :options="genderOptions" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">语气</span>
<AiChoicePills v-model="currentWorkspace.voiceTone" :options="voiceToneOptions" />
</div>
</div>
<div class="delivery-field">
<span class="delivery-field__label">语音来源</span>
<AiChoicePills
v-model="currentWorkspace.speechSource"
:options="speechSourceOptions"
/>
</div>
<AiAssetDropzone
title="模特图 / 人脸图"
description="上传清晰正脸图适合用于数字人口播"
placeholder="上传模特图"
helper="建议 1080px 以上单人正面"
accept="image/*"
:file-name="currentWorkspace.avatarAsset.fileName"
:preview-url="currentWorkspace.avatarAsset.previewUrl"
:preview-kind="currentWorkspace.avatarAsset.previewKind"
:source-url="currentWorkspace.avatarAsset.sourceUrl"
allow-url
@select="(file) => setAsset(activeTab, 'avatarAsset', file)"
@url-change="(url) => setAssetUrl(activeTab, 'avatarAsset', url, 'image/*')"
@clear="clearAsset(activeTab, 'avatarAsset')"
/>
</AiSectionCard>
<AiSectionCard
step="05"
title="话术和音乐"
description="支持直接口播原话术微调或者转成纯音乐版本"
class="delivery-card delivery-card--step-05"
>
<div class="delivery-field">
<span class="delivery-field__label">话术模式</span>
<AiChoicePills v-model="currentWorkspace.scriptMode" :options="scriptModeOptions" />
</div>
<div class="delivery-form-grid delivery-form-grid--two">
<div class="delivery-field">
<span class="delivery-field__label">话术内容</span>
<el-input
v-model="currentWorkspace.scriptText"
type="textarea"
:rows="4"
resize="none"
placeholder="输入要口播的话术或粘贴需要整体微调的原始话术"
/>
</div>
<div class="delivery-field">
<span class="delivery-field__label">微调约束</span>
<el-input
v-model="currentWorkspace.scriptRules"
type="textarea"
:rows="4"
resize="none"
placeholder="例如保留原来的成交语气不改卖点顺序强调防滑透气现货"
/>
</div>
</div>
<AiAssetDropzone
title="纯音乐素材"
description="需要做纯音乐版本时可上传也可以后续从原视频中抽取"
placeholder="上传背景音乐"
helper="支持 MP3 / WAV"
accept="audio/*"
:file-name="currentWorkspace.musicAsset.fileName"
:preview-url="currentWorkspace.musicAsset.previewUrl"
:preview-kind="currentWorkspace.musicAsset.previewKind"
:source-url="currentWorkspace.musicAsset.sourceUrl"
allow-url
@select="(file) => setAsset(activeTab, 'musicAsset', file)"
@url-change="(url) => setAssetUrl(activeTab, 'musicAsset', url, 'audio/*')"
@clear="clearAsset(activeTab, 'musicAsset')"
/>
</AiSectionCard>
<AiSectionCard
step="06"
title="场景和镜头"
description="支持用提示词微调也支持上传场景图直接替换原场景"
class="delivery-card delivery-card--step-06"
>
<div class="delivery-field">
<span class="delivery-field__label">场景处理方式</span>
<AiChoicePills v-model="currentWorkspace.sceneMode" :options="sceneModeOptions" />
</div>
<div class="delivery-form-grid delivery-form-grid--two">
<div class="delivery-field">
<span class="delivery-field__label">场景提示词</span>
<el-input
v-model="currentWorkspace.scenePrompt"
type="textarea"
:rows="4"
resize="none"
placeholder="例如保留产品特写换成室内直播间镜头推进更快背景偏暖光"
/>
</div>
<AiAssetDropzone
title="场景图替换"
description="有明确参考图时直接上传优先保证场景结构和光线"
placeholder="上传场景参考图"
helper="支持 JPG / PNG"
accept="image/*"
:file-name="currentWorkspace.sceneAsset.fileName"
:preview-url="currentWorkspace.sceneAsset.previewUrl"
:preview-kind="currentWorkspace.sceneAsset.previewKind"
:source-url="currentWorkspace.sceneAsset.sourceUrl"
allow-url
@select="(file) => setAsset(activeTab, 'sceneAsset', file)"
@url-change="(url) => setAssetUrl(activeTab, 'sceneAsset', url, 'image/*')"
@clear="clearAsset(activeTab, 'sceneAsset')"
/>
</div>
<div class="delivery-field">
<span class="delivery-field__label">镜头节奏</span>
<el-slider v-model="currentWorkspace.cameraRhythm" :min="1" :max="10" />
</div>
</AiSectionCard>
<AiSectionCard
step="07"
title="执行说明"
description="补充收尾逻辑例如保留人物动作只替换场景和话术重点"
class="delivery-card delivery-card--step-07"
>
<div class="delivery-field">
<span class="delivery-field__label">收口动作</span>
<el-input
v-model="currentWorkspace.cta"
placeholder="例如结尾引导领券下单强调今晚库存"
/>
</div>
<div class="delivery-field">
<span class="delivery-field__label">执行说明</span>
<el-input
v-model="currentWorkspace.notes"
type="textarea"
:rows="3"
resize="none"
placeholder="补充执行说明例如第二版转成纯音乐短视频"
/>
</div>
</AiSectionCard>
<AiSectionCard
step="08"
title="任务和预览"
description="先保存草稿,再开始组装带货视频。"
class="delivery-card delivery-card--preview delivery-card--step-03"
description="所有配置完成后开始组装带货视频"
class="delivery-card delivery-card--preview delivery-card--step-08"
>
<div class="task-actions">
<el-button class="delivery-primary-btn" type="primary" @click="startAssembly">
@@ -149,197 +346,6 @@
</div>
</div>
</AiSectionCard>
<AiSectionCard
step="04"
title="场景和镜头"
description="支持用提示词微调,也支持上传场景图直接替换原场景。"
class="delivery-card delivery-card--step-04"
>
<div class="delivery-field">
<span class="delivery-field__label">场景处理方式</span>
<AiChoicePills v-model="currentWorkspace.sceneMode" :options="sceneModeOptions" />
</div>
<div class="delivery-form-grid delivery-form-grid--two">
<div class="delivery-field">
<span class="delivery-field__label">场景提示词</span>
<el-input
v-model="currentWorkspace.scenePrompt"
type="textarea"
:rows="4"
resize="none"
placeholder="例如:保留产品特写,换成室内直播间,镜头推进更快,背景偏暖光。"
/>
</div>
<AiAssetDropzone
title="场景图替换"
description="有明确参考图时直接上传,优先保证场景结构和光线。"
placeholder="上传场景参考图"
helper="支持 JPG / PNG"
accept="image/*"
:file-name="currentWorkspace.sceneAsset.fileName"
:preview-url="currentWorkspace.sceneAsset.previewUrl"
:preview-kind="currentWorkspace.sceneAsset.previewKind"
@select="(file) => setAsset(activeTab, 'sceneAsset', file)"
@clear="clearAsset(activeTab, 'sceneAsset')"
/>
</div>
<div class="delivery-field">
<span class="delivery-field__label">镜头节奏</span>
<el-slider v-model="currentWorkspace.cameraRhythm" :min="1" :max="10" />
</div>
</AiSectionCard>
<AiSectionCard
step="05"
title="人脸和口播"
description="控制性别、人脸图、语气和语音来源。"
class="delivery-card delivery-card--step-05"
>
<div class="delivery-form-grid delivery-form-grid--two">
<div class="delivery-field">
<span class="delivery-field__label">性别</span>
<AiChoicePills v-model="currentWorkspace.gender" :options="genderOptions" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">语气</span>
<AiChoicePills v-model="currentWorkspace.voiceTone" :options="voiceToneOptions" />
</div>
</div>
<div class="delivery-field">
<span class="delivery-field__label">语音来源</span>
<AiChoicePills
v-model="currentWorkspace.speechSource"
:options="speechSourceOptions"
/>
</div>
<AiAssetDropzone
title="模特图 / 人脸图"
description="上传清晰正脸图,适合用于数字人口播。"
placeholder="上传模特图"
helper="建议 1080px 以上,单人正面"
accept="image/*"
:file-name="currentWorkspace.avatarAsset.fileName"
:preview-url="currentWorkspace.avatarAsset.previewUrl"
:preview-kind="currentWorkspace.avatarAsset.previewKind"
@select="(file) => setAsset(activeTab, 'avatarAsset', file)"
@clear="clearAsset(activeTab, 'avatarAsset')"
/>
</AiSectionCard>
<AiSectionCard
step="06"
title="话术和音乐"
description="支持直接口播、原话术微调,或者转成纯音乐版本。"
class="delivery-card delivery-card--step-06"
>
<div class="delivery-field">
<span class="delivery-field__label">话术模式</span>
<AiChoicePills v-model="currentWorkspace.scriptMode" :options="scriptModeOptions" />
</div>
<div class="delivery-form-grid delivery-form-grid--two">
<div class="delivery-field">
<span class="delivery-field__label">话术内容</span>
<el-input
v-model="currentWorkspace.scriptText"
type="textarea"
:rows="4"
resize="none"
placeholder="输入要口播的话术,或粘贴需要整体微调的原始话术。"
/>
</div>
<div class="delivery-field">
<span class="delivery-field__label">微调约束</span>
<el-input
v-model="currentWorkspace.scriptRules"
type="textarea"
:rows="4"
resize="none"
placeholder="例如:保留原来的成交语气,不改卖点顺序,强调防滑、透气、现货。"
/>
</div>
</div>
<AiAssetDropzone
title="纯音乐素材"
description="需要做纯音乐版本时可上传,也可以后续从原视频中抽取。"
placeholder="上传背景音乐"
helper="支持 MP3 / WAV"
accept="audio/*"
:file-name="currentWorkspace.musicAsset.fileName"
:preview-url="currentWorkspace.musicAsset.previewUrl"
:preview-kind="currentWorkspace.musicAsset.previewKind"
@select="(file) => setAsset(activeTab, 'musicAsset', file)"
@clear="clearAsset(activeTab, 'musicAsset')"
/>
</AiSectionCard>
<AiSectionCard
step="07"
title="标题标签关键词"
description="类目、平台和卖点一起配置,方便后续拆投放版本。"
class="delivery-card delivery-card--step-07"
>
<div class="delivery-field">
<span class="delivery-field__label">细分类目</span>
<AiChoicePills
v-model="currentWorkspace.categories"
:options="categoryOptions"
multiple
/>
</div>
<div class="delivery-field">
<span class="delivery-field__label">投放平台</span>
<AiChoicePills
v-model="currentWorkspace.platforms"
:options="platformOptions"
multiple
/>
</div>
<div class="delivery-field">
<span class="delivery-field__label">核心卖点</span>
<el-input
v-model="currentWorkspace.productFocus"
type="textarea"
:rows="2"
resize="none"
placeholder="例如:上脚显瘦、脚感轻、百搭、直播间主推款。"
/>
</div>
</AiSectionCard>
<AiSectionCard
step="08"
title="执行说明"
description="补充收尾逻辑,例如保留人物动作,只替换场景和话术重点。"
class="delivery-card delivery-card--step-08"
>
<div class="delivery-field">
<span class="delivery-field__label">收口动作</span>
<el-input
v-model="currentWorkspace.cta"
placeholder="例如:结尾引导领券下单,强调今晚库存。"
/>
</div>
<div class="delivery-field">
<span class="delivery-field__label">执行说明</span>
<el-input
v-model="currentWorkspace.notes"
type="textarea"
:rows="3"
resize="none"
placeholder="补充执行说明,例如:第二版转成纯音乐短视频。"
/>
</div>
</AiSectionCard>
</div>
</section>
</template>
@@ -359,6 +365,7 @@ type AssetState = {
fileName: string
previewUrl: string
previewKind: PreviewKind
sourceUrl: string
}
type AssetKey =
@@ -390,7 +397,6 @@ type WorkspaceState = {
scriptText: string
scriptRules: string
categories: string[]
platforms: string[]
productFocus: string
cta: string
notes: string
@@ -425,7 +431,6 @@ const languageOptions = ['国语', '英语', '粤语', '西班牙语']
const speechSourceOptions = ['文本直出', '从原视频提取语气', '从音频样本提取']
const scriptModeOptions = ['直接口播', '原话术微调', '纯音乐版本']
const categoryOptions = ['鞋子', '男装', '女装', '美妆', '家居', '零食', '数码']
const platformOptions = ['抖音', '快手', '视频号', '小红书']
const tabCopies: Record<WorkspaceTab, TabCopy> = {
remake: {
@@ -465,6 +470,7 @@ function createAssetState(): AssetState {
fileName: '',
previewUrl: '',
previewKind: 'file',
sourceUrl: '',
}
}
@@ -491,7 +497,6 @@ function createWorkspaceState(): WorkspaceState {
scriptText: '',
scriptRules: '',
categories: ['鞋子'],
platforms: ['抖音'],
productFocus: '',
cta: '',
notes: '',
@@ -521,6 +526,23 @@ function resolvePreviewKind(file: File): PreviewKind {
return 'file'
}
function resolvePreviewKindFromUrl(url: string, accept = ''): PreviewKind {
const cleanUrl = url.split('?')[0]?.toLowerCase() || ''
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(cleanUrl)) {
return 'image'
}
if (/\.(mp4|mov|webm|m4v|ogg)$/.test(cleanUrl)) {
return 'video'
}
if (accept.includes('image/*')) {
return 'image'
}
if (accept.includes('video/*')) {
return 'video'
}
return 'file'
}
function releasePreviewUrl(url: string) {
if (!url.startsWith('blob:')) {
return
@@ -536,6 +558,7 @@ function setAsset(tab: WorkspaceTab, assetKey: AssetKey, file: File) {
}
target.fileName = file.name
target.sourceUrl = ''
target.previewKind = resolvePreviewKind(file)
target.previewUrl = target.previewKind === 'file' ? '' : URL.createObjectURL(file)
if (target.previewUrl) {
@@ -543,6 +566,19 @@ function setAsset(tab: WorkspaceTab, assetKey: AssetKey, file: File) {
}
}
function setAssetUrl(tab: WorkspaceTab, assetKey: AssetKey, url: string, accept = '') {
const target = workspaces[tab][assetKey]
if (target.previewUrl) {
releasePreviewUrl(target.previewUrl)
}
const normalizedUrl = url.trim()
target.sourceUrl = normalizedUrl
target.fileName = normalizedUrl ? normalizedUrl : ''
target.previewKind = normalizedUrl ? resolvePreviewKindFromUrl(normalizedUrl, accept) : 'file'
target.previewUrl = target.previewKind === 'file' ? '' : normalizedUrl
}
function clearAsset(tab: WorkspaceTab, assetKey: AssetKey) {
const target = workspaces[tab][assetKey]
if (target.previewUrl) {
@@ -551,6 +587,7 @@ function clearAsset(tab: WorkspaceTab, assetKey: AssetKey) {
target.fileName = ''
target.previewUrl = ''
target.previewKind = 'file'
target.sourceUrl = ''
}
function saveDraft() {

View File

@@ -2217,6 +2217,33 @@ export function getPriceTrackResultDownloadUrl(resultId: number) {
return getJavaDownloadUrl(`/price-track/results/${resultId}/download`);
}
export function getTaskSkipPriceAsinsPaginated(
taskId: number,
page: number = 1,
pageSize: number = 1000,
) {
return unwrapJavaResponse(
get<JavaApiResponse<SkipPriceAsinPageVo>>(
`${JAVA_API_PREFIX}/price-track/tasks/${taskId}/skip-asins/paginated`,
{
params: {
page,
page_size: pageSize,
},
},
),
);
}
export interface SkipPriceAsinPageVo {
page: number;
pageSize: number;
total: number;
totalPages: number;
skipAsinsByCountry: Record<string, string[]>;
skipAsinDetailsByCountry: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
}
export function deletePendingPriceTrackShopResult(shopName: string) {
return unwrapJavaResponse(
del<JavaApiResponse<PriceTrackPendingDeleteVo>>(

View File

@@ -64,6 +64,7 @@ export interface PywebviewApi {
success: boolean;
path?: string;
error?: string;
alreadyRunning?: boolean;
}>;
enqueue_json?: (
data: unknown,

View File

@@ -50,6 +50,16 @@
<div v-if="fileName" class="ai-asset-dropzone__footer">
<span class="ai-asset-dropzone__filename">{{ fileName }}</span>
</div>
<el-input
v-if="allowUrl"
:model-value="sourceUrl"
class="ai-asset-dropzone__url"
clearable
:placeholder="urlPlaceholder"
@change="(value) => $emit('url-change', value)"
@clear="$emit('url-change', '')"
/>
</div>
</template>
@@ -66,6 +76,9 @@ withDefaults(
fileName?: string
previewUrl?: string
previewKind?: 'image' | 'video' | 'file'
allowUrl?: boolean
sourceUrl?: string
urlPlaceholder?: string
}>(),
{
description: '',
@@ -75,12 +88,16 @@ withDefaults(
fileName: '',
previewUrl: '',
previewKind: 'file',
allowUrl: false,
sourceUrl: '',
urlPlaceholder: '也可以粘贴视频在线链接',
},
)
const emit = defineEmits<{
(event: 'select', file: File): void
(event: 'clear'): void
(event: 'url-change', value: string): void
}>()
function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
@@ -189,4 +206,13 @@ function handleChange(uploadFile: UploadFile, _files: UploadFiles) {
font-size: 12px;
word-break: break-all;
}
.ai-asset-dropzone__url {
--el-input-bg-color: #171717;
--el-input-border-color: #383838;
--el-input-hover-border-color: #5a5a5a;
--el-input-focus-border-color: #8c63ff;
--el-input-text-color: #f2f2f2;
--el-input-placeholder-color: #777;
}
</style>

View File

@@ -1 +0,0 @@
16:09:19 [vite] (client) Pre-transform error: Failed to load url /src/main.ts (resolved id: /src/main.ts). Does the file exist?

View File

@@ -1,36 +0,0 @@
> crawler-plugin-frontend-vue@0.0.1 dev
> vite --host --port 5173
VITE v7.3.1 ready in 861 ms
➜ Local: http://localhost:5173/
➜ Network: http://192.168.31.112:5173/
14:18:30 [vite] (client) ✨ new dependencies optimized: element-plus/es, element-plus/es/components/base/style/css, element-plus/es/components/select/style/css, element-plus/es/components/option/style/css, element-plus/es/components/slider/style/css, element-plus/es/components/input/style/css, element-plus/es/components/button/style/css, element-plus/es/components/tabs/style/css, element-plus/es/components/tab-pane/style/css, element-plus/es/components/upload/style/css
14:18:30 [vite] (client) ✨ optimized dependencies changed. reloading
14:47:00 [vite] (client) hmr update /src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css
15:05:11 [vite] (client) hmr update /src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css
15:16:30 [vite] (client) hmr update /src/shared/components/ai-workflow/AiSectionCard.vue?vue&type=style&index=0&scoped=0c43ba21&lang.css
15:16:55 [vite] (client) hmr update /src/shared/components/ai-workflow/AiChoicePills.vue?vue&type=style&index=0&scoped=74573572&lang.css
15:17:34 [vite] (client) hmr update /src/shared/components/ai-workflow/AiAssetDropzone.vue?vue&type=style&index=0&scoped=1625dec6&lang.css
15:22:09 [vite] (client) hmr update /src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css
15:23:01 [vite] (client) hmr update /src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css
15:23:45 [vite] (client) ✨ new dependencies optimized: element-plus/es/components/dialog/style/css
15:23:45 [vite] (client) ✨ optimized dependencies changed. reloading
15:31:28 [vite] (client) hmr update /src/pages/brand/components/BrandTopBar.vue
15:31:55 [vite] (client) hmr update /src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css
15:52:41 [vite] (client) hmr update /src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css
16:02:40 [vite] (client) hmr update /src/pages/brand/components/BrandTopBar.vue, /src/pages/brand/components/BrandTopBar.vue?vue&type=style&index=0&scoped=12cdd860&lang.css
16:03:03 [vite] (client) hmr update /src/pages/image-video/ImageVideoPage.vue, /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css
16:05:24 [vite] (client) hmr update /src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css
16:11:14 [vite] (client) hmr update /src/pages/image-video/components/DeliveryVideoWorkspace.vue, /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css
16:11:14 [vite] (client) hmr update /src/shared/components/ai-workflow/AiSectionCard.vue?vue&type=style&index=0&scoped=0c43ba21&lang.css
16:16:44 [vite] (client) hmr update /src/pages/brand/components/BrandTopBar.vue, /src/pages/brand/components/BrandTopBar.vue?vue&type=style&index=0&scoped=12cdd860&lang.css
16:18:13 [vite] (client) hmr update /src/pages/image-video/components/DeliveryVideoWorkspace.vue
16:19:34 [vite] (client) hmr update /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css
16:19:45 [vite] (client) hmr update /src/pages/image-video/ImageVideoPage.vue?vue&type=style&index=0&scoped=a55049f3&lang.css
16:33:19 [vite] (client) hmr update /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css
16:34:33 [vite] (client) hmr update /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css
16:34:45 [vite] (client) hmr update /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css
16:49:01 [vite] (client) hmr update /src/pages/image-video/components/DeliveryVideoWorkspace.vue?vue&type=style&index=0&scoped=1f627f7d&lang.css