30b8c910e8
- 新增 web-fallback.ts:无 pywebview 时以浏览器能力降级(文件选择/上传/模板下载/配置), 其余桌面能力(enqueue_json/vc_*/folder 选择等)保持 undefined 交由页面既有请在本地客户端中打开提示 - 新增 ensure-auth.ts:无 uid 时经 /newApi/check_login 恢复登录态,无 token/失效跳登录页; 21 个入口 main.ts 挂载前先引导(桌面端已注入 uid 时零开销) - pywebview.ts:getPywebviewApi() 在纯浏览器环境返回降级桥;新增 isDesktopRuntime 标志 (首页更新面板/退出链接据此区分桌面与 Web);hasPywebview 语义修正为真实桥判定 - 登录页跳转、首页登出链接、品牌检测页 hasBridge 判定(select_brand_folder 存在性)适配 Web
496 lines
21 KiB
Vue
496 lines
21 KiB
Vue
<template>
|
||
<div class="page-shell module-page">
|
||
<AmazonToolPageShell tool-id="brand">
|
||
|
||
<div class="main-content">
|
||
<aside class="left-panel">
|
||
<div class="section-title">待检品牌文件</div>
|
||
<div class="upload-zone">
|
||
<div class="hint">
|
||
Excel 每行含「品牌」列即会加入检测。可多选文件或选择整个文件夹(.xlsx)。检测由本机客户端执行,请在桌面客户端中打开本页面。
|
||
</div>
|
||
<div class="btns">
|
||
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="selectFiles">选择 Excel</button>
|
||
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="selectFolder">选择文件夹</button>
|
||
</div>
|
||
<div class="selected-files">
|
||
<template v-if="selectedPaths.length">
|
||
<span v-for="name in displayPaths" :key="name" :title="name">{{ baseName(name) }}</span>
|
||
<span v-if="selectedPaths.length > displayPaths.length" class="more-line">
|
||
还有 {{ selectedPaths.length - displayPaths.length }} 个文件未展开显示
|
||
</span>
|
||
</template>
|
||
<span v-else>暂未选择文件</span>
|
||
</div>
|
||
<div v-if="!hasBridge" class="bridge-tip">当前浏览器环境无桌面客户端桥接,仅可预览页面;请用本机客户端打开。</div>
|
||
</div>
|
||
|
||
<div class="section-title">匹配策略</div>
|
||
<div class="radio-cards">
|
||
<label class="radio-card" :class="{ selected: strategy === 'Terms' }">
|
||
<input type="radio" name="brand-strategy" value="Terms" v-model="strategy" />
|
||
<span class="radio-card-name">词条 / 嵌入匹配(Terms)</span>
|
||
<span class="radio-card-desc">按品牌词条做宽范围比对,误杀率低,推荐第一遍使用</span>
|
||
</label>
|
||
<label class="radio-card" :class="{ selected: strategy === 'Simple' }">
|
||
<input type="radio" name="brand-strategy" value="Simple" v-model="strategy" />
|
||
<span class="radio-card-name">精确匹配(Simple)</span>
|
||
<span class="radio-card-desc">仅精确命中才判为疑似,误杀最低</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="section-title">运行方式</div>
|
||
<div class="run-mode-zone">
|
||
<label class="run-mode-row">
|
||
<input type="radio" name="brand-run-mode" value="immediate" v-model="runMode" />
|
||
<span class="run-mode-text">立即执行(前台运行,可实时取消)</span>
|
||
</label>
|
||
<label class="run-mode-row">
|
||
<input type="radio" name="brand-run-mode" value="queue" v-model="runMode" />
|
||
<span class="run-mode-text">添加到任务队列(后台排队执行)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="run-row">
|
||
<button
|
||
type="button"
|
||
class="btn-run"
|
||
:disabled="!selectedPaths.length || submitting || !hasBridge || !hasUid"
|
||
@click="submitRun"
|
||
>
|
||
{{ submitting ? '提交中...' : runMode === 'immediate' ? '立即运行品牌检测' : '添加到任务队列' }}
|
||
</button>
|
||
</div>
|
||
<p v-if="!hasBridge || !hasUid" class="env-tip">
|
||
品牌检测在桌面客户端内执行:请在本机客户端登录后打开本页面使用(浏览器仅可预览界面)。
|
||
</p>
|
||
<p v-else class="loading-msg">
|
||
检测完成后自动生成结果 Excel(含原始数据、不符合品牌、查询失败品牌),到右侧任务列表下载。
|
||
</p>
|
||
|
||
<div class="section-title">文档模板</div>
|
||
<div class="btns">
|
||
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="downloadTemplate('xlsx')">下载 Excel 模板</button>
|
||
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="downloadTemplate('zip')">下载文件夹模板</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="right-panel">
|
||
<div class="panel-header">
|
||
<span>品牌检测任务</span>
|
||
<button type="button" class="btn-refresh" @click="loadTasks">刷新任务状态</button>
|
||
</div>
|
||
<div class="task-list-wrap">
|
||
<div v-if="!tasks.length" class="empty-tasks">
|
||
暂无品牌检测任务。选择待检文件后点击「运行」或「添加到任务队列」。
|
||
</div>
|
||
<ul v-else class="task-list">
|
||
<li v-for="item in tasks" :key="`brand-${item.id}`" class="task-item">
|
||
<div class="left">
|
||
<span class="id" :title="taskDesc(item)">{{ taskDesc(item) }}</span>
|
||
<div class="files">任务 ID:{{ item.id }}</div>
|
||
<div v-if="item.file_paths?.length" class="files">文件:{{ item.file_paths.length }} 个</div>
|
||
<div class="files">创建时间:{{ formatDateTime(item.created_at) }}</div>
|
||
<template v-if="isRunning(item)">
|
||
<div class="task-progress">
|
||
<div class="task-progress-meta">
|
||
<span>任务进度</span>
|
||
<span>{{ taskProgress(item) }}%</span>
|
||
</div>
|
||
<div class="task-progress-track">
|
||
<div class="task-progress-fill" :style="{ width: `${taskProgress(item)}%` }"></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="errorMessage(item)" class="files error">{{ errorMessage(item) }}</div>
|
||
</template>
|
||
<div v-else-if="errorMessage(item)" class="files error">错误:{{ errorMessage(item) }}</div>
|
||
</div>
|
||
<div class="task-right">
|
||
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span>
|
||
<button v-if="isRunning(item)" type="button" class="act-btn danger" @click="cancelTask(item)">取消</button>
|
||
<button v-if="canDownload(item)" type="button" class="act-btn ok" @click="downloadResult(item)">下载结果</button>
|
||
<button v-if="canDelete(item)" type="button" class="act-btn" @click="deleteTask(item)">删除</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</AmazonToolPageShell>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
|
||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||
import { requestPostJson } from '@/shared/api/http'
|
||
import {
|
||
cancelBrandTask,
|
||
createBrandTask,
|
||
deleteBrandTask,
|
||
getBrandTaskDownloadUrl,
|
||
getBrandTasks,
|
||
runBrandNow,
|
||
type BrandTaskItem,
|
||
} from '@/shared/api/brand'
|
||
|
||
interface BrandExpandFolderPathsResponse {
|
||
success: boolean
|
||
paths?: string[]
|
||
error?: string
|
||
}
|
||
|
||
const selectedPaths = ref<string[]>([])
|
||
const strategy = ref<'Terms' | 'Simple'>('Terms')
|
||
const runMode = ref<'immediate' | 'queue'>('immediate')
|
||
const submitting = ref(false)
|
||
const tasks = ref<BrandTaskItem[]>([])
|
||
|
||
// 品牌检测由桌面客户端本地执行:以"文件夹选择"桥(Web 降级桥不提供)判定桌面运行时,浏览器下仅可预览
|
||
const hasBridge = computed(() => Boolean(getPywebviewApi()?.select_brand_folder))
|
||
const hasUid = computed(() => {
|
||
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
|
||
const numeric = Number(raw.trim())
|
||
return Number.isFinite(numeric) && numeric > 0
|
||
})
|
||
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
|
||
|
||
function baseName(path: string) {
|
||
return path.split(/[\\/]/).pop() || path
|
||
}
|
||
|
||
let pollTimer: number | null = null
|
||
|
||
function hasRunning() {
|
||
return tasks.value.some((item) => isRunning(item))
|
||
}
|
||
|
||
function isRunning(item: BrandTaskItem) {
|
||
return (item.status || '').toLowerCase() === 'running'
|
||
}
|
||
|
||
function isTerminal(item: BrandTaskItem) {
|
||
const status = (item.status || '').toLowerCase()
|
||
return status === 'success' || status === 'failed' || status === 'cancelled'
|
||
}
|
||
|
||
function canDelete(item: BrandTaskItem) {
|
||
return !isRunning(item)
|
||
}
|
||
|
||
function canDownload(item: BrandTaskItem) {
|
||
const status = (item.status || '').toLowerCase()
|
||
return status === 'success' && Boolean(item.result_paths?.zip_url)
|
||
}
|
||
|
||
function taskProgress(item: BrandTaskItem) {
|
||
const total = Number(item.progress_total) || 0
|
||
const current = Number(item.progress_current) || 0
|
||
if (!total) return 0
|
||
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
|
||
}
|
||
|
||
function taskDesc(item: BrandTaskItem) {
|
||
const desc = (item.desc || '').trim()
|
||
return desc ? (desc.length > 60 ? `${desc.slice(0, 60)}…` : desc) : `任务 #${item.id}`
|
||
}
|
||
|
||
function errorMessage(item: BrandTaskItem) {
|
||
return (item.error_message || '').trim()
|
||
}
|
||
|
||
function statusText(status?: BrandTaskItem['status']) {
|
||
const value = (status || '').toLowerCase()
|
||
const map: Record<string, string> = {
|
||
pending: '等待中',
|
||
running: '执行中',
|
||
success: '已完成',
|
||
failed: '失败',
|
||
cancelled: '已取消',
|
||
}
|
||
return map[value] || String(status ?? '未知')
|
||
}
|
||
|
||
function statusClass(status?: BrandTaskItem['status']) {
|
||
const value = (status || '').toLowerCase()
|
||
if (value === 'running') return 'running'
|
||
if (value === 'success') return 'success'
|
||
if (value === 'failed') return 'failed'
|
||
if (value === 'cancelled') return 'cancelled'
|
||
return 'pending'
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
if (!value) return '-'
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return value
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
const hours = String(date.getHours()).padStart(2, '0')
|
||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||
}
|
||
|
||
function normalizeSelected(paths: string[]) {
|
||
selectedPaths.value = paths
|
||
.filter((path) => /\.xlsx$/i.test(path))
|
||
.filter((path, index, array) => array.indexOf(path) === index)
|
||
}
|
||
|
||
async function selectFiles() {
|
||
const bridge = getPywebviewApi()
|
||
if (!bridge?.select_brand_xlsx_files) {
|
||
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
|
||
return
|
||
}
|
||
try {
|
||
const paths = await bridge.select_brand_xlsx_files()
|
||
if (!paths?.length) return
|
||
normalizeSelected(paths)
|
||
ElMessage.success(`已选择 ${selectedPaths.value.length} 个 Excel 文件`)
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '文件选择失败')
|
||
}
|
||
}
|
||
|
||
async function selectFolder() {
|
||
const bridge = getPywebviewApi()
|
||
if (!bridge?.select_brand_folder) {
|
||
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
|
||
return
|
||
}
|
||
try {
|
||
const folder = await bridge.select_brand_folder()
|
||
if (!folder) return
|
||
const res = await requestPostJson<BrandExpandFolderPathsResponse>('/api/brand/expand-folder', { folder })
|
||
if (!res.success || !res.paths?.length) {
|
||
ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件')
|
||
return
|
||
}
|
||
normalizeSelected(res.paths)
|
||
ElMessage.success(`已选择文件夹内 ${selectedPaths.value.length} 个 Excel 文件`)
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '文件夹选择失败')
|
||
}
|
||
}
|
||
|
||
async function submitRun() {
|
||
if (!hasBridge.value) {
|
||
ElMessage.warning('请在桌面客户端中打开本页面执行品牌检测')
|
||
return
|
||
}
|
||
if (!hasUid.value) {
|
||
ElMessage.warning('未获取到登录用户,请先在本机客户端登录后再试')
|
||
return
|
||
}
|
||
if (!selectedPaths.value.length) {
|
||
ElMessage.warning('请先选择待检 Excel 文件')
|
||
return
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
if (runMode.value === 'immediate') {
|
||
const res = await runBrandNow(selectedPaths.value, strategy.value)
|
||
if (res.success && res.task_id) {
|
||
ElMessage.success(`已创建任务 ${res.task_id},客户端开始检测`)
|
||
} else {
|
||
throw new Error((res as { error?: string }).error || '运行失败')
|
||
}
|
||
} else {
|
||
const res = await createBrandTask(selectedPaths.value, strategy.value)
|
||
if (res.success && res.task_id) {
|
||
ElMessage.success(`任务 ${res.task_id} 已添加到队列`)
|
||
} else {
|
||
throw new Error((res as { error?: string }).error || '添加失败')
|
||
}
|
||
}
|
||
selectedPaths.value = []
|
||
await loadTasks()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '提交失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function loadTasks() {
|
||
try {
|
||
const res = await getBrandTasks()
|
||
if (res.success && Array.isArray(res.items)) {
|
||
tasks.value = res.items
|
||
}
|
||
} catch (error) {
|
||
// 浏览器预览(无 uid / 无桌面后端)时静默,避免误报
|
||
if (hasBridge.value && hasUid.value) {
|
||
ElMessage.error(error instanceof Error ? error.message : '任务列表加载失败')
|
||
}
|
||
}
|
||
}
|
||
|
||
async function cancelTask(item: BrandTaskItem) {
|
||
try {
|
||
await ElMessageBox.confirm(`确认取消任务 ${item.id}?`, '取消任务', { type: 'warning' })
|
||
} catch {
|
||
return
|
||
}
|
||
try {
|
||
const res = await cancelBrandTask(item.id)
|
||
if (!res.success) throw new Error((res as { error?: string }).error || '取消失败')
|
||
ElMessage.success('已取消')
|
||
await loadTasks()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '取消失败')
|
||
}
|
||
}
|
||
|
||
async function deleteTask(item: BrandTaskItem) {
|
||
try {
|
||
await ElMessageBox.confirm(`确认删除任务 ${item.id}?删除后不可恢复。`, '删除任务', { type: 'warning' })
|
||
} catch {
|
||
return
|
||
}
|
||
try {
|
||
const res = await deleteBrandTask(item.id)
|
||
if (!res.success) throw new Error((res as { error?: string }).error || '删除失败')
|
||
ElMessage.success('已删除')
|
||
await loadTasks()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
async function downloadResult(item: BrandTaskItem) {
|
||
const zipUrl = item.result_paths?.zip_url
|
||
const filename = `brand_task_${item.id}.zip`
|
||
const bridge = getPywebviewApi()
|
||
if (bridge?.save_file_from_url && zipUrl) {
|
||
try {
|
||
const res = await bridge.save_file_from_url(zipUrl, filename)
|
||
if (res.success) {
|
||
ElMessage.success(`已保存:${res.path || filename}`)
|
||
} else if (res.error && res.error !== '用户取消') {
|
||
ElMessage.error(res.error)
|
||
}
|
||
return
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '下载失败')
|
||
return
|
||
}
|
||
}
|
||
if (zipUrl) {
|
||
window.open(zipUrl, '_blank')
|
||
return
|
||
}
|
||
window.open(getBrandTaskDownloadUrl(item.id), '_blank')
|
||
}
|
||
|
||
async function downloadTemplate(kind: 'xlsx' | 'zip') {
|
||
const bridge = getPywebviewApi()
|
||
try {
|
||
let res: { success: boolean; path?: string; error?: string } | undefined
|
||
if (kind === 'xlsx') {
|
||
res = bridge?.save_template_xlsx ? await bridge.save_template_xlsx() : undefined
|
||
} else {
|
||
res = bridge?.save_template_zip ? await bridge.save_template_zip() : undefined
|
||
}
|
||
if (res?.success) {
|
||
ElMessage.success(`模板已保存至:${res.path}`)
|
||
} else if (res?.error) {
|
||
ElMessage.warning(res.error)
|
||
} else {
|
||
ElMessage.warning('当前环境不支持模板下载,请在本机客户端中打开')
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '模板下载失败')
|
||
}
|
||
}
|
||
|
||
function schedulePoll() {
|
||
if (pollTimer) {
|
||
window.clearTimeout(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
pollTimer = window.setTimeout(async () => {
|
||
await loadTasks()
|
||
if (hasRunning()) {
|
||
schedulePoll()
|
||
}
|
||
}, 3000)
|
||
}
|
||
|
||
onMounted(() => {
|
||
void loadTasks()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (pollTimer) {
|
||
window.clearTimeout(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.module-page { min-height: 100vh; background: #151a25; }
|
||
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
|
||
.left-panel { width: 420px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
|
||
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
|
||
.section-title { font-size: 13px; color: #a0acbe; margin: 12px 0 10px; }
|
||
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
|
||
.hint { margin-bottom: 12px; }
|
||
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #2e3a52; margin-bottom: 6px; }
|
||
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.run-row { margin-top: 14px; }
|
||
.opt-btn { padding: 8px 14px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 7px; cursor: pointer; font-size: 12px; }
|
||
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
|
||
.opt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.selected-files { margin-top: 12px; color: #a0acbe; font-size: 12px; word-break: break-all; }
|
||
.selected-files span { display: block; margin: 3px 0; }
|
||
.selected-files .more-line { color: #5e6878; }
|
||
.bridge-tip { margin-top: 10px; color: #e6a23c; font-size: 11px; }
|
||
.env-tip { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: rgba(230, 162, 60, 0.1); color: #e6a23c; font-size: 12px; line-height: 1.6; }
|
||
.radio-cards { display: flex; flex-direction: column; gap: 8px; margin-bottom: 6px; }
|
||
.radio-card { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #2e3a52; cursor: pointer; }
|
||
.radio-card.selected { border-color: #409eff; background: rgba(64, 158, 255, 0.1); }
|
||
.radio-card input { position: absolute; opacity: 0; pointer-events: none; }
|
||
.radio-card-name { color: #c8d2e2; font-size: 13px; }
|
||
.radio-card-desc { color: #5e6878; font-size: 11px; }
|
||
.run-mode-zone { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #2e3a52; }
|
||
.run-mode-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; color: #c8d2e2; font-size: 12px; }
|
||
.run-mode-row input { width: 15px; height: 15px; accent-color: #409eff; }
|
||
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #3498db; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
||
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.loading-msg { margin-top: 10px; }
|
||
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 600; }
|
||
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
||
.empty-tasks { color: #5e6878; font-size: 13px; padding: 28px; text-align: center; }
|
||
.task-list { list-style: none; margin: 0; padding: 0; }
|
||
.task-item { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 14px; margin-bottom: 8px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
|
||
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
|
||
.files.error, .error { color: #ff6b6b; }
|
||
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
|
||
.status.pending { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
|
||
.status.running { background: rgba(52, 152, 219, 0.18); 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: #a0acbe; }
|
||
.act-btn { padding: 5px 10px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
|
||
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
|
||
.act-btn.danger { background: rgba(231, 76, 60, 0.15); color: #ff8f8f; border-color: transparent; }
|
||
.task-progress { margin-top: 6px; max-width: 460px; }
|
||
.task-progress-meta { display: flex; justify-content: space-between; color: #5e6878; font-size: 11px; margin-bottom: 4px; }
|
||
.task-progress-track { height: 5px; border-radius: 3px; background: #333; overflow: hidden; }
|
||
.task-progress-fill { height: 100%; background: #3498db; transition: width 0.25s ease; }
|
||
@media (max-width: 1100px) {
|
||
.main-content { flex-direction: column; height: auto; }
|
||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
||
}
|
||
</style>
|