baodan/docs/前端开发计划.md

1482 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 前端开发计划(完整版)
> **架构**Vue 3 自研前端 + BaoDan WebAppiframe 嵌入)
> **核心原则**BaoDan 有的直接用BaoDan 没有的才自己写
> **开发量**~880 行代码11 个文件
> **UI 框架**Element Plus表单/表格/导航/弹窗)
> **响应式**:移动端优先,适配企微 H5 内置浏览器
### 页面归属说明BaoDan 已有 vs 自研)
| 功能模块 | BaoDan 已提供 | 需要自研 |
|---------|-----------|---------|
| 智能问答对话 | BaoDan WebAppiframe 嵌入) | 筛选器、复制按钮、推荐问题(外挂增强) |
| 知识库管理 | BaoDan 后台管理(/datasets | 无BaoDan 原生功能满足需求) |
| Prompt 管理 | BaoDan 后台应用配置(/apps | 无BaoDan 原生功能满足需求) |
| 模型配置 | BaoDan 后台模型设置(/settings | 无BaoDan 原生功能满足需求) |
| 对话日志 | BaoDan 后台日志(/logs | 无BaoDan 原生功能满足需求) |
| 登录页 | 无 | **自研**(企微 OAuth + 账密登录) |
| 产品推荐 | 无 | **自研**(表单 + 结果页) |
| 权限管理 | 无 | **自研**(角色权限配置) |
> 知识库管理、Prompt 管理、模型配置、对话日志在侧边栏以 `window.open` 跳转到 BaoDan 后台,不需要自研页面。
> BaoDan 不提供的管理功能(文档编号自动生成、标签管理、保司数据源配置、使用概览、成本监控等)
> 需要通过后端 API 接口支持,前端管理页面可按需在二期迭代中补充。
---
## 一、目录结构
```
frontend/
├── index.html # HTML 入口
├── package.json # 依赖
├── vite.config.ts # Vite 配置(代理到 BaoDan
├── tsconfig.json # TypeScript 配置
├── src/
│ ├── main.ts # Vue 入口
│ ├── App.vue # 根组件(导航布局)
│ ├── router/
│ │ └── index.ts # 路由定义
│ ├── types/
│ │ └── index.ts # 所有 TypeScript 接口
│ ├── utils/
│ │ └── api.ts # Axios 封装 + JWT
│ ├── composables/
│ │ ├── useAuth.ts # 登录状态管理
│ │ ├── useChatPolling.ts # 对话轮询
│ │ └── useDraft.ts # 表单草稿
│ ├── pages/
│ │ ├── LoginPage.vue # 登录页
│ │ ├── ChatPage.vue # 对话页iframe
│ │ ├── RecommendPage.vue # 产品推荐页
│ │ ├── RecommendResult.vue # 推荐结果页
│ │ └── admin/
│ │ └── PermissionsPage.vue # 权限管理页
│ └── components/
│ ├── ChatEmbed.vue # 对话 iframe + 外挂增强
│ ├── ChatFilters.vue # 险种/保司筛选器
│ ├── ChatCopyButton.vue # 复制按钮
│ ├── ChatSuggestions.vue # 推荐问题
│ ├── RecommendForm.vue # 推荐表单
│ └── RecommendResult.vue # 推荐结果展示
```
---
## 二、基础配置文件
### 2.1 vite.config.ts
```typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
// 你的保险模块 API + BaoDan 原生 API 都在同一个 5001 端口
'/api': {
target: 'http://localhost:5001',
changeOrigin: true,
},
'/v1': {
target: 'http://localhost:5001',
changeOrigin: true,
},
},
},
})
```
### 2.2 index.html
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>保险智能客服系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
```
### 2.3 tsconfig.json
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths": { "@/*": ["./src/*"] },
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
```
### 2.4 src/main.ts
```typescript
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const app = createApp(App)
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
```
---
## 三、类型定义src/types/index.ts
```typescript
// ====== 认证 ======
export interface LoginResponse {
code: number
data: {
token: string
expires_in: number
user?: UserInfo
}
}
export interface UserInfo {
id: string
username: string
wecom_userid?: string
role: 'super_admin' | 'admin' | 'manager' | 'sales'
department?: string
}
// ====== 对话 ======
export interface ChatSession {
id: string
title: string
created_at: string
updated_at: string
message_count: number
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
sources?: { doc_name: string; chunk: string; score?: number }[]
created_at: string
}
export interface ChatFilters {
险种?: string
保司?: string
}
// ====== 产品推荐 ======
export interface CustomerInfo {
name: string
age: number
gender: 'male' | 'female'
health_status: '健康' | '有既往病史' | '慢性病' | '重大疾病史'
occupation: string
annual_income: number
monthly_budget: number
}
export interface RecommendRequest {
customer: CustomerInfo
insurance_types: string[]
coverage_amount: number
coverage_period: string
existing_policies: ExistingPolicy[]
}
export interface ExistingPolicy {
name: string
coverage: number
premium: number
}
export interface RecommendResult {
task_id: string
status: 'processing' | 'done' | 'failed'
proposal?: {
id: string
plans: Plan[]
}
error_message?: string
}
export interface Plan {
name: string
total_premium: number
items: PlanItem[]
summary: string
}
export interface PlanItem {
product_name: string
coverage: number
premium: number
reason: string
}
// ====== 角色权限 ======
export interface Role {
id: string
name: string
permissions: string[]
builtin: boolean
}
// ====== 统计 ======
export interface TrendData {
metric: string
granularity: string
data: { date: string; value: number }[]
}
export interface KBHealthData {
hot_questions: { question: string; count: number }[]
missed_questions: { question: string; count: number; handled: boolean }[]
coverage: Record<string, { doc_count: number; status: string; last_update: string }>
}
export interface TokenCostData {
total_tokens: number
total_cost_usd: number
by_model: { model: string; input_tokens: number; output_tokens: number; cost_usd: number }[]
daily_trend: { date: string; tokens: number; cost_usd: number }[]
}
// ====== 通用 ======
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
export interface PaginatedData<T> {
total: number
items: T[]
}
// ====== 路由元信息 ======
export interface RouteMeta {
requiresAuth?: boolean
title?: string
}
```
---
## 四、工具层
### 4.1 src/utils/api.ts
```typescript
import axios, { AxiosError } from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const api = axios.create({
baseURL: '/api',
timeout: 60000,
headers: { 'Content-Type': 'application/json' },
})
// 请求拦截器:自动添加 JWT
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:统一错误处理
api.interceptors.response.use(
(response) => {
const data = response.data
// 流式响应不处理SSE
if (response.headers['content-type']?.includes('text/event-stream')) {
return response
}
if (data.code !== 0) {
ElMessage.error(data.message || '请求失败')
return Promise.reject(new Error(data.message))
}
return data
},
(error: AxiosError) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else {
ElMessage.error('网络异常,请稍后重试')
}
return Promise.reject(error)
}
)
export default api
```
### 4.2 src/composables/useAuth.ts
```typescript
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { UserInfo } from '@/types'
const token = ref(localStorage.getItem('token') || '')
const user = ref<UserInfo | null>(null)
export function useAuth() {
const router = useRouter()
const isLoggedIn = computed(() => !!token.value)
function setAuth(newToken: string, userInfo?: UserInfo) {
token.value = newToken
localStorage.setItem('token', newToken)
if (userInfo) {
user.value = userInfo
localStorage.setItem('user', JSON.stringify(userInfo))
}
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
}
function checkAuth() {
if (!token.value) {
router.push('/login')
return false
}
// 尝试从 localStorage 恢复 user 信息
if (!user.value) {
const stored = localStorage.getItem('user')
if (stored) user.value = JSON.parse(stored)
}
return true
}
// 从 URL 参数恢复 token企微 OAuth 回调后重定向带 token
function restoreFromUrl() {
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
if (urlToken) {
setAuth(urlToken)
// 清除 URL 中的 token 参数
window.history.replaceState({}, '', window.location.pathname)
}
}
return { token, user, isLoggedIn, setAuth, logout, checkAuth, restoreFromUrl }
}
```
### 4.3 src/composables/useChatPolling.ts
```typescript
import { ref, onUnmounted } from 'vue'
import api from '@/utils/api'
/**
* 轮询 BaoDan 对话 API获取最新消息
* BaoDan iframe 的 postMessage 只有 2 个事件ready/expand
* 没有 message-complete 事件,所以用轮询替代
*/
export function useChatPolling() {
const latestAnswer = ref('')
const hasAnswer = ref(false)
const latestSources = ref<{ doc_name: string; chunk: string }[]>([])
let timer: ReturnType<typeof setInterval> | null = null
let lastMessageCount = 0
let currentConversationId = ''
function startPolling(conversationId: string) {
stopPolling()
currentConversationId = conversationId
lastMessageCount = 0
hasAnswer.value = false
timer = setInterval(async () => {
try {
const res = await api.get(`/chat/sessions/${conversationId}/messages?limit=1`)
const messages = res.data?.data?.messages || []
if (messages.length > lastMessageCount) {
const lastMsg = messages[0]
if (lastMsg.role === 'assistant') {
latestAnswer.value = lastMsg.content
latestSources.value = lastMsg.sources || []
hasAnswer.value = true
}
lastMessageCount = messages.length
}
} catch {
// 静默失败,下次轮询重试
}
}, 2000)
}
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
}
onUnmounted(() => stopPolling())
return { latestAnswer, hasAnswer, latestSources, startPolling, stopPolling }
}
```
### 4.4 src/composables/useDraft.ts
```typescript
import { watch } from 'vue'
/**
* 自动保存/恢复表单草稿到 localStorage
*/
export function useDraft<T>(key: string, formData: { value: T }) {
// 恢复草稿
const saved = localStorage.getItem(`draft_${key}`)
if (saved) {
try {
const parsed = JSON.parse(saved)
Object.assign(formData.value, parsed)
} catch {}
}
// 每次变更自动保存
watch(formData, (val) => {
localStorage.setItem(`draft_${key}`, JSON.stringify(val))
}, { deep: true })
// 清除草稿
function clearDraft() {
localStorage.removeItem(`draft_${key}`)
}
return { clearDraft }
}
```
---
## 五、路由与布局
### 5.1 src/router/index.ts
```typescript
import { createRouter, createWebHistory } from 'vue-router'
import { RouteMeta } from '@/types'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/pages/LoginPage.vue'),
meta: { requiresAuth: false, title: '登录' },
},
{
path: '/',
redirect: '/chat',
},
{
path: '/chat',
name: 'Chat',
component: () => import('@/pages/ChatPage.vue'),
meta: { requiresAuth: true, title: '智能问答' },
},
{
path: '/recommend',
name: 'Recommend',
component: () => import('@/pages/RecommendPage.vue'),
meta: { requiresAuth: true, title: '产品推荐' },
},
{
path: '/recommend/result/:id',
name: 'RecommendResult',
component: () => import('@/pages/RecommendResult.vue'),
meta: { requiresAuth: true, title: '推荐结果' },
},
{
path: '/admin/permissions',
name: 'Permissions',
component: () => import('@/pages/admin/PermissionsPage.vue'),
meta: { requiresAuth: true, title: '权限管理' },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫:检查登录状态
router.beforeEach((to, _from, next) => {
document.title = `${(to.meta as RouteMeta).title || ''} - 保险智能客服`
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})
export default router
```
### 5.2 src/App.vue
```vue
<template>
<el-config-provider :locale="zhCn">
<!-- 未登录只显示登录页 -->
<router-view v-if="$route.path === '/login'" />
<!-- 已登录:显示导航布局 -->
<el-container v-else style="height: 100vh">
<!-- 侧边栏 -->
<el-aside width="200px" style="background: #001529">
<div style="padding: 16px; color: #fff; font-size: 16px; font-weight: bold; text-align: center">
保险智能客服
</div>
<el-menu
:default-active="$route.path"
background-color="#001529"
text-color="#ffffffa6"
active-text-color="#fff"
router
>
<el-menu-item index="/chat">
<el-icon><ChatDotRound /></el-icon>
<span>智能问答</span>
</el-menu-item>
<el-menu-item index="/recommend">
<el-icon><Document /></el-icon>
<span>产品推荐</span>
</el-menu-item>
<el-menu-item index="/admin/permissions">
<el-icon><Setting /></el-icon>
<span>权限管理</span>
</el-menu-item>
<!-- 外部链接:跳转 BaoDan 后台 -->
<el-menu-item @click="openBaoDan('/datasets')">
<el-icon><FolderOpened /></el-icon>
<span>知识库管理</span>
</el-menu-item>
<el-menu-item @click="openBaoDan('/logs')">
<el-icon><Document /></el-icon>
<span>对话日志</span>
</el-menu-item>
<el-menu-item @click="openBaoDan('/apps')">
<el-icon><Edit /></el-icon>
<span>Prompt 管理</span>
</el-menu-item>
<el-menu-item @click="openBaoDan('/settings')">
<el-icon><Setting /></el-icon>
<span>模型配置</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<el-header style="display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #eee">
<span>{{ $route.meta.title }}</span>
<el-dropdown @command="handleCommand">
<span style="cursor: pointer">{{ username }} <el-icon><ArrowDown /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main style="padding: 0; overflow: hidden">
<router-view />
</el-main>
</el-container>
</el-container>
</el-config-provider>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { useAuth } from '@/composables/useAuth'
import { ChatDotRound, Document, Setting, FolderOpened, Edit, ArrowDown } from '@element-plus/icons-vue'
const router = useRouter()
const { user, logout, restoreFromUrl } = useAuth()
const username = computed(() => user.value?.username || '未登录')
// 页面加载时检查 URL 中的 token企微 OAuth 回调后重定向)
restoreFromUrl()
function openBaoDan(path: string) {
// 在新窗口打开 BaoDan 后台
window.open(`/baodan${path}`, '_blank')
}
function handleCommand(cmd: string) {
if (cmd === 'logout') logout()
}
</script>
<style>
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
</style>
```
---
## 六、页面组件
### 6.1 src/pages/LoginPage.vue
```vue
<template>
<div class="login-container">
<div class="login-card">
<h2 style="text-align: center; margin-bottom: 30px">保险智能客服系统</h2>
<!-- 企微登录按钮 -->
<el-button type="primary" size="large" style="width: 100%; height: 50px; font-size: 16px"
@click="loginWithWechat">
企微登录
</el-button>
<div style="text-align: center; margin: 20px 0; color: #999">─── 或者 ───</div>
<!-- 账密登录 -->
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="loginWithPassword">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" placeholder="密码" type="password"
prefix-icon="Lock" show-password @keyup.enter="loginWithPassword" />
</el-form-item>
<el-button type="primary" style="width: 100%" :loading="loading"
native-type="submit">
登录
</el-button>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import api from '@/utils/api'
import { ElMessage } from 'element-plus'
const router = useRouter()
const { setAuth } = useAuth()
const formRef = ref()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
function loginWithWechat() {
window.location.href = '/api/wecom/oauth'
}
async function loginWithPassword() {
await formRef.value?.validate()
loading.value = true
try {
const res = await api.post('/auth/password-login', form)
setAuth(res.data.token)
ElMessage.success('登录成功')
router.push('/chat')
} catch {
// 错误已由 api 拦截器处理
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
@media (max-width: 768px) {
.login-card { width: 90%; padding: 24px; }
}
</style>
```
### 6.2 src/pages/ChatPage.vue
```vue
<template>
<div style="height: 100%; display: flex; flex-direction: column">
<!-- 筛选器 -->
<ChatFilters v-model:selectedType="selectedType" v-model:selectedCompany="selectedCompany" />
<!-- 对话区域 -->
<div style="flex: 1; overflow: hidden; position: relative">
<ChatEmbed
:app-id="currentAppId"
:user-id="userId"
@new-answer="onNewAnswer"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import ChatFilters from '@/components/ChatFilters.vue'
import ChatEmbed from '@/components/ChatEmbed.vue'
const { user } = useAuth()
// 险种到 BaoDan 应用 ID 的映射(在 BaoDan 后台创建应用后配置)
const appMapping: Record<string, string> = {
'': 'app-all',
'重疾险': 'app-critical',
'医疗险': 'app-medical',
'寿险': 'app-life',
'意外险': 'app-accident',
'车险': 'app-auto',
'年金险': 'app-annuity',
}
const selectedType = ref('')
const selectedCompany = ref('')
const currentAppId = computed(() => appMapping[selectedType.value] || 'app-all')
const userId = computed(() => `wecom_${user.value?.id || 'guest'}`)
function onNewAnswer(answer: string) {
// 新回答到达时的处理(如更新 UI 状态)
console.log('新回答:', answer.substring(0, 50))
}
</script>
```
### 6.3 src/pages/RecommendPage.vue
```vue
<template>
<div style="padding: 20px; max-width: 900px; margin: 0 auto">
<el-card>
<template #header>
<span style="font-size: 18px; font-weight: bold">产品推荐方案</span>
</template>
<RecommendForm @submit="onSubmit" :loading="loading" />
</el-card>
<!-- 生成结果 -->
<el-card v-if="result" style="margin-top: 20px">
<template #header>
<span style="font-size: 18px; font-weight: bold">推荐结果</span>
</template>
<RecommendResult :result="result" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { RecommendRequest, RecommendResult } from '@/types'
import api from '@/utils/api'
import { ElMessage, ElMessageBox } from 'element-plus'
import RecommendForm from '@/components/RecommendForm.vue'
import RecommendResult from '@/components/RecommendResult.vue'
const router = useRouter()
const loading = ref(false)
const result = ref<RecommendResult | null>(null)
async function onSubmit(formData: RecommendRequest) {
loading.value = true
result.value = null
try {
// 提交推荐请求
const submitRes = await api.post('/recommend/generate', formData)
const taskId = submitRes.data.task_id
// 轮询任务状态
let retries = 0
const maxRetries = 60 // 最多轮询 60 次(约 120 秒)
while (retries < maxRetries) {
await new Promise(r => setTimeout(r, 2000))
const pollRes = await api.get(`/recommend/generate/${taskId}`)
const task = pollRes.data
if (task.status === 'done') {
result.value = task
ElMessage.success('方案生成成功')
return
}
if (task.status === 'failed') {
ElMessage.error(task.error_message || '方案生成失败')
return
}
retries++
}
ElMessage.error('生成超时,请重试')
} catch {
// 错误已由 api 拦截器处理
} finally {
loading.value = false
}
}
</script>
```
### 6.4 src/pages/RecommendResult.vue
```vue
<template>
<div>
<div style="display: flex; justify-content: space-between; margin-bottom: 16px">
<h3>方案推荐结果</h3>
<el-button @click="$router.push('/recommend')">返回表单</el-button>
</div>
<!-- 方案列表 -->
<el-collapse v-if="result?.proposal?.plans">
<el-collapse-item
v-for="plan in result.proposal.plans"
:key="plan.name"
:name="plan.name"
>
<template #title>
<span style="font-weight: bold">{{ plan.name }}</span>
<el-tag type="info" style="margin-left: 12px">
年保费: {{ plan.total_premium.toLocaleString() }}元
</el-tag>
</template>
<!-- 方案内容 Markdown 渲染 -->
<div v-html="renderMarkdown(plan)" class="plan-content" />
</el-collapse-item>
</el-collapse>
<!-- 操作按钮 -->
<div style="margin-top: 20px; display: flex; gap: 12px">
<el-button type="primary" @click="printPage">浏览器打印</el-button>
<el-button @click="$router.push('/recommend')">重新生成</el-button>
</div>
<!-- 免责声明 -->
<p style="color: #999; font-size: 12px; margin-top: 20px">
免责声明:以上方案仅供参考,具体保障内容以保险合同条款为准。
</p>
</div>
</template>
<script setup lang="ts">
import { marked } from 'marked'
import { RecommendResult, Plan } from '@/types'
const props = defineProps<{ result: RecommendResult }>()
function renderMarkdown(plan: Plan): string {
let md = `### ${plan.name}\n\n`
md += `**年保费:${plan.total_premium.toLocaleString()} 元**\n\n`
md += `| 产品 | 保额 | 年保费 | 推荐理由 |\n|------|------|--------|----------|\n`
for (const item of plan.items) {
md += `| ${item.product_name} | ${item.coverage.toLocaleString()}万 | ${item.premium.toLocaleString()}元 | ${item.reason} |\n`
}
md += `\n**方案总结**${plan.summary}\n`
return marked.parse(md) as string
}
function printPage() {
window.print()
}
</script>
<style scoped>
.plan-content :deep(table) { width: 100%; border-collapse: collapse; margin: 8px 0; }
.plan-content :deep(th), .plan-content :deep(td) { border: 1px solid #ddd; padding: 8px; text-align: left; }
.plan-content :deep(th) { background: #f5f5f5; }
@media print { .el-aside, .el-header { display: none !important; } }
</style>
```
### 6.5 src/pages/admin/PermissionsPage.vue
```vue
<template>
<div style="padding: 20px">
<el-row :gutter="20">
<!-- 角色列表 -->
<el-col :span="8">
<el-card>
<template #header><span>角色列表</span></template>
<el-menu>
<el-menu-item v-for="role in roles" :key="role.id"
@click="selectRole(role)" :class="{ 'is-active': selectedRole?.id === role.id }">
{{ role.name }}
<el-tag v-if="role.builtin" size="small" type="info">内置</el-tag>
</el-menu-item>
</el-menu>
<el-button type="primary" style="width: 100%; margin-top: 12px" @click="showAddDialog">
+ 新建角色
</el-button>
</el-card>
</el-col>
<!-- 权限配置 -->
<el-col :span="16">
<el-card v-if="selectedRole">
<template #header>
<span>{{ selectedRole.name }} 功能权限配置</span>
</template>
<el-checkbox-group v-model="selectedRole.permissions">
<div v-for="group in permissionGroups" :key="group.name" style="margin-bottom: 16px">
<h4>{{ group.name }}</h4>
<el-checkbox v-for="p in group.items" :key="p.value" :label="p.value">
{{ p.label }}
</el-checkbox>
</div>
</el-checkbox-group>
<el-button type="primary" @click="savePermissions" style="margin-top: 16px"
:disabled="selectedRole.builtin">
保存权限
</el-button>
</el-card>
<el-empty v-else description="请选择一个角色" />
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Role } from '@/types'
import api from '@/utils/api'
import { ElMessage } from 'element-plus'
const roles = ref<Role[]>([])
const selectedRole = ref<Role | null>(null)
const permissionGroups = [
{
name: '对话',
items: [
{ label: '智能问答', value: 'chat' },
{ label: '产品推荐', value: 'proposal' },
],
},
{
name: '管理',
items: [
{ label: '知识库管理', value: 'kb_manage' },
{ label: '对话日志', value: 'log_view' },
{ label: '用户管理', value: 'user_manage' },
{ label: '数据统计', value: 'team_stats' },
],
},
]
onMounted(async () => {
const res = await api.get('/admin/roles')
roles.value = res.data
})
function selectRole(role: Role) {
selectedRole.value = { ...role, permissions: [...role.permissions] }
}
async function savePermissions() {
if (!selectedRole.value) return
await api.put(`/admin/roles/${selectedRole.value.id}`, {
permissions: selectedRole.value.permissions,
})
ElMessage.success('保存成功')
// 刷新列表
const res = await api.get('/admin/roles')
roles.value = res.data
}
function showAddDialog() {
// TODO: 新建角色弹窗
ElMessage.info('新建角色功能开发中')
}
</script>
```
---
## 七、组件
### 7.1 src/components/ChatEmbed.vue
```vue
<template>
<div style="height: 100%; display: flex; flex-direction: column">
<!-- iframeBaoDan WebApp -->
<iframe
ref="iframeRef"
:src="iframeUrl"
style="flex: 1; border: none; width: 100%"
@load="onIframeLoad"
sandbox="allow-same-origin allow-scripts allow-popups"
/>
<!-- 外挂增强层 -->
<div v-if="hasNewAnswer" class="enhancement-bar">
<ChatCopyButton :text="latestAnswer" />
<ChatSuggestions
:conversation-id="conversationId"
:answer="latestAnswer"
@ask="onSuggestionAsk"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { useChatPolling } from '@/composables/useChatPolling'
import ChatCopyButton from './ChatCopyButton.vue'
import ChatSuggestions from './ChatSuggestions.vue'
const props = defineProps<{
appId: string
userId: string
}>()
const emit = defineEmits<{ 'new-answer': [answer: string] }>()
const iframeRef = ref<HTMLIFrameElement>()
const conversationId = ref('')
// iframe URL同源部署
const iframeUrl = computed(() =>
`/baodan/chat/${props.appId}?user=${props.userId}`
)
// 轮询获取最新回答
const { latestAnswer, hasAnswer, startPolling, stopPolling } = useChatPolling()
// 监听 BaoDan iframe 的 postMessage只用于检测 iframe 加载完成)
function onIframeLoad() {
// BaoDan iframe 加载完成后,开始轮询对话消息
// conversationId 需要从 BaoDan 的会话列表中获取
loadConversationId()
}
async function loadConversationId() {
try {
// 获取用户的最新会话列表,取第一个
const res = await fetch(`/api/chat/sessions?page_size=1`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
})
const data = await res.json()
if (data.data?.items?.length) {
conversationId.value = data.data.items[0].id
startPolling(conversationId.value)
}
} catch {}
}
// 监听 BaoDan postMessageiframe 加载就绪)
function handleMessage(event: MessageEvent) {
if (event.data?.type === 'baodan-chatbot-iframe-ready') {
loadConversationId()
}
}
window.addEventListener('message', handleMessage)
onUnmounted(() => {
window.removeEventListener('message', handleMessage)
stopPolling()
})
function onSuggestionAsk(question: string) {
// 把推荐问题发送给 BaoDan iframe
// 可以通过 postMessage 或直接操作 iframe
}
</script>
<style scoped>
.enhancement-bar {
padding: 8px 16px;
background: #f5f5f5;
border-top: 1px solid #e0e0e0;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
</style>
```
### 7.2 src/components/ChatFilters.vue
```vue
<template>
<div style="padding: 8px 16px; display: flex; gap: 12px; align-items: center; border-bottom: 1px solid #eee; background: #fafafa">
<span style="color: #666; font-size: 13px">筛选范围</span>
<el-select v-model="selectedType" placeholder="选择险种" clearable size="small"
@change="$emit('update:selectedType', $event)">
<el-option label="全部险种" value="" />
<el-option v-for="t in insuranceTypes" :key="t" :label="t" :value="t" />
</el-select>
<el-select v-model="selectedCompany" placeholder="选择保司" clearable size="small"
@change="$emit('update:selectedCompany', $event)">
<el-option label="全部保司" value="" />
<el-option v-for="c in companies" :key="c" :label="c" :value="c" />
</el-select>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
selectedType: string
selectedCompany: string
}>()
defineEmits<{
'update:selectedType': [value: string]
'update:selectedCompany': [value: string]
}>()
const selectedType = ref('')
const selectedCompany = ref('')
const insuranceTypes = ['重疾险', '医疗险', '寿险', '意外险', '车险', '年金险', '储蓄险']
const companies: string[] = [] // 可从后端加载
</script>
```
### 7.3 src/components/ChatCopyButton.vue
```vue
<template>
<el-button size="small" @click="copy" :type="copied ? 'success' : 'default'">
<el-icon v-if="!copied"><DocumentCopy /></el-icon>
<el-icon v-else><Check /></el-icon>
{{ copied ? '已复制' : '复制回答' }}
</el-button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { DocumentCopy, Check } from '@element-plus/icons-vue'
const props = defineProps<{ text: string }>()
const copied = ref(false)
async function copy() {
try {
await navigator.clipboard.writeText(props.text)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {}
}
</script>
```
### 7.4 src/components/ChatSuggestions.vue
```vue
<template>
<div v-if="suggestions.length" style="display: flex; gap: 8px; flex-wrap: wrap">
<el-tag v-for="s in suggestions" :key="s" @click="$emit('ask', s)"
style="cursor: pointer" type="info" effect="plain">
{{ s }}
</el-tag>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import api from '@/utils/api'
const props = defineProps<{
conversationId: string
answer: string
}>()
defineEmits<{ ask: [question: string] }>()
const suggestions = ref<string[]>([])
// 当回答变化时,请求推荐问题
watch(() => props.answer, async (newAnswer) => {
if (!newAnswer) return
try {
const res = await api.post('/chat/suggest', { message: newAnswer })
suggestions.value = res.data.suggestions || []
} catch {
suggestions.value = []
}
})
</script>
```
### 7.5 src/components/RecommendForm.vue
```vue
<template>
<el-form :model="formData" :rules="rules" ref="formRef" label-width="100px">
<el-row :gutter="20">
<!-- 基础信息 -->
<el-col :span="12">
<el-form-item label="姓名" prop="customer.name">
<el-input v-model="formData.customer.name" placeholder="客户姓名(可选)" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="年龄" prop="customer.age">
<el-input-number v-model="formData.customer.age" :min="1" :max="150" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="性别" prop="customer.gender">
<el-radio-group v-model="formData.customer.gender">
<el-radio value="male">男</el-radio>
<el-radio value="female">女</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="健康状况" prop="customer.health_status">
<el-select v-model="formData.customer.health_status" style="width: 100%">
<el-option label="健康" value="健康" />
<el-option label="有既往病史" value="有既往病史" />
<el-option label="慢性病" value="慢性病" />
<el-option label="重大疾病史" value="重大疾病史" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="职业" prop="customer.occupation">
<el-input v-model="formData.customer.occupation" placeholder="如:软件工程师" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="年收入(万)" prop="formData.customer.annual_income">
<el-input-number v-model="formData.customer.annual_income" :min="1" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="月预算(元)" prop="customer.monthly_budget">
<el-input-number v-model="formData.customer.monthly_budget" :min="1" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<!-- 关注险种 -->
<el-form-item label="关注险种" prop="insurance_types">
<el-checkbox-group v-model="formData.insurance_types">
<el-checkbox v-for="t in insuranceTypes" :key="t" :label="t" :value="t" />
</el-checkbox-group>
</el-form-item>
<!-- 保障设置 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="保额目标(万)" prop="coverage_amount">
<el-slider v-model="formData.coverage_amount" :min="10" :max="200" :step="10"
show-input input-size="small" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="保障期限" prop="coverage_period">
<el-select v-model="formData.coverage_period" style="width: 100%">
<el-option label="定期 10 年" value="定期10年" />
<el-option label="定期 20 年" value="定期20年" />
<el-option label="定期 30 年" value="定期30年" />
<el-option label="保至 60 岁" value="保至60岁" />
<el-option label="保至 70 岁" value="保至70岁" />
<el-option label="保至 80 岁" value="保至80岁" />
<el-option label="终身" value="终身" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-button type="primary" size="large" @click="onSubmit" :loading="loading"
style="width: 200px">
生成方案
</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { RecommendRequest } from '@/types'
import { useDraft } from '@/composables/useDraft'
const props = defineProps<{ loading: boolean }>()
const emit = defineEmits<{ submit: [data: RecommendRequest] }>()
const formRef = ref()
const formData = reactive<RecommendRequest>({
customer: { name: '', age: 30, gender: 'male', health_status: '健康', occupation: '', annual_income: 20, monthly_budget: 2000 },
insurance_types: [],
coverage_amount: 50,
coverage_period: '终身',
existing_policies: [],
})
// 自动保存/恢复草稿
useDraft('recommend', { value: formData })
const insuranceTypes = ['寿险', '重疾险', '医疗险', '意外险', '年金险', '储蓄险']
const rules = {
'customer.age': [{ required: true, message: '请输入年龄' },
{ type: 'number' as const, min: 1, max: 150, message: '年龄应在 1-150 之间' }],
'customer.gender': [{ required: true, message: '请选择性别' }],
'customer.health_status': [{ required: true, message: '请选择健康状况' }],
'customer.occupation': [{ required: true, message: '请输入职业' }],
'customer.monthly_budget': [{ required: true, message: '月预算必须大于 0' },
{ type: 'number' as const, min: 1, message: '月预算必须大于 0' }],
insurance_types: [{ type: 'array' as const, min: 1, message: '请至少选择一个关注的险种' }],
coverage_amount: [{ required: true, message: '请选择保额目标' }],
coverage_period: [{ required: true, message: '请选择保障期限' }],
}
async function onSubmit() {
await formRef.value?.validate()
emit('submit', { ...formData })
}
</script>
```
### 7.6 src/components/RecommendResult.vue
```vue
<template>
<div>
<div v-if="result?.proposal?.plans">
<div v-for="plan in result.proposal.plans" :key="plan.name" class="plan-card">
<h4>{{ plan.name }}
<el-tag size="small" type="info">年保费: {{ plan.total_premium.toLocaleString() }}元</el-tag>
</h4>
<table class="plan-table">
<thead><tr><th>产品</th><th>保额</th><th>年保费</th><th>推荐理由</th></tr></thead>
<tbody>
<tr v-for="item in plan.items" :key="item.product_name">
<td>{{ item.product_name }}</td>
<td>{{ item.coverage.toLocaleString() }}万</td>
<td>{{ item.premium.toLocaleString() }}元</td>
<td>{{ item.reason }}</td>
</tr>
</tbody>
</table>
<p style="color: #666; margin-top: 8px">{{ plan.summary }}</p>
</div>
<div style="margin-top: 16px; display: flex; gap: 12px">
<el-button type="primary" @click="window.print()">浏览器打印</el-button>
<el-button @click="$router.push('/recommend')">重新生成</el-button>
</div>
<p style="color: #999; font-size: 12px; margin-top: 16px">
免责声明:以上方案仅供参考,具体保障内容以保险合同条款为准。
</p>
</div>
<el-empty v-else description="暂无推荐结果" />
</div>
</template>
<script setup lang="ts">
import { RecommendResult } from '@/types'
defineProps<{ result: RecommendResult }>()
</script>
<style scoped>
.plan-card { border: 1px solid #e4e7ed; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
.plan-table { width: 100%; border-collapse: collapse; }
.plan-table th, .plan-table td { border: 1px solid #e4e7ed; padding: 8px; text-align: left; }
.plan-table th { background: #f5f7fa; }
</style>
```
---
## 八、开发顺序
| 天 | 任务 | 产出 |
|:---:|------|------|
| 1 | vite.config + tsconfig + index.html + main.ts + App.vue + router | 项目能跑起来 |
| 2 | types/index.ts + utils/api.ts + useAuth.ts + LoginPage.vue | 能登录 |
| 3 | ChatEmbed.vue + ChatFilters.vue + useChatPolling.ts + ChatPage.vue | 能对话 |
| 4 | ChatCopyButton.vue + ChatSuggestions.vue | 对话增强完成 |
| 5-6 | RecommendForm.vue + RecommendPage.vue + RecommendResult.vue + RecommendResult.vue | 推荐功能完成 |
| 7 | useDraft.ts + PermissionsPage.vue | 草稿保存 + 权限管理 |
| 8 | 统计页(如需要) | 统计页可用 |
**8 天完成前端全部开发。**