feat(customer-management): 迭代风险标签复核机制与列表 KPI
法务标签改用打标时间/复核周期/下次复核时间,移除 Drawer 内完成复核流程并推送法务工作台待办;列表 KPI 调整为五类卡片,新增页对齐地址解析与合作状态,并同步 PRD 与标注源。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
94
src/prototypes/customer-management/.spec/prototype-review.md
Normal file
94
src/prototypes/customer-management/.spec/prototype-review.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Prototype Review
|
||||
|
||||
- 审查目标:`src/prototypes/customer-management`(列表页 + 新增页,重点为风险标签与 KPI 卡片筛选)
|
||||
- 用户资料/参考资料:
|
||||
- 本轮用户说明:风险标签「前台分区、权限分责、底层统一」改造诉求;11 项标签目录与分组权限(信用与回款→财务、合规与法律→法务、经营与履约→业管、开票异常→财务、多头签约→业管);KPI 卡片筛选不同类型风险客户
|
||||
- `.spec/requirements-prd-list.md`、`.spec/requirements-prd-create.md`
|
||||
- `src/resources/oneos-web-legacy/需求说明/业务管理/客户管理`
|
||||
- 源码:`index.tsx`、`components/*`、`utils/*`、`types/*`、`data/*`
|
||||
- `.axhub/make/project.json`(入口与命名)
|
||||
- `annotation-source.json`(标注与 PRD 摘要)
|
||||
- 生成时间:2026-06-30
|
||||
|
||||
## 总体点评
|
||||
|
||||
客户管理原型在**风险标签列表维护**方向上已形成较完整的演示闭环:统一 `riskTagsByCustomer` 模型、按法务/财务/业管三分区 Drawer、角色切换、打标/审批/日志、批量打标,以及 8 张 KPI 卡片快速筛选,与近期用户口头决策高度一致,业务表达明显强于旧版 PRD 所描述的「单列表 + 运维/风控打标」方案。
|
||||
|
||||
当前最影响「需求评审可签字」的问题,不是单个控件缺失,而是**书面规格与实现、Legacy 需求三套口径并行**:`.spec/requirements-prd-list.md` 与 `annotation-source.json` 仍保留旧标签归属(如频繁争议→法务、车辆/资产→运维)、旧角色表(王风控/赵运维打标)、旧抽屉结构(单列表 Tab)和旧风险级别(极高),而源码已改为业管维护经营类标签、8 角色模拟、三分区卡片、致命级枚举与 KPI 卡片。评审若仍引用 PRD 旧章节,会产生「实现错了」的误判。
|
||||
|
||||
主流程方面,**列表 + 新增 + 风险标签维护**可演示;但 **详情/编辑/查看** 仍为 Toast,Legacy 与 PRD 均将其列为核心任务,主流程出口不完整。高风险标签的**解除申请**已能提交,但缺少与「新增待审批」对等的审批闭环,业务生命周期在「解除」分支上断档。
|
||||
|
||||
## P0-P3 优先级问题
|
||||
|
||||
### P1 - 书面 PRD / 标注与当前实现严重脱节,无法作为验收基线
|
||||
|
||||
- 证据:`requirements-prd-list.md` §5.1、§7.3–§7.4、§8–§9 仍描述运维/风控打标、标签类型「极高」、抽屉「当前标签」为扁平列表;`annotation-source.json` 中 `cm-risk-tag-drawer` 等节点摘要同步旧版。源码 `utils/riskTagConstants.ts` 已为 11 项目录 + 业管维护经营与履约;`permissions.ts` 为 8 角色且业管/财务/法务分责;`RiskTagDrawer.tsx` 为三分区卡片;`RiskKpiCards.tsx` + `utils/riskKpi.ts` 为 KPI 筛选,PRD §1 页面结构未收录。
|
||||
- 影响:产品、测试、开发若按 PRD 验收会系统性误判通过/不通过;标注阅读体验与真实原型行为不一致,需求评审无法形成单一真相源。
|
||||
- 修复方向:以当前源码与用户最新标签规则为准,回写 `requirements-prd-list.md` §1、§5、§7–§11 及 `annotation-source.json`;明确 KPI 卡片口径、三分区 Drawer、11 标签目录、8 角色权限表;标注「资料冲突/待确认」处需产品确认后再冻结。
|
||||
|
||||
### P1 - 详情 / 编辑 / 查看主流程未实现,Legacy 与 PRD 核心任务出口缺失
|
||||
|
||||
- 证据:PRD 列表 §1 核心任务含「进入详情/编辑」;Legacy `src/resources/.../客户管理` §2.16 要求查看页、编辑页与删除确认;源码 `index.tsx` 中 `onView` / `onEdit` 仅 `showToast('…原型演示')`,无页面路由或表单回显。
|
||||
- 影响:客户主数据「查—改—审标签—回看档案」链条在列表之后中断;无法验证编辑态是否仍遵守「不在编辑页维护风险标签」约束;与 OneOS Legacy 模块能力不对齐。
|
||||
- 修复方向:至少补充查看页/编辑页原型(可为只读详情 + 基础字段编辑),并在 PRD 标明与列表标签维护的边界;删除保留二次确认 Mock 即可。
|
||||
|
||||
### P1 - 高风险 / 致命标签「解除申请」缺少审批闭环
|
||||
|
||||
- 证据:用户改造要求「高风险和致命标签不能直接移除,只能提交解除申请或审批」;`index.tsx` `handleRemoveTag` 在 `isApplication` 时仅设 `removalRequested: true` 并写日志,标签仍为 `active`;`RiskTagDrawer.tsx` 审批面板仅处理 `status === 'pending'` 的新增待审批,无「解除待审批」审批入口与通过/驳回后状态迁移(`removed` / 保持生效)。
|
||||
- 影响:演示「解除高风险标签」时业务状态停在中间态,审计链路不完整,测试无法覆盖解除审批场景。
|
||||
- 修复方向:为 `removalRequested` 增加审批 UI(可与新增审批共用面板,区分动作类型);通过后 `status → removed` 并同步 `labels`;驳回则清除 `removalRequested` 并记录原因。
|
||||
|
||||
### P2 - 列表筛选与 KPI 卡片口径分裂,易产生「筛不出 / 数不对」误解
|
||||
|
||||
- 证据:筛选区「标签」多选(`FilterPanel.tsx` + `filters.ts`)匹配 `record.labels`,而 `syncRecordLabels` 仅同步**生效中**标签;KPI(`utils/riskKpi.ts`)基于 `riskTagsByCustomer`,「有风险客户」「高风险/待审批」含 `pending` 标签;KPI 切换即时生效,筛选区仍须点「查询」才生效(`pendingFilters` / `appliedFilters` 分离)。
|
||||
- 影响:同一客户可能在 KPI「高风险/待审批」命中,但筛选区「标签」选不到对应名称;KPI 与高级筛选组合时,用户难以理解最终列表口径。
|
||||
- 修复方向:统一筛选数据源(建议以 `riskTagsByCustomer` 为准并支持按 tagGroup / category / status 筛);或在 KPI 激活时联动写入 `appliedFilters`;在 PRD 补充 KPI 与筛选区优先级说明。
|
||||
|
||||
### P2 - Legacy 客户台账字段模型与原型列表差异大,合作状态等关键字段未贯通
|
||||
|
||||
- 证据:Legacy 要求列表含合作状态、城市、联系人、创建人等;原型 `CustomerRecord` / `CustomerTable` 为工商宽表 + 客户状态(正常/黑名单)+ 业务部门/负责人;新增页有「合作状态」(`CreatePage.tsx`),但列表无该列、筛选无合作状态,提交后也不落库(Toast 返回)。
|
||||
- 影响:与 OneOS 既有「业务管理-客户管理」认知不一致;无法演示「按合作状态 + 风险标签」联合运营场景;新增字段与列表台账脱节。
|
||||
- 修复方向:产品确认本原型是「风险增强版台账」还是「Legacy 对齐版」;若对齐 Legacy,补充合作状态/城市等列与筛选,并将新增 Mock 写入本地 state;若刻意简化,在 PRD 写清「不做范围」并与 Legacy 文档交叉引用。
|
||||
|
||||
## 完整性与项目对齐
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 核心用户 | 销售、业务负责人、法务、财务、业管、风控、管理层 — 角色切换已覆盖;运维角色可切换但无打标权限,与「经营与履约归业管」一致 |
|
||||
| 主流程 | 列表筛选 → KPI 卡片 → 表格 → 风险标签 Drawer → 打标/审批/日志:**可演示**;新增页 → 校验 → Toast:**可演示**;详情/编辑/查看:**未实现** |
|
||||
| 入口/出口 | 入口:侧边栏 `customer-management`(`project.json`);出口:新增返回列表、Drawer 关闭保持列表状态 — **符合**;缺详情/编辑出口 |
|
||||
| 范围边界 | PRD 声明导入/导出/删除为 Toast 演示 — **一致**;风险标签不在新增/编辑页维护 — **实现一致**;KPI 卡片、三分区 Drawer、11 标签 — **实现有、PRD 无** |
|
||||
| 项目 metadata | 原型 id `customer-management`,入口 `index.tsx` — **对齐** |
|
||||
| 资料冲突/待确认 | ① PRD/annotation vs 当前实现(标签归属、角色、KPI)— **待产品确认并以新口径回写 PRD**;② Legacy vs 原型字段模型 — **待确认范围**;③ 用户会话要求「致命」级 vs PRD「极高」— **实现用 fatal/致命,PRD 未更新**;④ 用户要求高风险附件必填 vs PRD §8.3 附件选填 — **实现已必填,PRD 待确认** |
|
||||
|
||||
## 业务逻辑连贯性
|
||||
|
||||
**流程顺序**:列表加载 → 角色切换 →(可选)KPI 卡片 / 筛选查询 → 行内打开 Drawer → Tab 切换打标/日志 — 顺序合理。批量打标依赖行选 + 权限,与单客户打标共用表单校验 — 一致。
|
||||
|
||||
**状态迁移**:新增低风险/中风险 → `active` 并同步 `labels`;高/致命 → `pending` → 审批 → `active`/`rejected` — **新增链路完整**。移除:低/中风险可直接 `removed`;高/致命仅 `removalRequested` — **解除链路不完整**(见 P1)。复核:`expireDate` 过期展示「待复核」Banner — **前端 Mock 可感知**。
|
||||
|
||||
**角色权限**:财务/法务/业管/ admin 可维护各自分区;销售/运维/风控/管理层查看或审批 — 与最新用户标签分组决策一致,但与 PRD §9 冲突。风控、管理层不可新增标签 — 符合用户会话;PRD 仍写风控可打全部标签。
|
||||
|
||||
**数据生命周期**:底层统一 `CustomerRiskTag[]`,列表 `labels` 为生效中投影 — 架构自洽。黑名单客户(`status === '黑名单'`)与「严重拖欠/制裁触发黑名单审批」仅 Toast 提示,未驱动 `status` 变更 — 业务联动待补。
|
||||
|
||||
**跨页面**:新增页合作状态、省市识别已实现;返回列表后数据不持久 — 原型可接受,但削弱与列表/KPI 联调。
|
||||
|
||||
## 状态、异常、边界与恢复
|
||||
|
||||
| 类别 | 检查结论 |
|
||||
|---|---|
|
||||
| 输入校验 | 打标:原因 ≥10 字、高/致命需附件、日期区间 — **有**;新增客户:必填 Toast — **有**但无字段级恢复(属 UI 范畴,业务上可感知错误) |
|
||||
| 空/无数据 | 表格空态、分区「暂无该类风险标签」、无日志兜底 — **有** |
|
||||
| 权限不足 | 销售新增 Tab 无权限文案、分区只读提示 — **有** |
|
||||
| 重复打标 | 同名有效标签拦截 — **有** |
|
||||
| 批量部分成功 | 已存在标签客户跳过 — **有**;缺批量结果汇总 Toast(条数/跳过数) |
|
||||
| 并发/重复点击 | 无防抖;Mock 环境风险低 |
|
||||
| 网络/集成失败 | 未模拟(原型范围外) |
|
||||
| 恢复路径 | Drawer 内 error 区展示校验失败;审批驳回需填原因 — **有**;解除申请无后续恢复 — **缺** |
|
||||
| KPI + 筛选重置 | 「重置」清空筛选并回到 KPI「全部客户」 — **有** |
|
||||
|
||||
## 证据与评估说明
|
||||
|
||||
- **用户资料优先级**:本轮用户说明(11 标签、分组权限、KPI 卡片、三分区 Drawer)权重高于旧 PRD 章节;评审中已将 PRD/Legacy 与实现冲突标为「待确认」。
|
||||
- **读取范围**:`.spec/requirements-prd-list.md`、`requirements-prd-create.md`;`src/resources/oneos-web-legacy/需求说明/业务管理/客户管理`;`index.tsx`、`CreatePage.tsx`、`FilterPanel.tsx`、`CustomerTable.tsx`、`RiskTagDrawer.tsx`、`RiskTagForm.tsx`、`RiskLabelTags.tsx`、`RiskKpiCards.tsx`、`BatchRiskTagModal.tsx`;`utils/filters.ts`、`riskKpi.ts`、`riskTagStore.ts`、`riskTagConstants.ts`、`permissions.ts`;`types.ts`、`types/riskTag.ts`;`data/customers.json`、`risk-tag-operation-logs.json`;`.axhub/make/project.json`;`annotation-source.json`(摘要)。未读取 Impeccable 产物。
|
||||
- **独立评估**:`degraded` — 未拆分子代理;业务基线与源码证据在同一轮审查中完成,已尽量先建立基线再对照实现。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 客户管理 · 新增页产品需求说明(PRD)
|
||||
|
||||
> 从列表页「新增客户」进入。保存为原型 Toast,不对接真实接口。
|
||||
> 从列表页「新增客户」进入。保存为原型 Toast,不对接真实接口,**不写回列表台账**。
|
||||
|
||||
---
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
| 布局 | 三组卡片表单 + 底部 sticky 操作栏 |
|
||||
| 操作 | 重置(清空表单)、提交(校验后 Toast 成功并返回列表) |
|
||||
|
||||
**重要约束:** 本页**不包含**风险标签字段;标签仅在列表页维护。
|
||||
|
||||
---
|
||||
|
||||
## 2. 客户信息
|
||||
@@ -19,15 +21,18 @@
|
||||
| 字段 | 必填 | 控件 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |
|
||||
| 客户分级 | 否 | 下拉 | A / B / C |
|
||||
| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |
|
||||
| 客户全称 | 是 | 文本 | 与证照一致;变更时同步发票抬头 |
|
||||
| 客户简称 | 否 | 文本 | 选填 |
|
||||
| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |
|
||||
| 通讯地址 | 否 | 文本 | |
|
||||
| 省市 | 否 | 文本(置灰) | 自动从通讯地址/地址解析识别 |
|
||||
| 所属城市 | 是 | 省-市(置灰) | **由地址解析自动识别**,不可手选 |
|
||||
| 通讯地址 | 是 | 文本 | 填写完整地址后解析省、市(同供应商管理交互) |
|
||||
| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |
|
||||
| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |
|
||||
| 合作状态 | 否 | 下拉 | 已合作 / 终止合作 / 洽谈中 / 合同到期 |
|
||||
| 备注 | 否 | 多行文本 | |
|
||||
|
||||
**已移除:** 客户分级(A/B/C)不在新增页展示。
|
||||
|
||||
---
|
||||
|
||||
## 3. 联系人信息
|
||||
@@ -55,6 +60,25 @@
|
||||
|
||||
## 5. 校验与提交
|
||||
|
||||
- 提交前校验所有必填项
|
||||
| 校验项 | 规则 |
|
||||
|---|---|
|
||||
| 客户类型 | 必选 |
|
||||
| 客户全称 | 非空 |
|
||||
| 所属城市 | 通讯地址须能识别出省、市 |
|
||||
| 业务负责部门 / 人员 | 必选 |
|
||||
| 发票抬头 / 税号 / 开户银行 | 非空 |
|
||||
|
||||
- 通过后 Toast「客户已保存(原型演示)」并返回列表
|
||||
- 重置清空全部字段,客户类型默认「企业」
|
||||
- 重置清空全部字段;客户类型默认「企业」
|
||||
- 校验失败:Toast 首条错误(原型阶段)
|
||||
|
||||
---
|
||||
|
||||
## 6. 原型实现索引
|
||||
|
||||
| 能力 | 代码位置 |
|
||||
|---|---|
|
||||
| 新增页 | `pages/CreatePage.tsx` |
|
||||
| 地址解析 | `utils/address.ts` |
|
||||
| 常量选项 | `utils/constants.ts` |
|
||||
| 表单编排 | `index.tsx` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 客户管理 · 列表页产品需求说明(PRD)
|
||||
|
||||
> 本页为客户主数据台账列表。标注「原型演示」的跳转、导入、导出仅 Toast 提示。
|
||||
> 本页为客户主数据台账列表。标注「原型演示」的跳转、导入、导出、详情/编辑仅 Toast 提示。
|
||||
|
||||
---
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 模块名称 | 客户管理 — 列表页 |
|
||||
| 目标用户 | 销售、业务负责人、运营、**法务、财务、风控** |
|
||||
| 核心任务 | 按多维度筛选客户台账,查看风险标签与状态,**在列表页维护客户风险标签**,新增客户或进入详情/编辑 |
|
||||
| 页面结构 | 面包屑 + **操作角色切换** + 筛选区 + 工具栏 + 表格 + 分页 |
|
||||
| 目标用户 | 销售、业务负责人、**法务、财务、业管、运维/资产、风控、管理层** |
|
||||
| 核心任务 | 按多维度与 KPI 卡片筛选客户;查看并**在列表页维护风险标签**;新增客户(详情/编辑为原型演示) |
|
||||
| 页面结构 | 面包屑 + **操作角色切换** + 筛选区 + **风险 KPI 卡片** + 工具栏 + 表格 + 分页 |
|
||||
|
||||
**重要约束:** 风险标签**不在**客户新增页、客户编辑表单中维护,仅在列表页作为操作入口。
|
||||
**重要约束:**
|
||||
|
||||
- 风险标签**不在**客户新增页、客户编辑表单中维护,仅在列表页作为操作入口。
|
||||
- 底层使用统一 `riskTagsByCustomer` 数组(`CustomerRiskTag[]`),前台按**法务 / 财务 / 业管**三分区展示,不按 `legalTags` / `financeTags` 等字段拆散存储。
|
||||
|
||||
---
|
||||
|
||||
@@ -32,13 +35,13 @@
|
||||
| 数据 | 来源 | 用途 |
|
||||
|---|---|---|
|
||||
| 客户台账 | `data/customers.json`(20 条样例) | 列表、筛选选项 |
|
||||
| 客户标签(展示) | 客户 `labels` 字段 | 列表「风险标签」列、筛选区「标签」多选 |
|
||||
| 风险标签明细 | `riskTagsByCustomer` 运行时状态 + 种子迁移 | 标签管理抽屉、审批、日志 |
|
||||
| 标签目录 | `utils/riskTagConstants.ts` → `TAG_CATALOG` | 打标表单选项、级别、分组 |
|
||||
| 客户标签(展示投影) | 客户 `labels` 字段 | 列表「风险标签」列、筛选区「标签」多选 |
|
||||
| 风险标签明细 | `riskTagsByCustomer` 运行时状态 + 种子迁移 | 抽屉、KPI 卡片、审批、日志 |
|
||||
| 标签目录 | `utils/riskTagConstants.ts` → `TAG_CATALOG`(11 项) | 打标表单、分区展示、KPI 口径 |
|
||||
| 操作日志 | `data/risk-tag-operation-logs.json` + 运行时追加 | 抽屉「操作日志」Tab |
|
||||
| 部门-负责人 | 由台账聚合 + `DEPT_MANAGER_MAP` | 筛选联动 |
|
||||
|
||||
列表 `labels` 与「生效中」风险标签自动同步,保证筛选器与列表展示一致。
|
||||
`labels` 由 `syncRecordLabels` 与**生效中**风险标签自动同步;**待审批**标签不参与 `labels` 投影。
|
||||
|
||||
---
|
||||
|
||||
@@ -54,7 +57,7 @@
|
||||
| 区域 | 可搜索单选 | 精确匹配 `region` |
|
||||
| 客户状态 | 多选 | `status` 在选中集合内 |
|
||||
| 业务部门 | 多选 | `department` 在选中集合内 |
|
||||
| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |
|
||||
| 业务负责人 | 多选 | 随部门联动;`manager` 在选中集合内 |
|
||||
| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |
|
||||
|
||||
### 4.1 操作逻辑
|
||||
@@ -62,210 +65,333 @@
|
||||
| 操作 | 行为 |
|
||||
|---|---|
|
||||
| **查询** | 将当前筛选项写入已应用条件,页码重置为 1,清空行选中 |
|
||||
| **重置** | 清空待应用与已应用条件,页码重置为 1,清空行选中 |
|
||||
| **重置** | 清空待应用与已应用条件;**KPI 卡片回到「全部客户」**;页码重置为 1;清空行选中 |
|
||||
| 修改筛选项未点查询 | 不影响当前列表 |
|
||||
|
||||
### 4.2 与 KPI 卡片的关系
|
||||
|
||||
- **KPI 卡片**:点击即生效,基于 `riskTagsByCustomer`(含待审批标签)。
|
||||
- **筛选区「标签」**:须点「查询」后生效,基于 `labels`(仅生效中标签)。
|
||||
- 二者叠加时取交集;口径差异见 §5 说明。
|
||||
|
||||
---
|
||||
|
||||
## 5. 列表工具栏
|
||||
## 5. 风险 KPI 卡片筛选
|
||||
|
||||
样式与供应商/车辆管理 KPI 卡片一致(`vm-kpi-*`),位于筛选区与工具栏之间。点击卡片切换列表数据范围并回到第 1 页;右上角「?」展示口径说明。
|
||||
|
||||
| KPI 卡片 | 统计/筛选口径 |
|
||||
|---|---|
|
||||
| 全部客户 | 台账全部记录 |
|
||||
| 有风险客户 | 存在**生效中**或**待审批**风险标签 |
|
||||
| 财务风险 | 含 `tagCategory = finance` 的有效标签(信用与回款、开票异常等) |
|
||||
| 法务风险 | 含 `tagCategory = legal` 的有效标签(合规与法律类) |
|
||||
| 业管风险 | 含 `tagCategory = business` 的有效标签(经营与履约、多头签约等) |
|
||||
|
||||
代码:`components/RiskKpiCards.tsx`、`utils/riskKpi.ts`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 列表工具栏
|
||||
|
||||
| 按钮 | 行为 |
|
||||
|---|---|
|
||||
| 新增客户 | 进入新增客户页(分组表单) |
|
||||
| 新增客户 | 进入新增客户页 |
|
||||
| 批量导入 | Toast:批量导入(原型演示) |
|
||||
| **批量打标** | 勾选 ≥1 行且当前角色有打标权限时显示;打开批量打标弹窗 |
|
||||
| **批量打标** | 勾选 ≥1 行且当前角色有打标权限时显示 |
|
||||
| 批量导出 | Toast:导出当前列表条数 |
|
||||
|
||||
### 5.1 操作角色切换(原型演示)
|
||||
### 6.1 操作角色切换(原型演示)
|
||||
|
||||
列表顶部提供「当前操作角色」下拉,用于模拟不同部门用户:
|
||||
列表顶部「当前操作角色(原型演示)」下拉,按**角色**切换(非按姓名),模拟 8 类用户:
|
||||
|
||||
| 角色 | 部门 | 打标权限 |
|
||||
|---|---|---|
|
||||
| 王风控 | 风控部 | 全部标签 |
|
||||
| 李法务 | 法务部 | 法务类标签 |
|
||||
| 张财务 | 财务部 | 财务类标签 |
|
||||
| 赵运维 | 运维部 | 运维类标签 |
|
||||
| 陈销售 | 销售一部 | 仅查看,无打标入口 |
|
||||
| 角色 | 演示账号 | 部门 | 打标 | 审批 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| 销售 | 陈销售 | 销售一部 | 否 | 否 | 可查看标签/抽屉,操作列「查看标签」 |
|
||||
| 法务 | 李法务 | 法务部 | 法务分区 | 是 | 维护合规与法律类标签 |
|
||||
| 财务 | 张财务 | 财务部 | 财务分区 | 否 | 信用与回款 + 开票异常 |
|
||||
| 业管 | 王业管 | 业管部 | 业管分区 | 否 | 经营与履约 + 多头签约 |
|
||||
| 运维/资产 | 赵运维 | 运维资产部 | 否 | 否 | 仅查看 |
|
||||
| 风控 | 刘风控 | 风控部 | 否 | 是 | 可审批待审批标签 |
|
||||
| 管理层 | 周管理 | 管理层 | 否 | 是 | 可审批;可看财务/法务敏感信息 |
|
||||
| 系统管理员 | 系统管理员 | 系统管理部 | 全部 | 是 | 全部维护与审批权限 |
|
||||
|
||||
切换角色后,风险标签管理抽屉内的**标签类型**、**可选标签**、**审批/移除按钮**随之联动更新。
|
||||
切换角色后,Drawer 内**分区维护按钮**、**新增标签可选范围**、**审批/解除按钮**、**敏感信息脱敏**随之联动。
|
||||
|
||||
---
|
||||
|
||||
## 6. 列表字段说明
|
||||
## 7. 列表字段说明
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| 客户编号 | 左固定列 |
|
||||
| 客户名称 | 超长省略 |
|
||||
| **风险标签** | 展示「生效中」标签;按风险级别着色(低/中/高/极高);首标签 + `+N` 悬浮全部;有待审批时显示「审」标识;可点击打开管理抽屉 |
|
||||
| **风险标签** | 最多展示 **2** 个生效中标签;超出显示 `+N`;悬浮/点击 `+N` 展示全部;按级别着色(低/中/高/致命);Tooltip 含类型、责任部门、级别、状态;**所有角色**可点击打开 Drawer |
|
||||
| 客户区域 | 大区名称 |
|
||||
| 客户状态 | 绿点「正常」/ 红点「黑名单」 |
|
||||
| 业务部门 / 业务负责人 | 文本 |
|
||||
| 客户类型 / 所属行业 | 文本 |
|
||||
| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |
|
||||
| 详细地址 / 创建时间 | 文本;创建时间不换行 |
|
||||
| 操作 | 详情、编辑、**管理标签**(有权限时)、删除(原型 Toast) |
|
||||
| 操作 | 详情、编辑(Toast 演示)、**管理标签** / **查看标签**、删除(Toast 演示) |
|
||||
|
||||
支持行多选(checkbox,仅当前页);横向滚动;操作列右固定。
|
||||
|
||||
---
|
||||
### 7.1 风险标签列展示样式
|
||||
|
||||
## 7. 客户标签枚举与分级目录
|
||||
| 风险级别 | 样式 |
|
||||
|---|---|
|
||||
| 低 | 灰色边框 |
|
||||
| 中 | 橙色浅底 |
|
||||
| 高 | 红色浅底 |
|
||||
| 致命 | 深红浅底 |
|
||||
|
||||
标签按业务分类维护,打标时下拉按 **optgroup 分组** 展示。风险级别由目录**自动带出且不可手改**。
|
||||
|
||||
### 7.1 信用与回款
|
||||
|
||||
| 标签名称 | 标签类型 | 风险级别 | 典型触发 |
|
||||
|---|---|---|---|
|
||||
| 逾期预警 | 财务 | 中 | 当前逾期或近 6 个月内曾逾期 |
|
||||
| 严重拖欠 | 财务 | 高 | 逾期 > 30 天未结清或历史坏账 |
|
||||
| 频繁改账期 | 财务 | 低 | 一年内申请延长账期 ≥ 3 次 |
|
||||
|
||||
### 7.2 合规与法律
|
||||
|
||||
| 标签名称 | 标签类型 | 风险级别 | 典型触发 |
|
||||
|---|---|---|---|
|
||||
| 资质存疑 | 法务 | 中 | 证照过期、抬头与付款主体不一致等 |
|
||||
| 涉诉/仲裁 | 法务 | 高 | 与我司或第三方重大未结诉讼 |
|
||||
| 制裁/高风险地区 | 法务 | 高 | 名单筛查命中(依公司合规清单) |
|
||||
|
||||
### 7.3 经营与履约
|
||||
|
||||
| 标签名称 | 标签类型 | 风险级别 | 典型触发 |
|
||||
|---|---|---|---|
|
||||
| 频繁争议 | 法务 | 中 | 合同或扣款争议次数超阈值 |
|
||||
| 车辆/资产高风险 | 运维 | 高 | 违章、事故、疑似骗保等 |
|
||||
| 信息不实 | 风控 | 中 | 联系人/主体核查不实、空壳公司等 |
|
||||
|
||||
### 7.4 操作与流程
|
||||
|
||||
| 标签名称 | 标签类型 | 风险级别 | 典型触发 |
|
||||
|---|---|---|---|
|
||||
| 开票异常 | 财务 | 低 | 频繁退票、抬头异常变更 |
|
||||
| 多头签约 | 风控 | 低 | 同一实控人多主体分别签约 |
|
||||
|
||||
### 7.5 列表展示样式
|
||||
|
||||
- 低:灰色边框
|
||||
- 中:橙色浅底
|
||||
- 高:红色浅底
|
||||
- 极高:深红浅底
|
||||
- 单元格最大宽度受限,避免撑乱表格
|
||||
无生效中标签但有待审批时,列可展示待审批标识;全无标签显示「—」。
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险标签维护(列表页专属)
|
||||
## 8. 风险标签目录(11 项)
|
||||
|
||||
### 8.1 入口
|
||||
标签按规范文档四组维护;打标下拉按 **optgroup**(信用与回款 / 合规与法律 / 经营与履约 / 操作与流程)分组。风险级别由目录**自动带出且不可手改**。
|
||||
|
||||
**维护归属(权限分责):**
|
||||
|
||||
- **信用与回款** → 财务部
|
||||
- **合规与法律** → 法务部
|
||||
- **经营与履约** → 业管部
|
||||
- **操作与流程**:开票异常 → 财务部;多头签约 → 业管部
|
||||
|
||||
### 8.1 信用与回款(财务)
|
||||
|
||||
| 代码 | 标签名称 | 展示分区 | 风险级别 | 典型触发 | 待审批 | 黑名单提示 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| CR-01 | 逾期预警 | 财务 | 中 | 当前逾期或近 6 个月曾逾期 | 否 | 否 |
|
||||
| CR-02 | 严重拖欠 | 财务 | 高 | 逾期 > 30 天或历史坏账 | 是 | 是 |
|
||||
| CR-03 | 频繁改账期 | 财务 | 低 | 一年内延长账期 ≥ 3 次 | 否 | 否 |
|
||||
|
||||
### 8.2 合规与法律(法务)
|
||||
|
||||
| 代码 | 标签名称 | 展示分区 | 风险级别 | 典型触发 | 待审批 | 黑名单提示 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| CP-01 | 资质存疑 | 法务 | 中 | 证照过期、抬头与付款主体不一致 | 否 | 否 |
|
||||
| CP-02 | 涉诉/仲裁 | 法务 | 高 | 重大未结诉讼/仲裁 | 是 | 是 |
|
||||
| CP-03 | 制裁/高风险地区 | 法务 | 高 | 制裁/合规名单命中 | 是 | 是 |
|
||||
|
||||
### 8.3 经营与履约(业管)
|
||||
|
||||
| 代码 | 标签名称 | 展示分区 | 风险级别 | 典型触发 | 待审批 | 黑名单提示 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| OP-01 | 频繁争议 | 业管 | 中 | 合同或扣款争议超阈值 | 否 | 否 |
|
||||
| OP-02 | 车辆/资产高风险 | 业管 | 高 | 违章、事故、疑似骗保等 | 是 | 否 |
|
||||
| OP-03 | 信息不实 | 业管 | 中 | 联系人/主体核查不实 | 否 | 否 |
|
||||
|
||||
### 8.4 操作与流程
|
||||
|
||||
| 代码 | 标签名称 | 展示分区 | 维护部门 | 风险级别 | 典型触发 |
|
||||
|---|---|---|---|---|---|
|
||||
| PR-01 | 开票异常 | 财务 | 财务部 | 低 | 频繁退票、抬头异常变更 |
|
||||
| PR-02 | 多头签约 | 业管 | 业管部 | 低 | 同一实控人多主体分别签约 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险标签维护(列表页专属)
|
||||
|
||||
### 9.1 入口
|
||||
|
||||
| 入口 | 说明 |
|
||||
|---|---|
|
||||
| 行操作「管理标签」 | 打开右侧抽屉,管理单个客户 |
|
||||
| 点击标签列 | 有权限时同「管理标签」 |
|
||||
| 工具栏「批量打标」 | 对当前勾选客户批量提交同一标签 |
|
||||
| 行操作「管理标签」/「查看标签」 | 打开右侧 Drawer |
|
||||
| 点击风险标签列 | 同打开 Drawer |
|
||||
| 工具栏「批量打标」 | 对勾选客户批量提交同一标签 |
|
||||
|
||||
**不在**新增页、编辑页维护标签。
|
||||
|
||||
### 8.2 管理抽屉结构
|
||||
### 9.2 管理抽屉结构
|
||||
|
||||
| Tab | 内容 |
|
||||
|---|---|
|
||||
| 当前标签 | 已打标签列表:名称、类型、级别、状态、原因摘要;支持查看详情、移除、审批 |
|
||||
| 新增标签 | 打标表单(见 8.3) |
|
||||
| 操作日志 | 该客户全部标签相关审计记录 |
|
||||
| 当前标签 | 按 **法务风险 / 财务风险 / 业管风险** 三分区 Card 展示;含分区说明、维护权限提示、标签卡片、空状态 |
|
||||
| 新增标签 | 角色受限打标表单(见 9.3) |
|
||||
| 操作日志 | 该客户审计记录,按时间倒序 |
|
||||
|
||||
### 8.3 打标表单字段
|
||||
Drawer 标题:**风险标签**;副标题为客户名称与编号。
|
||||
|
||||
### 9.3 当前标签页 — 分区与卡片
|
||||
|
||||
展示顺序:法务 → 财务 → 业管。
|
||||
|
||||
每个分区包含:
|
||||
|
||||
1. 分区标题与说明(责任部门、关注范围)
|
||||
2. 当前角色是否可维护(「可维护」/「当前角色无权维护该类标签」)
|
||||
3. 「新增该类标签」按钮(有权限时)
|
||||
4. 标签卡片:名称、级别、状态、时间字段、责任部门、操作人、原因摘要
|
||||
- **法务风险**:打标时间、复核周期、下次复核时间、最后复核时间(不展示「有效期至」)
|
||||
- **财务/业管**:打标时间、有效期至
|
||||
5. 空状态:「暂无该类风险标签」
|
||||
|
||||
卡片操作:查看详情;审批(新增待审批或**解除待审批**且有权);**申请解除**(有权且生效中,须走审批流)。
|
||||
|
||||
**法务风险复核规则(原型 Mock):**
|
||||
|
||||
- 法务标签自打标后持续生效,**不会因到期自动解除**
|
||||
- 距 `nextReviewAt` ≤ 7 天:卡片显示「即将复核」
|
||||
- 当前日期超过 `nextReviewAt`:状态显示「待复核」,标签仍保留
|
||||
- **不在 Drawer 内完成复核**;系统自动向**法务工作台**推送待办,法务查验后回到本页「申请解除」
|
||||
- 打开 Drawer 且存在待复核法务标签时,页顶 Banner 提示待办已推送,并写入操作日志(工作台待办)
|
||||
- 确认可解除时:填写解除原因 → 提交解除申请(走审批流);通过后记录 `removedAt`
|
||||
|
||||
**解除审批(原型 Mock):**
|
||||
|
||||
- 任意标签解除均提交申请,状态为「解除待审批」
|
||||
- 申请解除弹窗说明:**法务标签**提示「法务查验完成后…」;**财务/业管标签**仅提示「提交解除申请,经审批后生效」(不含法务查验文案)
|
||||
- 法务/风控/管理层/系统管理员可审批:通过 → 已解除;驳回 → 恢复生效并清除申请标记
|
||||
|
||||
### 9.4 新增标签表单
|
||||
|
||||
| 字段 | 必填 | 规则 |
|
||||
|---|---|---|
|
||||
| 标签名称 | 是 | 按 7.1–7.4 分组下拉;仅当前角色有权限的标签可选;已存在同名有效标签不可重复选 |
|
||||
| 标签类型 | 是 | **根据当前操作角色自动带出**(财务→财务、法务→法务、运维→运维、风控→风控),**置灰不可编辑** |
|
||||
| 风险级别 | 是 | **选择标签名称后按目录自动带出**,**置灰不可编辑** |
|
||||
| 生效时间 | 是 | 日期,默认当天 |
|
||||
| 有效期至 | 是 | 日期,须 ≥ 生效时间;默认一年后 |
|
||||
| 原因说明 | 是 | 至少 10 字,供法务/财务/风控审批留痕 |
|
||||
| 附件 | 否 | 支持 .pdf / .jpg / .png / .xlsx,可多个 |
|
||||
| 标签类型 | 是 | 非 admin:按角色自动带出法务/财务/业管并置灰;admin 可选三分区 |
|
||||
| 标签名称 | 是 | 按 8.1–8.4 optgroup;仅当前角色可维护的标签;不可重复有效同名标签 |
|
||||
| 风险级别 | 是 | 选标签后自动带出,置灰 |
|
||||
| 打标时间 | 是 | 日期(只读置灰) | **取操作当天**,不可编辑 |
|
||||
| 复核周期 | 法务必填 | 下拉 | 1 / 2 / 3 / 6 个月;变更时自动推算下次复核时间 |
|
||||
| 下次复核时间 | 法务必填 | 日期(只读置灰) | 根据打标时间 + 复核周期**自动推算**,不可编辑;下方提示「到期仅提醒责任人复核,不会自动解除标签。」 |
|
||||
| 有效期至 | 财务/业管必填 | 日期 | ≥ 打标时间;业务有效期,与复核提醒无关 |
|
||||
| 原因说明 | 是 | ≥ 10 字 |
|
||||
| 附件 | 高/致命必填 | .pdf / .jpg / .png / .xlsx |
|
||||
|
||||
### 8.4 批量打标
|
||||
**法务 vs 财务/业管字段差异:**
|
||||
|
||||
- 展示已选客户 chips(超过 6 个显示 +N)
|
||||
- 表单字段与单客户打标一致
|
||||
- 法务标签**不展示**「有效期至」,使用「下次复核时间」驱动定期复核提醒
|
||||
- 财务/业管保留「有效期至」,不在 UI 上与复核时间混用
|
||||
|
||||
提交规则:
|
||||
|
||||
- 低/中:直接 **生效中**
|
||||
- 高/致命:**待审批**;提交前二次确认
|
||||
- 涉诉/仲裁、严重拖欠、制裁/高风险地区:Toast 提示可能触发黑名单审批
|
||||
|
||||
销售/运维/风控/管理层(非 admin):新增 Tab 展示无权限说明。
|
||||
|
||||
### 9.5 批量打标
|
||||
|
||||
- 已选客户 chips(>6 显示 +N)
|
||||
- 表单与单客户一致
|
||||
- 已存在同名有效标签的客户自动跳过
|
||||
- 提交后写入批量操作日志
|
||||
- 写入批量操作日志
|
||||
|
||||
### 8.5 标签生命周期
|
||||
|
||||
| 状态 | 说明 |
|
||||
|---|---|
|
||||
| 生效中 | 参与列表展示,同步至 `labels` |
|
||||
| 待审批 | 高风险标签提交后进入;列表暂不展示为生效 |
|
||||
| 待确认 | 预留 |
|
||||
| 已驳回 | 审批未通过,不同步至 `labels` |
|
||||
| 已移除 | 操作人主动移除,需填写原因 |
|
||||
| 已过期 | 预留 |
|
||||
|
||||
**高风险规则:** 风险级别为「高」或「极高」的标签提交后状态为「待审批」,须法务或风控审批通过后变为「生效中」。
|
||||
|
||||
### 8.6 移除标签
|
||||
|
||||
- 仅「生效中」且当前角色有移除权限的标签可移除
|
||||
- 须填写移除原因(至少 5 字)
|
||||
- 写入操作日志,并从列表 `labels` 同步清除
|
||||
|
||||
---
|
||||
|
||||
## 9. 权限与审批
|
||||
|
||||
### 9.1 打标权限
|
||||
|
||||
| 角色 | 可选标签范围 | 标签类型(自动) |
|
||||
|---|---|---|
|
||||
| 财务 | 财务类(信用与回款、操作与流程中的财务标签) | 财务 |
|
||||
| 法务 | 法务类(合规与法律、经营与履约中的法务标签) | 法务 |
|
||||
| 运维 | 运维类(车辆/资产高风险) | 运维 |
|
||||
| 风控 / 管理员 | 全部 | 风控 |
|
||||
| 销售 | 无 | — |
|
||||
|
||||
### 9.2 审批权限
|
||||
|
||||
- 法务、风控、管理员可审批「待审批」标签
|
||||
- 支持通过 / 驳回;驳回须填写原因
|
||||
|
||||
### 9.3 移除权限
|
||||
|
||||
与打标权限一致:各角色仅能移除本类型标签;风控 / 管理员可移除全部类型。
|
||||
|
||||
---
|
||||
|
||||
## 10. 操作日志
|
||||
|
||||
所有标签 **新增、移除、提交审批、审批通过、审批驳回、批量打标** 均写入日志。
|
||||
### 9.6 统一数据模型 `CustomerRiskTag`
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| 操作类型 | 新增 / 移除 / 提交审批 / 审批通过 / 审批驳回 / 批量打标 |
|
||||
| 客户 | 客户名称 + ID |
|
||||
| 标签名称 | 涉及标签 |
|
||||
| 详情 | 原因、审批结论等 |
|
||||
| 操作人 | 姓名 + 角色 |
|
||||
| 操作时间 | `YYYY-MM-DD HH:mm:ss` |
|
||||
| id / customerId | 标识 |
|
||||
| tagCode / tagName | 目录代码与名称 |
|
||||
| tagCategory / tagCategoryName | legal / finance / business |
|
||||
| responsibleDept / editableRoles | 责任部门与可编辑角色 |
|
||||
| riskLevel / riskLevelName | low / medium / high / fatal |
|
||||
| status / statusName | active / pending / expired / removed / rejected |
|
||||
| source / sourceName | system / manual |
|
||||
| reason / taggedAt / nextReviewAt / reviewCycle | 原因与复核时间(法务) |
|
||||
| expireDate | 财务/业管业务有效期 |
|
||||
| lastReviewedAt / reviewStatus / reviewLogs | 法务复核记录 |
|
||||
| removedAt / removeReason | 解除时间与原因 |
|
||||
| createdBy / createdAt / approvedBy / approvedAt | 操作与审批 |
|
||||
| attachments / sensitiveLevel / financeDetail | 附件与脱敏 |
|
||||
| removalRequested | 解除申请标记(原型) |
|
||||
|
||||
抽屉「操作日志」Tab 按客户过滤;原型预置样例日志见 `data/risk-tag-operation-logs.json`。
|
||||
### 9.7 标签生命周期
|
||||
|
||||
| 状态 | 说明 | 同步至 `labels` |
|
||||
|---|---|---|
|
||||
| 生效中 | 正常展示 | 是 |
|
||||
| 待复核 | 法务标签超过下次复核时间,**仍生效** | 是 |
|
||||
| 待审批 | 高/致命新增后 | 否 |
|
||||
| 已驳回 | 审批拒绝 | 否 |
|
||||
| 已解除 | 解除成功(含复核确认解除并审批通过) | 否 |
|
||||
| 已失效 | 预留(法务标签**不会**自动进入此状态) | 否 |
|
||||
|
||||
**法务复核与解除规则:**
|
||||
|
||||
- 法务标签持续生效,系统仅按 `nextReviewAt` 提醒复核,**不自动解除**
|
||||
- 复核维持:在工作台完成查验(原型阶段以 Toast + 日志表示);如需解除使用「申请解除」
|
||||
- 复核确认风险解除:填写解除原因 → 提交解除申请 → 审批通过后 `status → 已解除` 并记录 `removedAt`
|
||||
- 任意路径的解除均需审批,不允许系统自动解除法务标签
|
||||
|
||||
### 9.8 敏感信息脱敏
|
||||
|
||||
| 场景 | 规则 |
|
||||
|---|---|
|
||||
| 财务敏感 | 非 finance / manager / admin 不可见逾期金额、账单编号等;提示「财务敏感信息已隐藏」 |
|
||||
| 法务附件 | 非 legal / riskControl / manager / admin 不可见法务附件;提示「法务敏感附件仅授权角色可见」 |
|
||||
| 操作日志 | 财务类日志 reason 对非授权角色脱敏 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 原型实现索引
|
||||
## 10. 权限与审批
|
||||
|
||||
### 10.1 分区维护(打标 / 解除)
|
||||
|
||||
| 角色 | 可维护分区 | 可选标签范围 |
|
||||
|---|---|---|
|
||||
| 财务 | 财务风险 | 信用与回款 + 开票异常 |
|
||||
| 法务 | 法务风险 | 合规与法律 |
|
||||
| 业管 | 业管风险 | 经营与履约 + 多头签约 |
|
||||
| 系统管理员 | 全部 | 全部 11 项 |
|
||||
| 销售 / 运维 / 风控 / 管理层 | — | 不可新增(风控/管理层可审批) |
|
||||
|
||||
### 10.2 审批权限
|
||||
|
||||
法务、风控、管理层、系统管理员可审批「待审批」新增标签,以及「解除待审批」的解除申请;支持通过/驳回,驳回须填原因。
|
||||
|
||||
### 10.3 查看权限
|
||||
|
||||
所有角色可打开 Drawer 查看标签名称与级别;敏感字段按 §9.8 脱敏。
|
||||
|
||||
---
|
||||
|
||||
## 11. 操作日志
|
||||
|
||||
记录:新增标签、解除标签、提交审批、审批通过、审批驳回、**工作台待办**、批量打标。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| 操作时间 | `YYYY-MM-DD HH:mm:ss` |
|
||||
| 操作人 / 操作角色 | 姓名 + 角色 |
|
||||
| 操作类型 | 见上 |
|
||||
| 标签名称 / 标签类型 | tagName + tagCategoryName |
|
||||
| 操作原因 | reason |
|
||||
| 变更前/后状态 | statusBefore → statusAfter |
|
||||
|
||||
抽屉内按客户过滤、时间倒序;不同类型以 Tag 区分。
|
||||
|
||||
---
|
||||
|
||||
## 12. 原型边界(当前实现)
|
||||
|
||||
| 能力 | 状态 |
|
||||
|---|---|
|
||||
| 列表 + KPI + 风险标签 Drawer | 已实现 |
|
||||
| 详情 / 编辑 / 查看页 | Toast 演示 |
|
||||
| 新增客户落库 | Toast 演示,不写回列表 |
|
||||
| 高风险解除申请审批闭环 | 已实现:申请 → 审批通过/驳回 |
|
||||
| 黑名单与标签联动 | 仅 Toast 提示,不自动改 `status` |
|
||||
| 定时复核任务 | 仅前端 Mock 提示 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 原型实现索引
|
||||
|
||||
| 能力 | 代码位置 |
|
||||
|---|---|
|
||||
| 列表编排 | `index.tsx` |
|
||||
| KPI 卡片 | `components/RiskKpiCards.tsx`、`utils/riskKpi.ts` |
|
||||
| 风险标签列 | `components/RiskLabelTags.tsx` |
|
||||
| 管理抽屉 | `components/RiskTagDrawer.tsx` |
|
||||
| 批量打标 | `components/BatchRiskTagModal.tsx` |
|
||||
| 打标表单 | `components/RiskTagForm.tsx` |
|
||||
| 打标表单 / 详情 | `components/RiskTagForm.tsx` |
|
||||
| 标签目录 | `utils/riskTagConstants.ts` |
|
||||
| 权限 | `utils/permissions.ts` |
|
||||
| 业务逻辑 | `utils/riskTagStore.ts` |
|
||||
| 类型定义 | `types/riskTag.ts` |
|
||||
| 列表筛选 | `utils/filters.ts` |
|
||||
| 类型定义 | `types/riskTag.ts`、`types.ts` |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,9 +2,9 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { CustomerRecord } from '../types';
|
||||
import { EMPTY_RISK_TAG_FORM, type MockUser, type RiskTagApplyForm, type RiskTagsByCustomer } from '../types/riskTag';
|
||||
import { canApplyRiskTagByName, roleToTagCategory } from '../utils/permissions';
|
||||
import { canAddRiskTags, canApplyRiskTagByName, roleToTagCategory } from '../utils/permissions';
|
||||
import { TAG_CATALOG_MAP } from '../utils/riskTagConstants';
|
||||
import { defaultEffectiveTo, hasDuplicateActiveTag, todayDate, validateApplyForm } from '../utils/riskTagStore';
|
||||
import { defaultExpireDate, hasDuplicateActiveTag, todayDate, validateApplyForm } from '../utils/riskTagStore';
|
||||
import { RiskTagApplyFormSection } from './RiskTagForm';
|
||||
|
||||
interface BatchRiskTagModalProps {
|
||||
@@ -26,12 +26,20 @@ export function BatchRiskTagModal({
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: BatchRiskTagModalProps) {
|
||||
const [form, setForm] = useState<RiskTagApplyForm>(() => ({
|
||||
const category = roleToTagCategory(currentUser);
|
||||
const [form, setForm] = useState<RiskTagApplyForm>(() => {
|
||||
const taggedAt = todayDate();
|
||||
const category = roleToTagCategory(currentUser);
|
||||
return {
|
||||
...EMPTY_RISK_TAG_FORM,
|
||||
tagType: roleToTagCategory(currentUser),
|
||||
effectiveFrom: todayDate(),
|
||||
effectiveTo: defaultEffectiveTo(),
|
||||
}));
|
||||
tagCategory: category,
|
||||
taggedAt,
|
||||
effectiveDate: taggedAt,
|
||||
expireDate: category === 'legal' ? '' : defaultExpireDate(category, taggedAt),
|
||||
nextReviewAt: category === 'legal' ? defaultExpireDate('legal', taggedAt) : '',
|
||||
reviewCycle: '3个月',
|
||||
};
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const selectedRecords = useMemo(
|
||||
@@ -41,21 +49,39 @@ export function BatchRiskTagModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const nextType = roleToTagCategory(currentUser);
|
||||
const nextCategory = roleToTagCategory(currentUser);
|
||||
setForm((prev) => {
|
||||
const keepTag = prev.tagName && canApplyRiskTagByName(currentUser, prev.tagName);
|
||||
const catalog = keepTag ? TAG_CATALOG_MAP[prev.tagName] : undefined;
|
||||
return {
|
||||
...prev,
|
||||
tagType: nextType,
|
||||
tagCategory: nextCategory,
|
||||
tagName: keepTag ? prev.tagName : '',
|
||||
riskLevel: keepTag && catalog ? catalog.defaultLevel : '中',
|
||||
riskLevel: keepTag && catalog ? catalog.defaultLevel : 'medium',
|
||||
};
|
||||
});
|
||||
}, [currentUser, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
if (!canAddRiskTags(currentUser)) {
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div className="vm-modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="vm-modal-header">
|
||||
<h2>批量打标</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="vm-edit-body">
|
||||
<p className="cm-muted">当前角色无权批量打标</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const validation = validateApplyForm(form);
|
||||
if (validation) {
|
||||
@@ -75,16 +101,23 @@ export function BatchRiskTagModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const result = onSubmit({ ...form, tagType: roleToTagCategory(currentUser) }, eligibleIds);
|
||||
const result = onSubmit(form, eligibleIds);
|
||||
if (result) {
|
||||
setError(result);
|
||||
return;
|
||||
}
|
||||
setForm({
|
||||
...EMPTY_RISK_TAG_FORM,
|
||||
tagType: roleToTagCategory(currentUser),
|
||||
effectiveFrom: todayDate(),
|
||||
effectiveTo: defaultEffectiveTo(),
|
||||
tagCategory: roleToTagCategory(currentUser),
|
||||
taggedAt: todayDate(),
|
||||
effectiveDate: todayDate(),
|
||||
expireDate: roleToTagCategory(currentUser) === 'legal'
|
||||
? ''
|
||||
: defaultExpireDate(roleToTagCategory(currentUser), todayDate()),
|
||||
nextReviewAt: roleToTagCategory(currentUser) === 'legal'
|
||||
? defaultExpireDate('legal', todayDate())
|
||||
: '',
|
||||
reviewCycle: '3个月',
|
||||
});
|
||||
setError('');
|
||||
onClose();
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { CustomerRecord } from '../types';
|
||||
import type { CustomerRiskTag, MockUser } from '../types/riskTag';
|
||||
import { RiskLabelTags } from './RiskLabelTags';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { canManageRiskTags } from '../utils/permissions';
|
||||
import { canAddRiskTags } from '../utils/permissions';
|
||||
|
||||
interface CustomerTableProps {
|
||||
records: CustomerRecord[];
|
||||
@@ -32,7 +32,7 @@ export function CustomerTable({
|
||||
}: CustomerTableProps) {
|
||||
const allSelected = records.length > 0 && records.every((record) => selectedIds.has(record.id));
|
||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||
const canManage = canManageRiskTags(currentUser);
|
||||
const canManage = canAddRiskTags(currentUser);
|
||||
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
@@ -94,7 +94,7 @@ export function CustomerTable({
|
||||
<td className="cm-col-labels">
|
||||
<RiskLabelTags
|
||||
tags={riskTagsByCustomer[record.id] ?? []}
|
||||
onView={canManage ? () => onManageTags(record) : undefined}
|
||||
onView={() => onManageTags(record)}
|
||||
/>
|
||||
</td>
|
||||
<td>{record.region}</td>
|
||||
@@ -114,11 +114,16 @@ export function CustomerTable({
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onView(record)}>详情</button>
|
||||
<span className="cm-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onEdit(record)}>编辑</button>
|
||||
{canManage && (
|
||||
{canManage ? (
|
||||
<>
|
||||
<span className="cm-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onManageTags(record)}>管理标签</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="cm-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onManageTags(record)}>查看标签</button>
|
||||
</>
|
||||
)}
|
||||
<span className="cm-action-sep" aria-hidden>|</span>
|
||||
<button type="button" className="vm-btn vm-btn-link cm-link-danger" onClick={() => onDelete(record)}>删除</button>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Building2,
|
||||
CircleHelp,
|
||||
Scale,
|
||||
Tags,
|
||||
TrendingDown,
|
||||
Briefcase,
|
||||
} from 'lucide-react';
|
||||
import type { CustomerRiskKpiKey } from '../types';
|
||||
import { RISK_KPI_CARDS } from '../utils/riskKpi';
|
||||
|
||||
const ICONS: Record<CustomerRiskKpiKey, React.ReactNode> = {
|
||||
all: <Building2 size={18} aria-hidden />,
|
||||
withRisk: <Tags size={18} aria-hidden />,
|
||||
finance: <TrendingDown size={18} aria-hidden />,
|
||||
legal: <Scale size={18} aria-hidden />,
|
||||
business: <Briefcase size={18} aria-hidden />,
|
||||
};
|
||||
|
||||
interface RiskKpiCardsProps {
|
||||
counts: Record<CustomerRiskKpiKey, number>;
|
||||
active: CustomerRiskKpiKey;
|
||||
onChange: (key: CustomerRiskKpiKey) => void;
|
||||
}
|
||||
|
||||
export function RiskKpiCards({ counts, active, onChange }: RiskKpiCardsProps) {
|
||||
return (
|
||||
<div className="vm-kpi-row cm-risk-kpi-row" data-annotation-id="cm-risk-kpi-cards">
|
||||
{RISK_KPI_CARDS.map((card) => (
|
||||
<button
|
||||
key={card.key}
|
||||
type="button"
|
||||
className={`vm-kpi-card ${active === card.key ? 'active' : ''}`}
|
||||
data-annotation-id={`cm-kpi-${card.key}`}
|
||||
onClick={() => onChange(card.key)}
|
||||
aria-pressed={active === card.key}
|
||||
>
|
||||
<span
|
||||
className="vm-kpi-tip"
|
||||
tabIndex={0}
|
||||
role="note"
|
||||
aria-label={`${card.title}说明`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<CircleHelp size={14} aria-hidden />
|
||||
<span className="vm-kpi-tooltip" role="tooltip">{card.desc}</span>
|
||||
</span>
|
||||
<span className="vm-kpi-icon" aria-hidden>{ICONS[card.key]}</span>
|
||||
<span className="vm-kpi-main">
|
||||
<span className="vm-kpi-eyebrow">{card.title}</span>
|
||||
<span className="vm-kpi-val">{counts[card.key]}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { CustomerRiskTag } from '../types/riskTag';
|
||||
import { getActiveTags, getPendingTags } from '../utils/riskTagStore';
|
||||
import { getActiveTags, getPendingTags, getVisibleListTags } from '../utils/riskTagStore';
|
||||
|
||||
interface RiskLabelTagsProps {
|
||||
tags: CustomerRiskTag[];
|
||||
@@ -9,19 +9,29 @@ interface RiskLabelTagsProps {
|
||||
|
||||
function levelClass(level: CustomerRiskTag['riskLevel']): string {
|
||||
switch (level) {
|
||||
case '低': return 'cm-risk-tag--low';
|
||||
case '中': return 'cm-risk-tag--medium';
|
||||
case '高': return 'cm-risk-tag--high';
|
||||
case '极高': return 'cm-risk-tag--critical';
|
||||
case 'low': return 'cm-risk-tag--low';
|
||||
case 'medium': return 'cm-risk-tag--medium';
|
||||
case 'high': return 'cm-risk-tag--high';
|
||||
case 'fatal': return 'cm-risk-tag--critical';
|
||||
default: return 'cm-risk-tag--medium';
|
||||
}
|
||||
}
|
||||
|
||||
function buildTooltip(tag: CustomerRiskTag): string {
|
||||
return [
|
||||
tag.tagName,
|
||||
`类型:${tag.tagCategoryName}`,
|
||||
`责任部门:${tag.responsibleDept}`,
|
||||
`风险级别:${tag.riskLevelName}`,
|
||||
`状态:${tag.statusName}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 2;
|
||||
|
||||
export function RiskLabelTags({ tags, onView }: RiskLabelTagsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const visibleTags = tags.filter((tag) =>
|
||||
tag.status === '生效中' || tag.status === '待审批' || tag.status === '待确认',
|
||||
);
|
||||
const visibleTags = getVisibleListTags(tags);
|
||||
const active = getActiveTags(visibleTags);
|
||||
const pending = getPendingTags(visibleTags);
|
||||
const displayTags = active.length > 0 ? active : pending;
|
||||
@@ -30,21 +40,24 @@ export function RiskLabelTags({ tags, onView }: RiskLabelTagsProps) {
|
||||
return <span className="cm-muted">—</span>;
|
||||
}
|
||||
|
||||
const primary = displayTags[0];
|
||||
const overflow = displayTags.length - 1;
|
||||
const shown = displayTags.slice(0, MAX_VISIBLE);
|
||||
const overflow = displayTags.length - MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<span className="cm-risk-label-cell">
|
||||
{shown.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className={`cm-risk-tag ${levelClass(primary.riskLevel)}`}
|
||||
className={`cm-risk-tag ${levelClass(tag.riskLevel)}`}
|
||||
onClick={onView}
|
||||
title={primary.tagName}
|
||||
title={buildTooltip(tag)}
|
||||
>
|
||||
{primary.tagName}
|
||||
{tag.tagName}
|
||||
</button>
|
||||
))}
|
||||
{pending.length > 0 && active.length === 0 && (
|
||||
<span className="cm-risk-tag-pending" title="待审批/待确认">审</span>
|
||||
<span className="cm-risk-tag-pending" title="待审批">审</span>
|
||||
)}
|
||||
{overflow > 0 && (
|
||||
<span
|
||||
@@ -53,6 +66,7 @@ export function RiskLabelTags({ tags, onView }: RiskLabelTagsProps) {
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onClick={onView}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`还有 ${overflow} 个标签`}
|
||||
@@ -61,22 +75,17 @@ export function RiskLabelTags({ tags, onView }: RiskLabelTagsProps) {
|
||||
{open && (
|
||||
<span className="cm-label-tooltip cm-risk-tag-tooltip" role="tooltip">
|
||||
{displayTags.map((tag) => (
|
||||
<span key={tag.id} className={`cm-risk-tag ${levelClass(tag.riskLevel)}`}>
|
||||
{tag.tagName}
|
||||
{tag.status !== '生效中' && (
|
||||
<span className="cm-risk-tag-status">{tag.status}</span>
|
||||
)}
|
||||
<span key={tag.id} className="cm-risk-tooltip-row">
|
||||
<span className={`cm-risk-tag ${levelClass(tag.riskLevel)}`}>{tag.tagName}</span>
|
||||
<span className="cm-risk-tooltip-meta">
|
||||
{tag.tagCategoryName} · {tag.riskLevelName} · {tag.statusName}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{pending.length > 0 && active.length > 0 && (
|
||||
<span className="cm-risk-tag-pending" title={`${pending.length} 个待审批`}>
|
||||
+{pending.length}审
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,35 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Clock, Plus, Tag, X } from 'lucide-react';
|
||||
import { AlertTriangle, Clock, Plus, ShieldAlert, Tag, X } from 'lucide-react';
|
||||
import type { CustomerRecord } from '../types';
|
||||
import type {
|
||||
CustomerRiskTag,
|
||||
MockUser,
|
||||
RiskTagApplyForm,
|
||||
RiskTagOperationLog,
|
||||
TagCategoryCode,
|
||||
} from '../types/riskTag';
|
||||
import { canApproveRiskTag, canApplyRiskTagByName, canRemoveRiskTag, roleToTagCategory } from '../utils/permissions';
|
||||
import { TAG_CATALOG_MAP } from '../utils/riskTagConstants';
|
||||
import {
|
||||
canAddRiskTags,
|
||||
canApproveRiskTag,
|
||||
canMaintainCategory,
|
||||
canRemoveRiskTag,
|
||||
canViewFinanceSensitive,
|
||||
roleLabel,
|
||||
roleToTagCategory,
|
||||
} from '../utils/permissions';
|
||||
import { TAG_CATEGORY_META, TAG_CATEGORY_ORDER } from '../utils/riskTagConstants';
|
||||
import { EMPTY_RISK_TAG_FORM } from '../types/riskTag';
|
||||
import {
|
||||
defaultEffectiveTo,
|
||||
formatNow,
|
||||
defaultExpireDate,
|
||||
defaultNextReviewDate,
|
||||
getDisplayStatusName,
|
||||
getTaggedAt,
|
||||
groupTagsByCategory,
|
||||
hasDuplicateActiveTag,
|
||||
isHighRiskLevel,
|
||||
isLegalRiskTag,
|
||||
legalTagsNeedReview,
|
||||
normalizeRiskTag,
|
||||
todayDate,
|
||||
validateApplyForm,
|
||||
} from '../utils/riskTagStore';
|
||||
@@ -29,8 +45,158 @@ interface RiskTagDrawerProps {
|
||||
currentUser: MockUser;
|
||||
onClose: () => void;
|
||||
onApply: (form: RiskTagApplyForm) => string | null;
|
||||
onRemove: (tagId: string, reason: string) => string | null;
|
||||
onRemove: (tagId: string, reason: string, isApplication?: boolean) => string | null;
|
||||
onApprove: (tagId: string, approved: boolean, rejectReason?: string) => string | null;
|
||||
onDispatchLegalTodos?: (customerId: string, customerName: string, overdueTags: CustomerRiskTag[]) => void;
|
||||
}
|
||||
|
||||
function actionTagClass(action: RiskTagOperationLog['action']): string {
|
||||
switch (action) {
|
||||
case '新增标签': return 'cm-log-tag--add';
|
||||
case '解除标签': return 'cm-log-tag--remove';
|
||||
case '提交审批': return 'cm-log-tag--pending';
|
||||
case '审批通过': return 'cm-log-tag--pass';
|
||||
case '审批驳回': return 'cm-log-tag--reject';
|
||||
case '到期复核': return 'cm-log-tag--review';
|
||||
case '工作台待办': return 'cm-log-tag--review';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function levelClass(level: CustomerRiskTag['riskLevel']): string {
|
||||
switch (level) {
|
||||
case 'low': return 'cm-risk-tag--low';
|
||||
case 'medium': return 'cm-risk-tag--medium';
|
||||
case 'high': return 'cm-risk-tag--high';
|
||||
case 'fatal': return 'cm-risk-tag--critical';
|
||||
default: return 'cm-risk-tag--medium';
|
||||
}
|
||||
}
|
||||
|
||||
interface CategorySectionProps {
|
||||
category: TagCategoryCode;
|
||||
tags: CustomerRiskTag[];
|
||||
currentUser: MockUser;
|
||||
onDetail: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onApprove: (id: string) => void;
|
||||
onAddInCategory: () => void;
|
||||
}
|
||||
|
||||
function renderTagTimeFields(tag: CustomerRiskTag) {
|
||||
const normalized = normalizeRiskTag(tag);
|
||||
if (isLegalRiskTag(normalized)) {
|
||||
return (
|
||||
<>
|
||||
<div><dt>打标时间</dt><dd>{getTaggedAt(normalized)}</dd></div>
|
||||
<div><dt>复核周期</dt><dd>{normalized.reviewCycle}</dd></div>
|
||||
<div><dt>下次复核时间</dt><dd>{normalized.nextReviewAt || '—'}</dd></div>
|
||||
<div><dt>最后复核时间</dt><dd>{normalized.lastReviewedAt || '—'}</dd></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div><dt>打标时间</dt><dd>{getTaggedAt(normalized)}</dd></div>
|
||||
<div><dt>有效期至</dt><dd>{normalized.expireDate}</dd></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
category,
|
||||
tags,
|
||||
currentUser,
|
||||
onDetail,
|
||||
onRemove,
|
||||
onApprove,
|
||||
onAddInCategory,
|
||||
}: CategorySectionProps) {
|
||||
const meta = TAG_CATEGORY_META[category];
|
||||
const canMaintain = canMaintainCategory(currentUser, category);
|
||||
const visibleTags = tags.filter((t) => t.status !== 'removed' && t.status !== 'rejected');
|
||||
const zoneAnnotationId = category === 'legal' ? 'cm-risk-zone-legal' : undefined;
|
||||
|
||||
return (
|
||||
<section className="cm-risk-zone" data-category={category} data-annotation-id={zoneAnnotationId}>
|
||||
<header className="cm-risk-zone-head">
|
||||
<div>
|
||||
<h3>{meta.name}标签</h3>
|
||||
<p className="cm-muted">{meta.description}</p>
|
||||
</div>
|
||||
{canMaintain ? (
|
||||
<span className="cm-risk-zone-perm cm-risk-zone-perm--ok">可维护</span>
|
||||
) : (
|
||||
<span className="cm-risk-zone-perm cm-risk-zone-perm--readonly">当前角色无权维护该类标签</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{canMaintain && (
|
||||
<button type="button" className="vm-btn vm-btn-secondary cm-risk-zone-add" onClick={onAddInCategory}>
|
||||
<Plus size={14} aria-hidden /> 新增该类标签
|
||||
</button>
|
||||
)}
|
||||
|
||||
{visibleTags.length === 0 ? (
|
||||
<p className="cm-risk-zone-empty">暂无该类风险标签</p>
|
||||
) : (
|
||||
<div className="cm-risk-zone-cards">
|
||||
{visibleTags.map((tag) => {
|
||||
const normalized = normalizeRiskTag(tag);
|
||||
const displayStatus = getDisplayStatusName(normalized);
|
||||
const dueSoon = isLegalRiskTag(normalized) && normalized.reviewStatus === 'dueSoon';
|
||||
const overdue = isLegalRiskTag(normalized) && normalized.reviewStatus === 'overdue';
|
||||
const canRemove = canRemoveRiskTag(currentUser, normalized.tagCategory)
|
||||
&& normalized.status === 'active'
|
||||
&& !normalized.removalRequested;
|
||||
const showApprove = canApproveRiskTag(currentUser)
|
||||
&& (normalized.status === 'pending' || (normalized.status === 'active' && normalized.removalRequested));
|
||||
|
||||
return (
|
||||
<article key={normalized.id} className="cm-risk-tag-item">
|
||||
<div className="cm-risk-tag-item-head">
|
||||
<div>
|
||||
<strong>{normalized.tagName}</strong>
|
||||
<span className={`cm-risk-tag-badge ${levelClass(normalized.riskLevel)}`}>
|
||||
{normalized.riskLevelName}
|
||||
</span>
|
||||
{dueSoon && <span className="cm-risk-badge cm-risk-badge--warn">即将复核</span>}
|
||||
{overdue && <span className="cm-risk-badge cm-risk-badge--review">待复核</span>}
|
||||
</div>
|
||||
<span className={`cm-risk-status cm-risk-status--${normalized.status}`}>{displayStatus}</span>
|
||||
</div>
|
||||
<dl className="cm-risk-tag-fields">
|
||||
{renderTagTimeFields(normalized)}
|
||||
<div><dt>责任部门</dt><dd>{normalized.responsibleDept}</dd></div>
|
||||
<div><dt>操作人</dt><dd>{normalized.createdBy}</dd></div>
|
||||
</dl>
|
||||
<p className="cm-risk-tag-reason">{normalized.reason}</p>
|
||||
<div className="cm-risk-tag-item-actions">
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onDetail(normalized.id)}>
|
||||
查看详情
|
||||
</button>
|
||||
{showApprove && (
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onApprove(normalized.id)}>
|
||||
审批
|
||||
</button>
|
||||
)}
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-link cm-link-danger"
|
||||
onClick={() => onRemove(normalized.id)}
|
||||
>
|
||||
申请解除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RiskTagDrawer({
|
||||
@@ -43,6 +209,7 @@ export function RiskTagDrawer({
|
||||
onApply,
|
||||
onRemove,
|
||||
onApprove,
|
||||
onDispatchLegalTodos,
|
||||
}: RiskTagDrawerProps) {
|
||||
const [tab, setTab] = useState<DrawerTab>('tags');
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
@@ -51,6 +218,7 @@ export function RiskTagDrawer({
|
||||
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [approveTargetId, setApproveTargetId] = useState<string | null>(null);
|
||||
const [confirmHighRisk, setConfirmHighRisk] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const detailTag = useMemo(
|
||||
@@ -59,45 +227,76 @@ export function RiskTagDrawer({
|
||||
);
|
||||
|
||||
const customerLogs = useMemo(
|
||||
() => logs.filter((log) => log.customerId === record?.id),
|
||||
() => logs
|
||||
.filter((log) => log.customerId === record?.id)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
|
||||
[logs, record?.id],
|
||||
);
|
||||
|
||||
const groupedTags = useMemo(() => groupTagsByCategory(tags), [tags]);
|
||||
const legalOverdueTags = useMemo(() => legalTagsNeedReview(tags), [tags]);
|
||||
|
||||
const disabledTagNames = useMemo(
|
||||
() => tags
|
||||
.filter((tag) => tag.status === '生效中' || tag.status === '待审批' || tag.status === '待确认')
|
||||
.filter((tag) => tag.status === 'active' || tag.status === 'pending')
|
||||
.map((tag) => tag.tagName),
|
||||
[tags],
|
||||
);
|
||||
|
||||
/** 顶部切换操作角色时,同步打标表单中的标签类型与可选标签 */
|
||||
const removeTarget = useMemo(
|
||||
() => tags.find((t) => t.id === removeTargetId) ?? null,
|
||||
[tags, removeTargetId],
|
||||
);
|
||||
|
||||
const approveTarget = useMemo(
|
||||
() => tags.find((t) => t.id === approveTargetId) ?? null,
|
||||
[tags, approveTargetId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const nextType = roleToTagCategory(currentUser);
|
||||
setForm((prev) => {
|
||||
const keepTag = prev.tagName && canApplyRiskTagByName(currentUser, prev.tagName);
|
||||
const catalog = keepTag ? TAG_CATALOG_MAP[prev.tagName] : undefined;
|
||||
return {
|
||||
...prev,
|
||||
tagType: nextType,
|
||||
tagName: keepTag ? prev.tagName : '',
|
||||
riskLevel: keepTag && catalog ? catalog.defaultLevel : '中',
|
||||
};
|
||||
});
|
||||
}, [currentUser, open]);
|
||||
|
||||
if (!open || !record) return null;
|
||||
|
||||
const resetAddForm = () => {
|
||||
const category = roleToTagCategory(currentUser);
|
||||
const taggedAt = todayDate();
|
||||
setForm({
|
||||
...EMPTY_RISK_TAG_FORM,
|
||||
tagType: roleToTagCategory(currentUser),
|
||||
effectiveFrom: todayDate(),
|
||||
effectiveTo: defaultEffectiveTo(),
|
||||
tagCategory: category,
|
||||
taggedAt,
|
||||
effectiveDate: taggedAt,
|
||||
expireDate: category === 'legal' ? '' : defaultExpireDate(category, taggedAt),
|
||||
nextReviewAt: category === 'legal'
|
||||
? defaultNextReviewDate(category, '3个月', taggedAt)
|
||||
: '',
|
||||
reviewCycle: '3个月',
|
||||
});
|
||||
setConfirmHighRisk(false);
|
||||
setError('');
|
||||
}, [currentUser, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !record || legalOverdueTags.length === 0 || !onDispatchLegalTodos) return;
|
||||
onDispatchLegalTodos(record.id, record.name, legalOverdueTags);
|
||||
}, [open, record, legalOverdueTags, onDispatchLegalTodos]);
|
||||
|
||||
const resetAddForm = (category?: TagCategoryCode) => {
|
||||
const cat = category ?? roleToTagCategory(currentUser);
|
||||
const taggedAt = todayDate();
|
||||
setForm({
|
||||
...EMPTY_RISK_TAG_FORM,
|
||||
tagCategory: cat,
|
||||
taggedAt,
|
||||
effectiveDate: taggedAt,
|
||||
expireDate: cat === 'legal' ? '' : defaultExpireDate(cat, taggedAt),
|
||||
nextReviewAt: cat === 'legal'
|
||||
? defaultNextReviewDate(cat, '3个月', taggedAt)
|
||||
: '',
|
||||
reviewCycle: '3个月',
|
||||
});
|
||||
setError('');
|
||||
setConfirmHighRisk(false);
|
||||
};
|
||||
|
||||
if (!open || !record) return null;
|
||||
|
||||
const handleSubmitApply = () => {
|
||||
const validation = validateApplyForm(form);
|
||||
if (validation) {
|
||||
@@ -108,7 +307,11 @@ export function RiskTagDrawer({
|
||||
setError('该客户已存在同名有效标签');
|
||||
return;
|
||||
}
|
||||
const result = onApply({ ...form, tagType: roleToTagCategory(currentUser) });
|
||||
if (isHighRiskLevel(form.riskLevel) && !confirmHighRisk) {
|
||||
setConfirmHighRisk(true);
|
||||
return;
|
||||
}
|
||||
const result = onApply(form);
|
||||
if (result) {
|
||||
setError(result);
|
||||
return;
|
||||
@@ -118,12 +321,12 @@ export function RiskTagDrawer({
|
||||
};
|
||||
|
||||
const handleConfirmRemove = () => {
|
||||
if (!removeTargetId) return;
|
||||
if (!removeTargetId || !removeTarget) return;
|
||||
if (removeReason.trim().length < 5) {
|
||||
setError('移除原因至少 5 个字');
|
||||
setError('解除原因至少 5 个字');
|
||||
return;
|
||||
}
|
||||
const result = onRemove(removeTargetId, removeReason.trim());
|
||||
const result = onRemove(removeTargetId, removeReason.trim(), true);
|
||||
if (result) {
|
||||
setError(result);
|
||||
return;
|
||||
@@ -149,10 +352,15 @@ export function RiskTagDrawer({
|
||||
setError('');
|
||||
};
|
||||
|
||||
const openAddForCategory = (category: TagCategoryCode) => {
|
||||
resetAddForm(category);
|
||||
setTab('add');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cm-drawer-backdrop" role="presentation" onClick={onClose}>
|
||||
<aside
|
||||
className="cm-drawer"
|
||||
className="cm-drawer cm-drawer--wide"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cm-risk-drawer-title"
|
||||
@@ -161,7 +369,7 @@ export function RiskTagDrawer({
|
||||
>
|
||||
<header className="cm-drawer-header">
|
||||
<div>
|
||||
<h2 id="cm-risk-drawer-title">风险标签管理</h2>
|
||||
<h2 id="cm-risk-drawer-title">风险标签</h2>
|
||||
<p>{record.name}({record.code})</p>
|
||||
</div>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
@@ -173,7 +381,12 @@ export function RiskTagDrawer({
|
||||
<button type="button" className={tab === 'tags' ? 'is-active' : ''} onClick={() => setTab('tags')}>
|
||||
<Tag size={14} aria-hidden /> 当前标签
|
||||
</button>
|
||||
<button type="button" className={tab === 'add' ? 'is-active' : ''} onClick={() => { setTab('add'); resetAddForm(); }}>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === 'add' ? 'is-active' : ''}
|
||||
onClick={() => { setTab('add'); resetAddForm(); }}
|
||||
disabled={!canAddRiskTags(currentUser)}
|
||||
>
|
||||
<Plus size={14} aria-hidden /> 新增标签
|
||||
</button>
|
||||
<button type="button" className={tab === 'logs' ? 'is-active' : ''} onClick={() => setTab('logs')}>
|
||||
@@ -185,40 +398,28 @@ export function RiskTagDrawer({
|
||||
{error && <div className="cm-risk-error" role="alert">{error}</div>}
|
||||
|
||||
{tab === 'tags' && (
|
||||
<div className="cm-risk-tag-list">
|
||||
{tags.length === 0 && <p className="cm-muted">暂无风险标签,可切换到「新增标签」打标。</p>}
|
||||
{tags.map((tag) => (
|
||||
<article key={tag.id} className="cm-risk-tag-item">
|
||||
<div className="cm-risk-tag-item-head">
|
||||
<div>
|
||||
<strong>{tag.tagName}</strong>
|
||||
<span className="cm-risk-tag-meta">{tag.tagType} · {tag.riskLevel}</span>
|
||||
</div>
|
||||
<span className={`cm-risk-status cm-risk-status--${tag.status}`}>{tag.status}</span>
|
||||
</div>
|
||||
<p className="cm-risk-tag-reason">{tag.reason}</p>
|
||||
<div className="cm-risk-tag-item-actions">
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => setDetailId(tag.id)}>
|
||||
查看详情
|
||||
</button>
|
||||
{tag.status === '待审批' && canApproveRiskTag(currentUser) && (
|
||||
<>
|
||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => { setApproveTargetId(tag.id); setRejectReason(''); setError(''); }}>
|
||||
审批
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{tag.status === '生效中' && canRemoveRiskTag(currentUser, tag.tagType) && (
|
||||
<button
|
||||
type="button"
|
||||
className="vm-btn vm-btn-link cm-link-danger"
|
||||
onClick={() => { setRemoveTargetId(tag.id); setRemoveReason(''); setError(''); }}
|
||||
<div className="cm-risk-zones">
|
||||
{legalOverdueTags.length > 0 && (
|
||||
<div
|
||||
className="cm-risk-review-banner cm-risk-review-banner--legal"
|
||||
role="status"
|
||||
data-annotation-id="cm-legal-workbench-todo-banner"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
)}
|
||||
<AlertTriangle size={16} aria-hidden />
|
||||
存在待复核法务风险标签,已向法务工作台发起待办,请前往查验;确认可解除后在本页「申请解除」。
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
{TAG_CATEGORY_ORDER.map((category) => (
|
||||
<CategorySection
|
||||
key={category}
|
||||
category={category}
|
||||
tags={groupedTags[category]}
|
||||
currentUser={currentUser}
|
||||
onDetail={setDetailId}
|
||||
onRemove={(id) => { setRemoveTargetId(id); setRemoveReason(''); setError(''); }}
|
||||
onApprove={(id) => { setApproveTargetId(id); setRejectReason(''); setError(''); }}
|
||||
onAddInCategory={() => openAddForCategory(category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -227,7 +428,10 @@ export function RiskTagDrawer({
|
||||
<RiskTagApplyFormSection
|
||||
form={form}
|
||||
currentUser={currentUser}
|
||||
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
|
||||
onChange={(patch) => {
|
||||
setConfirmHighRisk(false);
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
}}
|
||||
disabledTagNames={disabledTagNames}
|
||||
/>
|
||||
)}
|
||||
@@ -235,25 +439,55 @@ export function RiskTagDrawer({
|
||||
{tab === 'logs' && (
|
||||
<ol className="cm-risk-log-list">
|
||||
{customerLogs.length === 0 && <li className="cm-muted">暂无操作日志</li>}
|
||||
{customerLogs.map((log) => (
|
||||
{customerLogs.map((log) => {
|
||||
const isFinanceSensitive = log.tagCategory === 'finance'
|
||||
&& !canViewFinanceSensitive(currentUser);
|
||||
const reasonText = isFinanceSensitive
|
||||
? '财务敏感信息已隐藏'
|
||||
: log.reason || log.detail || '—';
|
||||
|
||||
return (
|
||||
<li key={log.id}>
|
||||
<div className="cm-risk-log-head">
|
||||
<strong>{log.action}</strong>
|
||||
<span className={`cm-log-tag ${actionTagClass(log.action)}`}>{log.action}</span>
|
||||
<time>{log.createdAt}</time>
|
||||
</div>
|
||||
<p>{log.detail}</p>
|
||||
<span className="cm-muted">{log.operator} · {log.tagName}</span>
|
||||
<p className="cm-risk-log-title">
|
||||
{log.tagName}
|
||||
<span className="cm-muted"> · {log.tagCategoryName}</span>
|
||||
</p>
|
||||
<p>{reasonText}</p>
|
||||
<div className="cm-risk-log-meta">
|
||||
<span>{log.operator}</span>
|
||||
<span className="cm-muted">({roleLabel(log.operatorRole)})</span>
|
||||
{(log.statusBefore || log.statusAfter) && (
|
||||
<span className="cm-muted">
|
||||
{log.statusBefore} → {log.statusAfter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="cm-drawer-footer">
|
||||
{tab === 'add' ? (
|
||||
{tab === 'add' && canAddRiskTags(currentUser) ? (
|
||||
<>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => { setTab('tags'); resetAddForm(); }}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmitApply}>提交打标</button>
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => { setTab('tags'); resetAddForm(); }}>
|
||||
取消
|
||||
</button>
|
||||
{confirmHighRisk ? (
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmitApply}>
|
||||
<ShieldAlert size={14} aria-hidden /> 确认提交高风险标签
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmitApply}>
|
||||
提交打标
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>关闭</button>
|
||||
@@ -262,32 +496,42 @@ export function RiskTagDrawer({
|
||||
|
||||
{detailTag && (
|
||||
<div className="cm-drawer-subpanel">
|
||||
<RiskTagDetailCard tag={detailTag} onClose={() => setDetailId(null)} />
|
||||
<RiskTagDetailCard tag={detailTag} currentUser={currentUser} onClose={() => setDetailId(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{removeTargetId && (
|
||||
<div className="cm-drawer-overlay-panel">
|
||||
<h3>移除标签</h3>
|
||||
<p className="cm-modal-hint">移除操作将写入审计日志,并同步更新列表展示。</p>
|
||||
{removeTargetId && removeTarget && (
|
||||
<div className="cm-drawer-overlay-panel" data-annotation-id="cm-risk-removal-apply">
|
||||
<h3>申请解除标签</h3>
|
||||
<p className="cm-modal-hint">
|
||||
{isLegalRiskTag(normalizeRiskTag(removeTarget))
|
||||
? '法务查验完成后,如需解除标签请在此提交申请,经审批后生效。'
|
||||
: '移除风险标签需提交解除申请,经审批后生效。'}
|
||||
</p>
|
||||
<textarea
|
||||
className="vm-input cm-textarea"
|
||||
rows={3}
|
||||
value={removeReason}
|
||||
placeholder="请填写移除原因(至少 5 字)"
|
||||
placeholder="请填写解除原因(至少 5 字)"
|
||||
onChange={(e) => setRemoveReason(e.target.value)}
|
||||
/>
|
||||
<div className="cm-drawer-overlay-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setRemoveTargetId(null)}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary cm-link-danger-btn" onClick={handleConfirmRemove}>确认移除</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary cm-link-danger-btn" onClick={handleConfirmRemove}>
|
||||
确认提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{approveTargetId && (
|
||||
<div className="cm-drawer-overlay-panel">
|
||||
<h3>审批标签</h3>
|
||||
<p className="cm-modal-hint">高风险标签需法务/风控确认后方可生效。</p>
|
||||
{approveTargetId && approveTarget && (
|
||||
<div className="cm-drawer-overlay-panel" data-annotation-id="cm-risk-removal-approval">
|
||||
<h3>{approveTarget.removalRequested ? '审批解除申请' : '审批标签'}</h3>
|
||||
<p className="cm-modal-hint">
|
||||
{approveTarget.removalRequested
|
||||
? '解除申请需法务/风控/管理层确认;通过后标签解除,驳回则保持生效。'
|
||||
: '高风险标签需法务/风控/管理层确认后方可生效。'}
|
||||
</p>
|
||||
<textarea
|
||||
className="vm-input cm-textarea"
|
||||
rows={2}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,12 @@
|
||||
"customerName": "广州粤海金融服务有限公司",
|
||||
"tagId": "seed-6-0",
|
||||
"tagName": "严重拖欠",
|
||||
"tagCategory": "finance",
|
||||
"tagCategoryName": "财务风险",
|
||||
"action": "提交审批",
|
||||
"detail": "财务张财务提交标签「严重拖欠」(财务/高),已进入审批流程",
|
||||
"reason": "客户连续两期未按合同约定回款,累计逾期金额超阈值",
|
||||
"statusBefore": "—",
|
||||
"statusAfter": "待审批",
|
||||
"operator": "张财务",
|
||||
"operatorRole": "finance",
|
||||
"createdAt": "2024-01-23 09:12:00"
|
||||
@@ -17,10 +21,14 @@
|
||||
"customerName": "广州粤海金融服务有限公司",
|
||||
"tagId": "seed-6-0",
|
||||
"tagName": "严重拖欠",
|
||||
"tagCategory": "finance",
|
||||
"tagCategoryName": "财务风险",
|
||||
"action": "审批通过",
|
||||
"detail": "风控王风控审批通过标签「严重拖欠」,标签已生效",
|
||||
"operator": "王风控",
|
||||
"operatorRole": "risk",
|
||||
"reason": "经财务与风控联合复核,确认拖欠事实成立",
|
||||
"statusBefore": "待审批",
|
||||
"statusAfter": "生效中",
|
||||
"operator": "刘风控",
|
||||
"operatorRole": "riskControl",
|
||||
"createdAt": "2024-01-23 14:30:00"
|
||||
},
|
||||
{
|
||||
@@ -29,8 +37,12 @@
|
||||
"customerName": "苏州工业园区精密模具厂",
|
||||
"tagId": "seed-16-1",
|
||||
"tagName": "制裁/高风险地区",
|
||||
"tagCategory": "legal",
|
||||
"tagCategoryName": "法务风险",
|
||||
"action": "提交审批",
|
||||
"detail": "法务李法务提交标签「制裁/高风险地区」(法务/极高),已进入审批流程",
|
||||
"reason": "注册地址涉及制裁名单关联地区,需法务确认合规风险",
|
||||
"statusBefore": "—",
|
||||
"statusAfter": "待审批",
|
||||
"operator": "李法务",
|
||||
"operatorRole": "legal",
|
||||
"createdAt": "2024-06-16 10:05:00"
|
||||
@@ -41,10 +53,78 @@
|
||||
"customerName": "苏州工业园区精密模具厂",
|
||||
"tagId": "seed-16-1",
|
||||
"tagName": "制裁/高风险地区",
|
||||
"tagCategory": "legal",
|
||||
"tagCategoryName": "法务风险",
|
||||
"action": "审批通过",
|
||||
"detail": "法务李法务审批通过标签「制裁/高风险地区」,标签已生效",
|
||||
"reason": "法务完成制裁名单比对,确认高风险地区关联",
|
||||
"statusBefore": "待审批",
|
||||
"statusAfter": "生效中",
|
||||
"operator": "李法务",
|
||||
"operatorRole": "legal",
|
||||
"createdAt": "2024-06-16 16:20:00"
|
||||
},
|
||||
{
|
||||
"id": "log-seed-5",
|
||||
"customerId": "9",
|
||||
"customerName": "天津市滨海新区港口设备制造厂",
|
||||
"tagId": "seed-9-0",
|
||||
"tagName": "涉诉/仲裁",
|
||||
"tagCategory": "legal",
|
||||
"tagCategoryName": "法务风险",
|
||||
"action": "新增标签",
|
||||
"reason": "收到法院传票,涉及租赁合同纠纷仲裁案件",
|
||||
"statusBefore": "—",
|
||||
"statusAfter": "待审批",
|
||||
"operator": "李法务",
|
||||
"operatorRole": "legal",
|
||||
"createdAt": "2024-03-05 11:20:00"
|
||||
},
|
||||
{
|
||||
"id": "log-seed-6",
|
||||
"customerId": "17",
|
||||
"customerName": "长沙湘江科创投资基金(有限合伙)",
|
||||
"tagId": "seed-17-0",
|
||||
"tagName": "频繁争议",
|
||||
"tagCategory": "business",
|
||||
"tagCategoryName": "业管风险",
|
||||
"action": "新增标签",
|
||||
"reason": "近半年发生三次合同条款争议,业管介入协调",
|
||||
"statusBefore": "—",
|
||||
"statusAfter": "生效中",
|
||||
"operator": "王业管",
|
||||
"operatorRole": "businessAdmin",
|
||||
"createdAt": "2024-07-10 09:45:00"
|
||||
},
|
||||
{
|
||||
"id": "log-seed-7",
|
||||
"customerId": "4",
|
||||
"customerName": "杭州云帆智能制造股份有限公司",
|
||||
"tagId": "seed-4-0",
|
||||
"tagName": "逾期预警",
|
||||
"tagCategory": "finance",
|
||||
"tagCategoryName": "财务风险",
|
||||
"action": "到期复核",
|
||||
"reason": "账期到期复核:当前仍有少量逾期,维持预警标签",
|
||||
"statusBefore": "待复核",
|
||||
"statusAfter": "生效中",
|
||||
"operator": "张财务",
|
||||
"operatorRole": "finance",
|
||||
"createdAt": "2024-08-01 10:00:00"
|
||||
},
|
||||
{
|
||||
"id": "log-seed-8",
|
||||
"customerId": "6",
|
||||
"customerName": "广州粤海金融服务有限公司",
|
||||
"tagId": "seed-6-1",
|
||||
"tagName": "多头签约",
|
||||
"tagCategory": "business",
|
||||
"tagCategoryName": "业管风险",
|
||||
"action": "解除标签",
|
||||
"reason": "客户已整合签约主体,多头签约风险已消除",
|
||||
"statusBefore": "生效中",
|
||||
"statusAfter": "已解除",
|
||||
"operator": "王业管",
|
||||
"operatorRole": "businessAdmin",
|
||||
"createdAt": "2024-09-12 15:30:00"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@axhub/annotation';
|
||||
import seedCustomers from './data/customers.json';
|
||||
import { FilterPanel } from './components/FilterPanel';
|
||||
import { RiskKpiCards } from './components/RiskKpiCards';
|
||||
import { CustomerTable } from './components/CustomerTable';
|
||||
import { CreatePage } from './pages/CreatePage';
|
||||
import { RiskTagDrawer } from './components/RiskTagDrawer';
|
||||
@@ -26,21 +27,28 @@ import {
|
||||
type CustomerFilters,
|
||||
type CustomerPageId,
|
||||
type CustomerRecord,
|
||||
type CustomerRiskKpiKey,
|
||||
} from './types';
|
||||
import type { CustomerRiskTag, MockUser, RiskTagApplyForm } from './types/riskTag';
|
||||
import { applyFilters } from './utils/filters';
|
||||
import { countRiskKpi } from './utils/riskKpi';
|
||||
import {
|
||||
buildApplyLog,
|
||||
buildApplyTag,
|
||||
buildApprovalLog,
|
||||
buildRemoveLog,
|
||||
buildRemovalApprovalLog,
|
||||
buildSeedRiskTags,
|
||||
buildWorkbenchTodoLog,
|
||||
formatNow,
|
||||
INITIAL_OPERATION_LOGS,
|
||||
isHighRiskLevel,
|
||||
normalizeRiskTag,
|
||||
syncRecordLabels,
|
||||
todayDate,
|
||||
triggersBlacklistHint,
|
||||
} from './utils/riskTagStore';
|
||||
import { canApplyRiskTagByName, canBatchTag, canManageRiskTags, canRemoveRiskTag, MOCK_USERS } from './utils/permissions';
|
||||
import { canApplyRiskTagByName, canBatchTag, canAddRiskTags, canApproveRiskTag, canRemoveRiskTag, MOCK_USERS, roleLabel } from './utils/permissions';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
@@ -53,6 +61,7 @@ type NavigationRef = {
|
||||
function validateCreateForm(form: CustomerCreateForm): string | null {
|
||||
if (!form.customerType) return '请选择客户类型';
|
||||
if (!form.customerName.trim()) return '请输入客户全称';
|
||||
if (!form.address.trim()) return '请填写通讯地址';
|
||||
if (!form.cityProvince || !form.cityName) return '请填写通讯地址以识别所属城市';
|
||||
if (!form.department) return '请选择业务负责部门';
|
||||
if (!form.manager) return '请选择业务负责人员';
|
||||
@@ -71,11 +80,12 @@ export default function CustomerManagementApp() {
|
||||
);
|
||||
const [riskTagsByCustomer, setRiskTagsByCustomer] = useState<Record<string, CustomerRiskTag[]>>(initialRiskTags);
|
||||
const [operationLogs, setOperationLogs] = useState(INITIAL_OPERATION_LOGS);
|
||||
const [currentUser, setCurrentUser] = useState<MockUser>(MOCK_USERS[0]);
|
||||
const [currentUser, setCurrentUser] = useState<MockUser>(MOCK_USERS[1]);
|
||||
|
||||
const [view, setView] = useState<CustomerPageId>('list');
|
||||
const [pendingFilters, setPendingFilters] = useState<CustomerFilters>(EMPTY_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<CustomerFilters>(EMPTY_FILTERS);
|
||||
const [riskKpiTab, setRiskKpiTab] = useState<CustomerRiskKpiKey>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -102,9 +112,17 @@ export default function CustomerManagementApp() {
|
||||
backToList: () => setView('list'),
|
||||
};
|
||||
|
||||
const kpiCounts = useMemo(() => ({
|
||||
all: countRiskKpi(records, riskTagsByCustomer, 'all'),
|
||||
withRisk: countRiskKpi(records, riskTagsByCustomer, 'withRisk'),
|
||||
finance: countRiskKpi(records, riskTagsByCustomer, 'finance'),
|
||||
legal: countRiskKpi(records, riskTagsByCustomer, 'legal'),
|
||||
business: countRiskKpi(records, riskTagsByCustomer, 'business'),
|
||||
}), [records, riskTagsByCustomer]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyFilters(records, appliedFilters),
|
||||
[records, appliedFilters],
|
||||
() => applyFilters(records, appliedFilters, riskTagsByCustomer, riskKpiTab),
|
||||
[records, appliedFilters, riskTagsByCustomer, riskKpiTab],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
@@ -122,6 +140,7 @@ export default function CustomerManagementApp() {
|
||||
const handleReset = () => {
|
||||
setPendingFilters(EMPTY_FILTERS);
|
||||
setAppliedFilters(EMPTY_FILTERS);
|
||||
setRiskKpiTab('all');
|
||||
setPage(1);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
@@ -169,12 +188,13 @@ export default function CustomerManagementApp() {
|
||||
form: RiskTagApplyForm,
|
||||
batchCount?: number,
|
||||
) => {
|
||||
if (!canManageRiskTags(currentUser)) return '当前角色无打标权限';
|
||||
if (!canAddRiskTags(currentUser)) return '当前角色无打标权限';
|
||||
|
||||
const tag = buildApplyTag(customerId, {
|
||||
...form,
|
||||
effectiveFrom: form.effectiveFrom || todayDate(),
|
||||
effectiveDate: form.effectiveDate || todayDate(),
|
||||
}, currentUser);
|
||||
if (!tag) return '标签不存在或无效';
|
||||
|
||||
setRiskTagsByCustomer((prev) => {
|
||||
const next = {
|
||||
@@ -198,9 +218,14 @@ export default function CustomerManagementApp() {
|
||||
if (!canApplyRiskTagByName(currentUser, form.tagName)) return '当前角色无权选择该标签';
|
||||
const result = appendTagsForCustomer(tagDrawerRecord.id, tagDrawerRecord.name, form);
|
||||
if (!result) {
|
||||
showToast(form.riskLevel === '高' || form.riskLevel === '极高'
|
||||
? '已提交审批,待法务/风控确认'
|
||||
: '标签已生效');
|
||||
const blacklist = triggersBlacklistHint(form.tagName);
|
||||
if (isHighRiskLevel(form.riskLevel)) {
|
||||
showToast(blacklist
|
||||
? '已提交审批;该标签可能触发黑名单审批'
|
||||
: '已提交审批,待法务/风控/管理层确认');
|
||||
} else {
|
||||
showToast('标签已生效');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [appendTagsForCustomer, currentUser, showToast, tagDrawerRecord]);
|
||||
@@ -217,8 +242,9 @@ export default function CustomerManagementApp() {
|
||||
|
||||
const tag = buildApplyTag(customerId, {
|
||||
...form,
|
||||
effectiveFrom: form.effectiveFrom || todayDate(),
|
||||
effectiveDate: form.effectiveDate || todayDate(),
|
||||
}, currentUser);
|
||||
if (!tag) return;
|
||||
|
||||
nextTags[customerId] = [...(nextTags[customerId] ?? []), tag];
|
||||
newLogs.unshift(buildApplyLog(tag, record.name, currentUser, customerIds.length));
|
||||
@@ -232,17 +258,35 @@ export default function CustomerManagementApp() {
|
||||
return null;
|
||||
}, [currentUser, operationLogs, records, refreshRecordLabels, riskTagsByCustomer, showToast]);
|
||||
|
||||
const handleRemoveTag = useCallback((tagId: string, reason: string): string | null => {
|
||||
const handleRemoveTag = useCallback((tagId: string, reason: string, isApplication = true): string | null => {
|
||||
if (!tagDrawerRecord) return '未选择客户';
|
||||
const tags = riskTagsByCustomer[tagDrawerRecord.id] ?? [];
|
||||
const target = tags.find((tag) => tag.id === tagId);
|
||||
if (!target) return '标签不存在';
|
||||
if (!canRemoveRiskTag(currentUser, target.tagType)) return '当前角色无权移除此类型标签';
|
||||
if (!canRemoveRiskTag(currentUser, target.tagCategory)) return '当前角色无权移除此类型标签';
|
||||
|
||||
const now = formatNow();
|
||||
if (isApplication) {
|
||||
setRiskTagsByCustomer((prev) => {
|
||||
const nextTags = (prev[tagDrawerRecord.id] ?? []).map((tag) =>
|
||||
tag.id === tagId ? { ...tag, status: '已移除' as const, updatedAt: now } : tag,
|
||||
tag.id === tagId
|
||||
? { ...tag, removalRequested: true, removeReason: reason }
|
||||
: tag,
|
||||
);
|
||||
return { ...prev, [tagDrawerRecord.id]: nextTags };
|
||||
});
|
||||
setOperationLogs((prev) => [
|
||||
buildRemoveLog(target, tagDrawerRecord.name, currentUser, reason, true),
|
||||
...prev,
|
||||
]);
|
||||
showToast('解除申请已提交,待审批');
|
||||
return null;
|
||||
}
|
||||
|
||||
setRiskTagsByCustomer((prev) => {
|
||||
const nextTags = (prev[tagDrawerRecord.id] ?? []).map((tag) =>
|
||||
tag.id === tagId
|
||||
? { ...tag, status: 'removed' as const, statusName: '已解除' }
|
||||
: tag,
|
||||
);
|
||||
const next = { ...prev, [tagDrawerRecord.id]: nextTags };
|
||||
refreshRecordLabels(next);
|
||||
@@ -250,28 +294,116 @@ export default function CustomerManagementApp() {
|
||||
});
|
||||
|
||||
setOperationLogs((prev) => [
|
||||
buildRemoveLog({ ...target, status: '已移除', updatedAt: now }, tagDrawerRecord.name, currentUser, reason),
|
||||
buildRemoveLog(
|
||||
{ ...target, status: 'removed', statusName: '已解除' },
|
||||
tagDrawerRecord.name,
|
||||
currentUser,
|
||||
reason,
|
||||
),
|
||||
...prev,
|
||||
]);
|
||||
showToast('标签已移除');
|
||||
showToast('标签已解除');
|
||||
return null;
|
||||
}, [currentUser, refreshRecordLabels, riskTagsByCustomer, showToast, tagDrawerRecord]);
|
||||
|
||||
const legalTodoDispatchedRef = useRef(new Set<string>());
|
||||
|
||||
const handleDispatchLegalTodos = useCallback((
|
||||
customerId: string,
|
||||
customerName: string,
|
||||
overdueTags: CustomerRiskTag[],
|
||||
) => {
|
||||
const pending = overdueTags.filter((tag) => {
|
||||
const key = `${customerId}:${tag.id}`;
|
||||
if (legalTodoDispatchedRef.current.has(key)) return false;
|
||||
legalTodoDispatchedRef.current.add(key);
|
||||
return true;
|
||||
});
|
||||
if (pending.length === 0) return;
|
||||
|
||||
setOperationLogs((prev) => [
|
||||
...pending.map((tag) => buildWorkbenchTodoLog(tag, customerName)),
|
||||
...prev,
|
||||
]);
|
||||
showToast(`已向法务工作台推送 ${pending.length} 条复核待办(原型演示)`);
|
||||
}, [showToast]);
|
||||
|
||||
const handleApproveTag = useCallback((tagId: string, approved: boolean, rejectReason?: string): string | null => {
|
||||
if (!tagDrawerRecord) return '未选择客户';
|
||||
if (!canApproveRiskTag(currentUser)) return '当前角色无审批权限';
|
||||
|
||||
const tags = riskTagsByCustomer[tagDrawerRecord.id] ?? [];
|
||||
const target = tags.find((tag) => tag.id === tagId);
|
||||
if (!target || target.status !== '待审批') return '标签不可审批';
|
||||
if (!target) return '标签不存在';
|
||||
|
||||
const isRemovalApproval = target.status === 'active' && target.removalRequested;
|
||||
const isAddApproval = target.status === 'pending';
|
||||
if (!isRemovalApproval && !isAddApproval) return '标签不可审批';
|
||||
|
||||
const now = formatNow();
|
||||
|
||||
if (isRemovalApproval) {
|
||||
setRiskTagsByCustomer((prev) => {
|
||||
const nextTags = (prev[tagDrawerRecord.id] ?? []).map((tag) => {
|
||||
if (tag.id !== tagId) return tag;
|
||||
if (approved) {
|
||||
return normalizeRiskTag({
|
||||
...tag,
|
||||
status: 'removed' as const,
|
||||
statusName: '已解除',
|
||||
removalRequested: false,
|
||||
removedAt: now,
|
||||
removeReason: tag.removeReason ?? rejectReason,
|
||||
approvedBy: currentUser.name,
|
||||
approvedAt: now,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...tag,
|
||||
removalRequested: false,
|
||||
approvedBy: currentUser.name,
|
||||
approvedAt: now,
|
||||
rejectReason,
|
||||
};
|
||||
});
|
||||
const next = { ...prev, [tagDrawerRecord.id]: nextTags };
|
||||
refreshRecordLabels(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
setOperationLogs((prev) => [
|
||||
buildRemovalApprovalLog(
|
||||
{ ...target, status: approved ? 'removed' : 'active', statusName: approved ? '已解除' : '生效中' },
|
||||
tagDrawerRecord.name,
|
||||
currentUser,
|
||||
approved,
|
||||
rejectReason,
|
||||
),
|
||||
...prev,
|
||||
]);
|
||||
showToast(approved ? '解除申请已通过,标签已解除' : '已驳回解除申请');
|
||||
return null;
|
||||
}
|
||||
|
||||
setRiskTagsByCustomer((prev) => {
|
||||
const nextTags = (prev[tagDrawerRecord.id] ?? []).map((tag) => {
|
||||
if (tag.id !== tagId) return tag;
|
||||
return approved
|
||||
? { ...tag, status: '生效中' as const, approver: currentUser.name, approvedAt: now, updatedAt: now }
|
||||
: { ...tag, status: '已驳回' as const, approver: currentUser.name, approvedAt: now, rejectReason, updatedAt: now };
|
||||
? {
|
||||
...tag,
|
||||
status: 'active' as const,
|
||||
statusName: '生效中',
|
||||
approvedBy: currentUser.name,
|
||||
approvedAt: now,
|
||||
}
|
||||
: {
|
||||
...tag,
|
||||
status: 'rejected' as const,
|
||||
statusName: '已驳回',
|
||||
approvedBy: currentUser.name,
|
||||
approvedAt: now,
|
||||
rejectReason,
|
||||
};
|
||||
});
|
||||
const next = { ...prev, [tagDrawerRecord.id]: nextTags };
|
||||
refreshRecordLabels(next);
|
||||
@@ -280,7 +412,7 @@ export default function CustomerManagementApp() {
|
||||
|
||||
setOperationLogs((prev) => [
|
||||
buildApprovalLog(
|
||||
{ ...target, status: approved ? '生效中' : '已驳回' },
|
||||
{ ...target, status: approved ? 'active' : 'rejected', statusName: approved ? '生效中' : '已驳回' },
|
||||
tagDrawerRecord.name,
|
||||
currentUser,
|
||||
approved,
|
||||
@@ -338,20 +470,20 @@ export default function CustomerManagementApp() {
|
||||
<span className="cm-role-bar-label">当前操作角色(原型演示):</span>
|
||||
<select
|
||||
className="vm-input cm-role-select"
|
||||
value={currentUser.name}
|
||||
value={currentUser.role}
|
||||
onChange={(e) => {
|
||||
const user = MOCK_USERS.find((item) => item.name === e.target.value);
|
||||
const user = MOCK_USERS.find((item) => item.role === e.target.value);
|
||||
if (user) setCurrentUser(user);
|
||||
}}
|
||||
>
|
||||
{MOCK_USERS.map((user) => (
|
||||
<option key={user.name} value={user.name}>
|
||||
{user.name} · {user.department}
|
||||
<option key={user.role} value={user.role}>
|
||||
{roleLabel(user.role)} · {user.department}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!canManageRiskTags(currentUser) && (
|
||||
<span className="cm-role-hint">销售角色仅可查看,不可打标</span>
|
||||
{!canAddRiskTags(currentUser) && (
|
||||
<span className="cm-role-hint">当前角色仅可查看,不可新增或解除标签</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -363,6 +495,12 @@ export default function CustomerManagementApp() {
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
|
||||
<RiskKpiCards
|
||||
counts={kpiCounts}
|
||||
active={riskKpiTab}
|
||||
onChange={(key) => { setRiskKpiTab(key); setPage(1); setSelectedIds(new Set()); }}
|
||||
/>
|
||||
|
||||
<section className="vm-table-section">
|
||||
<div className="vm-table-toolbar" data-annotation-id="cm-list-toolbar">
|
||||
<div className="vm-table-actions">
|
||||
@@ -441,6 +579,7 @@ export default function CustomerManagementApp() {
|
||||
onApply={handleApplyTag}
|
||||
onRemove={handleRemoveTag}
|
||||
onApprove={handleApproveTag}
|
||||
onDispatchLegalTodos={handleDispatchLegalTodos}
|
||||
/>
|
||||
|
||||
<BatchRiskTagModal
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DEPT_MANAGER_MAP,
|
||||
REGION_TREE,
|
||||
} from '../utils/constants';
|
||||
import { parseAddressToRegion } from '../utils/address';
|
||||
import { parseAddressToRegion, formatProvinceCity } from '../utils/address';
|
||||
|
||||
interface CreatePageProps {
|
||||
form: CustomerCreateForm;
|
||||
@@ -66,6 +66,8 @@ export function CreatePage({
|
||||
onChange({ address, cityProvince, cityName });
|
||||
};
|
||||
|
||||
const provinceCityDisplay = formatProvinceCity(form.cityProvince, form.cityName);
|
||||
|
||||
return (
|
||||
<div className="cm-create-page">
|
||||
<nav className="cm-breadcrumb" aria-label="面包屑">
|
||||
@@ -106,7 +108,16 @@ export function CreatePage({
|
||||
onChange={(event) => onChange({ customerShortName: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="所属城市" required hint="自动从通讯地址识别">
|
||||
<FormField label="省市" hint="自动从地址解析">
|
||||
<input
|
||||
className="vm-input cm-input-disabled"
|
||||
value={provinceCityDisplay}
|
||||
placeholder="自动从地址解析"
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="所属城市" required hint="自动从通讯地址识别,不可手选">
|
||||
<div className="cm-city-row">
|
||||
<select
|
||||
className="vm-input cm-input-disabled"
|
||||
@@ -130,11 +141,11 @@ export function CreatePage({
|
||||
</select>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="通讯地址" hint="填写完整地址自动识别省市">
|
||||
<FormField label="通讯地址" required hint="填写完整地址自动识别省市">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.address}
|
||||
placeholder="请输入通讯地址"
|
||||
placeholder="填写完整地址自动解析省市和详细地址"
|
||||
onChange={(event) => handleAddressChange(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -9,13 +9,14 @@ const createPrd = fs.readFileSync(path.join(protoRoot, '.spec/requirements-prd-c
|
||||
|
||||
const NODE_DEFS = [
|
||||
{ id: 'cm-list-filter', title: '筛选条件', pageId: 'list', color: '#2563eb', aiPrompt: '列表筛选区字段与查询逻辑。', annotationText: '默认展示 4 项(一行),点击「更多筛选」显示全部。' },
|
||||
{ id: 'cm-risk-role-switch', title: '操作角色切换', pageId: 'list', color: '#64748b', aiPrompt: '模拟法务/财务/运维/风控/销售角色,驱动打标权限。', annotationText: '切换角色后抽屉内标签类型与可选标签联动更新。' },
|
||||
{ id: 'cm-risk-role-switch', title: '操作角色切换', pageId: 'list', color: '#64748b', aiPrompt: '模拟 8 类角色(销售/法务/财务/业管/运维/风控/管理层/管理员),驱动分区打标与审批权限。', annotationText: '切换角色后 Drawer 分区维护、可选标签、审批与脱敏规则联动更新。' },
|
||||
{ id: 'cm-risk-kpi-cards', title: '风险 KPI 卡片', pageId: 'list', color: '#059669', aiPrompt: '按风险类型快速筛选客户列表。', annotationText: '5 张卡片:全部客户、有风险客户、财务风险、法务风险、业管风险。' },
|
||||
{ id: 'cm-list-toolbar', title: '列表工具栏', pageId: 'list', color: '#0f766e', aiPrompt: '新增、导入、导出、批量打标入口。', annotationText: '勾选客户后显示「批量打标」。' },
|
||||
{ id: 'cm-list-table', title: '客户台账列表', pageId: 'list', color: '#2563eb', aiPrompt: '客户台账表格列与行操作,含风险标签列。', annotationText: '操作列含「管理标签」;标签列按风险级别着色。' },
|
||||
{ id: 'cm-risk-tag-drawer', title: '风险标签管理抽屉', pageId: 'list', color: '#d97706', aiPrompt: '单客户打标、审批、移除、日志。', annotationText: '仅列表页维护标签,不在新增/编辑表单维护。' },
|
||||
{ id: 'cm-risk-tag-drawer', title: '风险标签抽屉', pageId: 'list', color: '#d97706', aiPrompt: '单客户打标、法务工作台待办、审批、解除申请与审批、日志。', annotationText: '打标时间/下次复核时间只读;解除说明按标签类型区分(法务含查验提示,财务/业管不含)。' },
|
||||
{ id: 'cm-batch-risk-tag-btn', title: '批量打标入口', pageId: 'list', color: '#0f766e', aiPrompt: '勾选多客户后批量提交同一标签。', annotationText: '' },
|
||||
{ id: 'cm-batch-risk-tag-modal', title: '批量打标弹窗', pageId: 'list', color: '#0f766e', aiPrompt: '批量打标表单与提交逻辑。', annotationText: '' },
|
||||
{ id: 'cm-create-base', title: '客户信息', pageId: 'create', color: '#2563eb', aiPrompt: '客户类型、全称、城市、部门负责人、合作状态等。', annotationText: '通讯地址自动识别省市;不含风险标签字段。' },
|
||||
{ id: 'cm-create-base', title: '客户信息', pageId: 'create', color: '#2563eb', aiPrompt: '客户类型、全称、城市、部门负责人、合作状态等。', annotationText: '通讯地址自动识别省市;合作状态四选一;不含风险标签字段。' },
|
||||
{ id: 'cm-create-contact', title: '联系人信息', pageId: 'create', color: '#64748b', aiPrompt: '联系人姓名、手机、职位。', annotationText: '均为选填。' },
|
||||
{ id: 'cm-create-invoice', title: '付款及开票信息', pageId: 'create', color: '#d97706', aiPrompt: '发票抬头、税号、银行账号等。', annotationText: '发票抬头只读,与客户全称一致。' },
|
||||
{ id: 'cm-create-footer', title: '底部操作栏', pageId: 'create', color: '#0f766e', aiPrompt: '重置与提交。', annotationText: '提交校验必填项后 Toast 并返回列表。' },
|
||||
@@ -52,12 +53,13 @@ function extractFromPrd({ prd, heading, sub }) {
|
||||
|
||||
const NODE_PRD_MAP = {
|
||||
'cm-list-filter': { prd: 'list', heading: '## 4. 筛选区' },
|
||||
'cm-risk-role-switch': { prd: 'list', heading: '## 5. 列表工具栏', sub: '### 5.1 操作角色切换(原型演示)' },
|
||||
'cm-list-toolbar': { prd: 'list', heading: '## 5. 列表工具栏' },
|
||||
'cm-list-table': { prd: 'list', heading: '## 6. 列表字段说明' },
|
||||
'cm-risk-tag-drawer': { prd: 'list', heading: '## 8. 风险标签维护(列表页专属)' },
|
||||
'cm-batch-risk-tag-btn': { prd: 'list', heading: '## 8. 风险标签维护(列表页专属)', sub: '### 8.1 入口' },
|
||||
'cm-batch-risk-tag-modal': { prd: 'list', heading: '## 8. 风险标签维护(列表页专属)', sub: '### 8.4 批量打标' },
|
||||
'cm-risk-role-switch': { prd: 'list', heading: '## 6. 列表工具栏', sub: '### 6.1 操作角色切换(原型演示)' },
|
||||
'cm-risk-kpi-cards': { prd: 'list', heading: '## 5. 风险 KPI 卡片筛选' },
|
||||
'cm-list-toolbar': { prd: 'list', heading: '## 6. 列表工具栏' },
|
||||
'cm-list-table': { prd: 'list', heading: '## 7. 列表字段说明' },
|
||||
'cm-risk-tag-drawer': { prd: 'list', heading: '## 9. 风险标签维护(列表页专属)' },
|
||||
'cm-batch-risk-tag-btn': { prd: 'list', heading: '## 9. 风险标签维护(列表页专属)', sub: '### 9.1 入口' },
|
||||
'cm-batch-risk-tag-modal': { prd: 'list', heading: '## 9. 风险标签维护(列表页专属)', sub: '### 9.5 批量打标' },
|
||||
'cm-create-base': { prd: 'create', heading: '## 2. 客户信息' },
|
||||
'cm-create-contact': { prd: 'create', heading: '## 3. 联系人信息' },
|
||||
'cm-create-invoice': { prd: 'create', heading: '## 4. 付款及开票信息' },
|
||||
|
||||
@@ -411,6 +411,31 @@
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cm-risk-review-banner--legal {
|
||||
border-color: rgba(217, 119, 6, 0.35);
|
||||
background: rgba(217, 119, 6, 0.08);
|
||||
}
|
||||
|
||||
.cm-risk-review-log-list {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.cm-risk-review-log-list li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cm-risk-review-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cm-risk-kpi-row {
|
||||
margin: 16px 0 20px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cm-risk-label-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -515,13 +540,17 @@ button.cm-risk-tag:hover {
|
||||
z-index: 56;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(520px, 100vw);
|
||||
width: min(560px, 100vw);
|
||||
height: 100vh;
|
||||
background: var(--ln-surface-card);
|
||||
border-left: 1px solid var(--ln-hairline);
|
||||
box-shadow: var(--ln-shadow-modal);
|
||||
}
|
||||
|
||||
.cm-drawer--wide {
|
||||
width: min(600px, 100vw);
|
||||
}
|
||||
|
||||
.cm-drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -588,13 +617,18 @@ button.cm-risk-tag:hover {
|
||||
.cm-risk-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 14px 16px;
|
||||
}
|
||||
|
||||
.cm-risk-form-full {
|
||||
.cm-risk-form > .cm-risk-form-full {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.cm-risk-form-grid-spacer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cm-risk-form .cm-form-field {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -703,6 +737,28 @@ button.cm-risk-tag:hover {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cm-risk-status--active {
|
||||
color: #047857;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.cm-risk-status--pending {
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.cm-risk-status--rejected,
|
||||
.cm-risk-status--removed {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.cm-risk-status--expired {
|
||||
color: var(--ln-muted);
|
||||
background: var(--ln-surface-pearl);
|
||||
}
|
||||
|
||||
/* legacy chinese status classes kept for backward compat */
|
||||
.cm-risk-status--生效中 {
|
||||
color: #047857;
|
||||
background: #ecfdf5;
|
||||
@@ -725,6 +781,213 @@ button.cm-risk-tag:hover {
|
||||
background: var(--ln-surface-pearl);
|
||||
}
|
||||
|
||||
.cm-risk-zones {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cm-risk-zone {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-card);
|
||||
background: var(--ln-surface-card);
|
||||
}
|
||||
|
||||
.cm-risk-zone-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cm-risk-zone-head h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cm-risk-zone-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cm-risk-zone-perm {
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--ln-radius-pill);
|
||||
}
|
||||
|
||||
.cm-risk-zone-perm--ok {
|
||||
color: #047857;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.cm-risk-zone-perm--readonly {
|
||||
color: var(--ln-muted);
|
||||
background: var(--ln-surface-pearl);
|
||||
}
|
||||
|
||||
.cm-risk-zone-add {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cm-risk-zone-empty {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-muted);
|
||||
background: var(--ln-canvas-soft);
|
||||
border-radius: var(--ln-radius-control);
|
||||
}
|
||||
|
||||
.cm-risk-zone-cards {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cm-risk-review-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--ln-radius-control);
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
color: #9a3412;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cm-risk-badge {
|
||||
display: inline-flex;
|
||||
margin-left: 6px;
|
||||
padding: 0 6px;
|
||||
line-height: 18px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cm-risk-badge--warn {
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fed7aa;
|
||||
}
|
||||
|
||||
.cm-risk-badge--review {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.cm-risk-tag-badge {
|
||||
display: inline-flex;
|
||||
margin-left: 6px;
|
||||
padding: 0 6px;
|
||||
line-height: 18px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.cm-risk-tag-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px 12px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.cm-risk-tag-fields dt {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.cm-risk-tag-fields dd {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cm-risk-no-permission {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-card);
|
||||
}
|
||||
|
||||
.cm-risk-no-permission p {
|
||||
margin: 0 0 8px;
|
||||
font-weight: 600;
|
||||
color: var(--ln-ink);
|
||||
}
|
||||
|
||||
.cm-sensitive-hint {
|
||||
display: inline-block;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
background: var(--ln-surface-pearl);
|
||||
border-radius: var(--ln-radius-control);
|
||||
}
|
||||
|
||||
.cm-risk-sensitive-list {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cm-log-tag {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--ln-radius-pill);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cm-log-tag--add { color: #047857; background: #ecfdf5; }
|
||||
.cm-log-tag--remove { color: #b91c1c; background: #fef2f2; }
|
||||
.cm-log-tag--pending { color: #b45309; background: #fffbeb; }
|
||||
.cm-log-tag--pass { color: #047857; background: #ecfdf5; }
|
||||
.cm-log-tag--reject { color: #b91c1c; background: #fef2f2; }
|
||||
.cm-log-tag--review { color: #2563eb; background: #eff6ff; }
|
||||
|
||||
.cm-risk-log-title {
|
||||
margin: 4px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cm-risk-log-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cm-risk-tooltip-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.cm-risk-tooltip-meta {
|
||||
font-size: 10px;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.cm-risk-alert--warn {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.cm-risk-tag-tooltip {
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.cm-risk-log-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -92,3 +92,11 @@ export const EMPTY_CREATE_FORM: CustomerCreateForm = {
|
||||
};
|
||||
|
||||
export type CustomerPageId = 'list' | 'create';
|
||||
|
||||
/** 风险标签 KPI 卡片筛选键 */
|
||||
export type CustomerRiskKpiKey =
|
||||
| 'all'
|
||||
| 'withRisk'
|
||||
| 'finance'
|
||||
| 'legal'
|
||||
| 'business';
|
||||
|
||||
@@ -1,98 +1,362 @@
|
||||
import type { CustomerRecord } from '../types';
|
||||
|
||||
export type RiskTagCategory = '法务' | '财务' | '运维' | '风控';
|
||||
|
||||
export type RiskLevel = '低' | '中' | '高' | '极高';
|
||||
|
||||
export type RiskTagLifecycleStatus =
|
||||
| '生效中'
|
||||
| '待审批'
|
||||
| '待确认'
|
||||
| '已驳回'
|
||||
| '已过期'
|
||||
| '已移除';
|
||||
export type TagCategoryCode = 'legal' | 'finance' | 'business';
|
||||
|
||||
|
||||
|
||||
export type RiskLevelCode = 'low' | 'medium' | 'high' | 'fatal';
|
||||
|
||||
|
||||
|
||||
export type TagStatusCode = 'active' | 'pending' | 'expired' | 'removed' | 'rejected';
|
||||
|
||||
|
||||
|
||||
export type TagSourceCode = 'system' | 'manual';
|
||||
|
||||
|
||||
|
||||
export type SensitiveLevel = 'normal' | 'financeSensitive' | 'legalSensitive';
|
||||
|
||||
|
||||
|
||||
export type ReviewCycleOption = '1个月' | '2个月' | '3个月' | '6个月';
|
||||
|
||||
|
||||
|
||||
export type ReviewStatusCode = 'normal' | 'dueSoon' | 'overdue';
|
||||
|
||||
|
||||
|
||||
export type ReviewConclusionCode = 'riskExists' | 'riskResolved' | 'pendingConfirmation';
|
||||
|
||||
|
||||
|
||||
export type UserRole =
|
||||
|
||||
| 'sales'
|
||||
|
||||
| 'legal'
|
||||
|
||||
| 'finance'
|
||||
|
||||
| 'businessAdmin'
|
||||
|
||||
| 'ops'
|
||||
|
||||
| 'riskControl'
|
||||
|
||||
| 'manager'
|
||||
|
||||
| 'admin';
|
||||
|
||||
|
||||
|
||||
export type RiskTagAction =
|
||||
| '新增'
|
||||
| '移除'
|
||||
| '变更'
|
||||
|
||||
| '新增标签'
|
||||
|
||||
| '解除标签'
|
||||
|
||||
| '提交审批'
|
||||
|
||||
| '审批通过'
|
||||
|
||||
| '审批驳回'
|
||||
|
||||
| '到期复核'
|
||||
|
||||
| '工作台待办'
|
||||
|
||||
| '批量打标';
|
||||
|
||||
|
||||
|
||||
export interface RiskTagAttachment {
|
||||
|
||||
id: string;
|
||||
|
||||
name: string;
|
||||
|
||||
size: string;
|
||||
|
||||
uploadedAt: string;
|
||||
|
||||
attachmentType?: 'legal' | 'finance' | 'general';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface FinanceSensitiveDetail {
|
||||
|
||||
overdueAmount?: string;
|
||||
|
||||
billNo?: string;
|
||||
|
||||
receivableNote?: string;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface RiskTagReviewLog {
|
||||
|
||||
id: string;
|
||||
|
||||
reviewedAt: string;
|
||||
|
||||
reviewedBy: string;
|
||||
|
||||
conclusion: ReviewConclusionCode;
|
||||
|
||||
conclusionName: string;
|
||||
|
||||
note: string;
|
||||
|
||||
nextReviewAt?: string;
|
||||
|
||||
attachments: RiskTagAttachment[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface CustomerRiskTag {
|
||||
|
||||
id: string;
|
||||
|
||||
customerId: string;
|
||||
|
||||
tagCode: string;
|
||||
|
||||
tagName: string;
|
||||
tagType: RiskTagCategory;
|
||||
riskLevel: RiskLevel;
|
||||
|
||||
tagCategory: TagCategoryCode;
|
||||
|
||||
tagCategoryName: string;
|
||||
|
||||
responsibleDept: string;
|
||||
|
||||
editableRoles: UserRole[];
|
||||
|
||||
riskLevel: RiskLevelCode;
|
||||
|
||||
riskLevelName: string;
|
||||
|
||||
status: TagStatusCode;
|
||||
|
||||
statusName: string;
|
||||
|
||||
source: TagSourceCode;
|
||||
|
||||
sourceName: string;
|
||||
|
||||
reason: string;
|
||||
effectiveFrom: string;
|
||||
effectiveTo: string;
|
||||
attachments: RiskTagAttachment[];
|
||||
status: RiskTagLifecycleStatus;
|
||||
operator: string;
|
||||
operatorRole: UserRole;
|
||||
approver?: string;
|
||||
approvedAt?: string;
|
||||
rejectReason?: string;
|
||||
|
||||
/** 打标时间(与 effectiveDate 同步) */
|
||||
|
||||
taggedAt: string;
|
||||
|
||||
/** @deprecated 请使用 taggedAt */
|
||||
|
||||
effectiveDate: string;
|
||||
|
||||
/** 财务/业管:业务有效期;法务:内部兼容字段,不在 UI 展示 */
|
||||
|
||||
expireDate: string;
|
||||
|
||||
/** 法务:下次复核时间 */
|
||||
|
||||
nextReviewAt: string;
|
||||
|
||||
reviewCycle: ReviewCycleOption | string;
|
||||
|
||||
lastReviewedAt?: string;
|
||||
|
||||
reviewStatus?: ReviewStatusCode;
|
||||
|
||||
removedAt?: string;
|
||||
|
||||
removeReason?: string;
|
||||
|
||||
reviewLogs?: RiskTagReviewLog[];
|
||||
|
||||
createdBy: string;
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
approvedBy?: string;
|
||||
|
||||
approvedAt?: string;
|
||||
|
||||
attachments: RiskTagAttachment[];
|
||||
|
||||
sensitiveLevel: SensitiveLevel;
|
||||
|
||||
financeDetail?: FinanceSensitiveDetail;
|
||||
|
||||
rejectReason?: string;
|
||||
|
||||
removalRequested?: boolean;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface RiskTagOperationLog {
|
||||
|
||||
id: string;
|
||||
|
||||
customerId: string;
|
||||
|
||||
customerName: string;
|
||||
|
||||
tagId?: string;
|
||||
|
||||
tagName: string;
|
||||
|
||||
tagCategory: TagCategoryCode;
|
||||
|
||||
tagCategoryName: string;
|
||||
|
||||
action: RiskTagAction;
|
||||
detail: string;
|
||||
|
||||
reason: string;
|
||||
|
||||
statusBefore?: string;
|
||||
|
||||
statusAfter?: string;
|
||||
|
||||
operator: string;
|
||||
|
||||
operatorRole: UserRole;
|
||||
|
||||
createdAt: string;
|
||||
|
||||
/** @deprecated 兼容旧日志展示 */
|
||||
|
||||
detail?: string;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface RiskTagApplyForm {
|
||||
|
||||
tagName: string;
|
||||
tagType: RiskTagCategory;
|
||||
riskLevel: RiskLevel;
|
||||
|
||||
tagCategory: TagCategoryCode;
|
||||
|
||||
riskLevel: RiskLevelCode;
|
||||
|
||||
reason: string;
|
||||
effectiveFrom: string;
|
||||
effectiveTo: string;
|
||||
|
||||
taggedAt: string;
|
||||
|
||||
/** @deprecated 与 taggedAt 同步 */
|
||||
|
||||
effectiveDate: string;
|
||||
|
||||
/** 财务/业管:有效期至 */
|
||||
|
||||
expireDate: string;
|
||||
|
||||
/** 法务:下次复核时间 */
|
||||
|
||||
nextReviewAt: string;
|
||||
|
||||
reviewCycle: ReviewCycleOption;
|
||||
|
||||
attachments: RiskTagAttachment[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const EMPTY_RISK_TAG_FORM: RiskTagApplyForm = {
|
||||
|
||||
tagName: '',
|
||||
tagType: '风控',
|
||||
riskLevel: '中',
|
||||
|
||||
tagCategory: 'legal',
|
||||
|
||||
riskLevel: 'medium',
|
||||
|
||||
reason: '',
|
||||
effectiveFrom: '',
|
||||
effectiveTo: '',
|
||||
|
||||
taggedAt: '',
|
||||
|
||||
effectiveDate: '',
|
||||
|
||||
expireDate: '',
|
||||
|
||||
nextReviewAt: '',
|
||||
|
||||
reviewCycle: '3个月',
|
||||
|
||||
attachments: [],
|
||||
|
||||
};
|
||||
|
||||
export type UserRole = 'finance' | 'legal' | 'risk' | 'ops' | 'sales' | 'admin';
|
||||
|
||||
|
||||
export interface RiskTagReviewForm {
|
||||
|
||||
conclusion: ReviewConclusionCode;
|
||||
|
||||
note: string;
|
||||
|
||||
nextReviewAt: string;
|
||||
|
||||
removeReason: string;
|
||||
|
||||
attachments: RiskTagAttachment[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const EMPTY_RISK_TAG_REVIEW_FORM: RiskTagReviewForm = {
|
||||
|
||||
conclusion: 'riskExists',
|
||||
|
||||
note: '',
|
||||
|
||||
nextReviewAt: '',
|
||||
|
||||
removeReason: '',
|
||||
|
||||
attachments: [],
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
export interface MockUser {
|
||||
|
||||
name: string;
|
||||
|
||||
role: UserRole;
|
||||
|
||||
department: string;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type RiskTagsByCustomer = Record<string, CustomerRiskTag[]>;
|
||||
|
||||
|
||||
|
||||
export interface RiskTagContext {
|
||||
|
||||
records: CustomerRecord[];
|
||||
|
||||
riskTagsByCustomer: RiskTagsByCustomer;
|
||||
|
||||
operationLogs: RiskTagOperationLog[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -15,23 +15,39 @@ const MUNICIPALITIES = new Set(['北京市', '天津市', '上海市', '重庆
|
||||
export function parseAddressToRegion(fullAddress: string): {
|
||||
cityProvince: string;
|
||||
cityName: string;
|
||||
detailAddress: string;
|
||||
} {
|
||||
const text = fullAddress.trim();
|
||||
if (!text) return { cityProvince: '', cityName: '' };
|
||||
if (!text) return { cityProvince: '', cityName: '', detailAddress: '' };
|
||||
|
||||
const province = PROVINCES.find((item) => text.startsWith(item));
|
||||
if (!province) return { cityProvince: '', cityName: '' };
|
||||
if (!province) return { cityProvince: '', cityName: '', detailAddress: text };
|
||||
|
||||
const node = REGION_TREE.find((item) => item.province === province);
|
||||
if (!node) return { cityProvince: '', cityName: '' };
|
||||
if (!node) return { cityProvince: '', cityName: '', detailAddress: text };
|
||||
|
||||
if (MUNICIPALITIES.has(province)) {
|
||||
const municipalityCity = node.cities.find((city) => city === province) ?? node.cities[0] ?? '';
|
||||
return { cityProvince: province, cityName: municipalityCity };
|
||||
const detailAddress = text.slice(province.length).trim() || text;
|
||||
return { cityProvince: province, cityName: municipalityCity, detailAddress };
|
||||
}
|
||||
|
||||
const rest = text.slice(province.length);
|
||||
const sortedCities = [...node.cities].sort((a, b) => b.length - a.length);
|
||||
const city = sortedCities.find((item) => rest.startsWith(item)) ?? '';
|
||||
return { cityProvince: province, cityName: city };
|
||||
const matchedCity = sortedCities.find((item) => rest.startsWith(item));
|
||||
const cityMatch = matchedCity ? null : rest.match(/^([\u4e00-\u9fa5]+?(?:市|区|州|盟|县))/);
|
||||
const city = matchedCity ?? cityMatch?.[1] ?? '';
|
||||
const detailAddress = city ? rest.slice(city.length).trim() : rest.trim();
|
||||
|
||||
return {
|
||||
cityProvince: province,
|
||||
cityName: city,
|
||||
detailAddress: detailAddress || text,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatProvinceCity(cityProvince: string, cityName: string): string {
|
||||
if (!cityProvince) return '';
|
||||
if (!cityName || cityName === cityProvince) return cityProvince;
|
||||
return `${cityProvince}-${cityName}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { CustomerFilters, CustomerRecord } from '../types';
|
||||
import type { CustomerFilters, CustomerRecord, CustomerRiskKpiKey } from '../types';
|
||||
import type { CustomerRiskTag } from '../types/riskTag';
|
||||
import { matchesRiskKpi } from './riskKpi';
|
||||
|
||||
function parseDate(value: string): number | null {
|
||||
if (!value) return null;
|
||||
@@ -16,8 +18,15 @@ function inDateRange(createTime: string, from: string, to: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function applyFilters(records: CustomerRecord[], filters: CustomerFilters): CustomerRecord[] {
|
||||
export function applyFilters(
|
||||
records: CustomerRecord[],
|
||||
filters: CustomerFilters,
|
||||
riskTagsByCustomer: Record<string, CustomerRiskTag[]> = {},
|
||||
kpiTab: CustomerRiskKpiKey = 'all',
|
||||
): CustomerRecord[] {
|
||||
return records.filter((record) => {
|
||||
if (!matchesRiskKpi(record, riskTagsByCustomer[record.id] ?? [], kpiTab)) return false;
|
||||
|
||||
if (filters.code && record.code !== filters.code) return false;
|
||||
if (filters.name && record.name !== filters.name) return false;
|
||||
if (filters.region && record.region !== filters.region) return false;
|
||||
|
||||
@@ -1,57 +1,65 @@
|
||||
import type { MockUser, RiskTagCategory, UserRole } from '../types/riskTag';
|
||||
import type { MockUser, TagCategoryCode, UserRole } from '../types/riskTag';
|
||||
import type { TagCatalogItem } from './riskTagConstants';
|
||||
import { TAG_CATALOG_MAP } from './riskTagConstants';
|
||||
import { ROLE_LABELS, ROLE_TO_DEFAULT_CATEGORY, TAG_CATALOG_MAP } from './riskTagConstants';
|
||||
|
||||
export const MOCK_USERS: MockUser[] = [
|
||||
{ name: '王风控', role: 'risk', department: '风控部' },
|
||||
{ name: '陈销售', role: 'sales', department: '销售一部' },
|
||||
{ name: '李法务', role: 'legal', department: '法务部' },
|
||||
{ name: '张财务', role: 'finance', department: '财务部' },
|
||||
{ name: '赵运维', role: 'ops', department: '运维部' },
|
||||
{ name: '陈销售', role: 'sales', department: '销售一部' },
|
||||
{ name: '王业管', role: 'businessAdmin', department: '业管部' },
|
||||
{ name: '赵运维', role: 'ops', department: '运维资产部' },
|
||||
{ name: '刘风控', role: 'riskControl', department: '风控部' },
|
||||
{ name: '周管理', role: 'manager', department: '管理层' },
|
||||
{ name: '系统管理员', role: 'admin', department: '系统管理部' },
|
||||
];
|
||||
|
||||
export const DEFAULT_MOCK_USER = MOCK_USERS[0];
|
||||
export const DEFAULT_MOCK_USER = MOCK_USERS[1];
|
||||
|
||||
const TAG_MAINTAINER_ROLES: UserRole[] = ['legal', 'finance', 'businessAdmin', 'admin'];
|
||||
|
||||
export function roleLabel(role: UserRole): string {
|
||||
return ROLE_LABELS[role];
|
||||
}
|
||||
|
||||
export function canViewRiskTags(_user: MockUser): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function canManageRiskTags(user: MockUser): boolean {
|
||||
return ['finance', 'legal', 'risk', 'ops', 'admin'].includes(user.role);
|
||||
return TAG_MAINTAINER_ROLES.includes(user.role);
|
||||
}
|
||||
|
||||
export function canAddRiskTags(user: MockUser): boolean {
|
||||
if (user.role === 'sales' || user.role === 'ops') return false;
|
||||
if (user.role === 'riskControl' || user.role === 'manager') return false;
|
||||
return TAG_MAINTAINER_ROLES.includes(user.role);
|
||||
}
|
||||
|
||||
export function canBatchTag(user: MockUser): boolean {
|
||||
return canManageRiskTags(user);
|
||||
return canAddRiskTags(user);
|
||||
}
|
||||
|
||||
/** 分区维护权限:信用与回款/开票异常→财务;合规与法律→法务;经营与履约/多头签约→业管 */
|
||||
export function canMaintainCategory(user: MockUser, category: TagCategoryCode): boolean {
|
||||
if (user.role === 'admin') return true;
|
||||
if (user.role === 'legal' && category === 'legal') return true;
|
||||
if (user.role === 'finance' && category === 'finance') return true;
|
||||
if (user.role === 'businessAdmin' && category === 'business') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canApproveRiskTag(user: MockUser): boolean {
|
||||
return ['legal', 'risk', 'admin'].includes(user.role);
|
||||
return ['legal', 'riskControl', 'manager', 'admin'].includes(user.role);
|
||||
}
|
||||
|
||||
export function canRemoveRiskTag(user: MockUser, tagType: RiskTagCategory): boolean {
|
||||
if (user.role === 'admin' || user.role === 'risk') return true;
|
||||
if (user.role === 'legal' && tagType === '法务') return true;
|
||||
if (user.role === 'finance' && tagType === '财务') return true;
|
||||
if (user.role === 'ops' && tagType === '运维') return true;
|
||||
return false;
|
||||
export function canRemoveRiskTag(user: MockUser, category: TagCategoryCode): boolean {
|
||||
return canMaintainCategory(user, category);
|
||||
}
|
||||
|
||||
/** 当前角色对应的标签类型(打标表单自动带出) */
|
||||
export function roleToTagCategory(user: MockUser): RiskTagCategory {
|
||||
const map: Record<UserRole, RiskTagCategory> = {
|
||||
finance: '财务',
|
||||
legal: '法务',
|
||||
ops: '运维',
|
||||
risk: '风控',
|
||||
sales: '风控',
|
||||
admin: '风控',
|
||||
};
|
||||
return map[user.role];
|
||||
}
|
||||
|
||||
/** 是否可选择该标签(按标签业务类型与角色匹配) */
|
||||
export function canApplyRiskTag(user: MockUser, tag: TagCatalogItem): boolean {
|
||||
if (user.role === 'admin' || user.role === 'risk') return true;
|
||||
if (user.role === 'legal' && tag.tagType === '法务') return true;
|
||||
if (user.role === 'finance' && tag.tagType === '财务') return true;
|
||||
if (user.role === 'ops' && tag.tagType === '运维') return true;
|
||||
return false;
|
||||
if (!canAddRiskTags(user)) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
return tag.editableRoles.includes(user.role);
|
||||
}
|
||||
|
||||
export function canApplyRiskTagByName(user: MockUser, tagName: string): boolean {
|
||||
@@ -60,14 +68,24 @@ export function canApplyRiskTagByName(user: MockUser, tagName: string): boolean
|
||||
return canApplyRiskTag(user, tag);
|
||||
}
|
||||
|
||||
export function roleLabel(role: UserRole): string {
|
||||
const map: Record<UserRole, string> = {
|
||||
finance: '财务',
|
||||
legal: '法务',
|
||||
risk: '风控',
|
||||
ops: '运维',
|
||||
sales: '销售',
|
||||
admin: '管理员',
|
||||
};
|
||||
return map[role];
|
||||
export function roleToTagCategory(user: MockUser): TagCategoryCode {
|
||||
return ROLE_TO_DEFAULT_CATEGORY[user.role] ?? 'legal';
|
||||
}
|
||||
|
||||
export function canViewFinanceSensitive(user: MockUser): boolean {
|
||||
return ['finance', 'manager', 'admin'].includes(user.role);
|
||||
}
|
||||
|
||||
export function canViewLegalAttachments(user: MockUser): boolean {
|
||||
return ['legal', 'riskControl', 'manager', 'admin'].includes(user.role);
|
||||
}
|
||||
|
||||
export function selectableCategoriesForRole(user: MockUser): TagCategoryCode[] {
|
||||
if (user.role === 'admin') return ['legal', 'finance', 'business'];
|
||||
const cat = ROLE_TO_DEFAULT_CATEGORY[user.role];
|
||||
return cat ? [cat] : [];
|
||||
}
|
||||
|
||||
export function getUserByRole(role: UserRole): MockUser {
|
||||
return MOCK_USERS.find((item) => item.role === role) ?? DEFAULT_MOCK_USER;
|
||||
}
|
||||
|
||||
43
src/prototypes/customer-management/utils/riskKpi.ts
Normal file
43
src/prototypes/customer-management/utils/riskKpi.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { CustomerRecord, CustomerRiskKpiKey } from '../types';
|
||||
import type { CustomerRiskTag } from '../types/riskTag';
|
||||
|
||||
function visibleTags(tags: CustomerRiskTag[]): CustomerRiskTag[] {
|
||||
return tags.filter((tag) => tag.status === 'active' || tag.status === 'pending');
|
||||
}
|
||||
|
||||
export function matchesRiskKpi(
|
||||
record: CustomerRecord,
|
||||
tags: CustomerRiskTag[],
|
||||
kpi: CustomerRiskKpiKey,
|
||||
): boolean {
|
||||
if (kpi === 'all') return true;
|
||||
|
||||
const activeTags = visibleTags(tags);
|
||||
if (kpi === 'withRisk') return activeTags.length > 0;
|
||||
if (kpi === 'finance') return activeTags.some((tag) => tag.tagCategory === 'finance');
|
||||
if (kpi === 'legal') return activeTags.some((tag) => tag.tagCategory === 'legal');
|
||||
if (kpi === 'business') return activeTags.some((tag) => tag.tagCategory === 'business');
|
||||
return true;
|
||||
}
|
||||
|
||||
export function countRiskKpi(
|
||||
records: CustomerRecord[],
|
||||
riskTagsByCustomer: Record<string, CustomerRiskTag[]>,
|
||||
kpi: CustomerRiskKpiKey,
|
||||
): number {
|
||||
return records.filter((record) =>
|
||||
matchesRiskKpi(record, riskTagsByCustomer[record.id] ?? [], kpi),
|
||||
).length;
|
||||
}
|
||||
|
||||
export const RISK_KPI_CARDS: Array<{
|
||||
key: CustomerRiskKpiKey;
|
||||
title: string;
|
||||
desc: string;
|
||||
}> = [
|
||||
{ key: 'all', title: '全部客户', desc: '当前台账内全部客户数量' },
|
||||
{ key: 'withRisk', title: '有风险客户', desc: '存在生效中或待审批风险标签的客户' },
|
||||
{ key: 'finance', title: '财务风险', desc: '含信用与回款、开票异常等财务类标签(财务部维护)' },
|
||||
{ key: 'legal', title: '法务风险', desc: '含合规与法律类标签(法务部维护)' },
|
||||
{ key: 'business', title: '业管风险', desc: '含经营与履约、多头签约等业管类标签(业管部维护)' },
|
||||
];
|
||||
@@ -1,37 +1,273 @@
|
||||
import type { RiskLevel, RiskTagCategory, UserRole } from '../types/riskTag';
|
||||
import type {
|
||||
RiskLevelCode,
|
||||
SensitiveLevel,
|
||||
TagCategoryCode,
|
||||
UserRole,
|
||||
} from '../types/riskTag';
|
||||
|
||||
/** 业务分组(与规范文档四张表一致) */
|
||||
export type TagGroupCode = 'credit' | 'compliance' | 'operation' | 'process';
|
||||
|
||||
export interface TagCatalogItem {
|
||||
tagCode: string;
|
||||
tagName: string;
|
||||
tagType: RiskTagCategory;
|
||||
tagGroup: TagGroupCode;
|
||||
groupName: string;
|
||||
defaultLevel: RiskLevel;
|
||||
tagCategory: TagCategoryCode;
|
||||
tagCategoryName: string;
|
||||
responsibleDept: string;
|
||||
editableRoles: UserRole[];
|
||||
defaultLevel: RiskLevelCode;
|
||||
requiresApproval: boolean;
|
||||
triggersBlacklist: boolean;
|
||||
reviewCycle: string;
|
||||
sensitiveLevel: SensitiveLevel;
|
||||
}
|
||||
|
||||
/** 标签目录 — 分组与级别对齐业务规范 */
|
||||
export const TAG_CATALOG: TagCatalogItem[] = [
|
||||
// 信用与回款
|
||||
{ tagName: '逾期预警', tagType: '财务', groupName: '信用与回款', defaultLevel: '中', requiresApproval: false },
|
||||
{ tagName: '严重拖欠', tagType: '财务', groupName: '信用与回款', defaultLevel: '高', requiresApproval: true },
|
||||
{ tagName: '频繁改账期', tagType: '财务', groupName: '信用与回款', defaultLevel: '低', requiresApproval: false },
|
||||
// 合规与法律
|
||||
{ tagName: '资质存疑', tagType: '法务', groupName: '合规与法律', defaultLevel: '中', requiresApproval: false },
|
||||
{ tagName: '涉诉/仲裁', tagType: '法务', groupName: '合规与法律', defaultLevel: '高', requiresApproval: true },
|
||||
{ tagName: '制裁/高风险地区', tagType: '法务', groupName: '合规与法律', defaultLevel: '高', requiresApproval: true },
|
||||
// 经营与履约
|
||||
{ tagName: '频繁争议', tagType: '法务', groupName: '经营与履约', defaultLevel: '中', requiresApproval: false },
|
||||
{ tagName: '车辆/资产高风险', tagType: '运维', groupName: '经营与履约', defaultLevel: '高', requiresApproval: true },
|
||||
{ tagName: '信息不实', tagType: '风控', groupName: '经营与履约', defaultLevel: '中', requiresApproval: false },
|
||||
// 操作与流程
|
||||
{ tagName: '开票异常', tagType: '财务', groupName: '操作与流程', defaultLevel: '低', requiresApproval: false },
|
||||
{ tagName: '多头签约', tagType: '风控', groupName: '操作与流程', defaultLevel: '低', requiresApproval: false },
|
||||
export const TAG_GROUP_LABELS: Record<TagGroupCode, string> = {
|
||||
credit: '信用与回款',
|
||||
compliance: '合规与法律',
|
||||
operation: '经营与履约',
|
||||
process: '操作与流程',
|
||||
};
|
||||
|
||||
export const TAG_CATEGORY_META: Record<
|
||||
TagCategoryCode,
|
||||
{ name: string; dept: string; description: string; order: number }
|
||||
> = {
|
||||
legal: {
|
||||
name: '法务风险',
|
||||
dept: '法务部',
|
||||
description: '由法务维护,关注资质、涉诉、制裁、合规风险(合规与法律)',
|
||||
order: 1,
|
||||
},
|
||||
finance: {
|
||||
name: '财务风险',
|
||||
dept: '财务部',
|
||||
description: '由财务维护,关注信用与回款、开票异常等财务类风险',
|
||||
order: 2,
|
||||
},
|
||||
business: {
|
||||
name: '业管风险',
|
||||
dept: '业管部',
|
||||
description: '由业管维护,关注经营履约、车辆/资产、信息核实及多头签约',
|
||||
order: 3,
|
||||
},
|
||||
};
|
||||
|
||||
export const RISK_LEVEL_LABELS: Record<RiskLevelCode, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
fatal: '致命',
|
||||
};
|
||||
|
||||
export const TAG_STATUS_LABELS: Record<
|
||||
import('../types/riskTag').TagStatusCode,
|
||||
string
|
||||
> = {
|
||||
active: '生效中',
|
||||
pending: '待审批',
|
||||
expired: '已失效',
|
||||
removed: '已解除',
|
||||
rejected: '已驳回',
|
||||
};
|
||||
|
||||
export const SOURCE_LABELS = {
|
||||
system: '系统生成',
|
||||
manual: '人工维护',
|
||||
} as const;
|
||||
|
||||
export const REVIEW_CYCLE_OPTIONS: import('../types/riskTag').ReviewCycleOption[] = [
|
||||
'1个月',
|
||||
'2个月',
|
||||
'3个月',
|
||||
'6个月',
|
||||
];
|
||||
|
||||
export const TAG_CATALOG_MAP = Object.fromEntries(
|
||||
TAG_CATALOG.map((item) => [item.tagName, item]),
|
||||
) as Record<string, TagCatalogItem>;
|
||||
export const REVIEW_CONCLUSION_LABELS: Record<
|
||||
import('../types/riskTag').ReviewConclusionCode,
|
||||
string
|
||||
> = {
|
||||
riskExists: '风险仍存在',
|
||||
riskResolved: '风险已解除',
|
||||
pendingConfirmation: '待进一步确认',
|
||||
};
|
||||
|
||||
/** 下拉分组展示顺序 */
|
||||
/** 标签目录 — 仅规范文档定义的 11 项 */
|
||||
export const TAG_CATALOG: TagCatalogItem[] = [
|
||||
// 信用与回款 → 财务
|
||||
{
|
||||
tagCode: 'CR-01',
|
||||
tagName: '逾期预警',
|
||||
tagGroup: 'credit',
|
||||
groupName: '信用与回款',
|
||||
tagCategory: 'finance',
|
||||
tagCategoryName: '财务风险',
|
||||
responsibleDept: '财务部',
|
||||
editableRoles: ['finance', 'admin'],
|
||||
defaultLevel: 'medium',
|
||||
requiresApproval: false,
|
||||
triggersBlacklist: false,
|
||||
reviewCycle: '1个月',
|
||||
sensitiveLevel: 'financeSensitive',
|
||||
},
|
||||
{
|
||||
tagCode: 'CR-02',
|
||||
tagName: '严重拖欠',
|
||||
tagGroup: 'credit',
|
||||
groupName: '信用与回款',
|
||||
tagCategory: 'finance',
|
||||
tagCategoryName: '财务风险',
|
||||
responsibleDept: '财务部',
|
||||
editableRoles: ['finance', 'admin'],
|
||||
defaultLevel: 'high',
|
||||
requiresApproval: true,
|
||||
triggersBlacklist: true,
|
||||
reviewCycle: '1个月',
|
||||
sensitiveLevel: 'financeSensitive',
|
||||
},
|
||||
{
|
||||
tagCode: 'CR-03',
|
||||
tagName: '频繁改账期',
|
||||
tagGroup: 'credit',
|
||||
groupName: '信用与回款',
|
||||
tagCategory: 'finance',
|
||||
tagCategoryName: '财务风险',
|
||||
responsibleDept: '财务部',
|
||||
editableRoles: ['finance', 'admin'],
|
||||
defaultLevel: 'low',
|
||||
requiresApproval: false,
|
||||
triggersBlacklist: false,
|
||||
reviewCycle: '1个月',
|
||||
sensitiveLevel: 'financeSensitive',
|
||||
},
|
||||
// 合规与法律 → 法务
|
||||
{
|
||||
tagCode: 'CP-01',
|
||||
tagName: '资质存疑',
|
||||
tagGroup: 'compliance',
|
||||
groupName: '合规与法律',
|
||||
tagCategory: 'legal',
|
||||
tagCategoryName: '法务风险',
|
||||
responsibleDept: '法务部',
|
||||
editableRoles: ['legal', 'admin'],
|
||||
defaultLevel: 'medium',
|
||||
requiresApproval: false,
|
||||
triggersBlacklist: false,
|
||||
reviewCycle: '3个月',
|
||||
sensitiveLevel: 'normal',
|
||||
},
|
||||
{
|
||||
tagCode: 'CP-02',
|
||||
tagName: '涉诉/仲裁',
|
||||
tagGroup: 'compliance',
|
||||
groupName: '合规与法律',
|
||||
tagCategory: 'legal',
|
||||
tagCategoryName: '法务风险',
|
||||
responsibleDept: '法务部',
|
||||
editableRoles: ['legal', 'admin'],
|
||||
defaultLevel: 'high',
|
||||
requiresApproval: true,
|
||||
triggersBlacklist: true,
|
||||
reviewCycle: '3个月',
|
||||
sensitiveLevel: 'legalSensitive',
|
||||
},
|
||||
{
|
||||
tagCode: 'CP-03',
|
||||
tagName: '制裁/高风险地区',
|
||||
tagGroup: 'compliance',
|
||||
groupName: '合规与法律',
|
||||
tagCategory: 'legal',
|
||||
tagCategoryName: '法务风险',
|
||||
responsibleDept: '法务部',
|
||||
editableRoles: ['legal', 'admin'],
|
||||
defaultLevel: 'high',
|
||||
requiresApproval: true,
|
||||
triggersBlacklist: true,
|
||||
reviewCycle: '3个月',
|
||||
sensitiveLevel: 'legalSensitive',
|
||||
},
|
||||
// 经营与履约 → 业管
|
||||
{
|
||||
tagCode: 'OP-01',
|
||||
tagName: '频繁争议',
|
||||
tagGroup: 'operation',
|
||||
groupName: '经营与履约',
|
||||
tagCategory: 'business',
|
||||
tagCategoryName: '业管风险',
|
||||
responsibleDept: '业管部',
|
||||
editableRoles: ['businessAdmin', 'admin'],
|
||||
defaultLevel: 'medium',
|
||||
requiresApproval: false,
|
||||
triggersBlacklist: false,
|
||||
reviewCycle: '6个月',
|
||||
sensitiveLevel: 'normal',
|
||||
},
|
||||
{
|
||||
tagCode: 'OP-02',
|
||||
tagName: '车辆/资产高风险',
|
||||
tagGroup: 'operation',
|
||||
groupName: '经营与履约',
|
||||
tagCategory: 'business',
|
||||
tagCategoryName: '业管风险',
|
||||
responsibleDept: '业管部',
|
||||
editableRoles: ['businessAdmin', 'admin'],
|
||||
defaultLevel: 'high',
|
||||
requiresApproval: true,
|
||||
triggersBlacklist: false,
|
||||
reviewCycle: '6个月',
|
||||
sensitiveLevel: 'normal',
|
||||
},
|
||||
{
|
||||
tagCode: 'OP-03',
|
||||
tagName: '信息不实',
|
||||
tagGroup: 'operation',
|
||||
groupName: '经营与履约',
|
||||
tagCategory: 'business',
|
||||
tagCategoryName: '业管风险',
|
||||
responsibleDept: '业管部',
|
||||
editableRoles: ['businessAdmin', 'admin'],
|
||||
defaultLevel: 'medium',
|
||||
requiresApproval: false,
|
||||
triggersBlacklist: false,
|
||||
reviewCycle: '6个月',
|
||||
sensitiveLevel: 'normal',
|
||||
},
|
||||
// 操作与流程 → 开票异常归财务,多头签约归业管
|
||||
{
|
||||
tagCode: 'PR-01',
|
||||
tagName: '开票异常',
|
||||
tagGroup: 'process',
|
||||
groupName: '操作与流程',
|
||||
tagCategory: 'finance',
|
||||
tagCategoryName: '财务风险',
|
||||
responsibleDept: '财务部',
|
||||
editableRoles: ['finance', 'admin'],
|
||||
defaultLevel: 'low',
|
||||
requiresApproval: false,
|
||||
triggersBlacklist: false,
|
||||
reviewCycle: '1个月',
|
||||
sensitiveLevel: 'financeSensitive',
|
||||
},
|
||||
{
|
||||
tagCode: 'PR-02',
|
||||
tagName: '多头签约',
|
||||
tagGroup: 'process',
|
||||
groupName: '操作与流程',
|
||||
tagCategory: 'business',
|
||||
tagCategoryName: '业管风险',
|
||||
responsibleDept: '业管部',
|
||||
editableRoles: ['businessAdmin', 'admin'],
|
||||
defaultLevel: 'low',
|
||||
requiresApproval: false,
|
||||
triggersBlacklist: false,
|
||||
reviewCycle: '6个月',
|
||||
sensitiveLevel: 'normal',
|
||||
},
|
||||
];
|
||||
|
||||
/** 表单下拉分组(按规范文档顺序) */
|
||||
export const TAG_GROUPS: Array<{ groupName: string; tagNames: string[] }> = [
|
||||
{ groupName: '信用与回款', tagNames: ['逾期预警', '严重拖欠', '频繁改账期'] },
|
||||
{ groupName: '合规与法律', tagNames: ['资质存疑', '涉诉/仲裁', '制裁/高风险地区'] },
|
||||
@@ -39,17 +275,45 @@ export const TAG_GROUPS: Array<{ groupName: string; tagNames: string[] }> = [
|
||||
{ groupName: '操作与流程', tagNames: ['开票异常', '多头签约'] },
|
||||
];
|
||||
|
||||
export const RISK_TAG_CATEGORIES: RiskTagCategory[] = ['法务', '财务', '运维', '风控'];
|
||||
export const LEGACY_TAG_ALIASES: Record<string, string> = {};
|
||||
|
||||
export const RISK_LEVELS: RiskLevel[] = ['低', '中', '高', '极高'];
|
||||
export const TAG_CATALOG_MAP = Object.fromEntries(
|
||||
TAG_CATALOG.map((item) => [item.tagName, item]),
|
||||
) as Record<string, TagCatalogItem>;
|
||||
|
||||
export const HIGH_RISK_LEVELS: RiskLevel[] = ['高', '极高'];
|
||||
export const TAG_CATALOG_BY_CODE = Object.fromEntries(
|
||||
TAG_CATALOG.map((item) => [item.tagCode, item]),
|
||||
) as Record<string, TagCatalogItem>;
|
||||
|
||||
export const TAG_CATEGORY_ORDER: TagCategoryCode[] = ['legal', 'finance', 'business'];
|
||||
|
||||
export const HIGH_RISK_LEVELS: RiskLevelCode[] = ['high', 'fatal'];
|
||||
|
||||
export const ROLE_LABELS: Record<UserRole, string> = {
|
||||
finance: '财务',
|
||||
legal: '法务',
|
||||
risk: '风控',
|
||||
ops: '运维',
|
||||
sales: '销售',
|
||||
admin: '管理员',
|
||||
legal: '法务',
|
||||
finance: '财务',
|
||||
businessAdmin: '业管',
|
||||
ops: '运维/资产',
|
||||
riskControl: '风控',
|
||||
manager: '管理层',
|
||||
admin: '系统管理员',
|
||||
};
|
||||
|
||||
export const ROLE_TO_DEFAULT_CATEGORY: Partial<Record<UserRole, TagCategoryCode>> = {
|
||||
legal: 'legal',
|
||||
finance: 'finance',
|
||||
businessAdmin: 'business',
|
||||
};
|
||||
|
||||
export function tagsForCategory(category: TagCategoryCode): TagCatalogItem[] {
|
||||
return TAG_CATALOG.filter((item) => item.tagCategory === category);
|
||||
}
|
||||
|
||||
export function tagsForRole(userRole: UserRole): TagCatalogItem[] {
|
||||
if (userRole === 'admin') return TAG_CATALOG;
|
||||
if (userRole === 'legal') return tagsForCategory('legal');
|
||||
if (userRole === 'finance') return tagsForCategory('finance');
|
||||
if (userRole === 'businessAdmin') return tagsForCategory('business');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -2,12 +2,26 @@ import type { CustomerRecord } from '../types';
|
||||
import type {
|
||||
CustomerRiskTag,
|
||||
MockUser,
|
||||
ReviewConclusionCode,
|
||||
ReviewCycleOption,
|
||||
ReviewStatusCode,
|
||||
RiskLevelCode,
|
||||
RiskTagApplyForm,
|
||||
RiskTagOperationLog,
|
||||
RiskTagReviewForm,
|
||||
RiskTagsByCustomer,
|
||||
RiskTagLifecycleStatus,
|
||||
TagCategoryCode,
|
||||
TagStatusCode,
|
||||
} from '../types/riskTag';
|
||||
import { HIGH_RISK_LEVELS, TAG_CATALOG_MAP } from './riskTagConstants';
|
||||
import {
|
||||
HIGH_RISK_LEVELS,
|
||||
LEGACY_TAG_ALIASES,
|
||||
REVIEW_CONCLUSION_LABELS,
|
||||
RISK_LEVEL_LABELS,
|
||||
SOURCE_LABELS,
|
||||
TAG_CATALOG_MAP,
|
||||
TAG_STATUS_LABELS,
|
||||
} from './riskTagConstants';
|
||||
import seedOperationLogs from '../data/risk-tag-operation-logs.json';
|
||||
|
||||
let idCounter = 1000;
|
||||
@@ -27,14 +41,190 @@ export function todayDate(): string {
|
||||
return formatNow().slice(0, 10);
|
||||
}
|
||||
|
||||
export function defaultEffectiveTo(): string {
|
||||
const d = new Date();
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
function addMonths(dateStr: string, months: number): string {
|
||||
const d = new Date(dateStr);
|
||||
d.setMonth(d.getMonth() + months);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function resolveInitialStatus(riskLevel: CustomerRiskTag['riskLevel']): RiskTagLifecycleStatus {
|
||||
return HIGH_RISK_LEVELS.includes(riskLevel) ? '待审批' : '生效中';
|
||||
export function parseReviewCycleMonths(cycle: string): number {
|
||||
const matched = cycle.match(/^(\d+)/);
|
||||
return matched ? Number(matched[1]) : 3;
|
||||
}
|
||||
|
||||
export function nextReviewDateFromCycle(fromDate: string, cycle: ReviewCycleOption | string): string {
|
||||
return addMonths(fromDate, parseReviewCycleMonths(cycle));
|
||||
}
|
||||
|
||||
export function isLegalRiskTag(tag: CustomerRiskTag): boolean {
|
||||
return tag.tagCategory === 'legal';
|
||||
}
|
||||
|
||||
export function getTaggedAt(tag: CustomerRiskTag): string {
|
||||
return tag.taggedAt || tag.effectiveDate;
|
||||
}
|
||||
|
||||
export function getNextReviewAt(tag: CustomerRiskTag): string {
|
||||
if (isLegalRiskTag(tag)) return tag.nextReviewAt || tag.expireDate;
|
||||
return tag.expireDate;
|
||||
}
|
||||
|
||||
export function computeReviewStatus(tag: CustomerRiskTag): ReviewStatusCode {
|
||||
if (!isLegalRiskTag(tag) || tag.status !== 'active') return 'normal';
|
||||
const nextReview = getNextReviewAt(tag);
|
||||
if (!nextReview) return 'normal';
|
||||
const today = todayDate();
|
||||
if (nextReview < today) return 'overdue';
|
||||
const diffMs = new Date(nextReview).getTime() - new Date(today).getTime();
|
||||
if (diffMs <= 7 * 24 * 60 * 60 * 1000) return 'dueSoon';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
export function normalizeRiskTag(tag: CustomerRiskTag): CustomerRiskTag {
|
||||
const taggedAt = getTaggedAt(tag);
|
||||
const isLegal = isLegalRiskTag(tag);
|
||||
const reviewCycle = (tag.reviewCycle || '3个月') as ReviewCycleOption;
|
||||
const nextReviewAt = tag.nextReviewAt
|
||||
|| (isLegal ? tag.expireDate : '')
|
||||
|| (isLegal ? nextReviewDateFromCycle(taggedAt, reviewCycle) : '');
|
||||
const normalized: CustomerRiskTag = {
|
||||
...tag,
|
||||
taggedAt,
|
||||
effectiveDate: taggedAt,
|
||||
reviewCycle,
|
||||
nextReviewAt: isLegal ? nextReviewAt : tag.nextReviewAt ?? '',
|
||||
expireDate: isLegal ? nextReviewAt : tag.expireDate,
|
||||
reviewLogs: tag.reviewLogs ?? [],
|
||||
};
|
||||
return {
|
||||
...normalized,
|
||||
reviewStatus: computeReviewStatus(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultExpireDate(category: TagCategoryCode, fromDate = todayDate()): string {
|
||||
if (category === 'legal') return nextReviewDateFromCycle(fromDate, '3个月');
|
||||
if (category === 'finance') return addMonths(fromDate, 1);
|
||||
return addMonths(fromDate, 12);
|
||||
}
|
||||
|
||||
export function defaultNextReviewDate(
|
||||
category: TagCategoryCode,
|
||||
cycle: ReviewCycleOption | string,
|
||||
fromDate = todayDate(),
|
||||
): string {
|
||||
if (category === 'legal') return nextReviewDateFromCycle(fromDate, cycle);
|
||||
return defaultExpireDate(category, fromDate);
|
||||
}
|
||||
|
||||
export function defaultEffectiveTo(): string {
|
||||
return defaultExpireDate('business');
|
||||
}
|
||||
|
||||
export function resolveInitialStatus(riskLevel: RiskLevelCode): TagStatusCode {
|
||||
return HIGH_RISK_LEVELS.includes(riskLevel) ? 'pending' : 'active';
|
||||
}
|
||||
|
||||
export function resolveStatusName(status: TagStatusCode): string {
|
||||
return TAG_STATUS_LABELS[status];
|
||||
}
|
||||
|
||||
export function resolveRiskLevelName(level: RiskLevelCode): string {
|
||||
return RISK_LEVEL_LABELS[level];
|
||||
}
|
||||
|
||||
export function isHighRiskLevel(level: RiskLevelCode): boolean {
|
||||
return HIGH_RISK_LEVELS.includes(level);
|
||||
}
|
||||
|
||||
/** @deprecated 法务标签请使用 isLegalReviewDueSoon */
|
||||
export function isExpiringSoon(tag: CustomerRiskTag, withinDays = 7): boolean {
|
||||
return isLegalReviewDueSoon(tag, withinDays);
|
||||
}
|
||||
|
||||
export function isLegalReviewDueSoon(tag: CustomerRiskTag, withinDays = 7): boolean {
|
||||
if (tag.status !== 'active' || !isLegalRiskTag(tag)) return false;
|
||||
const today = todayDate();
|
||||
const nextReview = getNextReviewAt(tag);
|
||||
if (!nextReview || nextReview <= today) return false;
|
||||
const diffMs = new Date(nextReview).getTime() - new Date(today).getTime();
|
||||
return diffMs <= withinDays * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export function isLegalReviewOverdue(tag: CustomerRiskTag): boolean {
|
||||
if (!isLegalRiskTag(tag) || tag.status !== 'active') return false;
|
||||
const nextReview = getNextReviewAt(tag);
|
||||
return Boolean(nextReview && nextReview < todayDate());
|
||||
}
|
||||
|
||||
/** @deprecated 法务标签请使用 isLegalReviewOverdue */
|
||||
export function isReviewOverdue(tag: CustomerRiskTag): boolean {
|
||||
return isLegalReviewOverdue(tag);
|
||||
}
|
||||
|
||||
export function getDisplayStatusName(tag: CustomerRiskTag): string {
|
||||
const normalized = normalizeRiskTag(tag);
|
||||
if (isLegalRiskTag(normalized) && normalized.reviewStatus === 'overdue') return '待复核';
|
||||
if (normalized.removalRequested && normalized.status === 'active') return '解除待审批';
|
||||
return normalized.statusName;
|
||||
}
|
||||
|
||||
export function buildTagFromCatalog(
|
||||
customerId: string,
|
||||
tagName: string,
|
||||
partial?: Partial<CustomerRiskTag>,
|
||||
): CustomerRiskTag | null {
|
||||
const resolvedName = LEGACY_TAG_ALIASES[tagName] ?? tagName;
|
||||
const catalog = TAG_CATALOG_MAP[resolvedName];
|
||||
if (!catalog) return null;
|
||||
|
||||
const createdAt = partial?.createdAt ?? formatNow();
|
||||
const taggedAt = partial?.taggedAt ?? partial?.effectiveDate ?? createdAt.slice(0, 10);
|
||||
const reviewCycle = (partial?.reviewCycle ?? catalog.reviewCycle) as ReviewCycleOption;
|
||||
const isLegal = catalog.tagCategory === 'legal';
|
||||
const nextReviewAt = partial?.nextReviewAt
|
||||
?? (isLegal ? partial?.expireDate : undefined)
|
||||
?? (isLegal ? nextReviewDateFromCycle(taggedAt, reviewCycle) : '');
|
||||
const expireDate = partial?.expireDate
|
||||
?? (isLegal ? nextReviewAt : defaultExpireDate(catalog.tagCategory, taggedAt));
|
||||
const status = partial?.status ?? 'active';
|
||||
const riskLevel = partial?.riskLevel ?? catalog.defaultLevel;
|
||||
|
||||
return normalizeRiskTag({
|
||||
id: partial?.id ?? nextId('tag'),
|
||||
customerId,
|
||||
tagCode: catalog.tagCode,
|
||||
tagName: catalog.tagName,
|
||||
tagCategory: catalog.tagCategory,
|
||||
tagCategoryName: catalog.tagCategoryName,
|
||||
responsibleDept: catalog.responsibleDept,
|
||||
editableRoles: catalog.editableRoles,
|
||||
riskLevel,
|
||||
riskLevelName: resolveRiskLevelName(riskLevel),
|
||||
status,
|
||||
statusName: resolveStatusName(status),
|
||||
source: partial?.source ?? 'system',
|
||||
sourceName: partial?.source === 'manual' ? SOURCE_LABELS.manual : SOURCE_LABELS.system,
|
||||
reason: partial?.reason ?? '历史数据迁移:列表既有标签自动建档',
|
||||
taggedAt,
|
||||
effectiveDate: taggedAt,
|
||||
expireDate,
|
||||
nextReviewAt: isLegal ? nextReviewAt : '',
|
||||
reviewCycle,
|
||||
lastReviewedAt: partial?.lastReviewedAt,
|
||||
reviewLogs: partial?.reviewLogs ?? [],
|
||||
createdBy: partial?.createdBy ?? '系统',
|
||||
createdAt,
|
||||
approvedBy: partial?.approvedBy,
|
||||
approvedAt: partial?.approvedAt,
|
||||
attachments: partial?.attachments ?? [],
|
||||
sensitiveLevel: catalog.sensitiveLevel,
|
||||
financeDetail: partial?.financeDetail,
|
||||
rejectReason: partial?.rejectReason,
|
||||
removalRequested: partial?.removalRequested,
|
||||
removedAt: partial?.removedAt,
|
||||
removeReason: partial?.removeReason,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSeedRiskTags(records: CustomerRecord[]): RiskTagsByCustomer {
|
||||
@@ -43,28 +233,37 @@ export function buildSeedRiskTags(records: CustomerRecord[]): RiskTagsByCustomer
|
||||
records.forEach((record) => {
|
||||
if (!record.labels.length) return;
|
||||
|
||||
map[record.id] = record.labels.map((tagName, index) => {
|
||||
const catalog = TAG_CATALOG_MAP[tagName];
|
||||
const riskLevel = catalog?.defaultLevel ?? '中';
|
||||
const createdAt = record.createTime;
|
||||
|
||||
return {
|
||||
map[record.id] = record.labels
|
||||
.map((tagName, index) =>
|
||||
buildTagFromCatalog(record.id, tagName, {
|
||||
id: `seed-${record.id}-${index}`,
|
||||
customerId: record.id,
|
||||
tagName,
|
||||
tagType: catalog?.tagType ?? '风控',
|
||||
riskLevel,
|
||||
reason: '历史数据迁移:列表既有标签自动建档',
|
||||
effectiveFrom: createdAt.slice(0, 10),
|
||||
effectiveTo: defaultEffectiveTo(),
|
||||
attachments: [],
|
||||
status: '生效中' as const,
|
||||
operator: '系统',
|
||||
operatorRole: 'admin' as const,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
});
|
||||
createdAt: record.createTime,
|
||||
taggedAt: record.createTime.slice(0, 10),
|
||||
nextReviewAt: record.id === '4' && (tagName === '涉诉/仲裁' || tagName === '资质存疑')
|
||||
? '2024-01-01'
|
||||
: undefined,
|
||||
source: 'system',
|
||||
financeDetail:
|
||||
tagName === '严重拖欠' || tagName === '逾期预警'
|
||||
? {
|
||||
overdueAmount: tagName === '严重拖欠' ? '128.6万元' : '32.5万元',
|
||||
billNo: `AR-${record.code}-2024`,
|
||||
receivableNote: '应收明细仅财务授权角色可见',
|
||||
}
|
||||
: undefined,
|
||||
attachments:
|
||||
tagName === '涉诉/仲裁' || tagName === '制裁/高风险地区'
|
||||
? [{
|
||||
id: `att-seed-${record.id}-${index}`,
|
||||
name: '诉讼材料摘要.pdf',
|
||||
size: '256 KB',
|
||||
uploadedAt: record.createTime,
|
||||
attachmentType: 'legal',
|
||||
}]
|
||||
: [],
|
||||
}),
|
||||
)
|
||||
.filter((tag): tag is CustomerRiskTag => Boolean(tag));
|
||||
});
|
||||
|
||||
return map;
|
||||
@@ -77,17 +276,26 @@ export function syncRecordLabels(
|
||||
return records.map((record) => ({
|
||||
...record,
|
||||
labels: (riskTagsByCustomer[record.id] ?? [])
|
||||
.filter((tag) => tag.status === '生效中')
|
||||
.filter((tag) => tag.status === 'active' && !tag.removalRequested)
|
||||
.map((tag) => tag.tagName),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getActiveTags(tags: CustomerRiskTag[]): CustomerRiskTag[] {
|
||||
return tags.filter((tag) => tag.status === '生效中');
|
||||
return tags.filter((tag) => tag.status === 'active');
|
||||
}
|
||||
|
||||
export function getPendingTags(tags: CustomerRiskTag[]): CustomerRiskTag[] {
|
||||
return tags.filter((tag) => tag.status === '待审批' || tag.status === '待确认');
|
||||
return tags.filter((tag) => tag.status === 'pending');
|
||||
}
|
||||
|
||||
export function getVisibleListTags(tags: CustomerRiskTag[]): CustomerRiskTag[] {
|
||||
return tags.filter(
|
||||
(tag) =>
|
||||
tag.status === 'active'
|
||||
|| tag.status === 'pending'
|
||||
|| (tag.status === 'active' && tag.removalRequested),
|
||||
);
|
||||
}
|
||||
|
||||
export function createOperationLog(
|
||||
@@ -104,26 +312,54 @@ export function buildApplyTag(
|
||||
customerId: string,
|
||||
form: RiskTagApplyForm,
|
||||
user: MockUser,
|
||||
): CustomerRiskTag {
|
||||
const now = formatNow();
|
||||
const status = resolveInitialStatus(form.riskLevel);
|
||||
): CustomerRiskTag | null {
|
||||
const catalog = TAG_CATALOG_MAP[form.tagName];
|
||||
if (!catalog) return null;
|
||||
|
||||
return {
|
||||
const now = formatNow();
|
||||
const riskLevel = form.riskLevel || catalog.defaultLevel;
|
||||
const status = resolveInitialStatus(riskLevel);
|
||||
const taggedAt = form.taggedAt || form.effectiveDate || todayDate();
|
||||
const isLegal = catalog.tagCategory === 'legal';
|
||||
const reviewCycle = form.reviewCycle || (catalog.reviewCycle as ReviewCycleOption);
|
||||
const nextReviewAt = isLegal
|
||||
? (form.nextReviewAt || nextReviewDateFromCycle(taggedAt, reviewCycle))
|
||||
: '';
|
||||
const expireDate = isLegal
|
||||
? nextReviewAt
|
||||
: (form.expireDate || defaultExpireDate(catalog.tagCategory, taggedAt));
|
||||
|
||||
return normalizeRiskTag({
|
||||
id: nextId('tag'),
|
||||
customerId,
|
||||
tagName: form.tagName,
|
||||
tagType: form.tagType,
|
||||
riskLevel: form.riskLevel,
|
||||
reason: form.reason.trim(),
|
||||
effectiveFrom: form.effectiveFrom || todayDate(),
|
||||
effectiveTo: form.effectiveTo || defaultEffectiveTo(),
|
||||
attachments: form.attachments,
|
||||
tagCode: catalog.tagCode,
|
||||
tagName: catalog.tagName,
|
||||
tagCategory: catalog.tagCategory,
|
||||
tagCategoryName: catalog.tagCategoryName,
|
||||
responsibleDept: catalog.responsibleDept,
|
||||
editableRoles: catalog.editableRoles,
|
||||
riskLevel,
|
||||
riskLevelName: resolveRiskLevelName(riskLevel),
|
||||
status,
|
||||
operator: user.name,
|
||||
operatorRole: user.role,
|
||||
statusName: resolveStatusName(status),
|
||||
source: 'manual',
|
||||
sourceName: SOURCE_LABELS.manual,
|
||||
reason: form.reason.trim(),
|
||||
taggedAt,
|
||||
effectiveDate: taggedAt,
|
||||
expireDate,
|
||||
nextReviewAt,
|
||||
reviewCycle,
|
||||
reviewLogs: [],
|
||||
createdBy: user.name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
attachments: form.attachments,
|
||||
sensitiveLevel: catalog.sensitiveLevel,
|
||||
financeDetail:
|
||||
catalog.sensitiveLevel === 'financeSensitive'
|
||||
? { overdueAmount: '待财务录入', billNo: '—', receivableNote: '新增后由财务补充明细' }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildApplyLog(
|
||||
@@ -132,21 +368,26 @@ export function buildApplyLog(
|
||||
user: MockUser,
|
||||
batchCount?: number,
|
||||
): RiskTagOperationLog {
|
||||
const action = batchCount && batchCount > 1 ? '批量打标' : '新增';
|
||||
const approvalHint = tag.status === '待审批' ? ',已进入审批流程' : '';
|
||||
const detail = batchCount && batchCount > 1
|
||||
? `批量为 ${batchCount} 个客户提交标签「${tag.tagName}」(${tag.riskLevel})${approvalHint}`
|
||||
: `提交标签「${tag.tagName}」(${tag.tagType}/${tag.riskLevel})${approvalHint}`;
|
||||
const action = tag.status === 'pending' ? '提交审批' : batchCount && batchCount > 1 ? '批量打标' : '新增标签';
|
||||
const statusAfter = tag.statusName;
|
||||
const reason = batchCount && batchCount > 1
|
||||
? `批量为 ${batchCount} 个客户提交标签「${tag.tagName}」`
|
||||
: `提交标签「${tag.tagName}」,原因:${tag.reason}`;
|
||||
|
||||
return createOperationLog({
|
||||
customerId: tag.customerId,
|
||||
customerName,
|
||||
tagId: tag.id,
|
||||
tagName: tag.tagName,
|
||||
action: tag.status === '待审批' ? '提交审批' : action,
|
||||
detail,
|
||||
tagCategory: tag.tagCategory,
|
||||
tagCategoryName: tag.tagCategoryName,
|
||||
action,
|
||||
reason,
|
||||
statusBefore: '—',
|
||||
statusAfter,
|
||||
operator: user.name,
|
||||
operatorRole: user.role,
|
||||
detail: reason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,16 +396,22 @@ export function buildRemoveLog(
|
||||
customerName: string,
|
||||
user: MockUser,
|
||||
reason: string,
|
||||
isApplication = false,
|
||||
): RiskTagOperationLog {
|
||||
return createOperationLog({
|
||||
customerId: tag.customerId,
|
||||
customerName,
|
||||
tagId: tag.id,
|
||||
tagName: tag.tagName,
|
||||
action: '移除',
|
||||
detail: `移除标签「${tag.tagName}」,原因:${reason}`,
|
||||
tagCategory: tag.tagCategory,
|
||||
tagCategoryName: tag.tagCategoryName,
|
||||
action: isApplication ? '提交审批' : '解除标签',
|
||||
reason: isApplication ? `申请解除标签,原因:${reason}` : `解除标签,原因:${reason}`,
|
||||
statusBefore: getDisplayStatusName(tag),
|
||||
statusAfter: isApplication ? '解除待审批' : TAG_STATUS_LABELS.removed,
|
||||
operator: user.name,
|
||||
operatorRole: user.role,
|
||||
detail: reason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,42 +427,204 @@ export function buildApprovalLog(
|
||||
customerName,
|
||||
tagId: tag.id,
|
||||
tagName: tag.tagName,
|
||||
tagCategory: tag.tagCategory,
|
||||
tagCategoryName: tag.tagCategoryName,
|
||||
action: approved ? '审批通过' : '审批驳回',
|
||||
detail: approved
|
||||
? `审批通过标签「${tag.tagName}」,标签已生效`
|
||||
reason: approved
|
||||
? `审批通过标签「${tag.tagName}」`
|
||||
: `驳回标签「${tag.tagName}」${rejectReason ? `:${rejectReason}` : ''}`,
|
||||
statusBefore: TAG_STATUS_LABELS.pending,
|
||||
statusAfter: approved ? TAG_STATUS_LABELS.active : TAG_STATUS_LABELS.rejected,
|
||||
operator: user.name,
|
||||
operatorRole: user.role,
|
||||
detail: rejectReason,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasDuplicateActiveTag(
|
||||
tags: CustomerRiskTag[],
|
||||
tagName: string,
|
||||
): boolean {
|
||||
export function buildRemovalApprovalLog(
|
||||
tag: CustomerRiskTag,
|
||||
customerName: string,
|
||||
user: MockUser,
|
||||
approved: boolean,
|
||||
rejectReason?: string,
|
||||
): RiskTagOperationLog {
|
||||
return createOperationLog({
|
||||
customerId: tag.customerId,
|
||||
customerName,
|
||||
tagId: tag.id,
|
||||
tagName: tag.tagName,
|
||||
tagCategory: tag.tagCategory,
|
||||
tagCategoryName: tag.tagCategoryName,
|
||||
action: approved ? '审批通过' : '审批驳回',
|
||||
reason: approved
|
||||
? `解除申请已通过,标签「${tag.tagName}」已解除`
|
||||
: `驳回解除申请「${tag.tagName}」${rejectReason ? `:${rejectReason}` : ''}`,
|
||||
statusBefore: '解除待审批',
|
||||
statusAfter: approved ? TAG_STATUS_LABELS.removed : TAG_STATUS_LABELS.active,
|
||||
operator: user.name,
|
||||
operatorRole: user.role,
|
||||
detail: rejectReason,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildReviewLog(
|
||||
tag: CustomerRiskTag,
|
||||
customerName: string,
|
||||
user: MockUser,
|
||||
conclusion: ReviewConclusionCode,
|
||||
note: string,
|
||||
nextReviewAt?: string,
|
||||
): RiskTagOperationLog {
|
||||
const conclusionName = REVIEW_CONCLUSION_LABELS[conclusion];
|
||||
const maintain = conclusion !== 'riskResolved';
|
||||
return createOperationLog({
|
||||
customerId: tag.customerId,
|
||||
customerName,
|
||||
tagId: tag.id,
|
||||
tagName: tag.tagName,
|
||||
tagCategory: tag.tagCategory,
|
||||
tagCategoryName: tag.tagCategoryName,
|
||||
action: '到期复核',
|
||||
reason: maintain
|
||||
? `完成复核:${conclusionName}。${note}${nextReviewAt ? `;下次复核 ${nextReviewAt}` : ''}`
|
||||
: `完成复核:${conclusionName},提交解除申请。${note}`,
|
||||
statusBefore: getDisplayStatusName(tag),
|
||||
statusAfter: maintain ? '生效中' : '解除待审批',
|
||||
operator: user.name,
|
||||
operatorRole: user.role,
|
||||
detail: note,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasDuplicateActiveTag(tags: CustomerRiskTag[], tagName: string): boolean {
|
||||
return tags.some(
|
||||
(tag) => tag.tagName === tagName && (tag.status === '生效中' || tag.status === '待审批' || tag.status === '待确认'),
|
||||
(tag) =>
|
||||
tag.tagName === tagName
|
||||
&& (tag.status === 'active' || tag.status === 'pending'),
|
||||
);
|
||||
}
|
||||
|
||||
export const INITIAL_OPERATION_LOGS = seedOperationLogs as RiskTagOperationLog[];
|
||||
export const INITIAL_OPERATION_LOGS = (seedOperationLogs as RiskTagOperationLog[]).map((log) => ({
|
||||
...log,
|
||||
tagCategory: log.tagCategory ?? 'finance',
|
||||
tagCategoryName: log.tagCategoryName ?? '财务风险',
|
||||
reason: log.reason ?? log.detail ?? '',
|
||||
statusBefore: log.statusBefore ?? '—',
|
||||
statusAfter: log.statusAfter ?? '—',
|
||||
}));
|
||||
|
||||
export function catalogDefaultsForTag(tagName: string): Pick<RiskTagApplyForm, 'tagType' | 'riskLevel'> {
|
||||
export function catalogDefaultsForTag(tagName: string): Pick<RiskTagApplyForm, 'tagCategory' | 'riskLevel'> {
|
||||
const catalog = TAG_CATALOG_MAP[tagName];
|
||||
if (!catalog) {
|
||||
return { tagType: '风控', riskLevel: '中' };
|
||||
return { tagCategory: 'legal', riskLevel: 'medium' };
|
||||
}
|
||||
return { tagType: catalog.tagType, riskLevel: catalog.defaultLevel };
|
||||
return { tagCategory: catalog.tagCategory, riskLevel: catalog.defaultLevel };
|
||||
}
|
||||
|
||||
export function validateApplyForm(form: RiskTagApplyForm): string | null {
|
||||
if (!form.tagName.trim()) return '请选择标签名称';
|
||||
if (!form.tagType) return '请选择标签类型';
|
||||
if (!form.tagCategory) return '请选择标签类型';
|
||||
if (!form.riskLevel) return '请选择风险级别';
|
||||
if (!form.reason.trim()) return '请填写原因说明';
|
||||
if (form.reason.trim().length < 10) return '原因说明至少 10 个字';
|
||||
if (!form.effectiveFrom) return '请选择生效时间';
|
||||
if (!form.effectiveTo) return '请选择有效期';
|
||||
if (form.effectiveTo < form.effectiveFrom) return '有效期不能早于生效时间';
|
||||
|
||||
const taggedAt = form.taggedAt || form.effectiveDate;
|
||||
if (!taggedAt) return '请选择打标时间';
|
||||
|
||||
if (form.tagCategory === 'legal') {
|
||||
if (!form.reviewCycle) return '请选择复核周期';
|
||||
if (!form.nextReviewAt) return '请选择下次复核时间';
|
||||
if (form.nextReviewAt < taggedAt) return '下次复核时间不能早于打标时间';
|
||||
} else {
|
||||
if (!form.expireDate) return '请选择有效期至';
|
||||
if (form.expireDate < taggedAt) return '有效期不能早于打标时间';
|
||||
}
|
||||
|
||||
if (isHighRiskLevel(form.riskLevel) && form.attachments.length === 0) {
|
||||
return '高风险和致命标签必须上传附件';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateReviewForm(form: RiskTagReviewForm): string | null {
|
||||
if (!form.note.trim()) return '请填写复核说明';
|
||||
if (form.note.trim().length < 5) return '复核说明至少 5 个字';
|
||||
if (form.conclusion === 'riskResolved') {
|
||||
if (!form.removeReason.trim()) return '请填写解除原因';
|
||||
if (form.removeReason.trim().length < 5) return '解除原因至少 5 个字';
|
||||
return null;
|
||||
}
|
||||
if (!form.nextReviewAt) return '请选择下次复核时间';
|
||||
if (form.nextReviewAt < todayDate()) return '下次复核时间不能早于今天';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function legalTagsNeedReview(tags: CustomerRiskTag[]): CustomerRiskTag[] {
|
||||
return tags
|
||||
.map(normalizeRiskTag)
|
||||
.filter((tag) => isLegalRiskTag(tag) && tag.reviewStatus === 'overdue');
|
||||
}
|
||||
|
||||
/** @deprecated 请使用 legalTagsNeedReview */
|
||||
export function tagsNeedReview(tags: CustomerRiskTag[]): CustomerRiskTag[] {
|
||||
return legalTagsNeedReview(tags);
|
||||
}
|
||||
|
||||
export function groupTagsByCategory(tags: CustomerRiskTag[]): Record<TagCategoryCode, CustomerRiskTag[]> {
|
||||
const groups: Record<TagCategoryCode, CustomerRiskTag[]> = {
|
||||
legal: [],
|
||||
finance: [],
|
||||
business: [],
|
||||
};
|
||||
tags
|
||||
.map(normalizeRiskTag)
|
||||
.filter((tag) => tag.status !== 'removed' && tag.status !== 'rejected')
|
||||
.forEach((tag) => {
|
||||
if (groups[tag.tagCategory]) {
|
||||
groups[tag.tagCategory].push(tag);
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function triggersBlacklistHint(tagName: string): boolean {
|
||||
const catalog = TAG_CATALOG_MAP[tagName];
|
||||
return catalog?.triggersBlacklist ?? false;
|
||||
}
|
||||
|
||||
export function buildWorkbenchTodoLog(
|
||||
tag: CustomerRiskTag,
|
||||
customerName: string,
|
||||
): RiskTagOperationLog {
|
||||
return createOperationLog({
|
||||
customerId: tag.customerId,
|
||||
customerName,
|
||||
tagId: tag.id,
|
||||
tagName: tag.tagName,
|
||||
tagCategory: tag.tagCategory,
|
||||
tagCategoryName: tag.tagCategoryName,
|
||||
action: '工作台待办',
|
||||
reason: `已向法务工作台发起待办:请查验客户「${customerName}」的法务标签「${tag.tagName}」,查验后如需解除请在本页申请解除。`,
|
||||
statusBefore: '待复核',
|
||||
statusAfter: '待办已推送',
|
||||
operator: '系统',
|
||||
operatorRole: 'admin',
|
||||
detail: `下次复核时间 ${tag.nextReviewAt || '—'}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function createReviewLogEntry(
|
||||
user: MockUser,
|
||||
form: RiskTagReviewForm,
|
||||
): NonNullable<CustomerRiskTag['reviewLogs']>[number] {
|
||||
return {
|
||||
id: nextId('rev'),
|
||||
reviewedAt: formatNow(),
|
||||
reviewedBy: user.name,
|
||||
conclusion: form.conclusion,
|
||||
conclusionName: REVIEW_CONCLUSION_LABELS[form.conclusion],
|
||||
note: form.note.trim(),
|
||||
nextReviewAt: form.conclusion === 'riskResolved' ? undefined : form.nextReviewAt,
|
||||
attachments: form.attachments,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user