7baf784a39
Phase 2 - Processing flow:
- Multi-task monitoring: store supports concurrent task tracking
- Task retry: POST /api/tasks/{id}/retry re-runs failed tasks
- Dashboard multi-task cards with progress, error details, retry/dismiss
- Log panel expanded from 10 to 50 lines with "view all" link
Phase 3 - UI/UX:
- Mobile sidebar drawer (< 768px) with hamburger menu
- Layout responsive styles (768px, 480px breakpoints)
- Tasks/Logs pages responsive (stat cards, filters, columns)
- File views responsive (header wrap, button sizing)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
894 lines
21 KiB
Vue
894 lines
21 KiB
Vue
<template>
|
|
<div class="dashboard">
|
|
<!-- Top stats row -->
|
|
<div class="stats-row animate-in">
|
|
<div
|
|
v-if="statsLoading"
|
|
v-for="n in 4"
|
|
:key="'sk'+n"
|
|
class="stat-card"
|
|
>
|
|
<div class="skeleton skeleton-circle" style="width:44px;height:44px;border-radius:12px"></div>
|
|
<div class="stat-info" style="gap:4px">
|
|
<div class="skeleton skeleton-text" style="width:48px;height:24px"></div>
|
|
<div class="skeleton skeleton-text short" style="width:64px;height:14px"></div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="!statsLoading"
|
|
class="stat-card"
|
|
v-for="stat in stats"
|
|
:key="stat.label"
|
|
:class="{ clickable: stat.route }"
|
|
@click="stat.route && $router.push(stat.route)"
|
|
>
|
|
<div class="stat-icon" :style="{ background: stat.bg }">
|
|
<span class="stat-emoji">{{ stat.emoji }}</span>
|
|
</div>
|
|
<div class="stat-info">
|
|
<span class="stat-value">{{ stat.value }}</span>
|
|
<span class="stat-label">{{ stat.label }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="main-grid">
|
|
<!-- Left column: Progress + Logs -->
|
|
<div class="col-left">
|
|
<!-- Active tasks list -->
|
|
<div class="card progress-card animate-in animate-in-delay-1">
|
|
<div class="card-head">
|
|
<h3>处理进度</h3>
|
|
<el-tag v-if="visibleTasks.length > 0" size="small" effect="dark">
|
|
{{ visibleTasks.length }} 个任务
|
|
</el-tag>
|
|
</div>
|
|
|
|
<div v-if="visibleTasks.length > 0" class="task-cards">
|
|
<div v-for="task in visibleTasks" :key="task.task_id" class="task-card-item">
|
|
<div class="task-card-header">
|
|
<span class="task-name">{{ task.name }}</span>
|
|
<el-tag :type="statusTagType(task.status)" size="small">{{ statusLabel(task.status) }}</el-tag>
|
|
</div>
|
|
<el-progress v-if="task.status === 'running' || task.status === 'pending'" :percentage="task.progress" :stroke-width="8" />
|
|
<div v-if="task.message" class="task-message">{{ task.message }}</div>
|
|
<!-- Error display -->
|
|
<el-alert v-if="task.status === 'failed' && task.error" :title="task.error" type="error" show-icon :closable="false" class="task-error" />
|
|
<!-- Actions -->
|
|
<div class="task-card-actions">
|
|
<el-button v-if="task.status === 'failed'" type="warning" size="small" @click="handleRetry(task.task_id)">重试</el-button>
|
|
<el-button v-if="task.status === 'completed' || task.status === 'failed'" size="small" @click="handleDismiss(task.task_id)">关闭</el-button>
|
|
</div>
|
|
<!-- Log lines for this task -->
|
|
<div v-if="task.log_lines?.length" class="task-logs">
|
|
<div v-for="(log, i) in task.log_lines.slice(-50)" :key="i" class="log-line">{{ log }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="empty-state">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" stroke-width="1">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<polyline points="12,6 12,12 16,14"/>
|
|
</svg>
|
|
<p>等待任务启动</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs -->
|
|
<div class="card log-card animate-in animate-in-delay-2">
|
|
<div class="card-head">
|
|
<h3>处理日志</h3>
|
|
<div style="display:flex;gap:8px;align-items:center">
|
|
<el-button size="small" link @click="$router.push('/tasks')">查看全部日志</el-button>
|
|
<el-button size="small" link @click="clearLogs">清空</el-button>
|
|
</div>
|
|
</div>
|
|
<div ref="logBox" class="log-box">
|
|
<div v-if="logs.length === 0" class="empty-state small">
|
|
<p>暂无日志</p>
|
|
</div>
|
|
<div
|
|
v-for="(line, i) in logs"
|
|
:key="i"
|
|
class="log-line"
|
|
:class="logCls(line)"
|
|
>
|
|
<span class="log-time">{{ fmtTime(i) }}</span>
|
|
{{ line }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right column: Upload + Actions -->
|
|
<div class="col-right">
|
|
<!-- Upload zone -->
|
|
<div class="card animate-in animate-in-delay-1">
|
|
<div class="card-head">
|
|
<h3>文件上传</h3>
|
|
<el-button size="small" @click="refreshStats" :icon="Refresh">刷新</el-button>
|
|
</div>
|
|
|
|
<div
|
|
class="drop-zone"
|
|
:class="{ dragover: isDragOver }"
|
|
@dragover.prevent="isDragOver = true"
|
|
@dragleave="isDragOver = false"
|
|
@drop.prevent="handleDrop"
|
|
@click="triggerInput"
|
|
>
|
|
<div class="drop-icon">
|
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
|
<polyline points="17,8 12,3 7,8"/>
|
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
|
</svg>
|
|
</div>
|
|
<p class="drop-text">拖拽文件到此处或 <span class="drop-link">点击选择</span></p>
|
|
<p class="drop-hint">支持 JPG / PNG / BMP (自动OCR) / XLS / XLSX (自动处理)</p>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
multiple
|
|
accept=".jpg,.jpeg,.png,.bmp,.xls,.xlsx"
|
|
hidden
|
|
@change="handleSelect"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Upload progress -->
|
|
<div v-if="uploading" class="upload-section">
|
|
<div class="upload-info">
|
|
<span class="upload-filename">{{ uploadingName }}</span>
|
|
<span class="upload-pct">{{ uploadPct }}%</span>
|
|
</div>
|
|
<div class="upload-bar">
|
|
<div class="upload-bar-fill" :style="{ width: uploadPct + '%' }"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick actions -->
|
|
<div class="card animate-in animate-in-delay-2">
|
|
<div class="card-head">
|
|
<h3>快捷操作</h3>
|
|
</div>
|
|
<div class="action-grid">
|
|
<button class="action-btn" @click="runPipeline" :disabled="processing">
|
|
<div class="action-icon secondary">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="action-info">
|
|
<span class="action-name">一键全流程</span>
|
|
<span class="action-desc">OCR识别 → Excel处理 → 生成采购单</span>
|
|
</div>
|
|
</button>
|
|
<button class="action-btn" @click="runOcr" :disabled="processing">
|
|
<div class="action-icon secondary">
|
|
<el-icon :size="20"><Document /></el-icon>
|
|
</div>
|
|
<div class="action-info">
|
|
<span class="action-name">批量OCR识别</span>
|
|
<span class="action-desc">仅识别图片</span>
|
|
</div>
|
|
</button>
|
|
<button class="action-btn" @click="runExcel" :disabled="processing">
|
|
<div class="action-icon secondary">
|
|
<el-icon :size="20"><Grid /></el-icon>
|
|
</div>
|
|
<div class="action-info">
|
|
<span class="action-name">Excel数据处理</span>
|
|
<span class="action-desc">处理Excel生成采购单</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { Refresh, Document, Grid } from '@element-plus/icons-vue'
|
|
import { useProcessingStore } from '../stores/processing'
|
|
import api from '../api'
|
|
|
|
const ps = useProcessingStore()
|
|
|
|
const isDragOver = ref(false)
|
|
const uploading = ref(false)
|
|
const uploadPct = ref(0)
|
|
const uploadingName = ref('')
|
|
const processing = ref(false)
|
|
const fileInput = ref<HTMLInputElement>()
|
|
const logBox = ref<HTMLElement>()
|
|
const statsLoading = ref(true)
|
|
|
|
const detailedStats = ref({
|
|
input_images: 0,
|
|
output_excel: 0,
|
|
unprocessed_images: 0,
|
|
unprocessed_excel: 0,
|
|
completed_results: 0,
|
|
total_processed: 0,
|
|
})
|
|
|
|
const visibleTasks = computed(() =>
|
|
ps.taskSource !== 'sync' ? ps.activeTaskList : []
|
|
)
|
|
const logs = computed(() => ps.logs.slice(0, 50))
|
|
|
|
const stats = computed(() => [
|
|
{
|
|
label: '未处理图片',
|
|
value: detailedStats.value.unprocessed_images,
|
|
emoji: '🖼️',
|
|
bg: '#f4f4f5',
|
|
route: '/files/images',
|
|
},
|
|
{
|
|
label: '待处理Excel',
|
|
value: detailedStats.value.unprocessed_excel,
|
|
emoji: '📊',
|
|
bg: '#f4f4f5',
|
|
route: '/files/tables',
|
|
},
|
|
{
|
|
label: '已完成采购单',
|
|
value: detailedStats.value.completed_results,
|
|
emoji: '✅',
|
|
bg: '#f0fdf4',
|
|
route: '/files/orders',
|
|
},
|
|
{
|
|
label: '已处理总数',
|
|
value: detailedStats.value.total_processed,
|
|
emoji: '📦',
|
|
bg: '#f4f4f5',
|
|
route: null,
|
|
},
|
|
])
|
|
|
|
function fmtTime(i: number): string {
|
|
const d = new Date()
|
|
d.setSeconds(d.getSeconds() - (logs.value.length - i))
|
|
return d.toTimeString().slice(0, 8)
|
|
}
|
|
|
|
function logCls(line: string): string {
|
|
if (line.includes('失败') || line.includes('错误')) return 'err'
|
|
if (line.includes('完成')) return 'ok'
|
|
return ''
|
|
}
|
|
|
|
function clearLogs(): void {
|
|
ps.logs.splice(0)
|
|
}
|
|
|
|
function statusTagType(status: string): string {
|
|
const map: Record<string, string> = { pending: 'info', running: '', completed: 'success', failed: 'danger' }
|
|
return map[status] || 'info'
|
|
}
|
|
|
|
function statusLabel(status: string): string {
|
|
const map: Record<string, string> = { pending: '等待中', running: '运行中', completed: '已完成', failed: '失败' }
|
|
return map[status] || status
|
|
}
|
|
|
|
async function handleRetry(taskId: string): Promise<void> {
|
|
try {
|
|
await ps.retryTask(taskId)
|
|
ElMessage.success('已重新提交任务')
|
|
} catch {
|
|
ElMessage.error('重试失败')
|
|
}
|
|
}
|
|
|
|
function handleDismiss(taskId: string): void {
|
|
ps.removeTask(taskId)
|
|
}
|
|
|
|
async function refreshStats(): Promise<void> {
|
|
statsLoading.value = true
|
|
try {
|
|
const res = await api.get('/files/stats/detailed')
|
|
detailedStats.value = res.data
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
statsLoading.value = false
|
|
}
|
|
}
|
|
|
|
function triggerInput(): void {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
async function handleDrop(e: DragEvent): Promise<void> {
|
|
isDragOver.value = false
|
|
if (e.dataTransfer?.files) {
|
|
await upload(Array.from(e.dataTransfer.files))
|
|
}
|
|
}
|
|
|
|
async function handleSelect(e: Event): Promise<void> {
|
|
const el = e.target as HTMLInputElement
|
|
if (el.files) {
|
|
await upload(Array.from(el.files))
|
|
el.value = ''
|
|
}
|
|
}
|
|
|
|
function getTargetDir(fileName: string): string {
|
|
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
|
if (['jpg', 'jpeg', 'png', 'bmp'].includes(ext)) return 'input'
|
|
if (['xls', 'xlsx'].includes(ext)) return 'output'
|
|
return 'input'
|
|
}
|
|
|
|
function getFileTypeLabel(fileName: string): string {
|
|
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
|
if (['jpg', 'jpeg', 'png', 'bmp'].includes(ext)) return 'OCR'
|
|
if (['xls', 'xlsx'].includes(ext)) return 'Excel'
|
|
return ''
|
|
}
|
|
|
|
async function upload(files: File[]): Promise<void> {
|
|
uploading.value = true
|
|
uploadPct.value = 0
|
|
const uploadedFiles: { name: string; type: string }[] = []
|
|
const failedFiles: string[] = []
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i]
|
|
uploadingName.value = file.name
|
|
const target = getTargetDir(file.name)
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
try {
|
|
await api.post(`/files/upload?target=${target}`, fd, {
|
|
onUploadProgress: (e) => {
|
|
uploadPct.value = Math.round(
|
|
((i + (e.loaded / (e.total || 1))) / files.length) * 100
|
|
)
|
|
},
|
|
})
|
|
const typeLabel = getFileTypeLabel(file.name)
|
|
uploadedFiles.push({ name: file.name, type: typeLabel })
|
|
} catch (err: any) {
|
|
failedFiles.push(file.name)
|
|
}
|
|
}
|
|
uploading.value = false
|
|
uploadingName.value = ''
|
|
uploadPct.value = 0
|
|
refreshStats()
|
|
|
|
// Show upload results
|
|
if (uploadedFiles.length > 0) {
|
|
ElMessage.success(`${uploadedFiles.length} 个文件上传成功`)
|
|
}
|
|
if (failedFiles.length > 0) {
|
|
ElMessage.error(`${failedFiles.length} 个文件上传失败: ${failedFiles.join(', ')}`)
|
|
}
|
|
|
|
// Auto-process: pipeline for images, excel for Excel files
|
|
if (uploadedFiles.length > 0) {
|
|
const hasImages = uploadedFiles.some(f => f.type === 'OCR')
|
|
const hasExcel = uploadedFiles.some(f => f.type === 'Excel')
|
|
if (hasImages) {
|
|
await doAction('/processing/pipeline')
|
|
} else if (hasExcel) {
|
|
await doAction('/processing/excel')
|
|
}
|
|
}
|
|
}
|
|
|
|
async function doAction(endpoint: string): Promise<void> {
|
|
processing.value = true
|
|
try {
|
|
await ps.startTask(endpoint)
|
|
} catch (err: any) {
|
|
ElMessage.error(err.response?.data?.detail || '启动失败')
|
|
} finally {
|
|
processing.value = false
|
|
}
|
|
}
|
|
|
|
const runPipeline = () => doAction('/processing/pipeline')
|
|
const runOcr = () => doAction('/processing/ocr-batch')
|
|
const runExcel = () => doAction('/processing/excel')
|
|
|
|
// Auto-refresh stats when any task completes or fails
|
|
watch(
|
|
() => visibleTasks.value.map(t => t.status),
|
|
(statuses) => {
|
|
if (statuses.some(s => s === 'completed' || s === 'failed')) {
|
|
refreshStats()
|
|
}
|
|
}
|
|
)
|
|
|
|
// Auto-scroll log panel
|
|
watch(
|
|
logs,
|
|
async () => {
|
|
await nextTick()
|
|
if (logBox.value) {
|
|
logBox.value.scrollTop = logBox.value.scrollHeight
|
|
}
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
onMounted(() => {
|
|
refreshStats()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dashboard {
|
|
width: 100%;
|
|
}
|
|
|
|
/* ── Stats row ── */
|
|
.stats-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 12px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.stat-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 16px 18px;
|
|
background: var(--bg-card);
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--border-light);
|
|
transition: border-color 0.15s ease;
|
|
}
|
|
|
|
.stat-card:hover {
|
|
border-color: #d4d4d8;
|
|
}
|
|
|
|
.stat-card.clickable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.stat-card.clickable:hover {
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 1px var(--primary);
|
|
}
|
|
|
|
.stat-icon {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: var(--radius-sm);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.stat-emoji {
|
|
font-size: 20px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.stat-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
line-height: 1;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* ── Main grid ── */
|
|
.main-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 400px;
|
|
gap: 16px;
|
|
align-items: start;
|
|
}
|
|
|
|
/* ── Card ── */
|
|
.card {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-light);
|
|
border-radius: var(--radius);
|
|
padding: 20px;
|
|
margin-bottom: 12px;
|
|
transition: border-color 0.15s ease;
|
|
}
|
|
|
|
.card:hover {
|
|
border-color: #d4d4d8;
|
|
}
|
|
|
|
.card-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.card-head h3 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
/* ── Drop zone ── */
|
|
.drop-zone {
|
|
border: 1px dashed #d4d4d8;
|
|
border-radius: var(--radius);
|
|
padding: 32px 20px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
background: #fafafa;
|
|
}
|
|
|
|
.drop-zone:hover,
|
|
.drop-zone.dragover {
|
|
border-color: #a1a1aa;
|
|
background: #f4f4f5;
|
|
}
|
|
|
|
.drop-zone.dragover {
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.drop-icon {
|
|
color: #a1a1aa;
|
|
margin-bottom: 12px;
|
|
transition: color 0.15s;
|
|
}
|
|
|
|
.drop-zone:hover .drop-icon {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.drop-text {
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.drop-link {
|
|
color: var(--text-primary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.drop-hint {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
margin-top: 6px;
|
|
}
|
|
|
|
/* ── Upload progress ── */
|
|
.upload-section {
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.upload-info {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.upload-filename {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 240px;
|
|
}
|
|
|
|
.upload-pct {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
font-family: var(--font-mono);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.upload-bar {
|
|
height: 4px;
|
|
background: #f4f4f5;
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.upload-bar-fill {
|
|
height: 100%;
|
|
background: var(--primary);
|
|
border-radius: 999px;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
/* ── Action buttons ── */
|
|
.action-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(1, 1fr);
|
|
gap: 8px;
|
|
}
|
|
|
|
.action-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 14px 16px;
|
|
border: 1px solid #e4e4e7;
|
|
border-radius: var(--radius-sm);
|
|
background: #ffffff;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
text-align: left;
|
|
}
|
|
|
|
.action-btn:hover:not(:disabled) {
|
|
background: #f4f4f5;
|
|
border-color: #d4d4d8;
|
|
}
|
|
|
|
.action-btn:active:not(:disabled) {
|
|
background: #e4e4e7;
|
|
}
|
|
|
|
.action-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.action-icon {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: var(--radius-sm);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.action-icon.secondary {
|
|
background: #f4f4f5;
|
|
color: #525252;
|
|
}
|
|
|
|
.action-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.action-name {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.action-desc {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/* ── Task cards ── */
|
|
.task-cards {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.task-card-item {
|
|
border: 1px solid var(--border-light);
|
|
border-radius: var(--radius-sm);
|
|
padding: 14px 16px;
|
|
background: #fafafa;
|
|
transition: border-color 0.15s ease;
|
|
}
|
|
|
|
.task-card-item:hover {
|
|
border-color: #d4d4d8;
|
|
}
|
|
|
|
.task-card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.task-name {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.task-message {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.task-error {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.task-card-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.task-logs {
|
|
margin-top: 10px;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
background: #09090b;
|
|
border-radius: var(--radius-sm);
|
|
padding: 10px 12px;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.task-logs .log-line {
|
|
color: #a1a1aa;
|
|
padding: 0;
|
|
word-break: break-all;
|
|
}
|
|
|
|
/* ── Progress area ── */
|
|
.progress-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.progress-area {
|
|
padding: 4px 0;
|
|
}
|
|
|
|
.progress-bar-wrapper {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.progress-bar-track {
|
|
width: 100%;
|
|
height: 6px;
|
|
background: #f4f4f5;
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-bar-fill {
|
|
height: 100%;
|
|
border-radius: 999px;
|
|
transition: width 0.5s ease;
|
|
min-width: 0;
|
|
}
|
|
|
|
.progress-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.progress-pct {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
font-family: var(--font-mono);
|
|
line-height: 1;
|
|
}
|
|
|
|
.progress-msg {
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Empty state ── */
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 32px 0;
|
|
color: var(--text-muted);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.empty-state.small {
|
|
padding: 20px 0;
|
|
}
|
|
|
|
/* ── Logs ── */
|
|
.log-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: 600px;
|
|
}
|
|
|
|
.log-box {
|
|
flex: 1;
|
|
max-height: 600px;
|
|
min-height: 400px;
|
|
overflow-y: auto;
|
|
background: #09090b;
|
|
border-radius: var(--radius-sm);
|
|
padding: 14px;
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.log-line {
|
|
color: #a1a1aa;
|
|
padding: 1px 0;
|
|
}
|
|
|
|
.log-line.err {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.log-line.ok {
|
|
color: #22c55e;
|
|
}
|
|
|
|
.log-time {
|
|
color: #525252;
|
|
margin-right: 8px;
|
|
font-size: 11px;
|
|
user-select: none;
|
|
}
|
|
|
|
/* ── Responsive ── */
|
|
@media (max-width: 1024px) {
|
|
.stats-row {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.main-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.stats-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.action-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|