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

42 KiB
Raw Blame 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

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

<!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

{
  "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

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

// ====== 认证 ======
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

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

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

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

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

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

<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

<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

<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

<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

<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

<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

<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

<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

<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

<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

<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

<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 天完成前端全部开发。