增加临研安报价端管理功能

This commit is contained in:
william.wan 2026-02-16 19:32:19 +08:00
parent 830b8ccb45
commit d5ec548602
13 changed files with 2939 additions and 496 deletions

View File

@ -1,119 +0,0 @@
# 临床试验责任保险报价流程
> 基于 ProcessOn 流程图RMO保险报价.pos整理
## 一、泳道角色
| 泳道 | 说明 |
|------|------|
| **申办者** | 投保人(采购、项目经理等) |
| **系统** | RMO 网站及 Smart-Ops 系统 |
| **临研安/华泰** | 服务方TPA 经纪) |
| **保司** | 太保/大地、其他保险公司 |
## 二、流程阶段
### 阶段一:信息收集与 AI 报价
1. **登录**(申办者)
- 申办者登录系统
2. **填写项目信息**(申办者)
- 上传项目方案、知情同意书或手动填写报价必备资料
- 可通过邮件/小程序通知进入此步骤
3. **标准报价材料**(系统)
- 系统生成标准报价材料
4. **AI 报价**(系统)
- 基于标准材料进行 AI 报价
5. **报价任务创建**(系统)
- 生成报价任务文档
- 通过 **API Smart-Ops** 与 Smart-Ops 系统对接
6. **报价任务**(临研安/华泰)
- 每保司报价生成一条任务
- 跟踪每一任务的完成状态
7. **报价**(临研安/华泰)
- 发件人RMO@vdano.com
- 向各保司发起报价请求
8. **初步评估**(保司)
- 太保/大地、其他保司分别进行初步评估
9. **核保通过**(保司)
- 保司核保通过后,生成正式报价
10. **正式报价**(保司)
- 输出正式报价文档
### 阶段二:全保司报价
11. **报价任务完成**(临研安/华泰)
- 汇总各保司报价结果
12. **页面对比呈现**(临研安/华泰)
- 保司回复到RMO@vdano.com
- 整理形成可对比的报价方式,呈现给申办者
13. **价格确定**(保司)
- 确定最终报价
### 阶段三:议价
14. **是否含报价?**(判断)
- **否**:邮件/小程序通知 → 返回「填写项目信息」
- **是**:进入「再次登录」流程
15. **再次登录?**(申办者)
- 申办者再次登录系统
16. **在线议价**(临研安/华泰)
- 申办者与经纪人在线沟通、调整报价
17. **主动人工跟进**(临研安/华泰)
- 经纪人对未完成报价的保司进行主动跟进
## 三、流程示意Mermaid
```mermaid
flowchart TB
subgraph 信息收集与AI报价
A[登录] --> B[填写项目信息]
B --> C[标准报价材料]
C --> D[AI报价]
D --> E[报价任务创建]
E --> F[报价任务]
F --> G[报价]
G --> H[初步评估]
H --> I[太保/大地]
H --> J[其他保司]
I --> K[核保通过]
J --> K
K --> L[正式报价]
end
subgraph 全保司报价
L --> M[报价任务完成]
M --> N[页面对比呈现]
N --> O[价格确定]
end
subgraph 议价
O --> P{是否含报价?}
P -->|否| Q[邮件/小程序通知]
Q --> B
P -->|是| R[再次登录]
R --> S[在线议价]
S --> T[主动人工跟进]
end
```
## 四、关键说明
- **API Smart-Ops**:报价信息通过 API 传输到 Smart-Ops 系统,用于后续报价、投保、理赔等流程
- **任务跟踪**:每保司报价生成一条任务,系统跟踪每一任务的完成状态
- **回复渠道**:保司报价回复至 RMO@vdano.com
- **数据归属**:所有报价数据属于申办者,在投保人租户下呈现

View File

@ -45,98 +45,109 @@
#### 2.1.1 免登录浏览区(已实现)
```
首页(免登录)
├── 主页
│ ├── Banner紧凑型生命科学风险管理的保险与保证方案含「获取报价」入口
│ ├── 风险管理体系
│ │ ├── 法律法规
│ │ ├── 实践指南
│ │ ├── 行业动态
│ │ └── 药物警戒
│ └── 角色专区(各方职责快捷入口)
├── 首页
│ ├── Hero赋能生命科学风险管理患者安全始终第一含「获取报价」「了解更多」入口
│ ├── RMO价值主张 / 解决方案
│ ├── 核心能力(患者安全专家、合作保司、团标与数字化、服务时限)
│ ├── 知识资源法规指南、保险知识、PV与保险、常见问题
│ └── 联系我们
├── 风险职责(原"各方关注"
│ ├── 风险职责总览
│ ├── 申办者职责
│ ├── 持有人职责
│ ├── 受试者专区
│ ├── 研究中心
│ └── CXO职责
├── 关于RMO下拉
│ ├── RMO概述/about/overview
│ ├── 合作保司、专家经纪、第三方机构
│ └── 风险职责
│ ├── 风险职责总览(/concern
│ ├── 申办者职责(/sponsor
│ ├── 持有人职责(/holder
│ ├── 研究中心(/institution
│ ├── 参与者(/participant
│ └── CXO职责/service-provider
├── 临床试验原RMO模式
├── 解决方案(下拉)
│ ├── 药物警戒PV服务、AI工具
│ ├── 临床保险:保险方案(/rmo-mode/insurance、保证设计/rmo-mode/guarantee
│ └── 产品保险:保险方案、保证设计(/post-market/*
├── 知识资源(下拉)
│ ├── PV知识法规指南、AI应用
│ ├── 保险知识(基础知识、国外比较、条款标准)
│ ├── 常见问题(/faq含职责逻辑、保障范围、PV与保险
│ └── 学习中心(案例学习、培训视频、考试中心)
├── 临床试验(/rmo-mode
│ ├── 临床试验模块首页
│ ├── 保险方案(含「获取报价」入口)
│ ├── 保险方案(含「获取报价」入口,跳转项目报价页
│ ├── 保证方案
│ └── 保险保证
├── 上市应用
├── 上市应用(/post-market
├── 海外风险(/overseas
├── 海外风险
├── 资源中心(原"体系管理"
├── 资源中心(/system-management
│ ├── 资源中心首页
│ ├── 法律法规(/laws
│ ├── 实践指南
│ ├── 培训材料
│ └── 常见问题
└── 登录(右上角)
├── 联系我们(/contact
└── 登录(右上角,/login
```
### 2.1.2 登录后系统区(待开发)
#
登录后依然可见登录前的菜单。如果用户没有账号,引导其注册账号。所有账号将会有管理员进行审批,以使其获得查看数据的权限。
### 2.1.2 登录后系统区(已实现)
登录后使用 DashboardLayout顶部保留首页、风险职责、临床试验、上市应用、海外风险、资源中心等导航入口侧边栏根据角色显示相应菜单。用户由管理员创建账号无需自助注册。
```
登录后系统(需权限验证)
├── 工作台
│ ├── 我的保障
│ ├── 我的项目
│ ├── 待办任务
│ └── 快捷方式(获取报价、申请保障、申请理赔)
登录后系统(需权限验证,已实现)
├── 工作台(/dashboard
│ ├── 数据统计卡片(询价项目、生效保障、全部项目)
│ ├── 待处理任务(待处理的报价项目、待处理的理赔)
│ └── 快捷方式(投保人可见:获取报价、报价页面、申请理赔、方案介绍、培训支持)
├── 项目列表
│ ├── 项目列表(试验项目编号、试验题目、保障范围、承保公司、承保状态)
│ └── 项目明细(详细信息、相关文档、操作按钮)
├── 项目报价(需权限:投保人,/dashboard/project-quotes
│ └── PV报价、临床试验保险报价、产品责任保险报价分表单提交
├── 询价列表(需权限:保险人)
│ └── 询价列表(申办者、项目编号、试验题目、保障范围、特别约定、成交情况)
├── 项目列表(需权限:投保人,/dashboard/projects
│ ├── 项目列表
│ └── 项目明细(/dashboard/projects/:id
├── 理赔进度(需权限:投保人、保险人
│ └── 理赔评估列表(项目编号、试验题目、保障范围、承保公司、承保状态
├── 询价列表(需权限:保险人,/dashboard/inquiries
│ └── 询价列表、询价明细(/dashboard/inquiries/:id
└── 智能工具(所有登录用户可见)
├── 理赔进度(需权限:投保人、保险人,/dashboard/claims
│ └── 理赔评估列表、理赔详情(/dashboard/claims/:id
└── 智能工具(/dashboard/tools
├── 保费测算工具(所有登录用户可见)
├── ICF智能修改需权限投保人
└── 方案风险评分(需权限:投保人)
├── 方案风险评分(需权限:投保人)
├── 方案风险评估所有登录用户可见AI 评估方案风险)
└── 药安查(所有登录用户可见,药物安全数据查询)
```
### 2.2 路由与页面结构
#### 2.2.0 路由架构说明
- **免登录浏览区路由**所有路由在 `App.tsx` 中定义,使用 `Layout` 组件包裹Header + Footer
- **登录后系统路由**:所有 `/dashboard/*` 路由使用 `ProtectedRoute` 包裹,使用 `DashboardLayout` 组件(侧边栏 + 主内容区)
- **路由守卫**未登录用户访问登录后页面应重定向到 `/login`
- **免登录浏览区路由**`router/index.ts` 中定义,使用 `Layout` 组件包裹Header + Footer + Breadcrumb
- **登录后系统路由**:所有 `/dashboard/*` 路由使用 `ProtectedRoute` 包裹,内层使用 `DashboardLayout`(侧边栏 + 主内容区)
- **路由守卫**`router.beforeEach` 检查登录状态,未登录访问 `/dashboard` 重定向至 `/login` 并携带 `from` 查询参数
### 2.3 核心页面详细需求
#### 2.2.1 首页(已实现)
**核心元素:**
- **Banner区域**(紧凑型)
- 标题:生命科学风险管理的保险与保证方案
- 按钮1保险方案链接到 `/rmo-mode/insurance`
- 按钮2保证方案链接到 `/rmo-mode/guarantee`
- 按钮3获取报价打开报价申请流程弹窗或跳转报价页面见 2.3.2.2
- **风险管理体系区域**
- 展示四个环节:法律法规、实践指南、行业动态、药物警戒
- 以卡片形式展示,带图标和说明文字
- **角色专区**(各方职责快捷入口)
- 申办者职责(链接到 `/sponsor`
- 持有人职责(链接到 `/holder`
- 研究中心(链接到 `/institution`
- 受试者专区(链接到 `/participant`
- CXO职责链接到 `/service-provider`
- **Hero 区域**(全屏滚动)
- 标题:赋能生命科学风险管理
- 副标题:患者安全始终第一
- 按钮1了解更多链接到 `/about/overview`
- 按钮2获取报价未登录时引导登录已登录跳转 `/dashboard/project-quotes`
- **RMO 价值主张/解决方案**RmoValueProposition 组件)
- **核心能力**(卡片展示)
- 100+ 患者安全专家、10+ 合作保司、1st 团标与数字化、7/15 服务时限
- **知识资源**(快捷入口)
- 法规指南、保险知识、PV与保险、常见问题
- **联系我们**(入口链接)
**交互要求:**
- Banner区域紧凑型设计
- 全屏分节滚动,带指示点导航
- 响应式设计,适配不同屏幕尺寸
- 所有链接按钮可点击跳转到对应页面
#### 2.2.2 临床试验页面原RMO模式已实现
**路由结构:**
@ -148,18 +159,12 @@
**内容模块:**
1. **保险方案页面**(已实现)
- 基础保障、全面保障说明
- 保险条款标准核心内容
- **获取报价入口**:页面内提供「获取报价」按钮,点击后打开报价申请流程(弹窗或报价页面,见 2.3.2.2
- 保险服务内容:
- 保险合同审查
- 理赔审查
- 保险条款修订
- 理赔规则制定
- 条款标准制定
- **获取报价入口**:页面内提供「获取报价」「前往报价页面」按钮;未登录点击引导登录,已登录跳转 `/dashboard/project-quotes`
- 基础保障、全面保障、保险条款标准核心内容
- 保险服务内容:保险合同审查、理赔审查、保险条款修订、理赔规则制定、条款标准制定
- 服务供应商展示(保险公司、经纪公司 logo
2. **保证方案页面**(已实现)
2. **保证方案页面**(已实现,导航中称为「保证设计」)
- 保证基金的基本逻辑(图示)
- 保证基金管理形式比较(表格)
- **自保(专项风险管理基金)**
@ -224,54 +229,62 @@
#### 2.2.4 资源中心(原"体系管理",已实现)
**路由结构:**
- `/system-management`资源中心首页ResourceCenterOverview
- `/system-management/laws`:法律法规
- `/system-management/practice-guide`:实践指南
- `/system-management/training`:培训材料
- `/system-management/faq``/faq`:常见问题
**内容模块:**
1. **实践指南**(已实现)
1. **法律法规**(已实现)
- 临床试验与风险管理相关法律法规
2. **实践指南**(已实现)
- 操作指南文档
- 最佳实践案例
- 流程规范
2. **培训材料**(已实现)
3. **培训材料**(已实现)
- 培训视频
- 培训文档
- 培训课程
3. **常见问题**(已实现)
4. **常见问题**(已实现)
- FAQ列表
- 问题分类
- 搜索功能(待实现)
#### 2.2.5 上市应用(已实现)
- **路由**`/post-market`
- **内容**上市应用相关说明
- **路由**`/post-market`、`/post-market/insurance`、`/post-market/guarantee`
- **内容**药品上市后风险管理与药物警戒的保险与保障方案(当前为建设中占位页)
#### 2.2.6 海外风险(已实现)
- **路由**`/overseas`
- **内容**海外风险相关说明
- **内容**跨境临床试验与海外市场的风险管理保险与保障(当前为建设中占位页)
#### 2.2.7 登录页面(已实现)
- **路由**`/login`
- **功能**:账号密码登录表单
- **后续**:登录成功后根据用户角色跳转到工作台(`/dashboard`
- **功能**:用户名/邮箱 + 密码登录表单测试账号admin、policyholder、insurer密码123456
- **登录逻辑**:登录成功后跳转到工作台(`/dashboard`);支持 `from` 查询参数回跳
- **说明**:用户由管理员创建,无自助注册;记住我、忘记密码为 UI 占位
### 2.3 登录后系统详细需求(待开发
### 2.3 登录后系统详细需求(已实现
#### 2.3.1 系统架构
- **布局**:侧边栏导航 + 主内容区DashboardLayout
- **侧边栏导航结构**
#### 2.3.1 系统架构(已实现)
- **布局**:侧边栏导航 + 主内容区DashboardLayout
- **顶部导航**:登录后仍可访问首页、风险职责、临床试验、上市应用、海外风险、资源中心
- **侧边栏导航结构**(根据角色动态显示):
- 工作台
- 项目报价(需权限:投保人,`/dashboard/project-quotes`
- 项目列表(需权限:投保人)
- 询价列表(需权限:保险人)
- 理赔进度(需权限:投保人、保险人)
- 智能工具(所有登录用户可见)
- 保费测算工具(所有登录用户可见)
- ICF智能修改需权限投保人
- 方案风险评分(需权限:投保人)
- **权限控制**:侧边栏菜单根据用户角色动态显示有权限的菜单项
- 智能工具
- 保费测算工具(所有用户)
- ICF智能修改仅投保人
- 方案风险评分(仅投保人)
- 方案风险评估(所有用户)
- 药安查(所有用户)
#### 2.3.2 工作台页面Dashboard
**路径**`/dashboard`
@ -311,33 +324,20 @@
- 点击可跳转到对应详情页
- 其他角色:根据权限显示相应内容
##### 2.3.2.3 报价申请流程(获取报价)
**入口**:店家首页 Banner「获取报价」、保险方案页「获取报价」、登录后工作台快捷方式「获取报价」。点击后打开报价申请流程弹窗或独立报价页面建议统一为弹窗以保持上下文
##### 2.3.2.3 报价申请流程(获取报价)(已实现)
**流程对应页面/弹窗需求:**
**入口**:首页「获取报价」、保险方案页「获取报价」、工作台快捷方式「获取报价」。当前实现为**跳转至项目报价页面** `/dashboard/project-quotes`;未登录时引导先登录。
1. **报价需提交的资料(第一步)**
- **表单字段**:项目方案编号、项目标题、申办者、项目分期。
- **填写方式**
- **手动填写**:用户逐项输入上述字段。
- **上传项目方案**:用户上传项目方案文件,系统调用 AI 识别并解析,自动填充「项目方案编号、项目标题、申办者、项目分期」;用户可核对并修改。
- **操作**:资料填写完成后,显示「生成报价」按钮。
**项目报价页面**`/dashboard/project-quotes`)已实现三种报价类型,以可折叠卡片展示:
1. **PV报价**:药物警戒服务报价,表单含姓名、邮箱、电话、公司、职位、业务问题、验证码、隐私承诺
2. **临床试验保险报价**:项目类型、风险等级、保障金额、受试者人数、试验周期、备注等
3. **产品责任保险报价**:产品类型、风险等级、保障金额、销售区域等
2. **生成报价(第二步)**
- 用户点击「生成报价」后,系统调用 AI 服务,基于当前资料自动生成报价。
- 报价结果展示在同一弹窗/页面内AI 生成报价区域)。
**临床试验保险弹窗流程**QuoteRequestModal已实现但当前未接入入口
- 弹窗内含:手动填写/上传项目方案AI 识别)、项目方案编号/标题/申办者/分期、生成报价AI、获取精准报价
- 与需求 3.3.2.1 流程一致,可将入口改为打开弹窗以保持上下文
3. **获取精准报价(第三步)**
- 用户点击「获取精准报价」后,系统将报价资料整合为标准化询价内容,以 Email 发送至各保司。
- 前端可展示「已发送至保司,等待保司回复」等状态;该次报价申请进入「询价中」状态,可在工作台「待处理的报价项目」中查看。
4. **精准报价回显(系统侧 + 前端)**
- **系统侧**:从 rmo@vdano.com 拉取保司回复邮件,解析并提取各保司报价;由临研安进行审核,审核通过的报价写入报价记录并关联该次申请。
- **前端**:报价页面/报价详情支持展示「精准报价」结果(各保司、报价内容等);工作台「待处理的报价项目」中该条状态更新为「已回显精准报价」,点击可进入报价页面查看。
**路由建议**(若采用独立页面):`/quote` 或 `/dashboard/quote`(新建申请),`/dashboard/quote/:id`(查看某次报价申请及精准报价结果)。若采用弹窗则无需单独路由,弹窗内可提供「查看我的报价」链接至投保人报价列表或工作台待处理任务。
**权限**:获取报价入口对免登录用户可开放(弹窗内可引导登录后再发精准报价);登录后工作台入口仅投保人可见。
**权限**:项目报价页面仅投保人可见;获取报价入口对免登录用户点击时引导登录。
#### 2.3.3 项目列表页面ProjectList
**路径**`/dashboard/projects`
@ -467,17 +467,18 @@
- 相关文档
- 操作按钮:查看明细、处理理赔
#### 2.3.8 智能工具页面Tools
#### 2.3.8 智能工具页面Tools(已实现)
**路径**`/dashboard/tools`
**权限说明**:所有登录用户可见
**工具入口页**
- 展示三个工具的入口卡片
- 保费测算工具(所有登录用户可见)
- ICF智能修改仅投保人可见
- 方案风险评分(仅投保人可见)
- 每个工具卡片显示:工具名称、工具描述
**工具入口页**(已实现):
- 展示五个工具的入口卡片
- 保费测算工具(所有用户)
- ICF智能修改仅投保人
- 方案风险评分(仅投保人)
- 方案风险评估所有用户AI 评估方案信息不足风险与偏倚风险)
- 药安查(所有用户,药物安全数据查询、不良反应与警戒信息检索)
##### 2.3.8.1 保费测算工具PremiumCalculator
**路径**`/dashboard/tools/premium-calculator`
@ -512,6 +513,16 @@
- 自动计算风险评分
- 显示评分结果、风险等级、改进建议
##### 2.3.8.4 方案风险评估ProtocolRiskAssessment已实现
**路径**`/dashboard/tools/protocol-risk`
**功能**:借助 AI 评估方案信息不足风险与偏倚风险
##### 2.3.8.5 药安查DrugSafetyQuery已实现
**路径**`/dashboard/tools/drug-safety`
**功能**:药物安全数据查询,不良反应与警戒信息检索
---
## 三、核心业务逻辑说明
@ -766,14 +777,14 @@ flowchart TB
#### 4.1.1 免登录浏览区(已实现)
- **访问方式**:网站免登录访问,所有内容公开
- **页面范围**:首页、风险职责、临床试验、上市应用、海外风险、资源中心、常见问题
- **页面范围**:首页、关于RMORMO概述、合作保司、专家经纪、第三方机构、风险职责及子页)、解决方案(药物警戒、临床保险、产品保险)、知识资源、临床试验、上市应用、海外风险、资源中心、常见问题、联系我们
#### 4.1.2 登录后系统(待开发
- **登录方式**账号、密码登录
- **用户角色**:投保人、保险人
- **权限控制**根据用户角色呈现有权限的内容
- **路由守卫**:未登录用户访问登录后页面应重定向到登录页
- **状态管理**需要全局状态管理用户信息、角色权限、登录状态(建议使用 Context API 或 Zustand
#### 4.1.2 登录后系统(已实现
- **登录方式**用户名/邮箱 + 密码登录 ✅
- **用户角色**:投保人、保险人AuthStore 中 user.role
- **权限控制**侧边栏、工作台内容根据角色动态显示 ✅
- **路由守卫**:未登录访问 `/dashboard` 重定向至 `/login`,支持 `from` 回跳 ✅
- **状态管理**Pinia AuthStore 管理用户信息、token、登录状态localStorage 持久化 ✅
#### 4.1.3 数据权限说明
- **数据权限维度**
@ -785,9 +796,9 @@ flowchart TB
- 保险人只能查看和操作分配给自己的项目
- **项目状态**:信息收集、询价中、已报价、已成交、已失效
### 4.2 登录后系统功能模块(待开发
### 4.2 登录后系统功能模块(已实现
#### 4.2.1 工作台Dashboard
#### 4.2.1 工作台Dashboard(已实现)
- **路径**`/dashboard`
- **布局**:侧边栏导航 + 主内容区DashboardLayout
- **权限**:所有登录用户可见,但内容根据角色不同
@ -815,15 +826,13 @@ flowchart TB
- 待处理的报价项目
- 待处理的理赔
#### 4.2.2 报价申请(获取报价)
- **入口**:首页 Banner、保险方案页、登录后工作台「获取报价」
- **形式**:弹窗或独立页面(建议弹窗);对应业务流程图见 3.3.2.1
- **功能要点**
- 报价资料表单:项目方案编号、项目标题、申办者、项目分期;支持手动填写或上传项目方案由 AI 识别并自动填充
- 生成报价:调用 AI 生成报价并展示
- 获取精准报价:系统整合资料后以 Email 发送至各保司;前端展示「询价中」等状态
- 精准报价回显:系统从 rmo@vdano.com 拉取保司回复,临研安审核通过后,报价回写到该次申请,前端在报价页面/报价详情展示
- **投保人**:可在工作台「待处理的报价项目」中查看各次申请状态并进入报价详情查看精准报价
#### 4.2.2 报价申请(获取报价)(已实现)
- **入口**:首页、保险方案页、工作台「获取报价」→ 跳转 `/dashboard/project-quotes`
- **形式**独立项目报价页面已实现临床试验保险弹窗流程QuoteRequestModal已实现但入口未接入
- **项目报价页面功能**
- PV报价、临床试验保险报价、产品责任保险报价三种可折叠表单
- 各类型有独立表单字段,提交后展示提交状态
- **临床试验保险弹窗**QuoteRequestModal手动填写/上传方案、AI 识别、生成报价、获取精准报价,与 3.3.2.1 流程一致
#### 4.2.3 项目列表ProjectList
- **路径**`/dashboard/projects`
@ -870,13 +879,15 @@ flowchart TB
- 列表字段:项目编号、试验题目、保障范围、承保公司、承保状态
- 操作按钮:查看明细、处理理赔
#### 4.2.7 智能工具Tools
#### 4.2.7 智能工具Tools(已实现)
- **路径**`/dashboard/tools`
- **权限**:所有登录用户可见
- **工具入口页**:展示三个工具的入口卡片
- 保费测算工具(所有登录用户可见)
- ICF智能修改仅投保人可见
- 方案风险评分(仅投保人可见)
- **工具入口页**:展示五个工具的入口卡片
- 保费测算工具(所有用户)
- ICF智能修改仅投保人
- 方案风险评分(仅投保人)
- 方案风险评估(所有用户)
- 药安查(所有用户)
##### 4.2.7.1 保费测算工具PremiumCalculator
- **路径**`/dashboard/tools/premium-calculator`
@ -891,6 +902,14 @@ flowchart TB
- **路径**`/dashboard/tools/risk-scoring`
- **功能**:对试验方案进行风险评分
##### 4.2.7.4 方案风险评估ProtocolRiskAssessment
- **路径**`/dashboard/tools/protocol-risk`
- **功能**AI 评估方案信息不足风险与偏倚风险(已实现)
##### 4.2.7.5 药安查DrugSafetyQuery
- **路径**`/dashboard/tools/drug-safety`
- **功能**:药物安全数据查询、不良反应与警戒信息检索(已实现)
### 4.3 内容管理(部分已实现)
- **内容发布**:支持发布活动动态、资源文件(待实现后台管理)
- **内容分类**:支持多级分类管理(资源中心已实现分类展示)
@ -959,31 +978,31 @@ flowchart TB
## 七、技术建议
### 7.1 技术栈(已采用)
- **前端框架**React 18.2.0 ✅
- **前端框架**Vue 3.4.0 ✅
- **语言**TypeScript 5.2.2 ✅
- **构建工具**Vite 5.0.8 ✅
- **路由**react-router-dom 6.20.0 ✅
- **路由**Vue Router 4.2.0 ✅
- **状态管理**Pinia 2.1.0 ✅
- **样式**:原生 CSS + CSS 变量 ✅
- **UI组件库**:无(使用原生 CSS 实现)✅
### 7.2 登录后系统技术栈建议(待实现)
- **状态管理**Context API 或 Zustand推荐 Zustand简单易用
- **数据请求**axios + React Query 或 SWR用于服务端数据缓存、同步、重试
- **路由守卫**ProtectedRoute 组件(检查登录状态)
- **权限控制**基于角色的权限控制RoleGuard 组件或 HOC
- **表单管理**React Hook Form如需要复杂表单
### 7.2 登录后系统技术栈(已采用)
- **状态管理**PiniaAuthStore 管理登录状态、用户信息、角色)✅
- **路由守卫**ProtectedRoute 组件(检查登录状态,未登录重定向至 /login
- **权限控制**:基于角色的权限控制(侧边栏根据 user.role 动态显示菜单)✅
- **数据请求**:当前为模拟数据,后续可接入 axios + 接口
### 7.3 其他技术建议
### 7.3 其他技术
- **图表库**ECharts / D3.js用于模式图可视化如需要
- **文件预览**@vue-office 或类似库(用于文档预览)
### 7.2 后端建议(未来扩展)
### 7.4 后端建议(未来扩展)
- **后端框架**Node.js / Python Django / Java Spring Boot
- **数据库**MySQL / PostgreSQL
- **文件存储**OSS / 本地存储
- **API设计**RESTful API
### 7.3 部署建议
### 7.5 部署建议
- **服务器**:云服务器(阿里云/腾讯云等)
- **CDN**静态资源CDN加速
- **域名SSL**配置HTTPS证书
@ -1001,36 +1020,25 @@ flowchart TB
6. ✅ 资源中心(实践指南、培训材料、常见问题)
7. ✅ 登录页面UI已实现登录逻辑待开发
### 8.2 第二阶段:登录后系统(待开发
1. 用户认证与权限系统
- 登录接口对接
- 全局状态管理AuthContext
### 8.2 第二阶段:登录后系统(已完成 ✅
1. 用户认证与权限系统
- 登录逻辑Pinia AuthStore当前为模拟接口
- 全局状态管理AuthStore
- 路由守卫ProtectedRoute
- 角色权限控制
2. ⏳ 工作台页面
- 我的保障、我的项目、待办任务
- 快捷方式(获取报价、申请保障、申请理赔)
- 待处理的报价项目(含状态与跳转报价详情)
3. ⏳ 报价申请流程(获取报价)
- 多入口(首页、保险方案页、工作台)打开报价弹窗/页面
- 报价资料表单(手动填写 + 上传方案 AI 识别)、生成报价、获取精准报价
- 报价状态与精准报价回显(系统拉取 rmo@vdano.com、临研安审核后展示
4. ⏳ 项目列表模块
- 项目列表(带权限过滤)
- 项目明细页
5. ⏳ 询价列表模块(保险人)
- 询价列表(需权限控制)
- 询价详情、处理报价
6. ⏳ 理赔进度模块(投保人、保险人)
- 理赔评估列表(需权限控制)
- 理赔详情、处理理赔
7. ⏳ 智能工具模块(所有登录用户可见)
- 保费测算工具(所有登录用户可见)
- ICF智能修改仅投保人可见
- 方案风险评分(仅投保人可见)
8. ⏳ 登录后系统布局
- DashboardLayout侧边栏 + 主内容区)
- 侧边栏导航(根据角色动态显示)
- 角色权限控制(侧边栏、工作台根据角色显示)
2. ✅ 工作台页面
- 数据统计卡片、待处理任务、快捷方式
- 投保人快捷方式:获取报价、报价页面、申请理赔、方案介绍、培训支持
3. ✅ 报价申请流程(获取报价)
- 多入口跳转至项目报价页(/dashboard/project-quotes
- 项目报价页含 PV报价、临床试验保险报价、产品责任保险报价
- 临床试验保险弹窗QuoteRequestModal已实现可接入入口
4. ✅ 项目列表模块(带权限过滤)
5. ✅ 询价列表模块(保险人权限控制)
6. ✅ 理赔进度模块(投保人、保险人可见)
7. ✅ 智能工具模块
- 保费测算、ICF智能修改、方案风险评分、方案风险评估、药安查
8. ✅ DashboardLayout侧边栏 + 主内容区)
### 8.3 第三阶段:功能增强(未来扩展)
1. 在线咨询功能
@ -1083,22 +1091,24 @@ flowchart TB
- 相关图片资源:申办者和持有人责任风险管理相关图片
### 10.3 项目状态
- **当前版本**v1.1
- **当前版本**v1.2
- **开发状态**
- ✅ 免登录浏览区:已完成,可演示
- ⏳ 登录后系统:待开发
- ✅ 免登录浏览区:已完成
- ✅ 登录后系统:已完成(含工作台、项目报价、项目列表、询价列表、理赔进度、智能工具)
- ⏳ 待完善:报价精准回显、后端接口对接、上市应用/海外风险内容填充
- **技术栈**Vue 3 + TypeScript + Vite + Pinia + Vue Router
- **最后更新**2025年2月
---
**文档版本**v1.1
**文档版本**v1.2
**创建日期**2025年1月
**最后更新**2025年2月
**最后更新**2025年2月(根据网站代码同步更新)
### 10.4 开发状态说明
- **✅ 已实现**:功能已开发完成,需求描述与代码实现一致
- **⏳ 待开发**:功能规划已完成,待开发实现
- **未来扩展**功能需求已明确,优先级较低,后续版本实现
- **✅ 已实现**:功能已开发完成,需求文档已与代码实现对齐
- **⏳ 待完善**:部分流程(如报价精准回显、后端接口)需后续对接
- **未来扩展**上市应用/海外风险内容、在线咨询、后台管理等

View File

@ -23,14 +23,19 @@
## 报价必备资料
- 项目方案编号(必填)
- 项目标题(必填)
- 投保人名称(必填)
- 申办者名称(必填)
- 受试药物名称(必填)
- 项目分期I、II、III、IV期、其他___必填
- 试验受试者人数(必填)
- 报价用途:制定项目预算;申请试验开展;
- 每人责任限额万元选项≤10、15、20、30、≥50[非必填可以AI推荐]
以下四项可以重复,即每次提交信息,可以申请多个保险方案报价,每一个即一个报价方案:
{- 每人责任限额万元选项≤10、15、20、30、≥50[非必填可以AI推荐]
- 累计责任限额[非必填可以AI推荐]
- 每次事故免赔额[非必填可以AI推荐]
- 拟投保人数 (默认为受试者总人数);
}
## 申请报价页面
1. 简洁页面:
@ -44,11 +49,13 @@
除上述简洁页面需要的字段,补充以下西信息:
- 疾病类型(选项:癌症、心脏类疾病、生育类疾病、疫苗试验、其他)
- 每一试验受试者的试验期限选项6、612]、1224]、2436]、3648]、4860]、6072]、7284]、84
- 项目预计时长选项6、612]、1224]、2436]、3648]、4860]、6072]、7284]、84
- 质量管理水平选项通过GMP、ISO等认证质量标准要求高管理先进、通过必要的认证质量标准要求较高管理较先进、其他情况
- 历史理赔赔付情况(选项:极少、较少、较多、极多)
- 试验受试者类型(选项:儿童、老年人、孕妇、其他成年人)
- 试验受试者健康状况(选项:良好、一般、较差)
- 安全性监测措施(选项:完善、较完善、不完善)
- 特别要求(备注对保障范围、理赔条件的要求)
# 报价流程
- 投保人填写报价资料;
@ -96,10 +103,125 @@ flowchart TB
- **回复渠道**:保司报价回复至 RMO@vdano.com
- **数据归属**:所有报价数据属于申办者,在投保人租户下呈现
# 标准报价信息表
| 项目 | 【取值】 |
|------------------------|------|
| **承保险种** | 【保险信息页面的标题】 |
| **投保人** | 【投保人名称】 |
| **被保险人** | 【申办者名称】 |
| **承保试验** | 【项目标题】 |
| **方案号** | 【项目方案编号】 |
| **研究分期** | 【项目分期】 |
| **受试者人数** | 【试验受试者人数】|
| **保单期限** | 【项目预计时长】 |
| **保单限额** | 【累计责任限额】|
| **每人责任限额** | 【每人责任限额】|
| **每次事故免赔额** | 【每次事故免赔额】|
| **特别约定** | 【特别要求】 |
# 与Smart-OPS的衔接
- 收集到的报价信息报价信息将传输到Smart-OPS系统工作台进行后续的报价、投保、理赔等流程
- 临研安人员登录Smart-OPS系统进行报价所有报价数据属于申办者所有该数据将在投保人租户下呈现
- 临研安人员在工作台页面处理所有报价的评估、整理。
-
# 在Smart-OPS中的操作
- 临研安/华泰经纪工作人员登录Smart-OPS,进入保险报价菜单;
- 显示报价任务列表,列表字段包括:投保人、项目编号、项目标题、主险限额、附加险限额、免赔额、保司、报价状态;操作按钮包括:编辑、查看、发送保司、整理报价
## 报价状态包括:
- 已创建:报价任务创建;
- 初步评估:针对大地、太保广州,临研安给出的初步评估报价。
- 正式报价:保司已回复初步报价
- 报价完成报价信息通过API接口返回到保险报价页面。
## 报价详情页面:点击编辑或查看后,进入报价任务的详情页面,该页面包括:
- 之前收集到的全部信息;
- 对应每一方案,发出给保司的邮件、回收的邮件;
## 数据呈现结构:
数据结构设计:项目、报价方案、保司报价任务三级结构
### 项目Project
- 项目ID
- 项目名称
- 其他项目全局信息(如申办者、阶段、试验类型等)
- 报价方案列表QuotationSchemes
- 研究分期
### 报价方案QuotationScheme
- 方案ID
- 关联项目ID
- 方案名称/标题 【以限额作为方案名称】
- 方案编号
- 受试者人数
- 项目周期/保险期限
- 主险/附加险需求
- 其他对保险的特别要求
- 保司报价任务列表InsurerQuotationTasks
### 保司报价任务InsurerQuotationTask
- 任务ID
- 关联方案ID
- 保险公司/保司名称
- 主险限额/附加险限额
- 每人/每次事故限额与免赔额
- 报价金额
- 特别约定
- 审核状态/报价状态(如已创建、初步评估、正式报价、报价完成等)
- 任务处理记录(分派、发送、回复等信息)
- 关联邮件/回收邮件集合
## Smart-OPS页面所需操作功能设计
在Smart-OPS保险报价页面应增加以下关键操作按钮及其流程说明
### 1. 生成标准报价信息
- **功能说明**根据项目及方案已收集到的基础信息一键生成标准格式的报价表可导出为Excel或PDF便于对保司、项目组进行沟通。
- **操作入口**:报价任务列表页、报价方案详情页增加“报价信息”按钮。
- **流程**
- 点击按钮后,系统根据当前方案或所有报价任务信息,生成符合行业/平台模板的报价文件,可对内容进行预览和导出。
### 2. 风险评估
- **功能说明**支持RMO团队或临研安对方案进行定量/定性的风险评估,便于后续作为保险条款的输入或决策支持。
- **操作入口**:报价方案详情页,增加“风险评估”按钮。
- **流程**
- 点击后打开风险评估表单,根据受试药物安全特征、试验设计、受试者状态对项目风险进行评估;
- 提交后,评估结果关联于当前方案,风险评估历史可追溯。
### 3. 申请报价
- **功能说明**:将已整理好的方案信息,一键发起给各保险公司(保司)申请正式报价。
- **操作入口**:报价任务列表页、方案详情页,增加“申请报价”按钮。
- **流程**
- 选择需申请的保司、确认发送内容/模板;
- 支持批量申请多个保司;
- 系统记录申请时间、发送状态,自动更新报价任务状态。
### 4. 整理报价
- **功能说明**:归集各保司返回的报价,生成汇总对比表,便于客户/投保人选定。
- **操作入口**:报价方案详情页、报价任务详情页,增加“整理报价”按钮。
- **流程**
- 临研安填写各保司返回的正式报价数据;
- 一键合并生成报价对比表可导出Excel/PDF
- 系统标记哪家保司已完成报价,未完成自动提醒跟进。
### 5. 返回报价
- **功能说明**:选择最终确定的报价,将结果通过系统返回给投保人,并自动同步到项目报价页面。
- **操作入口**:报价方案详情页内,完成整理报价后显示“返回报价”按钮。
- **流程**
- 确认最终方案和保司,点击“返回报价”;
- 支持填写补充说明或上传附件;
- 返回信息同步到投保人端页面。
### 功能按钮汇总(页面动线建议)
- 报价任务列表页:
- [生成标准报价信息] [申请报价] [整理报价]
- 报价方案详情页:
- [风险评估] [申请报价] [整理报价] [返回报价]
- 保司报价任务详情页:
- [整理报价] [返回报价]
> 每一个操作需在系统内形成业务日志,支持后续审计和流程追踪。

View File

@ -87,6 +87,8 @@ const pathLabels: Record<string, string> = {
// Dashboard
'/dashboard': '工作台',
'/dashboard/project-quotes': '项目报价',
'/dashboard/quote-compare': '报价对比',
'/dashboard/smart-ops/quote-tasks': '保险报价Smart-OPS',
'/dashboard/projects': '项目列表',
'/dashboard/projects/:id': '项目详情',
'/dashboard/inquiries': '询价列表',
@ -111,6 +113,8 @@ function getLabel(path: string): string {
if (inquiryMatch) return '询价详情'
const claimMatch = path.match(/^(\/dashboard\/claims\/)[^/]+$/)
if (claimMatch) return '理赔详情'
const quoteTaskMatch = path.match(/^(\/dashboard\/smart-ops\/quote-tasks\/)[^/]+$/)
if (quoteTaskMatch) return '报价任务详情'
//
const segments = path.split('/').filter(Boolean)
const lastSegment = segments[segments.length - 1]
@ -146,6 +150,8 @@ function getLabel(path: string): string {
coverage: '保障范围',
'privacy-policy': '隐私政策',
'project-quotes': '项目报价',
'quote-compare': '报价对比',
'quote-tasks': '保险报价',
projects: '项目列表',
inquiries: '询价列表',
claims: '理赔进度',

View File

@ -19,10 +19,14 @@
<span v-show="sidebarOpen" class="nav-text">工作台</span>
</RouterLink>
<template v-if="isPolicyholder">
<RouterLink to="/dashboard/project-quotes" :class="['nav-item', { active: isActiveParent('/dashboard/project-quotes') }]">
<RouterLink to="/dashboard/project-quotes" :class="['nav-item', { active: route.path === '/dashboard/project-quotes' }]">
<span class="nav-icon">💰</span>
<span v-show="sidebarOpen" class="nav-text">项目报价</span>
</RouterLink>
<RouterLink to="/dashboard/quote-compare" :class="['nav-item', { active: route.path === '/dashboard/quote-compare' }]">
<span class="nav-icon">📊</span>
<span v-show="sidebarOpen" class="nav-text">报价对比</span>
</RouterLink>
<RouterLink to="/dashboard/projects" :class="['nav-item', { active: isActiveParent('/dashboard/projects') }]">
<span class="nav-icon">📋</span>
<span v-show="sidebarOpen" class="nav-text">项目列表</span>
@ -34,6 +38,12 @@
<span v-show="sidebarOpen" class="nav-text">询价列表</span>
</RouterLink>
</template>
<template v-if="isTPA">
<RouterLink to="/dashboard/smart-ops/quote-tasks" :class="['nav-item', { active: isActiveParent('/dashboard/smart-ops') }]">
<span class="nav-icon">📑</span>
<span v-show="sidebarOpen" class="nav-text">保险报价Smart-OPS</span>
</RouterLink>
</template>
<template v-if="isPolicyholder || isInsurer">
<RouterLink to="/dashboard/claims" :class="['nav-item', { active: isActiveParent('/dashboard/claims') }]">
<span class="nav-icon">📝</span>
@ -132,10 +142,11 @@ const systemPaths = ['/system-management', '/faq']
const isPolicyholder = computed(() => auth.user?.role === '投保人')
const isInsurer = computed(() => auth.user?.role === '保险人')
const isTPA = computed(() => auth.user?.role === '服务方')
const isDashboardHome = computed(() =>
route.path === '/dashboard' &&
!['/dashboard/projects', '/dashboard/project-quotes', '/dashboard/inquiries', '/dashboard/claims', '/dashboard/tools'].some(p => route.path.startsWith(p))
!['/dashboard/projects', '/dashboard/project-quotes', '/dashboard/quote-compare', '/dashboard/inquiries', '/dashboard/claims', '/dashboard/tools', '/dashboard/smart-ops'].some(p => route.path.startsWith(p))
)
function isActiveParent(path: string) {

View File

@ -2,6 +2,499 @@
min-height: 100%;
}
.project-quotes-page.clinical-active .page-body {
padding-top: 8px;
padding-bottom: 8px;
}
.project-quotes-page.clinical-active .section {
margin-bottom: 0;
}
/* Tab 布局:顶部 Tab内容区全宽 */
.quote-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.quote-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
font-size: 15px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--white);
cursor: pointer;
color: var(--text-color);
transition: all 0.2s ease;
}
.quote-tab:hover {
border-color: rgba(14, 165, 233, 0.5);
background: rgba(14, 165, 233, 0.04);
}
.quote-tab.active {
background: var(--brand-primary, #0ea5e9);
color: var(--white);
border-color: var(--brand-primary, #0ea5e9);
}
.tab-icon {
font-size: 20px;
}
.tab-content-wrap {
width: 100%;
max-width: 100%;
}
.tab-panel {
padding: 0;
max-width: 960px;
}
.tab-panel.clinical-panel {
max-width: 100%;
}
/* 临床试验 Tab 紧凑单屏布局 */
.clinical-compact {
height: calc(100vh - 280px);
min-height: 420px;
overflow: hidden;
}
.clinical-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
height: 100%;
align-content: start;
}
.clinical-card {
padding: 12px 16px;
margin-bottom: 0;
}
.clinical-card .form-title {
font-size: 14px;
margin: 0 0 10px 0;
}
.upload-row {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.upload-row .form-group.compact {
flex: 1;
min-width: 120px;
margin-bottom: 0;
}
.upload-row .form-group.compact label {
font-size: 12px;
margin-bottom: 4px;
}
.upload-row .form-group.compact input[type="file"] {
font-size: 12px;
padding: 6px 8px;
}
.btn-ai {
padding: 8px 16px;
font-size: 13px;
white-space: nowrap;
}
.btn-manual {
padding: 8px 16px;
font-size: 13px;
white-space: nowrap;
background: var(--white);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.btn-manual:hover:not(:disabled) {
background: var(--bg-color, #f5f5f5);
border-color: rgba(14, 165, 233, 0.5);
}
.btn-manual:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.recognized-fields {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border-color);
}
.form-grid-4 {
grid-template-columns: repeat(4, 1fr);
gap: 8px 12px;
}
.form-group.compact {
margin-bottom: 8px;
}
.form-group.compact label {
font-size: 11px;
margin-bottom: 2px;
}
.form-group.compact input,
.form-group.compact select {
padding: 6px 8px;
font-size: 12px;
}
.scheme-block.compact {
padding: 8px 10px;
margin-bottom: 8px;
}
.scheme-row {
display: flex;
align-items: center;
gap: 8px;
}
.scheme-row .scheme-label {
font-size: 12px;
min-width: 48px;
}
.scheme-fields {
display: flex;
gap: 6px;
flex: 1;
flex-wrap: wrap;
}
.scheme-select,
.scheme-input {
padding: 4px 8px;
font-size: 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
min-width: 0;
}
.scheme-select {
min-width: 80px;
}
.scheme-input {
width: 80px;
}
.scheme-input.scheme-num {
width: 70px;
}
.clinical-card .btn-add-scheme {
padding: 6px 12px;
font-size: 12px;
margin-top: 4px;
}
.generate-inline {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.generate-inline .btn-primary {
align-self: flex-start;
padding: 8px 16px;
font-size: 13px;
}
.generate-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.generate-row .btn-primary {
align-self: flex-start;
padding: 8px 16px;
font-size: 13px;
}
.ai-result.compact,
.success-tip.compact {
padding: 8px 12px;
margin-top: 0;
}
.ai-result.compact pre {
font-size: 11px;
line-height: 1.4;
}
.success-tip.compact p {
margin: 0 0 6px 0;
font-size: 12px;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
@media (max-width: 1200px) {
.form-grid-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.clinical-grid {
grid-template-columns: 1fr;
}
.clinical-compact {
height: auto;
min-height: auto;
overflow: visible;
}
.form-grid-4 {
grid-template-columns: 1fr;
}
}
/* 临床试验 Tab 内样式 */
.quote-mode-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.mode-tab {
padding: 10px 20px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--white);
cursor: pointer;
color: var(--text-color);
}
.mode-tab.active {
background: var(--brand-primary, #0ea5e9);
color: var(--white);
border-color: var(--brand-primary, #0ea5e9);
}
.form-card {
padding: 24px 28px;
margin-bottom: 24px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--white);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.form-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 16px 0;
color: var(--brand-text-default);
}
.form-desc {
font-size: 14px;
color: var(--text-color);
margin: 0 0 20px 0;
line-height: 1.5;
}
.fill-mode-row {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
cursor: pointer;
}
.form-grid {
display: grid;
gap: 16px 20px;
}
.form-grid-2 {
grid-template-columns: 1fr 1fr;
}
.form-group-full {
grid-column: 1 / -1;
}
.link-detail {
font-size: 14px;
color: var(--brand-primary, #0ea5e9);
background: none;
border: none;
cursor: pointer;
padding: 0;
margin-top: 16px;
display: inline-block;
}
.link-detail:hover {
text-decoration: underline;
}
.scheme-block {
padding: 16px;
margin-bottom: 16px;
background: rgba(14, 165, 233, 0.04);
border-radius: 8px;
border: 1px solid rgba(14, 165, 233, 0.2);
}
.scheme-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.scheme-label {
font-weight: 600;
font-size: 14px;
}
.btn-remove {
padding: 6px 12px;
font-size: 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--white);
color: var(--text-color);
cursor: pointer;
}
.btn-remove:hover {
background: #fee2e2;
color: #dc2626;
}
.btn-add-scheme {
padding: 10px 16px;
font-size: 14px;
border: 1px dashed var(--border-color);
border-radius: 8px;
background: var(--white);
color: var(--brand-primary, #0ea5e9);
cursor: pointer;
}
.btn-add-scheme:hover {
background: rgba(14, 165, 233, 0.06);
}
.ai-result {
margin-top: 16px;
padding: 16px;
background: rgba(14, 165, 233, 0.06);
border: 1px solid rgba(14, 165, 233, 0.2);
border-radius: 8px;
}
.ai-result pre {
margin: 0;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.success-tip {
margin-top: 16px;
padding: 16px;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 8px;
}
.success-tip p {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--text-color);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.form-actions .btn {
padding: 10px 20px;
font-size: 14px;
border-radius: 8px;
cursor: pointer;
border: none;
text-decoration: none;
display: inline-block;
}
.form-actions .btn-primary {
background: var(--brand-primary, #0ea5e9);
color: var(--white);
}
.form-actions .btn-primary:hover:not(:disabled) {
background: var(--brand-primary-dark, #0284c7);
}
.form-actions .btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 表单通用样式 */
.tab-panel .form-group input,
.tab-panel .form-group select,
.tab-panel .form-group textarea {
width: 100%;
padding: 10px 12px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
box-sizing: border-box;
}
.tab-panel .form-group textarea {
resize: vertical;
min-height: 60px;
}
.quotes-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));

View File

@ -47,6 +47,9 @@ import ClaimDetail from '@/views/dashboard/ClaimDetail.vue'
import Tools from '@/views/dashboard/Tools.vue'
import PremiumCalculator from '@/views/dashboard/PremiumCalculator.vue'
import ProjectQuotes from '@/views/dashboard/ProjectQuotes.vue'
import QuoteCompare from '@/views/dashboard/QuoteCompare.vue'
import QuoteTaskList from '@/views/dashboard/smart-ops/QuoteTaskList.vue'
import QuoteTaskDetail from '@/views/dashboard/smart-ops/QuoteTaskDetail.vue'
import ICFEditor from '@/views/dashboard/ICFEditor.vue'
import RiskScoring from '@/views/dashboard/RiskScoring.vue'
import ProtocolRiskAssessment from '@/views/dashboard/ProtocolRiskAssessment.vue'
@ -122,6 +125,10 @@ const router = createRouter({
children: [
{ path: '', component: Dashboard },
{ path: 'project-quotes', component: ProjectQuotes },
{ path: 'clinical-trial-quote', redirect: { path: '/dashboard/project-quotes', query: { tab: 'clinical' } } },
{ path: 'quote-compare', component: QuoteCompare },
{ path: 'smart-ops/quote-tasks', component: QuoteTaskList },
{ path: 'smart-ops/quote-tasks/:id', component: QuoteTaskDetail },
{ path: 'projects', component: ProjectList },
{ path: 'projects/:id', component: ProjectDetail },
{ path: 'inquiries', component: InquiryList },

View File

@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export type UserRole = '投保人' | '保险人'
export type UserRole = '投保人' | '保险人' | '服务方'
export interface User {
id: string
@ -46,6 +46,10 @@ export const useAuthStore = defineStore('auth', () => {
user: { id: '2', name: '保险人', email: 'insurer@rmo.com', role: '保险人' },
token: 'mock_token_insurer'
},
tpa: {
user: { id: '3', name: '服务方', email: 'tpa@vdano.com', role: '服务方' },
token: 'mock_token_tpa'
},
admin: {
user: { id: '1', name: '投保人', email: 'admin@rmo.com', role: '投保人' },
token: 'mock_token_admin'

View File

@ -20,7 +20,7 @@
:disabled="loading"
/>
<div class="form-hint">
<small>测试账号admin, policyholder, insurer密码123456</small>
<small>测试账号admin, policyholder, insurer, tpa密码123456</small>
</div>
</div>
<div class="form-group">

View File

@ -1,151 +1,224 @@
<template>
<PageContainer>
<div class="project-quotes-page">
<PageHeader title="项目报价" description="选择报价类型并填写表单获取报价" />
<div class="project-quotes-page" :class="{ 'clinical-active': activeTab === 'clinical' }">
<PageHeader
title="项目报价"
description="选择报价类型并填写表单获取报价。临床试验保险报价信息将传输至 Smart-OPS由临研安向各保司询价保司回复至 RMO@vdano.com。"
/>
<div class="page-body">
<section class="section">
<div class="quotes-cards-grid">
<!-- PV报价卡片 -->
<div class="quote-card" :class="{ expanded: expandedCard === 'pv' }">
<div class="quote-card-header" @click="toggleCard('pv')">
<div class="quote-card-icon">📊</div>
<div class="quote-card-title-wrap">
<h3>PV报价</h3>
<p>药物警戒服务报价咨询</p>
</div>
<span class="quote-card-arrow">{{ expandedCard === 'pv' ? '▼' : '▶' }}</span>
</div>
<div v-show="expandedCard === 'pv'" class="quote-card-body">
<form class="quote-form" @submit.prevent="handlePvSubmit">
<!-- Tab 切换 -->
<div class="quote-tabs">
<button
:class="['quote-tab', { active: activeTab === 'pv' }]"
@click="activeTab = 'pv'"
>
<span class="tab-icon">📊</span>
<span class="tab-text">PV报价</span>
</button>
<button
:class="['quote-tab', { active: activeTab === 'clinical' }]"
@click="activeTab = 'clinical'"
>
<span class="tab-icon">🛡</span>
<span class="tab-text">临床试验保险报价</span>
</button>
<button
:class="['quote-tab', { active: activeTab === 'product' }]"
@click="activeTab = 'product'"
>
<span class="tab-icon">💼</span>
<span class="tab-text">产品责任保险报价</span>
</button>
</div>
<!-- Tab 内容区全宽展示 -->
<div class="tab-content-wrap">
<!-- PV报价 -->
<div v-show="activeTab === 'pv'" class="tab-panel">
<form class="quote-form" @submit.prevent="handlePvSubmit">
<div class="form-row">
<div class="form-group">
<label for="pv-name">姓名 <span class="required">*</span></label>
<input id="pv-name" v-model="pvForm.name" type="text" placeholder="请输入姓名" required />
</div>
<div class="form-row">
<div class="form-group">
<label for="pv-email">电子邮箱 <span class="required">*</span></label>
<input id="pv-email" v-model="pvForm.email" type="email" placeholder="请输入电子邮箱" required />
</div>
<div class="form-group">
<label for="pv-phone">联系电话 <span class="required">*</span></label>
<input id="pv-phone" v-model="pvForm.phone" type="tel" placeholder="请输入联系电话" required />
</div>
<div class="form-group">
<label for="pv-email">电子邮箱 <span class="required">*</span></label>
<input id="pv-email" v-model="pvForm.email" type="email" placeholder="请输入电子邮箱" required />
</div>
<div class="form-row">
<div class="form-group">
<label for="pv-company">公司 <span class="required">*</span></label>
<input id="pv-company" v-model="pvForm.company" type="text" placeholder="请输入公司名称" required />
</div>
<div class="form-group">
<label for="pv-position">职位</label>
<input id="pv-position" v-model="pvForm.position" type="text" placeholder="请输入职位" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="pv-phone">联系电话 <span class="required">*</span></label>
<input id="pv-phone" v-model="pvForm.phone" type="tel" placeholder="请输入联系电话" required />
</div>
<div class="form-group">
<label for="pv-question">业务相关问题 <span class="required">*</span></label>
<textarea id="pv-question" v-model="pvForm.question" placeholder="请描述您的业务问题或需求" rows="4" required></textarea>
<label for="pv-company">公司 <span class="required">*</span></label>
<input id="pv-company" v-model="pvForm.company" type="text" placeholder="请输入公司名称" required />
</div>
<div class="form-group">
<label for="pv-captcha">验证码 <span class="required">*</span></label>
<div class="captcha-row">
<input id="pv-captcha" v-model="pvForm.captcha" type="text" placeholder="请输入验证码" maxlength="4" required />
<span class="captcha-code">{{ captchaCode }}</span>
<button type="button" class="btn-captcha-refresh" @click="refreshCaptcha">刷新</button>
</div>
<div class="form-group">
<label for="pv-position">职位</label>
<input id="pv-position" v-model="pvForm.position" type="text" placeholder="请输入职位" />
</div>
<div class="form-group">
<label for="pv-question">业务相关问题 <span class="required">*</span></label>
<textarea id="pv-question" v-model="pvForm.question" placeholder="请描述您的业务问题或需求" rows="4" required></textarea>
</div>
<div class="form-group">
<label for="pv-captcha">验证码 <span class="required">*</span></label>
<div class="captcha-row">
<input id="pv-captcha" v-model="pvForm.captcha" type="text" placeholder="请输入验证码" maxlength="4" required />
<span class="captcha-code">{{ captchaCode }}</span>
<button type="button" class="btn-captcha-refresh" @click="refreshCaptcha">刷新</button>
</div>
</div>
<div class="privacy-commitment">
<label class="privacy-checkbox-label">
<input type="checkbox" v-model="pvPrivacyAgreed" required />
<span>我已阅读并理解该承诺声明</span>
</label>
<p>达诺/华泰经纪及RMO生态成员承诺对您在本网页下提供的任何信息包括您的个人信息将按照相关的法律法规及泰格医药隐私政策的规定进行严格保密更多信息详见<RouterLink to="/privacy-policy" target="_blank">隐私政策</RouterLink></p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="pvSubmitting || !pvPrivacyAgreed">
{{ pvSubmitting ? '提交中...' : '提交' }}
</button>
</div>
</form>
</div>
<!-- 临床试验保险报价紧凑单屏布局 -->
<div v-show="activeTab === 'clinical'" class="tab-panel clinical-panel clinical-compact">
<div class="clinical-grid">
<!-- 1. 报价资料 -->
<div class="form-card card clinical-card">
<h3 class="form-title">1. 报价资料</h3>
<div class="upload-row">
<div class="form-group compact">
<label>项目方案文件 <span class="required">*</span></label>
<input type="file" accept=".pdf,.doc,.docx" @change="onProtocolFileChange" />
</div>
</div>
<div class="privacy-commitment">
<label class="privacy-checkbox-label">
<input type="checkbox" v-model="pvPrivacyAgreed" required />
<span>我已阅读并理解该承诺声明</span>
</label>
<p>达诺/华泰经纪及RMO生态成员承诺对您在本网页下提供的任何信息包括您的个人信息将按照相关的法律法规及泰格医药隐私政策的规定进行严格保密更多信息详见<RouterLink to="/privacy-policy" target="_blank">隐私政策</RouterLink></p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="pvSubmitting || !pvPrivacyAgreed">
{{ pvSubmitting ? '提交中...' : '提交' }}
<div class="form-group compact">
<label>知情同意书</label>
<input type="file" accept=".pdf,.doc,.docx" @change="onIcfFileChange" />
</div>
<button
type="button"
class="btn btn-primary btn-ai"
:disabled="!hasProtocolFile || aiRecognizing || manualFillMode"
@click="handleAiRecognize"
>
{{ aiRecognizing ? 'AI 识别中…' : 'AI 识别' }}
</button>
<button
type="button"
class="btn btn-secondary btn-manual"
:disabled="manualFillMode || aiRecognized"
@click="handleManualFill"
>
手动填写
</button>
</div>
</form>
<!-- AI 识别或手动填写后的字段 -->
<div v-if="aiRecognized || manualFillMode" class="recognized-fields">
<div class="form-grid form-grid-4">
<div class="form-group compact">
<label>项目方案编号</label>
<input v-model="clinicalForm.projectCode" placeholder="CT-2025-001" />
</div>
<div class="form-group compact">
<label>项目标题</label>
<input v-model="clinicalForm.projectTitle" placeholder="试验方案标题" />
</div>
<div class="form-group compact">
<label>投保人</label>
<input v-model="clinicalForm.policyholder" placeholder="投保人名称" />
</div>
<div class="form-group compact">
<label>申办者</label>
<input v-model="clinicalForm.sponsor" placeholder="申办者名称" />
</div>
<div class="form-group compact">
<label>受试药物</label>
<input v-model="clinicalForm.drugName" placeholder="受试药物名称" />
</div>
<div class="form-group compact">
<label>项目分期</label>
<select v-model="clinicalForm.phase">
<option value="">请选择</option>
<option value="I">I </option>
<option value="II">II </option>
<option value="III">III </option>
<option value="IV">IV </option>
<option value="other">其他</option>
</select>
</div>
<div class="form-group compact">
<label>受试者人数</label>
<input v-model.number="clinicalForm.subjectCount" type="number" placeholder="人数" min="1" />
</div>
<div class="form-group compact">
<label>报价用途</label>
<select v-model="clinicalForm.quotePurpose">
<option value="">请选择</option>
<option value="budget">制定项目预算</option>
<option value="trial">申请试验开展</option>
</select>
</div>
</div>
</div>
</div>
<!-- 2. 报价方案保险方案非AI识别始终显示 -->
<div class="form-card card clinical-card">
<h3 class="form-title">2. 报价方案</h3>
<div v-for="(scheme, idx) in clinicalForm.schemes" :key="idx" class="scheme-block compact">
<div class="scheme-row">
<span class="scheme-label">方案{{ idx + 1 }}</span>
<div class="scheme-fields">
<select v-model="scheme.perPersonLimit" class="scheme-select">
<option value="">每人限额</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
<option value="30">30</option>
<option value="50">50</option>
</select>
<input v-model="scheme.aggregateLimit" class="scheme-input" placeholder="累计限额(万)" />
<input v-model="scheme.deductible" class="scheme-input" placeholder="免赔额" />
<input v-model.number="scheme.insuredCount" type="number" class="scheme-input scheme-num" placeholder="拟投保人数" />
</div>
<button v-if="clinicalForm.schemes.length > 1" type="button" class="btn-remove" @click="removeScheme(idx)"></button>
</div>
</div>
<button type="button" class="btn-add-scheme" @click="addScheme">+ 添加方案</button>
<!-- 生成报价仅在已填写报价资料时显示 -->
<div v-if="aiRecognized || manualFillMode" class="generate-inline">
<button
type="button"
class="btn btn-primary"
:disabled="!canGenerateClinical || generating"
@click="handleClinicalGenerate"
>
{{ generating ? '生成中…' : '生成报价' }}
</button>
<div v-if="aiQuoteResult && !quoteTaskCreated" class="ai-result compact">
<pre>{{ aiQuoteResult }}</pre>
</div>
<div v-if="quoteTaskCreated" class="success-tip compact">
<p> 报价任务已创建</p>
<RouterLink to="/dashboard/quote-compare" class="btn btn-primary btn-sm">查看报价对比</RouterLink>
</div>
</div>
</div>
</div>
</div>
<!-- 临床试验保险报价卡片 -->
<div class="quote-card" :class="{ expanded: expandedCard === 'clinical' }">
<div class="quote-card-header" @click="toggleCard('clinical')">
<div class="quote-card-icon">🛡</div>
<div class="quote-card-title-wrap">
<h3>临床试验保险报价</h3>
<p>临床试验责任保险报价咨询</p>
</div>
<span class="quote-card-arrow">{{ expandedCard === 'clinical' ? '▼' : '▶' }}</span>
</div>
<div v-show="expandedCard === 'clinical'" class="quote-card-body">
<form class="quote-form" @submit.prevent="handleClinicalSubmit">
<div class="form-group">
<label for="clinical-projectType">项目类型</label>
<select id="clinical-projectType" v-model="clinicalForm.projectType">
<option value="">请选择</option>
<option value="phase1">I期临床试验</option>
<option value="phase2">II期临床试验</option>
<option value="phase3">III期临床试验</option>
<option value="phase4">IV期临床试验</option>
</select>
</div>
<div class="form-group">
<label for="clinical-riskLevel">风险等级</label>
<select id="clinical-riskLevel" v-model="clinicalForm.riskLevel">
<option value="">请选择</option>
<option value="low">低风险</option>
<option value="medium">中风险</option>
<option value="high">高风险</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label for="clinical-coverage">保障金额</label>
<input id="clinical-coverage" v-model="clinicalForm.coverageAmount" type="number" placeholder="请输入保障金额" />
</div>
<div class="form-group">
<label for="clinical-participants">受试者人数</label>
<input id="clinical-participants" v-model="clinicalForm.participantCount" type="number" placeholder="请输入受试者人数" />
</div>
</div>
<div class="form-group">
<label for="clinical-duration">试验周期</label>
<input id="clinical-duration" v-model="clinicalForm.duration" type="number" placeholder="请输入试验周期" />
</div>
<div class="form-group">
<label for="clinical-remark">备注说明</label>
<textarea id="clinical-remark" v-model="clinicalForm.remark" placeholder="请输入其他需求说明" rows="3"></textarea>
</div>
<div class="privacy-commitment">
<label class="privacy-checkbox-label">
<input type="checkbox" v-model="clinicalPrivacyAgreed" required />
<span>我已阅读并理解该承诺声明</span>
</label>
<p>达诺/华泰经纪及RMO生态成员承诺对您在本网页下提供的任何信息包括您的个人信息将按照相关的法律法规及泰格医药隐私政策的规定进行严格保密更多信息详见<RouterLink to="/privacy-policy" target="_blank">隐私政策</RouterLink></p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="clinicalSubmitting || !clinicalPrivacyAgreed">
{{ clinicalSubmitting ? '提交中...' : '提交' }}
</button>
</div>
</form>
</div>
</div>
<!-- 产品责任保险报价卡片 -->
<div class="quote-card" :class="{ expanded: expandedCard === 'product' }">
<div class="quote-card-header" @click="toggleCard('product')">
<div class="quote-card-icon">💼</div>
<div class="quote-card-title-wrap">
<h3>产品责任保险报价</h3>
<p>上市后产品责任保险报价咨询</p>
</div>
<span class="quote-card-arrow">{{ expandedCard === 'product' ? '▼' : '▶' }}</span>
</div>
<div v-show="expandedCard === 'product'" class="quote-card-body">
<form class="quote-form" @submit.prevent="handleProductSubmit">
<!-- 产品责任保险报价 -->
<div v-show="activeTab === 'product'" class="tab-panel">
<form class="quote-form" @submit.prevent="handleProductSubmit">
<div class="form-row">
<div class="form-group">
<label for="product-productName">产品名称</label>
<input id="product-productName" v-model="productForm.productName" type="text" placeholder="请输入产品名称" />
@ -159,34 +232,34 @@
<option value="other">其他</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label for="product-coverage">保障金额</label>
<input id="product-coverage" v-model="productForm.coverageAmount" type="number" placeholder="请输入保障金额" />
</div>
<div class="form-group">
<label for="product-sales">年销售额</label>
<input id="product-sales" v-model="productForm.annualSales" type="number" placeholder="请输入年销售额" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="product-coverage">保障金额</label>
<input id="product-coverage" v-model="productForm.coverageAmount" type="number" placeholder="请输入保障金额" />
</div>
<div class="form-group">
<label for="product-remark">备注说明</label>
<textarea id="product-remark" v-model="productForm.remark" placeholder="请输入其他需求说明" rows="3"></textarea>
<label for="product-sales">年销售额</label>
<input id="product-sales" v-model="productForm.annualSales" type="number" placeholder="请输入年销售额" />
</div>
<div class="privacy-commitment">
<label class="privacy-checkbox-label">
<input type="checkbox" v-model="productPrivacyAgreed" required />
<span>我已阅读并理解该承诺声明</span>
</label>
<p>达诺/华泰经纪及RMO生态成员承诺对您在本网页下提供的任何信息包括您的个人信息将按照相关的法律法规及泰格医药隐私政策的规定进行严格保密更多信息详见<RouterLink to="/privacy-policy" target="_blank">隐私政策</RouterLink></p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="productSubmitting || !productPrivacyAgreed">
{{ productSubmitting ? '提交中...' : '提交' }}
</button>
</div>
</form>
</div>
</div>
<div class="form-group">
<label for="product-remark">备注说明</label>
<textarea id="product-remark" v-model="productForm.remark" placeholder="请输入其他需求说明" rows="3"></textarea>
</div>
<div class="privacy-commitment">
<label class="privacy-checkbox-label">
<input type="checkbox" v-model="productPrivacyAgreed" required />
<span>我已阅读并理解该承诺声明</span>
</label>
<p>达诺/华泰经纪及RMO生态成员承诺对您在本网页下提供的任何信息包括您的个人信息将按照相关的法律法规及泰格医药隐私政策的规定进行严格保密更多信息详见<RouterLink to="/privacy-policy" target="_blank">隐私政策</RouterLink></p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="productSubmitting || !productPrivacyAgreed">
{{ productSubmitting ? '提交中...' : '提交' }}
</button>
</div>
</form>
</div>
</div>
</section>
@ -196,13 +269,26 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import PageContainer from '@/components/PageContainer.vue'
import PageHeader from '@/components/PageHeader.vue'
type ExpandedCard = 'pv' | 'clinical' | 'product' | null
const route = useRoute()
const expandedCard = ref<ExpandedCard>('pv')
type TabType = 'pv' | 'clinical' | 'product'
interface QuotationScheme {
perPersonLimit: string
aggregateLimit: string
deductible: string
insuredCount: number | undefined
}
const activeTab = ref<TabType>(
(route.query.tab as TabType) === 'clinical' ? 'clinical' :
(route.query.tab as TabType) === 'product' ? 'product' : 'pv'
)
const pvForm = ref({
name: '',
@ -214,13 +300,34 @@ const pvForm = ref({
captcha: ''
})
const protocolFile = ref<File | null>(null)
const icfFile = ref<File | null>(null)
const aiRecognized = ref(false)
const aiRecognizing = ref(false)
const manualFillMode = ref(false)
const generating = ref(false)
const aiQuoteResult = ref('')
const quoteTaskCreated = ref(false)
const hasProtocolFile = computed(() => !!protocolFile.value)
const clinicalForm = ref({
projectType: '',
riskLevel: '',
coverageAmount: '',
participantCount: '',
duration: '',
remark: ''
projectCode: '',
projectTitle: '',
policyholder: '',
sponsor: '',
drugName: '',
phase: '',
subjectCount: undefined as number | undefined,
quotePurpose: '',
schemes: [
{
perPersonLimit: '',
aggregateLimit: '',
deductible: '',
insuredCount: undefined as number | undefined
} as QuotationScheme
]
})
const productForm = ref({
@ -233,12 +340,79 @@ const productForm = ref({
const captchaCode = ref('')
const pvPrivacyAgreed = ref(false)
const clinicalPrivacyAgreed = ref(false)
const productPrivacyAgreed = ref(false)
const pvSubmitting = ref(false)
const clinicalSubmitting = ref(false)
const productSubmitting = ref(false)
const canGenerateClinical = computed(() =>
clinicalForm.value.projectCode.trim() &&
clinicalForm.value.projectTitle.trim() &&
clinicalForm.value.policyholder.trim() &&
clinicalForm.value.sponsor.trim() &&
clinicalForm.value.drugName.trim() &&
clinicalForm.value.phase &&
(clinicalForm.value.subjectCount ?? 0) > 0 &&
clinicalForm.value.quotePurpose
)
function addScheme() {
clinicalForm.value.schemes.push({
perPersonLimit: '',
aggregateLimit: '',
deductible: '',
insuredCount: clinicalForm.value.subjectCount
})
}
function removeScheme(idx: number) {
clinicalForm.value.schemes.splice(idx, 1)
}
function onProtocolFileChange(e: Event) {
protocolFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
}
function onIcfFileChange(e: Event) {
icfFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
}
async function handleAiRecognize() {
if (!protocolFile.value) return
aiRecognizing.value = true
await new Promise(r => setTimeout(r, 1200))
clinicalForm.value.projectCode = 'CT-2025-' + Math.floor(1000 + Math.random() * 9000)
clinicalForm.value.projectTitle = protocolFile.value.name.replace(/\.[^.]+$/, '') || '临床试验方案'
clinicalForm.value.policyholder = clinicalForm.value.policyholder || '示例投保人'
clinicalForm.value.sponsor = clinicalForm.value.sponsor || '示例申办者'
clinicalForm.value.drugName = clinicalForm.value.drugName || '示例药物'
clinicalForm.value.phase = clinicalForm.value.phase || 'I'
clinicalForm.value.subjectCount = clinicalForm.value.subjectCount || 100
clinicalForm.value.quotePurpose = clinicalForm.value.quotePurpose || 'budget'
clinicalForm.value.schemes[0].perPersonLimit = '20'
clinicalForm.value.schemes[0].aggregateLimit = '500'
clinicalForm.value.schemes[0].insuredCount = clinicalForm.value.subjectCount
aiRecognized.value = true
aiRecognizing.value = false
}
function handleManualFill() {
manualFillMode.value = true
}
async function handleClinicalGenerate() {
if (!canGenerateClinical.value) return
generating.value = true
aiQuoteResult.value = ''
quoteTaskCreated.value = false
await new Promise(r => setTimeout(r, 1500))
aiQuoteResult.value =
`基于当前项目信息(${clinicalForm.value.projectTitle}${clinicalForm.value.phase}期)的预估报价:\n` +
'· 建议每人保额80120 万\n· 累计责任限额400600 万\n· 预估年保费区间:约 1.1 万1.4 万元\n' +
'(实际以各保司精准报价为准,报价任务已创建并传输至 Smart-OPS'
quoteTaskCreated.value = true
generating.value = false
}
function generateCaptcha() {
captchaCode.value = Math.random().toString(36).slice(2, 6).toUpperCase()
}
@ -249,12 +423,10 @@ function refreshCaptcha() {
onMounted(() => {
generateCaptcha()
if (route.query.tab === 'clinical') activeTab.value = 'clinical'
if (route.query.tab === 'product') activeTab.value = 'product'
})
function toggleCard(card: ExpandedCard) {
expandedCard.value = expandedCard.value === card ? null : card
}
async function handlePvSubmit() {
if (pvForm.value.captcha.toUpperCase() !== captchaCode.value) {
alert('验证码错误,请重新输入')
@ -273,18 +445,6 @@ async function handlePvSubmit() {
}
}
async function handleClinicalSubmit() {
clinicalSubmitting.value = true
try {
await new Promise(r => setTimeout(r, 800))
alert('临床试验保险报价提交成功!我们会尽快与您联系。')
clinicalForm.value = { projectType: '', riskLevel: '', coverageAmount: '', participantCount: '', duration: '', remark: '' }
clinicalPrivacyAgreed.value = false
} finally {
clinicalSubmitting.value = false
}
}
async function handleProductSubmit() {
productSubmitting.value = true
try {

View File

@ -0,0 +1,293 @@
<template>
<PageContainer>
<div class="quote-compare-page">
<PageHeader
title="报价对比"
description="各保司报价结果汇总,方便投保人对比选择。报价信息由保司回复至 RMO@vdano.com 后经临研安整理呈现。"
/>
<div class="page-body">
<section class="section">
<!-- 项目筛选 -->
<div class="filter-card card">
<div class="filter-row">
<div class="form-group">
<label>项目</label>
<select v-model="selectedProject" @change="loadQuotes">
<option value="">请选择项目</option>
<option
v-for="p in mockProjects"
:key="p.id"
:value="p.id"
>
{{ p.code }} - {{ p.title }}
</option>
</select>
</div>
</div>
</div>
<!-- 报价方案选择 -->
<div v-if="selectedProject" class="scheme-card card">
<h3 class="scheme-title">报价方案</h3>
<div class="scheme-tabs">
<button
v-for="(s, idx) in mockSchemes"
:key="idx"
:class="['scheme-tab', { active: selectedSchemeIdx === idx }]"
@click="selectedSchemeIdx = idx"
>
方案 {{ idx + 1 }}每人 {{ s.perPerson }} / 累计 {{ s.aggregate }}
</button>
</div>
</div>
<!-- 对比表格 -->
<div v-if="selectedProject && quotes.length > 0" class="compare-card card">
<h3 class="compare-title">保司报价对比</h3>
<div class="table-wrap">
<table class="compare-table">
<thead>
<tr>
<th>保司</th>
<th>报价状态</th>
<th>年保费</th>
<th>每人责任限额</th>
<th>累计责任限额</th>
<th>免赔额</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr v-for="q in quotes" :key="q.insurer">
<td class="insurer-name">{{ q.insurer }}</td>
<td>
<span :class="['status-badge', q.status]">
{{ statusText[q.status] }}
</span>
</td>
<td class="premium">{{ q.premium || '--' }}</td>
<td>{{ q.perPersonLimit || '--' }}</td>
<td>{{ q.aggregateLimit || '--' }}</td>
<td>{{ q.deductible || '--' }}</td>
<td class="note">{{ q.note || '--' }}</td>
</tr>
</tbody>
</table>
</div>
<p class="contact-tip">
如需调整报价请联系经纪人进行线下沟通
</p>
</div>
<!-- 空状态 -->
<div v-else-if="selectedProject && quotes.length === 0" class="empty-state card">
<p>暂无报价数据保司回复至 RMO@vdano.com 后经整理将在此展示</p>
</div>
</section>
</div>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import PageContainer from '@/components/PageContainer.vue'
import PageHeader from '@/components/PageHeader.vue'
const selectedProject = ref('')
const selectedSchemeIdx = ref(0)
const mockProjects = [
{ id: '1', code: 'CT-2025-1001', title: 'XXX药物III期临床试验' },
{ id: '2', code: 'CT-2025-1002', title: 'YYY疫苗I期临床试验' }
]
const mockSchemes = [
{ perPerson: '100', aggregate: '500' },
{ perPerson: '80', aggregate: '400' }
]
const statusText: Record<string, string> = {
pending: '待报价',
received: '已报价'
}
interface QuoteItem {
insurer: string
status: 'pending' | 'received'
premium?: string
perPersonLimit?: string
aggregateLimit?: string
deductible?: string
note?: string
}
const quotes = ref<QuoteItem[]>([])
function loadQuotes() {
if (!selectedProject.value) {
quotes.value = []
return
}
//
quotes.value = [
{ insurer: '太平洋', status: 'received', premium: '¥12,800/年', perPersonLimit: '100万', aggregateLimit: '500万', deductible: '0', note: '3个工作日内出单' },
{ insurer: '太平', status: 'received', premium: '¥11,500/年', perPersonLimit: '80万', aggregateLimit: '400万', deductible: '0', note: '支持医疗直付' },
{ insurer: '大地', status: 'received', premium: '¥13,200/年', perPersonLimit: '100万', aggregateLimit: '600万', deductible: '0', note: '含SUSAR/SAE专项' },
{ insurer: '平安', status: 'received', premium: '¥14,000/年', perPersonLimit: '120万', aggregateLimit: '800万', deductible: '0', note: '全国网点理赔' },
{ insurer: '华泰财', status: 'received', premium: '¥12,000/年', perPersonLimit: '100万', aggregateLimit: '500万', deductible: '0', note: '与经纪服务联动' },
{ insurer: '亚太', status: 'pending', premium: undefined, perPersonLimit: undefined, aggregateLimit: undefined, deductible: undefined, note: undefined }
]
}
watch(selectedProject, loadQuotes)
</script>
<style scoped>
.quote-compare-page {
min-height: 100%;
}
.filter-card,
.scheme-card,
.compare-card,
.empty-state {
padding: 24px 28px;
margin-bottom: 24px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--white);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.form-group {
min-width: 200px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-color);
margin-bottom: 6px;
}
.form-group select {
width: 100%;
padding: 10px 12px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.scheme-title,
.compare-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 16px 0;
color: var(--brand-text-default);
}
.scheme-tabs {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.scheme-tab {
padding: 10px 16px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--white);
cursor: pointer;
color: var(--text-color);
}
.scheme-tab.active {
background: var(--brand-primary, #0ea5e9);
color: var(--white);
border-color: var(--brand-primary, #0ea5e9);
}
.table-wrap {
overflow-x: auto;
}
.compare-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.compare-table th,
.compare-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.compare-table th {
font-weight: 600;
color: var(--brand-text-default);
background: rgba(14, 165, 233, 0.06);
}
.compare-table tbody tr:hover {
background: rgba(14, 165, 233, 0.03);
}
.insurer-name {
font-weight: 600;
color: var(--brand-primary, #0ea5e9);
}
.premium {
font-weight: 600;
color: var(--brand-text-default);
}
.note {
max-width: 180px;
font-size: 13px;
color: var(--text-color);
}
.status-badge {
display: inline-block;
padding: 4px 10px;
font-size: 12px;
border-radius: 6px;
}
.status-badge.pending {
background: rgba(234, 179, 8, 0.2);
color: #b45309;
}
.status-badge.received {
background: rgba(34, 197, 94, 0.2);
color: #15803d;
}
.contact-tip {
margin-top: 20px;
padding: 12px 16px;
background: rgba(14, 165, 233, 0.06);
border-radius: 8px;
font-size: 14px;
color: var(--text-color);
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-color);
}
</style>

View File

@ -0,0 +1,872 @@
<template>
<PageContainer>
<div class="quote-task-detail-page">
<PageHeader
:title="`报价任务详情 - ${task?.projectCode || ''}`"
description="查看/编辑报价任务,包含收集到的全部信息及与保司的邮件往来记录。"
>
<template #actions>
<div class="header-actions-row">
<button type="button" class="btn btn-outline" @click="showQuoteInfoModal = true">报价信息</button>
<button type="button" class="btn btn-outline" @click="showRiskModal = true">风险评估</button>
<button
v-if="task && ['created', 'preliminary'].includes(task.status)"
type="button"
class="btn btn-outline"
@click="showApplyModal = true"
>
申请报价
</button>
<button
v-if="task && (task.status === 'formal' || task.status === 'completed')"
type="button"
class="btn btn-outline"
@click="openOrganizeModal"
>
整理报价
</button>
<button
v-if="task && task.status === 'completed'"
type="button"
class="btn btn-primary"
@click="showReturnModal = true"
>
返回报价
</button>
<RouterLink to="/dashboard/smart-ops/quote-tasks" class="btn btn-secondary">
返回列表
</RouterLink>
</div>
</template>
</PageHeader>
<div class="page-body" v-if="task">
<section class="section">
<!-- Tab 切换 -->
<div class="detail-tabs">
<button
:class="['detail-tab', { active: detailTab === 'collected' }]"
@click="detailTab = 'collected'"
>
收集到的报价信息
</button>
<button
:class="['detail-tab', { active: detailTab === 'standard' }]"
@click="detailTab = 'standard'"
>
标准报价信息
</button>
<button
:class="['detail-tab', { active: detailTab === 'organized' }]"
@click="detailTab = 'organized'"
>
整理后的报价信息
</button>
</div>
<!-- Tab 1: 收集到的报价信息 -->
<div v-show="detailTab === 'collected'" class="tab-panel">
<div class="info-card card">
<h3 class="card-title">客户提供的原始信息</h3>
<div class="info-grid">
<div class="info-item">
<span class="info-label">投保人</span>
<span class="info-value">{{ task.policyholder }}</span>
</div>
<div class="info-item">
<span class="info-label">被保险人/申办者</span>
<span class="info-value">{{ task.sponsor }}</span>
</div>
<div class="info-item">
<span class="info-label">承保试验</span>
<span class="info-value">{{ task.projectTitle }}</span>
</div>
<div class="info-item">
<span class="info-label">方案号</span>
<span class="info-value">{{ task.projectCode }}</span>
</div>
<div class="info-item">
<span class="info-label">研究分期</span>
<span class="info-value">{{ task.phase }}</span>
</div>
<div class="info-item">
<span class="info-label">受试者人数</span>
<span class="info-value">{{ task.subjectCount }}</span>
</div>
<div class="info-item">
<span class="info-label">保单期限</span>
<span class="info-value">{{ task.policyDuration || '--' }}</span>
</div>
<div class="info-item">
<span class="info-label">累计责任限额</span>
<span class="info-value">{{ task.aggregateLimit }}</span>
</div>
<div class="info-item">
<span class="info-label">每人责任限额</span>
<span class="info-value">{{ task.perPersonLimit }}</span>
</div>
<div class="info-item">
<span class="info-label">每次事故免赔额</span>
<span class="info-value">{{ task.deductible || '0' }}</span>
</div>
<div class="info-item info-item-full">
<span class="info-label">特别约定</span>
<span class="info-value">{{ task.specialRequirements || '--' }}</span>
</div>
</div>
</div>
<!-- 保司报价任务 & 邮件记录 -->
<div class="info-card card">
<h3 class="card-title">当前保司报价任务</h3>
<div class="insurer-info">
<div class="insurer-row">
<span class="insurer-name">{{ task.insurer }}</span>
<span :class="['status-badge', task.status]">{{ statusText[task.status] }}</span>
</div>
<div v-if="task.premium" class="insurer-quote">
报价金额<strong>{{ task.premium }}</strong>
</div>
</div>
</div>
<!-- 邮件记录 -->
<div class="info-card card">
<h3 class="card-title">邮件往来记录</h3>
<div class="mail-list">
<div v-for="(m, idx) in mockMails" :key="idx" class="mail-item">
<div class="mail-header">
<span class="mail-dir">{{ m.direction === 'sent' ? '发往保司' : '保司回复' }}</span>
<span class="mail-date">{{ m.date }}</span>
</div>
<div class="mail-body">
<p><strong>主题</strong>{{ m.subject }}</p>
<p v-if="m.content" class="mail-content">{{ m.content }}</p>
</div>
</div>
</div>
<p v-if="mockMails.length === 0" class="empty-hint">暂无邮件记录</p>
</div>
</div>
<!-- Tab 2: 标准报价信息 -->
<div v-show="detailTab === 'standard'" class="tab-panel">
<div class="info-card card">
<h3 class="card-title">标准报价信息表</h3>
<p class="form-desc">根据收集到的信息生成的标准格式报价表可导出 Excel/PDF 用于与保司项目组沟通</p>
<div class="info-grid">
<div class="info-item">
<span class="info-label">承保险种</span>
<span class="info-value">临床试验责任保险</span>
</div>
<div class="info-item">
<span class="info-label">投保人</span>
<span class="info-value">{{ task.policyholder }}</span>
</div>
<div class="info-item">
<span class="info-label">被保险人</span>
<span class="info-value">{{ task.sponsor }}</span>
</div>
<div class="info-item">
<span class="info-label">承保试验</span>
<span class="info-value">{{ task.projectTitle }}</span>
</div>
<div class="info-item">
<span class="info-label">方案号</span>
<span class="info-value">{{ task.projectCode }}</span>
</div>
<div class="info-item">
<span class="info-label">研究分期</span>
<span class="info-value">{{ task.phase }}</span>
</div>
<div class="info-item">
<span class="info-label">受试者人数</span>
<span class="info-value">{{ task.subjectCount }}</span>
</div>
<div class="info-item">
<span class="info-label">保单期限</span>
<span class="info-value">{{ task.policyDuration || '--' }}</span>
</div>
<div class="info-item">
<span class="info-label">保单限额</span>
<span class="info-value">{{ task.aggregateLimit }}</span>
</div>
<div class="info-item">
<span class="info-label">每人责任限额</span>
<span class="info-value">{{ task.perPersonLimit }}</span>
</div>
<div class="info-item">
<span class="info-label">每次事故免赔额</span>
<span class="info-value">{{ task.deductible || '0' }}</span>
</div>
<div class="info-item info-item-full">
<span class="info-label">特别约定</span>
<span class="info-value">{{ task.specialRequirements || '--' }}</span>
</div>
</div>
<div class="export-actions">
<button type="button" class="btn btn-outline" @click="exportQuoteInfo('excel')">导出 Excel</button>
<button type="button" class="btn btn-primary" @click="exportQuoteInfo('pdf')">导出 PDF</button>
</div>
</div>
</div>
<!-- Tab 3: 整理后的报价信息各保司报价回复 -->
<div v-show="detailTab === 'organized'" class="tab-panel">
<div class="info-card card">
<h3 class="card-title">各保司报价回复汇总</h3>
<p class="form-desc">归集各保司返回的正式报价便于客户/投保人对比选定</p>
<div class="organize-table-wrap">
<table class="organize-table">
<thead>
<tr>
<th>保司</th>
<th>报价状态</th>
<th>年保费</th>
<th>每人责任限额</th>
<th>累计责任限额</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr v-for="q in organizedQuotes" :key="q.insurer">
<td>{{ q.insurer }}</td>
<td><span :class="['status-badge', q.status]">{{ statusText[q.status] }}</span></td>
<td>{{ q.premium || '--' }}</td>
<td>{{ q.perPersonLimit || '--' }}</td>
<td>{{ q.aggregateLimit || '--' }}</td>
<td>{{ q.note || '--' }}</td>
</tr>
</tbody>
</table>
</div>
<p v-if="organizedQuotes.length === 0" class="empty-hint">暂无整理后的报价数据</p>
</div>
</div>
</section>
</div>
<div v-else class="loading-state">加载中</div>
</div>
<!-- 报价信息生成标准报价信息弹窗 -->
<div v-if="showQuoteInfoModal" class="modal-overlay" @click.self="showQuoteInfoModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>生成标准报价信息</h3>
<button type="button" class="modal-close" @click="showQuoteInfoModal = false">×</button>
</div>
<div class="modal-body">
<p>根据当前方案信息生成符合行业/平台模板的报价文件可导出 Excel PDF</p>
<div class="modal-preview">
<p>承保险种投保人被保险人承保试验方案号研究分期受试者人数保单期限保单限额等</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @click="showQuoteInfoModal = false">取消</button>
<button type="button" class="btn btn-outline" @click="exportQuoteInfo('excel')">导出 Excel</button>
<button type="button" class="btn btn-primary" @click="exportQuoteInfo('pdf')">导出 PDF</button>
</div>
</div>
</div>
<!-- 风险评估 弹窗 -->
<div v-if="showRiskModal" class="modal-overlay" @click.self="showRiskModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>风险评估</h3>
<button type="button" class="modal-close" @click="showRiskModal = false">×</button>
</div>
<div class="modal-body">
<p>根据受试药物安全特征试验设计受试者状态对项目风险进行定量/定性评估</p>
<div class="form-group">
<label>受试药物安全特征</label>
<select v-model="riskForm.drugSafety">
<option value="">请选择</option>
<option value="low">低风险</option>
<option value="medium">中风险</option>
<option value="high">高风险</option>
</select>
</div>
<div class="form-group">
<label>试验设计风险</label>
<select v-model="riskForm.trialDesign">
<option value="">请选择</option>
<option value="low"></option>
<option value="medium"></option>
<option value="high"></option>
</select>
</div>
<div class="form-group">
<label>受试者状态风险</label>
<select v-model="riskForm.subjectStatus">
<option value="">请选择</option>
<option value="low"></option>
<option value="medium"></option>
<option value="high"></option>
</select>
</div>
<div class="form-group">
<label>综合评估说明</label>
<textarea v-model="riskForm.remark" rows="3" placeholder="补充说明"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @click="showRiskModal = false">取消</button>
<button type="button" class="btn btn-primary" @click="submitRiskAssessment">提交</button>
</div>
</div>
</div>
<!-- 申请报价 弹窗 -->
<div v-if="showApplyModal" class="modal-overlay" @click.self="showApplyModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>申请报价</h3>
<button type="button" class="modal-close" @click="showApplyModal = false">×</button>
</div>
<div class="modal-body">
<p>将方案信息发起给 {{ task?.insurer }} 申请正式报价</p>
<p class="form-hint">确认发送内容及模板后系统将记录申请时间并更新任务状态</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @click="showApplyModal = false">取消</button>
<button type="button" class="btn btn-primary" @click="confirmApplyQuote">确认申请</button>
</div>
</div>
</div>
<!-- 整理报价 弹窗 -->
<div v-if="showOrganizeModal" class="modal-overlay" @click.self="showOrganizeModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>整理报价</h3>
<button type="button" class="modal-close" @click="showOrganizeModal = false">×</button>
</div>
<div class="modal-body">
<p>填写 {{ task?.insurer }} 返回的正式报价数据</p>
<div class="form-group">
<label>年保费</label>
<input v-model="organizeForm.premium" type="text" placeholder="如¥12,800/年" />
</div>
<div class="form-group">
<label>每人责任限额</label>
<input v-model="organizeForm.perPersonLimit" type="text" placeholder="如100万" />
</div>
<div class="form-group">
<label>累计责任限额</label>
<input v-model="organizeForm.aggregateLimit" type="text" placeholder="如500万" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @click="showOrganizeModal = false">取消</button>
<button type="button" class="btn btn-primary" @click="confirmOrganize">保存</button>
</div>
</div>
</div>
<!-- 返回报价 弹窗 -->
<div v-if="showReturnModal" class="modal-overlay" @click.self="showReturnModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>返回报价</h3>
<button type="button" class="modal-close" @click="showReturnModal = false">×</button>
</div>
<div class="modal-body">
<p>确认最终方案和保司将报价结果返回给投保人并同步到项目报价页面</p>
<div class="form-group">
<label>补充说明</label>
<textarea v-model="returnForm.remark" rows="3" placeholder="可选"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @click="showReturnModal = false">取消</button>
<button type="button" class="btn btn-primary" @click="confirmReturnQuote">确认返回</button>
</div>
</div>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import PageContainer from '@/components/PageContainer.vue'
import PageHeader from '@/components/PageHeader.vue'
interface QuoteTaskDetail {
id: string
policyholder: string
sponsor: string
projectTitle: string
projectCode: string
phase: string
subjectCount: number
policyDuration?: string
aggregateLimit: string
perPersonLimit: string
deductible?: string
specialRequirements?: string
insurer: string
status: 'created' | 'preliminary' | 'formal' | 'completed'
premium?: string
}
const route = useRoute()
const task = ref<QuoteTaskDetail | null>(null)
const showQuoteInfoModal = ref(false)
const showRiskModal = ref(false)
const showApplyModal = ref(false)
const showOrganizeModal = ref(false)
const showReturnModal = ref(false)
const detailTab = ref<'collected' | 'standard' | 'organized'>('collected')
const riskForm = ref({ drugSafety: '', trialDesign: '', subjectStatus: '', remark: '' })
const organizeForm = ref({ premium: '', perPersonLimit: '', aggregateLimit: '' })
const returnForm = ref({ remark: '' })
interface OrganizedQuote {
insurer: string
status: string
premium?: string
perPersonLimit?: string
aggregateLimit?: string
note?: string
}
const organizedQuotes = ref<OrganizedQuote[]>([
{ insurer: '太平洋', status: 'completed', premium: '¥12,800/年', perPersonLimit: '100万', aggregateLimit: '500万', note: '3个工作日内出单' },
{ insurer: '大地', status: 'formal', premium: '¥13,200/年', perPersonLimit: '100万', aggregateLimit: '600万', note: '含SUSAR/SAE专项' }
])
const statusText: Record<string, string> = {
created: '已创建',
preliminary: '初步评估',
formal: '正式报价',
completed: '报价完成'
}
const mockMails = ref([
{ direction: 'sent' as const, date: '2025-02-15 10:00', subject: '临床试验责任保险询价 - CT-2025-1001', content: '请见附件报价资料请于5个工作日内回复。' },
{ direction: 'received' as const, date: '2025-02-16 14:30', subject: 'Re: 临床试验责任保险询价 - CT-2025-1001', content: '报价已出,详见附件。' }
])
function loadTask() {
const id = route.params.id as string
//
task.value = {
id,
policyholder: '示例制药',
sponsor: '示例制药有限公司',
projectTitle: 'XXX药物III期临床试验',
projectCode: 'CT-2025-1001',
phase: 'III期',
subjectCount: 300,
policyDuration: '(12,24] 月',
aggregateLimit: '500万',
perPersonLimit: '100万',
deductible: '0',
specialRequirements: '含SUSAR/SAE专项保障',
insurer: '太平洋',
status: 'completed',
premium: '¥12,800/年'
}
}
function exportQuoteInfo(format: string) {
alert(`导出标准报价信息(${format})成功`)
if (showQuoteInfoModal.value) showQuoteInfoModal.value = false
}
function submitRiskAssessment() {
alert('风险评估已提交,评估结果已关联当前方案')
showRiskModal.value = false
riskForm.value = { drugSafety: '', trialDesign: '', subjectStatus: '', remark: '' }
}
function confirmApplyQuote() {
alert(`已向 ${task.value?.insurer} 发起报价申请`)
showApplyModal.value = false
}
function openOrganizeModal() {
if (task.value) {
const existing = organizedQuotes.value.find(q => q.insurer === task.value!.insurer)
organizeForm.value = existing
? { premium: existing.premium || '', perPersonLimit: existing.perPersonLimit || '', aggregateLimit: existing.aggregateLimit || '' }
: { premium: task.value.premium || '', perPersonLimit: task.value.perPersonLimit || '', aggregateLimit: task.value.aggregateLimit || '' }
}
showOrganizeModal.value = true
}
function confirmOrganize() {
if (task.value) {
const existing = organizedQuotes.value.find(q => q.insurer === task.value!.insurer)
if (existing) {
existing.premium = organizeForm.value.premium
existing.perPersonLimit = organizeForm.value.perPersonLimit
existing.aggregateLimit = organizeForm.value.aggregateLimit
} else {
organizedQuotes.value.push({
insurer: task.value.insurer,
status: task.value.status,
premium: organizeForm.value.premium,
perPersonLimit: organizeForm.value.perPersonLimit,
aggregateLimit: organizeForm.value.aggregateLimit
})
}
}
alert('报价数据已保存')
showOrganizeModal.value = false
organizeForm.value = { premium: '', perPersonLimit: '', aggregateLimit: '' }
}
function confirmReturnQuote() {
alert('报价已返回投保人,已同步到项目报价页面')
showReturnModal.value = false
returnForm.value = { remark: '' }
}
onMounted(loadTask)
</script>
<style scoped>
.quote-task-detail-page {
min-height: 100%;
}
.header-actions-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.detail-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.detail-tab {
padding: 10px 20px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--white);
cursor: pointer;
color: var(--text-color);
}
.detail-tab.active {
background: var(--brand-primary, #0ea5e9);
color: var(--white);
border-color: var(--brand-primary, #0ea5e9);
}
.tab-panel {
padding: 0;
}
.form-desc {
font-size: 14px;
color: var(--text-color);
margin: 0 0 16px 0;
line-height: 1.5;
}
.export-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.organize-table-wrap {
overflow-x: auto;
}
.organize-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.organize-table th,
.organize-table td {
padding: 12px 16px;
border: 1px solid var(--border-color);
text-align: left;
}
.organize-table th {
background: rgba(14, 165, 233, 0.08);
font-weight: 600;
}
.info-card {
padding: 24px 28px;
margin-bottom: 24px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--white);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.card-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 20px 0;
color: var(--brand-text-default);
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px 32px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-item-full {
grid-column: 1 / -1;
}
.info-label {
font-size: 13px;
color: var(--text-color);
}
.info-value {
font-size: 15px;
font-weight: 500;
color: var(--brand-text-default);
}
.insurer-info {
padding: 16px;
background: rgba(14, 165, 233, 0.06);
border-radius: 8px;
border: 1px solid rgba(14, 165, 233, 0.2);
}
.insurer-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.insurer-name {
font-size: 16px;
font-weight: 600;
color: var(--brand-primary, #0ea5e9);
}
.status-badge {
display: inline-block;
padding: 4px 10px;
font-size: 12px;
border-radius: 6px;
}
.status-badge.created { background: rgba(107, 114, 128, 0.2); color: #4b5563; }
.status-badge.preliminary { background: rgba(234, 179, 8, 0.2); color: #b45309; }
.status-badge.formal { background: rgba(59, 130, 246, 0.2); color: #1d4ed8; }
.status-badge.completed { background: rgba(34, 197, 94, 0.2); color: #15803d; }
.insurer-quote {
font-size: 14px;
color: var(--text-color);
}
.mail-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.mail-item {
padding: 16px;
background: var(--bg-color, #f9fafb);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.mail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.mail-dir {
font-size: 14px;
font-weight: 600;
color: var(--brand-primary, #0ea5e9);
}
.mail-date {
font-size: 13px;
color: var(--text-color);
}
.mail-body p {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--text-color);
}
.mail-content {
margin-top: 8px !important;
padding: 8px 0;
}
.empty-hint {
padding: 24px;
text-align: center;
color: var(--text-color);
font-size: 14px;
}
.btn {
padding: 10px 20px;
font-size: 14px;
border-radius: 8px;
border: none;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: var(--brand-primary, #0ea5e9);
color: var(--white);
}
.btn-secondary {
background: var(--white);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.loading-state {
padding: 48px;
text-align: center;
color: var(--text-color);
}
.btn-outline {
background: var(--white);
color: var(--brand-primary, #0ea5e9);
border: 1px solid var(--brand-primary, #0ea5e9);
}
.btn-outline:hover {
background: rgba(14, 165, 233, 0.08);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--white);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
max-width: 480px;
width: 90%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
margin: 0;
font-size: 18px;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-color);
line-height: 1;
}
.modal-body {
padding: 20px;
overflow-y: auto;
}
.modal-body p {
margin: 0 0 16px 0;
font-size: 14px;
color: var(--text-color);
}
.modal-preview {
padding: 12px;
background: var(--bg-color, #f9fafb);
border-radius: 8px;
font-size: 13px;
color: var(--text-color);
}
.modal-body .form-group {
margin-bottom: 16px;
}
.modal-body .form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.modal-body .form-group input,
.modal-body .form-group select,
.modal-body .form-group textarea {
width: 100%;
padding: 10px 12px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.form-hint {
font-size: 13px;
color: var(--text-color);
margin: 0 0 16px 0;
}
.modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid var(--border-color);
}
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,584 @@
<template>
<PageContainer>
<div class="quote-task-list-page">
<PageHeader
title="保险报价Smart-OPS"
description="临研安/华泰经纪工作台:管理报价任务,发送保司询价,整理报价结果。保司回复至 RMO@vdano.com。"
/>
<div class="page-body">
<section class="section">
<div class="list-card card">
<div class="list-toolbar">
<div class="filter-row">
<input
v-model="keyword"
type="text"
placeholder="项目编号 / 项目标题"
class="search-input"
/>
<select v-model="statusFilter" class="status-select">
<option value="">全部状态</option>
<option value="created">已创建</option>
<option value="preliminary">初步评估</option>
<option value="formal">正式报价</option>
<option value="completed">报价完成</option>
</select>
<button class="btn btn-primary" @click="loadTasks">查询</button>
</div>
<div class="action-toolbar">
<button type="button" class="btn btn-outline" @click="showQuoteInfoModal = true">
生成标准报价信息
</button>
<button type="button" class="btn btn-outline" @click="showApplyQuoteModal = true">
申请报价
</button>
<button type="button" class="btn btn-outline" @click="openOrganizeModal">
整理报价
</button>
</div>
</div>
<div class="table-wrap">
<table class="task-table">
<thead>
<tr>
<th>投保人</th>
<th>项目编号</th>
<th>项目标题</th>
<th>主险限额</th>
<th>附加险限额</th>
<th>免赔额</th>
<th>保司</th>
<th>报价状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="t in filteredTasks" :key="t.id">
<td>{{ t.policyholder }}</td>
<td>{{ t.projectCode }}</td>
<td class="project-title">{{ t.projectTitle }}</td>
<td>{{ t.mainLimit }}</td>
<td>{{ t.addOnLimit || '--' }}</td>
<td>{{ t.deductible || '--' }}</td>
<td>{{ t.insurer }}</td>
<td>
<span :class="['status-badge', t.status]">
{{ statusText[t.status] }}
</span>
</td>
<td>
<div class="action-btns">
<RouterLink :to="`/dashboard/smart-ops/quote-tasks/${t.id}`" class="btn-link">
编辑
</RouterLink>
<RouterLink :to="`/dashboard/smart-ops/quote-tasks/${t.id}`" class="btn-link">
查看
</RouterLink>
<button
v-if="['created', 'preliminary'].includes(t.status)"
type="button"
class="btn-link"
@click="handleApplyQuote(t)"
>
申请报价
</button>
<button
type="button"
class="btn-link"
@click="handleQuoteInfo(t)"
>
报价信息
</button>
<button
v-if="t.status === 'formal' || t.status === 'completed'"
type="button"
class="btn-link"
@click="handleOrganizeQuote(t)"
>
整理报价
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="filteredTasks.length === 0" class="empty-state">
暂无报价任务
</div>
</div>
</section>
</div>
</div>
<!-- 生成标准报价信息 弹窗 -->
<div v-if="showQuoteInfoModal" class="modal-overlay" @click.self="showQuoteInfoModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>生成标准报价信息</h3>
<button type="button" class="modal-close" @click="showQuoteInfoModal = false">×</button>
</div>
<div class="modal-body">
<p>根据已收集的基础信息生成符合行业/平台模板的报价文件可预览及导出</p>
<div class="modal-preview">
<p>预览标准报价信息表承保险种投保人被保险人承保试验方案号研究分期受试者人数保单期限保单限额等</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @click="showQuoteInfoModal = false">取消</button>
<button type="button" class="btn btn-outline" @click="exportQuoteInfo('excel')">导出 Excel</button>
<button type="button" class="btn btn-primary" @click="exportQuoteInfo('pdf')">导出 PDF</button>
</div>
</div>
</div>
<!-- 申请报价 弹窗 -->
<div v-if="showApplyQuoteModal" class="modal-overlay" @click.self="showApplyQuoteModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>申请报价</h3>
<button type="button" class="modal-close" @click="showApplyQuoteModal = false">×</button>
</div>
<div class="modal-body">
<p>选择需申请的保司确认发送内容及模板支持批量申请</p>
<div class="form-group">
<label>选择保司</label>
<div class="insurer-checkboxes">
<label v-for="ins in ['太平洋', '太平', '大地', '平安', '华泰财', '亚太']" :key="ins" class="checkbox-label">
<input type="checkbox" :value="ins" v-model="selectedInsurers" />
{{ ins }}
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @click="showApplyQuoteModal = false">取消</button>
<button type="button" class="btn btn-primary" @click="confirmApplyQuote">确认申请</button>
</div>
</div>
</div>
<!-- 整理报价 弹窗 -->
<div v-if="showOrganizeModal" class="modal-overlay" @click.self="showOrganizeModal = false">
<div class="modal-content modal-wide">
<div class="modal-header">
<h3>整理报价</h3>
<button type="button" class="modal-close" @click="showOrganizeModal = false">×</button>
</div>
<div class="modal-body">
<p>归集各保司返回的报价生成汇总对比表可导出 Excel/PDF</p>
<div class="organize-table-wrap">
<p v-if="filteredTasks.length === 0" class="empty-hint">暂无报价任务可整理</p>
<table v-else class="organize-table">
<thead>
<tr>
<th>保司</th>
<th>报价状态</th>
<th>年保费</th>
<th>每人限额</th>
<th>累计限额</th>
</tr>
</thead>
<tbody>
<tr v-for="t in filteredTasks" :key="t.id">
<td>{{ t.insurer }}</td>
<td><span :class="['status-badge', t.status]">{{ statusText[t.status] }}</span></td>
<td><input v-model="organizeData[t.id].premium" type="text" placeholder="填写" class="organize-input" /></td>
<td><input v-model="organizeData[t.id].perPerson" type="text" placeholder="填写" class="organize-input" /></td>
<td><input v-model="organizeData[t.id].aggregate" type="text" placeholder="填写" class="organize-input" /></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @click="showOrganizeModal = false">取消</button>
<button type="button" class="btn btn-outline" @click="exportOrganize('excel')">导出 Excel</button>
<button type="button" class="btn btn-primary" @click="confirmOrganize">保存并生成对比表</button>
</div>
</div>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import PageContainer from '@/components/PageContainer.vue'
import PageHeader from '@/components/PageHeader.vue'
interface QuoteTask {
id: string
policyholder: string
projectCode: string
projectTitle: string
mainLimit: string
addOnLimit?: string
deductible?: string
insurer: string
status: 'created' | 'preliminary' | 'formal' | 'completed'
}
const keyword = ref('')
const statusFilter = ref('')
const showQuoteInfoModal = ref(false)
const showApplyQuoteModal = ref(false)
const showOrganizeModal = ref(false)
const selectedInsurers = ref<string[]>([])
const organizeData = ref<Record<string, { premium?: string; perPerson?: string; aggregate?: string }>>({})
const statusText: Record<string, string> = {
created: '已创建',
preliminary: '初步评估',
formal: '正式报价',
completed: '报价完成'
}
const tasks = ref<QuoteTask[]>([])
const filteredTasks = computed(() => {
let list = tasks.value
if (keyword.value.trim()) {
const k = keyword.value.toLowerCase()
list = list.filter(t =>
t.projectCode.toLowerCase().includes(k) ||
t.projectTitle.toLowerCase().includes(k)
)
}
if (statusFilter.value) {
list = list.filter(t => t.status === statusFilter.value)
}
return list
})
function loadTasks() {
//
tasks.value = [
{ id: '1', policyholder: '示例制药', projectCode: 'CT-2025-1001', projectTitle: 'XXX药物III期临床试验', mainLimit: '100万/人', addOnLimit: '500万', deductible: '0', insurer: '太平洋', status: 'completed' },
{ id: '2', policyholder: '示例制药', projectCode: 'CT-2025-1001', projectTitle: 'XXX药物III期临床试验', mainLimit: '100万/人', addOnLimit: '500万', deductible: '0', insurer: '大地', status: 'formal' },
{ id: '3', policyholder: '示例制药', projectCode: 'CT-2025-1001', projectTitle: 'XXX药物III期临床试验', mainLimit: '80万/人', addOnLimit: '400万', deductible: '0', insurer: '太平', status: 'preliminary' },
{ id: '4', policyholder: '示例生物', projectCode: 'CT-2025-1002', projectTitle: 'YYY疫苗I期临床试验', mainLimit: '50万/人', addOnLimit: '200万', deductible: '0', insurer: '太保', status: 'created' }
]
}
function handleApplyQuote(t: QuoteTask) {
selectedInsurers.value = [t.insurer]
showApplyQuoteModal.value = true
}
function handleQuoteInfo(t: QuoteTask) {
showQuoteInfoModal.value = true
}
function openOrganizeModal() {
const list = filteredTasks.value
organizeData.value = list.length ? list.reduce((acc, x) => ({ ...acc, [x.id]: {} }), {} as Record<string, { premium?: string; perPerson?: string; aggregate?: string }>) : {}
showOrganizeModal.value = true
}
function handleOrganizeQuote(t: QuoteTask) {
openOrganizeModal()
}
function exportQuoteInfo(format: string) {
alert(`导出标准报价信息(${format})成功`)
showQuoteInfoModal.value = false
}
function confirmApplyQuote() {
if (selectedInsurers.value.length === 0) {
alert('请至少选择一家保司')
return
}
alert(`已向 ${selectedInsurers.value.join('、')} 发起报价申请`)
showApplyQuoteModal.value = false
selectedInsurers.value = []
}
function confirmOrganize() {
alert('报价已整理,对比表已生成')
showOrganizeModal.value = false
}
function exportOrganize(format: string) {
alert(`导出报价对比表(${format})成功`)
}
onMounted(loadTasks)
</script>
<style scoped>
.quote-task-list-page {
min-height: 100%;
}
.list-card {
padding: 24px 28px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--white);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.list-toolbar {
margin-bottom: 20px;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.search-input {
width: 220px;
padding: 10px 12px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.status-select {
width: 140px;
padding: 10px 12px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.btn {
padding: 10px 20px;
font-size: 14px;
border-radius: 8px;
border: none;
cursor: pointer;
}
.btn-primary {
background: var(--brand-primary, #0ea5e9);
color: var(--white);
}
.table-wrap {
overflow-x: auto;
}
.task-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.task-table th,
.task-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.task-table th {
font-weight: 600;
color: var(--brand-text-default);
background: rgba(14, 165, 233, 0.06);
}
.project-title {
max-width: 200px;
}
.status-badge {
display: inline-block;
padding: 4px 10px;
font-size: 12px;
border-radius: 6px;
}
.status-badge.created {
background: rgba(107, 114, 128, 0.2);
color: #4b5563;
}
.status-badge.preliminary {
background: rgba(234, 179, 8, 0.2);
color: #b45309;
}
.status-badge.formal {
background: rgba(59, 130, 246, 0.2);
color: #1d4ed8;
}
.status-badge.completed {
background: rgba(34, 197, 94, 0.2);
color: #15803d;
}
.action-btns {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn-link {
font-size: 13px;
color: var(--brand-primary, #0ea5e9);
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: none;
}
.btn-link:hover {
text-decoration: underline;
}
.empty-state {
padding: 48px 24px;
text-align: center;
color: var(--text-color);
}
.action-toolbar {
display: flex;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.btn-outline {
background: var(--white);
color: var(--brand-primary, #0ea5e9);
border: 1px solid var(--brand-primary, #0ea5e9);
}
.btn-outline:hover {
background: rgba(14, 165, 233, 0.08);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--white);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
max-width: 480px;
width: 90%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-content.modal-wide {
max-width: 720px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
margin: 0;
font-size: 18px;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-color);
line-height: 1;
}
.modal-body {
padding: 20px;
overflow-y: auto;
}
.modal-body p {
margin: 0 0 16px 0;
font-size: 14px;
color: var(--text-color);
}
.modal-preview {
padding: 12px;
background: var(--bg-color, #f9fafb);
border-radius: 8px;
font-size: 13px;
color: var(--text-color);
}
.insurer-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 14px;
}
.organize-table-wrap {
overflow-x: auto;
margin-top: 12px;
}
.organize-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.organize-table th,
.organize-table td {
padding: 10px 12px;
border: 1px solid var(--border-color);
text-align: left;
}
.organize-table th {
background: rgba(14, 165, 233, 0.08);
font-weight: 600;
}
.organize-input {
width: 100%;
padding: 6px 8px;
font-size: 13px;
border: 1px solid var(--border-color);
border-radius: 6px;
}
.modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid var(--border-color);
}
.empty-hint {
padding: 24px;
text-align: center;
color: var(--text-color);
}
</style>