Explorar el Código

feat(任务中心): 添加解析任务中心,支持上传模板后追踪解析进度

- 前端新增 TaskCenter 组件(浮动按钮+任务面板)
- 前端新增 taskCenter store 管理任务状态和轮询
- 前端 API 新增任务中心和解析相关接口
- 后端分离上传和解析:上传仅保存文件,创建模板后手动触发解析
- 后端 ParseController 新增 /start/{documentId} 接口
- 上传文档时增加用户登录检查和文件类型验证
何文松 hace 1 mes
padre
commit
260d41b0ba

+ 43 - 3
backend/parse-service/src/main/java/com/lingyue/parse/controller/ParseController.java

@@ -25,7 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
  */
 @Slf4j
 @RestController
-@RequestMapping("/parse")
+@RequestMapping("/api/v1/parse")
 @RequiredArgsConstructor
 @Tag(name = "解析任务", description = "解析任务启动与状态查询接口")
 public class ParseController {
@@ -37,13 +37,13 @@ public class ParseController {
     private final DocumentElementService documentElementService;
 
     /**
-     * 启动解析
+     * 启动解析(传入文件路径)
      *
      * @param documentId 文档ID
      * @param filePath 原始文件路径
      */
     @PostMapping("/start")
-    @Operation(summary = "启动解析任务")
+    @Operation(summary = "启动解析任务(需传入文件路径)")
     public AjaxResult<?> startParse(
             @Parameter(description = "文档ID", required = true)
             @RequestParam("documentId") String documentId,
@@ -53,6 +53,46 @@ public class ParseController {
         parseTaskExecutor.submitParseTask(documentId, filePath);
         return AjaxResult.success("解析任务已提交");
     }
+    
+    /**
+     * 启动解析(仅需文档ID,自动获取文件路径)
+     *
+     * @param documentId 文档ID
+     */
+    @PostMapping("/start/{documentId}")
+    @Operation(summary = "启动解析任务", description = "根据文档ID启动解析,自动从数据库获取文件路径")
+    public AjaxResult<?> startParseByDocumentId(
+            @Parameter(description = "文档ID", required = true)
+            @PathVariable String documentId) {
+
+        // 从数据库获取文档信息
+        Document document = documentRepository.selectById(documentId);
+        if (document == null) {
+            return AjaxResult.error("文档不存在: " + documentId);
+        }
+        
+        String filePath = document.getFileUrl();
+        if (filePath == null || filePath.isEmpty()) {
+            return AjaxResult.error("文档文件路径为空");
+        }
+        
+        // 检查是否已在解析中
+        ParseTaskCenterVO existingTask = taskCenterService.getTaskDetailByDocumentId(documentId);
+        if (existingTask != null && "processing".equals(existingTask.getStatus())) {
+            return AjaxResult.error("该文档正在解析中,请勿重复提交");
+        }
+        
+        // 更新文档状态为解析中
+        document.setStatus("parsing");
+        document.setParseStatus("processing");
+        documentRepository.updateById(document);
+        
+        // 提交解析任务
+        parseTaskExecutor.submitParseTask(documentId, filePath);
+        log.info("解析任务已提交: documentId={}, filePath={}", documentId, filePath);
+        
+        return AjaxResult.success("解析任务已提交");
+    }
 
     /**
      * 查询解析状态(按文档ID)

+ 2 - 3
backend/parse-service/src/main/java/com/lingyue/parse/service/FileUploadService.java

@@ -73,7 +73,7 @@ public class FileUploadService {
         document.setUserId(userId);
         document.setName(file.getOriginalFilename());
         document.setType(mapFileTypeToDocType(fileType));
-        document.setStatus("parsing");
+        document.setStatus("uploaded");  // 状态改为已上传,等待解析
         document.setFileSize(file.getSize());
         document.setFileUrl(filePath);
         document.setParseStatus("pending");
@@ -81,8 +81,7 @@ public class FileUploadService {
         documentService.saveDocument(document);
         log.info("文档记录创建成功, documentId={}", documentId);
 
-        // 7. 提交解析任务(异步执行)
-        parseTaskExecutor.submitParseTask(documentId, filePath, fileType);
+        // 注意:不再自动触发解析,需要通过单独的接口手动触发
         
         // 8. 构建响应
         return FileUploadResponse.builder()

+ 6 - 0
frontend/vue-demo/src/App.vue

@@ -76,6 +76,10 @@
           <router-view />
         </main>
       </div>
+
+      <!-- 任务中心悬浮按钮和面板 -->
+      <TaskCenterFab />
+      <TaskCenterPanel />
     </div>
   </el-config-provider>
 </template>
@@ -86,6 +90,8 @@ import { useRouter, useRoute } from 'vue-router'
 import { Bell, HomeFilled, Files, Document, QuestionFilled } from '@element-plus/icons-vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
+import TaskCenterFab from '@/components/TaskCenter/TaskCenterFab.vue'
+import TaskCenterPanel from '@/components/TaskCenter/TaskCenterPanel.vue'
 
 const router = useRouter()
 const route = useRoute()

+ 43 - 0
frontend/vue-demo/src/api/index.js

@@ -282,4 +282,47 @@ export const authApi = {
   }
 }
 
+// ==================== 任务中心 API ====================
+
+export const taskCenterApi = {
+  // 获取任务列表
+  list(params = {}) {
+    return api.get('/tasks/list', { params })
+  },
+
+  // 获取任务详情
+  getById(taskId) {
+    return api.get(`/tasks/${taskId}/detail`)
+  },
+
+  // 根据文档ID获取任务详情
+  getByDocumentId(documentId) {
+    return api.get(`/tasks/by-document/${documentId}`)
+  },
+
+  // 获取任务统计
+  getStatistics() {
+    return api.get('/tasks/statistics')
+  },
+
+  // 删除任务
+  delete(taskId) {
+    return api.delete(`/tasks/${taskId}`)
+  }
+}
+
+// ==================== 解析 API ====================
+
+export const parseApi = {
+  // 启动文档解析
+  startParse(documentId) {
+    return api.post(`/parse/start/${documentId}`)
+  },
+
+  // 获取解析状态
+  getStatus(documentId) {
+    return api.get(`/parse/status/${documentId}`)
+  }
+}
+
 export default api

+ 138 - 0
frontend/vue-demo/src/components/TaskCenter/TaskCenterFab.vue

@@ -0,0 +1,138 @@
+<template>
+  <!-- 悬浮按钮 - 任务中心入口 -->
+  <div class="task-fab" :class="{ 'has-running': runningCount > 0, 'attention': attention }" @click="handleClick">
+    <div class="fab-icon">
+      <el-icon :size="24"><List /></el-icon>
+    </div>
+    <span v-if="runningCount > 0" class="fab-badge">{{ runningCount }}</span>
+    <div class="fab-ripple" v-if="attention"></div>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref, watch } from 'vue'
+import { List } from '@element-plus/icons-vue'
+import { useTaskCenterStore } from '@/stores/taskCenter'
+
+const store = useTaskCenterStore()
+const attention = ref(false)
+
+const runningCount = computed(() => store.runningCount)
+
+function handleClick() {
+  store.toggleOpen()
+}
+
+// 当有新任务开始时,显示注意动画
+watch(() => store.runningTotal, (newVal, oldVal) => {
+  if (newVal > oldVal) {
+    attention.value = true
+    setTimeout(() => {
+      attention.value = false
+    }, 2000)
+  }
+})
+</script>
+
+<style scoped>
+.task-fab {
+  position: fixed;
+  right: 24px;
+  bottom: 24px;
+  width: 56px;
+  height: 56px;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  z-index: 3999;
+}
+
+.task-fab:hover {
+  transform: scale(1.1);
+  box-shadow: 0 6px 28px rgba(102, 126, 234, 0.5);
+}
+
+.task-fab.has-running {
+  background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+  box-shadow: 0 4px 20px rgba(79, 172, 254, 0.4);
+  animation: pulse 2s infinite;
+}
+
+.task-fab.attention {
+  animation: bounce 0.5s ease-in-out;
+}
+
+.fab-icon {
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.fab-badge {
+  position: absolute;
+  top: -4px;
+  right: -4px;
+  min-width: 20px;
+  height: 20px;
+  padding: 0 6px;
+  border-radius: 10px;
+  background: #f56c6c;
+  color: #fff;
+  font-size: 12px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 2px 8px rgba(245, 108, 108, 0.4);
+}
+
+.fab-ripple {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  border-radius: 50%;
+  border: 2px solid rgba(255, 255, 255, 0.6);
+  animation: ripple 1s ease-out infinite;
+}
+
+@keyframes pulse {
+  0%, 100% {
+    box-shadow: 0 4px 20px rgba(79, 172, 254, 0.4);
+  }
+  50% {
+    box-shadow: 0 4px 30px rgba(79, 172, 254, 0.6);
+  }
+}
+
+@keyframes bounce {
+  0%, 100% {
+    transform: scale(1);
+  }
+  25% {
+    transform: scale(1.2);
+  }
+  50% {
+    transform: scale(0.95);
+  }
+  75% {
+    transform: scale(1.1);
+  }
+}
+
+@keyframes ripple {
+  0% {
+    transform: scale(1);
+    opacity: 1;
+  }
+  100% {
+    transform: scale(1.5);
+    opacity: 0;
+  }
+}
+</style>

+ 780 - 0
frontend/vue-demo/src/components/TaskCenter/TaskCenterPanel.vue

@@ -0,0 +1,780 @@
+<template>
+  <!-- 任务中心浮动面板 -->
+  <transition name="task-panel">
+    <div v-if="open" class="task-panel" :class="{ expanded: showDetail }">
+      <el-card class="task-panel-card" shadow="always">
+        <div class="task-panel-header">
+          <div class="title">
+            <span class="title-icon">📋</span>
+            任务中心
+          </div>
+          <el-button text :icon="Close" @click="handleClose" />
+        </div>
+
+        <el-tabs v-model="activeTab" class="task-tabs" @tab-change="handleTabChange">
+          <el-tab-pane label="全部" name="all" />
+          <el-tab-pane name="processing">
+            <template #label>
+              <span class="tab-label">
+                <span class="tab-text">运行中</span>
+                <span class="tab-count" v-if="statusTotals.processing > 0">{{ statusTotals.processing }}</span>
+              </span>
+            </template>
+          </el-tab-pane>
+          <el-tab-pane label="已完成" name="completed" />
+          <el-tab-pane name="failed">
+            <template #label>
+              <span class="tab-label">
+                <span class="tab-text">失败</span>
+                <span class="tab-count error" v-if="statusTotals.failed > 0">{{ statusTotals.failed }}</span>
+              </span>
+            </template>
+          </el-tab-pane>
+          <el-tab-pane name="pending">
+            <template #label>
+              <span class="tab-label">
+                <span class="tab-text">等待中</span>
+                <span class="tab-count" v-if="statusTotals.pending > 0">{{ statusTotals.pending }}</span>
+              </span>
+            </template>
+          </el-tab-pane>
+        </el-tabs>
+
+        <div class="task-body">
+          <!-- 左侧任务列表 -->
+          <div class="task-list" v-loading="listLoading">
+            <div
+              v-for="item in list"
+              :key="item.id"
+              class="task-card"
+              :class="[{ active: selectedId === item.id }, statusClass(item.status)]"
+              @click="handleSelect(item.id)"
+            >
+              <div class="task-title-row">
+                <div class="task-title">{{ item.name || `任务 ${item.id?.slice(0, 8)}` }}</div>
+                <el-tag size="small" :type="tagType(item.status)">{{ statusText(item.status) }}</el-tag>
+              </div>
+              
+              <div class="task-sub" v-if="item.documentId">
+                <span>文档: {{ item.documentId?.slice(0, 8) }}...</span>
+              </div>
+              
+              <div class="task-meta">
+                <div class="task-meta-left">
+                  <div class="task-time">{{ formatRelativeTime(item.createdAt || item.startedAt) }}</div>
+                  <template v-if="item.status === 'processing' && item.startedAt">
+                    <span class="task-meta-separator">·</span>
+                    <div class="task-duration-inline">
+                      <span class="task-duration-value">{{ formatElapsedTime(item.startedAt) }}</span>
+                    </div>
+                  </template>
+                </div>
+                <div class="task-meta-right">
+                  <span class="task-step" v-if="item.currentStep">{{ stepText(item.currentStep) }}</span>
+                </div>
+              </div>
+
+              <!-- 进度条区域 -->
+              <div class="task-progress-section" v-if="selectedId !== item.id">
+                <template v-if="item.status === 'processing' || item.status === 'pending'">
+                  <div class="task-progress-row" v-if="item.progress != null">
+                    <el-progress :percentage="item.progress" :stroke-width="6" />
+                    <span class="task-progress-percent">{{ item.progress }}%</span>
+                  </div>
+                </template>
+                <template v-else-if="item.status === 'failed'">
+                  <div class="task-progress-row" v-if="item.progress != null">
+                    <el-progress :percentage="item.progress" :stroke-width="6" status="exception" />
+                    <span class="task-progress-percent task-progress-percent--error">{{ item.progress }}%</span>
+                  </div>
+                </template>
+              </div>
+
+              <!-- 操作按钮 -->
+              <div class="task-actions">
+                <el-button
+                  text
+                  type="danger"
+                  size="small"
+                  :icon="Delete"
+                  @click.stop="handleDelete(item)"
+                  :disabled="item.status === 'processing'"
+                >
+                  删除
+                </el-button>
+              </div>
+            </div>
+            <el-empty v-if="!listLoading && (!list || list.length === 0)" description="暂无任务" />
+          </div>
+
+          <!-- 右侧任务详情 -->
+          <div class="task-detail" :class="{ show: showDetail }" v-loading="detailLoading">
+            <template v-if="detail">
+              <div class="detail-header">
+                <h3>{{ detail.name || `任务详情` }}</h3>
+                <el-tag :type="tagType(detail.status)">{{ statusText(detail.status) }}</el-tag>
+              </div>
+
+              <div class="detail-meta">
+                <span>开始时间:{{ formatDateTime(detail.startedAt) || '-' }}</span>
+                <span v-if="detail.completedAt">| 完成时间:{{ formatDateTime(detail.completedAt) }}</span>
+              </div>
+
+              <div class="detail-progress">
+                <el-progress :percentage="detail.progress ?? 0" :status="detail.status === 'failed' ? 'exception' : undefined" />
+                <div class="progress-info">
+                  <div class="current-stage" v-if="detail.currentStep">
+                    <span class="current-stage-label">当前阶段:</span>
+                    <span class="current-stage-value">{{ stepText(detail.currentStep) }}</span>
+                  </div>
+                </div>
+              </div>
+
+              <!-- 失败信息 -->
+              <template v-if="detail.status === 'failed' && detail.errorMessage">
+                <div class="detail-section">错误信息</div>
+                <div class="task-error-info">
+                  <div class="error-remark">{{ detail.errorMessage }}</div>
+                </div>
+              </template>
+
+              <!-- 执行阶段 -->
+              <div class="detail-section">执行阶段</div>
+              <div v-if="detail.stages && detail.stages.length" class="stages">
+                <div v-for="s in detail.stages" :key="s.stageName" class="stage-row">
+                  <div class="stage-name">
+                    <span class="stage-icon" :class="s.status">
+                      <el-icon v-if="s.status === 'completed'"><CircleCheckFilled /></el-icon>
+                      <el-icon v-else-if="s.status === 'in_progress'"><Loading /></el-icon>
+                      <el-icon v-else-if="s.status === 'failed'"><CircleCloseFilled /></el-icon>
+                      <el-icon v-else><Clock /></el-icon>
+                    </span>
+                    {{ s.displayName }}
+                  </div>
+                  <div class="stage-meta">
+                    <span :class="['stage-status', s.status]">{{ stageStatusText(s.status) }}</span>
+                    <span v-if="s.resultSummary" class="stage-result">{{ s.resultSummary }}</span>
+                  </div>
+                </div>
+              </div>
+              <el-empty v-else description="暂无阶段数据" :image-size="60" />
+            </template>
+            <template v-else>
+              <el-empty description="请选择任务查看详情" :image-size="80" />
+            </template>
+          </div>
+        </div>
+      </el-card>
+    </div>
+  </transition>
+</template>
+
+<script setup>
+import { computed, watch, onMounted } from 'vue'
+import { Close, Delete, CircleCheckFilled, CircleCloseFilled, Loading, Clock } from '@element-plus/icons-vue'
+import { useTaskCenterStore } from '@/stores/taskCenter'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+const store = useTaskCenterStore()
+
+const open = computed(() => store.open)
+const list = computed(() => store.list)
+const listLoading = computed(() => store.listLoading)
+const detailLoading = computed(() => store.detailLoading)
+const selectedId = computed(() => store.selectedId)
+const detail = computed(() => store.detail)
+const showDetail = computed(() => !!store.selectedId)
+const statusTotals = computed(() => store.statusTotals || { processing: 0, failed: 0, pending: 0 })
+
+const activeTab = computed({
+  get: () => store.activeTab,
+  set: (v) => store.setActiveTab(v)
+})
+
+function handleClose() {
+  store.close()
+}
+
+function handleTabChange() {
+  store.selectedId = null
+  store.detail = null
+  store.fetchList()
+}
+
+function handleSelect(id) {
+  if (store.selectedId === id) {
+    store.selectedId = null
+    store.detail = null
+    return
+  }
+  store.selectTask(id)
+}
+
+watch(
+  () => store.open,
+  (val) => {
+    if (val) {
+      store.selectedId = null
+      store.detail = null
+      store.fetchRunningCount()
+      store.fetchStatusTotals()
+      store.fetchList()
+      store.startListPolling()
+    } else {
+      store.stopListPolling()
+    }
+  }
+)
+
+onMounted(() => {
+  store.fetchList({ pageNum: 1, pageSize: 20 })
+})
+
+// 工具函数
+function tagType(status) {
+  if (status === 'processing') return 'primary'
+  if (status === 'completed') return 'success'
+  if (status === 'failed') return 'danger'
+  return 'info'
+}
+
+function statusClass(status) {
+  if (status === 'processing') return 'status-processing'
+  if (status === 'completed') return 'status-completed'
+  if (status === 'failed') return 'status-failed'
+  return 'status-pending'
+}
+
+function statusText(status) {
+  if (status === 'processing') return '运行中'
+  if (status === 'completed') return '已完成'
+  if (status === 'failed') return '失败'
+  return '等待中'
+}
+
+function stageStatusText(status) {
+  if (status === 'in_progress') return '进行中'
+  if (status === 'completed') return '已完成'
+  if (status === 'failed') return '失败'
+  return '等待中'
+}
+
+function stepText(step) {
+  const stepMap = {
+    'parsing': '文本解析',
+    'pdf_extraction': 'PDF提取',
+    'word_extraction': 'Word提取',
+    'excel_extraction': 'Excel提取',
+    'ocr': 'OCR识别',
+    'saving': '保存文本',
+    'layout_analysis': '版面分析',
+    'recording': '记录存储',
+    'completed': '已完成',
+    'failed': '失败'
+  }
+  return stepMap[step] || step
+}
+
+function formatRelativeTime(v) {
+  if (!v) return ''
+  const d = parseDateTime(v)
+  if (!d) return ''
+  const now = new Date()
+  const diffMs = now.getTime() - d.getTime()
+  const diffSec = Math.floor(diffMs / 1000)
+  if (diffSec < 60 && diffSec >= 0) return `${diffSec}秒前`
+  const diffMin = Math.floor(diffSec / 60)
+  if (diffMin < 60 && diffMin >= 0) return `${diffMin}分钟前`
+  const diffHour = Math.floor(diffMin / 60)
+  if (diffHour < 24 && diffHour >= 0) return `${diffHour}小时前`
+  return formatDateTime(v)
+}
+
+function formatElapsedTime(startTime) {
+  const start = parseDateTime(startTime)
+  if (!start) return '-'
+  const now = new Date()
+  const diffMs = Math.max(0, now.getTime() - start.getTime())
+  return formatMs(diffMs)
+}
+
+function formatDateTime(v) {
+  if (!v) return ''
+  if (typeof v === 'string') return v
+  return v.toLocaleString('zh-CN')
+}
+
+function parseDateTime(v) {
+  if (!v) return null
+  if (v instanceof Date) return v
+  if (typeof v !== 'string') return null
+  const s = v.replace(' ', 'T')
+  const d = new Date(s)
+  if (isNaN(d.getTime())) return null
+  return d
+}
+
+function formatMs(ms) {
+  if (ms == null) return '-'
+  const s = Math.floor(ms / 1000)
+  if (s < 60) return `${s}s`
+  const m = Math.floor(s / 60)
+  const rs = s % 60
+  if (m < 60) return `${m}m${rs}s`
+  const h = Math.floor(m / 60)
+  const rm = m % 60
+  return `${h}h${rm}m`
+}
+
+async function handleDelete(item) {
+  try {
+    await ElMessageBox.confirm(
+      `确认删除任务"${item.name || item.id}"吗?`,
+      '删除确认',
+      {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }
+    )
+    await store.deleteTask(item.id)
+    ElMessage.success('删除成功')
+  } catch (error) {
+    if (error !== 'cancel') {
+      ElMessage.error(error?.message || '删除失败')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.task-panel {
+  position: fixed;
+  right: 16px;
+  bottom: 16px;
+  width: 480px;
+  max-width: calc(100vw - 32px);
+  z-index: 4000;
+  transition: width 0.2s ease;
+}
+
+.task-panel.expanded {
+  width: 900px;
+}
+
+.task-panel-card {
+  border-radius: 12px;
+  border: 1px solid rgba(0, 0, 0, 0.06);
+  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
+}
+
+.task-panel-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.task-panel-header .title {
+  font-weight: 700;
+  font-size: 16px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.task-panel-header .title-icon {
+  font-size: 20px;
+}
+
+.task-tabs :deep(.el-tabs__item) {
+  font-weight: 600;
+}
+
+.tab-label {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.tab-count {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 18px;
+  height: 18px;
+  padding: 0 6px;
+  border-radius: 999px;
+  font-size: 12px;
+  color: #fff;
+  background: var(--el-color-primary);
+}
+
+.tab-count.error {
+  background: var(--el-color-danger);
+}
+
+.task-body {
+  display: flex;
+  gap: 0;
+  height: 480px;
+}
+
+.task-panel.expanded .task-body {
+  gap: 16px;
+}
+
+.task-list {
+  width: 100%;
+  overflow: auto;
+  padding-right: 6px;
+  transition: width 0.2s ease;
+}
+
+.task-panel.expanded .task-list {
+  width: 360px;
+  border-right: 1px solid #ebeef5;
+  padding-right: 16px;
+}
+
+.task-card {
+  padding: 12px;
+  border: 1px solid rgba(0, 0, 0, 0.06);
+  border-radius: 10px;
+  margin-bottom: 10px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+  background: #fff;
+}
+
+.task-card:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  transform: translateY(-1px);
+}
+
+.task-card.status-processing {
+  background-color: rgba(64, 158, 255, 0.05);
+  border-color: rgba(64, 158, 255, 0.2);
+}
+
+.task-card.status-completed {
+  background-color: rgba(103, 194, 58, 0.05);
+}
+
+.task-card.status-failed {
+  background-color: rgba(245, 108, 108, 0.05);
+  border-color: rgba(245, 108, 108, 0.2);
+}
+
+.task-card.status-pending {
+  background-color: rgba(144, 147, 153, 0.05);
+}
+
+.task-card.active {
+  border-color: var(--el-color-primary);
+  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.15);
+}
+
+.task-title-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  margin-bottom: 4px;
+}
+
+.task-title {
+  font-weight: 600;
+  font-size: 14px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.task-sub {
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 8px;
+}
+
+.task-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+}
+
+.task-meta-left {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #909399;
+}
+
+.task-meta-separator {
+  color: #c0c4cc;
+}
+
+.task-duration-inline {
+  display: flex;
+  align-items: center;
+}
+
+.task-duration-value {
+  font-weight: 600;
+  color: var(--el-color-primary);
+}
+
+.task-meta-right {
+  display: flex;
+  align-items: center;
+}
+
+.task-step {
+  font-size: 12px;
+  color: var(--el-color-primary);
+  font-weight: 500;
+}
+
+.task-progress-section {
+  margin-top: 8px;
+}
+
+.task-progress-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.task-progress-row :deep(.el-progress) {
+  flex: 1;
+}
+
+.task-progress-row :deep(.el-progress__text) {
+  display: none;
+}
+
+.task-progress-percent {
+  font-size: 12px;
+  color: #606266;
+  font-weight: 600;
+  min-width: 36px;
+  text-align: right;
+}
+
+.task-progress-percent--error {
+  color: var(--el-color-danger);
+}
+
+.task-actions {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  margin-top: 8px;
+  padding-top: 8px;
+  border-top: 1px solid rgba(0, 0, 0, 0.06);
+}
+
+.task-detail {
+  overflow: auto;
+  width: 0;
+  opacity: 0;
+  pointer-events: none;
+  transition: all 0.2s ease;
+}
+
+.task-detail.show {
+  width: calc(100% - 376px);
+  opacity: 1;
+  pointer-events: auto;
+  padding-left: 16px;
+}
+
+.detail-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.detail-header h3 {
+  margin: 0;
+  font-size: 16px;
+}
+
+.detail-meta {
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 12px;
+}
+
+.detail-progress {
+  padding: 12px;
+  border-radius: 8px;
+  border: 1px solid rgba(0, 0, 0, 0.06);
+  background: #fafafa;
+  margin-bottom: 16px;
+}
+
+.progress-info {
+  margin-top: 8px;
+}
+
+.current-stage {
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.current-stage-label {
+  color: #909399;
+}
+
+.current-stage-value {
+  color: var(--el-color-primary);
+  font-weight: 600;
+}
+
+.detail-section {
+  margin-top: 16px;
+  font-weight: 600;
+  margin-bottom: 8px;
+  font-size: 14px;
+}
+
+.task-error-info {
+  padding: 12px;
+  border-radius: 8px;
+  border: 1px solid rgba(245, 108, 108, 0.3);
+  background: rgba(245, 108, 108, 0.05);
+}
+
+.error-remark {
+  color: var(--el-color-danger);
+  font-size: 13px;
+  line-height: 1.6;
+  word-break: break-word;
+}
+
+.stages {
+  border: 1px solid #ebeef5;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.stage-row {
+  padding: 10px 12px;
+  border-bottom: 1px solid #ebeef5;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.stage-row:last-child {
+  border-bottom: none;
+}
+
+.stage-name {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+}
+
+.stage-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 20px;
+  height: 20px;
+}
+
+.stage-icon.completed {
+  color: var(--el-color-success);
+}
+
+.stage-icon.in_progress {
+  color: var(--el-color-primary);
+}
+
+.stage-icon.failed {
+  color: var(--el-color-danger);
+}
+
+.stage-icon.pending {
+  color: #c0c4cc;
+}
+
+.stage-meta {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+}
+
+.stage-status {
+  padding: 2px 8px;
+  border-radius: 4px;
+  background: #f5f7fa;
+}
+
+.stage-status.completed {
+  color: var(--el-color-success);
+  background: rgba(103, 194, 58, 0.1);
+}
+
+.stage-status.in_progress {
+  color: var(--el-color-primary);
+  background: rgba(64, 158, 255, 0.1);
+}
+
+.stage-status.failed {
+  color: var(--el-color-danger);
+  background: rgba(245, 108, 108, 0.1);
+}
+
+.stage-result {
+  color: #909399;
+}
+
+/* 动画 */
+.task-panel-enter-active,
+.task-panel-leave-active {
+  transition: all 0.2s ease;
+}
+
+.task-panel-enter-from,
+.task-panel-leave-to {
+  opacity: 0;
+  transform: translateY(10px);
+}
+
+@media (max-width: 768px) {
+  .task-panel {
+    width: calc(100vw - 32px);
+    left: 16px;
+    right: 16px;
+  }
+
+  .task-body {
+    flex-direction: column;
+    height: 60vh;
+  }
+
+  .task-panel.expanded .task-list {
+    width: 100%;
+    border-right: none;
+    border-bottom: 1px solid #ebeef5;
+    padding-bottom: 12px;
+    max-height: 200px;
+  }
+
+  .task-detail.show {
+    width: 100%;
+    padding-left: 0;
+    padding-top: 12px;
+  }
+}
+</style>

+ 228 - 0
frontend/vue-demo/src/stores/taskCenter.js

@@ -0,0 +1,228 @@
+import { defineStore } from 'pinia'
+import { taskCenterApi } from '@/api'
+
+export const useTaskCenterStore = defineStore('taskCenter', {
+  state: () => ({
+    open: false,
+    activeTab: 'all',
+    listLoading: false,
+    detailLoading: false,
+    list: [],
+    total: 0,
+    selectedId: null,
+    detail: null,
+    runningTotal: 0,
+    statusTotals: {
+      processing: 0,
+      failed: 0,
+      pending: 0
+    },
+    pollTimer: null,
+    listPollTimer: null
+  }),
+
+  getters: {
+    runningCount(state) {
+      return state.runningTotal || 0
+    }
+  },
+
+  actions: {
+    /**
+     * 任务开始时调用:打开任务中心并聚焦"运行中"
+     */
+    async notifyTaskStarted({ documentId } = {}) {
+      this.open = true
+      this.activeTab = 'processing'
+      this.selectedId = null
+      this.detail = null
+      this.fetchRunningCount()
+      this.fetchStatusTotals()
+      await this.fetchList({ pageNum: 1, pageSize: 20 })
+      this.startListPolling()
+
+      // 如果传入了 documentId,尝试选中该任务
+      if (documentId) {
+        // 等待一小段时间让任务创建完成
+        setTimeout(async () => {
+          try {
+            const detail = await taskCenterApi.getByDocumentId(documentId)
+            if (detail && detail.id) {
+              this.selectTask(detail.id)
+            }
+          } catch (e) {
+            console.warn('获取任务详情失败:', e)
+          }
+        }, 1000)
+      }
+    },
+
+    toggleOpen() {
+      this.open = !this.open
+      if (!this.open) {
+        this.stopPolling()
+        this.stopListPolling()
+      } else {
+        this.fetchList()
+        this.fetchStatusTotals()
+        this.startListPolling()
+      }
+    },
+
+    close() {
+      this.open = false
+      this.stopPolling()
+      this.stopListPolling()
+    },
+
+    setActiveTab(tab) {
+      this.activeTab = tab || 'all'
+    },
+
+    async fetchList({ pageNum = 1, pageSize = 20, silent = false } = {}) {
+      if (!silent) {
+        this.listLoading = true
+      }
+      try {
+        const params = {
+          status: this.activeTab === 'all' ? undefined : this.activeTab,
+          pageNum,
+          pageSize
+        }
+        const resp = await taskCenterApi.list(params)
+        // resp 是分页对象 { records, total, current, size }
+        this.list = resp?.records || []
+        this.total = resp?.total || 0
+      } catch (e) {
+        console.error('获取任务列表失败:', e)
+      } finally {
+        if (!silent) {
+          this.listLoading = false
+        }
+      }
+    },
+
+    async fetchRunningCount() {
+      try {
+        const resp = await taskCenterApi.list({
+          status: 'processing',
+          pageNum: 1,
+          pageSize: 1
+        })
+        this.runningTotal = resp?.total || 0
+      } catch (e) {
+        // 失败不影响主流程
+      }
+    },
+
+    async fetchStatusTotals() {
+      try {
+        const stats = await taskCenterApi.getStatistics()
+        this.statusTotals = {
+          processing: stats?.processing || 0,
+          failed: stats?.failed || 0,
+          pending: stats?.pending || 0
+        }
+      } catch (e) {
+        // 统计失败不影响主流程
+      }
+    },
+
+    async selectTask(taskId) {
+      if (!taskId) return
+      this.selectedId = taskId
+      await this.fetchDetail(taskId)
+      this.maybeStartPolling()
+    },
+
+    async fetchDetail(taskId, silent = false) {
+      if (!silent) {
+        this.detailLoading = true
+      }
+      try {
+        const resp = await taskCenterApi.getById(taskId)
+        this.detail = resp
+      } catch (e) {
+        console.error('获取任务详情失败:', e)
+      } finally {
+        if (!silent) {
+          this.detailLoading = false
+        }
+      }
+    },
+
+    maybeStartPolling() {
+      this.stopPolling()
+      if (!this.open) return
+      const status = this.detail?.status
+      if (status !== 'processing') return
+
+      this.pollTimer = setInterval(async () => {
+        if (!this.selectedId) return
+        try {
+          await this.fetchDetail(this.selectedId, true)
+          const s = this.detail?.status
+          if (s === 'completed' || s === 'failed') {
+            // 任务结束后刷新列表和统计
+            await this.fetchList({ pageNum: 1, pageSize: 20, silent: true })
+            this.fetchRunningCount()
+            this.fetchStatusTotals()
+            this.stopPolling()
+          }
+        } catch (e) {
+          // 轮询异常不停止
+        }
+      }, 3000)
+    },
+
+    stopPolling() {
+      if (this.pollTimer) {
+        clearInterval(this.pollTimer)
+        this.pollTimer = null
+      }
+    },
+
+    startListPolling(intervalMs = 5000) {
+      this.stopListPolling()
+      if (!this.open) return
+
+      this.listPollTimer = setInterval(async () => {
+        if (!this.open) {
+          this.stopListPolling()
+          return
+        }
+        try {
+          await this.fetchList({ pageNum: 1, pageSize: 20, silent: true })
+          this.fetchStatusTotals()
+        } catch (e) {
+          // 轮询异常不停止
+        }
+      }, intervalMs)
+    },
+
+    stopListPolling() {
+      if (this.listPollTimer) {
+        clearInterval(this.listPollTimer)
+        this.listPollTimer = null
+      }
+    },
+
+    async deleteTask(taskId) {
+      try {
+        await taskCenterApi.delete(taskId)
+        // 如果删除的是当前选中的任务,清除选中状态
+        if (this.selectedId === taskId) {
+          this.selectedId = null
+          this.detail = null
+        }
+        // 刷新列表和统计
+        await this.fetchList()
+        this.fetchRunningCount()
+        this.fetchStatusTotals()
+        return true
+      } catch (e) {
+        throw e
+      }
+    }
+  }
+})

+ 52 - 3
frontend/vue-demo/src/views/Home.vue

@@ -167,6 +167,8 @@
             class="upload-area"
             drag
             action="/api/v1/parse/upload"
+            :data="uploadData"
+            :before-upload="beforeUpload"
             :on-success="handleUploadSuccess"
             :on-error="handleUploadError"
             :show-file-list="true"
@@ -207,10 +209,12 @@ import { useRouter } from 'vue-router'
 import { Promotion, UploadFilled, CircleCheckFilled } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
 import { useTemplateStore } from '@/stores/template'
-import { templateApi } from '@/api'
+import { useTaskCenterStore } from '@/stores/taskCenter'
+import { templateApi, parseApi } from '@/api'
 
 const router = useRouter()
 const templateStore = useTemplateStore()
+const taskCenterStore = useTaskCenterStore()
 
 const aiInput = ref('')
 const thinkingMode = ref('deep')
@@ -219,6 +223,12 @@ const showCreateDialog = ref(false)
 
 // 用户信息
 const username = computed(() => localStorage.getItem('username') || '用户')
+const userId = computed(() => localStorage.getItem('userId') || '')
+
+// 上传时附带的数据
+const uploadData = computed(() => ({
+  userId: userId.value
+}))
 
 // 问候语
 const greeting = computed(() => {
@@ -317,6 +327,33 @@ async function handleCreateTemplate() {
   }
 }
 
+function beforeUpload(file) {
+  // 检查用户是否已登录
+  if (!userId.value) {
+    ElMessage.warning('请先登录后再上传文件')
+    router.push('/login')
+    return false
+  }
+  
+  // 检查文件类型(使用扩展名判断,更可靠)
+  const fileName = file.name.toLowerCase()
+  const allowedExtensions = ['.doc', '.docx', '.pdf']
+  const hasValidExtension = allowedExtensions.some(ext => fileName.endsWith(ext))
+  if (!hasValidExtension) {
+    ElMessage.error('仅支持 Word (.doc, .docx) 和 PDF 格式')
+    return false
+  }
+  
+  // 检查文件大小(最大 100MB)
+  const maxSize = 100 * 1024 * 1024
+  if (file.size > maxSize) {
+    ElMessage.error('文件大小不能超过 100MB')
+    return false
+  }
+  
+  return true
+}
+
 function handleUploadSuccess(response) {
   if (response.code === 200) {
     uploadTemplate.baseDocumentId = response.data.id
@@ -342,15 +379,27 @@ async function handleUploadTemplate() {
 
   uploading.value = true
   try {
+    const documentId = uploadTemplate.baseDocumentId
     const template = await templateStore.createTemplate({
       name: uploadTemplate.name,
       description: uploadTemplate.description || '',
-      baseDocumentId: uploadTemplate.baseDocumentId
+      baseDocumentId: documentId
     })
     showUploadDialog.value = false
     // 重置表单
     Object.assign(uploadTemplate, { name: '', description: '', baseDocumentId: '' })
-    ElMessage.success('模板创建成功')
+    
+    // 触发文档解析
+    try {
+      await parseApi.startParse(documentId)
+      ElMessage.success('模板创建成功,正在解析文档...')
+      // 打开任务中心并追踪此文档的解析任务
+      taskCenterStore.notifyTaskStarted({ documentId })
+    } catch (parseError) {
+      console.error('启动解析失败:', parseError)
+      ElMessage.warning('模板创建成功,但解析启动失败,请稍后手动触发')
+    }
+    
     router.push(`/editor/${template.id}`)
   } catch (error) {
     ElMessage.error('创建失败: ' + error.message)