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

1482 lines
42 KiB
Markdown
Raw Normal View History

# 前端开发计划(完整版)
> **架构**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 天完成前端全部开发。**