1140 lines
44 KiB
Vue
1140 lines
44 KiB
Vue
<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>
|