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,432 @@
|
||||
<template>
|
||||
<div class="tasks-page">
|
||||
<!-- Stats row -->
|
||||
<div class="stats-row animate-in">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(99,102,241,0.1)">
|
||||
<el-icon :size="20" color="#6366f1"><Timer /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ taskStats.total }}</span>
|
||||
<span class="stat-label">总任务数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(16,185,129,0.1)">
|
||||
<el-icon :size="20" color="#10b981"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ taskStats.completed }}</span>
|
||||
<span class="stat-label">成功</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(239,68,68,0.1)">
|
||||
<el-icon :size="20" color="#ef4444"><CircleClose /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ taskStats.failed }}</span>
|
||||
<span class="stat-label">失败</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
|
||||
<el-icon :size="20" color="#f59e0b"><Loading /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ taskStats.running }}</span>
|
||||
<span class="stat-label">运行中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main table card -->
|
||||
<div class="card animate-in animate-in-delay-1">
|
||||
<div class="card-head">
|
||||
<h3>任务历史</h3>
|
||||
<div class="card-actions">
|
||||
<el-select v-model="filterStatus" placeholder="状态" clearable size="small" style="width: 120px" @change="loadData">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="成功" value="completed" />
|
||||
<el-option label="失败" value="failed" />
|
||||
<el-option label="运行中" value="running" />
|
||||
</el-select>
|
||||
<el-select v-model="filterName" placeholder="类型" clearable size="small" style="width: 150px" @change="loadData">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="一键全流程" value="一键全流程处理" />
|
||||
<el-option label="批量OCR" value="批量OCR识别" />
|
||||
<el-option label="Excel处理" value="Excel标准化处理" />
|
||||
<el-option label="合并采购单" value="合并采购单" />
|
||||
</el-select>
|
||||
<el-input v-model="search" placeholder="搜索..." clearable size="small" style="width: 160px" @keyup.enter="loadData" @clear="loadData">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" v-loading="loading" stripe max-height="500" size="small" class="task-table">
|
||||
<el-table-column prop="id" label="ID" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="task-id">{{ row.id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="类型" width="150" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="status-tag" :class="row.status">{{ statusLabel(row.status) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.progress" :stroke-width="6" :status="row.status === 'completed' ? 'success' : row.status === 'failed' ? 'exception' : ''" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="消息" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">
|
||||
<span class="time-cell">{{ formatTime(row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.status === 'failed'" type="warning" link size="small" @click="retryTask(row)">重试</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-bar">
|
||||
<span class="pagination-info">共 {{ total }} 条记录</span>
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="loadData" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail dialog -->
|
||||
<el-dialog v-model="showDetailDialog" title="任务详情" width="700px" :close-on-click-modal="false">
|
||||
<div v-if="detailTask" class="task-detail">
|
||||
<div class="detail-meta">
|
||||
<div class="meta-item"><span class="meta-label">任务ID</span><span class="meta-value">{{ detailTask.id }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">类型</span><span class="meta-value">{{ detailTask.name }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">状态</span><span class="status-tag" :class="detailTask.status">{{ statusLabel(detailTask.status) }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">进度</span><span class="meta-value">{{ detailTask.progress }}%</span></div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailTask.result_files && detailTask.result_files.length > 0" class="detail-files">
|
||||
<h4>结果文件</h4>
|
||||
<div v-for="f in detailTask.result_files" :key="f" class="file-chip">{{ f }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-logs">
|
||||
<h4>执行日志</h4>
|
||||
<div class="log-box">
|
||||
<div v-if="detailTask.log_lines.length === 0" class="log-empty">暂无日志</div>
|
||||
<div v-for="(line, i) in detailTask.log_lines" :key="i" class="log-line" :class="logCls(line)">{{ line }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showDetailDialog = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
|
||||
import api from '../api'
|
||||
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const filterStatus = ref('')
|
||||
const filterName = ref('')
|
||||
const items = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const total = ref(0)
|
||||
|
||||
const taskStats = reactive({ total: 0, completed: 0, failed: 0, running: 0 })
|
||||
|
||||
const showDetailDialog = ref(false)
|
||||
const detailTask = ref<any>(null)
|
||||
|
||||
function statusLabel(s: string) {
|
||||
const m: Record<string, string> = { pending: '等待中', running: '运行中', completed: '成功', failed: '失败' }
|
||||
return m[s] || s
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
if (!iso) return '-'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function logCls(line: string) {
|
||||
if (line.includes('失败') || line.includes('错误') || line.includes('Error')) return 'err'
|
||||
if (line.includes('完成')) return 'ok'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/tasks', {
|
||||
params: { page: page.value, page_size: pageSize.value, status: filterStatus.value, name: filterName.value, search: search.value },
|
||||
})
|
||||
items.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} catch {
|
||||
ElMessage.error('加载任务历史失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await api.get('/tasks/stats')
|
||||
Object.assign(taskStats, res.data)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function showDetail(row: any) {
|
||||
detailTask.value = row
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
async function retryTask(row: any) {
|
||||
try {
|
||||
await api.post(`/tasks/${row.id}/retry`)
|
||||
ElMessage.success('重试任务已创建')
|
||||
loadData()
|
||||
loadStats()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.response?.data?.detail || '重试失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tasks-page {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 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;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
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: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-table {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-tag.completed {
|
||||
background: rgba(16,185,129,0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-tag.failed {
|
||||
background: rgba(239,68,68,0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-tag.running {
|
||||
background: rgba(245,158,11,0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.status-tag.pending {
|
||||
background: rgba(99,102,241,0.1);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Detail dialog */
|
||||
.task-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-files h4,
|
||||
.detail-logs h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-chip {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: rgba(16,185,129,0.08);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--success);
|
||||
margin: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
background: #0f1117;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
color: #475569;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
color: #94a3b8;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.log-line.err { color: #f87171; }
|
||||
.log-line.ok { color: #34d399; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user