feat: complete web application — FastAPI backend + Vue 3 SPA frontend
- Full FastAPI backend with JWT auth, file management, processing pipeline, memory CRUD, barcode mappings, config management, cloud sync - Vue 3 + Element Plus frontend with dashboard, task history, HTTP logs, memory editor, barcode editor, config editor, sync page - HTTP request logging middleware with SQLite persistence - Task history tracking with progress and retry support - File metadata recording for upload/download operations - WebAuth section in config.ini for bcrypt password storage - Bug fix: logs.py count query returns tuple not dict Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,719 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- Top stats row -->
|
||||
<div class="stats-row animate-in">
|
||||
<div class="stat-card" v-for="stat in stats" :key="stat.label">
|
||||
<div class="stat-icon" :style="{ background: stat.bg }">
|
||||
<el-icon :size="20" :color="stat.color"><component :is="stat.icon" /></el-icon>
|
||||
</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 -->
|
||||
<div class="col-left">
|
||||
<!-- Upload zone -->
|
||||
<div class="card animate-in animate-in-delay-1">
|
||||
<div class="card-head">
|
||||
<h3>文件上传</h3>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="refreshFiles" :icon="Refresh">刷新</el-button>
|
||||
<el-button size="small" type="danger" plain @click="clearInput" :icon="Delete">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="drop-zone"
|
||||
:class="{ dragover: isDragOver, 'has-files': inputFiles.length > 0 }"
|
||||
@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 / 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-bar">
|
||||
<div class="upload-bar-fill" :style="{ width: uploadPct + '%' }"></div>
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<div v-if="inputFiles.length > 0" class="file-list">
|
||||
<div v-for="f in inputFiles" :key="f.name" class="file-item">
|
||||
<div class="file-icon">
|
||||
<el-icon :size="16" :color="f.name.endsWith('.xls') || f.name.endsWith('.xlsx') ? '#10b981' : '#6366f1'">
|
||||
<Document />
|
||||
</el-icon>
|
||||
</div>
|
||||
<span class="file-name">{{ f.name }}</span>
|
||||
<span class="file-size">{{ fmtSize(f.size) }}</span>
|
||||
<el-button type="danger" link size="small" @click.stop="delFile(f)">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="card animate-in animate-in-delay-2">
|
||||
<div class="card-head">
|
||||
<h3>处理操作</h3>
|
||||
</div>
|
||||
<div class="action-grid">
|
||||
<button class="action-btn primary" @click="runPipeline" :disabled="processing">
|
||||
<div class="action-icon">
|
||||
<svg width="22" height="22" 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">标准化转换</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="action-btn" @click="runMerge" :disabled="processing">
|
||||
<div class="action-icon secondary">
|
||||
<el-icon :size="20"><Files /></el-icon>
|
||||
</div>
|
||||
<div class="action-info">
|
||||
<span class="action-name">合并采购单</span>
|
||||
<span class="action-desc">汇总导出</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result files -->
|
||||
<div v-if="resultFiles.length > 0" class="card animate-in animate-in-delay-3">
|
||||
<div class="card-head">
|
||||
<h3>处理结果</h3>
|
||||
<el-tag type="success" size="small">{{ resultFiles.length }} 个文件</el-tag>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
<div v-for="f in resultFiles" :key="f.name" class="file-item result">
|
||||
<div class="file-icon success">
|
||||
<el-icon :size="16"><Document /></el-icon>
|
||||
</div>
|
||||
<span class="file-name">{{ f.name }}</span>
|
||||
<span class="file-size">{{ fmtSize(f.size) }}</span>
|
||||
<el-button type="primary" link size="small" @click="downloadFile(f)">
|
||||
<el-icon><Download /></el-icon> 下载
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Progress + Logs -->
|
||||
<div class="col-right">
|
||||
<!-- Progress -->
|
||||
<div class="card animate-in animate-in-delay-2">
|
||||
<div class="card-head">
|
||||
<h3>处理进度</h3>
|
||||
<el-tag v-if="currentTask" :type="statusType" size="small" effect="dark">
|
||||
{{ statusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTask" class="progress-area">
|
||||
<div class="progress-ring">
|
||||
<svg viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="42" fill="none" stroke="#e5e7eb" stroke-width="6"/>
|
||||
<circle
|
||||
cx="50" cy="50" r="42" fill="none"
|
||||
:stroke="statusColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
:stroke-dasharray="264"
|
||||
:stroke-dashoffset="264 - (264 * currentTask.progress / 100)"
|
||||
transform="rotate(-90 50 50)"
|
||||
style="transition: stroke-dashoffset 0.6s var(--ease-out)"
|
||||
/>
|
||||
</svg>
|
||||
<div class="progress-pct">{{ currentTask.progress }}%</div>
|
||||
</div>
|
||||
<p class="progress-msg">{{ currentTask.message }}</p>
|
||||
</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-3">
|
||||
<div class="card-head">
|
||||
<h3>处理日志</h3>
|
||||
<el-button size="small" link @click="logs.length = 0">清空</el-button>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Delete, Document, Close, Download, Grid, Files } 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 processing = ref(false)
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const logBox = ref<HTMLElement>()
|
||||
const inputFiles = ref<any[]>([])
|
||||
const resultFiles = ref<any[]>([])
|
||||
const fileStats = ref({ file_count: 0, total_size: 0 })
|
||||
|
||||
const currentTask = computed(() => ps.currentTask)
|
||||
const logs = computed(() => ps.logs)
|
||||
|
||||
const statusType = computed(() => {
|
||||
const m: Record<string, string> = { pending: 'info', running: 'warning', completed: 'success', failed: 'danger' }
|
||||
return m[currentTask.value?.status || ''] || 'info'
|
||||
})
|
||||
const statusColor = computed(() => {
|
||||
const m: Record<string, string> = { pending: '#6366f1', running: '#f59e0b', completed: '#10b981', failed: '#ef4444' }
|
||||
return m[currentTask.value?.status || ''] || '#6366f1'
|
||||
})
|
||||
const statusText = computed(() => {
|
||||
const m: Record<string, string> = { pending: '等待中', running: '运行中', completed: '已完成', failed: '已失败' }
|
||||
return m[currentTask.value?.status || ''] || ''
|
||||
})
|
||||
|
||||
const stats = computed(() => [
|
||||
{ label: '待处理', value: inputFiles.value.length, icon: Document, color: '#6366f1', bg: 'rgba(99,102,241,0.1)' },
|
||||
{ label: '已输出', value: resultFiles.value.length, icon: Files, color: '#10b981', bg: 'rgba(16,185,129,0.1)' },
|
||||
{ label: '存储文件', value: fileStats.value.file_count, icon: Grid, color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
|
||||
])
|
||||
|
||||
function fmtSize(b: number) {
|
||||
if (b < 1024) return b + ' B'
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'
|
||||
return (b / 1048576).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function fmtTime(i: number) {
|
||||
const d = new Date()
|
||||
d.setSeconds(d.getSeconds() - (logs.value.length - i))
|
||||
return d.toTimeString().slice(0, 8)
|
||||
}
|
||||
|
||||
function logCls(line: string) {
|
||||
if (line.includes('失败') || line.includes('错误')) return 'err'
|
||||
if (line.includes('完成')) return 'ok'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function refreshFiles() {
|
||||
try {
|
||||
const [inp, res] = await Promise.all([
|
||||
api.get('/files/list', { params: { directory: 'input' } }),
|
||||
api.get('/files/list', { params: { directory: 'result' } }),
|
||||
])
|
||||
inputFiles.value = inp.data
|
||||
resultFiles.value = res.data
|
||||
} catch {}
|
||||
loadFileStats()
|
||||
}
|
||||
|
||||
async function loadFileStats() {
|
||||
try {
|
||||
const res = await api.get('/files/stats')
|
||||
const dirs = res.data.directories || []
|
||||
const totalFiles = dirs.reduce((s: number, d: any) => s + d.file_count, 0)
|
||||
const totalSize = dirs.reduce((s: number, d: any) => s + d.total_size, 0)
|
||||
fileStats.value = { file_count: totalFiles, total_size: totalSize }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function clearInput() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定清空所有待处理文件?', '确认')
|
||||
await api.post('/files/clear/input')
|
||||
ElMessage.success('已清空')
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function triggerInput() { fileInput.value?.click() }
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
isDragOver.value = false
|
||||
if (e.dataTransfer?.files) await upload(Array.from(e.dataTransfer.files))
|
||||
}
|
||||
|
||||
async function handleSelect(e: Event) {
|
||||
const el = e.target as HTMLInputElement
|
||||
if (el.files) { await upload(Array.from(el.files)); el.value = '' }
|
||||
}
|
||||
|
||||
async function upload(files: File[]) {
|
||||
uploading.value = true
|
||||
uploadPct.value = 0
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', files[i])
|
||||
try {
|
||||
await api.post('/files/upload', fd, {
|
||||
onUploadProgress: (e) => { uploadPct.value = Math.round(((i + (e.loaded / (e.total || 1))) / files.length) * 100) },
|
||||
})
|
||||
} catch (err: any) {
|
||||
ElMessage.error(`上传失败: ${files[i].name}`)
|
||||
}
|
||||
}
|
||||
uploading.value = false
|
||||
ElMessage.success(`上传完成,共 ${files.length} 个文件`)
|
||||
refreshFiles()
|
||||
}
|
||||
|
||||
async function delFile(f: any) {
|
||||
try { await api.delete(`/files/${f.directory}/${f.name}`); refreshFiles() } catch {}
|
||||
}
|
||||
|
||||
function downloadFile(f: any) {
|
||||
const token = localStorage.getItem('token')
|
||||
window.open(`/api/files/download/${f.directory || 'result'}/${encodeURIComponent(f.name)}?token=${token}`, '_blank')
|
||||
}
|
||||
|
||||
async function doAction(endpoint: string) {
|
||||
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')
|
||||
const runMerge = () => doAction('/processing/merge')
|
||||
|
||||
watch(logs, async () => {
|
||||
await nextTick()
|
||||
if (logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
refreshFiles()
|
||||
loadFileStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* ── Stats row ── */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-light);
|
||||
transition: all 0.2s var(--ease-out);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Main grid ── */
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-head h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Drop zone ── */
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border-light);
|
||||
border-radius: 12px;
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s var(--ease-out);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.drop-zone:hover, .drop-zone.dragover {
|
||||
border-color: var(--amber-400);
|
||||
background: var(--amber-50);
|
||||
}
|
||||
|
||||
.drop-zone.dragover {
|
||||
transform: scale(1.01);
|
||||
box-shadow: 0 0 0 4px rgba(255,193,7,0.15);
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
color: #9ca3af;
|
||||
margin-bottom: 12px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.drop-zone:hover .drop-icon { color: var(--amber-500); }
|
||||
|
||||
.drop-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.drop-link {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ── Upload bar ── */
|
||||
.upload-bar {
|
||||
height: 4px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--amber-400), var(--amber-600));
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
/* ── File list ── */
|
||||
.file-list {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: rgba(99,102,241,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-icon.success {
|
||||
background: rgba(16,185,129,0.08);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── Action buttons ── */
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--ease-out);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.action-btn:hover:not(:disabled) {
|
||||
border-color: var(--amber-400);
|
||||
box-shadow: 0 0 0 3px rgba(255,193,7,0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
grid-column: 1 / -1;
|
||||
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover:not(:disabled) {
|
||||
box-shadow: 0 8px 24px rgba(15,23,42,0.3);
|
||||
}
|
||||
|
||||
.action-btn.primary .action-icon {
|
||||
background: rgba(255,193,7,0.15);
|
||||
color: var(--amber-400);
|
||||
}
|
||||
|
||||
.action-btn.primary .action-name { color: #fff; }
|
||||
.action-btn.primary .action-desc { color: rgba(255,255,255,0.5); }
|
||||
|
||||
.action-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-icon.secondary {
|
||||
background: rgba(99,102,241,0.08);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.action-name {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Progress ── */
|
||||
.progress-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.progress-ring {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.progress-ring svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.progress-pct {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.progress-msg {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── 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 {
|
||||
max-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
flex: 1;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: #0f1117;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
color: #94a3b8;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.log-line.err { color: #f87171; }
|
||||
.log-line.ok { color: #34d399; }
|
||||
|
||||
.log-time {
|
||||
color: #475569;
|
||||
margin-right: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user