feat: 报告生成模块(LLM 生成 + PDF 导出 + 前端页面)
- 新增报告生成 API(/api/report/generate, /api/report/export/pdf, /api/report/preview) - 新增 PDF 导出模块(gofpdf,支持系统中文字体自动检测) - 新增前端报告生成页面(report/ReportGenerationPage.vue) - 新增前端 API(report.js) - 路由注册(后端 router.go + 前端 router/index.js) - Studio 页面新增「报告生成」应用入口 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import http from './http'
|
||||
|
||||
export function generateReport(data) {
|
||||
return http.post('/report/generate', data)
|
||||
}
|
||||
|
||||
export function exportReportPDF(data) {
|
||||
return http.post('/report/export/pdf', data, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export function previewReport(data) {
|
||||
return http.post('/report/preview', data)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const routes = [
|
||||
{ path: 'apps/knowledge-operations', name: 'KnowledgeOperationsApp', meta: { workspaceKey: 'knowledge-operations' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||
{ path: 'apps/process-coordination', name: 'ProcessCoordinationApp', meta: { workspaceKey: 'process-coordination' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||
{ path: 'apps/report-generation', name: 'ReportGenerationApp', meta: { workspaceKey: 'report-generation' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||
{ path: 'report-gen', name: 'ReportGeneration', meta: { requiresAdmin: true }, component: () => import('@/views/report/ReportGenerationPage.vue') },
|
||||
{ path: 'apps/wechat-official-account', name: 'WechatOfficialAccountApp', meta: { workspaceKey: 'wechat-official-account' }, component: () => import('@/views/workbench/OfficialAccountWorkflowPage.vue') },
|
||||
{ path: 'apps/logistics-fulfillment', name: 'LogisticsFulfillmentApp', meta: { workspaceKey: 'logistics-fulfillment' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||
{ path: 'apps/hr-email-sorter', name: 'HrEmailSorter', meta: { workspaceKey: 'hr-email-sorter' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
<template>
|
||||
<div class="report-gen-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>报告生成</h1>
|
||||
<p>输入报告主题,AI 自动规划章节并生成正式报告,支持导出 PDF</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" :loading="generating" @click="generateReport">
|
||||
<el-icon v-if="!generating"><Edit /></el-icon>
|
||||
{{ generating ? '生成中...' : '生成报告' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-layout">
|
||||
<!-- 左侧:输入面板 -->
|
||||
<section class="input-panel">
|
||||
<div class="card">
|
||||
<div class="card-title">报告信息</div>
|
||||
|
||||
<el-form :model="form" label-position="top" class="report-form">
|
||||
<el-form-item label="报告主题">
|
||||
<el-input
|
||||
v-model="form.topic"
|
||||
placeholder="例如:2024年企业AI应用实践报告"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="摘要/背景(可选)">
|
||||
<el-input
|
||||
v-model="form.summary"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="简要描述报告的背景与目的"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="期望章节(可选,留空由 AI 自动规划)">
|
||||
<el-select
|
||||
v-model="form.sections"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="选择或输入章节名称"
|
||||
>
|
||||
<el-option label="背景概述" value="背景概述" />
|
||||
<el-option label="核心内容" value="核心内容" />
|
||||
<el-option label="关键数据" value="关键数据" />
|
||||
<el-option label="实施方案" value="实施方案" />
|
||||
<el-option label="风险与对策" value="风险与对策" />
|
||||
<el-option label="总结建议" value="总结建议" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="关联知识库(可选)">
|
||||
<el-select v-model="form.knowledge_key" placeholder="选择知识库,留空则全局检索">
|
||||
<el-option label="全局知识库" value="" />
|
||||
<el-option
|
||||
v-for="space in knowledgeSpaces"
|
||||
:key="space.id"
|
||||
:label="space.name"
|
||||
:value="space.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作指引 -->
|
||||
<div class="card guide-card">
|
||||
<div class="card-title">操作指引</div>
|
||||
<ol class="guide-steps">
|
||||
<li>填写报告主题(必填)</li>
|
||||
<li>输入摘要/背景(可选)</li>
|
||||
<li>选择期望章节或留空由 AI 自动规划</li>
|
||||
<li>选择关联知识库(可选)</li>
|
||||
<li>点击「生成报告」预览内容</li>
|
||||
<li>确认无误后导出 PDF</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右侧:报告预览 -->
|
||||
<section class="preview-panel">
|
||||
<div class="card" :class="{ 'has-report': reportContent }">
|
||||
<div class="card-header">
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">报告预览</div>
|
||||
<span v-if="reportContent" class="report-meta">
|
||||
生成于 {{ reportContent?.created_at || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-actions" v-if="reportContent">
|
||||
<el-button size="small" :loading="exporting" @click="exportToPDF">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 PDF
|
||||
</el-button>
|
||||
<el-button size="small" @click="resetReport">
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!reportContent" class="empty-preview">
|
||||
<div class="eyebrow">Report Generator</div>
|
||||
<h3>报告预览区</h3>
|
||||
<p>填写左侧报告信息后点击「生成报告」,AI 将自动创建报告并在右侧预览</p>
|
||||
<div class="preview-icons">
|
||||
<span class="icon-item">📄</span>
|
||||
<span class="icon-item">📊</span>
|
||||
<span class="icon-item">📑</span>
|
||||
<span class="icon-item">📋</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 报告内容 -->
|
||||
<div v-else class="report-preview">
|
||||
<!-- 章节列表 -->
|
||||
<div class="chapters-list">
|
||||
<div
|
||||
v-for="(chapter, idx) in reportContent.chapters"
|
||||
:key="idx"
|
||||
class="chapter-card"
|
||||
>
|
||||
<div class="chapter-header">
|
||||
<span class="chapter-num">{{ idx + 1 }}</span>
|
||||
<strong>{{ chapter.title }}</strong>
|
||||
</div>
|
||||
<div class="chapter-body">
|
||||
{{ chapter.body }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Edit, Download } from '@element-plus/icons-vue'
|
||||
import { generateReport, exportReportPDF } from '@/api/report'
|
||||
import { listKnowledgeSpaces } from '@/api/knowledge'
|
||||
|
||||
const generating = ref(false)
|
||||
const exporting = ref(false)
|
||||
const reportContent = ref(null)
|
||||
|
||||
const form = ref({
|
||||
topic: '',
|
||||
summary: '',
|
||||
sections: [],
|
||||
knowledge_key: '',
|
||||
})
|
||||
|
||||
const knowledgeSpaces = ref([])
|
||||
|
||||
async function generateReport() {
|
||||
if (!form.value.topic.trim()) {
|
||||
ElMessage.warning('请输入报告主题')
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
try {
|
||||
const res = await generateReport({
|
||||
topic: form.value.topic.trim(),
|
||||
summary: form.value.summary,
|
||||
sections: form.value.sections,
|
||||
knowledge_key: form.value.knowledge_key,
|
||||
})
|
||||
|
||||
if (res?.data) {
|
||||
reportContent.value = res.data.report || res.data
|
||||
ElMessage.success('报告生成成功')
|
||||
} else {
|
||||
reportContent.value = res?.data?.report || null
|
||||
if (reportContent.value) {
|
||||
ElMessage.success('报告生成成功')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '报告生成失败,请稍后重试')
|
||||
reportContent.value = null
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportToPDF() {
|
||||
if (!reportContent.value) return
|
||||
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await exportReportPDF({
|
||||
report: reportContent.value,
|
||||
})
|
||||
|
||||
// 下载 PDF
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const filename = (reportContent.value.topic || 'report') + '.pdf'
|
||||
a.download = filename.replace(/[^a-zA-Z一-龥 -]/g, '_')
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('PDF 已下载')
|
||||
} catch (error) {
|
||||
ElMessage.error('PDF 导出失败')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetReport() {
|
||||
reportContent.value = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await listKnowledgeSpaces()
|
||||
const items = res?.data?.items || []
|
||||
if (items.length) {
|
||||
knowledgeSpaces.value = items.map((item) => ({
|
||||
id: item.key,
|
||||
name: item.name,
|
||||
desc: item.description,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默失败
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-gen-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 24px;
|
||||
color: #1f2d3d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-left p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7785;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 380px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef1f6;
|
||||
box-shadow: 0 4px 12px rgba(31, 35, 41, 0.04);
|
||||
padding: 20px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
font-size: 12px;
|
||||
color: #8a96a3;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.report-form .el-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.report-form .el-form-item :deep(.el-form-item__label) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #4b5968;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.guide-card {
|
||||
margin-top: 16px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.guide-steps {
|
||||
margin: 0;
|
||||
padding: 0 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: #5f6b7a;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.guide-steps li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.empty-preview {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-preview .eyebrow {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #8a96a3;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.empty-preview h3 {
|
||||
margin: 12px 0 10px;
|
||||
font-size: 20px;
|
||||
color: #1f2d3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-preview p {
|
||||
font-size: 14px;
|
||||
color: #6b7785;
|
||||
line-height: 1.6;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-icons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.icon-item {
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: #f5f8ff;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.icon-item:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.report-preview {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.report-preview::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.report-preview::-webkit-scrollbar-thumb {
|
||||
background: #dcdfe6;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chapters-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chapter-card {
|
||||
border-radius: 12px;
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #eef1f6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chapter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: linear-gradient(135deg, #f5f8ff 0%, #eef6ff 100%);
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
|
||||
.chapter-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 8px;
|
||||
background: #2b63d9;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chapter-header strong {
|
||||
font-size: 14px;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
|
||||
.chapter-body {
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: #3d4854;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.main-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -547,6 +547,14 @@ const templateCards = [
|
||||
actions: ['归并进展', '提炼风险', '生成周报'],
|
||||
results: ['周报文档'],
|
||||
},
|
||||
{
|
||||
key: 'report-generation',
|
||||
label: '报告生成',
|
||||
summary: 'AI 驱动正式报告生成,自动规划章节,支持 PDF 导出。',
|
||||
inputs: ['报告主题', '背景资料'],
|
||||
actions: ['章节规划', 'LLM 撰写', 'PDF 导出'],
|
||||
results: ['PDF 报告'],
|
||||
},
|
||||
]
|
||||
|
||||
const toolboxItems = [
|
||||
|
||||
Reference in New Issue
Block a user