1069 lines
27 KiB
Vue
1069 lines
27 KiB
Vue
<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> |