Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue
2026-03-29 19:03:24 +08:00

806 lines
34 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>
<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.resultId || item.taskId || 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">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</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.taskId!) }}%</span>
</div>
<div class="delete-brand-progress-bar">
<div class="delete-brand-progress-bar-fill" :style="{ width: `${formatProgressPercent(item.taskId!)}%` }"></div>
</div>
<div class="files delete-brand-progress">{{ formatProgress(item.taskId!) }}</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="item.openStoreUrl"
type="button"
class="download"
@click="openShop(item.openStoreUrl)"
>
打开紫鸟
</button>
<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.resultId || item.taskId || 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">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</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.taskId!) }}%</span>
</div>
<div class="delete-brand-progress-bar">
<div class="delete-brand-progress-bar-fill" :style="{ width: `${formatProgressPercent(item.taskId!)}%` }"></div>
</div>
<div class="files delete-brand-progress">{{ formatProgress(item.taskId!) }}</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="item.openStoreUrl"
type="button"
class="download"
@click="openShop(item.openStoreUrl)"
>
打开紫鸟
</button>
<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,
getDeleteBrandTaskDownloadUrl,
runDeleteBrand,
type DeleteBrandPreviewRow,
type DeleteBrandResultItem,
type DeleteBrandRunVo,
type DeleteBrandTaskDetailVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
const selectedPaths = ref<string[]>([])
const uploadedFiles = ref<UploadedJavaFile[]>([])
const running = ref(false)
const resultItems = ref<DeleteBrandResultItem[]>([])
const currentRunItems = ref<DeleteBrandResultItem[]>([])
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 displayPaths = computed(() => selectedPaths.value.slice(0, 10))
const runningTaskIdsInHistory = computed(() => {
return historyItems.value
.filter(item => {
const status = item.taskId ? taskDetails.value[item.taskId]?.task?.status : null;
// 优先认后端的实时状态
if (status) return status === 'RUNNING';
// 如果还没拿到底层详情,看 history 接口带回来的全局任务状态
if (item.taskStatus) return item.taskStatus === 'RUNNING';
// 最后降级到根据 success/error 猜测(向下兼容)
return (item.taskId || 0) > 0 && item.success === false && !item.error;
})
.map(item => item.taskId);
});
const currentSectionItems = computed(() => {
// 当前运行的任务 = 用户手动触发的 + 历史记录里还在跑的
const allCurrent = [...currentRunItems.value];
historyItems.value.forEach(hItem => {
if (hItem.taskId && runningTaskIdsInHistory.value.includes(hItem.taskId)) {
// 避免重复
if (!allCurrent.some(c => c.taskId === hItem.taskId)) {
allCurrent.push(hItem);
}
}
});
return allCurrent;
});
const historySectionItems = computed(() =>
historyItems.value.filter(
(item) => !currentSectionItems.value.some(c => c.taskId === item.taskId || (c.resultId && c.resultId === item.resultId))
),
);
const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
function getTaskStatus(taskId?: number) {
if (!taskId) return ''
return taskDetails.value[taskId]?.task?.status || ''
}
function getDisplayError(item: DeleteBrandResultItem) {
if (item.error) return item.error
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 status = getTaskStatus(taskId)
// 只要状态是运行中就显示进度条,不管 matched 了matched 不对的状态应该在后端就标记为 FAILED
return status === 'RUNNING'
}
function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
return items.map((item) => {
if (item.matched === false) {
return {
...item,
success: false,
error: item.error || '未匹配到紫鸟店铺',
openStoreUrl: undefined,
}
}
return item
})
}
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 getItemTaskId(item: DeleteBrandResultItem) {
return item.taskId || 0
}
function formatProgress(taskId: number) {
if (!taskId) return ''
const detail = taskDetails.value[taskId]
const info = detail?.line_progress?.info
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(`进度:${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) parts.push(`已完成文件:${info.finished_files}`)
return parts.join('')
}
function formatProgressPercent(taskId: number) {
if (!taskId) return 0
const info = taskDetails.value[taskId]?.line_progress?.info
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 isTaskRunning(taskId: number) {
const status = taskDetails.value[taskId]?.task?.status
return status === 'RUNNING'
}
function isTaskTerminal(taskId: number) {
const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS' || status === 'FAILED'
}
function getPollingTaskIds() {
const ids = new Set<number>()
// 1. 检查本会话触发的任务
currentRunItems.value.forEach((item) => {
if (item.taskId && !isTaskTerminal(item.taskId)) {
ids.add(item.taskId)
}
})
// 2. 检查历史加载的任务
historyItems.value.forEach((item) => {
if (item.taskId) {
if (taskDetails.value[item.taskId]) {
// 如果已经拿到了详情,以后端详情为准
if (!isTaskTerminal(item.taskId)) {
ids.add(item.taskId)
}
} else {
// 还没拿到详情,看 history 接口中的 taskStatus 或 success 标记
const isRunning = item.taskStatus === 'RUNNING' || (!item.taskStatus && !item.success && !item.error);
if (isRunning) {
ids.add(item.taskId)
}
}
}
})
return Array.from(ids)
}
function canDownloadTaskResult(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId || !item.matched) return false
const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS'
}
async function refreshTaskDetails(taskIds?: number[]) {
const ids = taskIds || getPollingTaskIds()
if (!ids.length) return
try {
const batch = await getDeleteBrandTaskDetails(ids)
for (const detail of batch.items || []) {
const id = detail?.task?.id
if (typeof id === 'number' && id > 0) {
taskDetails.value[id] = detail
}
}
} catch {
// ignore polling failures
}
}
function getPollIntervalMs() {
return document.visibilityState === 'visible' ? 4500 : 10000
}
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
try {
await refreshTaskDetails(taskIds)
} finally {
pollingInFlight.value = false
}
const stillRunning = taskIds.some((id) => isTaskRunning(id))
if (stillRunning) {
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
}
}
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 || [])
currentRunItems.value = normalizedItems
syncResultState()
await refreshTaskDetails(getPollingTaskIds())
ensurePolling()
const api = getPywebviewApi()
const hasUnmatched = normalizedItems.some((item) => !item.matched)
if (!hasUnmatched && api?.enqueue_json) {
const payload = {
type: 'delete-brand-run',
ts: Date.now(),
data: { ...result, items: normalizedItems },
}
queuePayloadText.value = JSON.stringify(payload, null, 2)
const pushResult = await api.enqueue_json(payload)
if (pushResult?.success) {
queuePushResult.value = `已推送到 Python 队列,当前队列长度:${pushResult.queue_size ?? '-'}`
} else {
queuePushResult.value = `推送到 Python 队列失败:${pushResult?.error || '未知错误'}`
}
} else {
queuePushResult.value = hasUnmatched
? '存在未匹配到紫鸟店铺的文件,已跳过推送到 Python 队列'
: '当前环境未启用 pywebview enqueue_json浏览器环境不会推送'
queuePayloadText.value = ''
}
if (hasUnmatched) {
ElMessage.error('存在未匹配到紫鸟店铺的文件,已标记为失败')
} else {
ElMessage.success('删除品牌解析完成')
}
} 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) {
const result = await api.save_file_from_url(url, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
window.open(url, '_blank')
}
async function loadHistory() {
try {
const response = await getDeleteBrandHistory()
historyItems.value = normalizeDeleteBrandItems(response.items || [])
syncResultState()
// 关键:页面加载历史后,立刻拉取详情触发状态机
await refreshTaskDetails()
ensurePolling(true)
} catch (err) {
console.error('loadHistory error:', err)
}
}
function removeRecordFromView(resultId: number) {
const removedFromCurrent = currentRunItems.value.find((item) => item.resultId === resultId)
const removedTaskId = removedFromCurrent?.taskId
currentRunItems.value = currentRunItems.value.filter((item) => item.resultId !== resultId)
historyItems.value = historyItems.value.filter((item) => item.resultId !== resultId)
if (removedTaskId && !currentRunItems.value.some((item) => item.taskId === removedTaskId)) {
delete taskDetails.value[removedTaskId]
}
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 openShop(url: string) {
window.open(url, '_blank')
}
onMounted(() => {
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>