Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue

1909 lines
58 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="delete-brand" />
<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" @click="selectFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
</div>
<div class="selected-files clean-placeholder split-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="option-group">
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">文件名作为店铺名</span>
<div class="desc">例如 鲍丽明.xlsx 会解析为店铺名 鲍丽明并自动联动紫鸟进行店铺匹配</div>
</div>
</label>
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">文件夹仅用于批量导入</span>
<div class="desc">文件夹名和子目录名不会参与店铺匹配只是方便一次上传多个 Excel</div>
</div>
</label>
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">按国家分组去重</span>
<div class="desc">解析后按国家返回并在每个国家内按 ASIN 去重保留首次出现的状态</div>
</div>
</label>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="running" @click="submitRun">
{{ running ? '解析中...' : '开始解析' }}
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !queueRunnableItems.length"
@click="pushToPythonQueue">
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
</button>
<span class="loading-msg">
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
</span>
</div>
<div v-if="queuePushResult || queuePayloadText" class="queue-debug-card">
<div class="section-title queue-debug-title">Python 队列推送结果</div>
<div v-if="queuePushResult" class="queue-debug-line">
{{ queuePushResult }}
</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
</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.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>
</div>
<div v-if="!hasVisibleItems" class="empty-tasks">
暂无删除品牌结果完成解析后会在这里展示按国家分组的去重结果
</div>
<template v-else>
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list clean-result-list">
<li v-for="item in currentSectionItems"
:key="`current-${item.taskId || item.resultId || item.sourceFilename}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ formatMatchResult(item) }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="getQueueStatus(item)" class="files">队列状态{{ getQueueStatus(item) }}</div>
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount
}}</div>
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }}
</div>
<div v-if="shouldShowProgress(item)" class="delete-brand-progress-block">
<div class="delete-brand-progress-header">
<span>任务进度</span>
<span>{{ formatProgressPercent(item) }}%</span>
</div>
<div class="delete-brand-progress-bar">
<div class="delete-brand-progress-bar-fill"
:style="{ width: `${formatProgressPercent(item)}%` }"></div>
</div>
<div class="files delete-brand-progress">{{ formatProgress(item) }}</div>
</div>
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
{{ formatPreview(item.previewRows) }}
</div>
<div v-if="getDisplayError(item)" class="files">错误信息{{ getDisplayError(item) }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="getTaskStatusInfo(item).className">
{{ getTaskStatusInfo(item).text }}
</span>
<button v-if="canDownloadTaskResult(item)" type="button" class="download"
@click="downloadTaskResult(item)">
下载
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史记录</div>
<ul class="task-list clean-result-list">
<li v-for="item in historySectionItems"
:key="`history-${item.taskId || item.resultId || item.sourceFilename}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ formatMatchResult(item) }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount
}}</div>
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }}
</div>
<div v-if="shouldShowProgress(item)" class="delete-brand-progress-block">
<div class="delete-brand-progress-header">
<span>任务进度</span>
<span>{{ formatProgressPercent(item) }}%</span>
</div>
<div class="delete-brand-progress-bar">
<div class="delete-brand-progress-bar-fill"
:style="{ width: `${formatProgressPercent(item)}%` }"></div>
</div>
<div class="files delete-brand-progress">{{ formatProgress(item) }}</div>
</div>
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
{{ formatPreview(item.previewRows) }}
</div>
<div v-if="getDisplayError(item)" class="files">错误信息{{ getDisplayError(item) }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="getTaskStatusInfo(item).className">
{{ getTaskStatusInfo(item).text }}
</span>
<!-- 历史记录区不再显示打开紫鸟按钮 -->
<button v-if="canDownloadTaskResult(item)" type="button" class="download"
@click="downloadTaskResult(item)">
下载
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteHistoryRecord(item.resultId)">
删除
</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import {
deleteDeleteBrandHistory,
getDeleteBrandHistory,
getDeleteBrandTaskDetails,
getDeleteBrandTaskProgress,
getDeleteBrandTaskDownloadUrl,
runDeleteBrand,
type DeleteBrandPreviewRow,
type DeleteBrandResultItem,
type DeleteBrandRunVo,
type DeleteBrandTaskDetailVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
_pushed?: boolean
_completed?: boolean
}
interface StoredCurrentTask {
taskId: number
items: SessionDeleteBrandItem[]
createdAt: number
queuePushResult?: string
queuePayloadText?: string
}
function getStorageKey() {
const uid = typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
return `delete-brand:current-tasks:${uid}`
}
const selectedPaths = ref<string[]>([])
const uploadedFiles = ref<UploadedJavaFile[]>([])
const running = ref(false)
const resultItems = ref<DeleteBrandResultItem[]>([])
const sessionTasks = ref<StoredCurrentTask[]>([])
const historyItems = ref<DeleteBrandResultItem[]>([])
const summary = ref<DeleteBrandRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const queuePushResult = ref('')
const queuePayloadText = ref('')
const taskDetails = ref<Record<number, DeleteBrandTaskDetailVo>>({})
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
const pushing = ref(false)
const chainStarted = ref(false)
const activeItemKey = ref('')
const autoAdvancing = ref(false)
const autoRetryTimer = ref<number | null>(null)
const waitingForBackendRecovery = ref(false)
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
const currentSectionItems = computed(() => {
const allCurrent: DeleteBrandResultItem[] = [];
sessionTasks.value.forEach(task => {
allCurrent.push(...task.items);
});
return allCurrent;
});
function loadSessionTasksFromStorage() {
try {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(getStorageKey()) : null
if (raw) {
sessionTasks.value = JSON.parse(raw)
if (sessionTasks.value.length > 0) {
const lastTask = sessionTasks.value[sessionTasks.value.length - 1]
queuePushResult.value = lastTask.queuePushResult || ''
queuePayloadText.value = lastTask.queuePayloadText || ''
}
} else {
sessionTasks.value = []
}
} catch {
sessionTasks.value = []
}
}
function saveSessionTask(taskId: number, items: DeleteBrandResultItem[], pushResult?: string, payloadText?: string) {
if (!items?.length) return
const existingIndex = sessionTasks.value.findIndex(t => t.taskId === taskId)
const existingTask = existingIndex >= 0 ? sessionTasks.value[existingIndex] : null
// 全量保留任务项(不在存储层按 matched/success 过滤),避免单任务多店铺时出现“未处理项丢失”。
// 同时尽量继承已有的 _pushed/_completed 会话态。
const mergedItems = items.map((item) => {
const prev = existingTask?.items.find((i) => isSameDeleteBrandItem(i, item))
// 保护大字段:当新值为空数组时,保留本地已有的非空数据,避免被轮询详情的精简 payload 覆盖。
const nextCountries = Array.isArray(item.countries) ? item.countries : undefined
const prevCountries = Array.isArray(prev?.countries) ? prev?.countries : undefined
const countries = (nextCountries && nextCountries.length > 0)
? nextCountries
: (prevCountries && prevCountries.length > 0 ? prevCountries : (nextCountries ?? prevCountries))
const nextPreviewRows = Array.isArray(item.previewRows) ? item.previewRows : undefined
const prevPreviewRows = Array.isArray(prev?.previewRows) ? prev?.previewRows : undefined
const previewRows = (nextPreviewRows && nextPreviewRows.length > 0)
? nextPreviewRows
: (prevPreviewRows && prevPreviewRows.length > 0 ? prevPreviewRows : (nextPreviewRows ?? prevPreviewRows))
return {
...item,
countries,
previewRows,
_pushed: prev?._pushed ?? (item as SessionDeleteBrandItem)._pushed ?? false,
_completed: prev?._completed ?? (item as SessionDeleteBrandItem)._completed ?? false,
} as SessionDeleteBrandItem
})
const newTask: StoredCurrentTask = {
taskId,
items: mergedItems,
createdAt: existingTask?.createdAt || Date.now(),
queuePushResult: pushResult ?? existingTask?.queuePushResult,
queuePayloadText: payloadText ?? existingTask?.queuePayloadText,
}
if (existingIndex >= 0) {
sessionTasks.value[existingIndex] = newTask
} else {
sessionTasks.value.push(newTask)
}
if (typeof window !== 'undefined') {
window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
}
}
function removeSessionTaskFromStorage(taskId: number) {
const oldLen = sessionTasks.value.length
sessionTasks.value = sessionTasks.value.filter(t => t.taskId !== taskId)
if (sessionTasks.value.length !== oldLen && typeof window !== 'undefined') {
window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
}
}
function isTerminalStatus(status?: string | null) {
return status === 'SUCCESS' || status === 'FAILED' || status === 'COMPLETED'
}
function pruneTerminalSessionTasksByHistory(items: DeleteBrandResultItem[]) {
if (!items?.length) return false
const terminalTaskIds = new Set<number>()
items.forEach((item) => {
if (item.taskId && isTerminalStatus(item.taskStatus)) {
terminalTaskIds.add(item.taskId)
}
})
if (!terminalTaskIds.size) return false
const oldLen = sessionTasks.value.length
sessionTasks.value = sessionTasks.value.filter((task) => !terminalTaskIds.has(task.taskId))
if (sessionTasks.value.length !== oldLen) {
persistSessionTasks()
return true
}
return false
}
const historySectionItems = computed(() =>
historyItems.value.filter(
(item) => !currentSectionItems.value.some(c => isSameDeleteBrandItem(c, item))
),
);
const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
const queueRunnableItems = computed(() =>
currentSectionItems.value.filter((item) => canPushToQueue(item) && getQueueStatus(item) === '未入队'),
)
function persistSessionTasks() {
if (typeof window !== 'undefined') {
if (sessionTasks.value.length === 0) window.localStorage.removeItem(getStorageKey())
else window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
}
}
function isMultiFileTask(taskId?: number) {
if (!taskId) return false
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
return (sessionTask?.items.length || 0) > 1
}
function findSessionTask(taskId?: number) {
if (!taskId) return null
return sessionTasks.value.find(t => t.taskId === taskId) || null
}
function isSameDeleteBrandItem(left: Pick<DeleteBrandResultItem, 'resultId' | 'fileKey' | 'sourceFilename'> | null | undefined,
right: Pick<DeleteBrandResultItem, 'resultId' | 'fileKey' | 'sourceFilename'> | null | undefined) {
if (!left || !right) return false
if (left.resultId != null && right.resultId != null) {
return left.resultId === right.resultId
}
if (left.fileKey && right.fileKey) {
return left.fileKey === right.fileKey
}
return left.sourceFilename === right.sourceFilename
}
function findSessionItem(taskId: number | undefined, item: DeleteBrandResultItem) {
const sessionTask = findSessionTask(taskId)
if (!sessionTask) return null
return sessionTask.items.find(i => isSameDeleteBrandItem(i, item)) || null
}
function updateCompletedItemsFromProgress(taskId: number | undefined) {
if (!taskId) return false
const sessionTask = findSessionTask(taskId)
if (!sessionTask) return false
let changed = false
const info = taskDetails.value[taskId]?.line_progress?.info
const multiFileTask = isMultiFileTask(taskId)
// 关键修复:多文件任务下不再用 finished_files 按顺序回填 completed。
// 否则当第 1 个文件完成后,推入第 2 个文件时会因为 finished_files=1 被误判为“本文件已完成(100%)”。
// 改为仅当后端进度明确指向该文件且 current_line >= total_lines 时,才标记该文件 completed。
if (info?.file_name && info.current_line != null && info.total_lines != null && info.total_lines > 0) {
if (info.current_line >= info.total_lines) {
const sessionItem = sessionTask.items.find(i => i.sourceFilename === info.file_name)
if (sessionItem && !sessionItem._completed) {
sessionItem._completed = true
changed = true
}
}
}
// 单文件任务兜底:任务终态时标记完成,避免偶发进度丢包导致状态卡住。
if (!multiFileTask) {
const status = taskDetails.value[taskId]?.task?.status
if ((status === 'SUCCESS' || status === 'FAILED') && sessionTask.items.length === 1) {
const onlyItem = sessionTask.items[0]
if (onlyItem && !onlyItem._completed) {
onlyItem._completed = true
changed = true
}
}
}
if (changed) {
persistSessionTasks()
}
return changed
}
function getTaskStatus(taskId?: number) {
if (!taskId) return ''
return taskDetails.value[taskId]?.task?.status || ''
}
function getDisplayError(item: DeleteBrandResultItem) {
if (item.error) return item.error
if (item.matchStatus && item.matchStatus !== 'MATCHED') {
return item.matchMessage || ''
}
const taskId = item.taskId
if (!taskId) return ''
return taskDetails.value[taskId]?.task?.errorMessage || ''
}
function shouldShowProgress(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) return false
const progress = taskDetails.value[taskId]?.line_progress
const status = getTaskStatus(taskId)
const multiFileTask = isMultiFileTask(taskId)
const qs = getQueueStatus(item)
if (multiFileTask) {
const sessionItem = findSessionItem(taskId, item)
const isActiveItem = getItemKey(item) === activeItemKey.value
const isProgressTarget = Boolean(progress?.has_progress && progress?.info?.file_name === item.sourceFilename)
// 多文件任务下,展示规则更宽松:
// 1) 当前活跃文件2) 后端进度指向的文件3) 已推送文件4) 已完成文件(任务未终态时保留)。
if (isActiveItem || isProgressTarget || sessionItem?._pushed) {
return true
}
if ((status === 'RUNNING' || status === 'PENDING' || !status) && sessionItem?._completed) {
return true
}
return { success: false, retryable: true }
}
// 单文件任务:完成/终态后隐藏。
if (qs === '本文件解析完毕' || qs === '已被全局判定为终态') {
return false
}
if (progress?.has_progress || progress?.info) {
return true
}
return qs === '已入队' || qs === '处理中'
}
function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
return items.map((item) => {
if (item.matchStatus === 'MATCHED') {
return {
...item,
_pushed: false,
_completed: false,
} as SessionDeleteBrandItem
}
return {
...item,
matched: false,
openStoreUrl: undefined,
error: item.error || undefined,
_pushed: false,
_completed: false,
} as SessionDeleteBrandItem
})
}
function formatMatchResult(item: DeleteBrandResultItem) {
if (item.matchStatus === 'MATCHED') {
return `已匹配 ${item.shopId || ''}`.trim()
}
if (item.matchMessage) {
return item.matchMessage
}
if (item.matchStatus === 'CONFLICT') {
return '存在多个同名店铺,请人工确认'
}
if (item.matchStatus === 'PENDING' || item.matchStatus === 'INDEX_STALE') {
return '店铺索引暂未就绪'
}
if (item.matchStatus) {
return item.matchStatus
}
return item.matched ? `已匹配 ${item.shopId || ''}`.trim() : '待匹配'
}
function mergeVisibleItems() {
resultItems.value = [...currentSectionItems.value, ...historySectionItems.value]
}
function syncResultState() {
mergeVisibleItems()
updateSummary(resultItems.value)
}
function updateSummary(items: DeleteBrandResultItem[]) {
summary.value = {
total: items.length,
successCount: items.filter((item) => item.success).length,
failedCount: items.filter((item) => !item.success).length,
items,
}
}
function getFileProgress(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) return null
const detail = taskDetails.value[taskId]
const fileProgress = detail?.fileProgress || []
return fileProgress.find((fp) => {
if (fp.fileKey && item.fileKey) return fp.fileKey === item.fileKey
if (fp.sourceFilename && item.sourceFilename) return fp.sourceFilename === item.sourceFilename
return false
}) || null
}
function formatProgress(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) return ''
const detail = taskDetails.value[taskId]
const info = detail?.line_progress?.info
const fp = getFileProgress(item)
const multiFileTask = isMultiFileTask(taskId)
// 多文件任务优先按“当前运行文件 + 本地会话态”展示,避免后端 finished_files 按顺序分配导致某个文件被误判 COMPLETED。
if (multiFileTask) {
const sessionItem = findSessionItem(taskId, item)
const status = detail?.task?.status
const isRunningTarget = Boolean(info?.file_name && info.file_name === item.sourceFilename)
let displayStatus = sessionItem?._completed ? 'COMPLETED' : (sessionItem?._pushed ? 'PENDING' : 'PENDING')
let processedRows = 0
let totalRows = Math.max(0, item.totalRows ?? fp?.totalRows ?? 0)
if (status === 'SUCCESS') {
displayStatus = 'COMPLETED'
processedRows = totalRows
} else if (status === 'FAILED' && sessionItem?._completed) {
displayStatus = 'COMPLETED'
processedRows = totalRows
} else if (isRunningTarget) {
displayStatus = 'RUNNING'
processedRows = Math.max(0, Math.min(totalRows || Number.MAX_SAFE_INTEGER, info?.current_line ?? 0))
if (!totalRows && info?.total_lines && info.total_lines > 0) {
totalRows = info.total_lines
}
} else if (sessionItem?._completed) {
displayStatus = 'COMPLETED'
processedRows = totalRows
} else if (sessionItem?._pushed) {
displayStatus = 'PENDING'
processedRows = 0
}
const parts: string[] = []
parts.push(`文件状态:${displayStatus}`)
parts.push(`文件进度:${processedRows}/${totalRows}`)
if (info?.finished_files != null) {
const total = info.file_total && info.file_total > 0 ? `/${info.file_total}` : ''
parts.push(`已完成文件:${info.finished_files}${total}`)
}
if (info?.phase) parts.push(`阶段:${info.phase}`)
return parts.join('')
}
if (fp) {
const parts: string[] = []
const processedRows = fp.processedRows ?? 0
const totalRows = fp.totalRows ?? 0
if (fp.status) parts.push(`文件状态:${fp.status}`)
parts.push(`文件进度:${processedRows}/${totalRows}`)
if (info?.finished_files != null) {
const total = info.file_total && info.file_total > 0 ? `/${info.file_total}` : ''
parts.push(`已完成文件:${info.finished_files}${total}`)
}
if (info?.phase) parts.push(`阶段:${info.phase}`)
return parts.join('')
}
if (!detail?.line_progress?.has_progress || !info) {
const status = detail?.task?.status
return status ? `任务状态:${status}` : ''
}
const parts: string[] = []
if (info.phase) parts.push(`阶段:${info.phase}`)
if (info.file_index && info.file_total) parts.push(`文件:${info.file_index}/${info.file_total}`)
if (info.file_name) parts.push(`当前文件:${info.file_name}`)
if (info.current_line != null && info.total_lines != null) {
parts.push(`${isMultiFileTask(taskId) ? '当前文件进度' : '进度'}${info.current_line}/${info.total_lines}`)
}
if (info.current_country) parts.push(`国家:${info.current_country}`)
if (info.current_asin) parts.push(`ASIN${info.current_asin}`)
if (info.finished_files != null) {
const total = info.file_total && info.file_total > 0 ? `/${info.file_total}` : ''
parts.push(`已完成文件:${info.finished_files}${total}`)
}
return parts.join('')
}
function formatProgressPercent(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) return 0
const info = taskDetails.value[taskId]?.line_progress?.info
const multiFileTask = isMultiFileTask(taskId)
if (multiFileTask) {
const sessionItem = findSessionItem(taskId, item)
const taskStatus = taskDetails.value[taskId]?.task?.status
if (taskStatus === 'SUCCESS' || sessionItem?._completed) return 100
const totalRows = Math.max(0, item.totalRows ?? getFileProgress(item)?.totalRows ?? 0)
const isRunningTarget = Boolean(info?.file_name && info.file_name === item.sourceFilename)
if (isRunningTarget) {
if (info?.current_line != null && info?.total_lines && info.total_lines > 0) {
return Math.max(0, Math.min(100, Math.round((info.current_line / info.total_lines) * 100)))
}
if (info?.current_line != null && totalRows > 0) {
return Math.max(0, Math.min(100, Math.round((info.current_line / totalRows) * 100)))
}
}
return 0
}
const fp = getFileProgress(item)
if (fp) {
return Math.max(0, Math.min(100, fp.percent ?? 0))
}
if (!info) return 0
if (info.current_line != null && info.total_lines && info.total_lines > 0) {
return Math.max(0, Math.min(100, Math.round((info.current_line / info.total_lines) * 100)))
}
if (info.finished_files != null && info.file_total && info.file_total > 0) {
return Math.max(0, Math.min(100, Math.round((info.finished_files / info.file_total) * 100)))
}
const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS' ? 100 : 0
}
function isTaskTerminal(taskId: number) {
const status = taskDetails.value[taskId]?.task?.status
return isTerminalStatus(status)
}
function getPollingTaskIds() {
const ids = new Set<number>()
sessionTasks.value.forEach(task => {
// 只轮询有文件被推给 Python 的任务
if (task.items.some(i => (i as any)._pushed)) {
ids.add(task.taskId)
}
})
return Array.from(ids)
}
function getQueueStatus(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) return ''
const sessionTask = findSessionTask(taskId)
if (!sessionTask) return ''
const sessionItem = findSessionItem(taskId, item)
const status = getTaskStatus(taskId)
const info = taskDetails.value[taskId]?.line_progress
const multiFileTask = isMultiFileTask(taskId)
if (!multiFileTask && (status === 'SUCCESS' || status === 'FAILED' || item.taskStatus === 'SUCCESS' || item.taskStatus === 'FAILED')) {
return '已被全局判定为终态'
}
// 先看前端会话态:一旦该文件已判定完成,优先展示完成,避免被 RUNNING 覆盖导致链式推进卡住。
if (sessionItem?._completed) {
return '本文件解析完毕'
}
// 多文件场景下,如果后端进度明确指向当前文件,即使本地 _pushed 丢失也应判定为处理中(例如刷新页面后恢复场景)。
if (status === 'RUNNING' && info?.has_progress && info?.info?.file_name === item.sourceFilename) {
return '处理中'
}
// 多文件:未推送且未完成,才视为未入队。
if (multiFileTask && !sessionItem?._pushed && !sessionItem?._completed) {
return '未入队'
}
if (multiFileTask && status === 'SUCCESS') {
return '本文件解析完毕'
}
if (!multiFileTask) {
const detail = taskDetails.value[taskId]
if (detail?.items) {
const finished = detail.items.find((i: DeleteBrandResultItem) => isSameDeleteBrandItem(i, item))
if (finished && status === 'SUCCESS') {
return '本文件解析完毕'
}
}
}
if (sessionItem?._pushed) {
return '已入队'
}
return '未入队'
}
function canDownloadTaskResult(item: DeleteBrandResultItem) {
if (item.downloadUrl) return true
const taskId = item.taskId
if (!taskId) return { success: false, retryable: false }
const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS'
}
function mergeTaskDetailItemsIntoSession(detail: DeleteBrandTaskDetailVo) {
const taskId = detail?.task?.id
if (!taskId) return false
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
if (!sessionTask) return { success: false, retryable: false }
const detailItems = detail?.items || []
let changed = false
// 以本地会话项为主做增量合并,保留 _pushed/_completed
// 对后端新增返回的项追加到末尾,避免单任务多店铺/后续补齐时丢项。
const mergedItems = sessionTask.items.map((item) => {
const latest = detailItems.find((candidate) => isSameDeleteBrandItem(candidate, item))
if (!latest) return item
const latestCountries = Array.isArray(latest.countries) ? latest.countries : undefined
const itemCountries = Array.isArray(item.countries) ? item.countries : undefined
const countries = (latestCountries && latestCountries.length > 0)
? latestCountries
: (itemCountries && itemCountries.length > 0 ? itemCountries : (latestCountries ?? itemCountries))
const latestPreviewRows = Array.isArray(latest.previewRows) ? latest.previewRows : undefined
const itemPreviewRows = Array.isArray(item.previewRows) ? item.previewRows : undefined
const previewRows = (latestPreviewRows && latestPreviewRows.length > 0)
? latestPreviewRows
: (itemPreviewRows && itemPreviewRows.length > 0 ? itemPreviewRows : (latestPreviewRows ?? itemPreviewRows))
const merged = {
...item,
...latest,
countries,
previewRows,
_pushed: item._pushed,
_completed: item._completed,
} as SessionDeleteBrandItem
if (JSON.stringify(merged) !== JSON.stringify(item)) {
changed = true
}
return merged
})
for (const latest of detailItems) {
if (!mergedItems.some((item) => isSameDeleteBrandItem(item, latest))) {
mergedItems.push({
...latest,
_pushed: false,
_completed: false,
} as SessionDeleteBrandItem)
changed = true
}
}
if (JSON.stringify(mergedItems) !== JSON.stringify(sessionTask.items)) {
sessionTask.items = mergedItems
changed = true
}
if (changed) {
persistSessionTasks()
}
return changed
}
async function refreshTaskDetails(taskIds?: number[]) {
const ids = taskIds || getPollingTaskIds()
if (!ids.length) return
try {
const batch = await getDeleteBrandTaskProgress(ids)
if (waitingForBackendRecovery.value && chainStarted.value) {
queuePushResult.value = '后端已恢复,继续执行当前任务并自动衔接后续任务...'
}
waitingForBackendRecovery.value = false
let changed = false
for (const detail of batch.items || []) {
const id = detail?.task?.id
if (typeof id === 'number' && id > 0) {
taskDetails.value[id] = detail
if (mergeTaskDetailItemsIntoSession(detail)) {
changed = true
}
if (updateCompletedItemsFromProgress(id)) {
changed = true
}
}
}
if (changed) {
syncResultState()
}
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (transient && chainStarted.value) {
waitingForBackendRecovery.value = true
queuePushResult.value = '当前任务运行中,正在等待后端服务恢复...'
}
}
}
function getPollIntervalMs() {
return getTaskPollIntervalMs()
}
function scheduleNextPoll(immediate = false) {
if (pollTimer.value) {
if (!immediate) return
clearTimeout(pollTimer.value)
pollTimer.value = null
}
const executePoll = async () => {
pollTimer.value = null
if (pollingInFlight.value) {
scheduleNextPoll()
return
}
const taskIds = getPollingTaskIds()
if (!taskIds.length) return
pollingInFlight.value = true
let stateChanged = false
let shouldRefreshHistory = false
try {
await refreshTaskDetails(taskIds)
await maybeAutoAdvance()
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
// 以后端任务终态为准:进入 SUCCESS/FAILED 后立即清理本地当前任务并刷新 history
// 避免 _completed 会话态偶发不同步导致持续轮询 batch。
removeSessionTaskFromStorage(taskId)
stateChanged = true
shouldRefreshHistory = true
}
}
} finally {
pollingInFlight.value = false
}
if (shouldRefreshHistory) {
await loadHistory()
} else if (stateChanged) {
syncResultState()
}
if (getPollingTaskIds().length > 0) {
scheduleNextPoll()
}
}
if (immediate) {
executePoll()
} else {
pollTimer.value = window.setTimeout(executePoll, getPollIntervalMs())
}
}
function ensurePolling(immediate = false) {
if (pollTimer.value && !immediate) return
scheduleNextPoll(immediate)
}
function getTaskStatusInfo(item: DeleteBrandResultItem) {
// 1. 优先查实时详情中的状态
const status = item.taskId ? taskDetails.value[item.taskId]?.task?.status : null
if (status === 'SUCCESS') return { className: 'success', text: '已完成' }
if (status === 'FAILED') return { className: 'failed', text: '失败' }
if (status === 'RUNNING') return { className: 'running', text: '执行中' }
// 2. 其次看 history 接口带回来的后端任务状态
if (item.taskStatus === 'SUCCESS') return { className: 'success', text: '已完成' }
if (item.taskStatus === 'FAILED') return { className: 'failed', text: '失败' }
if (item.taskStatus === 'RUNNING') return { className: 'running', text: '执行中' }
// 3. 最后根据 item 本身的 success 标识兜底
if (item.success) return { className: 'success', text: '已完成' }
return { className: 'failed', text: '失败' }
}
function stopPolling() {
if (pollTimer.value) {
window.clearTimeout(pollTimer.value)
pollTimer.value = null
}
clearAutoRetryTimer()
}
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) {
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
return
}
try {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
selectedPaths.value = paths
uploadedFiles.value = await uploadPathsToJava(paths)
ElMessage.success(`已选择 ${paths.length} 个删除品牌文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function selectFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
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
}
selectedPaths.value = result.items.map((item) => item.relativePath || item.absolutePath)
uploadedFiles.value = await uploadPathsToJava(result.items)
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 xlsx 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function submitRun() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择待处理文件')
return
}
try {
running.value = true
const result = await runDeleteBrand({
files: uploadedFiles.value.map((item) => ({
fileKey: item.fileKey,
originalFilename: item.originalFilename,
relativePath: item.relativePath,
})),
})
const normalizedItems = normalizeDeleteBrandItems(result.items || [])
const hasMatchedItems = normalizedItems.some((item) => item.matchStatus === 'MATCHED')
const hasPendingItems = normalizedItems.some((item) => item.matchStatus && item.matchStatus !== 'MATCHED')
queuePushResult.value = hasMatchedItems
? '解析完成,可在左侧点击“推送到 Python 队列”开始串行处理'
: '解析完成,当前没有可推送的已匹配文件'
queuePayloadText.value = ''
if (normalizedItems.length && normalizedItems[0].taskId) {
saveSessionTask(normalizedItems[0].taskId, normalizedItems, queuePushResult.value, queuePayloadText.value)
}
syncResultState()
if (hasPendingItems) {
ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
} else if (hasMatchedItems) {
ElMessage.success('删除品牌解析完成,请在左侧推送到 Python 队列')
} else {
ElMessage.warning('当前没有可推送的已匹配文件,请先等待店铺索引刷新。')
}
// 更新历史记录,以展示那些未进入队列的失败项
await loadHistory()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '执行失败')
} finally {
running.value = false
}
}
async function downloadTaskResult(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) {
ElMessage.warning('未找到任务ID')
return
}
const api = getPywebviewApi()
const url = getDeleteBrandTaskDownloadUrl(taskId)
const filename =
item.outputFilename ||
item.sourceFilename ||
`delete-brand_${taskId}.xlsx`
if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url_new(url, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
}
async function loadHistory() {
try {
const response = await getDeleteBrandHistory()
historyItems.value = normalizeDeleteBrandItems(response.items || [])
pruneTerminalSessionTasksByHistory(historyItems.value)
syncResultState()
// 关键:页面加载历史后,立刻拉取详情触发状态机
await refreshTaskDetails()
ensurePolling(true)
} catch (err) {
const message = (err instanceof Error ? err.message : String(err || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (!transient) {
console.error('loadHistory error:', err)
}
}
}
function removeRecordFromView(resultId: number) {
sessionTasks.value.forEach(st => {
st.items = st.items.filter(item => item.resultId !== resultId)
})
const oldLen = sessionTasks.value.length
sessionTasks.value = sessionTasks.value.filter(st => st.items.length > 0)
if (sessionTasks.value.length !== oldLen && typeof window !== 'undefined') {
window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
}
historyItems.value = historyItems.value.filter((item) => item.resultId !== resultId)
syncResultState()
if (getPollingTaskIds().length === 0) {
stopPolling()
}
}
async function deleteHistoryRecord(resultId: number) {
try {
await deleteDeleteBrandHistory(resultId)
removeRecordFromView(resultId)
await loadHistory()
ElMessage.success('已删除')
} catch (error) {
const message = error instanceof Error ? error.message : '删除失败'
// 后端可能已被清理/重复点击导致「记录不存在」,前端也应立即移除并停止轮询
if (typeof message === 'string' && message.includes('记录不存在')) {
removeRecordFromView(resultId)
ElMessage.success('已删除')
return
}
ElMessage.error(message)
}
}
function formatPreview(rows: DeleteBrandPreviewRow[]) {
const preview = rows.slice(0, 5).map((item) => `${item.country}:${item.asin}${item.status ? `${item.status}` : ''}`)
const hiddenCount = rows.length - preview.length
return hiddenCount > 0 ? `${preview.join('、')}${rows.length}` : preview.join('、')
}
function debugAutoAdvance(step: string, payload?: Record<string, unknown>) {
if (!import.meta.env.DEV) return
console.log(`[DeleteBrandAutoAdvance] ${step}`, payload || {})
}
function clearAutoRetryTimer() {
if (autoRetryTimer.value) {
window.clearTimeout(autoRetryTimer.value)
autoRetryTimer.value = null
}
}
function scheduleAutoAdvanceRetry(delayMs = 3000) {
clearAutoRetryTimer()
autoRetryTimer.value = window.setTimeout(() => {
autoRetryTimer.value = null
maybeAutoAdvance().catch(() => undefined)
}, delayMs)
}
function isQueueBusyError(message?: string) {
if (!message) return false
return message.includes('当前店铺正在执行中')
}
function isPythonQueueBusy() {
for (const task of sessionTasks.value) {
for (const item of task.items) {
const qs = getQueueStatus(item)
if (qs === '已入队' || qs === '处理中') {
return true
}
}
}
return false
}
function getItemKey(item: DeleteBrandResultItem) {
if (item.resultId != null) return `result:${item.resultId}`
return `task:${item.taskId || 0}:${item.sourceFilename || ''}`
}
function canPushToQueue(item: DeleteBrandResultItem) {
return Boolean(item.taskId && (item.matchStatus === 'MATCHED' || item.matched))
}
function findNextAutoRunnableItem() {
return currentSectionItems.value.find((item) => {
if (!canPushToQueue(item)) return false
if (getItemKey(item) === activeItemKey.value) return false
const sessionItem = findSessionItem(item.taskId, item)
if (sessionItem?._completed) return false
return getQueueStatus(item) === '未入队'
})
}
async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }) {
if (options?.auto && isPythonQueueBusy()) {
debugAutoAdvance('runItem blocked by busy queue', {
mode: 'auto',
taskId: item.taskId,
sourceFilename: item.sourceFilename,
})
return { success: false, retryable: true }
}
const api = getPywebviewApi()
const taskId = item.taskId
if (!taskId) return { success: false, retryable: false }
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
if (!sessionTask) return { success: false, retryable: false }
if (!api?.enqueue_json) {
ElMessage.error('当前环境未启用 pywebview enqueue_json浏览器环境不会推送')
return { success: false, retryable: false }
}
const payload = {
type: 'delete-brand-run',
ts: Date.now(),
data: {
taskId: taskId,
items: [item]
},
}
queuePayloadText.value = JSON.stringify(payload, null, 2)
const pushResult = await api.enqueue_json(payload)
let qpr = ''
if (pushResult?.success) {
qpr = `已推送文件 ${item.sourceFilename},当前队列长度:${pushResult.queue_size ?? '-'}`
const sessionItem = findSessionItem(taskId, item)
if (sessionItem) {
sessionItem._pushed = true
sessionItem._completed = false
}
activeItemKey.value = getItemKey(item)
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
ensurePolling(true)
queuePushResult.value = qpr
return { success: true, retryable: false }
} else {
const message = pushResult?.error || '未知错误'
qpr = `推送到 Python 队列失败:${message}`
queuePushResult.value = qpr
saveSessionTask(taskId, sessionTask.items, qpr, queuePayloadText.value)
if (options?.auto && isQueueBusyError(message)) {
return { success: false, retryable: true }
}
}
queuePushResult.value = qpr
return { success: false, retryable: false }
}
async function pushToPythonQueue() {
const nextItem = findNextAutoRunnableItem()
if (!nextItem) {
ElMessage.warning('当前没有可推送的已匹配文件')
return
}
pushing.value = true
chainStarted.value = true
clearAutoRetryTimer()
const started = await runItem(nextItem)
pushing.value = false
if (!started.success) {
chainStarted.value = false
activeItemKey.value = ''
return
}
ElMessage.success('已开始按顺序推送到 Python 队列')
}
async function maybeAutoAdvance() {
if (!chainStarted.value || autoAdvancing.value) {
debugAutoAdvance('skip: chain not started or auto advancing', {
chainStarted: chainStarted.value,
autoAdvancing: autoAdvancing.value,
})
return
}
if (isPythonQueueBusy()) {
debugAutoAdvance('skip: queue busy')
return
}
const currentKey = activeItemKey.value
if (currentKey) {
const currentItem = currentSectionItems.value.find(item => getItemKey(item) === currentKey)
if (currentItem) {
const taskId = currentItem.taskId
const multiFileTask = isMultiFileTask(taskId)
if (multiFileTask) {
const changed = updateCompletedItemsFromProgress(taskId)
debugAutoAdvance('sync completed from progress', {
taskId,
changed,
status: getQueueStatus(currentItem),
})
}
const status = getQueueStatus(currentItem)
if (status === '已入队' || status === '处理中') {
debugAutoAdvance('skip: current item still running', {
currentKey,
status,
taskId,
sourceFilename: currentItem.sourceFilename,
})
return
}
if (multiFileTask) {
const sessionItem = findSessionItem(taskId, currentItem)
if (sessionItem && !sessionItem._completed) {
debugAutoAdvance('skip: multi-file current not completed yet', {
currentKey,
taskId,
sourceFilename: currentItem.sourceFilename,
pushed: sessionItem._pushed,
completed: sessionItem._completed,
})
return
}
}
}
}
const nextItem = findNextAutoRunnableItem()
if (!nextItem) {
debugAutoAdvance('stop chain: no next runnable item', {
currentKey,
})
activeItemKey.value = ''
chainStarted.value = false
return
}
autoAdvancing.value = true
try {
debugAutoAdvance('try start next item', {
taskId: nextItem.taskId,
sourceFilename: nextItem.sourceFilename,
nextKey: getItemKey(nextItem),
})
const started = await runItem(nextItem, { auto: true })
if (!started.success) {
if (started.retryable) {
debugAutoAdvance('delay retry next item', {
taskId: nextItem.taskId,
sourceFilename: nextItem.sourceFilename,
})
scheduleAutoAdvanceRetry()
return
}
debugAutoAdvance('stop chain: runItem returned false', {
taskId: nextItem.taskId,
sourceFilename: nextItem.sourceFilename,
})
chainStarted.value = false
}
} finally {
autoAdvancing.value = false
}
}
onMounted(() => {
loadSessionTasksFromStorage()
const taskIds = getPollingTaskIds()
if (taskIds.length > 0) {
refreshTaskDetails(taskIds).then(async () => {
let stateChanged = false
let shouldRefreshHistory = false
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
// 首屏恢复也以后端终态为准,避免依赖 _completed 造成卡轮询。
removeSessionTaskFromStorage(taskId)
stateChanged = true
shouldRefreshHistory = true
}
}
if (shouldRefreshHistory) {
await loadHistory()
} else if (stateChanged) {
syncResultState()
}
ensurePolling(true)
})
} else {
syncResultState()
}
loadHistory().catch(() => undefined)
})
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
.queue-debug-card {
margin-top: 18px;
padding: 14px;
border: 1px solid #2a2a2a;
border-radius: 10px;
background: #202020;
}
.queue-debug-title {
margin-bottom: 8px;
}
.queue-debug-line {
color: #b8c1cc;
font-size: 12px;
line-height: 1.6;
margin-bottom: 8px;
}
.queue-debug-payload {
margin: 0;
max-height: 220px;
overflow: auto;
padding: 10px;
border-radius: 8px;
background: #141414;
color: #8fd3ff;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.module-page {
min-height: 100vh;
background: #1a1a1a;
}
.main-content {
display: flex;
min-height: calc(100vh - 56px);
height: calc(100vh - 56px);
}
.left-panel {
width: 380px;
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 {
font-size: 13px;
color: #bbb;
margin-bottom: 10px;
}
.upload-zone {
border: 1px dashed #3a3a3a;
border-radius: 10px;
padding: 24px;
text-align: center;
background: #252525;
margin-bottom: 20px;
transition: all 0.2s ease;
}
.upload-zone:hover {
border-color: #3498db;
background: #2a2a2a;
}
.hint {
color: #888;
font-size: 13px;
margin-bottom: 12px;
line-height: 1.5;
}
.btns {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.opt-btn,
.btn-run,
.download,
.btn-delete {
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.opt-btn {
padding: 8px 16px;
font-size: 13px;
color: #ccc;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
}
.opt-btn:hover {
color: #3498db;
background: #333;
border-color: #3498db;
}
.selected-files {
margin-top: 14px;
font-size: 12px;
color: #888;
max-height: 112px;
overflow-y: auto;
text-align: left;
}
.selected-files span {
display: block;
margin: 4px 0;
word-break: break-all;
}
.more-line {
color: #b8c1cc;
}
.split-selected-files {
max-height: 120px;
min-height: 72px;
padding-right: 4px;
}
.option-group {
margin-bottom: 20px;
}
.radio-item {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
padding: 10px 12px;
border-radius: 8px;
background: #252525;
border: 1px solid #2a2a2a;
margin-bottom: 8px;
transition: all 0.2s ease;
}
.radio-item:hover,
.radio-item.active {
background: #2a2a2a;
border-color: #3a3a3a;
}
.radio-item input {
margin-top: 3px;
}
.label {
font-weight: 500;
color: #e0e0e0;
font-size: 13px;
}
.desc {
font-size: 12px;
color: #888;
margin-top: 2px;
line-height: 1.5;
font-weight: 400;
}
.run-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
margin-top: 16px;
}
.btn-run {
padding: 10px 22px;
background: #3498db;
color: #fff;
border-radius: 8px;
font-size: 14px;
}
.btn-run:hover {
background: #2980b9;
}
.btn-run:disabled {
background: #555;
color: #999;
cursor: not-allowed;
}
.loading-msg {
color: #3498db;
font-size: 13px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2a2a2a;
font-size: 15px;
font-weight: 600;
color: #ddd;
}
.task-list-wrap {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid #2a2a2a;
border-radius: 8px;
margin-bottom: 10px;
background: #1e1e1e;
}
.split-result-item {
align-items: flex-start;
}
.left {
flex: 1;
min-width: 0;
}
.split-result-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.id {
display: inline-block;
font-weight: 600;
color: #e0e0e0;
font-size: 13px;
}
.files {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.time {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.split-entry-list {
line-height: 1.6;
word-break: break-all;
max-width: 100%;
}
.delete-brand-preview {
white-space: normal;
}
.delete-brand-progress-block {
margin-top: 8px;
}
.delete-brand-progress-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
font-size: 12px;
color: #8fbfff;
}
.delete-brand-progress-bar {
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.delete-brand-progress-bar-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #2d8cf0 0%, #4db3ff 100%);
transition: width 0.3s ease;
}
.delete-brand-progress {
color: #69b6ff;
line-height: 1.6;
white-space: normal;
margin-top: 6px;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.split-result-actions {
flex-shrink: 0;
min-width: 180px;
justify-content: flex-end;
align-self: center;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.success {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, 0.18);
color: #ff6b6b;
}
.status.running {
background: rgba(52, 152, 219, 0.18);
color: #3498db;
}
.download {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(52, 152, 219, 0.18);
color: #69b6ff;
}
.download:hover {
background: rgba(52, 152, 219, 0.28);
}
.btn-delete {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(231, 76, 60, 0.12);
color: #ff8f8f;
}
.btn-delete:hover {
background: rgba(231, 76, 60, 0.22);
}
.empty-tasks {
color: #666;
font-size: 13px;
padding: 24px 8px;
}
.clean-placeholder {
max-height: 112px;
}
.clean-result-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 220px));
gap: 16px;
margin-bottom: 18px;
}
.summary-card {
padding: 16px 18px;
border: 1px solid #2a2a2a;
border-radius: 10px;
background: #1e1e1e;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #eaf4ff;
}
.summary-label {
font-size: 12px;
color: #8d8d8d;
}
.result-list-wrap {
border: 1px solid #2a2a2a;
border-radius: 10px;
background: #1e1e1e;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2a2a2a;
font-size: 14px;
color: #ddd;
}
@media (max-width: 1100px) {
.main-content {
flex-direction: column;
height: auto;
}
.left-panel {
width: 100%;
border-right: none;
border-bottom: 1px solid #2a2a2a;
}
.right-panel {
min-height: 420px;
}
.task-item {
flex-direction: column;
align-items: flex-start;
}
.task-right {
width: 100%;
justify-content: flex-start;
}
.split-result-actions {
min-width: 0;
align-self: flex-start;
}
.clean-result-summary {
grid-template-columns: 1fr;
}
}
</style>