Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandPublishTab.vue
T
2026-07-25 17:57:34 +08:00

1140 lines
44 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">
<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>
</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.taskNo || `任务 ${detail.task.id}` }}</strong>
<span class="task-meta">任务 ID{{ detail.task.id }}</span>
<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>
</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>
<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>
<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 } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import {
activatePublishFile,
activatePublishTask,
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 { 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 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 || [])
}
}
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
uploading.value = true
const uploaded = await uploadPathsToJava(paths)
selectedPaths.value = paths
uploadedFiles.value = uploaded
ElMessage.success(`已上传 ${uploaded.length} 个待上架文件`)
} catch (error) {
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
}
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) {
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('后端未返回有效任务标识')
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)
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 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)
}
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 { 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; }
.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>