AgentCPM前端交互开发:使用Vue.js构建研报生成与管理界面

你是不是也遇到过这样的场景?后端同事开发了一个强大的AgentCPM深度研报助手,功能很酷,能自动分析数据、生成专业报告。但每次想用,都得在命令行里敲代码,或者等后端同事给你一个简陋的测试页面。对于非技术同事来说,这门槛就太高了。

这时候,一个直观、易用的Web管理界面就显得至关重要。它能将复杂的AI能力包装成简单的按钮和表单,让产品、运营、甚至业务部门的同事都能轻松上手,真正把AI工具用起来。

今天,我就带你从零开始,用Vue.js为AgentCPM研报助手搭建一个完整的前端交互界面。我们会一步步实现项目搭建、接口对接、任务状态实时追踪、研报结果展示与下载,以及基础的权限控制。即使你Vue.js刚入门,跟着这篇教程也能做出一个像模像样的管理后台。

1. 环境准备与项目初始化

工欲善其事,必先利其器。我们先来把开发环境准备好。

首先,确保你的电脑上已经安装了Node.js(建议版本16或以上)和npm。打开终端,输入 node -vnpm -v 检查一下。如果还没安装,去Node.js官网下载安装包,一路下一步就行。

接下来,我们用Vue官方推荐的脚手架工具Vite来创建项目,它比传统的Vue CLI更快、更轻量。

# 使用npm创建Vue项目
npm create vue@latest agentcpm-frontend

# 创建过程中,命令行会交互式地询问你配置选项
# 这里是我的选择,你可以参考:
# ✔ Project name: agentcpm-frontend
# ✔ Add TypeScript? No (为了教程简单,我们先不用TS)
# ✔ Add JSX Support? No
# ✔ Add Vue Router for Single Page Application? Yes (我们需要路由)
# ✔ Add Pinia for state management? Yes (状态管理很有用)
# ✔ Add Vitest for Unit Testing? No (教程先跳过测试)
# ✔ Add an End-to-End Testing Solution? No
# ✔ Add ESLint for code quality? Yes (代码规范很重要)

# 创建完成后,进入项目目录并安装依赖
cd agentcpm-frontend
npm install

# 安装我们需要的额外依赖
npm install axios pinia-plugin-persistedstate element-plus @element-plus/icons-vue
# axios: 用于HTTP请求
# pinia-plugin-persistedstate: 让Pinia状态持久化(比如记住登录状态)
# element-plus & icons-vue: UI组件库,让我们快速搭建美观的界面

安装完成后,我们先简单配置一下Element Plus,让它能在项目里用起来。修改 src/main.js 文件:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

import App from './App.vue'
import router from './router'

import './assets/main.css'

const app = createApp(App)

// 配置Pinia及持久化插件
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)

// 配置Element Plus
app.use(ElementPlus)
// 全局注册所有图标(方便使用)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.use(router)

app.mount('#app')

好了,基础环境搭建完毕。运行 npm run dev,在浏览器打开 http://localhost:5173,你应该能看到Vue的欢迎页面。我们的画布已经准备好了,接下来开始绘制。

2. 核心概念:前端如何与AI后端“对话”

在动手写代码前,我们先花几分钟理解一下前端在这个系统里的角色。你可以把前端想象成一个“翻译官”和“调度员”。

翻译官:用户在前端页面输入“帮我分析一下上季度新能源汽车市场趋势”,前端需要把这句话,连同用户可能选择的“报告类型”、“深度级别”等参数,打包成一个后端API能理解的JSON格式的“请求”,通过HTTP发送出去。

调度员:后端AgentCPM接到任务后,开始“思考”和“撰写”,这可能需要几十秒甚至几分钟。前端不能干等着,它需要不断向后端询问:“任务完成了吗?到哪一步了?”。这就是轮询。一旦后端回复“完成了,这是报告”,前端又要负责把报告内容(可能是HTML、Markdown或PDF链接)漂亮地展示给用户,并提供下载按钮。

整个流程的核心就是HTTP通信。我们使用Axios这个库来负责所有的网络请求,因为它好用、功能全。接下来,我们就来封装这个“通信模块”。

3. 封装Axios:打造健壮的请求层

直接在组件里写 axios.get(‘/api/xxx’) 不是好习惯,代码会散落各处,难以维护和统一处理错误。我们把它封装起来。

src 目录下新建一个 utils 文件夹,然后创建 request.js 文件:

import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'

// 1. 创建axios实例,配置基础URL和超时时间
const service = axios.create({
  baseURL: import.meta.env.VITE_APP_BASE_API || '/api', // 从环境变量读取,默认是 /api
  timeout: 30000, // 30秒超时,对于生成任务可以设长一点
})

// 2. 请求拦截器:在发送请求前做点什么
service.interceptors.request.use(
  (config) => {
    // 从Pinia store获取用户token(如果已登录)
    const userStore = useUserStore()
    if (userStore.token) {
      config.headers['Authorization'] = `Bearer ${userStore.token}`
    }
    // 可以在这里统一添加其他header,如Content-Type
    config.headers['Content-Type'] = 'application/json'
    return config
  },
  (error) => {
    // 对请求错误做些什么
    console.error('Request error:', error)
    return Promise.reject(error)
  }
)

// 3. 响应拦截器:在收到响应后做点什么
service.interceptors.response.use(
  (response) => {
    // 如果后端有统一的成功响应格式,比如 { code: 200, data: ..., message: 'success' }
    const res = response.data
    // 假设成功code为200
    if (res.code === 200) {
      return res.data // 直接返回真正的数据部分
    } else {
      // 业务逻辑错误(如参数错误、权限不足)
      ElMessage({
        message: res.message || 'Error',
        type: 'error',
        duration: 5 * 1000,
      })
      // 如果是401未授权,跳转到登录页
      if (res.code === 401) {
        const userStore = useUserStore()
        userStore.logout()
        router.push('/login')
      }
      return Promise.reject(new Error(res.message || 'Error'))
    }
  },
  (error) => {
    // HTTP状态码错误(如404, 500)
    console.error('Response error:', error)
    let message = '请求失败'
    if (error.response) {
      // 服务器有响应,但状态码不在2xx范围
      switch (error.response.status) {
        case 401:
          message = '登录已过期,请重新登录'
          const userStore = useUserStore()
          userStore.logout()
          router.push('/login')
          break
        case 403:
          message = '没有权限访问此资源'
          break
        case 404:
          message = '请求的资源不存在'
          break
        case 500:
          message = '服务器内部错误'
          break
        default:
          message = `网络错误: ${error.response.status}`
      }
    } else if (error.request) {
      // 请求发出了,但没有收到响应(网络断开、后端服务挂了)
      message = '网络连接异常,请检查后重试'
    } else {
      // 请求配置出错
      message = error.message
    }
    ElMessage({
      message,
      type: 'error',
      duration: 5 * 1000,
    })
    return Promise.reject(error)
  }
)

export default service

这个封装做了几件重要的事:统一处理了请求头(比如自动加token)、统一处理了响应成功和失败(包括网络错误和业务逻辑错误)、以及用Element Plus的Message组件给了用户友好的错误提示。

接下来,基于这个 service,我们创建专门与AgentCPM后端交互的API模块。在 src 下新建 api 文件夹,创建 report.js

import request from '@/utils/request'

// 研报任务相关的API
export function createReportTask(data) {
  // 创建新的研报生成任务
  return request({
    url: '/report/task',
    method: 'post',
    data, // data可能包含:topic(主题), reportType(报告类型), depth(深度)等
  })
}

export function getReportTaskList(params) {
  // 获取研报任务列表,支持分页和筛选
  return request({
    url: '/report/tasks',
    method: 'get',
    params, // params可能包含:page, pageSize, status, keyword等
  })
}

export function getReportTaskDetail(taskId) {
  // 获取单个任务的详细信息,包括实时状态
  return request({
    url: `/report/task/${taskId}`,
    method: 'get',
  })
}

export function cancelReportTask(taskId) {
  // 取消一个正在运行的任务
  return request({
    url: `/report/task/${taskId}/cancel`,
    method: 'post',
  })
}

export function downloadReport(taskId, format = 'pdf') {
  // 下载已完成的研报,format可以是 pdf/html/markdown
  // 注意:文件下载可能需要特殊处理,这里假设后端返回文件流
  return request({
    url: `/report/task/${taskId}/download`,
    method: 'get',
    params: { format },
    responseType: 'blob', // 重要!告诉axios期待二进制数据
  })
}

// 用户认证相关的API(为后面的权限控制做准备)
export function login(data) {
  return request({
    url: '/auth/login',
    method: 'post',
    data,
  })
}

export function getUserInfo() {
  return request({
    url: '/auth/userinfo',
    method: 'get',
  })
}

API层也封装好了,前后端通信的桥梁已经搭稳。现在,我们需要一个“中央仓库”来管理这些异步请求带来的数据状态,比如当前用户信息、任务列表。这就是Pinia的用武之地。

4. 状态管理:用Pinia管理用户与任务状态

src/stores 目录下,我们先创建一个管理用户状态的store,user.js

import { defineStore } from 'pinia'
import { login as apiLogin, getUserInfo as apiGetUserInfo } from '@/api/report'
import router from '@/router'

export const useUserStore = defineStore(
  'user',
  {
    state: () => ({
      token: '',
      userInfo: null, // 用户信息,如用户名、角色、权限列表
    }),
    getters: {
      isLoggedIn: (state) => !!state.token,
      userName: (state) => state.userInfo?.username || '',
    },
    actions: {
      // 登录动作
      async login(loginForm) {
        try {
          const res = await apiLogin(loginForm)
          this.token = res.token // 假设返回数据里有token
          // 登录成功后,获取用户详情
          await this.getUserInfo()
          // 跳转到首页
          router.push('/')
          return Promise.resolve()
        } catch (error) {
          return Promise.reject(error)
        }
      },
      // 获取用户信息
      async getUserInfo() {
        try {
          const res = await apiGetUserInfo()
          this.userInfo = res
          return Promise.resolve(res)
        } catch (error) {
          return Promise.reject(error)
        }
      },
      // 登出动作
      logout() {
        this.token = ''
        this.userInfo = null
        // 清除本地存储的token(如果用了持久化插件)
        // 跳转到登录页
        router.push('/login')
      },
    },
    persist: true, // 使用插件,状态将持久化到localStorage
  }
)

接下来,创建管理研报任务的store,report.js

import { defineStore } from 'pinia'
import {
  getReportTaskList,
  createReportTask,
  getReportTaskDetail,
  cancelReportTask,
} from '@/api/report'

export const useReportStore = defineStore('report', {
  state: () => ({
    taskList: [], // 任务列表
    total: 0, // 总任务数,用于分页
    currentTask: null, // 当前查看/正在生成的任务详情
    loading: false, // 列表加载状态
  }),
  actions: {
    // 获取任务列表
    async fetchTaskList(params = { page: 1, pageSize: 10 }) {
      this.loading = true
      try {
        const res = await getReportTaskList(params)
        this.taskList = res.list || []
        this.total = res.total || 0
        return Promise.resolve(res)
      } catch (error) {
        return Promise.reject(error)
      } finally {
        this.loading = false
      }
    },
    // 创建新任务
    async createNewTask(taskData) {
      try {
        const res = await createReportTask(taskData)
        // 创建成功后,可以选择重新获取列表,或者直接在前端添加
        // this.fetchTaskList()
        return Promise.resolve(res)
      } catch (error) {
        return Promise.reject(error)
      }
    },
    // 获取任务详情
    async fetchTaskDetail(taskId) {
      try {
        const res = await getReportTaskDetail(taskId)
        this.currentTask = res
        return Promise.resolve(res)
      } catch (error) {
        return Promise.reject(error)
      }
    },
    // 取消任务
    async cancelTask(taskId) {
      try {
        await cancelReportTask(taskId)
        // 取消后更新本地任务状态
        const task = this.taskList.find(t => t.id === taskId)
        if (task) {
          task.status = 'cancelled'
        }
        if (this.currentTask?.id === taskId) {
          this.currentTask.status = 'cancelled'
        }
        return Promise.resolve()
      } catch (error) {
        return Promise.reject(error)
      }
    },
  },
})

状态管理中心也准备好了。现在,让我们把这些模块组装到具体的页面里,从登录和主页开始。

5. 构建页面:登录、主页与任务管理

5.1 登录页面

修改 src/views/LoginView.vue (如果使用Vue Router创建时选了文件路由,它可能已存在):

<template>
  <div class="login-container">
    <el-card class="login-box">
      <h2 style="text-align: center; margin-bottom: 30px;">AgentCPM 研报系统</h2>
      <el-form
        ref="loginFormRef"
        :model="loginForm"
        :rules="loginRules"
        label-width="80px"
      >
        <el-form-item label="用户名" prop="username">
          <el-input
            v-model="loginForm.username"
            placeholder="请输入用户名"
            :prefix-icon="User"
          />
        </el-form-item>
        <el-form-item label="密码" prop="password">
          <el-input
            v-model="loginForm.password"
            type="password"
            placeholder="请输入密码"
            :prefix-icon="Lock"
            @keyup.enter="handleLogin"
          />
        </el-form-item>
        <el-form-item>
          <el-button
            type="primary"
            style="width: 100%;"
            :loading="loading"
            @click="handleLogin"
          >
            登录
          </el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'

const router = useRouter()
const userStore = useUserStore()

const loginFormRef = ref()
const loading = ref(false)

const loginForm = reactive({
  username: '',
  password: '',
})

const loginRules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}

const handleLogin = async () => {
  // 表单验证
  if (!loginFormRef.value) return
  const valid = await loginFormRef.value.validate()
  if (!valid) return

  loading.value = true
  try {
    await userStore.login(loginForm)
    ElMessage.success('登录成功')
  } catch (error) {
    // 错误信息已在request拦截器中统一提示
    console.error('Login failed:', error)
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.login-container {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
  width: 400px;
  padding: 30px;
}
</style>

5.2 主页与任务列表

修改 src/views/HomeView.vue 或创建 src/views/ReportListView.vue

<template>
  <div class="report-list-container">
    <div class="header">
      <h1>研报任务管理</h1>
      <el-button type="primary" :icon="Plus" @click="showCreateDialog = true">
        新建研报任务
      </el-button>
    </div>

    <!-- 筛选区域 -->
    <el-card class="filter-card">
      <el-form :model="filterForm" inline>
        <el-form-item label="任务状态">
          <el-select v-model="filterForm.status" placeholder="全部状态" clearable>
            <el-option label="全部" value="" />
            <el-option label="排队中" value="pending" />
            <el-option label="生成中" value="running" />
            <el-option label="已完成" value="completed" />
            <el-option label="失败" value="failed" />
            <el-option label="已取消" value="cancelled" />
          </el-select>
        </el-form-item>
        <el-form-item label="关键词">
          <el-input
            v-model="filterForm.keyword"
            placeholder="输入任务主题或ID搜索"
            :prefix-icon="Search"
            clearable
          />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
          <el-button @click="resetFilter">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <!-- 任务列表表格 -->
    <el-card>
      <el-table
        v-loading="reportStore.loading"
        :data="reportStore.taskList"
        style="width: 100%"
      >
        <el-table-column prop="id" label="任务ID" width="180" />
        <el-table-column prop="topic" label="报告主题" min-width="200" />
        <el-table-column prop="reportType" label="报告类型" width="120">
          <template #default="{ row }">
            <el-tag size="small">{{ formatReportType(row.reportType) }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="status" label="状态" width="120">
          <template #default="{ row }">
            <el-tag :type="getStatusTagType(row.status)" size="small">
              {{ formatStatus(row.status) }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="progress" label="进度" width="100">
          <template #default="{ row }">
            <span v-if="row.status === 'running'">{{ row.progress || 0 }}%</span>
            <span v-else>-</span>
          </template>
        </el-table-column>
        <el-table-column prop="createdAt" label="创建时间" width="180">
          <template #default="{ row }">
            {{ formatDate(row.createdAt) }}
          </template>
        </el-table-column>
        <el-table-column label="操作" width="200" fixed="right">
          <template #default="{ row }">
            <el-button
              size="small"
              @click="viewTaskDetail(row.id)"
            >
              查看
            </el-button>
            <el-button
              v-if="row.status === 'pending' || row.status === 'running'"
              size="small"
              type="danger"
              @click="handleCancelTask(row.id)"
            >
              取消
            </el-button>
            <el-button
              v-if="row.status === 'completed'"
              size="small"
              type="success"
              @click="handleDownload(row.id)"
            >
              下载
            </el-button>
          </template>
        </el-table-column>
      </el-table>

      <!-- 分页 -->
      <div class="pagination">
        <el-pagination
          v-model:current-page="pagination.page"
          v-model:page-size="pagination.pageSize"
          :total="reportStore.total"
          :page-sizes="[10, 20, 50, 100]"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>

    <!-- 创建任务对话框 -->
    <el-dialog v-model="showCreateDialog" title="新建研报任务" width="500px">
      <el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
        <el-form-item label="报告主题" prop="topic">
          <el-input
            v-model="createForm.topic"
            placeholder="例如:2024年Q1新能源汽车市场趋势分析"
            type="textarea"
            :rows="2"
          />
        </el-form-item>
        <el-form-item label="报告类型" prop="reportType">
          <el-select v-model="createForm.reportType" placeholder="请选择">
            <el-option label="行业分析" value="industry" />
            <el-option label="公司研究" value="company" />
            <el-option label="市场简报" value="brief" />
            <el-option label="深度报告" value="deep" />
          </el-select>
        </el-form-item>
        <el-form-item label="分析深度" prop="depth">
          <el-radio-group v-model="createForm.depth">
            <el-radio label="standard">标准</el-radio>
            <el-radio label="deep">深度</el-radio>
            <el-radio label="comprehensive">全面</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="附加说明" prop="notes">
          <el-input
            v-model="createForm.notes"
            placeholder="可选的额外要求或说明"
            type="textarea"
            :rows="3"
          />
        </el-form-item>
      </el-form>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="showCreateDialog = false">取消</el-button>
          <el-button type="primary" :loading="creating" @click="handleCreateTask">
            提交任务
          </el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { useReportStore } from '@/stores/report'
import { downloadReport } from '@/api/report'

const router = useRouter()
const reportStore = useReportStore()

// 筛选表单
const filterForm = reactive({
  status: '',
  keyword: '',
})

// 分页
const pagination = reactive({
  page: 1,
  pageSize: 10,
})

// 创建任务相关
const showCreateDialog = ref(false)
const creating = ref(false)
const createFormRef = ref()
const createForm = reactive({
  topic: '',
  reportType: '',
  depth: 'standard',
  notes: '',
})

const createRules = {
  topic: [{ required: true, message: '请输入报告主题', trigger: 'blur' }],
  reportType: [{ required: true, message: '请选择报告类型', trigger: 'change' }],
}

// 辅助函数
const formatReportType = (type) => {
  const map = { industry: '行业分析', company: '公司研究', brief: '市场简报', deep: '深度报告' }
  return map[type] || type
}

const formatStatus = (status) => {
  const map = {
    pending: '排队中',
    running: '生成中',
    completed: '已完成',
    failed: '失败',
    cancelled: '已取消',
  }
  return map[status] || status
}

const getStatusTagType = (status) => {
  const map = {
    pending: 'info',
    running: 'primary',
    completed: 'success',
    failed: 'danger',
    cancelled: 'warning',
  }
  return map[status] || 'info'
}

const formatDate = (dateStr) => {
  if (!dateStr) return '-'
  return new Date(dateStr).toLocaleString()
}

// 方法
const loadTaskList = () => {
  const params = {
    page: pagination.page,
    pageSize: pagination.pageSize,
    ...filterForm,
  }
  reportStore.fetchTaskList(params)
}

const handleSearch = () => {
  pagination.page = 1 // 搜索时回到第一页
  loadTaskList()
}

const resetFilter = () => {
  filterForm.status = ''
  filterForm.keyword = ''
  pagination.page = 1
  loadTaskList()
}

const handleSizeChange = (val) => {
  pagination.pageSize = val
  loadTaskList()
}

const handleCurrentChange = (val) => {
  pagination.page = val
  loadTaskList()
}

const viewTaskDetail = (taskId) => {
  router.push(`/report/detail/${taskId}`)
}

const handleCancelTask = async (taskId) => {
  try {
    await ElMessageBox.confirm('确定要取消此任务吗?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning',
    })
    await reportStore.cancelTask(taskId)
    ElMessage.success('任务已取消')
  } catch (error) {
    if (error !== 'cancel') {
      console.error('Cancel task failed:', error)
    }
  }
}

const handleDownload = async (taskId) => {
  try {
    const blob = await downloadReport(taskId, 'pdf')
    // 创建下载链接
    const url = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = url
    link.download = `研报_${taskId}.pdf`
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    window.URL.revokeObjectURL(url)
  } catch (error) {
    console.error('Download failed:', error)
  }
}

const handleCreateTask = async () => {
  if (!createFormRef.value) return
  const valid = await createFormRef.value.validate()
  if (!valid) return

  creating.value = true
  try {
    await reportStore.createNewTask(createForm)
    ElMessage.success('任务创建成功')
    showCreateDialog.value = false
    // 重置表单
    createFormRef.value.resetFields()
    // 刷新列表
    loadTaskList()
  } catch (error) {
    console.error('Create task failed:', error)
  } finally {
    creating.value = false
  }
}

// 监听筛选条件变化,自动搜索(防抖可以后续优化)
watch(
  filterForm,
  () => {
    // 简单防抖,避免频繁请求
    clearTimeout(window.filterTimer)
    window.filterTimer = setTimeout(() => {
      handleSearch()
    }, 500)
  },
  { deep: true }
)

// 初始化加载
onMounted(() => {
  loadTaskList()
})
</script>

<style scoped>
.report-list-container {
  padding: 20px;
}
.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}
.filter-card {
  margin-bottom: 20px;
}
.pagination {
  margin-top: 20px;
  display: flex;
  justify-content: flex-end;
}
</style>

这个列表页面已经具备了任务管理的核心功能:查看、筛选、分页、创建、取消和下载。接下来,我们实现最关键的环节——任务详情与实时状态展示。

6. 任务详情与实时状态轮询

创建一个新的组件 src/views/ReportDetailView.vue,用于展示任务详情和实时进度。

<template>
  <div class="detail-container" v-loading="loading">
    <el-page-header @back="goBack">
      <template #content>
        <span class="text-large font-600 mr-3"> 研报任务详情 </span>
      </template>
    </el-page-header>

    <el-card class="detail-card" v-if="task">
      <!-- 基础信息 -->
      <div class="section">
        <h3>任务信息</h3>
        <el-descriptions :column="2" border>
          <el-descriptions-item label="任务ID">{{ task.id }}</el-descriptions-item>
          <el-descriptions-item label="报告主题">{{ task.topic }}</el-descriptions-item>
          <el-descriptions-item label="报告类型">{{ formatReportType(task.reportType) }}</el-descriptions-item>
          <el-descriptions-item label="分析深度">{{ formatDepth(task.depth) }}</el-descriptions-item>
          <el-descriptions-item label="当前状态">
            <el-tag :type="getStatusTagType(task.status)">
              {{ formatStatus(task.status) }}
            </el-tag>
          </el-descriptions-item>
          <el-descriptions-item label="创建时间">{{ formatDate(task.createdAt) }}</el-descriptions-item>
          <el-descriptions-item label="完成时间" v-if="task.completedAt">
            {{ formatDate(task.completedAt) }}
          </el-descriptions-item>
          <el-descriptions-item label="附加说明" :span="2">
            {{ task.notes || '无' }}
          </el-descriptions-item>
        </el-descriptions>
      </div>

      <!-- 进度展示 -->
      <div class="section" v-if="task.status === 'running'">
        <h3>生成进度</h3>
        <div class="progress-container">
          <el-progress
            :percentage="task.progress || 0"
            :stroke-width="16"
            :text-inside="true"
            striped
            striped-flow
          />
          <div class="progress-steps" v-if="task.steps">
            <div
              v-for="(step, index) in task.steps"
              :key="index"
              class="step-item"
              :class="{ active: step.status === 'completed', current: step.status === 'running' }"
            >
              <div class="step-icon">
                <el-icon v-if="step.status === 'completed'"><CircleCheck /></el-icon>
                <el-icon v-else-if="step.status === 'running'"><Loading /></el-icon>
                <el-icon v-else><Circle /></el-icon>
              </div>
              <div class="step-label">{{ step.name }}</div>
            </div>
          </div>
          <div class="current-step" v-if="currentStep">
            当前步骤:{{ currentStep.name }}
            <span v-if="currentStep.detail"> - {{ currentStep.detail }}</span>
          </div>
        </div>
      </div>

      <!-- 结果展示 -->
      <div class="section" v-if="task.status === 'completed' && task.result">
        <div class="section-header">
          <h3>生成结果</h3>
          <div class="action-buttons">
            <el-button type="primary" :icon="Download" @click="handleDownload('pdf')">
              下载PDF
            </el-button>
            <el-button :icon="Download" @click="handleDownload('html')">
              下载HTML
            </el-button>
            <el-button :icon="Download" @click="handleDownload('markdown')">
              下载Markdown
            </el-button>
          </div>
        </div>

        <!-- 这里根据实际返回的数据格式渲染 -->
        <!-- 假设结果是HTML,可以用v-html渲染(注意XSS风险,确保内容可信) -->
        <!-- 或者如果是Markdown,可以引入一个Markdown渲染器 -->
        <div class="result-preview" v-if="resultType === 'html'">
          <div v-html="task.result.content"></div>
        </div>
        <div class="result-preview" v-else-if="resultType === 'markdown'">
          <!-- 这里可以集成一个Markdown渲染组件,如marked.js -->
          <pre>{{ task.result.content }}</pre>
        </div>
        <div class="result-preview" v-else>
          <p>报告生成成功!请下载查看完整内容。</p>
          <p v-if="task.result.summary">
            <strong>报告摘要:</strong>{{ task.result.summary }}
          </p>
        </div>
      </div>

      <!-- 错误信息 -->
      <div class="section" v-if="task.status === 'failed' && task.error">
        <h3 style="color: #f56c6c;">任务失败</h3>
        <el-alert :title="task.error.message" type="error" :closable="false">
          <template #default>
            <div v-if="task.error.details">
              <p>错误详情:</p>
              <pre>{{ task.error.details }}</pre>
            </div>
          </template>
        </el-alert>
        <div style="margin-top: 20px;">
          <el-button type="primary" @click="handleRetry">重试任务</el-button>
        </div>
      </div>

      <!-- 操作按钮 -->
      <div class="action-area">
        <el-button v-if="task.status === 'pending' || task.status === 'running'" type="danger" @click="handleCancel">
          取消任务
        </el-button>
        <el-button @click="goBack">返回列表</el-button>
      </div>
    </el-card>

    <el-empty v-else description="任务不存在或已删除" />
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, CircleCheck, Loading, Circle } from '@element-plus/icons-vue'
import { useReportStore } from '@/stores/report'
import { downloadReport } from '@/api/report'

const route = useRoute()
const router = useRouter()
const reportStore = useReportStore()

const taskId = route.params.id
const task = ref(null)
const loading = ref(false)
const pollTimer = ref(null)

// 计算属性
const currentStep = computed(() => {
  if (!task.value?.steps) return null
  return task.value.steps.find(step => step.status === 'running') || 
         task.value.steps.find(step => step.status === 'pending')
})

const resultType = computed(() => {
  if (!task.value?.result) return null
  if (task.value.result.contentType === 'html') return 'html'
  if (task.value.result.contentType === 'markdown') return 'markdown'
  return 'other'
})

// 辅助函数(与列表页相同,可提取为工具函数)
const formatReportType = (type) => {
  const map = { industry: '行业分析', company: '公司研究', brief: '市场简报', deep: '深度报告' }
  return map[type] || type
}

const formatDepth = (depth) => {
  const map = { standard: '标准', deep: '深度', comprehensive: '全面' }
  return map[depth] || depth
}

const formatStatus = (status) => {
  const map = {
    pending: '排队中',
    running: '生成中',
    completed: '已完成',
    failed: '失败',
    cancelled: '已取消',
  }
  return map[status] || status
}

const getStatusTagType = (status) => {
  const map = {
    pending: 'info',
    running: 'primary',
    completed: 'success',
    failed: 'danger',
    cancelled: 'warning',
  }
  return map[status] || 'info'
}

const formatDate = (dateStr) => {
  if (!dateStr) return '-'
  return new Date(dateStr).toLocaleString()
}

// 方法
const loadTaskDetail = async () => {
  loading.value = true
  try {
    await reportStore.fetchTaskDetail(taskId)
    task.value = reportStore.currentTask
  } catch (error) {
    console.error('Load task detail failed:', error)
    ElMessage.error('获取任务详情失败')
  } finally {
    loading.value = false
  }
}

// 轮询任务状态(针对运行中的任务)
const startPolling = () => {
  if (pollTimer.value) clearInterval(pollTimer.value)
  
  pollTimer.value = setInterval(() => {
    if (task.value && (task.value.status === 'pending' || task.value.status === 'running')) {
      loadTaskDetail()
    } else {
      // 任务完成或失败,停止轮询
      stopPolling()
    }
  }, 3000) // 每3秒轮询一次
}

const stopPolling = () => {
  if (pollTimer.value) {
    clearInterval(pollTimer.value)
    pollTimer.value = null
  }
}

const goBack = () => {
  router.push('/report/list')
}

const handleDownload = async (format) => {
  try {
    const blob = await downloadReport(taskId, format)
    const url = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = url
    link.download = `研报_${taskId}.${format}`
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    window.URL.revokeObjectURL(url)
  } catch (error) {
    console.error('Download failed:', error)
  }
}

const handleCancel = async () => {
  try {
    await ElMessageBox.confirm('确定要取消此任务吗?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning',
    })
    await reportStore.cancelTask(taskId)
    ElMessage.success('任务已取消')
    // 取消后重新加载详情
    loadTaskDetail()
  } catch (error) {
    if (error !== 'cancel') {
      console.error('Cancel task failed:', error)
    }
  }
}

const handleRetry = async () => {
  // 这里可以调用重试API,或者导航到创建页面并填充原有数据
  ElMessage.info('重试功能需根据后端API实现')
}

// 生命周期
onMounted(() => {
  loadTaskDetail().then(() => {
    if (task.value && (task.value.status === 'pending' || task.value.status === 'running')) {
      startPolling()
    }
  })
})

onUnmounted(() => {
  stopPolling()
})

// 监听任务状态变化
import { watch } from 'vue'
watch(
  () => task.value?.status,
  (newStatus) => {
    if (newStatus === 'pending' || newStatus === 'running') {
      startPolling()
    } else {
      stopPolling()
    }
  }
)
</script>

<style scoped>
.detail-container {
  padding: 20px;
}
.detail-card {
  margin-top: 20px;
}
.section {
  margin-bottom: 30px;
}
.section-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
}
.progress-container {
  padding: 20px;
  background: #f5f7fa;
  border-radius: 4px;
}
.progress-steps {
  display: flex;
  justify-content: space-between;
  margin-top: 30px;
  margin-bottom: 20px;
}
.step-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  flex: 1;
  position: relative;
}
.step-item:not(:last-child)::after {
  content: '';
  position: absolute;
  top: 12px;
  left: 60%;
  right: -40%;
  height: 2px;
  background: #dcdfe6;
  z-index: 1;
}
.step-item.active::after {
  background: #409eff;
}
.step-icon {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: white;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 2;
  margin-bottom: 8px;
}
.step-item.active .step-icon {
  color: #409eff;
}
.step-item.current .step-icon {
  color: #409eff;
  animation: rotate 2s linear infinite;
}
@keyframes rotate {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}
.step-label {
  font-size: 12px;
  color: #606266;
}
.current-step {
  margin-top: 10px;
  font-size: 14px;
  color: #409eff;
}
.result-preview {
  border: 1px solid #ebeef5;
  border-radius: 4px;
  padding: 20px;
  background: white;
  max-height: 600px;
  overflow-y: auto;
}
.result-preview pre {
  white-space: pre-wrap;
  word-wrap: break-word;
}
.action-area {
  margin-top: 30px;
  text-align: center;
}
</style>

这个详情页实现了几个关键功能:实时进度轮询(对于运行中的任务,每3秒查询一次状态)、步骤可视化(展示AI生成报告的各个阶段)、多格式结果预览与下载。用户在这里可以直观地看到任务从创建到完成的完整生命周期。

7. 路由与权限控制

最后,我们把页面串起来,并加上简单的权限控制。修改 src/router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/LoginView.vue'),
      meta: { requiresAuth: false }, // 登录页不需要认证
    },
    {
      path: '/',
      redirect: '/report/list', // 首页重定向到报告列表
    },
    {
      path: '/report/list',
      name: 'reportList',
      component: () => import('@/views/ReportListView.vue'),
      meta: { requiresAuth: true }, // 需要登录
    },
    {
      path: '/report/detail/:id',
      name: 'reportDetail',
      component: () => import('@/views/ReportDetailView.vue'),
      meta: { requiresAuth: true },
    },
    // 可以添加更多路由,如用户管理、系统设置等
    {
      path: '/:pathMatch(.*)*',
      redirect: '/report/list', // 404重定向
    },
  ],
})

// 路由守卫:权限控制
router.beforeEach((to, from, next) => {
  const userStore = useUserStore()
  
  // 如果目标路由需要认证,但用户未登录
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next('/login')
  } else {
    next()
  }
})

export default router

为了让登录状态持久化,我们已经在Pinia store中配置了 persist: true。用户刷新页面后,token依然存在。你还可以在 App.vue 的全局初始化中,尝试用token获取用户信息。

8. 总结与展望

走完这一趟,一个功能完整的AgentCPM研报管理前端界面就搭建起来了。我们从零开始,完成了项目初始化、请求封装、状态管理、页面组件开发、实时状态轮询和路由权限控制。这个界面虽然基础,但已经具备了核心的交互能力:创建任务、监控进度、查看结果、下载报告。

实际开发中,你还可以根据需求继续深化:

  1. 更丰富的报告预览:集成Markdown渲染器(如 marked + highlight.js)来优雅地展示报告内容。
  2. 批量操作:在列表页支持批量删除、批量下载。
  3. 任务统计看板:增加一个Dashboard页面,用图表展示任务数量、成功率、平均耗时等数据。
  4. 更细粒度的权限:根据用户角色(如管理员、普通用户)控制不同功能(如删除任务、查看所有用户任务)。
  5. WebSocket实时推送:对于实时性要求更高的场景,可以将轮询改为WebSocket,实现服务端主动推送状态更新。
  6. 导出任务:支持将任务列表导出为Excel或CSV。

前端技术选型没有绝对的好坏,Vue 3的组合式API和Pinia让状态管理变得非常清晰。Element Plus组件库大大提升了开发效率。最重要的是,我们通过前端这个“翻译官”和“调度员”,把后端强大的AI能力,变成了产品团队和业务方都能轻松使用的工具。这才是前端在AI应用中的核心价值——降低使用门槛,提升体验和效率。

希望这篇教程能帮你快速上手。代码里还有很多可以优化和扩展的地方,你可以根据自己的业务需求灵活调整。动手试试吧,把想法变成现实。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐