增加理赔及注册功能,修改需求说明文档(PRD),符合当前项目状况。

This commit is contained in:
william.wan 2026-02-17 21:37:04 +08:00
parent d5ec548602
commit ed57c34820
21 changed files with 2518 additions and 730 deletions

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -223,5 +223,75 @@ flowchart TB
> 每一个操作需在系统内形成业务日志,支持后续审计和流程追踪。 > 每一个操作需在系统内形成业务日志,支持后续审计和流程追踪。
# 报价对比一览表
## 维度说明
1. **保障范围**所用保险条款事件SUSAR、所有AE/SAE相关因素受试药物、对照药物、合并用药、手术操作赔付比例全流程服务承诺书
2. **风险管理服务**:知情同意书审阅/修改/建议CRC 及相关方风险管理与最小化培训
3. **理赔服务**:服务专线、费用理算、理赔沟通协调、理赔时效承诺、纠纷协调
4. **价格**:保费、限额、免赔额、未入组人数退费比例
---
## 保司报价对比表
### 一、基础报价与保障限额
| 保司名称 | 保费报价(元) | 每人责任限额(万) | 累计责任限额(万) | 免赔额(万) | 报价有效期 | 是否支持附加险 |
|------------|----------------|--------------------|--------------------|--------------|------------|----------------|
| 太平保险 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 太平洋保险 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 亚太 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 华泰财 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 大地 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 平安 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
### 二、保障范围
| 保司名称 | 所用保险条款 | 承保事件SUSAR/AE/SAE | 相关因素覆盖(受试药物/对照药物/合并用药/手术操作) | 医药费用100%赔付 | 可能相关赔付比例 | 肯定相关赔付比例 | 全流程服务承诺书 |
|------------|------------------------|---------------------------|------------------------------------------------------|------------------|------------------|------------------|------------------|
| 太平保险 | 待填 | 待填 | 待填 | 待填 | 待填如70%/80%| 待填 | 待填 |
| 太平洋保险 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 亚太 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 华泰财 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 大地 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 平安 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
### 三、风险管理服务
| 保司名称 | 知情同意书审阅、修改、建议 | CRC 及相关方风险管理与最小化培训 |
|------------|----------------------------|-----------------------------------|
| 太平保险 | 待填 | 待填 |
| 太平洋保险 | 待填 | 待填 |
| 亚太 | 待填 | 待填 |
| 华泰财 | 待填 | 待填 |
| 大地 | 待填 | 待填 |
| 平安 | 待填 | 待填 |
### 四、理赔服务
| 保司名称 | 服务专线 | 相关费用理算 | 理赔结论沟通、协调 | 理赔时效承诺7/15天 | 受试者潜在纠纷沟通、协调、解释 |
|------------|-------------|--------------|--------------------|-------------------------|--------------------------------|
| 太平保险 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 太平洋保险 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 亚太 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 华泰财 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 大地 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 平安 | 待填 | 待填 | 待填 | 待填 | 待填 |
### 五、价格与退费
| 保司名称 | 未入组人数退费比例 | 支付周期 | 备注 |
|------------|--------------------|----------|------|
| 太平保险 | 待填 | 待填 | — |
| 太平洋保险 | 待填 | 待填 | — |
| 亚太 | 待填 | 待填 | — |
| 华泰财 | 待填 | 待填 | 与经纪服务联动 |
| 大地 | 待填 | 待填 | 含 SUSAR/SAE 专项 |
| 平安 | 待填 | 待填 | 全国网点理赔 |
---
> **使用说明**:以上表格为报价对比模板,临研安/华泰经纪在整理各保司正式报价时按实填写。服务专线 4009606520 为平台统一服务热线。

127
docs/风险管理模式.md Normal file
View File

@ -0,0 +1,127 @@
风险管理模式
#### 3.1.1 参与主体
- **临床试验风险主体**:申办者(研究机构及研究者协助)
- **参与临床试验的主体**申办者、研究者、研究机构工作人员、CRO、CRC、受试者
#### 3.1.2 临床试验风险分类
**临床试验风险类型**SUSAR、ADRSADR、AESAE、与试验相关非医疗一切风险
**风险逻辑:**
**从临床试验药物角度:**
1. **为证明与药物相关**AE不良事件
2. **已证明与药物相关**ADR药物不良反应
- **SADR严重药物不良反应**严重的ADR
- ADR写入IB的RSI参考安全信息
3. **新发现的严重不良反应**SUSAR可疑且非预期的严重不良反应需及时报告
4. **未写入RSI的严重不良反应属于SUSAR**
**从临床试验操作角度:**
1. 临床试验方案合理性
2. 临床试验方案操作是否符合规定
3. 医疗行为是否合理
4. 临床试验组织管理是否合理
**其他与临床试验有相关性的内容:**
1. 行为原则
2. 心理原因
3. 其他
#### 3.1.3 赔偿/补偿的内容及基本原则
1. **最大范围医疗报销原则**与试验相关的一切风险发生的必要且合理的医疗支出100%报销。(高频、时效、便捷、最好无垫付)
2. **身故、伤残赔偿金**(低频)
3. **无过错责任补偿金**(低频)
4. **精算损失赔偿金**(低频)
#### 3.1.4 风险与保险条款的匹配性问题
**首要风险:**
- 投保人最关注的厌恶的风险有哪些:
- SUSAR影响试验走向
- SAE经济成本、影响试验进展
- 非医疗类恶性事件(影响试验进展)
- **特点**:此类风险发生的意愿程度,投保人与保险人完全一致。通过高杠杆方式保险解决
**次要风险:**
- AE与试验相关风险
- **特点**:控制该类风险并非投保人第一关注点(本质上是道德风险),因此容易产生风险敞口,因此保险人在评估风险杠杆承担存在压力和不确定性。
- **解决方案**"自保"+"风险减量服务"+"外溢风险管理服务"
#### 3.1.5 风险管理逻辑模型
**基于以上背景,拟提出的风险管理逻辑模型:**
1. **首要风险**:通过高杠杆方式保险解决
2. **次要风险**"自保"+"风险减量服务"+"外溢风险管理服务"解决
**具体解决方式:**
**首要风险的责任:**
- SUSAR、SAE造成的身故、残疾赔偿金
- **强调**:责任明确、金额较大、证据链清晰
- **方式**:用保险进行风险转移
**次要风险的责任:**
- 非SUSAR所发生的医疗费用
- **方式**用RMO模式解决
### 3.2 RMO模式详细说明
#### 3.2.1 模式定位
RMO模式也可以换个名字避免和我们全委托投保的选择冲突
**定位**:为投保人解决三件事:
1. "自保"代位执行
2. 风险减量服务
3. 外溢风险管理服务
#### 3.2.2 合作机构
- 华泰保险经纪
- 临研安
- 其他合作方X
#### 3.2.3 具体操作流程
**第一步:风险评估与投保**
- 华泰经纪协助申办者基于项目复杂度和风险度厘清首要风险与次要风险
- 通过华泰保险经纪完成首要风险投保
**第二步:专项风险管理基金设立**
- 申办者根据项目的大小、历史或行业经验向华泰经纪支付风险管理费采购健康医疗服务例如一个项目3-5万
- 双方约定健康医疗服务费的使用范围、对象、执行和审批流程、额度等要素
- 该费用作为申办者的专项风险管理基金,实行多退少补原则
**第三步:费用管理**
- 健康医疗服务成本以医院出具的医疗费用清单为结算凭证
- **风险管理费采用两种模式:**
- **比例费用**整体费用的12-15%
- 举例申办者支付5万元其中6千元为风险管理费4.4万计入专项风险管理基金
- **案件费用**:出现大额、疑难医疗行为与受试者提出额外医疗行为时,针对每个案件收取服务费用(费用结算固定值或者减损比例值)
**第四步:医疗直付(逐步实现)**
- 华泰力争逐步做到与医院实现直付
- 华泰长期为日本多家保险公司提供此类服务日本人在中国的转诊、医疗推荐、费用结算都是华泰完成此业务已开展近30年
- 具有外币结算资质
- 华泰逐步实现和各家医院打通直付,具体额度、时效、范围等还需要再讨论
**第五步:出险处理流程**
1. 受试者发生AE或其他医疗需求
2. 医院第一时间通知华泰
3. 华泰正常情况下24小时内与医院、受试者沟通并通知申办者
- 特殊情况处理:如遇特殊情况,例如受试者非集中、境外、无法联系、不可抗力等,应急处理模式
4. 安抚受试者、了解医院拟采取的医疗方式及成本
5. 合理且必要的医疗行为及时安排并承诺受试者救治
6. 评估申办者专项风险管理基金的余额
7. 将以上信息报送申办者
8. 申办者同意使用该基金后,华泰与医院进行结算
9. 不足部分需申办者补齐,并提供一定费用的余额,以便下次使用
**第六步:风险减量服务**
- 华泰经纪与临研安针对临床试验链条的各个环节进行风险点检查
- 人员培训
- 方案完善建议
**第七步:外溢风险管理服务**
- 针对受试者出险后的全流程管理
- 联系、安抚、安排就医、沟通诉求、沟通合理预期

View File

@ -84,11 +84,14 @@ const pathLabels: Record<string, string> = {
'/privacy-policy': '隐私政策', '/privacy-policy': '隐私政策',
'/overseas': '海外风险', '/overseas': '海外风险',
'/login': '登录', '/login': '登录',
'/register': '用户注册',
// Dashboard // Dashboard
'/dashboard': '工作台', '/dashboard': '工作台',
'/dashboard/project-quotes': '项目报价', '/dashboard/project-quotes': '项目报价',
'/dashboard/quote-compare': '报价对比', '/dashboard/quote-compare': '报价对比',
'/dashboard/smart-ops/quote-tasks': '保险报价Smart-OPS', '/dashboard/smart-ops': 'Smart-OPS',
'/dashboard/smart-ops/quote-tasks': '保险报价',
'/dashboard/smart-ops/user-approvals': '用户审批',
'/dashboard/projects': '项目列表', '/dashboard/projects': '项目列表',
'/dashboard/projects/:id': '项目详情', '/dashboard/projects/:id': '项目详情',
'/dashboard/inquiries': '询价列表', '/dashboard/inquiries': '询价列表',
@ -151,7 +154,9 @@ function getLabel(path: string): string {
'privacy-policy': '隐私政策', 'privacy-policy': '隐私政策',
'project-quotes': '项目报价', 'project-quotes': '项目报价',
'quote-compare': '报价对比', 'quote-compare': '报价对比',
'smart-ops': 'Smart-OPS',
'quote-tasks': '保险报价', 'quote-tasks': '保险报价',
'user-approvals': '用户审批',
projects: '项目列表', projects: '项目列表',
inquiries: '询价列表', inquiries: '询价列表',
claims: '理赔进度', claims: '理赔进度',

View File

@ -39,10 +39,16 @@
</RouterLink> </RouterLink>
</template> </template>
<template v-if="isTPA"> <template v-if="isTPA">
<RouterLink to="/dashboard/smart-ops/quote-tasks" :class="['nav-item', { active: isActiveParent('/dashboard/smart-ops') }]"> <div class="nav-group">
<span class="nav-icon">📑</span> <div :class="['nav-item', { active: isActiveParent('/dashboard/smart-ops') }]">
<span v-show="sidebarOpen" class="nav-text">保险报价Smart-OPS</span> <span class="nav-icon">📑</span>
</RouterLink> <span v-show="sidebarOpen" class="nav-text">Smart-OPS</span>
</div>
<div v-show="isActiveParent('/dashboard/smart-ops')" class="nav-submenu">
<RouterLink to="/dashboard/smart-ops/quote-tasks" :class="['nav-subitem', { active: route.path.startsWith('/dashboard/smart-ops/quote-tasks') }]">保险报价</RouterLink>
<RouterLink to="/dashboard/smart-ops/user-approvals" :class="['nav-subitem', { active: route.path === '/dashboard/smart-ops/user-approvals' }]">用户审批</RouterLink>
</div>
</div>
</template> </template>
<template v-if="isPolicyholder || isInsurer"> <template v-if="isPolicyholder || isInsurer">
<RouterLink to="/dashboard/claims" :class="['nav-item', { active: isActiveParent('/dashboard/claims') }]"> <RouterLink to="/dashboard/claims" :class="['nav-item', { active: isActiveParent('/dashboard/claims') }]">

View File

@ -291,8 +291,20 @@
} }
/* 登录按钮vdano 风格多为线框或文字按钮 */ /* 登录按钮vdano 风格多为线框或文字按钮 */
.login-btn { .register-btn {
margin-left: 20px; margin-left: 20px;
margin-right: 12px;
font-size: 14px;
color: var(--text-color);
text-decoration: none;
}
.register-btn:hover {
color: #0ea5e9;
}
.login-btn {
margin-left: 0;
padding: 8px 24px; padding: 8px 24px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;

View File

@ -118,6 +118,7 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<RouterLink to="/register" class="register-btn">注册</RouterLink>
<RouterLink to="/login" class="login-btn">登录</RouterLink> <RouterLink to="/login" class="login-btn">登录</RouterLink>
</template> </template>
</nav> </nav>

View File

@ -9,7 +9,7 @@ function Contact() {
<div className="contact-page"> <div className="contact-page">
<PageHeader <PageHeader
title="联系我们" title="联系我们"
description="获取RMO最新资讯第一时间了解我们的企业动态" description="获取RMO最新资讯第一时间了解风险管理动态"
/> />
<div className="page-body"> <div className="page-body">
<section className="section"> <section className="section">

View File

@ -184,7 +184,7 @@ function Home() {
> >
<div className="section-content"> <div className="section-content">
<h2 className="section-title"></h2> <h2 className="section-title"></h2>
<p className="section-subtitle">RMO最新资讯</p> <p className="section-subtitle">RMO最新资讯</p>
<div className="contact-actions"> <div className="contact-actions">
<Link to="/contact" className="btn btn-primary"></Link> <Link to="/contact" className="btn btn-primary"></Link>
<Link to="/about/overview" className="btn btn-secondary">RMO</Link> <Link to="/about/overview" className="btn btn-secondary">RMO</Link>

View File

@ -35,6 +35,7 @@ import LearningCenter from '@/views/LearningCenter.vue'
import Contact from '@/views/Contact.vue' import Contact from '@/views/Contact.vue'
import PrivacyPolicy from '@/views/PrivacyPolicy.vue' import PrivacyPolicy from '@/views/PrivacyPolicy.vue'
import Login from '@/views/Login.vue' import Login from '@/views/Login.vue'
import Register from '@/views/Register.vue'
// Dashboard // Dashboard
import Dashboard from '@/views/dashboard/Dashboard.vue' import Dashboard from '@/views/dashboard/Dashboard.vue'
@ -50,6 +51,7 @@ import ProjectQuotes from '@/views/dashboard/ProjectQuotes.vue'
import QuoteCompare from '@/views/dashboard/QuoteCompare.vue' import QuoteCompare from '@/views/dashboard/QuoteCompare.vue'
import QuoteTaskList from '@/views/dashboard/smart-ops/QuoteTaskList.vue' import QuoteTaskList from '@/views/dashboard/smart-ops/QuoteTaskList.vue'
import QuoteTaskDetail from '@/views/dashboard/smart-ops/QuoteTaskDetail.vue' import QuoteTaskDetail from '@/views/dashboard/smart-ops/QuoteTaskDetail.vue'
import UserApprovalList from '@/views/dashboard/smart-ops/UserApprovalList.vue'
import ICFEditor from '@/views/dashboard/ICFEditor.vue' import ICFEditor from '@/views/dashboard/ICFEditor.vue'
import RiskScoring from '@/views/dashboard/RiskScoring.vue' import RiskScoring from '@/views/dashboard/RiskScoring.vue'
import ProtocolRiskAssessment from '@/views/dashboard/ProtocolRiskAssessment.vue' import ProtocolRiskAssessment from '@/views/dashboard/ProtocolRiskAssessment.vue'
@ -113,6 +115,7 @@ const router = createRouter({
{ path: 'privacy-policy', component: PrivacyPolicy }, { path: 'privacy-policy', component: PrivacyPolicy },
{ path: 'overseas', component: Overseas }, { path: 'overseas', component: Overseas },
{ path: 'login', component: Login }, { path: 'login', component: Login },
{ path: 'register', component: Register },
] ]
}, },
{ {
@ -129,6 +132,7 @@ const router = createRouter({
{ path: 'quote-compare', component: QuoteCompare }, { path: 'quote-compare', component: QuoteCompare },
{ path: 'smart-ops/quote-tasks', component: QuoteTaskList }, { path: 'smart-ops/quote-tasks', component: QuoteTaskList },
{ path: 'smart-ops/quote-tasks/:id', component: QuoteTaskDetail }, { path: 'smart-ops/quote-tasks/:id', component: QuoteTaskDetail },
{ path: 'smart-ops/user-approvals', component: UserApprovalList },
{ path: 'projects', component: ProjectList }, { path: 'projects', component: ProjectList },
{ path: 'projects/:id', component: ProjectDetail }, { path: 'projects/:id', component: ProjectDetail },
{ path: 'inquiries', component: InquiryList }, { path: 'inquiries', component: InquiryList },

118
src/stores/registration.ts Normal file
View File

@ -0,0 +1,118 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface PendingRegistration {
id: string
organizationId: string
organizationName: string
name: string
phone: string
email: string
position: string
department: string
idCardFile?: string
workProofFile?: string
status: 'pending' | 'approved' | 'rejected'
rejectReason?: string
createdAt: string
updatedAt?: string
}
export interface Organization {
id: string
name: string
}
const STORAGE_KEY = 'rmo_pending_registrations'
const ORGS_KEY = 'rmo_organizations'
function loadRegistrations(): PendingRegistration[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveRegistrations(list: PendingRegistration[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list))
}
export const useRegistrationStore = defineStore('registration', () => {
const organizations = ref<Organization[]>([
{ id: 'org1', name: '示例制药有限公司' },
{ id: 'org2', name: '示例生物科技有限公司' },
{ id: 'org3', name: '华泰保险经纪有限公司' },
{ id: 'org4', name: '临研安(北京)科技有限公司' },
{ id: 'org5', name: '太平洋财产保险股份有限公司' }
])
const reservedEmails = ['admin@rmo.com', 'policyholder@rmo.com', 'insurer@rmo.com', 'tpa@vdano.com']
function getPendingRegistrations(): PendingRegistration[] {
return loadRegistrations().filter(r => r.status === 'pending')
}
function getAllRegistrations(): PendingRegistration[] {
return loadRegistrations()
}
function submitRegistration(data: Omit<PendingRegistration, 'id' | 'status' | 'createdAt'>): Promise<{ success: boolean; message?: string }> {
return new Promise((resolve) => {
const list = loadRegistrations()
if (reservedEmails.includes(data.email.toLowerCase())) {
resolve({ success: false, message: '该邮箱已被注册' })
return
}
if (list.some(r => r.email.toLowerCase() === data.email.toLowerCase())) {
resolve({ success: false, message: '该邮箱已被注册' })
return
}
if (data.phone && list.some(r => r.phone === data.phone)) {
resolve({ success: false, message: '该手机号已被注册' })
return
}
const now = new Date().toISOString()
const reg: PendingRegistration = {
...data,
id: `reg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
status: 'pending',
createdAt: now
}
list.unshift(reg)
saveRegistrations(list)
resolve({ success: true })
})
}
function approveRegistration(id: string): void {
const list = loadRegistrations()
const idx = list.findIndex(r => r.id === id)
if (idx >= 0) {
list[idx].status = 'approved'
list[idx].updatedAt = new Date().toISOString()
saveRegistrations(list)
}
}
function rejectRegistration(id: string, reason?: string): void {
const list = loadRegistrations()
const idx = list.findIndex(r => r.id === id)
if (idx >= 0) {
list[idx].status = 'rejected'
list[idx].rejectReason = reason
list[idx].updatedAt = new Date().toISOString()
saveRegistrations(list)
}
}
return {
organizations,
getPendingRegistrations,
getAllRegistrations,
submitRegistration,
approveRegistration,
rejectRegistration
}
})

View File

@ -98,7 +98,7 @@ const sections = [
{ class: 'solutions-section', title: '', subtitle: '' }, { class: 'solutions-section', title: '', subtitle: '' },
{ class: 'capabilities-section', title: '核心能力', subtitle: '' }, { class: 'capabilities-section', title: '核心能力', subtitle: '' },
{ class: 'knowledge-section', title: '知识资源', subtitle: '分享我们对行业的洞见和风险资讯' }, { class: 'knowledge-section', title: '知识资源', subtitle: '分享我们对行业的洞见和风险资讯' },
{ class: 'contact-section', title: '联系我们', subtitle: '获取RMO最新资讯第一时间了解我们的企业动态' } { class: 'contact-section', title: '联系我们', subtitle: '获取RMO最新资讯第一时间了解风险管理动态' }
] ]
const capabilities = [ const capabilities = [

View File

@ -46,7 +46,7 @@
</button> </button>
</form> </form>
<div class="login-footer"> <div class="login-footer">
<p>还没有账号请联系管理员创建账号</p> <p>还没有账号<RouterLink to="/register">立即注册</RouterLink>请联系管理员创建账号</p>
</div> </div>
</div> </div>
</div> </div>

267
src/views/Register.vue Normal file
View File

@ -0,0 +1,267 @@
<template>
<PageContainer>
<div class="register">
<PageHeader
title="用户注册"
description="选择组织并填写个人信息,提交后等待平台管理员审批。审批通过后将获得登录权限。"
/>
<div class="page-body">
<section class="section">
<div class="container">
<div class="register-card card main-card">
<form class="register-form" @submit.prevent="handleSubmit">
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="success-message">
注册申请已提交我们将尽快审核审核结果将通过邮件通知您
<RouterLink to="/login">返回登录</RouterLink>
</div>
<template v-else>
<div class="form-section">
<h3>1. 选择组织</h3>
<div class="form-group">
<label for="organization">所在公司/组织 <span class="required">*</span></label>
<select
id="organization"
v-model="form.organizationId"
required
:disabled="loading"
>
<option value="">请选择组织</option>
<option
v-for="org in registrationStore.organizations"
:key="org.id"
:value="org.id"
>
{{ org.name }}
</option>
</select>
</div>
</div>
<div class="form-section">
<h3>2. 个人信息</h3>
<div class="form-row">
<div class="form-group">
<label for="name">姓名 <span class="required">*</span></label>
<input
id="name"
v-model="form.name"
type="text"
placeholder="请输入姓名"
required
:disabled="loading"
/>
</div>
<div class="form-group">
<label for="phone">手机号 <span class="required">*</span></label>
<input
id="phone"
v-model="form.phone"
type="tel"
placeholder="请输入手机号"
required
:disabled="loading"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="email">邮箱 <span class="required">*</span></label>
<input
id="email"
v-model="form.email"
type="email"
placeholder="请输入邮箱"
required
:disabled="loading"
/>
</div>
<div class="form-group">
<label for="position">职位</label>
<input
id="position"
v-model="form.position"
type="text"
placeholder="请输入职位"
:disabled="loading"
/>
</div>
</div>
<div class="form-group">
<label for="department">部门</label>
<input
id="department"
v-model="form.department"
type="text"
placeholder="请输入部门"
:disabled="loading"
/>
</div>
<div class="form-group">
<label>证明材料可选</label>
<div class="upload-hint">
可上传身份证工作证明等支持后续补充
</div>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
multiple
class="file-input"
@change="handleFileChange"
/>
</div>
</div>
<div class="form-actions">
<RouterLink to="/login" class="btn btn-outline">返回登录</RouterLink>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? '提交中...' : '提交注册' }}
</button>
</div>
</template>
</form>
</div>
</div>
</section>
</div>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRegistrationStore } from '@/stores/registration'
import PageContainer from '@/components/PageContainer.vue'
import PageHeader from '@/components/PageHeader.vue'
const registrationStore = useRegistrationStore()
const form = reactive({
organizationId: '',
organizationName: '',
name: '',
phone: '',
email: '',
position: '',
department: '',
idCardFile: '',
workProofFile: ''
})
const error = ref('')
const success = ref(false)
const loading = ref(false)
function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement
const files = target.files
if (files?.length) {
//
console.log('Selected files:', Array.from(files).map(f => f.name))
}
}
async function handleSubmit() {
error.value = ''
if (!form.organizationId) {
error.value = '请选择所在组织'
return
}
const org = registrationStore.organizations.find(o => o.id === form.organizationId)
if (!org) {
error.value = '请选择有效的组织'
return
}
loading.value = true
try {
const result = await registrationStore.submitRegistration({
organizationId: form.organizationId,
organizationName: org.name,
name: form.name.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
position: form.position.trim(),
department: form.department.trim(),
idCardFile: form.idCardFile,
workProofFile: form.workProofFile
})
if (result.success) {
success.value = true
} else {
error.value = result.message || '提交失败'
}
} catch {
error.value = '提交失败,请稍后重试'
} finally {
loading.value = false
}
}
</script>
<style scoped>
@import '@/pages/Login.css';
.register-card {
max-width: 560px;
margin: 0 auto;
padding: 40px;
}
.register-card h2 {
font-size: 28px;
margin-bottom: 24px;
text-align: center;
color: var(--primary-color);
}
.form-section {
margin-bottom: 28px;
}
.form-section h3 {
font-size: 16px;
margin-bottom: 16px;
color: var(--text-color);
font-weight: 600;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.required {
color: #e11;
}
.upload-hint {
font-size: 12px;
color: var(--text-light);
margin-bottom: 8px;
}
.file-input {
font-size: 14px;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
}
.success-message {
background: #e8f5e9;
color: #2e7d32;
padding: 20px;
border-radius: 8px;
border: 1px solid #c8e6c9;
}
.success-message a {
color: var(--primary-color);
margin-left: 8px;
}
</style>

View File

@ -44,33 +44,33 @@
<!-- 对比表格 --> <!-- 对比表格 -->
<div v-if="selectedProject && quotes.length > 0" class="compare-card card"> <div v-if="selectedProject && quotes.length > 0" class="compare-card card">
<h3 class="compare-title">保司报价对比</h3> <h3 class="compare-title">保司报价对比</h3>
<p class="compare-desc">归集各保司返回的正式报价按多维度对比呈现便于选定</p>
<div class="table-wrap"> <div class="table-wrap">
<table class="compare-table"> <table class="compare-table insurer-compare-table transposed">
<thead> <thead>
<tr> <tr>
<th>保司</th> <th class="th-sticky">维度</th>
<th>报价状态</th> <th v-for="q in quotes" :key="q.insurer">{{ q.insurer }}</th>
<th>年保费</th>
<th>每人责任限额</th>
<th>累计责任限额</th>
<th>免赔额</th>
<th>备注</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="q in quotes" :key="q.insurer"> <template v-for="(group, gIdx) in compareDimensionGroups" :key="gIdx">
<td class="insurer-name">{{ q.insurer }}</td> <tr v-if="group.groupName" class="group-header">
<td> <td :colspan="quotes.length + 1" class="group-cell">{{ group.groupName }}</td>
<span :class="['status-badge', q.status]"> </tr>
{{ statusText[q.status] }} <tr v-for="dim in group.dimensions" :key="dim.key">
</span> <td class="td-sticky dim-label">{{ dim.label }}</td>
</td> <td v-for="q in quotes" :key="q.insurer">
<td class="premium">{{ q.premium || '--' }}</td> <template v-if="dim.key === 'status'">
<td>{{ q.perPersonLimit || '--' }}</td> <span v-if="getQuoteValue(q, dim.key)" :class="['status-badge', getQuoteValue(q, dim.key)]">{{ statusText[getQuoteValue(q, dim.key)] || getQuoteValue(q, dim.key) }}</span>
<td>{{ q.aggregateLimit || '--' }}</td> <span v-else>待填</span>
<td>{{ q.deductible || '--' }}</td> </template>
<td class="note">{{ q.note || '--' }}</td> <template v-else>
</tr> {{ getQuoteValue(q, dim.key) || '待填' }}
</template>
</td>
</tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -109,19 +109,99 @@ const mockSchemes = [
const statusText: Record<string, string> = { const statusText: Record<string, string> = {
pending: '待报价', pending: '待报价',
received: '已报价' received: '已报价',
completed: '报价完成',
formal: '正式报价',
created: '已创建',
preliminary: '初步评估'
} }
interface QuoteItem { interface QuoteItem {
insurer: string insurer: string
status: 'pending' | 'received' status: string
premium?: string premium?: string
perPersonLimit?: string perPersonLimit?: string
aggregateLimit?: string aggregateLimit?: string
deductible?: string deductible?: string
validity?: string
addOnSupported?: string
insuranceClause?: string
coveredEvents?: string
relatedFactors?: string
med100Percent?: string
possibleRelatedPercent?: string
certainRelatedPercent?: string
fullProcessCommitment?: string
icfReview?: string
crcTraining?: string
serviceHotline?: string
expenseSettlement?: string
claimsCommunication?: string
claimsTimeCommit?: string
disputeCoordination?: string
refundForUnenrolled?: string
paymentCycle?: string
note?: string note?: string
} }
const compareDimensionGroups = [
{
groupName: '基础报价与保障限额',
dimensions: [
{ key: 'status', label: '报价状态' },
{ key: 'premium', label: '保费(元)' },
{ key: 'perPersonLimit', label: '每人限额(万)' },
{ key: 'aggregateLimit', label: '累计限额(万)' },
{ key: 'deductible', label: '免赔额(万)' },
{ key: 'validity', label: '有效期' },
{ key: 'addOnSupported', label: '附加险' }
]
},
{
groupName: '保障范围',
dimensions: [
{ key: 'insuranceClause', label: '保险条款' },
{ key: 'coveredEvents', label: '承保事件' },
{ key: 'relatedFactors', label: '相关因素' },
{ key: 'med100Percent', label: '医药费100%' },
{ key: 'possibleRelatedPercent', label: '可能相关%' },
{ key: 'certainRelatedPercent', label: '肯定相关%' },
{ key: 'fullProcessCommitment', label: '全流程承诺' }
]
},
{
groupName: '风险管理服务',
dimensions: [
{ key: 'icfReview', label: 'ICF审阅' },
{ key: 'crcTraining', label: 'CRC培训' }
]
},
{
groupName: '理赔服务',
dimensions: [
{ key: 'serviceHotline', label: '服务专线' },
{ key: 'expenseSettlement', label: '费用理算' },
{ key: 'claimsCommunication', label: '理赔沟通' },
{ key: 'claimsTimeCommit', label: '理赔时效' },
{ key: 'disputeCoordination', label: '纠纷协调' }
]
},
{
groupName: '价格与退费',
dimensions: [
{ key: 'refundForUnenrolled', label: '未入组退费' },
{ key: 'paymentCycle', label: '支付周期' },
{ key: 'note', label: '备注' }
]
}
]
function getQuoteValue(q: QuoteItem, key: string): string {
const v = (q as Record<string, unknown>)[key]
if (v === undefined || v === null) return ''
return String(v)
}
const quotes = ref<QuoteItem[]>([]) const quotes = ref<QuoteItem[]>([])
function loadQuotes() { function loadQuotes() {
@ -136,7 +216,7 @@ function loadQuotes() {
{ insurer: '大地', status: 'received', premium: '¥13,200/年', perPersonLimit: '100万', aggregateLimit: '600万', deductible: '0', note: '含SUSAR/SAE专项' }, { 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: '¥14,000/年', perPersonLimit: '120万', aggregateLimit: '800万', deductible: '0', note: '全国网点理赔' },
{ insurer: '华泰财', status: 'received', premium: '¥12,000/年', perPersonLimit: '100万', aggregateLimit: '500万', 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 } { insurer: '亚太', status: 'pending', note: '—' }
] ]
} }
@ -230,7 +310,7 @@ watch(selectedProject, loadQuotes)
.compare-table td { .compare-table td {
padding: 12px 16px; padding: 12px 16px;
text-align: left; text-align: left;
border-bottom: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
.compare-table th { .compare-table th {
@ -243,19 +323,40 @@ watch(selectedProject, loadQuotes)
background: rgba(14, 165, 233, 0.03); background: rgba(14, 165, 233, 0.03);
} }
.insurer-name { .compare-desc {
font-weight: 600; font-size: 14px;
color: var(--brand-primary, #0ea5e9); color: var(--text-color);
margin: -8px 0 16px 0;
line-height: 1.5;
} }
.premium { .insurer-compare-table.transposed {
font-weight: 600;
color: var(--brand-text-default);
}
.note {
max-width: 180px;
font-size: 13px; font-size: 13px;
}
.insurer-compare-table .th-sticky,
.insurer-compare-table .td-sticky {
position: sticky;
left: 0;
background: var(--white);
z-index: 1;
box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.08);
}
.insurer-compare-table thead .th-sticky {
background: rgba(14, 165, 233, 0.08);
}
.insurer-compare-table .group-header .group-cell {
background: rgba(14, 165, 233, 0.06);
font-weight: 600;
font-size: 13px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
}
.insurer-compare-table .dim-label {
font-weight: 500;
color: var(--text-color); color: var(--text-color);
} }
@ -271,11 +372,27 @@ watch(selectedProject, loadQuotes)
color: #b45309; color: #b45309;
} }
.status-badge.received { .status-badge.received,
.status-badge.completed {
background: rgba(34, 197, 94, 0.2); background: rgba(34, 197, 94, 0.2);
color: #15803d; color: #15803d;
} }
.status-badge.formal {
background: rgba(59, 130, 246, 0.2);
color: #1d4ed8;
}
.status-badge.preliminary {
background: rgba(234, 179, 8, 0.2);
color: #b45309;
}
.status-badge.created {
background: rgba(107, 114, 128, 0.2);
color: #4b5563;
}
.contact-tip { .contact-tip {
margin-top: 20px; margin-top: 20px;
padding: 12px 16px; padding: 12px 16px;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,408 @@
<template>
<PageContainer>
<div class="user-approval-page">
<PageHeader
title="用户审批"
description="平台管理员在 Smart-OPS 后台查看、审批用户注册申请。支持通过、驳回、备注原因。"
/>
<div class="page-body">
<section class="section">
<div class="list-card card">
<div class="list-toolbar">
<div class="filter-row">
<select v-model="statusFilter" class="status-select">
<option value="">全部状态</option>
<option value="pending">待审批</option>
<option value="approved">已通过</option>
<option value="rejected">已驳回</option>
</select>
<button class="btn btn-primary" @click="loadList">刷新</button>
</div>
</div>
<div class="table-wrap">
<table class="approval-table">
<thead>
<tr>
<th>申请人</th>
<th>组织</th>
<th>邮箱</th>
<th>手机</th>
<th>职位/部门</th>
<th>申请时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="r in filteredList" :key="r.id">
<td>{{ r.name }}</td>
<td>{{ r.organizationName }}</td>
<td>{{ r.email }}</td>
<td>{{ r.phone }}</td>
<td>{{ r.position || '--' }} / {{ r.department || '--' }}</td>
<td>{{ formatDate(r.createdAt) }}</td>
<td>
<span :class="['status-badge', r.status]">
{{ statusText[r.status] }}
</span>
</td>
<td>
<template v-if="r.status === 'pending'">
<button type="button" class="btn-link" @click="openApproveModal(r)">通过</button>
<button type="button" class="btn-link btn-link-danger" @click="openRejectModal(r)">驳回</button>
</template>
<template v-else-if="r.status === 'rejected' && r.rejectReason">
<span class="reject-hint" :title="r.rejectReason">驳回原因</span>
</template>
<span v-else>--</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="filteredList.length === 0" class="empty-state">
暂无注册申请
</div>
</div>
</section>
</div>
<!-- 通过确认弹窗 -->
<div v-if="showApproveModal" class="modal-overlay" @click.self="showApproveModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>通过注册申请</h3>
<button type="button" class="modal-close" @click="showApproveModal = false">×</button>
</div>
<div class="modal-body">
<p v-if="currentReg">
确认通过 <strong>{{ currentReg.name }}</strong>{{ currentReg.email }}的注册申请
通过后用户将激活账号并获取登录权限
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @click="showApproveModal = false">取消</button>
<button type="button" class="btn btn-primary" @click="confirmApprove">确认通过</button>
</div>
</div>
</div>
<!-- 驳回弹窗 -->
<div v-if="showRejectModal" class="modal-overlay" @click.self="showRejectModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>驳回注册申请</h3>
<button type="button" class="modal-close" @click="showRejectModal = false">×</button>
</div>
<div class="modal-body">
<p v-if="currentReg">
驳回 <strong>{{ currentReg.name }}</strong>{{ currentReg.email }}的注册申请
驳回后将通过邮件通知用户补齐资料或说明原因
</p>
<div class="form-group">
<label>驳回原因可选将通知用户</label>
<textarea
v-model="rejectReason"
rows="3"
placeholder="请输入驳回原因,便于用户补充或修改"
class="reject-textarea"
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" @click="showRejectModal = false">取消</button>
<button type="button" class="btn btn-primary btn-danger" @click="confirmReject">确认驳回</button>
</div>
</div>
</div>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { PendingRegistration } from '@/stores/registration'
import { useRegistrationStore } from '@/stores/registration'
import PageContainer from '@/components/PageContainer.vue'
import PageHeader from '@/components/PageHeader.vue'
const registrationStore = useRegistrationStore()
const statusFilter = ref('')
const showApproveModal = ref(false)
const showRejectModal = ref(false)
const currentReg = ref<PendingRegistration | null>(null)
const rejectReason = ref('')
const statusText: Record<string, string> = {
pending: '待审批',
approved: '已通过',
rejected: '已驳回'
}
const list = ref<PendingRegistration[]>([])
const filteredList = computed(() => {
let arr = list.value
if (statusFilter.value) {
arr = arr.filter(r => r.status === statusFilter.value)
}
return arr
})
function formatDate(iso: string) {
const d = new Date(iso)
return d.toLocaleString('zh-CN')
}
function loadList() {
list.value = registrationStore.getAllRegistrations()
}
function openApproveModal(reg: PendingRegistration) {
currentReg.value = reg
showApproveModal.value = true
}
function openRejectModal(reg: PendingRegistration) {
currentReg.value = reg
rejectReason.value = ''
showRejectModal.value = true
}
function confirmApprove() {
if (currentReg.value) {
registrationStore.approveRegistration(currentReg.value.id)
loadList()
showApproveModal.value = false
currentReg.value = null
}
}
function confirmReject() {
if (currentReg.value) {
registrationStore.rejectRegistration(currentReg.value.id, rejectReason.value.trim() || undefined)
loadList()
showRejectModal.value = false
currentReg.value = null
rejectReason.value = ''
}
}
onMounted(loadList)
</script>
<style scoped>
.user-approval-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;
gap: 12px;
align-items: center;
}
.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;
}
.approval-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.approval-table th,
.approval-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.approval-table th {
font-weight: 600;
color: var(--brand-text-default);
background: rgba(14, 165, 233, 0.06);
}
.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.approved {
background: rgba(34, 197, 94, 0.2);
color: #15803d;
}
.status-badge.rejected {
background: rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
.btn-link {
font-size: 13px;
color: var(--brand-primary, #0ea5e9);
background: none;
border: none;
cursor: pointer;
padding: 0 4px;
text-decoration: none;
}
.btn-link:hover {
text-decoration: underline;
}
.btn-link-danger {
color: #dc2626;
}
.reject-hint {
font-size: 12px;
color: var(--text-light);
cursor: help;
}
.empty-state {
padding: 48px 24px;
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-danger {
background: #dc2626;
color: var(--white);
border-color: #dc2626;
}
.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);
}
.form-group {
margin-top: 16px;
}
.form-group label {
display: block;
font-size: 14px;
margin-bottom: 8px;
color: var(--text-color);
}
.reject-textarea {
width: 100%;
padding: 10px 12px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
resize: vertical;
}
.modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid var(--border-color);
}
</style>