# 前端开发计划(完整版)
> **架构**:Vue 3 自研前端 + BaoDan WebApp(iframe 嵌入)
> **核心原则**:BaoDan 有的直接用,BaoDan 没有的才自己写
> **开发量**:~880 行代码,11 个文件
> **UI 框架**:Element Plus(表单/表格/导航/弹窗)
> **响应式**:移动端优先,适配企微 H5 内置浏览器
### 页面归属说明(BaoDan 已有 vs 自研)
| 功能模块 | BaoDan 已提供 | 需要自研 |
|---------|-----------|---------|
| 智能问答对话 | BaoDan WebApp(iframe 嵌入) | 筛选器、复制按钮、推荐问题(外挂增强) |
| 知识库管理 | 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
保险智能客服系统
```
### 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
}
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 {
code: number
message: string
data: T
}
export interface PaginatedData {
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(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 | 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(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
保险智能客服
智能问答
产品推荐
权限管理
知识库管理
对话日志
Prompt 管理
模型配置
{{ $route.meta.title }}
{{ username }}
退出登录
```
---
## 六、页面组件
### 6.1 src/pages/LoginPage.vue
```vue
保险智能客服系统
企微登录
─── 或者 ───
登录
```
### 6.2 src/pages/ChatPage.vue
```vue
```
### 6.3 src/pages/RecommendPage.vue
```vue
产品推荐方案
推荐结果
```
### 6.4 src/pages/RecommendResult.vue
```vue
方案推荐结果
返回表单
{{ plan.name }}
年保费: {{ plan.total_premium.toLocaleString() }}元
浏览器打印
重新生成
免责声明:以上方案仅供参考,具体保障内容以保险合同条款为准。
```
### 6.5 src/pages/admin/PermissionsPage.vue
```vue
角色列表
{{ role.name }}
内置
+ 新建角色
{{ selectedRole.name }} — 功能权限配置
{{ group.name }}
{{ p.label }}
保存权限
```
---
## 七、组件
### 7.1 src/components/ChatEmbed.vue
```vue
```
### 7.2 src/components/ChatFilters.vue
```vue
筛选范围:
```
### 7.3 src/components/ChatCopyButton.vue
```vue
{{ copied ? '已复制' : '复制回答' }}
```
### 7.4 src/components/ChatSuggestions.vue
```vue
{{ s }}
```
### 7.5 src/components/RecommendForm.vue
```vue
男
女
生成方案
```
### 7.6 src/components/RecommendResult.vue
```vue
{{ plan.name }}
年保费: {{ plan.total_premium.toLocaleString() }}元
| 产品 | 保额 | 年保费 | 推荐理由 |
| {{ item.product_name }} |
{{ item.coverage.toLocaleString() }}万 |
{{ item.premium.toLocaleString() }}元 |
{{ item.reason }} |
{{ plan.summary }}
浏览器打印
重新生成
免责声明:以上方案仅供参考,具体保障内容以保险合同条款为准。
```
---
## 八、开发顺序
| 天 | 任务 | 产出 |
|:---:|------|------|
| 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 天完成前端全部开发。**