Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandPublishTab.vue
T
huangzd1997 759f0b15d8
Build Backend JAR / build (push) Has been cancelled
task-101: 上传/解析/入队三道前端拦截,阻止坏数据卡死 Python 自动化
新增 shared/dispatch-guard.ts(纯 TS 校验,可单测)与 dispatch-guard-ui.ts
(ElMessageBox 弹窗层),在数据流的三个位置设闸:

- 选文件:空选择、非 xlsx/xls/csv、空路径直接拦;重复文件弹确认
- 解析结果:taskId 非法、totalRows 为 0、整批行被丢弃、需要分组却无分组直接拦;
  部分行被丢弃弹确认后才允许推送
- 入队前:结构不符、必填字段缺失、items/groups/country_codes 为空数组、
  行数页数为 0、JSON 不安全值(NaN/Infinity/BigInt/循环引用/数组洞)

覆盖 11 个推 Python 队列的 Tab 与 dedupe/split/convert 三个纯 Java Tab。
闸位按 Tab 挑选以免留下孤儿后端状态:collect-data 校验前置到
activateCollectDataTask 之前(任务保持 PENDING 无需回滚)、product-risk 的国家
检查提到建任务之前;已建任务后才拦下的复用各 Tab 原有失败补偿路径。

两处边界:对象属性 undefined 放行(taskNo 这类可选字段是惯用写法),只拦数组
元素 undefined 与数组洞;price-track 的 asin_rows_by_country 仅在 mode=asin
时要求非空,status 模式下 loadAsinRowsForAppClient 本就返回 {}。

顺带修复 similar-asin / appearance-patent 的 selectFiles 缺少 try/catch,
上传失败会残留上一批文件与解析结果。

测试:tests/dispatch-guard.test.ts 新增 37 个 test_task_101_* 用例。
2026-09-01 12:53:12 +08:00

1243 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-shell module-page">
<BrandTopBar active="publish" />
<div class="main-content">
<aside class="left-panel left-panel--with-template">
<div class="section-title">上传模块</div>
<div class="upload-zone">
<div class="hint">
选择需要上架的 Excel 文件或文件夹每个 Excel 文件名会作为店铺名进行匹配
</div>
<div class="btns">
<button type="button" class="opt-btn" :disabled="selectionDisabled" @click="selectFiles">
{{ uploading ? '上传中...' : '选择 Excel 文件' }}
</button>
<button type="button" class="opt-btn" :disabled="selectionDisabled" @click="selectFolder">
选择文件夹
</button>
</div>
<div class="selected-files clean-placeholder publish-selected-files">
<template v-if="selectedPaths.length">
<span v-for="path in displayPaths" :key="path">{{ path }}</span>
<span v-if="selectedPaths.length > displayPaths.length" class="more-line">
还有 {{ selectedPaths.length - displayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择上架文件</span>
</div>
</div>
<div class="section-title">国家选择</div>
<div class="country-group">
<div class="country-group-title">发布国家</div>
<div class="country-checks">
<label v-for="row in COUNTRY_OPTIONS" :key="row.code" class="country-check-row">
<input
v-model="publishCountry"
type="radio"
name="publish-country"
class="country-check-input"
:value="row.code"
/>
<span class="country-check-text">{{ row.label }} ({{ row.code }})</span>
</label>
</div>
</div>
<div class="country-group">
<div class="country-group-title">同步国家</div>
<div class="country-checks">
<label
v-for="row in COUNTRY_OPTIONS"
:key="row.code"
class="country-check-row"
:class="{ disabled: row.code === publishCountry }"
>
<input
type="checkbox"
class="country-check-input"
:checked="syncCountries.includes(row.code)"
:disabled="row.code === publishCountry"
@change="onSyncCountryChange(row.code, $event)"
/>
<span class="country-check-text">{{ row.label }} ({{ row.code }})</span>
</label>
</div>
<p class="country-hint">发布国家会从同步国家中自动排除</p>
</div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row">
<button
type="button"
class="btn-run"
:disabled="parsing || uploading || !uploadedFiles.length"
@click="submitRun"
>
{{ parsing ? '解析中...' : queueWorkerRunning || hasQueueWork ? '加入等待队列' : '开始上架' }}
</button>
<span class="loading-msg">{{ operationHint }}</span>
</div>
<div v-if="queueMessage" class="queue-status">
<div class="section-title queue-status-title">任务状态</div>
<div>{{ queueMessage }}</div>
</div>
<ModuleTemplateDownload module-code="publish" filename="上架 文档格式.xlsx" />
</aside>
<section class="right-panel">
<div class="panel-header">上架结果</div>
<div class="task-list-wrap">
<div class="clean-result-summary">
<div class="summary-card">
<span class="summary-label">任务文件</span>
<strong>{{ summary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已处理文件</span>
<strong>{{ summary.completed }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ summary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ summary.failedCount }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>上架任务列表</span>
<span class="history-count">历史任务 {{ historyItems.length }}</span>
</div>
<div v-if="!visibleTasks.length" class="empty-tasks">
暂无上架任务完成文件选择后点击开始上架创建任务
</div>
<section
v-for="detail in visibleTasks"
v-else
:key="detail.task.id"
class="task-section"
>
<div class="task-section-header">
<div class="task-title-wrap">
<strong>上架任务 #{{ detail.task.id }}</strong>
<span class="task-meta">创建时间{{ formatDateTime(detail.task.createdAt) }}</span>
</div>
<div class="task-actions">
<span class="status" :class="statusClass(detail.task.status)">
{{ statusText(detail.task.status) }}
</span>
<button
v-if="canDownload(detail)"
type="button"
class="download"
@click="downloadResult(detail)"
>
下载结果
</button>
<button
type="button"
class="btn-delete"
:disabled="isDeletingTask(detail.task.id)"
@click="deleteTaskRecord(detail)"
>
{{ isDeletingTask(detail.task.id) ? '删除中...' : '删除' }}
</button>
</div>
</div>
<div v-if="detail.task.errorMessage" class="task-error">
{{ detail.task.errorMessage }}
</div>
<ul class="file-list">
<li v-for="file in detail.files" :key="fileKey(file)" class="file-item">
<div class="file-main">
<div class="file-title" :title="file.sourceFilename || ''">
{{ file.sourceFilename || `文件 ${file.fileId}` }}
</div>
<div class="file-info">
<span>店铺{{ file.shopName || '-' }}</span>
<span>匹配{{ matchText(file) }}</span>
<span v-if="file.platform">平台{{ file.platform }}</span>
<span>数据{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
</div>
<template v-if="shouldShowFileProgress(file)">
<div class="file-progress-header">
<span>{{ file.progressMessage || statusText(file.status) }}</span>
<span>{{ fileProgressPercent(file) }}%</span>
</div>
<div class="file-progress-track">
<div
class="file-progress-fill"
:class="statusClass(file.status)"
:style="{ width: `${fileProgressPercent(file)}%` }"
/>
</div>
</template>
<div v-if="fileErrorText(file)" class="file-error">{{ fileErrorText(file) }}</div>
</div>
<span class="status file-status" :class="statusClass(file.status)">
{{ statusText(file.status) }}
</span>
</li>
</ul>
</section>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue'
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import {
activatePublishFile,
activatePublishTask,
deletePublishTask,
getPublishDashboard,
getPublishHistory,
getPublishItemsPageUrl,
getPublishTaskProgressBatch,
parsePublish,
submitPublishTaskResult,
type PublishDashboardVo,
type PublishFileItem,
type PublishTaskDetailVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import {
EXCEL_EXTENSIONS,
checkParseResult,
checkQueuePayload,
checkSelectedFiles,
} from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { useZiniaoVersion, type ZiniaoVersion } from '@/shared/utils/ziniao-version'
const COUNTRY_OPTIONS = [
{ code: 'DE', label: '德国' },
{ code: 'UK', label: '英国' },
{ code: 'FR', label: '法国' },
{ code: 'IT', label: '意大利' },
{ code: 'ES', label: '西班牙' },
] as const
interface PublishDispatchOptions {
publishCountry: string
syncCountries: string[]
ziniaoVersion: ZiniaoVersion
}
interface PublishQueueBatch {
taskId: number
pendingFileIds: number[]
activeFileId: number | null
files: PublishFileItem[]
options: PublishDispatchOptions
}
interface StoredPublishQueueState {
active: PublishQueueBatch | null
pending: PublishQueueBatch[]
}
const selectedPaths = ref<string[]>([])
const uploadedFiles = ref<UploadedJavaFile[]>([])
const uploading = ref(false)
const parsing = ref(false)
const queueWorkerRunning = ref(false)
const queueMessage = ref('')
const publishCountry = ref<string>('DE')
const syncCountries = ref<string[]>(['UK', 'FR', 'IT', 'ES'])
const ziniaoVersion = useZiniaoVersion()
const currentTaskId = ref<number | null>(null)
const currentFiles = ref<PublishFileItem[]>([])
const pendingFileIds = ref<number[]>([])
const activeFileId = ref<number | null>(null)
const dispatchOptions = ref<PublishDispatchOptions | null>(null)
const queuedBatches = ref<PublishQueueBatch[]>([])
const taskSnapshots = ref<Record<number, PublishTaskDetailVo>>({})
const historyItems = ref<PublishTaskDetailVo[]>([])
const missingTaskIds = ref<number[]>([])
const deletingTaskIds = ref<number[]>([])
const dashboard = ref<PublishDashboardVo>({
pendingCount: 0,
runningCount: 0,
successCount: 0,
failedCount: 0,
})
const timers = createCategorizedTimers('publish-tab')
let disposed = false
function currentUserId() {
const value = Number(typeof window === 'undefined' ? 0 : window.localStorage.getItem('uid'))
return Number.isFinite(value) && value > 0 ? value : 0
}
function queueStorageKey() {
return `publish:queue-state:${currentUserId()}`
}
function pollingStorageKey() {
return `publish:polling-task-ids:${currentUserId()}`
}
function normalizeStatus(status?: string | null) {
return String(status || '').trim().toUpperCase()
}
function isTerminalStatus(status?: string | null) {
return ['SUCCESS', 'FAILED', 'COMPLETED', 'CANCELLED'].includes(normalizeStatus(status))
}
function fileKey(file: PublishFileItem) {
if (file.fileId > 0) return `id:${file.fileId}`
if (file.fileKey) return `key:${file.fileKey}`
return `name:${file.sourceFilename || ''}`
}
function mergeFiles(base: PublishFileItem[], incoming: PublishFileItem[]) {
const merged = new Map<string, PublishFileItem>()
for (const file of base || []) merged.set(fileKey(file), file)
for (const file of incoming || []) {
const key = fileKey(file)
merged.set(key, { ...(merged.get(key) || {}), ...file })
}
return Array.from(merged.values())
}
function mergeDetail(previous: PublishTaskDetailVo | undefined, incoming: PublishTaskDetailVo) {
if (!previous) return incoming
return {
task: { ...previous.task, ...incoming.task },
files: mergeFiles(previous.files || [], incoming.files || []),
result: incoming.result
? { ...(previous.result || {}), ...incoming.result }
: previous.result,
}
}
function applyTaskSnapshot(taskId: number, detail: PublishTaskDetailVo) {
const merged = mergeDetail(taskSnapshots.value[taskId], detail)
taskSnapshots.value = { ...taskSnapshots.value, [taskId]: merged }
missingTaskIds.value = missingTaskIds.value.filter((id) => id !== taskId)
if (currentTaskId.value === taskId) {
currentFiles.value = mergeFiles(currentFiles.value, merged.files || [])
}
}
// The backend advances processedRows when Python submits result chunks; heartbeat is liveness only.
const progressLoop = useTaskProgressLoop<PublishTaskDetailVo>({
scope: 'publish-tab',
storageKey: pollingStorageKey(),
fetchProgress: async (taskIds) => {
const batch = await getPublishTaskProgressBatch(taskIds)
const requested = new Set(taskIds)
missingTaskIds.value = [
...missingTaskIds.value.filter((id) => !requested.has(id)),
...(batch.missingTaskIds || []),
]
for (const missingTaskId of batch.missingTaskIds || []) {
progressLoop.remove(missingTaskId)
}
return batch
},
extractTaskId: (detail) => detail.task?.id,
extractStatus: (detail) => detail.task?.status,
isTerminal: isTerminalStatus,
onUpdate: applyTaskSnapshot,
onTerminal: async (taskId) => {
if (currentTaskId.value === taskId && isTerminalStatus(taskSnapshots.value[taskId]?.task?.status)) {
pendingFileIds.value = []
activeFileId.value = null
saveQueueState()
}
await Promise.all([loadDashboard(), loadHistory()])
},
onError: () => {
if (hasQueueWork.value) queueMessage.value = '任务仍在执行,正在等待后端状态恢复...'
},
})
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
const activeBatchHasWork = computed(() => pendingFileIds.value.length > 0 || activeFileId.value != null)
const hasQueueWork = computed(() => activeBatchHasWork.value || queuedBatches.value.length > 0)
const selectionDisabled = computed(() => uploading.value || parsing.value)
const currentDetail = computed<PublishTaskDetailVo | null>(() => {
const taskId = currentTaskId.value
if (!taskId) return null
const snapshot = taskSnapshots.value[taskId]
if (snapshot) return snapshot
return {
task: { id: taskId, status: hasQueueWork.value ? 'RUNNING' : 'PENDING' },
files: currentFiles.value,
}
})
const visibleTasks = computed(() => {
const tasks = new Map<number, PublishTaskDetailVo>()
for (const detail of historyItems.value) {
if (detail.task?.id) tasks.set(detail.task.id, detail)
}
if (currentDetail.value) {
const taskId = currentDetail.value.task.id
tasks.set(taskId, mergeDetail(tasks.get(taskId), currentDetail.value))
}
return Array.from(tasks.values()).sort((left, right) => right.task.id - left.task.id)
})
const summary = computed(() => {
const files = visibleTasks.value.flatMap((detail) => detail.files || [])
const successCount = files.filter((file) => ['SUCCESS', 'COMPLETED'].includes(normalizeStatus(file.status))).length
const failedCount = files.filter((file) => ['FAILED', 'CANCELLED'].includes(normalizeStatus(file.status))).length
return {
total: files.length,
completed: successCount + failedCount,
successCount,
failedCount,
}
})
const operationHint = computed(() => {
if (uploading.value) return '正在逐个上传文件到后端...'
if (parsing.value) return '正在解析 Excel、匹配店铺并创建批次...'
if (queueWorkerRunning.value) return '当前文件完成后会自动派发下一个文件。'
return '创建批次后会按文件严格串行处理。'
})
watch(publishCountry, (code, previousCode) => {
const selected = new Set(syncCountries.value)
if (previousCode) selected.add(previousCode)
selected.delete(code)
syncCountries.value = COUNTRY_OPTIONS.map((item) => item.code).filter((item) => selected.has(item))
})
function onSyncCountryChange(code: string, event: Event) {
const element = event.target as HTMLInputElement | null
if (!element) return
const selected = new Set(syncCountries.value)
if (element.checked) selected.add(code)
else selected.delete(code)
selected.delete(publishCountry.value)
syncCountries.value = COUNTRY_OPTIONS.map((item) => item.code).filter((item) => selected.has(item))
}
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) throw new Error('当前桌面端未提供文件上传能力')
const uploaded: UploadedJavaFile[] = []
for (const item of paths) {
const filePath = typeof item === 'string' ? item : item.absolutePath
const relativePath = typeof item === 'string' ? undefined : item.relativePath
const result = await api.upload_file_to_java(filePath, relativePath)
if (!result?.success || !result.data) {
throw new Error(result?.error || result?.message || `上传失败:${filePath}`)
}
uploaded.push(result.data)
}
return uploaded
}
async function selectFiles() {
const api = getPywebviewApi()
if (!api?.select_brand_xlsx_files || !api.upload_file_to_java) {
ElMessage.warning('当前环境不支持文件选择或上传,请在本机客户端中打开')
return
}
try {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
uploading.value = true
const uploaded = await uploadPathsToJava(paths)
selectedPaths.value = paths
uploadedFiles.value = uploaded
ElMessage.success(`已上传 ${uploaded.length} 个待上架文件`)
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
selectedPaths.value = []
uploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
} finally {
uploading.value = false
}
}
async function selectFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder || !api.upload_file_to_java) {
ElMessage.warning('当前环境不支持文件夹选择或上传,请在本机客户端中打开')
return
}
try {
const folder = await api.select_brand_folder()
if (!folder) return
const result = await expandBrandFolderRecursive(folder)
if (!result.success || !result.items?.length) {
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
return
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
uploading.value = true
const uploaded = await uploadPathsToJava(result.items)
selectedPaths.value = result.items.map((item) => item.relativePath || item.absolutePath)
uploadedFiles.value = uploaded
ElMessage.success(`已上传文件夹内 ${uploaded.length} 个 xlsx 文件`)
} catch (error) {
selectedPaths.value = []
uploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
} finally {
uploading.value = false
}
}
function isMatchedFile(file: PublishFileItem) {
const matchStatus = normalizeStatus(file.matchStatus)
return Boolean(
file.matched
|| matchStatus === 'MATCHED'
|| (matchStatus === 'INDEX_STALE' && file.shopId),
)
}
async function submitRun() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择并上传待上架 Excel 文件或文件夹')
return
}
if (!publishCountry.value) {
ElMessage.warning('请选择发布国家')
return
}
parsing.value = true
try {
const parsed = await parsePublish({
files: uploadedFiles.value.map((file) => ({
fileKey: file.fileKey,
originalFilename: file.originalFilename,
relativePath: file.relativePath,
})),
publish_country: publishCountry.value,
sync_countries: syncCountries.value.filter((country) => country !== publishCountry.value),
})
if (!parsed.taskId) throw new Error('后端未返回有效任务标识')
// publish 的 Vo 用 files 承载明细,没有 acceptedRows;这里先卡住 taskId 与总行数
const guard = checkParseResult(
{ taskId: parsed.taskId, totalRows: parsed.totalRows, acceptedRows: parsed.totalRows },
{ requiredColumnsHint: '店铺名 / 商品行' },
)
if (!(await passGuard(guard))) return
const options: PublishDispatchOptions = {
publishCountry: publishCountry.value,
syncCountries: syncCountries.value.filter((country) => country !== publishCountry.value),
ziniaoVersion: ziniaoVersion.value,
}
const files = parsed.files || []
applyTaskSnapshot(parsed.taskId, {
task: {
id: parsed.taskId,
taskNo: parsed.taskNo,
status: 'PENDING',
sourceFileCount: parsed.sourceFileCount,
totalRows: parsed.totalRows,
},
files,
result: parsed.result,
})
const batch: PublishQueueBatch = {
taskId: parsed.taskId,
pendingFileIds: files
.filter((file) => !isTerminalStatus(file.status) && isMatchedFile(file))
.map((file) => file.fileId)
.filter((fileId) => Number.isFinite(fileId) && fileId > 0),
activeFileId: null,
files,
options,
}
progressLoop.add(parsed.taskId)
selectedPaths.value = []
uploadedFiles.value = []
await Promise.all([loadDashboard(), loadHistory()])
if (!batch.pendingFileIds.length) {
queueMessage.value = '解析完成,当前没有匹配成功且可执行的文件。'
ElMessage.warning(queueMessage.value)
return
}
const shouldWait = queueWorkerRunning.value || activeBatchHasWork.value || queuedBatches.value.length > 0
if (shouldWait) {
queuedBatches.value.push(batch)
saveQueueState()
queueMessage.value = `批次 ${parsed.taskNo || parsed.taskId} 已加入等待队列,前面还有 ${queuedBatches.value.length} 个批次`
ElMessage.success(queueMessage.value)
if (!queueWorkerRunning.value) void processQueue()
return
}
setActiveBatch(batch)
saveQueueState()
queueMessage.value = `批次 ${parsed.taskNo || parsed.taskId} 已创建,开始串行处理 ${batch.pendingFileIds.length} 个文件。`
ElMessage.success(queueMessage.value)
void processQueue()
} catch (error) {
const message = error instanceof Error ? error.message : '上架任务创建失败'
queueMessage.value = message
ElMessage.error(message)
} finally {
parsing.value = false
}
}
function saveQueueState() {
if (typeof window === 'undefined') return
const active: PublishQueueBatch | null = currentTaskId.value && dispatchOptions.value && activeBatchHasWork.value
? {
taskId: currentTaskId.value,
pendingFileIds: pendingFileIds.value,
activeFileId: activeFileId.value,
files: currentFiles.value,
options: dispatchOptions.value,
}
: null
const pending = queuedBatches.value.filter(
(batch) => batch.pendingFileIds.length > 0 || batch.activeFileId != null,
)
if (!active && !pending.length) {
window.localStorage.removeItem(queueStorageKey())
return
}
const state: StoredPublishQueueState = { active, pending }
window.localStorage.setItem(queueStorageKey(), JSON.stringify(state))
}
function setActiveBatch(batch: PublishQueueBatch, restoreForm = false) {
currentTaskId.value = batch.taskId
currentFiles.value = batch.files
pendingFileIds.value = [...batch.pendingFileIds]
activeFileId.value = batch.activeFileId
dispatchOptions.value = batch.options
if (restoreForm) {
publishCountry.value = batch.options.publishCountry
syncCountries.value = [...batch.options.syncCountries]
ziniaoVersion.value = batch.options.ziniaoVersion
}
}
function takeNextBatch() {
while (queuedBatches.value.length) {
const next = queuedBatches.value.shift()
if (!next) break
if (
missingTaskIds.value.includes(next.taskId)
|| isTerminalStatus(taskSnapshots.value[next.taskId]?.task?.status)
) {
continue
}
setActiveBatch(next)
saveQueueState()
return true
}
saveQueueState()
return false
}
function loadQueueState() {
if (typeof window === 'undefined') return
try {
const raw = window.localStorage.getItem(queueStorageKey())
if (!raw) return
const state = JSON.parse(raw) as Partial<StoredPublishQueueState> & Partial<PublishQueueBatch>
const active = state.active && Number.isFinite(state.active.taskId) && state.active.taskId > 0
? state.active
: !state.active && Number.isFinite(state.taskId) && Number(state.taskId) > 0 && state.options
? {
taskId: Number(state.taskId),
pendingFileIds: state.pendingFileIds || [],
activeFileId: state.activeFileId ?? null,
files: state.files || [],
options: state.options,
}
: null
if (active) setActiveBatch(active, true)
queuedBatches.value = (Array.isArray(state.pending) ? state.pending : [])
.filter((batch) => Number.isFinite(batch.taskId) && batch.taskId > 0 && batch.options)
.map((batch) => ({
...batch,
pendingFileIds: (batch.pendingFileIds || []).filter((id) => Number.isFinite(id) && id > 0),
activeFileId: Number.isFinite(batch.activeFileId) && Number(batch.activeFileId) > 0
? Number(batch.activeFileId)
: null,
files: Array.isArray(batch.files) ? batch.files : [],
}))
} catch {
window.localStorage.removeItem(queueStorageKey())
}
}
function getCurrentFile(fileId: number) {
const taskId = currentTaskId.value
const snapshotFiles = taskId ? taskSnapshots.value[taskId]?.files || [] : []
return snapshotFiles.find((file) => file.fileId === fileId)
|| currentFiles.value.find((file) => file.fileId === fileId)
}
function updateCurrentFile(fileId: number, patch: Partial<PublishFileItem>) {
const update = (files: PublishFileItem[]) => files.map((file) => (
file.fileId === fileId ? { ...file, ...patch } : file
))
currentFiles.value = update(currentFiles.value)
const taskId = currentTaskId.value
if (taskId && taskSnapshots.value[taskId]) {
taskSnapshots.value = {
...taskSnapshots.value,
[taskId]: {
...taskSnapshots.value[taskId],
files: update(taskSnapshots.value[taskId].files || []),
},
}
}
}
function buildQueuePayload(taskId: number, file: PublishFileItem) {
const options = dispatchOptions.value
if (!options) throw new Error('缺少当前批次的派发配置')
const pageSize = Math.max(1, Number(file.pageSize || 100))
const totalRows = Math.max(0, Number(file.totalRows || 0))
const totalPages = Math.max(0, Number(file.totalPages || (totalRows ? Math.ceil(totalRows / pageSize) : 0)))
const paginationUrl = file.pageUrl || getPublishItemsPageUrl(taskId, file.fileId, pageSize, 1)
return {
type: 'publish-run',
ts: Date.now(),
data: {
taskId,
fileId: file.fileId,
fileKey: file.fileKey || '',
sourceFilename: file.sourceFilename || '',
shopName: file.shopName || '',
shopId: file.shopId || '',
matchedUserId: file.matchedUserId,
platform: file.platform || '',
companyName: file.companyName || '',
matched: isMatchedFile(file),
matchStatus: file.matchStatus || '',
matchMessage: file.matchMessage || '',
ziniao_version: options.ziniaoVersion,
publish_country: options.publishCountry,
sync_countries: options.syncCountries,
paginationUrl,
pagination_url: paginationUrl,
items_url: paginationUrl,
pageSize,
totalPages,
totalRows,
page_size: pageSize,
total_pages: totalPages,
total_rows: totalRows,
source: 'frontend-vue-publish',
},
}
}
async function submitPublishResultWithRetry(
taskId: number,
file: PublishFileItem,
reason: string,
) {
let lastError: unknown
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await submitPublishTaskResult(taskId, {
files: [{
fileId: file.fileId,
fileKey: file.fileKey,
sourceFilename: file.sourceFilename,
error: reason,
rows: [],
}],
})
return
} catch (error) {
lastError = error
if (attempt < 3) await timers.sleep('result-compensation', attempt * 500)
}
}
throw lastError instanceof Error ? lastError : new Error('失败状态提交失败')
}
async function submitDispatchFailure(taskId: number, file: PublishFileItem, reason: string) {
await submitPublishResultWithRetry(taskId, file, reason)
updateCurrentFile(file.fileId, {
status: 'FAILED',
error: reason,
errorMessage: reason,
progressPercent: 100,
})
}
async function waitForFileTerminal(taskId: number, fileId: number) {
while (!disposed) {
if (missingTaskIds.value.includes(taskId)) return 'FAILED'
if (!progressLoop.inFlight.value) await progressLoop.refreshOnce()
const file = getCurrentFile(fileId)
const fileStatus = normalizeStatus(file?.status)
if (isTerminalStatus(fileStatus)) return fileStatus
const taskStatus = normalizeStatus(taskSnapshots.value[taskId]?.task?.status)
if (isTerminalStatus(taskStatus)) return taskStatus
await timers.sleep('queue-wait', getTaskPollIntervalMs())
}
return 'STOPPED'
}
async function processQueue() {
if (disposed || queueWorkerRunning.value || (!activeBatchHasWork.value && !queuedBatches.value.length)) return
const api = getPywebviewApi()
if (!api?.enqueue_json) {
queueMessage.value = '当前客户端未提供 enqueue_json,无法派发上架任务。'
ElMessage.error(queueMessage.value)
return
}
queueWorkerRunning.value = true
try {
while (!disposed) {
if (!currentTaskId.value || !dispatchOptions.value || !activeBatchHasWork.value) {
if (!takeNextBatch()) break
}
const taskId = currentTaskId.value
if (!taskId || !dispatchOptions.value) continue
progressLoop.add(taskId)
await activatePublishTask(taskId)
while (!disposed && (activeFileId.value != null || pendingFileIds.value.length > 0)) {
if (
missingTaskIds.value.includes(taskId)
|| isTerminalStatus(taskSnapshots.value[taskId]?.task?.status)
) {
pendingFileIds.value = []
activeFileId.value = null
saveQueueState()
break
}
if (activeFileId.value != null) {
const restoredFileId = activeFileId.value
queueMessage.value = `正在等待文件 ${getCurrentFile(restoredFileId)?.sourceFilename || restoredFileId} 完成...`
const restoredStatus = await waitForFileTerminal(taskId, restoredFileId)
if (restoredStatus === 'STOPPED') return
activeFileId.value = null
saveQueueState()
continue
}
const nextFileId = pendingFileIds.value.shift()
if (!nextFileId) break
const file = getCurrentFile(nextFileId)
if (!file || isTerminalStatus(file.status)) {
saveQueueState()
continue
}
activeFileId.value = nextFileId
saveQueueState()
try {
await activatePublishFile(taskId, nextFileId)
updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已派发到 Python 队列' })
const payload = buildQueuePayload(taskId, file)
// totalRows/totalPages 为 0 时 Python 端翻不到任何明细,会一直停在执行中
const guard = checkQueuePayload(payload, {
expectedType: 'publish-run',
requiredDataKeys: ['taskId', 'fileId', 'shopName', 'publish_country', 'paginationUrl'],
positiveNumberKeys: ['totalRows', 'totalPages'],
})
if (!(await passGuard(guard))) {
throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止推送`)
}
const result = await api.enqueue_json(payload)
if (!result?.success) throw new Error(result?.error || 'Python 队列拒绝接收任务')
queueMessage.value = pendingFileIds.value.length
? `文件 ${file.sourceFilename || nextFileId} 已入队,完成后继续剩余 ${pendingFileIds.value.length} 个文件。`
: `文件 ${file.sourceFilename || nextFileId} 已入队,等待执行完成。`
const finalStatus = await waitForFileTerminal(taskId, nextFileId)
if (finalStatus === 'STOPPED') return
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} ${['SUCCESS', 'COMPLETED'].includes(finalStatus) ? '已完成' : '执行失败'},继续下一个文件。`
activeFileId.value = null
saveQueueState()
} catch (error) {
const reason = error instanceof Error ? error.message : '文件派发失败'
try {
await submitDispatchFailure(taskId, file, reason)
} catch (compensationError) {
const message = compensationError instanceof Error ? compensationError.message : '失败状态提交失败'
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 派发失败,后端未确认失败状态:${message}。串行队列已暂停。`
saveQueueState()
throw new Error(queueMessage.value)
}
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 派发失败,已记录并继续下一个文件。`
activeFileId.value = null
saveQueueState()
}
}
if (disposed) return
await progressLoop.refreshOnce()
await Promise.all([loadDashboard(), loadHistory()])
if (queuedBatches.value.length) {
queueMessage.value = `当前批次已完成派发,继续处理后续 ${queuedBatches.value.length} 个等待批次。`
continue
}
queueMessage.value = '所有上架批次已按顺序派发完成,正在生成结果文件。'
ElMessage.success('上架等待队列已完成派发')
break
}
} catch (error) {
if (disposed) return
const message = error instanceof Error ? error.message : '串行队列执行失败'
queueMessage.value = message
ElMessage.error(message)
} finally {
queueWorkerRunning.value = false
saveQueueState()
}
}
async function loadDashboard() {
try {
dashboard.value = await getPublishDashboard()
} catch {
// Dashboard failure must not interrupt the active queue.
}
}
async function loadHistory() {
try {
const history = await getPublishHistory()
historyItems.value = history.items || []
for (const detail of historyItems.value) {
const taskId = detail.task?.id
if (!taskId) continue
applyTaskSnapshot(taskId, detail)
if (!isTerminalStatus(detail.task.status)) progressLoop.add(taskId)
}
} catch {
// Keep current snapshots when history is temporarily unavailable.
}
}
function reconcileStoredQueue() {
function reconcileBatch(batch: PublishQueueBatch) {
if (missingTaskIds.value.includes(batch.taskId)) return null
const snapshot = taskSnapshots.value[batch.taskId]
if (isTerminalStatus(snapshot?.task?.status)) return null
const files = mergeFiles(batch.files || [], snapshot?.files || [])
let pendingIds = (batch.pendingFileIds || []).filter((fileId) => {
const file = files.find((item) => item.fileId === fileId)
return file && !isTerminalStatus(file.status)
})
let activeId = batch.activeFileId
if (activeId != null) {
const activeStatus = normalizeStatus(files.find((file) => file.fileId === activeId)?.status)
if (!activeStatus || activeStatus === 'PENDING') {
pendingIds = [activeId, ...pendingIds.filter((fileId) => fileId !== activeId)]
activeId = null
} else if (isTerminalStatus(activeStatus)) {
activeId = null
}
}
if (!pendingIds.length && activeId == null) return null
return { ...batch, pendingFileIds: pendingIds, activeFileId: activeId, files }
}
const taskId = currentTaskId.value
const active = taskId && dispatchOptions.value
? reconcileBatch({
taskId,
pendingFileIds: pendingFileIds.value,
activeFileId: activeFileId.value,
files: currentFiles.value,
options: dispatchOptions.value,
})
: null
if (active) {
setActiveBatch(active)
} else {
currentTaskId.value = null
currentFiles.value = []
pendingFileIds.value = []
activeFileId.value = null
dispatchOptions.value = null
}
queuedBatches.value = queuedBatches.value
.map(reconcileBatch)
.filter((batch): batch is PublishQueueBatch => batch != null)
if (active?.activeFileId != null) {
const activeId = active.activeFileId
const activeStatus = normalizeStatus(active.files.find((file) => file.fileId === activeId)?.status)
if (!activeStatus || activeStatus === 'PENDING') {
queueMessage.value = `文件 ${activeId} 尚未激活,已恢复到待派发队列。`
}
}
saveQueueState()
}
function statusText(status?: string | null) {
const value = normalizeStatus(status)
if (value === 'SUCCESS' || value === 'COMPLETED') return '已完成'
if (value === 'FAILED') return '失败'
if (value === 'CANCELLED') return '已取消'
if (value === 'RUNNING') return '执行中'
return '等待中'
}
function statusClass(status?: string | null) {
const value = normalizeStatus(status)
if (value === 'SUCCESS' || value === 'COMPLETED') return 'success'
if (value === 'FAILED' || value === 'CANCELLED') return 'failed'
if (value === 'RUNNING') return 'running'
return 'pending'
}
function matchText(file: PublishFileItem) {
if (isMatchedFile(file)) return file.shopId ? `已匹配 ${file.shopId}` : '已匹配'
return file.matchMessage || file.matchStatus || '未匹配'
}
function fileErrorText(file: PublishFileItem) {
return file.errorMessage || file.error || ''
}
function shouldShowFileProgress(file: PublishFileItem) {
return ['RUNNING', 'SUCCESS', 'COMPLETED', 'FAILED', 'CANCELLED'].includes(normalizeStatus(file.status))
}
function fileProgressCurrent(file: PublishFileItem) {
return Math.max(0, Number(file.progressCurrent ?? file.processedRows ?? 0))
}
function fileProgressTotal(file: PublishFileItem) {
return Math.max(0, Number(file.progressTotal ?? file.totalRows ?? 0))
}
function fileProgressPercent(file: PublishFileItem) {
if (isTerminalStatus(file.status)) return 100
const explicit = Number(file.progressPercent ?? file.percent)
if (Number.isFinite(explicit) && explicit > 0) return Math.max(0, Math.min(100, Math.round(explicit)))
const current = fileProgressCurrent(file)
const total = fileProgressTotal(file)
return total > 0 ? Math.max(0, Math.min(100, Math.round((current / total) * 100))) : 0
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
function resultInfo(detail: PublishTaskDetailVo) {
return {
url: detail.result?.downloadUrl || detail.task.downloadUrl || '',
filename: detail.result?.resultFilename || detail.task.resultFilename || '',
}
}
function canDownload(detail: PublishTaskDetailVo) {
return Boolean(resultInfo(detail).url)
}
async function downloadResult(detail: PublishTaskDetailVo) {
const result = resultInfo(detail)
if (!result.url) {
ElMessage.warning('当前任务尚未生成下载地址')
return
}
const fallbackExtension = (detail.task.sourceFileCount || detail.files.length) > 1 ? 'zip' : 'xlsx'
const filename = result.filename || `publish_${detail.task.id}.${fallbackExtension}`
const saved = await saveUrlWithProgress(result.url, filename, `publish:${detail.task.id}`)
if (saved.success) ElMessage.success(`已保存:${saved.path || filename}`)
else if (saved.error && saved.error !== '用户取消') ElMessage.error(saved.error)
}
function isDeletingTask(taskId: number) {
return deletingTaskIds.value.includes(taskId)
}
function removeTaskLocally(taskId: number) {
progressLoop.remove(taskId)
historyItems.value = historyItems.value.filter((detail) => detail.task?.id !== taskId)
const snapshots = { ...taskSnapshots.value }
delete snapshots[taskId]
taskSnapshots.value = snapshots
missingTaskIds.value = Array.from(new Set([...missingTaskIds.value, taskId]))
queuedBatches.value = queuedBatches.value.filter((batch) => batch.taskId !== taskId)
if (currentTaskId.value === taskId) {
currentTaskId.value = null
currentFiles.value = []
pendingFileIds.value = []
activeFileId.value = null
dispatchOptions.value = null
}
saveQueueState()
}
async function deleteTaskRecord(detail: PublishTaskDetailVo) {
const taskId = detail.task?.id
if (!taskId || isDeletingTask(taskId)) return
try {
await ElMessageBox.confirm(
`确定删除上架任务 #${taskId}?任务明细和结果文件也会一并删除。`,
'删除任务',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
},
)
} catch {
return
}
deletingTaskIds.value = [...deletingTaskIds.value, taskId]
try {
await deletePublishTask(taskId)
removeTaskLocally(taskId)
ElMessage.success('任务已删除')
await Promise.all([loadDashboard(), loadHistory()])
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除任务失败')
} finally {
deletingTaskIds.value = deletingTaskIds.value.filter((id) => id !== taskId)
}
}
onMounted(async () => {
loadQueueState()
if (currentTaskId.value) progressLoop.add(currentTaskId.value)
for (const batch of queuedBatches.value) progressLoop.add(batch.taskId)
await Promise.all([loadDashboard(), loadHistory()])
if (currentTaskId.value || queuedBatches.value.length) await progressLoop.refreshOnce()
reconcileStoredQueue()
if (hasQueueWork.value) {
queueMessage.value = activeFileId.value != null
? `检测到未完成文件 ${activeFileId.value},继续等待完成后衔接后续文件。`
: `检测到未完成队列,继续处理当前文件及后续 ${queuedBatches.value.length} 个批次。`
void processQueue()
}
})
onBeforeUnmount(() => {
disposed = true
timers.clearScope()
})
</script>
<style scoped>
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; min-height: calc(100vh - 88px); height: calc(100vh - 88px); }
.left-panel { width: 400px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #1a1a1a; }
.section-title { margin-bottom: 10px; color: #bbb; font-size: 13px; }
.upload-zone { margin-bottom: 20px; padding: 20px; border: 1px dashed #3a3a3a; border-radius: 8px; background: #252525; text-align: center; }
.hint, .loading-msg, .task-meta, .file-info, .history-count { color: #888; font-size: 12px; line-height: 1.5; }
.btns, .run-row, .task-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.btns { justify-content: center; }
.opt-btn, .btn-run, .download, .btn-delete { border: 0; border-radius: 6px; cursor: pointer; transition: background-color .15s ease, color .15s ease, opacity .15s ease; }
.opt-btn { padding: 8px 16px; border: 1px solid #3a3a3a; background: #2a2a2a; color: #ccc; font-size: 13px; }
.opt-btn:hover:not(:disabled) { border-color: #3498db; color: #67b7ef; }
.opt-btn:disabled, .btn-run:disabled { opacity: .55; cursor: not-allowed; }
.selected-files { max-height: 120px; min-height: 72px; margin-top: 14px; overflow-y: auto; color: #888; font-size: 12px; text-align: left; }
.selected-files span { display: block; margin: 4px 0; word-break: break-all; }
.more-line { color: #b8c1cc; }
.country-group { margin-bottom: 12px; padding: 14px; border: 1px solid #2f2f2f; border-radius: 8px; background: #252525; }
.country-group-title { margin-bottom: 10px; color: #a9b4bf; font-size: 12px; }
.country-checks { display: flex; flex-wrap: wrap; gap: 10px 16px; }
.country-check-row { display: inline-flex; align-items: center; gap: 8px; color: #ccc; cursor: pointer; font-size: 12px; user-select: none; }
.country-check-row.disabled { opacity: .45; cursor: not-allowed; }
.country-check-input { width: 16px; height: 16px; flex-shrink: 0; accent-color: #409eff; cursor: pointer; }
.country-check-input:disabled { cursor: not-allowed; }
.country-hint { margin: 10px 0 0; color: #777; font-size: 11px; }
.run-row { margin-top: 16px; }
.btn-run { min-height: 38px; padding: 9px 20px; background: #3498db; color: #fff; font-size: 14px; }
.btn-run:hover:not(:disabled) { background: #2980b9; }
.queue-status { margin-top: 16px; padding: 12px; border-left: 3px solid #409eff; background: #252525; color: #b8d8ef; font-size: 12px; line-height: 1.6; }
.queue-status-title { margin-bottom: 4px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; color: #ddd; font-size: 15px; font-weight: 600; }
.task-list-wrap { flex: 1; padding: 16px; overflow-y: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 12px; margin-bottom: 18px; }
.summary-card { padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 8px; background: #1e1e1e; }
.summary-card strong { display: block; margin-top: 8px; color: #eaf4ff; font-size: 22px; }
.summary-label { color: #8d8d8d; font-size: 12px; }
.result-list-wrap { min-height: 260px; border: 1px solid #2a2a2a; border-radius: 8px; background: #1e1e1e; }
.result-list-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #2a2a2a; color: #ddd; font-size: 14px; }
.empty-tasks { padding: 24px 16px; color: #666; font-size: 13px; }
.task-section + .task-section { border-top: 1px solid #303030; }
.task-section-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; background: #222; }
.task-title-wrap { display: flex; min-width: 0; flex-direction: column; gap: 4px; color: #ddd; }
.task-error, .file-error { color: #ef8d8d; font-size: 12px; }
.task-error { padding: 10px 16px 0; }
.download { padding: 7px 12px; background: #2d6b46; color: #dff7e8; font-size: 12px; white-space: nowrap; }
.download:hover { background: #367d54; }
.btn-delete { padding: 7px 12px; background: #472929; color: #efaaaa; font-size: 12px; white-space: nowrap; }
.btn-delete:hover:not(:disabled) { background: #5a3030; color: #ffd0d0; }
.btn-delete:disabled { cursor: wait; opacity: .55; }
.status { display: inline-flex; align-items: center; justify-content: center; min-width: 58px; min-height: 26px; padding: 0 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
.status.pending { background: #3a3424; color: #e4c56a; }
.status.running { background: #24394a; color: #75bff1; }
.status.success { background: #233c2d; color: #72d598; }
.status.failed { background: #472929; color: #ef8d8d; }
.file-list { margin: 0; padding: 0; list-style: none; }
.file-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; border-top: 1px solid #292929; }
.file-main { flex: 1; min-width: 0; }
.file-title { overflow: hidden; color: #e1e1e1; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.file-info { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 6px; }
.file-progress-header { display: flex; justify-content: space-between; gap: 12px; margin-top: 10px; color: #969696; font-size: 11px; }
.file-progress-track { height: 6px; margin-top: 5px; overflow: hidden; border-radius: 3px; background: #303030; }
.file-progress-fill { height: 100%; border-radius: inherit; background: #8d7b3f; transition: width .25s ease; }
.file-progress-fill.running { background: #409eff; }
.file-progress-fill.success { background: #42b36b; }
.file-progress-fill.failed { background: #d06161; }
.file-error { margin-top: 7px; }
.file-status { flex-shrink: 0; }
@media (max-width: 1100px) {
.main-content { height: auto; flex-direction: column; }
.left-panel { width: 100%; border-right: 0; border-bottom: 1px solid #2a2a2a; }
.right-panel { min-height: 420px; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.clean-result-summary { grid-template-columns: 1fr; }
.task-section-header, .file-item { flex-direction: column; }
.task-actions { width: 100%; }
}
</style>