# 前端开发计划(完整版) > **架构**: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 ``` --- ## 六、页面组件 ### 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 ``` ### 6.5 src/pages/admin/PermissionsPage.vue ```vue ``` --- ## 七、组件 ### 7.1 src/components/ChatEmbed.vue ```vue