Files
crawler-plugin/frontend-vue/src/pages/brand/index.vue
2026-03-20 14:26:34 +08:00

1069 lines
27 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>
<PageShell theme="dark">
<div class="brand-page">
<header class="top-bar">
<div class="logo-area">
<span class="app-name">数富AI-亚马逊</span>
<router-link to="/home" class="btn-home">返回首页</router-link>
</div>
<div class="nav-tabs">
<div class="nav-tab-group">
<button type="button" class="nav-tab" :class="{ active: activeTab === 'brand' }"
@click="activeTab = 'brand'">
品牌检测
</button>
<button type="button" class="nav-tab" :class="{ active: activeTab === 'dedupe' }"
@click="activeTab = 'dedupe'">
数据去重
</button>
<button type="button" class="nav-tab" :class="{ active: activeTab === 'convert' }"
@click="activeTab = 'convert'">
格式转换
</button>
<button type="button" class="nav-tab" :class="{ active: activeTab === 'split' }"
@click="activeTab = 'split'">
数据拆分
</button>
</div>
</div>
</header>
<div v-if="activeTab === 'brand'" class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">选择 .xlsx 文件或文件夹将读取该文件夹下所有 xlsx</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
</div>
<el-alert v-if="!hasPywebviewSupport" class="desktop-alert" type="warning" :closable="false"
title="当前环境未检测到 pywebview文件选择与本地保存能力需要在桌面端使用。" />
<div class="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" :class="{ active: runMode === 'immediate' }">
<input v-model="runMode" type="radio" value="immediate" />
<div>
<span class="label">立即运行</span>
<div class="desc">立即执行执行完成后可下载结果</div>
</div>
</label>
<label class="radio-item" :class="{ active: runMode === 'task' }">
<input v-model="runMode" type="radio" value="task" />
<div>
<span class="label">添加任务</span>
<div class="desc">数量较多时建议添加任务后台执行可在右侧任务队列查看状态与下载结果</div>
</div>
</label>
</div>
<div class="section-title">匹配方式</div>
<div class="option-group">
<label class="radio-item" :class="{ active: matchStrategy === 'Terms' }">
<input v-model="matchStrategy" type="radio" value="Terms" />
<div>
<span class="label">精确匹配表达式</span>
<div class="desc">使用精确匹配适合严格匹配品牌名称</div>
</div>
</label>
<label class="radio-item" :class="{ active: matchStrategy === 'Simple' }">
<input v-model="matchStrategy" type="radio" value="Simple" />
<div>
<span class="label">嵌入结果包含输入的词语</span>
<div class="desc">使用嵌入策略结果中包含输入的品牌词语</div>
</div>
</label>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="running" @click="submitRun">
{{ runMode === 'immediate' ? '立即运行' : '添加任务' }}
</button>
<span v-if="running" class="loading-msg">生成中请稍候</span>
<button v-if="activeTaskId" type="button" class="btn-cancel-run" @click="cancelActiveTask">
取消生成
</button>
</div>
<div v-if="activeTaskId" class="progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" :style="{ width: `${progressPercent}%` }"></div>
</div>
<span class="progress-text">{{ progressText }}</span>
</div>
<button type="button" class="template-download-row" @click="downloadTemplateXlsx">
<span class="template-download-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"
stroke="rgba(255,255,255,0.92)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 2v6h6" stroke="rgba(255,255,255,0.92)" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M8 13h8M8 17h5" stroke="rgba(255,255,255,0.86)" stroke-width="1.5" stroke-linecap="round" />
</svg>
</span>
<span class="template-download-text">
<span class="title">模板下载</span>
<span class="hint">品牌文档格式_模板.xlsx · 点击选择保存位置</span>
</span>
</button>
<button type="button" class="template-download-row" @click="downloadTemplateZip">
<span class="template-download-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"
stroke="rgba(255,255,255,0.92)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 2v6h6" stroke="rgba(255,255,255,0.92)" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M4 8h16M7 12h10M7 16h7" stroke="rgba(255,255,255,0.86)" stroke-width="1.5"
stroke-linecap="round" />
</svg>
</span>
<span class="template-download-text">
<span class="title">模板2下载</span>
<span class="hint">以文件夹方式上传.zip · 点击下载</span>
</span>
</button>
</aside>
<section class="right-panel">
<div class="panel-header">任务队列</div>
<div class="task-list-wrap">
<div v-if="tasks.length === 0" class="empty-tasks">暂无任务</div>
<ul v-else class="task-list">
<li v-for="task in tasks" :key="task.id" class="task-item">
<div class="left">
<span class="id" :title="getTaskTitle(task)">
{{ shorten(getTaskTitle(task), 20) }}
</span>
<div class="files">{{ (task.file_paths?.length || 0) }} 个文件</div>
<div class="time">{{ task.created_at || '-' }}</div>
</div>
<div class="task-right">
<div v-if="task.status === 'running' && (task.progress_total || 0) > 0" class="inline-progress">
<div class="progress-bar-bg small">
<div class="progress-bar-fill" :style="{ width: `${getTaskProgressPercent(task)}%` }"></div>
</div>
<span class="inline-progress-text">
{{ task.progress_current || 0 }} / {{ task.progress_total || 0 }}
</span>
</div>
<span class="status" :class="task.status || 'pending'">
{{ getStatusLabel(task.status) }}
</span>
<button v-if="task.status === 'running'" type="button" class="btn-cancel"
@click="cancelTask(task.id)">
取消
</button>
<button v-if="task.result_paths?.zip_url" type="button" class="download"
@click="downloadTaskResult(task)">
下载结果
</button>
<button type="button" class="btn-delete" @click="removeTask(task.id)">删除</button>
</div>
</li>
</ul>
</div>
</section>
</div>
<BrandDedupeTab v-else-if="activeTab === 'dedupe'" />
<BrandConvertTab v-else-if="activeTab === 'convert'" />
<BrandSplitTab v-else-if="activeTab === 'split'" />
</div>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
cancelBrandTask,
createBrandTask,
createBrandTaskEvents,
deleteBrandTask,
expandBrandFolder,
getBrandTask,
getBrandTaskDownloadUrl,
getBrandTasks,
getBrandTemplateXlsxUrl,
getBrandTemplateZipUrl,
runBrandNow,
type BrandTaskItem,
} from '@/shared/api/brand'
import { getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
import BrandDedupeTab from '@/pages/brand/components/BrandDedupeTab.vue'
import BrandConvertTab from '@/pages/brand/components/BrandConvertTab.vue'
import BrandSplitTab from '@/pages/brand/components/BrandSplitTab.vue'
const hasPywebviewSupport = hasPywebview()
const activeTab = ref<'brand' | 'dedupe' | 'convert' | 'split'>('brand')
const selectedPaths = ref<string[]>([])
const runMode = ref<'immediate' | 'task'>('immediate')
const matchStrategy = ref<'Terms' | 'Simple'>('Terms')
const tasks = ref<BrandTaskItem[]>([])
const running = ref(false)
const activeTaskId = ref<string | null>(null)
const progressCurrent = ref(0)
const progressTotal = ref(0)
const progressStatus = ref<'running' | 'success' | 'failed' | 'cancelled'>('running')
let taskEvents: EventSource | null = null
let pollTimer: number | null = null
let taskRefreshTimer: number | null = null
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
const progressPercent = computed(() => {
if (!progressTotal.value) return progressStatus.value === 'running' ? 12 : 100
return Math.min(100, Math.round((progressCurrent.value / progressTotal.value) * 100))
})
const progressText = computed(() => {
if (progressStatus.value === 'success') return '已完成'
if (progressStatus.value === 'failed') return '失败'
if (progressStatus.value === 'cancelled') return '已取消'
if (!progressTotal.value) return '生成中...'
return `${progressCurrent.value} / ${progressTotal.value}`
})
function getTaskTitle(task: BrandTaskItem) {
return (task.desc || '').trim() || `任务 #${task.id}`
}
function shorten(value: string, maxLength: number) {
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
}
function getStatusLabel(status?: string) {
const statusMap: Record<string, string> = {
pending: '等待中',
running: '执行中',
success: '已完成',
failed: '失败',
cancelled: '已取消',
}
return statusMap[(status || '').toLowerCase()] || (status || '-')
}
function getTaskProgressPercent(task: BrandTaskItem) {
const current = task.progress_current || 0
const total = task.progress_total || 0
return total ? Math.min(100, Math.round((current / total) * 100)) : 0
}
function stopTaskMonitor() {
activeTaskId.value = null
running.value = false
if (taskEvents) {
taskEvents.close()
taskEvents = null
}
if (pollTimer !== null) {
window.clearInterval(pollTimer)
pollTimer = null
}
}
async function loadTasks() {
try {
const result = await getBrandTasks()
tasks.value = result.success && Array.isArray(result.items) ? result.items : []
} catch {
tasks.value = []
}
}
async function refreshMonitoredTask(taskId: string | number) {
const result = await getBrandTask(taskId)
if (!result.success || !result.task) return
const task = result.task
progressCurrent.value = task.progress_current || 0
progressTotal.value = task.progress_total || 0
const status = (task.status || '').toLowerCase()
if (status === 'success' || status === 'failed' || status === 'cancelled') {
progressStatus.value = status as typeof progressStatus.value
stopTaskMonitor()
await loadTasks()
if (status === 'success') ElMessage.success('生成完成,可下载结果')
if (status === 'failed') ElMessage.error('生成失败')
if (status === 'cancelled') ElMessage.info('已取消')
}
}
function monitorTask(taskId: string) {
stopTaskMonitor()
activeTaskId.value = taskId
running.value = true
progressCurrent.value = 0
progressTotal.value = 0
progressStatus.value = 'running'
taskEvents = createBrandTaskEvents(taskId)
taskEvents.onmessage = async (event) => {
try {
const data = JSON.parse(event.data) as { status?: string }
const status = (data.status || '').toLowerCase()
if (status === 'success' || status === 'failed' || status === 'cancelled') {
progressStatus.value = status as typeof progressStatus.value
stopTaskMonitor()
await loadTasks()
}
} catch {
// ignore invalid SSE payload
}
}
taskEvents.onerror = () => {
if (taskEvents) {
taskEvents.close()
taskEvents = null
}
}
refreshMonitoredTask(taskId).catch(() => undefined)
pollTimer = window.setInterval(() => {
refreshMonitoredTask(taskId).catch(() => undefined)
}, 5000)
}
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) {
selectedPaths.value = 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 expandBrandFolder(folder)
if (result.success && result.paths?.length) {
selectedPaths.value = result.paths
ElMessage.success(`已选择文件夹内 ${result.paths.length} 个 xlsx 文件`)
return
}
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function downloadTemplateXlsx() {
const api = getPywebviewApi()
if (api?.save_template_xlsx) {
try {
const result = await api.save_template_xlsx()
if (result.success) {
ElMessage.success(`模板已保存至:${result.path || ''}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error || '保存失败')
}
return
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '下载失败')
return
}
}
window.location.href = getBrandTemplateXlsxUrl()
}
async function downloadTemplateZip() {
const api = getPywebviewApi()
if (api?.save_template_zip) {
try {
const result = await api.save_template_zip()
if (result.success) {
ElMessage.success(`模板已保存至:${result.path || ''}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error || '保存失败')
}
return
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '下载失败')
return
}
}
window.location.href = getBrandTemplateZipUrl()
}
async function submitRun() {
if (!selectedPaths.value.length) {
ElMessage.warning('请先选择 Excel 文件或文件夹')
return
}
try {
if (runMode.value === 'immediate') {
running.value = true
const result = await runBrandNow(selectedPaths.value, matchStrategy.value)
if (result.success && result.task_id) {
monitorTask(result.task_id)
await loadTasks()
return
}
running.value = false
ElMessage.error(result.error || '运行失败')
return
}
const result = await createBrandTask(selectedPaths.value, matchStrategy.value)
if (result.success) {
ElMessage.success('任务已添加,请在任务队列查看')
await loadTasks()
return
}
ElMessage.error(result.error || '添加失败')
} catch (error) {
running.value = false
ElMessage.error(error instanceof Error ? error.message : '请求失败')
}
}
async function cancelTask(taskId: string | number) {
const result = await cancelBrandTask(taskId)
if (result.success) {
ElMessage.success('已取消')
if (String(activeTaskId.value) === String(taskId)) {
progressStatus.value = 'cancelled'
stopTaskMonitor()
}
await loadTasks()
return
}
ElMessage.error(result.error || '取消失败')
}
async function cancelActiveTask() {
if (!activeTaskId.value) return
await cancelTask(activeTaskId.value)
}
async function removeTask(taskId: string | number) {
const result = await deleteBrandTask(taskId)
if (result.success) {
ElMessage.success('已删除')
await loadTasks()
return
}
ElMessage.error(result.error || '删除失败')
}
async function downloadTaskResult(task: BrandTaskItem) {
const zipUrl = task.result_paths?.zip_url
if (!zipUrl) {
ElMessage.error('无法获取下载地址')
return
}
const api = getPywebviewApi()
const filename = `brand_task_${task.id}.zip`
if (api?.save_file_from_url) {
try {
const result = await api.save_file_from_url(zipUrl, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
return
}
}
window.open(getBrandTaskDownloadUrl(task.id), '_blank')
}
onMounted(() => {
document.title = '亚马逊 - 数富AI'
loadTasks().catch(() => undefined)
taskRefreshTimer = window.setInterval(() => {
loadTasks().catch(() => undefined)
}, 10000)
})
onBeforeUnmount(() => {
stopTaskMonitor()
if (taskRefreshTimer !== null) {
window.clearInterval(taskRefreshTimer)
taskRefreshTimer = null
}
})
</script>
<style scoped>
.brand-page {
min-height: 100vh;
background: #0d0d0d;
color: #e0e0e0;
font-family: "Microsoft YaHei", "Noto Serif SC", "SimSun", "Songti SC", serif;
font-weight: 700;
}
.top-bar {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid #2a2a2a;
}
.logo-area {
display: flex;
align-items: center;
gap: 10px;
}
.app-name {
font-size: 18px;
font-weight: 600;
color: #fff;
}
.btn-home {
font-size: 13px;
color: #999;
text-decoration: none;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s ease;
}
.btn-home:hover {
color: #fff;
background: #2a2a2a;
}
.nav-tabs {
display: flex;
align-items: center;
}
.nav-tab-group {
display: flex;
align-items: center;
background: rgba(77, 72, 72, 0.99);
border-radius: 10px;
}
.nav-tab {
padding: 8px 14px;
font-size: 13px;
color: #9fb9d3;
background: transparent;
border: none;
border-radius: 6px;
}
.nav-tab:hover {
color: #d8ecff;
background: rgba(52, 152, 219, 0.12);
}
.nav-tab.active {
color: #3498db;
background: rgba(52, 152, 219, 0.2);
}
.main-content {
display: flex;
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,
.btn-cancel-run,
.btn-cancel,
.btn-delete,
.download {
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;
}
.desktop-alert {
margin-top: 14px;
text-align: left;
}
.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;
}
.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;
}
.btn-cancel-run {
padding: 9px 18px;
border-radius: 8px;
background: rgba(231, 126, 35, 0.18);
color: #f0ad4e;
}
.btn-cancel-run:hover,
.btn-cancel:hover {
background: rgba(231, 126, 35, 0.28);
}
.progress-wrap {
margin-top: 14px;
}
.progress-bar-bg {
height: 8px;
border-radius: 999px;
background: #2d2d2d;
overflow: hidden;
}
.progress-bar-bg.small {
width: 180px;
height: 6px;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #3498db 0%, #5dade2 100%);
border-radius: inherit;
transition: width 0.2s ease;
}
.progress-text,
.inline-progress-text {
display: inline-block;
margin-top: 8px;
font-size: 12px;
color: #888;
}
.template-download-row {
width: 100%;
margin-top: 16px;
padding: 14px 16px;
background: linear-gradient(135deg, #252525 0%, #1e2a1e 100%);
border: 1px solid #2a3a2a;
border-radius: 10px;
display: flex;
align-items: center;
gap: 14px;
transition: all 0.25s ease;
color: inherit;
}
.template-download-row:hover {
border-color: #2e7d32;
background: linear-gradient(135deg, #2a2a2a 0%, #243324 100%);
box-shadow: 0 4px 12px rgba(46, 125, 50, 0.15);
}
.template-download-row:hover .title {
color: #2ecc71;
}
.template-download-row:hover .template-download-icon {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(46, 125, 50, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.template-download-icon {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 10px;
background: linear-gradient(145deg, #1b5e20 0%, #2e7d32 50%, #388e3c 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(46, 125, 50, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.template-download-icon svg {
width: 28px;
height: 28px;
}
.template-download-text .title {
font-size: 14px;
color: #e0e0e0;
font-weight: 600;
display: block;
}
.template-download-text .hint {
font-size: 12px;
color: #888;
margin-top: 2px;
font-weight: 400;
margin-bottom: 0;
}
.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;
}
.left {
flex: 1;
min-width: 0;
}
.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;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.pending {
background: rgba(255, 193, 7, 0.2);
color: #d4a500;
}
.status.running {
background: rgba(52, 152, 219, 0.2);
color: #3498db;
}
.status.success {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, 0.18);
color: #ff6b6b;
}
.status.cancelled {
background: rgba(149, 165, 166, 0.18);
color: #b2bec3;
}
.btn-cancel,
.btn-delete,
.download {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
}
.btn-cancel {
background: rgba(231, 126, 35, 0.18);
color: #f0ad4e;
}
.btn-delete {
background: #2a2a2a;
color: #b8b8b8;
}
.btn-delete:hover {
background: #343434;
color: #fff;
}
.download {
background: rgba(52, 152, 219, 0.18);
color: #69b6ff;
}
.download:hover {
background: rgba(52, 152, 219, 0.28);
}
.empty-tasks {
color: #666;
font-size: 13px;
padding: 24px 8px;
}
@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;
}
.progress-bar-bg.small {
width: 100%;
}
}
</style>