AgentCPM前端交互开发:使用Vue.js构建研报生成与管理界面
AgentCPM前端交互开发:使用Vue.js构建研报生成与管理界面
你是不是也遇到过这样的场景?后端同事开发了一个强大的AgentCPM深度研报助手,功能很酷,能自动分析数据、生成专业报告。但每次想用,都得在命令行里敲代码,或者等后端同事给你一个简陋的测试页面。对于非技术同事来说,这门槛就太高了。
这时候,一个直观、易用的Web管理界面就显得至关重要。它能将复杂的AI能力包装成简单的按钮和表单,让产品、运营、甚至业务部门的同事都能轻松上手,真正把AI工具用起来。
今天,我就带你从零开始,用Vue.js为AgentCPM研报助手搭建一个完整的前端交互界面。我们会一步步实现项目搭建、接口对接、任务状态实时追踪、研报结果展示与下载,以及基础的权限控制。即使你Vue.js刚入门,跟着这篇教程也能做出一个像模像样的管理后台。
1. 环境准备与项目初始化
工欲善其事,必先利其器。我们先来把开发环境准备好。
首先,确保你的电脑上已经安装了Node.js(建议版本16或以上)和npm。打开终端,输入 node -v 和 npm -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研报管理前端界面就搭建起来了。我们从零开始,完成了项目初始化、请求封装、状态管理、页面组件开发、实时状态轮询和路由权限控制。这个界面虽然基础,但已经具备了核心的交互能力:创建任务、监控进度、查看结果、下载报告。
实际开发中,你还可以根据需求继续深化:
- 更丰富的报告预览:集成Markdown渲染器(如
marked+highlight.js)来优雅地展示报告内容。 - 批量操作:在列表页支持批量删除、批量下载。
- 任务统计看板:增加一个Dashboard页面,用图表展示任务数量、成功率、平均耗时等数据。
- 更细粒度的权限:根据用户角色(如管理员、普通用户)控制不同功能(如删除任务、查看所有用户任务)。
- WebSocket实时推送:对于实时性要求更高的场景,可以将轮询改为WebSocket,实现服务端主动推送状态更新。
- 导出任务:支持将任务列表导出为Excel或CSV。
前端技术选型没有绝对的好坏,Vue 3的组合式API和Pinia让状态管理变得非常清晰。Element Plus组件库大大提升了开发效率。最重要的是,我们通过前端这个“翻译官”和“调度员”,把后端强大的AI能力,变成了产品团队和业务方都能轻松使用的工具。这才是前端在AI应用中的核心价值——降低使用门槛,提升体验和效率。
希望这篇教程能帮你快速上手。代码里还有很多可以优化和扩展的地方,你可以根据自己的业务需求灵活调整。动手试试吧,把想法变成现实。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐



所有评论(0)