feat: processing flow enhancement + responsive UI

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>
This commit is contained in:
2026-05-13 19:18:18 +08:00
parent 32af38fe2a
commit 7baf784a39
11 changed files with 585 additions and 101 deletions
+125 -54
View File
@@ -35,27 +35,34 @@
<div class="main-grid">
<!-- Left column: Progress + Logs -->
<div class="col-left">
<!-- Progress -->
<!-- Active tasks list -->
<div class="card progress-card animate-in animate-in-delay-1">
<div class="card-head">
<h3>处理进度</h3>
<el-tag v-if="currentTask" :type="statusType" size="small" effect="dark">
{{ statusText }}
<el-tag v-if="visibleTasks.length > 0" size="small" effect="dark">
{{ visibleTasks.length }} 个任务
</el-tag>
</div>
<div v-if="currentTask" class="progress-area">
<div class="progress-bar-wrapper">
<div class="progress-bar-track">
<div
class="progress-bar-fill"
:style="{ width: currentTask.progress + '%', background: statusColor }"
></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 class="progress-meta">
<span class="progress-pct" :style="{ color: statusColor }">{{ currentTask.progress }}%</span>
<span class="progress-msg">{{ currentTask.message }}</span>
</div>
</div>
<div v-else class="empty-state">
@@ -71,7 +78,10 @@
<div class="card log-card animate-in animate-in-delay-2">
<div class="card-head">
<h3>处理日志</h3>
<el-button size="small" link @click="clearLogs">清空</el-button>
<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">
@@ -207,41 +217,10 @@ const detailedStats = ref({
total_processed: 0,
})
const currentTask = computed(() => {
if (ps.taskSource !== 'sync') return ps.currentTask
return null
})
const logs = computed(() => ps.logs.slice(0, 10))
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: '#a1a1aa',
running: '#f97316',
completed: '#22c55e',
failed: '#ef4444',
}
return m[currentTask.value?.status || ''] || '#a1a1aa'
})
const statusText = computed(() => {
const m: Record<string, string> = {
pending: '等待中',
running: '运行中',
completed: '已完成',
failed: '已失败',
}
return m[currentTask.value?.status || ''] || ''
})
const visibleTasks = computed(() =>
ps.taskSource !== 'sync' ? ps.activeTaskList : []
)
const logs = computed(() => ps.logs.slice(0, 50))
const stats = computed(() => [
{
@@ -290,6 +269,29 @@ 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 {
@@ -400,11 +402,11 @@ const runPipeline = () => doAction('/processing/pipeline')
const runOcr = () => doAction('/processing/ocr-batch')
const runExcel = () => doAction('/processing/excel')
// Auto-refresh stats when task completes
// Auto-refresh stats when any task completes or fails
watch(
() => currentTask.value?.status,
(status) => {
if (status === 'completed' || status === 'failed') {
() => visibleTasks.value.map(t => t.status),
(statuses) => {
if (statuses.some(s => s === 'completed' || s === 'failed')) {
refreshStats()
}
}
@@ -693,6 +695,75 @@ onMounted(() => {
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;
+182 -3
View File
@@ -1,6 +1,6 @@
<template>
<el-container class="layout">
<el-aside :width="isCollapse ? '72px' : '240px'" class="sidebar">
<el-aside v-show="!isMobile" :width="isCollapse ? '72px' : '240px'" class="sidebar">
<!-- Logo -->
<div class="sidebar-logo" @click="isCollapse = !isCollapse">
<div class="logo-mark">
@@ -83,6 +83,9 @@
<!-- Header -->
<header class="topbar">
<div class="topbar-left">
<button v-if="isMobile" class="hamburger-btn" @click="mobileDrawer = true">
<el-icon :size="22"><MenuIcon /></el-icon>
</button>
<h2 class="page-title">{{ pageTitle }}</h2>
</div>
<div class="topbar-right">
@@ -122,6 +125,82 @@
</el-container>
</el-container>
<!-- Mobile sidebar drawer -->
<el-drawer
v-model="mobileDrawer"
direction="ltr"
size="260px"
:with-header="false"
class="mobile-drawer"
>
<div class="drawer-sidebar">
<!-- Logo -->
<div class="sidebar-logo">
<div class="logo-mark">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/>
<rect x="9" y="3" width="6" height="4" rx="1"/>
<path d="M9 14l2 2 4-4"/>
</svg>
</div>
<span class="logo-text">益选 OCR</span>
</div>
<!-- Navigation -->
<el-menu
:default-active="route.path"
:default-openeds="filesMenuOpen"
mode="vertical"
background-color="transparent"
text-color="var(--text-sidebar)"
active-text-color="#fafafa"
class="sidebar-nav"
router
@select="onMenuSelect"
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<template #title>处理中心</template>
</el-menu-item>
<el-sub-menu index="/files">
<template #title>
<el-icon><FolderOpened /></el-icon>
<span>文件处理</span>
</template>
<el-menu-item index="/files/orders">采购单</el-menu-item>
<el-menu-item index="/files/tables">表格处理</el-menu-item>
<el-menu-item index="/files/images">图片处理</el-menu-item>
</el-sub-menu>
<el-menu-item index="/tasks">
<el-icon><Timer /></el-icon>
<template #title>任务历史</template>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Notebook /></el-icon>
<template #title>日志中心</template>
</el-menu-item>
<el-menu-item index="/memory">
<el-icon><Memo /></el-icon>
<template #title>记忆库</template>
</el-menu-item>
<el-menu-item index="/barcodes">
<el-icon><Connection /></el-icon>
<template #title>条码映射</template>
</el-menu-item>
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<template #title>系统配置</template>
</el-menu-item>
<el-menu-item index="/sync">
<el-icon><Cloudy /></el-icon>
<template #title>云端同步</template>
</el-menu-item>
</el-menu>
</div>
</el-drawer>
<!-- Change password dialog -->
<el-dialog v-model="showPwd" title="修改密码" width="420px" :close-on-click-modal="false">
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="70px">
@@ -143,12 +222,12 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
import { ref, computed, reactive, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
HomeFilled, Memo, Connection, Setting, Cloudy, Timer, Notebook, FolderOpened,
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight, Menu as MenuIcon
} from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth'
import api from '../api'
@@ -158,6 +237,8 @@ const router = useRouter()
const authStore = useAuthStore()
const isCollapse = ref(false)
const isMobile = ref(window.innerWidth < 768)
const mobileDrawer = ref(false)
const showPwd = ref(false)
const pwdForm = reactive({ old_password: '', new_password: '', confirm_password: '' })
const pwdFormRef = ref<FormInstance>()
@@ -187,18 +268,35 @@ const isOnline = ref(navigator.onLine)
function updateOnlineStatus() {
isOnline.value = navigator.onLine
}
// Track viewport for mobile drawer
function updateMobileState() {
isMobile.value = window.innerWidth < 768
}
// Close drawer on route change
watch(() => route.path, () => {
mobileDrawer.value = false
})
onMounted(async () => {
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
window.addEventListener('resize', updateMobileState)
await authStore.fetchUser()
})
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
window.removeEventListener('resize', updateMobileState)
})
const filesMenuOpen = ['/files']
function onMenuSelect() {
mobileDrawer.value = false
}
const pageTitles: Record<string, string> = {
'/': '处理中心',
'/files/orders': '采购单',
@@ -416,6 +514,18 @@ async function changePassword() {
z-index: 10;
}
.topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 18px;
font-weight: 600;
@@ -423,6 +533,50 @@ async function changePassword() {
letter-spacing: -0.3px;
}
/* ── Hamburger button (mobile) ── */
.hamburger-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
background: transparent;
border-radius: 10px;
color: var(--text-primary);
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.hamburger-btn:hover {
background: var(--bg-hover);
}
/* ── Mobile drawer ── */
.mobile-drawer :deep(.el-drawer__body) {
padding: 0;
}
.drawer-sidebar {
background: #09090b;
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.drawer-sidebar .sidebar-logo {
padding: 20px 20px 24px;
}
.drawer-sidebar .sidebar-nav {
flex: 1;
padding: 0 12px;
border-right: none;
overflow: visible;
}
.user-chip {
display: flex;
align-items: center;
@@ -506,4 +660,29 @@ async function changePassword() {
.page-leave-to {
opacity: 0;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.content {
padding: 12px;
}
.topbar {
padding: 0 12px;
}
.page-title {
font-size: 16px;
}
.user-name {
display: none;
}
}
@media (max-width: 480px) {
.content {
padding: 8px;
}
.topbar {
height: 52px;
padding: 0 8px;
}
}
</style>
+31
View File
@@ -306,4 +306,35 @@ onUnmounted(cancelSearch)
font-size: 13px;
color: var(--text-muted);
}
/* ── 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>
+31
View File
@@ -453,4 +453,35 @@ onUnmounted(cancelSearch)
.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>
+16
View File
@@ -366,4 +366,20 @@ onMounted(loadData)
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
/* ── Responsive ── */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.header-actions {
flex-wrap: wrap;
gap: 6px;
}
.header-actions .el-button {
font-size: 12px;
}
}
</style>
+16
View File
@@ -335,4 +335,20 @@ onMounted(loadData)
.preview-image-wrap { flex:1; display:flex; align-items:center; justify-content:center; min-height:0 }
.preview-table-wrap { flex:1; overflow:auto; min-height:0; border:1px solid var(--border-light); border-radius:8px }
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
/* ── Responsive ── */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.header-actions {
flex-wrap: wrap;
gap: 6px;
}
.header-actions .el-button {
font-size: 12px;
}
}
</style>
+16
View File
@@ -337,4 +337,20 @@ onMounted(loadData)
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
/* ── Responsive ── */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.header-actions {
flex-wrap: wrap;
gap: 6px;
}
.header-actions .el-button {
font-size: 12px;
}
}
</style>