Files
orc-order-v2/web/frontend/src/views/Tasks.vue
T

514 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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" @input="debouncedSearch" @clear="loadData">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
<el-button size="small" type="danger" @click="clearAllTasks">清除全部</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="180" 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>
<el-button type="danger" link size="small" @click="deleteTask(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-row-detail">
<span class="file-path-text">{{ f }}</span>
<el-button size="small" @click="copyPath(f)">复制路径</el-button>
</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, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Timer, CircleCheck, CircleClose, Loading, Search, Refresh } from '@element-plus/icons-vue'
import api from '../api'
import { useDebounce } from '../composables/useDebounce'
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 })
// Debounced search
const { debounced: debouncedSearch, cancel: cancelSearch } = useDebounce(loadData, 400)
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 {
ElMessage.error('加载统计数据失败')
}
}
function showDetail(row: any) {
detailTask.value = row
showDetailDialog.value = true
}
async function copyPath(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制路径')
} catch {
ElMessage.error('复制失败')
}
}
async function deleteTask(row: any) {
try {
await ElMessageBox.confirm(`确定删除任务 #${row.id}`, '确认删除')
await api.delete(`/tasks/${row.id}`)
ElMessage.success('已删除')
loadData()
loadStats()
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.response?.data?.detail || '删除失败')
}
}
async function clearAllTasks() {
try {
await ElMessageBox.confirm('确定清除所有任务历史记录?此操作不可撤销。', '确认清除', { type: 'warning' })
await api.delete('/tasks')
ElMessage.success('已清除所有任务历史')
loadData()
loadStats()
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.response?.data?.detail || '清除失败')
}
}
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()
})
onUnmounted(cancelSearch)
</script>
<style scoped>
.tasks-page {
width: 100%;
}
.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-row-detail {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: #f9fafb;
border: 1px solid var(--border-light);
border-radius: 6px;
margin-bottom: 6px;
}
.file-path-text {
font-size: 13px;
font-family: var(--font-mono);
color: var(--text-primary);
}
.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; }
/* ── Responsive ── */
@media (max-width: 768px) {
.stats-row {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.stat-card {
padding: 14px 16px;
}
.stat-value {
font-size: 20px;
}
.card-head {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.card-actions {
flex-wrap: wrap;
gap: 6px;
}
.card {
padding: 14px;
}
}
@media (max-width: 640px) {
.stats-row {
grid-template-columns: 1fr;
}
}
</style>