Compare commits

..

2 Commits

36 changed files with 4383 additions and 247 deletions

View File

@@ -37,6 +37,12 @@
"title": "客户管理",
"itemKey": "prototypes/customer-management"
},
{
"id": "item:prototypes:supplier-management",
"kind": "item",
"title": "供应商管理",
"itemKey": "prototypes/supplier-management"
},
{
"id": "item:prototypes:lease-contract-management",
"kind": "item",

View File

@@ -9,9 +9,11 @@
| 项 | 说明 |
|---|---|
| 模块名称 | 客户管理 — 列表页 |
| 目标用户 | 销售、业务负责人、运营、风控 |
| 核心任务 | 按多维度筛选客户台账,查看标签与状态,新增客户或进入详情/编辑 |
| 页面结构 | 面包屑 + 筛选区 + 工具栏 + 表格 + 分页 |
| 目标用户 | 销售、业务负责人、运营、**法务、财务、风控** |
| 核心任务 | 按多维度筛选客户台账,查看风险标签与状态,**在列表页维护客户风险标签**新增客户或进入详情/编辑 |
| 页面结构 | 面包屑 + **操作角色切换** + 筛选区 + 工具栏 + 表格 + 分页 |
**重要约束:** 风险标签**不在**客户新增页、客户编辑表单中维护,仅在列表页作为操作入口。
---
@@ -30,9 +32,14 @@
| 数据 | 来源 | 用途 |
|---|---|---|
| 客户台账 | `data/customers.json`20 条样例) | 列表、筛选选项 |
| 客户标签 | `CUSTOMER_LABEL_OPTIONS` 常量 | 筛选与列表展示 |
| 客户标签(展示) | 客户 `labels` 字段 | 列表「风险标签」列、筛选区「标签」多选 |
| 风险标签明细 | `riskTagsByCustomer` 运行时状态 + 种子迁移 | 标签管理抽屉、审批、日志 |
| 标签目录 | `utils/riskTagConstants.ts``TAG_CATALOG` | 打标表单选项、级别、分组 |
| 操作日志 | `data/risk-tag-operation-logs.json` + 运行时追加 | 抽屉「操作日志」Tab |
| 部门-负责人 | 由台账聚合 + `DEPT_MANAGER_MAP` | 筛选联动 |
列表 `labels` 与「生效中」风险标签自动同步,保证筛选器与列表展示一致。
---
## 4. 筛选区
@@ -43,7 +50,7 @@
|---|---|---|
| 客户编号 | 可搜索单选 | 精确匹配 `code` |
| 客户名称 | 可搜索单选 | 精确匹配 `name` |
| 标签 | 多选 | 客户 `labels` 包含任一选中标签 |
| 标签 | 多选 | 客户 `labels` 包含任一选中标签OR |
| 区域 | 可搜索单选 | 精确匹配 `region` |
| 客户状态 | 多选 | `status` 在选中集合内 |
| 业务部门 | 多选 | `department` 在选中集合内 |
@@ -54,8 +61,8 @@
| 操作 | 行为 |
|---|---|
| **查询** | 将当前筛选项写入已应用条件,页码重置为 1 |
| **重置** | 清空待应用与已应用条件,页码重置为 1 |
| **查询** | 将当前筛选项写入已应用条件,页码重置为 1,清空行选中 |
| **重置** | 清空待应用与已应用条件,页码重置为 1,清空行选中 |
| 修改筛选项未点查询 | 不影响当前列表 |
---
@@ -66,8 +73,23 @@
|---|---|
| 新增客户 | 进入新增客户页(分组表单) |
| 批量导入 | Toast批量导入原型演示 |
| **批量打标** | 勾选 ≥1 行且当前角色有打标权限时显示;打开批量打标弹窗 |
| 批量导出 | Toast导出当前列表条数 |
### 5.1 操作角色切换(原型演示)
列表顶部提供「当前操作角色」下拉,用于模拟不同部门用户:
| 角色 | 部门 | 打标权限 |
|---|---|---|
| 王风控 | 风控部 | 全部标签 |
| 李法务 | 法务部 | 法务类标签 |
| 张财务 | 财务部 | 财务类标签 |
| 赵运维 | 运维部 | 运维类标签 |
| 陈销售 | 销售一部 | 仅查看,无打标入口 |
切换角色后,风险标签管理抽屉内的**标签类型**、**可选标签**、**审批/移除按钮**随之联动更新。
---
## 6. 列表字段说明
@@ -76,21 +98,174 @@
|---|---|
| 客户编号 | 左固定列 |
| 客户名称 | 超长省略 |
| 标签 | 展示第一个标签;多于 1 个显示「+N」悬浮展示全部 |
| **风险标签** | 展示「生效中」标签;按风险级别着色(低/中/高/极高);首标签 + `+N` 悬浮全部;有待审批时显示「审」标识;可点击打开管理抽屉 |
| 客户区域 | 大区名称 |
| 客户状态 | 绿点「正常」/ 红点「黑名单」 |
| 业务部门 / 业务负责人 | 文本 |
| 客户类型 / 所属行业 | 文本 |
| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |
| 详细地址 / 创建时间 | 文本;创建时间不换行 |
| 操作 | 详情、编辑、删除(原型 Toast |
| 操作 | 详情、编辑、**管理标签**(有权限时)、删除(原型 Toast |
支持行多选checkbox横向滚动操作列右固定。
支持行多选checkbox,仅当前页);横向滚动;操作列右固定。
---
## 7. 客户标签枚举
## 7. 客户标签枚举与分级目录
逾期预警、严重拖欠、频繁改账期、资质存疑、涉诉/仲裁、制裁/高风险地区、频繁争议、车辆/资产高风险、信息不实、开票异常、多头签约
标签按业务分类维护,打标时下拉按 **optgroup 分组** 展示。风险级别由目录**自动带出且不可手改**
标签样式:白底 + 橙色描边(`#f77234`)。
### 7.1 信用与回款
| 标签名称 | 标签类型 | 风险级别 | 典型触发 |
|---|---|---|---|
| 逾期预警 | 财务 | 中 | 当前逾期或近 6 个月内曾逾期 |
| 严重拖欠 | 财务 | 高 | 逾期 > 30 天未结清或历史坏账 |
| 频繁改账期 | 财务 | 低 | 一年内申请延长账期 ≥ 3 次 |
### 7.2 合规与法律
| 标签名称 | 标签类型 | 风险级别 | 典型触发 |
|---|---|---|---|
| 资质存疑 | 法务 | 中 | 证照过期、抬头与付款主体不一致等 |
| 涉诉/仲裁 | 法务 | 高 | 与我司或第三方重大未结诉讼 |
| 制裁/高风险地区 | 法务 | 高 | 名单筛查命中(依公司合规清单) |
### 7.3 经营与履约
| 标签名称 | 标签类型 | 风险级别 | 典型触发 |
|---|---|---|---|
| 频繁争议 | 法务 | 中 | 合同或扣款争议次数超阈值 |
| 车辆/资产高风险 | 运维 | 高 | 违章、事故、疑似骗保等 |
| 信息不实 | 风控 | 中 | 联系人/主体核查不实、空壳公司等 |
### 7.4 操作与流程
| 标签名称 | 标签类型 | 风险级别 | 典型触发 |
|---|---|---|---|
| 开票异常 | 财务 | 低 | 频繁退票、抬头异常变更 |
| 多头签约 | 风控 | 低 | 同一实控人多主体分别签约 |
### 7.5 列表展示样式
- 低:灰色边框
- 中:橙色浅底
- 高:红色浅底
- 极高:深红浅底
- 单元格最大宽度受限,避免撑乱表格
---
## 8. 风险标签维护(列表页专属)
### 8.1 入口
| 入口 | 说明 |
|---|---|
| 行操作「管理标签」 | 打开右侧抽屉,管理单个客户 |
| 点击标签列 | 有权限时同「管理标签」 |
| 工具栏「批量打标」 | 对当前勾选客户批量提交同一标签 |
**不在**新增页、编辑页维护标签。
### 8.2 管理抽屉结构
| Tab | 内容 |
|---|---|
| 当前标签 | 已打标签列表:名称、类型、级别、状态、原因摘要;支持查看详情、移除、审批 |
| 新增标签 | 打标表单(见 8.3 |
| 操作日志 | 该客户全部标签相关审计记录 |
### 8.3 打标表单字段
| 字段 | 必填 | 规则 |
|---|---|---|
| 标签名称 | 是 | 按 7.17.4 分组下拉;仅当前角色有权限的标签可选;已存在同名有效标签不可重复选 |
| 标签类型 | 是 | **根据当前操作角色自动带出**(财务→财务、法务→法务、运维→运维、风控→风控),**置灰不可编辑** |
| 风险级别 | 是 | **选择标签名称后按目录自动带出****置灰不可编辑** |
| 生效时间 | 是 | 日期,默认当天 |
| 有效期至 | 是 | 日期,须 ≥ 生效时间;默认一年后 |
| 原因说明 | 是 | 至少 10 字,供法务/财务/风控审批留痕 |
| 附件 | 否 | 支持 .pdf / .jpg / .png / .xlsx可多个 |
### 8.4 批量打标
- 展示已选客户 chips超过 6 个显示 +N
- 表单字段与单客户打标一致
- 已存在同名有效标签的客户自动跳过
- 提交后写入批量操作日志
### 8.5 标签生命周期
| 状态 | 说明 |
|---|---|
| 生效中 | 参与列表展示,同步至 `labels` |
| 待审批 | 高风险标签提交后进入;列表暂不展示为生效 |
| 待确认 | 预留 |
| 已驳回 | 审批未通过,不同步至 `labels` |
| 已移除 | 操作人主动移除,需填写原因 |
| 已过期 | 预留 |
**高风险规则:** 风险级别为「高」或「极高」的标签提交后状态为「待审批」,须法务或风控审批通过后变为「生效中」。
### 8.6 移除标签
- 仅「生效中」且当前角色有移除权限的标签可移除
- 须填写移除原因(至少 5 字)
- 写入操作日志,并从列表 `labels` 同步清除
---
## 9. 权限与审批
### 9.1 打标权限
| 角色 | 可选标签范围 | 标签类型(自动) |
|---|---|---|
| 财务 | 财务类(信用与回款、操作与流程中的财务标签) | 财务 |
| 法务 | 法务类(合规与法律、经营与履约中的法务标签) | 法务 |
| 运维 | 运维类(车辆/资产高风险) | 运维 |
| 风控 / 管理员 | 全部 | 风控 |
| 销售 | 无 | — |
### 9.2 审批权限
- 法务、风控、管理员可审批「待审批」标签
- 支持通过 / 驳回;驳回须填写原因
### 9.3 移除权限
与打标权限一致:各角色仅能移除本类型标签;风控 / 管理员可移除全部类型。
---
## 10. 操作日志
所有标签 **新增、移除、提交审批、审批通过、审批驳回、批量打标** 均写入日志。
| 字段 | 说明 |
|---|---|
| 操作类型 | 新增 / 移除 / 提交审批 / 审批通过 / 审批驳回 / 批量打标 |
| 客户 | 客户名称 + ID |
| 标签名称 | 涉及标签 |
| 详情 | 原因、审批结论等 |
| 操作人 | 姓名 + 角色 |
| 操作时间 | `YYYY-MM-DD HH:mm:ss` |
抽屉「操作日志」Tab 按客户过滤;原型预置样例日志见 `data/risk-tag-operation-logs.json`
---
## 11. 原型实现索引
| 能力 | 代码位置 |
|---|---|
| 列表编排 | `index.tsx` |
| 风险标签列 | `components/RiskLabelTags.tsx` |
| 管理抽屉 | `components/RiskTagDrawer.tsx` |
| 批量打标 | `components/BatchRiskTagModal.tsx` |
| 打标表单 | `components/RiskTagForm.tsx` |
| 标签目录 | `utils/riskTagConstants.ts` |
| 权限 | `utils/permissions.ts` |
| 业务逻辑 | `utils/riskTagStore.ts` |
| 类型定义 | `types/riskTag.ts` |

View File

@@ -0,0 +1,81 @@
# UI Review
- 审查目标src/prototypes/customer-management
- 使用设计依据DESIGN.md项目默认 OneOS 设计规范)
- 生成时间2026-07-01
## 总体点评
客户管理原型在信息架构上与 OneOS 中后台惯例高度一致:列表页采用「筛选区 + 工具栏 + 表格 + 分页」四段式,新增页采用分组卡片 + 底部 sticky 操作栏,并复用 `vehicle-management``vm-*` 样式与令牌桥接,整体视觉气质专业、数据密度适中,与 DESIGN.md 描述的 Emerald 主色、Inter 字体、32px 控件高度等基调基本吻合。
亮点方面:筛选区默认折叠为 4 项并支持「更多筛选」,有效控制了首屏认知负荷;表格对编号列与操作列做了 sticky 处理,在 17 列宽表场景下保留了关键操作可达性;新增页省-市、部门-负责人联动逻辑清晰,发票抬头随客户全称自动带入,符合业务心智模型。
主要风险集中在三类:一是部分组件(状态徽章、客户标签)仍使用硬编码色值,偏离 OneOS 语义色与 Emerald 主色体系;二是新增页表单错误反馈仅依赖 3 秒消失的 Toast缺少字段级错误态与焦点引导三是列表页面包屑信息不完整与新增页及 DESIGN.md 约定的页面结构存在不一致。Impeccable detect 扫描未检出自动化规则违规,独立评估标记为 degraded未使用浏览器预览/截图)。
## P0-P3 优先级问题
### P1 - 新增页校验失败仅 Toast 提示,缺少字段级错误反馈
- 证据:`index.tsx``validateCreateForm` 返回首条错误文案后,经 `showToast` 展示并在 3 秒后消失;`CreatePage.tsx` 各字段无 `aria-invalid`、错误文案或错误边框态。
- 影响:用户提交长表单后无法快速定位未填项,需记忆 Toast 内容或在多卡片间逐字段排查,显著增加完成新增任务的难度;不符合 Nielsen「错误识别与恢复」及 DESIGN.md 对校验失败使用错误色 `#ef4444` 的语义约定。
- 修复方向:提交时在首个无效字段旁展示 inline 错误,并为对应控件设置 `aria-invalid` + `aria-describedby`;校验失败后将焦点移至首个错误字段;保留 Toast 作为成功态补充即可。
### P1 - 必填标识对屏幕阅读器不可感知
- 证据:`CreatePage.tsx``FormField` 的必填星号使用 `aria-hidden`;关联的 `input`/`select` 未设置 `aria-required="true"`
- 影响:依赖读屏或键盘的用户无法在进入字段前获知必填约束,提交失败后只能依赖 Toast 间接推断,构成 WCAG 可感知性缺口。
- 修复方向:移除星号上的 `aria-hidden`,在必填控件上添加 `aria-required="true"`;可选在标签文案中追加「(必填)」供读屏朗读;星号保留视觉样式并配合 `aria-hidden` 仅当标签已含文字说明时。
### P2 - 状态徽章与客户标签硬编码非 OneOS 语义色
- 证据:`StatusBadge.tsx` 内联 `#00b42a` / `#f53f3f` / `#86909c``LabelTags.tsx` 内联 `#f77234` 橙色描边与 `#1d2129` 文字色,未使用 `--oneos-*``--ln-*` 令牌。
- 影响:与 DESIGN.md「使用 CSS 变量 `--oneos-*`,不要硬编码色值」及「不要引入与 Emerald 主色冲突的第三方色板」冲突;列表中标签橙色与系统 Emerald 主色并置,削弱品牌一致性与状态色语义(成功/错误/警告)的可学习性。
- 修复方向:状态点改用 `var(--oneos-success)` / `var(--oneos-error)` / `var(--oneos-muted)` 或等效 `--ln-*` 桥接变量;标签样式复用 `oneos-app.css` 中已有 tag/chip 模式,或基于 `--oneos-primary-light` 背景 + `--oneos-primary` 描边统一视觉。
### P2 - 列表页面包屑未标示当前页「客户管理」
- 证据:列表视图 `index.tsx` 面包屑仅渲染 `<span className="cm-breadcrumb-muted">业务管理</span>`;新增页则完整展示「业务管理 / 客户管理 / 新增」且前两级可点击返回。
- 影响:用户在列表页无法从导航结构确认当前位置;与 DESIGN.md「列表页面包屑 + 筛选区 + …」及 PRD 页面结构约定不一致,也弱化了「用户控制与自由」中的位置感知。
- 修复方向:列表页补全为「业务管理 / 客户管理」,当前级使用 `cm-breadcrumb-current` 高亮;「业务管理」在具备上级路由时可设为链接,与新增页交互保持一致。
### P3 - 17 列宽表首屏信息密度过高,缺少列管理能力
- 证据:`styles/index.css` 设定 `.cm-table { min-width: 2240px; }`,表格含 17 个数据列;虽有个别 sticky 列,但中间字段(信用代码、地址等)需大量横向滚动才能浏览。
- 影响:销售/运营在对比客户核心字段(名称、状态、负责人)时 extraneous cognitive load 偏高;存在可用绕行(横向滚动 + sticky但不利于日常高频扫读。
- 修复方向:原型阶段可标注默认可见列集合并提供「列设置」入口;或将次要工商字段折叠进行内「详情」抽屉,列表保留 810 个高频列,与 DESIGN.md「数据密度适中」目标对齐。
## 核心元件
### 筛选区FilterPanel
符合 DESIGN.md 列表页「筛选区 + 工具栏 + 表格 + 分页」结构。标题 `h1.cm-page-title` 承担页面主标题职责4 项默认展示 + 「更多筛选」折叠策略合理,查询/重置与 `pendingFilters` / `appliedFilters` 分离符合 PRD。保留点部门-负责人联动、日期范围 `aria-label` 完整。调整点:筛选卡片与列表面包屑之间层级关系可再梳理(标题在筛选卡片内、面包屑在其外,略分散)。
### 工具栏与表格CustomerTable + TablePagination
主操作「新增客户」使用 `vm-btn-primary` 且带图标+文字,符合 DESIGN.md 主按钮约定;批量导入/导出为 secondary/ghost 层级清晰。表格空态文案「暂无符合条件的客户」存在,但缺少引导操作(如「新增客户」链接)。行内操作「详情 | 编辑 | 删除」链接化呈现符合中后台惯例;删除使用 `cm-link-danger` 指向错误色,语义正确。分页与「共 N 条」footer 布局清晰。宽表 sticky 左右列实现到位,但列数过多见 P3。
### 新增表单CreatePage
三组卡片(客户信息 / 联系人 / 开票)分组符合 PRD 与认知 chunking 原则;三列网格在 960px / 640px 有响应式断点。省-市、部门-负责人 disabled 联动、发票抬头只读同步逻辑正确。底部 sticky footer 固定「重置 / 提交」符合中后台表单模式。调整点:校验与必填可感知性见 P1`cm-create-footer` 使用负 margin 对齐父级 padding对 padding 变更较脆弱,后续可改为与 `.vm-page` 内边距一致的 layout token。
### 状态与标签StatusBadge / LabelTags
状态徽章圆点 + 文案结构清晰,但色值未走设计令牌(见 P2。LabelTags「首标签 + +N 悬浮」符合 PRD`+N` 支持 focus 展开 tooltip键盘可达性优于纯 hover。标签橙色硬编码与 OneOS Emerald 体系脱节,建议统一为系统 tag 样式。
### 面包屑与全局反馈
新增页面包屑可点击返回、当前页高亮表现良好。Toast 使用 `role="status"` + `aria-live="polite"`,成功/演示提示合适;但不宜作为表单校验的唯一错误通道。列表页面包屑不完整见 P2。
## 响应式与可访问性
- **响应式**:新增表单网格在 ≤960px / ≤640px 降为 2 列 / 1 列;列表表格依赖横向滚动,无移动端列精简方案,窄屏下操作成本较高(中后台可接受但需知晓)。
- **键盘**`vm-btn`、分页、FilterPicker 等复用组件具备 `:focus-visible` 样式MultiSelectField 的 `role="listbox"` 与 checkbox 组合非标准 listbox 模式,读屏语义可能混淆(未列入 TOP5建议后续改为 `role="group"` + checkbox 或 Combobox 模式)。
- **对比度**:正文与背景通过 `--ln-*` 桥接整体可读LabelTags / StatusBadge 硬编码色未经验证对比度。
- **动效**:筛选 Chevron 旋转与 `prefers-reduced-motion: reduce` 降级已在模块 CSS 中处理。
- **检查结论**:未发现阻断级 a11y 缺陷,但表单必填感知与错误恢复需优先补强。
## 证据与评估说明
- **浏览器/截图**:未使用(当前会话无预览环境接入)。
- **Scanner**:已运行 `rules/references/impeccable/scripts/detect.mjs --json src/prototypes/customer-management`,结果为 `[]`0 条自动化发现)。
- **独立评估**degraded — 设计评估基于源码、DESIGN.md 与 PRD证据评估仅含 CLI scanner缺少浏览器可视化与响应式实测结论以静态审查为主。

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
{
"type": "excalidraw",
"version": 2,
"source": "@axhub/make",
"elements": [],
"appState": {
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

View File

@@ -0,0 +1,151 @@
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 { TAG_CATALOG_MAP } from '../utils/riskTagConstants';
import { defaultEffectiveTo, hasDuplicateActiveTag, todayDate, validateApplyForm } from '../utils/riskTagStore';
import { RiskTagApplyFormSection } from './RiskTagForm';
interface BatchRiskTagModalProps {
open: boolean;
records: CustomerRecord[];
selectedIds: Set<string>;
riskTagsByCustomer: RiskTagsByCustomer;
currentUser: MockUser;
onClose: () => void;
onSubmit: (form: RiskTagApplyForm, customerIds: string[]) => string | null;
}
export function BatchRiskTagModal({
open,
records,
selectedIds,
riskTagsByCustomer,
currentUser,
onClose,
onSubmit,
}: BatchRiskTagModalProps) {
const [form, setForm] = useState<RiskTagApplyForm>(() => ({
...EMPTY_RISK_TAG_FORM,
tagType: roleToTagCategory(currentUser),
effectiveFrom: todayDate(),
effectiveTo: defaultEffectiveTo(),
}));
const [error, setError] = useState('');
const selectedRecords = useMemo(
() => records.filter((record) => selectedIds.has(record.id)),
[records, selectedIds],
);
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) return null;
const handleSubmit = () => {
const validation = validateApplyForm(form);
if (validation) {
setError(validation);
return;
}
if (!canApplyRiskTagByName(currentUser, form.tagName)) {
setError('当前角色无权选择该标签');
return;
}
const eligibleIds = selectedRecords
.filter((record) => !hasDuplicateActiveTag(riskTagsByCustomer[record.id] ?? [], form.tagName))
.map((record) => record.id);
if (eligibleIds.length === 0) {
setError('所选客户均已存在该标签');
return;
}
const result = onSubmit({ ...form, tagType: roleToTagCategory(currentUser) }, eligibleIds);
if (result) {
setError(result);
return;
}
setForm({
...EMPTY_RISK_TAG_FORM,
tagType: roleToTagCategory(currentUser),
effectiveFrom: todayDate(),
effectiveTo: defaultEffectiveTo(),
});
setError('');
onClose();
};
const skippedCount = selectedRecords.length - selectedRecords.filter(
(record) => !hasDuplicateActiveTag(riskTagsByCustomer[record.id] ?? [], form.tagName),
).length;
return (
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="vm-modal vm-modal-lg cm-batch-risk-modal"
role="dialog"
aria-modal="true"
aria-labelledby="cm-batch-risk-title"
data-annotation-id="cm-batch-risk-tag-modal"
onClick={(e) => e.stopPropagation()}
>
<header className="vm-modal-header">
<div>
<h2 id="cm-batch-risk-title"></h2>
<p> {selectedRecords.length} · {currentUser.name}{currentUser.department}</p>
</div>
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="vm-edit-body">
{error && <div className="cm-risk-error" role="alert">{error}</div>}
<div className="cm-batch-selected">
{selectedRecords.slice(0, 6).map((record) => (
<span key={record.id} className="cm-batch-chip">{record.name}</span>
))}
{selectedRecords.length > 6 && (
<span className="cm-batch-chip">+{selectedRecords.length - 6}</span>
)}
</div>
{form.tagName && skippedCount > 0 && (
<div className="cm-risk-alert">
{skippedCount} {form.tagName}
</div>
)}
<RiskTagApplyFormSection
form={form}
currentUser={currentUser}
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
/>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={handleSubmit}>
</button>
</footer>
</div>
</div>
);
}

View File

@@ -1,29 +1,38 @@
import React, { useState } from 'react';
import type { CustomerRecord } from '../types';
import { LabelTags } from './LabelTags';
import type { CustomerRiskTag, MockUser } from '../types/riskTag';
import { RiskLabelTags } from './RiskLabelTags';
import { StatusBadge } from './StatusBadge';
import { canManageRiskTags } from '../utils/permissions';
interface CustomerTableProps {
records: CustomerRecord[];
selectedIds: Set<string>;
riskTagsByCustomer: Record<string, CustomerRiskTag[]>;
currentUser: MockUser;
onToggleRow: (id: string, checked: boolean) => void;
onToggleAll: (checked: boolean) => void;
onView: (record: CustomerRecord) => void;
onEdit: (record: CustomerRecord) => void;
onDelete: (record: CustomerRecord) => void;
onManageTags: (record: CustomerRecord) => void;
}
export function CustomerTable({
records,
selectedIds,
riskTagsByCustomer,
currentUser,
onToggleRow,
onToggleAll,
onView,
onEdit,
onDelete,
onManageTags,
}: CustomerTableProps) {
const allSelected = records.length > 0 && records.every((record) => selectedIds.has(record.id));
const [hoverId, setHoverId] = useState<string | null>(null);
const canManage = canManageRiskTags(currentUser);
if (records.length === 0) {
return (
@@ -48,7 +57,7 @@ export function CustomerTable({
</th>
<th className="sticky-col-left cm-col-code"></th>
<th></th>
<th></th>
<th className="cm-col-labels"></th>
<th></th>
<th></th>
<th></th>
@@ -82,7 +91,12 @@ export function CustomerTable({
</td>
<td className="sticky-col-left cm-col-code tabular-nums">{record.code}</td>
<td className="cm-col-name" title={record.name}>{record.name}</td>
<td className="cm-col-labels"><LabelTags tags={record.labels} /></td>
<td className="cm-col-labels">
<RiskLabelTags
tags={riskTagsByCustomer[record.id] ?? []}
onView={canManage ? () => onManageTags(record) : undefined}
/>
</td>
<td>{record.region}</td>
<td><StatusBadge status={record.status} /></td>
<td>{record.department}</td>
@@ -100,6 +114,12 @@ 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 && (
<>
<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>
</div>

View File

@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import type { CustomerRiskTag } from '../types/riskTag';
import { getActiveTags, getPendingTags } from '../utils/riskTagStore';
interface RiskLabelTagsProps {
tags: CustomerRiskTag[];
onView?: () => void;
}
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';
default: return 'cm-risk-tag--medium';
}
}
export function RiskLabelTags({ tags, onView }: RiskLabelTagsProps) {
const [open, setOpen] = useState(false);
const visibleTags = tags.filter((tag) =>
tag.status === '生效中' || tag.status === '待审批' || tag.status === '待确认',
);
const active = getActiveTags(visibleTags);
const pending = getPendingTags(visibleTags);
const displayTags = active.length > 0 ? active : pending;
if (!displayTags.length) {
return <span className="cm-muted"></span>;
}
const primary = displayTags[0];
const overflow = displayTags.length - 1;
return (
<span className="cm-risk-label-cell">
<button
type="button"
className={`cm-risk-tag ${levelClass(primary.riskLevel)}`}
onClick={onView}
title={primary.tagName}
>
{primary.tagName}
</button>
{pending.length > 0 && active.length === 0 && (
<span className="cm-risk-tag-pending" title="待审批/待确认"></span>
)}
{overflow > 0 && (
<span
className="cm-risk-tag-count"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
tabIndex={0}
role="button"
aria-label={`还有 ${overflow} 个标签`}
>
+{overflow}
{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>
))}
</span>
)}
</span>
)}
{pending.length > 0 && active.length > 0 && (
<span className="cm-risk-tag-pending" title={`${pending.length} 个待审批`}>
+{pending.length}
</span>
)}
</span>
);
}

View File

@@ -0,0 +1,308 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Clock, Plus, Tag, X } from 'lucide-react';
import type { CustomerRecord } from '../types';
import type {
CustomerRiskTag,
MockUser,
RiskTagApplyForm,
RiskTagOperationLog,
} from '../types/riskTag';
import { canApproveRiskTag, canApplyRiskTagByName, canRemoveRiskTag, roleToTagCategory } from '../utils/permissions';
import { TAG_CATALOG_MAP } from '../utils/riskTagConstants';
import { EMPTY_RISK_TAG_FORM } from '../types/riskTag';
import {
defaultEffectiveTo,
formatNow,
hasDuplicateActiveTag,
todayDate,
validateApplyForm,
} from '../utils/riskTagStore';
import { RiskTagApplyFormSection, RiskTagDetailCard } from './RiskTagForm';
type DrawerTab = 'tags' | 'add' | 'logs';
interface RiskTagDrawerProps {
open: boolean;
record: CustomerRecord | null;
tags: CustomerRiskTag[];
logs: RiskTagOperationLog[];
currentUser: MockUser;
onClose: () => void;
onApply: (form: RiskTagApplyForm) => string | null;
onRemove: (tagId: string, reason: string) => string | null;
onApprove: (tagId: string, approved: boolean, rejectReason?: string) => string | null;
}
export function RiskTagDrawer({
open,
record,
tags,
logs,
currentUser,
onClose,
onApply,
onRemove,
onApprove,
}: RiskTagDrawerProps) {
const [tab, setTab] = useState<DrawerTab>('tags');
const [detailId, setDetailId] = useState<string | null>(null);
const [form, setForm] = useState<RiskTagApplyForm>(EMPTY_RISK_TAG_FORM);
const [removeReason, setRemoveReason] = useState('');
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [approveTargetId, setApproveTargetId] = useState<string | null>(null);
const [error, setError] = useState('');
const detailTag = useMemo(
() => tags.find((tag) => tag.id === detailId) ?? null,
[tags, detailId],
);
const customerLogs = useMemo(
() => logs.filter((log) => log.customerId === record?.id),
[logs, record?.id],
);
const disabledTagNames = useMemo(
() => tags
.filter((tag) => tag.status === '生效中' || tag.status === '待审批' || tag.status === '待确认')
.map((tag) => tag.tagName),
[tags],
);
/** 顶部切换操作角色时,同步打标表单中的标签类型与可选标签 */
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 = () => {
setForm({
...EMPTY_RISK_TAG_FORM,
tagType: roleToTagCategory(currentUser),
effectiveFrom: todayDate(),
effectiveTo: defaultEffectiveTo(),
});
setError('');
};
const handleSubmitApply = () => {
const validation = validateApplyForm(form);
if (validation) {
setError(validation);
return;
}
if (hasDuplicateActiveTag(tags, form.tagName)) {
setError('该客户已存在同名有效标签');
return;
}
const result = onApply({ ...form, tagType: roleToTagCategory(currentUser) });
if (result) {
setError(result);
return;
}
resetAddForm();
setTab('tags');
};
const handleConfirmRemove = () => {
if (!removeTargetId) return;
if (removeReason.trim().length < 5) {
setError('移除原因至少 5 个字');
return;
}
const result = onRemove(removeTargetId, removeReason.trim());
if (result) {
setError(result);
return;
}
setRemoveTargetId(null);
setRemoveReason('');
setError('');
};
const handleConfirmApprove = (approved: boolean) => {
if (!approveTargetId) return;
if (!approved && !rejectReason.trim()) {
setError('请填写驳回原因');
return;
}
const result = onApprove(approveTargetId, approved, rejectReason.trim() || undefined);
if (result) {
setError(result);
return;
}
setApproveTargetId(null);
setRejectReason('');
setError('');
};
return (
<div className="cm-drawer-backdrop" role="presentation" onClick={onClose}>
<aside
className="cm-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="cm-risk-drawer-title"
data-annotation-id="cm-risk-tag-drawer"
onClick={(e) => e.stopPropagation()}
>
<header className="cm-drawer-header">
<div>
<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="关闭">
<X size={18} aria-hidden />
</button>
</header>
<nav className="cm-drawer-tabs" aria-label="标签管理分区">
<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(); }}>
<Plus size={14} aria-hidden />
</button>
<button type="button" className={tab === 'logs' ? 'is-active' : ''} onClick={() => setTab('logs')}>
<Clock size={14} aria-hidden />
</button>
</nav>
<div className="cm-drawer-body">
{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(''); }}
>
</button>
)}
</div>
</article>
))}
</div>
)}
{tab === 'add' && (
<RiskTagApplyFormSection
form={form}
currentUser={currentUser}
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
disabledTagNames={disabledTagNames}
/>
)}
{tab === 'logs' && (
<ol className="cm-risk-log-list">
{customerLogs.length === 0 && <li className="cm-muted"></li>}
{customerLogs.map((log) => (
<li key={log.id}>
<div className="cm-risk-log-head">
<strong>{log.action}</strong>
<time>{log.createdAt}</time>
</div>
<p>{log.detail}</p>
<span className="cm-muted">{log.operator} · {log.tagName}</span>
</li>
))}
</ol>
)}
</div>
<footer className="cm-drawer-footer">
{tab === 'add' ? (
<>
<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={onClose}></button>
)}
</footer>
{detailTag && (
<div className="cm-drawer-subpanel">
<RiskTagDetailCard tag={detailTag} onClose={() => setDetailId(null)} />
</div>
)}
{removeTargetId && (
<div className="cm-drawer-overlay-panel">
<h3></h3>
<p className="cm-modal-hint"></p>
<textarea
className="vm-input cm-textarea"
rows={3}
value={removeReason}
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>
</div>
</div>
)}
{approveTargetId && (
<div className="cm-drawer-overlay-panel">
<h3></h3>
<p className="cm-modal-hint">/</p>
<textarea
className="vm-input cm-textarea"
rows={2}
value={rejectReason}
placeholder="若驳回,请填写原因"
onChange={(e) => setRejectReason(e.target.value)}
/>
<div className="cm-drawer-overlay-actions">
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setApproveTargetId(null)}></button>
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => handleConfirmApprove(false)}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={() => handleConfirmApprove(true)}></button>
</div>
</div>
)}
</aside>
</div>
);
}

View File

@@ -0,0 +1,242 @@
import React, { useMemo, useRef } from 'react';
import { Upload, X } from 'lucide-react';
import type { MockUser, RiskTagApplyForm, RiskTagAttachment } from '../types/riskTag';
import { TAG_CATALOG_MAP, TAG_GROUPS } from '../utils/riskTagConstants';
import { canApplyRiskTag, roleToTagCategory } from '../utils/permissions';
import { formatNow, nextId } from '../utils/riskTagStore';
interface RiskTagApplyFormSectionProps {
form: RiskTagApplyForm;
currentUser: MockUser;
onChange: (patch: Partial<RiskTagApplyForm>) => void;
disabledTagNames?: string[];
}
export function RiskTagApplyFormSection({
form,
currentUser,
onChange,
disabledTagNames = [],
}: RiskTagApplyFormSectionProps) {
const inputRef = useRef<HTMLInputElement>(null);
const tagType = roleToTagCategory(currentUser);
const selectableGroups = useMemo(
() => TAG_GROUPS.map((group) => ({
...group,
tags: group.tagNames
.map((name) => TAG_CATALOG_MAP[name])
.filter((tag) => tag && canApplyRiskTag(currentUser, tag)),
})).filter((group) => group.tags.length > 0),
[currentUser],
);
const handleTagNameChange = (tagName: string) => {
const catalog = TAG_CATALOG_MAP[tagName];
onChange({
tagName,
tagType,
riskLevel: catalog?.defaultLevel ?? '中',
});
};
const handleUpload = (file: File) => {
const attachment: RiskTagAttachment = {
id: nextId('att'),
name: file.name,
size: `${Math.max(1, Math.round(file.size / 1024))} KB`,
uploadedAt: formatNow(),
};
onChange({ attachments: [...form.attachments, attachment] });
};
const removeAttachment = (id: string) => {
onChange({ attachments: form.attachments.filter((item) => item.id !== id) });
};
const needsApproval = form.riskLevel === '高' || form.riskLevel === '极高';
const levelLocked = Boolean(form.tagName);
return (
<div className="cm-risk-form">
<div className="cm-risk-form-grid">
<label className="cm-form-field cm-risk-form-full">
<span className="cm-form-label"> <span className="cm-required">*</span></span>
<select
className="vm-input"
value={form.tagName}
onChange={(e) => handleTagNameChange(e.target.value)}
>
<option value=""></option>
{selectableGroups.map((group) => (
<optgroup key={group.groupName} label={group.groupName}>
{group.tags.map((item) => (
<option
key={item.tagName}
value={item.tagName}
disabled={disabledTagNames.includes(item.tagName)}
>
{item.tagName}
{disabledTagNames.includes(item.tagName) ? '(已存在)' : ''}
</option>
))}
</optgroup>
))}
</select>
{selectableGroups.length === 0 && (
<span className="cm-form-hint">///</span>
)}
</label>
<label className="cm-form-field">
<span className="cm-form-label"> <span className="cm-required">*</span></span>
<select
className="vm-input cm-input-disabled"
value={tagType}
disabled
aria-readonly="true"
>
<option value={tagType}>{tagType}</option>
</select>
</label>
<label className="cm-form-field">
<span className="cm-form-label"> <span className="cm-required">*</span></span>
<select
className={`vm-input ${levelLocked ? 'cm-input-disabled' : ''}`}
value={form.riskLevel}
disabled={levelLocked}
aria-readonly={levelLocked}
onChange={(e) => onChange({ riskLevel: e.target.value as RiskTagApplyForm['riskLevel'] })}
>
<option value={form.riskLevel}>{form.riskLevel}</option>
</select>
{levelLocked && (
<span className="cm-form-hint"></span>
)}
</label>
<label className="cm-form-field">
<span className="cm-form-label"> <span className="cm-required">*</span></span>
<input
type="date"
className="vm-input"
value={form.effectiveFrom}
onChange={(e) => onChange({ effectiveFrom: e.target.value })}
/>
</label>
<label className="cm-form-field">
<span className="cm-form-label"> <span className="cm-required">*</span></span>
<input
type="date"
className="vm-input"
value={form.effectiveTo}
onChange={(e) => onChange({ effectiveTo: e.target.value })}
/>
</label>
</div>
<label className="cm-form-field cm-risk-form-full">
<span className="cm-form-label"> <span className="cm-required">*</span></span>
<textarea
className="vm-input cm-textarea"
rows={3}
value={form.reason}
placeholder="请说明打标依据,供法务/财务/风控审批留痕(至少 10 字)"
onChange={(e) => onChange({ reason: e.target.value })}
/>
</label>
<div className="cm-form-field cm-risk-form-full">
<span className="cm-form-label"></span>
<button
type="button"
className="vm-upload-zone cm-risk-upload"
onClick={() => inputRef.current?.click()}
>
<Upload size={20} aria-hidden />
<p></p>
<p className="hint"> .pdf.jpg.png.xlsx</p>
<input
ref={inputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.xlsx,.xls"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.target.value = '';
}}
/>
</button>
{form.attachments.length > 0 && (
<ul className="cm-risk-attachments">
{form.attachments.map((file) => (
<li key={file.id}>
<span>{file.name}</span>
<span className="cm-muted">{file.size}</span>
<button type="button" className="vm-btn vm-btn-link" onClick={() => removeAttachment(file.id)}>
</button>
</li>
))}
</ul>
)}
</div>
{needsApproval && (
<div className="cm-risk-alert">
<strong></strong>
</div>
)}
</div>
);
}
interface RiskTagDetailCardProps {
tag: import('../types/riskTag').CustomerRiskTag;
onClose?: () => void;
}
export function RiskTagDetailCard({ tag, onClose }: RiskTagDetailCardProps) {
const groupName = TAG_CATALOG_MAP[tag.tagName]?.groupName;
return (
<div className="cm-risk-detail-card">
<div className="cm-risk-detail-head">
<div>
<h4>{tag.tagName}</h4>
<span className={`cm-risk-status cm-risk-status--${tag.status}`}>{tag.status}</span>
</div>
{onClose && (
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭详情">
<X size={16} aria-hidden />
</button>
)}
</div>
<dl className="cm-risk-detail-list">
{groupName && <div><dt></dt><dd>{groupName}</dd></div>}
<div><dt></dt><dd>{tag.tagType}</dd></div>
<div><dt></dt><dd>{tag.riskLevel}</dd></div>
<div><dt></dt><dd>{tag.effectiveFrom}</dd></div>
<div><dt></dt><dd>{tag.effectiveTo}</dd></div>
<div><dt></dt><dd>{tag.operator}</dd></div>
<div><dt></dt><dd>{tag.createdAt}</dd></div>
{tag.approver && <div><dt></dt><dd>{tag.approver}</dd></div>}
{tag.approvedAt && <div><dt></dt><dd>{tag.approvedAt}</dd></div>}
{tag.rejectReason && <div><dt></dt><dd>{tag.rejectReason}</dd></div>}
<div className="cm-risk-detail-full"><dt></dt><dd>{tag.reason}</dd></div>
{tag.attachments.length > 0 && (
<div className="cm-risk-detail-full">
<dt></dt>
<dd>
<ul className="cm-risk-attachments">
{tag.attachments.map((file) => (
<li key={file.id}>{file.name}{file.size}</li>
))}
</ul>
</dd>
</div>
)}
</dl>
</div>
);
}

View File

@@ -0,0 +1,50 @@
[
{
"id": "log-seed-1",
"customerId": "6",
"customerName": "广州粤海金融服务有限公司",
"tagId": "seed-6-0",
"tagName": "严重拖欠",
"action": "提交审批",
"detail": "财务张财务提交标签「严重拖欠」(财务/高),已进入审批流程",
"operator": "张财务",
"operatorRole": "finance",
"createdAt": "2024-01-23 09:12:00"
},
{
"id": "log-seed-2",
"customerId": "6",
"customerName": "广州粤海金融服务有限公司",
"tagId": "seed-6-0",
"tagName": "严重拖欠",
"action": "审批通过",
"detail": "风控王风控审批通过标签「严重拖欠」,标签已生效",
"operator": "王风控",
"operatorRole": "risk",
"createdAt": "2024-01-23 14:30:00"
},
{
"id": "log-seed-3",
"customerId": "16",
"customerName": "苏州工业园区精密模具厂",
"tagId": "seed-16-1",
"tagName": "制裁/高风险地区",
"action": "提交审批",
"detail": "法务李法务提交标签「制裁/高风险地区」(法务/极高),已进入审批流程",
"operator": "李法务",
"operatorRole": "legal",
"createdAt": "2024-06-16 10:05:00"
},
{
"id": "log-seed-4",
"customerId": "16",
"customerName": "苏州工业园区精密模具厂",
"tagId": "seed-16-1",
"tagName": "制裁/高风险地区",
"action": "审批通过",
"detail": "法务李法务审批通过标签「制裁/高风险地区」,标签已生效",
"operator": "李法务",
"operatorRole": "legal",
"createdAt": "2024-06-16 16:20:00"
}
]

View File

@@ -5,7 +5,7 @@
import '../vehicle-management/style.css';
import './styles/index.css';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Download, FileUp, Plus } from 'lucide-react';
import { Download, FileUp, Plus, Tags } from 'lucide-react';
import {
AnnotationViewer,
type AnnotationDirectoryRouteNode,
@@ -16,6 +16,8 @@ import seedCustomers from './data/customers.json';
import { FilterPanel } from './components/FilterPanel';
import { CustomerTable } from './components/CustomerTable';
import { CreatePage } from './pages/CreatePage';
import { RiskTagDrawer } from './components/RiskTagDrawer';
import { BatchRiskTagModal } from './components/BatchRiskTagModal';
import { TablePagination } from '../vehicle-management/components/TablePagination';
import {
EMPTY_CREATE_FORM,
@@ -25,7 +27,20 @@ import {
type CustomerPageId,
type CustomerRecord,
} from './types';
import type { CustomerRiskTag, MockUser, RiskTagApplyForm } from './types/riskTag';
import { applyFilters } from './utils/filters';
import {
buildApplyLog,
buildApplyTag,
buildApprovalLog,
buildRemoveLog,
buildSeedRiskTags,
formatNow,
INITIAL_OPERATION_LOGS,
syncRecordLabels,
todayDate,
} from './utils/riskTagStore';
import { canApplyRiskTagByName, canBatchTag, canManageRiskTags, canRemoveRiskTag, MOCK_USERS } from './utils/permissions';
import annotationSourceDocument from './annotation-source.json';
const DEFAULT_PAGE_SIZE = 10;
@@ -38,7 +53,7 @@ type NavigationRef = {
function validateCreateForm(form: CustomerCreateForm): string | null {
if (!form.customerType) return '请选择客户类型';
if (!form.customerName.trim()) return '请输入客户全称';
if (!form.cityProvince || !form.cityName) return '请选择所属城市';
if (!form.cityProvince || !form.cityName) return '请填写通讯地址以识别所属城市';
if (!form.department) return '请选择业务负责部门';
if (!form.manager) return '请选择业务负责人员';
if (!form.invoiceTitle.trim()) return '请填写发票抬头';
@@ -48,7 +63,16 @@ function validateCreateForm(form: CustomerCreateForm): string | null {
}
export default function CustomerManagementApp() {
const [records] = useState<CustomerRecord[]>(seedCustomers as CustomerRecord[]);
const seedRecords = seedCustomers as CustomerRecord[];
const initialRiskTags = useMemo(() => buildSeedRiskTags(seedRecords), []);
const [records, setRecords] = useState<CustomerRecord[]>(() =>
syncRecordLabels(seedRecords, initialRiskTags),
);
const [riskTagsByCustomer, setRiskTagsByCustomer] = useState<Record<string, CustomerRiskTag[]>>(initialRiskTags);
const [operationLogs, setOperationLogs] = useState(INITIAL_OPERATION_LOGS);
const [currentUser, setCurrentUser] = useState<MockUser>(MOCK_USERS[0]);
const [view, setView] = useState<CustomerPageId>('list');
const [pendingFilters, setPendingFilters] = useState<CustomerFilters>(EMPTY_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<CustomerFilters>(EMPTY_FILTERS);
@@ -57,6 +81,8 @@ export default function CustomerManagementApp() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [createForm, setCreateForm] = useState<CustomerCreateForm>(EMPTY_CREATE_FORM);
const [toast, setToast] = useState('');
const [tagDrawerRecord, setTagDrawerRecord] = useState<CustomerRecord | null>(null);
const [batchTagOpen, setBatchTagOpen] = useState(false);
const navigationRef = useRef<NavigationRef>({});
const showToast = useCallback((msg: string) => {
@@ -64,6 +90,10 @@ export default function CustomerManagementApp() {
window.setTimeout(() => setToast(''), 3000);
}, []);
const refreshRecordLabels = useCallback((nextTags: Record<string, CustomerRiskTag[]>) => {
setRecords((prev) => syncRecordLabels(prev, nextTags));
}, []);
navigationRef.current = {
openCreate: () => {
setCreateForm(EMPTY_CREATE_FORM);
@@ -133,6 +163,135 @@ export default function CustomerManagementApp() {
setView('list');
};
const appendTagsForCustomer = useCallback((
customerId: string,
customerName: string,
form: RiskTagApplyForm,
batchCount?: number,
) => {
if (!canManageRiskTags(currentUser)) return '当前角色无打标权限';
const tag = buildApplyTag(customerId, {
...form,
effectiveFrom: form.effectiveFrom || todayDate(),
}, currentUser);
setRiskTagsByCustomer((prev) => {
const next = {
...prev,
[customerId]: [...(prev[customerId] ?? []), tag],
};
refreshRecordLabels(next);
return next;
});
setOperationLogs((prev) => [
buildApplyLog(tag, customerName, currentUser, batchCount),
...prev,
]);
return null;
}, [currentUser, refreshRecordLabels]);
const handleApplyTag = useCallback((form: RiskTagApplyForm): string | null => {
if (!tagDrawerRecord) return '未选择客户';
if (!canApplyRiskTagByName(currentUser, form.tagName)) return '当前角色无权选择该标签';
const result = appendTagsForCustomer(tagDrawerRecord.id, tagDrawerRecord.name, form);
if (!result) {
showToast(form.riskLevel === '高' || form.riskLevel === '极高'
? '已提交审批,待法务/风控确认'
: '标签已生效');
}
return result;
}, [appendTagsForCustomer, currentUser, showToast, tagDrawerRecord]);
const handleBatchApply = useCallback((form: RiskTagApplyForm, customerIds: string[]): string | null => {
if (!canBatchTag(currentUser)) return '当前角色无批量打标权限';
const newLogs = [...operationLogs];
const nextTags = { ...riskTagsByCustomer };
customerIds.forEach((customerId) => {
const record = records.find((item) => item.id === customerId);
if (!record) return;
const tag = buildApplyTag(customerId, {
...form,
effectiveFrom: form.effectiveFrom || todayDate(),
}, currentUser);
nextTags[customerId] = [...(nextTags[customerId] ?? []), tag];
newLogs.unshift(buildApplyLog(tag, record.name, currentUser, customerIds.length));
});
setRiskTagsByCustomer(nextTags);
refreshRecordLabels(nextTags);
setOperationLogs(newLogs);
showToast(`已为 ${customerIds.length} 个客户提交打标`);
setSelectedIds(new Set());
return null;
}, [currentUser, operationLogs, records, refreshRecordLabels, riskTagsByCustomer, showToast]);
const handleRemoveTag = useCallback((tagId: string, reason: string): 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 '当前角色无权移除此类型标签';
const now = formatNow();
setRiskTagsByCustomer((prev) => {
const nextTags = (prev[tagDrawerRecord.id] ?? []).map((tag) =>
tag.id === tagId ? { ...tag, status: '已移除' as const, updatedAt: now } : tag,
);
const next = { ...prev, [tagDrawerRecord.id]: nextTags };
refreshRecordLabels(next);
return next;
});
setOperationLogs((prev) => [
buildRemoveLog({ ...target, status: '已移除', updatedAt: now }, tagDrawerRecord.name, currentUser, reason),
...prev,
]);
showToast('标签已移除');
return null;
}, [currentUser, refreshRecordLabels, riskTagsByCustomer, showToast, tagDrawerRecord]);
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 '标签不可审批';
const now = formatNow();
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 };
});
const next = { ...prev, [tagDrawerRecord.id]: nextTags };
refreshRecordLabels(next);
return next;
});
setOperationLogs((prev) => [
buildApprovalLog(
{ ...target, status: approved ? '生效中' : '已驳回' },
tagDrawerRecord.name,
currentUser,
approved,
rejectReason,
),
...prev,
]);
showToast(approved ? '审批通过,标签已生效' : '已驳回该标签');
return null;
}, [currentUser, refreshRecordLabels, riskTagsByCustomer, showToast, tagDrawerRecord]);
const handleDirectoryRoute = useCallback((node: AnnotationDirectoryRouteNode) => {
const payload = node.payload as { pageId?: string } | undefined;
const pageId: CustomerPageId = payload?.pageId === 'create' ? 'create' : 'list';
@@ -156,6 +315,8 @@ export default function CustomerManagementApp() {
[view, handleDirectoryRoute],
);
const drawerTags = tagDrawerRecord ? (riskTagsByCustomer[tagDrawerRecord.id] ?? []) : [];
return (
<>
<div className="vm-page cm-page">
@@ -173,6 +334,27 @@ export default function CustomerManagementApp() {
<span className="cm-breadcrumb-muted"></span>
</nav>
<div className="cm-role-bar" data-annotation-id="cm-risk-role-switch">
<span className="cm-role-bar-label"></span>
<select
className="vm-input cm-role-select"
value={currentUser.name}
onChange={(e) => {
const user = MOCK_USERS.find((item) => item.name === e.target.value);
if (user) setCurrentUser(user);
}}
>
{MOCK_USERS.map((user) => (
<option key={user.name} value={user.name}>
{user.name} · {user.department}
</option>
))}
</select>
{!canManageRiskTags(currentUser) && (
<span className="cm-role-hint"></span>
)}
</div>
<FilterPanel
records={records}
filters={pendingFilters}
@@ -196,6 +378,17 @@ export default function CustomerManagementApp() {
<FileUp size={16} aria-hidden />
</button>
{canBatchTag(currentUser) && selectedIds.size > 0 && (
<button
type="button"
className="vm-btn vm-btn-secondary"
onClick={() => setBatchTagOpen(true)}
data-annotation-id="cm-batch-risk-tag-btn"
>
<Tags size={16} aria-hidden />
{selectedIds.size}
</button>
)}
</div>
<button
type="button"
@@ -211,11 +404,14 @@ export default function CustomerManagementApp() {
<CustomerTable
records={paged}
selectedIds={selectedIds}
riskTagsByCustomer={riskTagsByCustomer}
currentUser={currentUser}
onToggleRow={handleToggleRow}
onToggleAll={handleToggleAll}
onView={(record) => showToast(`详情:${record.name}(原型演示)`)}
onEdit={(record) => showToast(`编辑:${record.name}(原型演示)`)}
onDelete={(record) => showToast(`删除:${record.name}(原型演示)`)}
onManageTags={setTagDrawerRecord}
/>
<div className="vm-table-footer">
<div className="cm-table-total"> {filtered.length} </div>
@@ -234,6 +430,29 @@ export default function CustomerManagementApp() {
{toast && <div className="vm-toast" role="status" aria-live="polite">{toast}</div>}
</div>
<RiskTagDrawer
open={!!tagDrawerRecord}
record={tagDrawerRecord}
tags={drawerTags}
logs={operationLogs}
currentUser={currentUser}
onClose={() => setTagDrawerRecord(null)}
onApply={handleApplyTag}
onRemove={handleRemoveTag}
onApprove={handleApproveTag}
/>
<BatchRiskTagModal
open={batchTagOpen}
records={records}
selectedIds={selectedIds}
riskTagsByCustomer={riskTagsByCustomer}
currentUser={currentUser}
onClose={() => setBatchTagOpen(false)}
onSubmit={handleBatchApply}
/>
<AnnotationViewer
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}

View File

@@ -1,11 +1,12 @@
import React, { useMemo } from 'react';
import type { CustomerCreateForm } from '../types';
import {
CUSTOMER_LEVEL_OPTIONS,
COOP_STATUS_OPTIONS,
CUSTOMER_TYPE_OPTIONS,
DEPT_MANAGER_MAP,
REGION_TREE,
} from '../utils/constants';
import { parseAddressToRegion } from '../utils/address';
interface CreatePageProps {
form: CustomerCreateForm;
@@ -60,8 +61,9 @@ export function CreatePage({
onChange({ department, manager: '' });
};
const handleProvinceChange = (cityProvince: string) => {
onChange({ cityProvince, cityName: '' });
const handleAddressChange = (address: string) => {
const { cityProvince, cityName } = parseAddressToRegion(address);
onChange({ address, cityProvince, cityName });
};
return (
@@ -88,17 +90,6 @@ export function CreatePage({
))}
</select>
</FormField>
<FormField label="客户分级">
<select
className="vm-input"
value={form.customerLevel}
onChange={(event) => onChange({ customerLevel: event.target.value })}
>
{CUSTOMER_LEVEL_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</FormField>
<FormField label="客户全称" required>
<input
className="vm-input"
@@ -115,12 +106,12 @@ export function CreatePage({
onChange={(event) => onChange({ customerShortName: event.target.value })}
/>
</FormField>
<FormField label="所属城市" required>
<FormField label="所属城市" required hint="自动从通讯地址识别">
<div className="cm-city-row">
<select
className="vm-input"
className="vm-input cm-input-disabled"
value={form.cityProvince}
onChange={(event) => handleProvinceChange(event.target.value)}
disabled
>
<option value=""></option>
{REGION_TREE.map((item) => (
@@ -128,10 +119,9 @@ export function CreatePage({
))}
</select>
<select
className="vm-input"
className="vm-input cm-input-disabled"
value={form.cityName}
disabled={!form.cityProvince}
onChange={(event) => onChange({ cityName: event.target.value })}
disabled
>
<option value=""></option>
{cityOptions.map((city) => (
@@ -140,12 +130,12 @@ export function CreatePage({
</select>
</div>
</FormField>
<FormField label="通讯地址">
<FormField label="通讯地址" hint="填写完整地址自动识别省市">
<input
className="vm-input"
value={form.address}
placeholder="请输入通讯地址"
onChange={(event) => onChange({ address: event.target.value })}
onChange={(event) => handleAddressChange(event.target.value)}
/>
</FormField>
<FormField label="业务负责部门" required>
@@ -173,6 +163,18 @@ export function CreatePage({
))}
</select>
</FormField>
<FormField label="合作状态">
<select
className={`vm-input ${!form.coopStatus ? 'cm-select-placeholder' : ''}`}
value={form.coopStatus}
onChange={(event) => onChange({ coopStatus: event.target.value })}
>
<option value=""></option>
{COOP_STATUS_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</FormField>
<FormField label="备注" hint="">
<textarea
className="vm-input cm-textarea"

View File

@@ -9,9 +9,13 @@ 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-list-toolbar', title: '列表工具栏', pageId: 'list', color: '#0f766e', aiPrompt: '新增、导入、导出入口。', annotationText: '「新增客户」进入分组表单页。' },
{ id: 'cm-list-table', title: '客户台账列表', pageId: 'list', color: '#2563eb', aiPrompt: '客户台账表格列与行操作。', annotationText: '' },
{ id: 'cm-create-base', title: '客户信息', pageId: 'create', color: '#2563eb', aiPrompt: '客户类型、分级、全称、城市、部门负责人等。', annotationText: '客户全称变更时同步发票抬头。' },
{ id: 'cm-risk-role-switch', title: '操作角色切换', pageId: 'list', color: '#64748b', aiPrompt: '模拟法务/财务/运维/风控/销售角色,驱动打标权限。', annotationText: '切换角色后抽屉内标签类型与可选标签联动更新。' },
{ 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-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-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 并返回列表。' },
@@ -48,8 +52,12 @@ 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-create-base': { prd: 'create', heading: '## 2. 客户信息' },
'cm-create-contact': { prd: 'create', heading: '## 3. 联系人信息' },
'cm-create-invoice': { prd: 'create', heading: '## 4. 付款及开票信息' },

View File

@@ -153,7 +153,8 @@
}
.cm-col-labels {
min-width: 120px;
min-width: 140px;
max-width: 180px;
}
.cm-col-credit {
@@ -178,7 +179,7 @@
}
.cm-col-actions {
min-width: 168px;
min-width: 220px;
}
.sticky-col-left {
@@ -346,6 +347,10 @@
cursor: not-allowed;
}
.cm-select-placeholder {
color: var(--ln-muted);
}
.cm-city-row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -372,3 +377,500 @@
transition: none;
}
}
/* ==========================================================================
风险标签 — 列表展示 / 抽屉 / 批量打标
========================================================================== */
.cm-role-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
padding: 10px 14px;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-control);
background: var(--ln-surface-pearl);
font-size: 0.875rem;
}
.cm-role-bar-label {
color: var(--ln-muted);
}
.cm-role-select {
width: auto;
min-width: 200px;
min-height: var(--vm-control-height);
height: var(--vm-control-height);
}
.cm-role-hint {
color: var(--ln-warning);
font-size: 0.8125rem;
}
.cm-risk-label-cell {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
}
.cm-risk-tag {
display: inline-flex;
align-items: center;
max-width: 88px;
padding: 0 6px;
line-height: 20px;
border-radius: 4px;
border: 1px solid transparent;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: var(--ln-surface-card);
cursor: default;
}
button.cm-risk-tag {
cursor: pointer;
font: inherit;
}
button.cm-risk-tag:hover {
filter: brightness(0.98);
}
.cm-risk-tag--low {
border-color: #c9cdd4;
color: #4e5969;
}
.cm-risk-tag--medium {
border-color: #ffcf8b;
color: #b45309;
background: #fffbeb;
}
.cm-risk-tag--high {
border-color: #fca5a5;
color: #b91c1c;
background: #fef2f2;
}
.cm-risk-tag--critical {
border-color: #f87171;
color: #991b1b;
background: #fee2e2;
}
.cm-risk-tag-count {
display: inline-flex;
align-items: center;
padding: 0 6px;
line-height: 20px;
border-radius: 4px;
border: 1px solid var(--ln-hairline);
font-size: 12px;
color: var(--ln-muted);
cursor: default;
position: relative;
}
.cm-risk-tag-pending {
display: inline-flex;
align-items: center;
padding: 0 5px;
line-height: 18px;
border-radius: 4px;
font-size: 11px;
color: #b45309;
background: #fff7ed;
border: 1px solid #fed7aa;
}
.cm-risk-tag-tooltip {
min-width: 140px;
}
.cm-risk-tag-status {
margin-left: 4px;
font-size: 10px;
color: var(--ln-muted);
}
.cm-drawer-backdrop {
position: fixed;
inset: 0;
z-index: 55;
background: rgba(24, 24, 27, 0.35);
}
.cm-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 56;
display: flex;
flex-direction: column;
width: min(520px, 100vw);
height: 100vh;
background: var(--ln-surface-card);
border-left: 1px solid var(--ln-hairline);
box-shadow: var(--ln-shadow-modal);
}
.cm-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 12px;
border-bottom: 1px solid var(--ln-hairline);
}
.cm-drawer-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--ln-ink);
}
.cm-drawer-header p {
margin: 4px 0 0;
font-size: 0.875rem;
color: var(--ln-muted);
}
.cm-drawer-tabs {
display: flex;
gap: 4px;
padding: 0 16px;
border-bottom: 1px solid var(--ln-hairline);
}
.cm-drawer-tabs button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--ln-muted);
font-size: 0.875rem;
cursor: pointer;
}
.cm-drawer-tabs button.is-active {
color: var(--ln-primary);
border-bottom-color: var(--ln-primary);
font-weight: 600;
}
.cm-drawer-body {
flex: 1;
overflow: auto;
padding: 16px 20px;
}
.cm-drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--ln-hairline);
background: var(--ln-surface-card);
}
.cm-risk-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 16px;
}
.cm-risk-form-full {
margin-top: 14px;
}
.cm-risk-form .cm-form-field {
gap: 6px;
}
.cm-risk-upload {
min-height: 96px;
padding: 16px;
}
.cm-risk-upload p {
margin: 6px 0 0;
font-size: 0.875rem;
}
.cm-risk-upload .hint {
font-size: 0.75rem;
color: var(--ln-muted);
}
.cm-risk-attachments {
margin: 8px 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 6px;
}
.cm-risk-attachments li {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
}
.cm-risk-alert {
margin-top: 12px;
padding: 10px 12px;
border-radius: var(--ln-radius-control);
background: #fff7ed;
border: 1px solid #fed7aa;
color: #9a3412;
font-size: 0.875rem;
}
.cm-risk-error {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: var(--ln-radius-control);
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
font-size: 0.875rem;
}
.cm-risk-tag-list {
display: grid;
gap: 12px;
}
.cm-risk-tag-item {
padding: 12px 14px;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-control);
background: var(--ln-canvas-soft);
}
.cm-risk-tag-item-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.cm-risk-tag-item-head strong {
display: block;
color: var(--ln-ink);
}
.cm-risk-tag-meta {
display: block;
margin-top: 2px;
font-size: 0.75rem;
color: var(--ln-muted);
}
.cm-risk-tag-reason {
margin: 8px 0 0;
font-size: 0.8125rem;
color: var(--ln-body);
line-height: 1.5;
}
.cm-risk-tag-item-actions {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
margin-top: 8px;
}
.cm-risk-status {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--ln-radius-pill);
font-size: 0.75rem;
white-space: nowrap;
}
.cm-risk-status--生效中 {
color: #047857;
background: #ecfdf5;
}
.cm-risk-status--待审批,
.cm-risk-status--待确认 {
color: #b45309;
background: #fffbeb;
}
.cm-risk-status--已驳回,
.cm-risk-status--已移除 {
color: #b91c1c;
background: #fef2f2;
}
.cm-risk-status--已过期 {
color: var(--ln-muted);
background: var(--ln-surface-pearl);
}
.cm-risk-log-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 12px;
}
.cm-risk-log-list li {
padding: 10px 12px;
border-left: 3px solid var(--ln-primary);
background: var(--ln-canvas-soft);
border-radius: 0 var(--ln-radius-control) var(--ln-radius-control) 0;
}
.cm-risk-log-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.cm-risk-log-head time {
font-size: 0.75rem;
color: var(--ln-muted);
}
.cm-risk-log-list p {
margin: 6px 0 4px;
font-size: 0.875rem;
color: var(--ln-body);
}
.cm-drawer-subpanel {
position: absolute;
inset: auto 16px 80px 16px;
z-index: 2;
max-height: 60vh;
overflow: auto;
}
.cm-drawer-overlay-panel {
position: absolute;
inset: auto 16px 80px 16px;
z-index: 3;
padding: 16px;
background: var(--ln-surface-card);
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card);
box-shadow: var(--ln-shadow-hover);
}
.cm-drawer-overlay-panel h3 {
margin: 0 0 8px;
font-size: 1rem;
}
.cm-drawer-overlay-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.cm-link-danger-btn {
background: var(--ln-error) !important;
border-color: var(--ln-error) !important;
color: #fff !important;
}
.cm-risk-detail-card {
padding: 14px 16px;
background: var(--ln-surface-card);
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card);
box-shadow: var(--ln-shadow-hover);
}
.cm-risk-detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.cm-risk-detail-head h4 {
margin: 0 0 6px;
}
.cm-risk-detail-list {
margin: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 16px;
}
.cm-risk-detail-list div {
min-width: 0;
}
.cm-risk-detail-list dt {
margin: 0;
font-size: 0.75rem;
color: var(--ln-muted);
}
.cm-risk-detail-list dd {
margin: 2px 0 0;
font-size: 0.875rem;
color: var(--ln-ink);
}
.cm-risk-detail-full {
grid-column: 1 / -1;
}
.cm-batch-selected {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.cm-batch-chip {
display: inline-flex;
padding: 2px 8px;
border-radius: var(--ln-radius-pill);
background: var(--ln-canvas-soft);
border: 1px solid var(--ln-hairline);
font-size: 0.8125rem;
color: var(--ln-body);
}
.cm-batch-risk-modal {
max-height: calc(100vh - 48px);
overflow: auto;
}
@media (max-width: 640px) {
.cm-drawer {
width: 100vw;
}
.cm-risk-form-grid,
.cm-risk-detail-list {
grid-template-columns: 1fr;
}
}

View File

@@ -1,5 +1,7 @@
export type CustomerStatus = '正常' | '黑名单';
export type CoopStatus = '已合作' | '终止合作' | '洽谈中' | '合同到期';
export interface CustomerRecord {
id: string;
code: string;
@@ -46,6 +48,7 @@ export const EMPTY_FILTERS: CustomerFilters = {
export interface CustomerCreateForm {
customerType: string;
customerLevel: string;
coopStatus: string;
customerName: string;
customerShortName: string;
cityProvince: string;
@@ -68,6 +71,7 @@ export interface CustomerCreateForm {
export const EMPTY_CREATE_FORM: CustomerCreateForm = {
customerType: '企业',
customerLevel: 'B',
coopStatus: '',
customerName: '',
customerShortName: '',
cityProvince: '',

View File

@@ -0,0 +1,98 @@
import type { CustomerRecord } from '../types';
export type RiskTagCategory = '法务' | '财务' | '运维' | '风控';
export type RiskLevel = '低' | '中' | '高' | '极高';
export type RiskTagLifecycleStatus =
| '生效中'
| '待审批'
| '待确认'
| '已驳回'
| '已过期'
| '已移除';
export type RiskTagAction =
| '新增'
| '移除'
| '变更'
| '提交审批'
| '审批通过'
| '审批驳回'
| '批量打标';
export interface RiskTagAttachment {
id: string;
name: string;
size: string;
uploadedAt: string;
}
export interface CustomerRiskTag {
id: string;
customerId: string;
tagName: string;
tagType: RiskTagCategory;
riskLevel: RiskLevel;
reason: string;
effectiveFrom: string;
effectiveTo: string;
attachments: RiskTagAttachment[];
status: RiskTagLifecycleStatus;
operator: string;
operatorRole: UserRole;
approver?: string;
approvedAt?: string;
rejectReason?: string;
createdAt: string;
updatedAt: string;
}
export interface RiskTagOperationLog {
id: string;
customerId: string;
customerName: string;
tagId?: string;
tagName: string;
action: RiskTagAction;
detail: string;
operator: string;
operatorRole: UserRole;
createdAt: string;
}
export interface RiskTagApplyForm {
tagName: string;
tagType: RiskTagCategory;
riskLevel: RiskLevel;
reason: string;
effectiveFrom: string;
effectiveTo: string;
attachments: RiskTagAttachment[];
}
export const EMPTY_RISK_TAG_FORM: RiskTagApplyForm = {
tagName: '',
tagType: '风控',
riskLevel: '中',
reason: '',
effectiveFrom: '',
effectiveTo: '',
attachments: [],
};
export type UserRole = 'finance' | 'legal' | 'risk' | 'ops' | 'sales' | 'admin';
export interface MockUser {
name: string;
role: UserRole;
department: string;
}
export type RiskTagsByCustomer = Record<string, CustomerRiskTag[]>;
export interface RiskTagContext {
records: CustomerRecord[];
riskTagsByCustomer: RiskTagsByCustomer;
operationLogs: RiskTagOperationLog[];
}

View File

@@ -0,0 +1,37 @@
import { REGION_TREE } from './constants';
const PROVINCES = [
'北京市', '天津市', '河北省', '山西省', '内蒙古自治区',
'上海市', '江苏省', '浙江省', '安徽省', '福建省', '江西省', '山东省',
'广东省', '广西壮族自治区', '海南省',
'河南省', '湖北省', '湖南省',
'辽宁省', '吉林省', '黑龙江省',
'重庆市', '四川省', '贵州省', '云南省', '西藏自治区',
'陕西省', '甘肃省', '青海省', '宁夏回族自治区', '新疆维吾尔自治区',
];
const MUNICIPALITIES = new Set(['北京市', '天津市', '上海市', '重庆市']);
export function parseAddressToRegion(fullAddress: string): {
cityProvince: string;
cityName: string;
} {
const text = fullAddress.trim();
if (!text) return { cityProvince: '', cityName: '' };
const province = PROVINCES.find((item) => text.startsWith(item));
if (!province) return { cityProvince: '', cityName: '' };
const node = REGION_TREE.find((item) => item.province === province);
if (!node) return { cityProvince: '', cityName: '' };
if (MUNICIPALITIES.has(province)) {
const municipalityCity = node.cities.find((city) => city === province) ?? node.cities[0] ?? '';
return { cityProvince: province, cityName: municipalityCity };
}
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 };
}

View File

@@ -18,6 +18,8 @@ export const CUSTOMER_TYPE_OPTIONS = ['企业', '个人', '事业单位'] as con
export const CUSTOMER_LEVEL_OPTIONS = ['A', 'B', 'C'] as const;
export const COOP_STATUS_OPTIONS = ['已合作', '终止合作', '洽谈中', '合同到期'] as const;
export const REGION_OPTIONS = [
'华北地区',
'华东地区',

View File

@@ -0,0 +1,73 @@
import type { MockUser, RiskTagCategory, UserRole } from '../types/riskTag';
import type { TagCatalogItem } from './riskTagConstants';
import { TAG_CATALOG_MAP } from './riskTagConstants';
export const MOCK_USERS: MockUser[] = [
{ name: '王风控', role: 'risk', department: '风控部' },
{ name: '李法务', role: 'legal', department: '法务部' },
{ name: '张财务', role: 'finance', department: '财务部' },
{ name: '赵运维', role: 'ops', department: '运维部' },
{ name: '陈销售', role: 'sales', department: '销售一部' },
];
export const DEFAULT_MOCK_USER = MOCK_USERS[0];
export function canManageRiskTags(user: MockUser): boolean {
return ['finance', 'legal', 'risk', 'ops', 'admin'].includes(user.role);
}
export function canBatchTag(user: MockUser): boolean {
return canManageRiskTags(user);
}
export function canApproveRiskTag(user: MockUser): boolean {
return ['legal', 'risk', '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 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;
}
export function canApplyRiskTagByName(user: MockUser, tagName: string): boolean {
const tag = TAG_CATALOG_MAP[tagName];
if (!tag) return false;
return canApplyRiskTag(user, tag);
}
export function roleLabel(role: UserRole): string {
const map: Record<UserRole, string> = {
finance: '财务',
legal: '法务',
risk: '风控',
ops: '运维',
sales: '销售',
admin: '管理员',
};
return map[role];
}

View File

@@ -0,0 +1,55 @@
import type { RiskLevel, RiskTagCategory, UserRole } from '../types/riskTag';
export interface TagCatalogItem {
tagName: string;
tagType: RiskTagCategory;
groupName: string;
defaultLevel: RiskLevel;
requiresApproval: boolean;
}
/** 标签目录 — 分组与级别对齐业务规范 */
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_CATALOG_MAP = Object.fromEntries(
TAG_CATALOG.map((item) => [item.tagName, item]),
) as Record<string, TagCatalogItem>;
/** 下拉分组展示顺序 */
export const TAG_GROUPS: Array<{ groupName: string; tagNames: string[] }> = [
{ groupName: '信用与回款', tagNames: ['逾期预警', '严重拖欠', '频繁改账期'] },
{ groupName: '合规与法律', tagNames: ['资质存疑', '涉诉/仲裁', '制裁/高风险地区'] },
{ groupName: '经营与履约', tagNames: ['频繁争议', '车辆/资产高风险', '信息不实'] },
{ groupName: '操作与流程', tagNames: ['开票异常', '多头签约'] },
];
export const RISK_TAG_CATEGORIES: RiskTagCategory[] = ['法务', '财务', '运维', '风控'];
export const RISK_LEVELS: RiskLevel[] = ['低', '中', '高', '极高'];
export const HIGH_RISK_LEVELS: RiskLevel[] = ['高', '极高'];
export const ROLE_LABELS: Record<UserRole, string> = {
finance: '财务',
legal: '法务',
risk: '风控',
ops: '运维',
sales: '销售',
admin: '管理员',
};

View File

@@ -0,0 +1,221 @@
import type { CustomerRecord } from '../types';
import type {
CustomerRiskTag,
MockUser,
RiskTagApplyForm,
RiskTagOperationLog,
RiskTagsByCustomer,
RiskTagLifecycleStatus,
} from '../types/riskTag';
import { HIGH_RISK_LEVELS, TAG_CATALOG_MAP } from './riskTagConstants';
import seedOperationLogs from '../data/risk-tag-operation-logs.json';
let idCounter = 1000;
export function nextId(prefix: string): string {
idCounter += 1;
return `${prefix}-${idCounter}`;
}
export function formatNow(): string {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
}
export function todayDate(): string {
return formatNow().slice(0, 10);
}
export function defaultEffectiveTo(): string {
const d = new Date();
d.setFullYear(d.getFullYear() + 1);
return d.toISOString().slice(0, 10);
}
export function resolveInitialStatus(riskLevel: CustomerRiskTag['riskLevel']): RiskTagLifecycleStatus {
return HIGH_RISK_LEVELS.includes(riskLevel) ? '待审批' : '生效中';
}
export function buildSeedRiskTags(records: CustomerRecord[]): RiskTagsByCustomer {
const map: 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 {
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,
};
});
});
return map;
}
export function syncRecordLabels(
records: CustomerRecord[],
riskTagsByCustomer: RiskTagsByCustomer,
): CustomerRecord[] {
return records.map((record) => ({
...record,
labels: (riskTagsByCustomer[record.id] ?? [])
.filter((tag) => tag.status === '生效中')
.map((tag) => tag.tagName),
}));
}
export function getActiveTags(tags: CustomerRiskTag[]): CustomerRiskTag[] {
return tags.filter((tag) => tag.status === '生效中');
}
export function getPendingTags(tags: CustomerRiskTag[]): CustomerRiskTag[] {
return tags.filter((tag) => tag.status === '待审批' || tag.status === '待确认');
}
export function createOperationLog(
partial: Omit<RiskTagOperationLog, 'id' | 'createdAt'>,
): RiskTagOperationLog {
return {
...partial,
id: nextId('log'),
createdAt: formatNow(),
};
}
export function buildApplyTag(
customerId: string,
form: RiskTagApplyForm,
user: MockUser,
): CustomerRiskTag {
const now = formatNow();
const status = resolveInitialStatus(form.riskLevel);
return {
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,
status,
operator: user.name,
operatorRole: user.role,
createdAt: now,
updatedAt: now,
};
}
export function buildApplyLog(
tag: CustomerRiskTag,
customerName: string,
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}`;
return createOperationLog({
customerId: tag.customerId,
customerName,
tagId: tag.id,
tagName: tag.tagName,
action: tag.status === '待审批' ? '提交审批' : action,
detail,
operator: user.name,
operatorRole: user.role,
});
}
export function buildRemoveLog(
tag: CustomerRiskTag,
customerName: string,
user: MockUser,
reason: string,
): RiskTagOperationLog {
return createOperationLog({
customerId: tag.customerId,
customerName,
tagId: tag.id,
tagName: tag.tagName,
action: '移除',
detail: `移除标签「${tag.tagName}」,原因:${reason}`,
operator: user.name,
operatorRole: user.role,
});
}
export function buildApprovalLog(
tag: CustomerRiskTag,
customerName: string,
user: MockUser,
approved: boolean,
rejectReason?: string,
): RiskTagOperationLog {
return createOperationLog({
customerId: tag.customerId,
customerName,
tagId: tag.id,
tagName: tag.tagName,
action: approved ? '审批通过' : '审批驳回',
detail: approved
? `审批通过标签「${tag.tagName}」,标签已生效`
: `驳回标签「${tag.tagName}${rejectReason ? `${rejectReason}` : ''}`,
operator: user.name,
operatorRole: user.role,
});
}
export function hasDuplicateActiveTag(
tags: CustomerRiskTag[],
tagName: string,
): boolean {
return tags.some(
(tag) => tag.tagName === tagName && (tag.status === '生效中' || tag.status === '待审批' || tag.status === '待确认'),
);
}
export const INITIAL_OPERATION_LOGS = seedOperationLogs as RiskTagOperationLog[];
export function catalogDefaultsForTag(tagName: string): Pick<RiskTagApplyForm, 'tagType' | 'riskLevel'> {
const catalog = TAG_CATALOG_MAP[tagName];
if (!catalog) {
return { tagType: '风控', riskLevel: '中' };
}
return { tagType: catalog.tagType, riskLevel: catalog.defaultLevel };
}
export function validateApplyForm(form: RiskTagApplyForm): string | null {
if (!form.tagName.trim()) return '请选择标签名称';
if (!form.tagType) 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 '有效期不能早于生效时间';
return null;
}

View File

@@ -0,0 +1,196 @@
{
"documentVersion": 1,
"format": "axhub-annotation-source",
"data": {
"version": 2,
"prototypeName": "supplier-management",
"pageId": "list",
"updatedAt": 1782697342692,
"nodes": [
{
"id": "sm-list-filter",
"index": 1,
"title": "筛选条件",
"pageId": "list",
"locator": {
"selectors": ["[data-annotation-id=\"sm-list-filter\"]"],
"fingerprint": "sm-list-filter",
"path": []
},
"aiPrompt": "供应商列表筛选区字段与查询逻辑。",
"annotationText": "默认展示 4 项,点击「更多筛选」显示全部 6 项。",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "sm-kpi-all",
"index": 2,
"title": "统计卡片",
"pageId": "list",
"locator": {
"selectors": ["[data-annotation-id=\"sm-kpi-all\"]"],
"fingerprint": "sm-kpi-all",
"path": []
},
"aiPrompt": "KPI 卡片点击切换列表数据范围。",
"annotationText": "参照车辆管理列表页8 张统计卡片可点击筛选。",
"hasMarkdown": true,
"color": "#0f766e",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "sm-list-toolbar",
"index": 3,
"title": "列表工具栏",
"pageId": "list",
"locator": {
"selectors": ["[data-annotation-id=\"sm-list-toolbar\"]"],
"fingerprint": "sm-list-toolbar",
"path": []
},
"aiPrompt": "新增、导入、导出入口。",
"annotationText": "「新增供应商」进入分组表单页。",
"hasMarkdown": true,
"color": "#0f766e",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "sm-list-table",
"index": 4,
"title": "供应商台账列表",
"pageId": "list",
"locator": {
"selectors": ["[data-annotation-id=\"sm-list-table\"]"],
"fingerprint": "sm-list-table",
"path": []
},
"aiPrompt": "供应商台账表格列与行操作。",
"annotationText": "",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "sm-create-entity",
"index": 5,
"title": "主体信息",
"pageId": "create",
"locator": {
"selectors": ["[data-annotation-id=\"sm-create-entity\"]"],
"fingerprint": "sm-create-entity",
"path": []
},
"aiPrompt": "签约公司、供应商名称、地址解析字段。",
"annotationText": "签约公司下拉默认展示置灰占位「请选择羚牛签约主体」,不可作为有效选项。地址解析为必填项,填写完整地址后自动反写省市与详细地址。",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "sm-create-invoice",
"index": 6,
"title": "开票信息",
"pageId": "create",
"locator": {
"selectors": ["[data-annotation-id=\"sm-create-invoice\"]"],
"fingerprint": "sm-create-invoice",
"path": []
},
"aiPrompt": "纳税人识别号、注册电话、注册地址。",
"annotationText": "",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "sm-create-bank",
"index": 7,
"title": "银行账户",
"pageId": "create",
"locator": {
"selectors": ["[data-annotation-id=\"sm-create-bank\"]"],
"fingerprint": "sm-create-bank",
"path": []
},
"aiPrompt": "开户行与银行账号。",
"annotationText": "",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "sm-create-docs",
"index": 8,
"title": "资质证照",
"pageId": "create",
"locator": {
"selectors": ["[data-annotation-id=\"sm-create-docs\"]"],
"fingerprint": "sm-create-docs",
"path": []
},
"aiPrompt": "营业执照 OCR 与其他证件上传。",
"annotationText": "营业执照上传后自动反写名称、税号、通讯地址。",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
},
{
"id": "sm-create-footer",
"index": 9,
"title": "表单提交",
"pageId": "create",
"locator": {
"selectors": ["[data-annotation-id=\"sm-create-footer\"]"],
"fingerprint": "sm-create-footer",
"path": []
},
"aiPrompt": "返回、保存与提交操作。",
"annotationText": "底部固定操作栏:左侧「返回」回到列表;右侧「保存」暂存草稿,「提交」校验通过后提交并返回列表。",
"hasMarkdown": true,
"color": "#0f766e",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
}
],
"directory": {
"roots": [
{
"id": "root",
"title": "供应商管理",
"children": [
{
"id": "list",
"title": "列表页",
"pageId": "list",
"payload": { "pageId": "list" }
},
{
"id": "create",
"title": "新增页",
"pageId": "create",
"payload": { "pageId": "create" }
}
]
}
]
}
}
}

View File

@@ -0,0 +1,10 @@
{
"type": "excalidraw",
"version": 2,
"source": "@axhub/make",
"elements": [],
"appState": {
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import type { CoopStatus } from '../types';
const STATUS_CLASS: Record<CoopStatus, string> = {
: 'sm-status-cooperated',
: 'sm-status-negotiating',
: 'sm-status-terminated',
: 'sm-status-expired',
};
interface CoopStatusBadgeProps {
status: CoopStatus;
}
export function CoopStatusBadge({ status }: CoopStatusBadgeProps) {
return (
<span className={`sm-status-badge ${STATUS_CLASS[status]}`}>
<span className="sm-status-dot" aria-hidden />
{status}
</span>
);
}

View File

@@ -0,0 +1,180 @@
import React, { useMemo, useState } from 'react';
import { ChevronDown, Search } from 'lucide-react';
import type { SupplierFilters, SupplierRecord } from '../types';
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
import { MultiSelectField } from '../../customer-management/components/MultiSelectField';
import {
COOP_STATUS_OPTIONS,
REGION_OPTIONS,
SUPPLIER_TYPE_OPTIONS,
} from '../utils/constants';
import { buildCodeOptions, buildOptions } from '../utils/filters';
const DEFAULT_VISIBLE_COUNT = 4;
interface FilterPanelProps {
records: SupplierRecord[];
filters: SupplierFilters;
onChange: (patch: Partial<SupplierFilters>) => void;
onReset: () => void;
onSearch: () => void;
}
export function FilterPanel({
records,
filters,
onChange,
onReset,
onSearch,
}: FilterPanelProps) {
const [expanded, setExpanded] = useState(false);
const codeOptions = useMemo(() => buildCodeOptions(records), [records]);
const nameOptions = useMemo(() => buildOptions(records, 'supplierName'), [records]);
const cityOptions = useMemo(() => buildOptions(records, 'city'), [records]);
const creatorOptions = useMemo(() => buildOptions(records, 'creator'), [records]);
const codePickerOptions = codeOptions.map((item) => item.label);
const codeLabelToValue = useMemo(
() => Object.fromEntries(codeOptions.map((item) => [item.label, item.value])),
[codeOptions],
);
const codeValueToLabel = useMemo(
() => Object.fromEntries(codeOptions.map((item) => [item.value, item.label])),
[codeOptions],
);
const fields = [
{
label: '供应商名称',
control: (
<>
<input
className="vm-input"
value={filters.nameKeyword}
placeholder="请选择或输入供应商名称"
aria-label="供应商名称"
list="sm-supplier-name-options"
onChange={(event) => onChange({ nameKeyword: event.target.value })}
/>
<datalist id="sm-supplier-name-options">
{nameOptions.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</>
),
},
{
label: '供应商编码',
control: (
<FilterPickerField
value={filters.code ? (codeValueToLabel[filters.code] ?? '') : ''}
options={codePickerOptions}
placeholder="请选择或搜索供应商编码"
ariaLabel="供应商编码"
onChange={(label) => onChange({ code: codeLabelToValue[label] ?? '' })}
/>
),
},
{
label: '合作状态',
control: (
<MultiSelectField
values={filters.coopStatuses}
options={[...COOP_STATUS_OPTIONS]}
placeholder="请选择合作状态"
ariaLabel="合作状态"
onChange={(coopStatuses) => onChange({ coopStatuses: coopStatuses as SupplierFilters['coopStatuses'] })}
/>
),
},
{
label: '类型',
control: (
<MultiSelectField
values={filters.types}
options={[...SUPPLIER_TYPE_OPTIONS]}
placeholder="请选择供应商类型"
ariaLabel="类型"
onChange={(types) => onChange({ types: types as SupplierFilters['types'] })}
/>
),
},
{
label: '区域',
control: (
<MultiSelectField
values={filters.regions}
options={[...REGION_OPTIONS]}
placeholder="请选择区域"
ariaLabel="区域"
onChange={(regions) => onChange({ regions })}
/>
),
},
{
label: '城市',
control: (
<FilterPickerField
value={filters.city}
options={cityOptions}
placeholder="请选择城市"
ariaLabel="城市"
onChange={(city) => onChange({ city })}
/>
),
},
{
label: '创建人',
control: (
<FilterPickerField
value={filters.creator}
options={creatorOptions}
placeholder="请选择创建人"
ariaLabel="创建人"
onChange={(creator) => onChange({ creator })}
/>
),
},
];
const visibleFields = expanded ? fields : fields.slice(0, DEFAULT_VISIBLE_COUNT);
return (
<section className="vm-filter-card sm-filter-card" data-annotation-id="sm-list-filter">
<header className="vm-filter-header">
<h2 className="vm-filter-title"></h2>
</header>
<div className="vm-filter-grid">
{visibleFields.map((field) => (
<label key={field.label} className="vm-filter-field">
<span>{field.label}</span>
{field.control}
</label>
))}
</div>
<div className="vm-filter-actions">
{fields.length > DEFAULT_VISIBLE_COUNT && (
<button
type="button"
className="vm-btn vm-btn-link"
onClick={() => setExpanded(!expanded)}
>
{expanded ? '收起' : '更多筛选'}
<ChevronDown
size={14}
aria-hidden
style={{ transform: expanded ? 'rotate(180deg)' : undefined, transition: 'transform 0.2s' }}
/>
</button>
)}
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
<Search size={16} aria-hidden />
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import {
CircleHelp,
Building2,
Handshake,
MessageSquare,
Ban,
Clock,
Package,
Shield,
FileWarning,
} from 'lucide-react';
import type { SupplierKpiKey } from '../types';
import { KPI_CARDS } from '../utils/constants';
const ICONS: Record<SupplierKpiKey, React.ReactNode> = {
all: <Building2 size={18} aria-hidden />,
cooperated: <Handshake size={18} aria-hidden />,
negotiating: <MessageSquare size={18} aria-hidden />,
terminated: <Ban size={18} aria-hidden />,
expired: <Clock size={18} aria-hidden />,
parts: <Package size={18} aria-hidden />,
insurance: <Shield size={18} aria-hidden />,
licenseAbnormal: <FileWarning size={18} aria-hidden />,
};
interface KpiCardsProps {
counts: Record<SupplierKpiKey, number>;
active: SupplierKpiKey;
onChange: (key: SupplierKpiKey) => void;
}
export function KpiCards({ counts, active, onChange }: KpiCardsProps) {
return (
<div className="vm-kpi-row">
{KPI_CARDS.map((card) => (
<button
key={card.key}
type="button"
className={`vm-kpi-card ${active === card.key ? 'active' : ''}`}
data-annotation-id={`sm-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>
);
}

View File

@@ -0,0 +1,99 @@
import React, { useState } from 'react';
import type { SupplierRecord } from '../types';
import { CoopStatusBadge } from './CoopStatusBadge';
interface SupplierTableProps {
records: SupplierRecord[];
onView: (record: SupplierRecord) => void;
onEdit: (record: SupplierRecord) => void;
onDelete: (record: SupplierRecord) => void;
}
export function SupplierTable({
records,
onView,
onEdit,
onDelete,
}: SupplierTableProps) {
const [hoverId, setHoverId] = useState<string | null>(null);
return (
<>
{records.length === 0 ? (
<div className="vm-empty" data-annotation-id="sm-list-table">
<p></p>
</div>
) : (
<div className="vm-table-wrap sm-table-wrap" data-annotation-id="sm-list-table">
<table className="vm-table sm-table">
<thead>
<tr>
<th className="sm-col-code sticky-col-left"></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>/</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th className="sm-col-time"></th>
<th className="sticky-col-right sm-col-actions"></th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr
key={record.id}
onMouseEnter={() => setHoverId(record.id)}
onMouseLeave={() => setHoverId(null)}
className={hoverId === record.id ? 'is-hovered' : undefined}
>
<td className="sm-col-code sticky-col-left tabular-nums">{record.supplierCode}</td>
<td><CoopStatusBadge status={record.coopStatus} /></td>
<td className="sm-col-name">
<button
type="button"
className="vm-btn vm-btn-link sm-name-link"
onClick={() => onView(record)}
>
{record.supplierName}
</button>
</td>
<td>{record.type}</td>
<td>{record.region}</td>
<td>{record.city}</td>
<td className="sm-col-credit tabular-nums" title={record.creditCodeOrId}>
{record.creditCodeOrId}
</td>
<td>{record.contact}</td>
<td className="tabular-nums">{record.contactMobile}</td>
<td>{record.contactPhone === '-' || !record.contactPhone ? '—' : record.contactPhone}</td>
<td className="sm-col-address" title={record.address}>{record.address}</td>
<td className="sm-col-email" title={record.email}>{record.email}</td>
<td className="sm-col-remark" title={record.remark}>{record.remark || '—'}</td>
<td>{record.creator}</td>
<td className="sm-col-time tabular-nums">{record.createTime}</td>
<td className="sticky-col-right sm-col-actions">
<div className="sm-row-actions">
<button type="button" className="vm-btn vm-btn-link" onClick={() => onView(record)}></button>
<span className="sm-action-sep" aria-hidden>|</span>
<button type="button" className="vm-btn vm-btn-link" onClick={() => onEdit(record)}></button>
<span className="sm-action-sep" aria-hidden>|</span>
<button type="button" className="vm-btn vm-btn-link sm-link-danger" onClick={() => onDelete(record)}></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,154 @@
[
{
"id": "1",
"supplierCode": "GYS-2025-001",
"coopStatus": "已合作",
"supplierName": "嘉兴某某加氢站",
"type": "加氢站",
"region": "华东",
"city": "浙江省-嘉兴市",
"creditCodeOrId": "91330400MA2XXXXX1",
"contact": "张三",
"contactMobile": "13800138001",
"contactPhone": "0571-88888888",
"address": "浙江省嘉兴市南湖区科技大道1号",
"email": "zhangsan@example.com",
"remark": "",
"creator": "李四",
"createTime": "2025-01-10 09:30",
"licenseStatus": "正常"
},
{
"id": "2",
"supplierCode": "GYS-2025-002",
"coopStatus": "洽谈中",
"supplierName": "上海某某保险公司",
"type": "保险公司",
"region": "华东",
"city": "上海市-上海市",
"creditCodeOrId": "91310000MA2YYYYY2",
"contact": "王五",
"contactMobile": "13900139002",
"contactPhone": "-",
"address": "上海市浦东新区张江镇",
"email": "wangwu@example.com",
"remark": "意向供应商",
"creator": "李专员",
"createTime": "2025-02-15 14:20",
"licenseStatus": "正常"
},
{
"id": "3",
"supplierCode": "GYS-2025-003",
"coopStatus": "已合作",
"supplierName": "杭州某某维修站",
"type": "维修站",
"region": "华东",
"city": "浙江省-杭州市",
"creditCodeOrId": "91330100MA2ZZZZZ3",
"contact": "赵六",
"contactMobile": "13700137003",
"contactPhone": "0571-99999999",
"address": "浙江省杭州市余杭区未来科技城",
"email": "zhaoliu@example.com",
"remark": "",
"creator": "张经理",
"createTime": "2025-01-20 11:00",
"licenseStatus": "异常"
},
{
"id": "4",
"supplierCode": "GYS-2024-012",
"coopStatus": "终止合作",
"supplierName": "某某备件供应商",
"type": "备件供应商",
"region": "华南",
"city": "广东省-广州市",
"creditCodeOrId": "91440100MA2AAAAA4",
"contact": "孙七",
"contactMobile": "13600136004",
"contactPhone": "-",
"address": "广东省广州市天河区",
"email": "sunqi@example.com",
"remark": "已终止",
"creator": "李四",
"createTime": "2024-06-01 10:00",
"licenseStatus": "正常"
},
{
"id": "5",
"supplierCode": "GYS-2025-004",
"coopStatus": "合约过期",
"supplierName": "南京某某整车厂",
"type": "整车厂",
"region": "华东",
"city": "江苏省-南京市",
"creditCodeOrId": "91320100MA2BBBBB5",
"contact": "周八",
"contactMobile": "13500135005",
"contactPhone": "025-66666666",
"address": "江苏省南京市江宁区",
"email": "zhouba@example.com",
"remark": "待续约",
"creator": "王专员",
"createTime": "2025-03-01 08:45",
"licenseStatus": "异常"
},
{
"id": "6",
"supplierCode": "GYS-2025-005",
"coopStatus": "已合作",
"supplierName": "深圳某某充电站",
"type": "充电站",
"region": "华南",
"city": "广东省-深圳市",
"creditCodeOrId": "91440300MA2CCCCC6",
"contact": "钱九",
"contactMobile": "13400134006",
"contactPhone": "0755-77777777",
"address": "广东省深圳市南山区科技园",
"email": "qianjiu@example.com",
"remark": "",
"creator": "张经理",
"createTime": "2025-03-12 16:10",
"licenseStatus": "正常"
},
{
"id": "7",
"supplierCode": "GYS-2025-006",
"coopStatus": "已合作",
"supplierName": "北京某某备件中心",
"type": "备件供应商",
"region": "华北",
"city": "北京市-北京市",
"creditCodeOrId": "91110100MA2DDDDD7",
"contact": "吴十",
"contactMobile": "13300133007",
"contactPhone": "010-55555555",
"address": "北京市朝阳区望京街道",
"email": "wushi@example.com",
"remark": "",
"creator": "李四",
"createTime": "2025-04-02 10:20",
"licenseStatus": "正常"
},
{
"id": "8",
"supplierCode": "GYS-2025-007",
"coopStatus": "洽谈中",
"supplierName": "苏州某某救援车队",
"type": "救援车队",
"region": "华东",
"city": "江苏省-苏州市",
"creditCodeOrId": "91320500MA2EEEEE8",
"contact": "郑十一",
"contactMobile": "13200132008",
"contactPhone": "-",
"address": "江苏省苏州市工业园区",
"email": "zheng11@example.com",
"remark": "评估中",
"creator": "李专员",
"createTime": "2025-04-18 11:35",
"licenseStatus": "正常"
}
]

View File

@@ -0,0 +1,319 @@
/**
* @name 供应商管理
*/
import '../vehicle-management/style.css';
import './styles/index.css';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Download, FileUp, Plus } from 'lucide-react';
import {
AnnotationViewer,
type AnnotationDirectoryRouteNode,
type AnnotationSourceDocument,
type AnnotationViewerOptions,
} from '@axhub/annotation';
import seedSuppliers from './data/suppliers.json';
import { FilterPanel } from './components/FilterPanel';
import { KpiCards } from './components/KpiCards';
import { SupplierTable } from './components/SupplierTable';
import { CreatePage } from './pages/CreatePage';
import { ImportModal } from '../vehicle-management/components/ImportModal';
import { TablePagination } from '../vehicle-management/components/TablePagination';
import {
EMPTY_CREATE_FORM,
EMPTY_FILTERS,
type SupplierCreateForm,
type SupplierFilters,
type SupplierKpiKey,
type SupplierPageId,
type SupplierRecord,
} from './types';
import { applyFilters, countKpi } from './utils/filters';
import annotationSourceDocument from './annotation-source.json';
const DEFAULT_PAGE_SIZE = 10;
type NavigationRef = {
openCreate?: () => void;
backToList?: () => void;
};
function validateCreateForm(form: SupplierCreateForm): string | null {
if (!form.contractingCompany) return '请选择签约公司';
if (!form.supplierName.trim()) return '请输入供应商名称';
if (!form.fullAddress.trim()) return '请填写地址解析';
return null;
}
export default function SupplierManagementApp() {
const [records] = useState<SupplierRecord[]>(seedSuppliers as SupplierRecord[]);
const [view, setView] = useState<SupplierPageId>('list');
const [pendingFilters, setPendingFilters] = useState<SupplierFilters>(EMPTY_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<SupplierFilters>(EMPTY_FILTERS);
const [kpiTab, setKpiTab] = useState<SupplierKpiKey>('all');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [createForm, setCreateForm] = useState<SupplierCreateForm>(EMPTY_CREATE_FORM);
const [importOpen, setImportOpen] = useState(false);
const [toast, setToast] = useState('');
const navigationRef = useRef<NavigationRef>({});
const showToast = useCallback((msg: string) => {
setToast(msg);
window.setTimeout(() => setToast(''), 3000);
}, []);
navigationRef.current = {
openCreate: () => {
setCreateForm(EMPTY_CREATE_FORM);
setView('create');
},
backToList: () => setView('list'),
};
const kpiCounts = useMemo(() => ({
all: countKpi(records, 'all'),
cooperated: countKpi(records, 'cooperated'),
negotiating: countKpi(records, 'negotiating'),
terminated: countKpi(records, 'terminated'),
expired: countKpi(records, 'expired'),
parts: countKpi(records, 'parts'),
insurance: countKpi(records, 'insurance'),
licenseAbnormal: countKpi(records, 'licenseAbnormal'),
}), [records]);
const filtered = useMemo(
() => applyFilters(records, appliedFilters, kpiTab),
[records, appliedFilters, kpiTab],
);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const paged = useMemo(() => {
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [filtered, page, pageSize]);
const handleSearch = () => {
setAppliedFilters(pendingFilters);
setPage(1);
};
const handleReset = () => {
setPendingFilters(EMPTY_FILTERS);
setAppliedFilters(EMPTY_FILTERS);
setPage(1);
setKpiTab('all');
};
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setPage(1);
};
const handleOpenCreate = () => {
setCreateForm(EMPTY_CREATE_FORM);
setView('create');
};
const handleSaveCreate = () => {
const error = validateCreateForm(createForm);
if (error) {
showToast(error);
return;
}
showToast('已保存草稿(原型演示)');
};
const handleSubmitCreate = () => {
const error = validateCreateForm(createForm);
if (error) {
showToast(error);
return;
}
showToast('供应商已提交(原型演示)');
setView('list');
};
const handleImport = (file: File) => {
showToast(`已接收文件 ${file.name},导入校验为原型演示`);
setImportOpen(false);
};
const handleDirectoryRoute = useCallback((node: AnnotationDirectoryRouteNode) => {
const payload = node.payload as { pageId?: string } | undefined;
const pageId: SupplierPageId = payload?.pageId === 'create' ? 'create' : 'list';
if (pageId === 'create') {
navigationRef.current.openCreate?.();
} else {
navigationRef.current.backToList?.();
}
}, []);
const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({
showToolbar: true,
showThemeToggle: true,
showColorFilter: true,
emptyWhenNoData: false,
toolbarEdge: 'right',
currentPageId: view,
onDirectoryRoute: handleDirectoryRoute,
}),
[view, handleDirectoryRoute],
);
return (
<>
<div className="vm-page sm-page">
{view === 'create' ? (
<CreatePage
form={createForm}
onChange={(patch) => setCreateForm((prev) => ({ ...prev, ...patch }))}
onSave={handleSaveCreate}
onSubmit={handleSubmitCreate}
onBack={() => setView('list')}
/>
) : (
<>
<nav className="sm-breadcrumb sm-breadcrumb-list" aria-label="面包屑">
<span className="sm-breadcrumb-muted"></span>
</nav>
<FilterPanel
records={records}
filters={pendingFilters}
onChange={(patch) => setPendingFilters((prev) => ({ ...prev, ...patch }))}
onReset={handleReset}
onSearch={handleSearch}
/>
<KpiCards
counts={kpiCounts}
active={kpiTab}
onChange={(key) => { setKpiTab(key); setPage(1); }}
/>
<section className="vm-table-section">
<div className="vm-table-toolbar" data-annotation-id="sm-list-toolbar">
<div className="vm-table-actions">
<button type="button" className="vm-btn vm-btn-primary" onClick={handleOpenCreate}>
<Plus size={16} aria-hidden />
</button>
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => setImportOpen(true)}>
<FileUp size={16} aria-hidden />
</button>
</div>
<button
type="button"
className="vm-btn vm-btn-ghost"
onClick={() => showToast(`导出功能(原型演示):当前列表共 ${filtered.length}`)}
>
<Download size={16} aria-hidden />
</button>
</div>
<div className="vm-table-card">
<SupplierTable
records={paged}
onView={(record) => showToast(`查看:${record.supplierName}(原型演示)`)}
onEdit={(record) => showToast(`编辑:${record.supplierName}(原型演示)`)}
onDelete={(record) => showToast(`删除:${record.supplierName}(原型演示)`)}
/>
<div className="vm-table-footer sm-table-footer">
<div className="sm-table-total"> {filtered.length} </div>
<TablePagination
page={Math.min(page, totalPages)}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</section>
</>
)}
{toast && <div className="vm-toast" role="status" aria-live="polite">{toast}</div>}
</div>
<ImportModal open={importOpen} onClose={() => setImportOpen(false)} onImport={handleImport} />
<AnnotationViewer
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>
</>
);
}
if (typeof window !== 'undefined') {
const applySupplierManagementAnnotationFixes = () => {
if (window.location.pathname !== '/prototypes/supplier-management') return;
const footer = document.querySelector('.sm-create-footer');
if (footer && !footer.getAttribute('data-annotation-buttons-applied')) {
const originalButtons = Array.from(footer.querySelectorAll('button'));
const createFooterButton = (textContent: string, type: 'button' | 'submit', index: number) => {
const button = (originalButtons[index] ?? originalButtons[0])?.cloneNode(false) as
| HTMLButtonElement
| undefined;
const nextButton = button ?? document.createElement('button');
nextButton.type = type;
nextButton.textContent = textContent;
return nextButton;
};
footer.setAttribute('data-annotation-buttons-applied', 'true');
footer.replaceChildren(
createFooterButton('返回', 'button', 0),
createFooterButton('保存', 'button', 0),
createFooterButton('提交', 'submit', 1),
);
}
const subjectSelect = document.querySelector(
'section:nth-of-type(1) label:nth-of-type(1) select.vm-input',
) as HTMLSelectElement | null;
if (subjectSelect) {
const firstOption = subjectSelect.options[0];
if (firstOption?.textContent?.trim() === '请选择羚牛签约主体') {
firstOption.value = '';
firstOption.disabled = true;
firstOption.hidden = true;
if (!subjectSelect.value || subjectSelect.value === '请选择羚牛签约主体') {
subjectSelect.value = '';
}
subjectSelect.style.color = subjectSelect.value ? '' : '#8a8f99';
if (!subjectSelect.getAttribute('data-placeholder-color-applied')) {
subjectSelect.setAttribute('data-placeholder-color-applied', 'true');
subjectSelect.addEventListener('change', () => {
subjectSelect.style.color = subjectSelect.value ? '' : '#8a8f99';
});
}
}
}
const firstSection = document.querySelector('section:nth-of-type(1)');
if (firstSection && !firstSection.getAttribute('data-address-parse-required-applied')) {
const walker = document.createTreeWalker(firstSection, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
if (node.nodeValue?.trim() === '地址解析') {
node.nodeValue = node.nodeValue.replace('地址解析', '地址解析*');
firstSection.setAttribute('data-address-parse-required-applied', 'true');
break;
}
node = walker.nextNode();
}
}
};
window.addEventListener('DOMContentLoaded', applySupplierManagementAnnotationFixes);
new MutationObserver(applySupplierManagementAnnotationFixes).observe(document.documentElement, {
childList: true,
subtree: true,
});
}

View File

@@ -0,0 +1,265 @@
import React, { useRef } from 'react';
import { Upload } from 'lucide-react';
import type { SupplierCreateForm } from '../types';
import { CONTRACTING_COMPANY_OPTIONS } from '../utils/constants';
import { parseFullAddress } from '../utils/address';
interface CreatePageProps {
form: SupplierCreateForm;
onChange: (patch: Partial<SupplierCreateForm>) => void;
onSave: () => void;
onSubmit: () => void;
onBack: () => void;
}
function FormField({
label,
required,
hint,
fullWidth,
children,
}: {
label: string;
required?: boolean;
hint?: string;
fullWidth?: boolean;
children: React.ReactNode;
}) {
return (
<label className={`sm-form-field ${fullWidth ? 'sm-form-field-full' : ''}`}>
<span className="sm-form-label">
{label}
{required && <span className="sm-required" aria-hidden>*</span>}
</span>
{children}
{hint && <span className="sm-form-hint">{hint}</span>}
</label>
);
}
function UploadField({
label,
hint,
accept,
multiple,
fileNames,
onUpload,
}: {
label: string;
hint: string;
accept: string;
multiple?: boolean;
fileNames: string[];
onUpload: (files: FileList | null) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="sm-upload-field">
<span className="sm-form-label">{label}</span>
<button
type="button"
className="vm-upload-zone sm-upload-zone"
onClick={() => inputRef.current?.click()}
>
<Upload size={28} aria-hidden />
<p></p>
<p className="hint">{hint}</p>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
className="sr-only"
onChange={(event) => onUpload(event.target.files)}
/>
</button>
{fileNames.length > 0 && (
<ul className="sm-upload-files">
{fileNames.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
)}
</div>
);
}
export function CreatePage({
form,
onChange,
onSave,
onSubmit,
onBack,
}: CreatePageProps) {
const handleAddressParse = (fullAddress: string) => {
const parsed = parseFullAddress(fullAddress);
onChange({
fullAddress,
provinceCity: parsed.provinceCity,
detailAddress: parsed.detailAddress,
});
};
const handleBusinessLicenseUpload = (files: FileList | null) => {
const file = files?.[0];
if (!file) return;
onChange({
businessLicenseFile: file.name,
supplierName: form.supplierName || '嘉兴某某科技有限公司',
taxId: form.taxId || '91330400MA2XXXXX1',
regAddress: form.regAddress || '浙江省嘉兴市南湖区科技大道1号',
});
};
const handleOtherDocsUpload = (files: FileList | null) => {
if (!files?.length) return;
const names = Array.from(files).map((file) => file.name);
onChange({ otherDocFiles: [...form.otherDocFiles, ...names] });
};
return (
<div className="sm-create-page">
<nav className="sm-breadcrumb" aria-label="面包屑">
<button type="button" className="sm-breadcrumb-link" onClick={onBack}></button>
<span aria-hidden>/</span>
<button type="button" className="sm-breadcrumb-link" onClick={onBack}></button>
<span aria-hidden>/</span>
<span className="sm-breadcrumb-current"></span>
</nav>
<section className="sm-form-card" data-annotation-id="sm-create-entity">
<h2 className="sm-form-card-title"></h2>
<div className="sm-form-grid">
<FormField label="签约公司" required>
<select
className={`vm-input ${!form.contractingCompany ? 'sm-select-placeholder' : ''}`}
value={form.contractingCompany}
onChange={(event) => onChange({ contractingCompany: event.target.value })}
>
<option value="" disabled></option>
{CONTRACTING_COMPANY_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</FormField>
<FormField label="供应商名称" required>
<input
className="vm-input"
value={form.supplierName}
placeholder="请输入供应商名称"
onChange={(event) => onChange({ supplierName: event.target.value })}
/>
</FormField>
<FormField label="省市">
<input
className="vm-input sm-input-disabled"
value={form.provinceCity}
placeholder="自动从地址解析"
disabled
readOnly
/>
</FormField>
<FormField label="详细地址">
<input
className="vm-input sm-input-disabled"
value={form.detailAddress}
placeholder="自动从地址解析"
disabled
readOnly
/>
</FormField>
<FormField label="地址解析" required fullWidth>
<input
className="vm-input"
value={form.fullAddress}
placeholder="填写完整地址自动解析省市和详细地址"
onChange={(event) => handleAddressParse(event.target.value)}
/>
</FormField>
</div>
</section>
<section className="sm-form-card" data-annotation-id="sm-create-invoice">
<h2 className="sm-form-card-title"></h2>
<div className="sm-form-grid">
<FormField label="纳税人识别号">
<input
className="vm-input"
value={form.taxId}
placeholder="请输入纳税人识别号"
onChange={(event) => onChange({ taxId: event.target.value })}
/>
</FormField>
<FormField label="注册电话">
<input
className="vm-input"
value={form.regPhone}
placeholder="手机号或固定电话"
onChange={(event) => onChange({ regPhone: event.target.value })}
/>
</FormField>
<FormField label="注册地址">
<input
className="vm-input"
value={form.regAddress}
placeholder="请输入注册地址"
onChange={(event) => onChange({ regAddress: event.target.value })}
/>
</FormField>
</div>
</section>
<section className="sm-form-card" data-annotation-id="sm-create-bank">
<h2 className="sm-form-card-title"></h2>
<div className="sm-form-grid sm-form-grid-2">
<FormField label="开户行">
<input
className="vm-input"
value={form.bankName}
placeholder="请输入开户行"
onChange={(event) => onChange({ bankName: event.target.value })}
/>
</FormField>
<FormField label="银行账号">
<input
className="vm-input"
value={form.bankAccount}
placeholder="请输入银行账号"
onChange={(event) => onChange({ bankAccount: event.target.value })}
/>
</FormField>
</div>
</section>
<section className="sm-form-card" data-annotation-id="sm-create-docs">
<h2 className="sm-form-card-title"></h2>
<div className="sm-upload-grid">
<UploadField
label="营业执照"
hint="支持 OCR 识别,上传后自动反写名称、税号、通讯地址"
accept=".pdf,.jpg,.jpeg,.png"
fileNames={form.businessLicenseFile ? [form.businessLicenseFile] : []}
onUpload={handleBusinessLicenseUpload}
/>
<UploadField
label="其他证件"
hint="支持多文件上传不限数量PDF、图片"
accept=".pdf,.jpg,.jpeg,.png"
multiple
fileNames={form.otherDocFiles}
onUpload={handleOtherDocsUpload}
/>
</div>
</section>
<footer className="sm-create-footer" data-annotation-id="sm-create-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onBack}></button>
<div className="sm-create-footer-actions">
<button type="button" className="vm-btn vm-btn-secondary" onClick={onSave}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSubmit}></button>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,320 @@
/* 供应商管理 — 模块样式(基于 vehicle-management vm-* */
@import '../../customer-management/styles/index.css';
.sm-page {
padding-bottom: 48px;
}
.sm-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 0.875rem;
color: var(--ln-muted);
}
.sm-breadcrumb-list {
margin-bottom: 4px;
}
.sm-breadcrumb-muted {
color: var(--ln-muted);
}
.sm-breadcrumb-link {
border: none;
background: transparent;
padding: 0;
color: var(--ln-body);
cursor: pointer;
font: inherit;
}
.sm-breadcrumb-link:hover {
color: var(--ln-primary);
}
.sm-breadcrumb-current {
color: var(--ln-ink);
font-weight: 500;
}
.sm-filter-card {
margin-bottom: 16px;
}
.sm-table-wrap {
overflow: auto;
}
.sm-table {
min-width: 2200px;
}
.sm-col-code {
min-width: 120px;
}
.sm-col-name {
min-width: 180px;
}
.sm-col-credit {
min-width: 180px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sm-col-address,
.sm-col-email,
.sm-col-remark {
min-width: 180px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sm-col-time {
min-width: 160px;
white-space: nowrap;
}
.sm-col-actions {
min-width: 168px;
}
.sticky-col-left {
position: sticky;
left: 0;
z-index: 2;
background: var(--ln-surface-card);
}
.sticky-col-right {
position: sticky;
right: 0;
z-index: 2;
background: var(--ln-surface-card);
}
.sm-row-actions {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.sm-action-sep {
color: var(--ln-hairline);
}
.sm-link-danger {
color: var(--ln-error) !important;
}
.sm-name-link {
font-weight: 500;
}
.sm-table-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.sm-table-total {
color: var(--ln-muted);
font-size: 0.875rem;
}
.sm-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: var(--ln-radius-pill);
font-size: 0.8125rem;
background: var(--ln-canvas-soft);
}
.sm-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.sm-status-cooperated {
color: #047857;
background: #ecfdf5;
}
.sm-status-cooperated .sm-status-dot {
background: #10b981;
}
.sm-status-negotiating {
color: #b45309;
background: #fffbeb;
}
.sm-status-negotiating .sm-status-dot {
background: #f59e0b;
}
.sm-status-terminated {
color: #b91c1c;
background: #fef2f2;
}
.sm-status-terminated .sm-status-dot {
background: #ef4444;
}
.sm-status-expired {
color: #6b7280;
background: #f3f4f6;
}
.sm-status-expired .sm-status-dot {
background: #9ca3af;
}
.sm-create-page {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 80px;
}
.sm-form-card {
background: var(--ln-surface-card);
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card);
box-shadow: var(--ln-shadow-soft);
padding: 20px 24px 24px;
}
.sm-form-card-title {
margin: 0 0 20px;
font-size: 1rem;
font-weight: 600;
color: var(--ln-ink);
}
.sm-form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px 24px;
}
.sm-form-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sm-form-field {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.sm-form-field-full {
grid-column: 1 / -1;
}
.sm-form-label {
font-size: 0.875rem;
color: var(--ln-body);
}
.sm-required {
color: var(--ln-error);
margin-left: 2px;
}
.sm-form-hint {
font-size: 0.75rem;
color: var(--ln-muted);
}
.sm-input-disabled {
background: var(--ln-surface-pearl) !important;
color: var(--ln-muted) !important;
cursor: not-allowed;
}
.sm-upload-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px 24px;
}
.sm-upload-field {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.sm-upload-zone {
min-height: 160px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.sm-upload-files {
margin: 0;
padding-left: 18px;
font-size: 0.8125rem;
color: var(--ln-muted);
}
.sm-select-placeholder {
color: var(--ln-muted);
}
.sm-create-footer {
position: sticky;
bottom: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 24px;
margin: 0 -32px -32px;
background: var(--ln-surface-card);
border-top: 1px solid var(--ln-hairline);
box-shadow: 0 -2px 10px rgba(24, 24, 27, 0.05);
}
.sm-create-footer-actions {
display: flex;
align-items: center;
gap: 12px;
}
@media (max-width: 960px) {
.sm-form-grid,
.sm-upload-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.sm-form-grid,
.sm-form-grid-2,
.sm-upload-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,93 @@
export type CoopStatus = '已合作' | '终止合作' | '洽谈中' | '合约过期';
export type SupplierType =
| '备件供应商'
| '保险公司'
| '加氢站'
| '充电站'
| '维修站'
| '救援车队'
| '整车厂'
| '其他';
export type SupplierKpiKey =
| 'all'
| 'cooperated'
| 'negotiating'
| 'terminated'
| 'expired'
| 'parts'
| 'insurance'
| 'licenseAbnormal';
export interface SupplierRecord {
id: string;
supplierCode: string;
coopStatus: CoopStatus;
supplierName: string;
type: SupplierType;
region: string;
city: string;
creditCodeOrId: string;
contact: string;
contactMobile: string;
contactPhone: string;
address: string;
email: string;
remark: string;
creator: string;
createTime: string;
licenseStatus: '正常' | '异常';
}
export interface SupplierFilters {
code: string;
coopStatuses: CoopStatus[];
types: SupplierType[];
regions: string[];
city: string;
creator: string;
nameKeyword: string;
}
export const EMPTY_FILTERS: SupplierFilters = {
code: '',
coopStatuses: [],
types: [],
regions: [],
city: '',
creator: '',
nameKeyword: '',
};
export interface SupplierCreateForm {
contractingCompany: string;
supplierName: string;
provinceCity: string;
detailAddress: string;
fullAddress: string;
taxId: string;
regPhone: string;
regAddress: string;
bankName: string;
bankAccount: string;
businessLicenseFile: string;
otherDocFiles: string[];
}
export const EMPTY_CREATE_FORM: SupplierCreateForm = {
contractingCompany: '',
supplierName: '',
provinceCity: '',
detailAddress: '',
fullAddress: '',
taxId: '',
regPhone: '',
regAddress: '',
bankName: '',
bankAccount: '',
businessLicenseFile: '',
otherDocFiles: [],
};
export type SupplierPageId = 'list' | 'create';

View File

@@ -0,0 +1,32 @@
const PROVINCES = [
'北京市', '天津市', '河北省', '山西省', '内蒙古自治区',
'上海市', '江苏省', '浙江省', '安徽省', '福建省', '江西省', '山东省',
'广东省', '广西壮族自治区', '海南省',
'河南省', '湖北省', '湖南省',
'辽宁省', '吉林省', '黑龙江省',
'重庆市', '四川省', '贵州省', '云南省', '西藏自治区',
'陕西省', '甘肃省', '青海省', '宁夏回族自治区', '新疆维吾尔自治区',
];
export function parseFullAddress(fullAddress: string): {
provinceCity: string;
detailAddress: string;
} {
const text = fullAddress.trim();
if (!text) return { provinceCity: '', detailAddress: '' };
const province = PROVINCES.find((item) => text.startsWith(item));
if (!province) {
return { provinceCity: '', detailAddress: text };
}
const rest = text.slice(province.length);
const cityMatch = rest.match(/^([\u4e00-\u9fa5]+?(?:市|区|州|盟|县))/);
const city = cityMatch?.[1] ?? '';
const detailAddress = city ? rest.slice(city.length).trim() : rest.trim();
return {
provinceCity: city ? `${province}-${city}` : province,
detailAddress: detailAddress || text,
};
}

View File

@@ -0,0 +1,38 @@
import type { CoopStatus, SupplierType } from '../types';
export const COOP_STATUS_OPTIONS: CoopStatus[] = ['已合作', '终止合作', '洽谈中', '合约过期'];
export const SUPPLIER_TYPE_OPTIONS: SupplierType[] = [
'备件供应商',
'保险公司',
'加氢站',
'充电站',
'维修站',
'救援车队',
'整车厂',
'其他',
];
export const REGION_OPTIONS = ['华北', '华东', '华南', '华中', '东北', '西南', '西北'];
export const CONTRACTING_COMPANY_OPTIONS = [
'羚牛氢能科技有限公司',
'羚牛新能源(上海)有限公司',
'羚牛氢能(嘉兴)有限公司',
'羚牛氢能(广州)有限公司',
];
export const KPI_CARDS: Array<{
key: import('../types').SupplierKpiKey;
title: string;
desc: string;
}> = [
{ key: 'all', title: '全部供应商', desc: '当前台账内全部供应商数量' },
{ key: 'cooperated', title: '已合作', desc: '合作状态为已合作的供应商' },
{ key: 'negotiating', title: '洽谈中', desc: '合作状态为洽谈中的供应商' },
{ key: 'terminated', title: '终止合作', desc: '合作状态为终止合作的供应商' },
{ key: 'expired', title: '合约过期', desc: '合作状态为合约过期的供应商' },
{ key: 'parts', title: '备件供应商', desc: '类型为备件供应商的记录' },
{ key: 'insurance', title: '保险公司', desc: '类型为保险公司的记录' },
{ key: 'licenseAbnormal', title: '资质异常', desc: '资质证照状态异常的供应商' },
];

View File

@@ -0,0 +1,59 @@
import type { SupplierFilters, SupplierKpiKey, SupplierRecord } from '../types';
export function applyFilters(
records: SupplierRecord[],
filters: SupplierFilters,
kpiTab: SupplierKpiKey,
): SupplierRecord[] {
return records.filter((record) => {
if (kpiTab === 'cooperated' && record.coopStatus !== '已合作') return false;
if (kpiTab === 'negotiating' && record.coopStatus !== '洽谈中') return false;
if (kpiTab === 'terminated' && record.coopStatus !== '终止合作') return false;
if (kpiTab === 'expired' && record.coopStatus !== '合约过期') return false;
if (kpiTab === 'parts' && record.type !== '备件供应商') return false;
if (kpiTab === 'insurance' && record.type !== '保险公司') return false;
if (kpiTab === 'licenseAbnormal' && record.licenseStatus !== '异常') return false;
if (filters.code && record.supplierCode !== filters.code) return false;
if (filters.coopStatuses.length > 0 && !filters.coopStatuses.includes(record.coopStatus)) {
return false;
}
if (filters.types.length > 0 && !filters.types.includes(record.type)) return false;
if (filters.regions.length > 0 && !filters.regions.includes(record.region)) return false;
if (filters.city && record.city !== filters.city) return false;
if (filters.creator && record.creator !== filters.creator) return false;
if (filters.nameKeyword) {
const kw = filters.nameKeyword.trim().toLowerCase();
if (!record.supplierName.toLowerCase().includes(kw)) return false;
}
return true;
});
}
export function buildOptions(records: SupplierRecord[], key: keyof SupplierRecord): string[] {
const set = new Set<string>();
records.forEach((record) => {
const value = record[key];
if (typeof value === 'string' && value.trim()) set.add(value);
});
return Array.from(set).sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
export function buildCodeOptions(records: SupplierRecord[]): Array<{ value: string; label: string }> {
return records.map((record) => ({
value: record.supplierCode,
label: `${record.supplierCode} · ${record.supplierName}`,
}));
}
export function countKpi(records: SupplierRecord[], key: SupplierKpiKey): number {
if (key === 'all') return records.length;
if (key === 'cooperated') return records.filter((r) => r.coopStatus === '已合作').length;
if (key === 'negotiating') return records.filter((r) => r.coopStatus === '洽谈中').length;
if (key === 'terminated') return records.filter((r) => r.coopStatus === '终止合作').length;
if (key === 'expired') return records.filter((r) => r.coopStatus === '合约过期').length;
if (key === 'parts') return records.filter((r) => r.type === '备件供应商').length;
if (key === 'insurance') return records.filter((r) => r.type === '保险公司').length;
if (key === 'licenseAbnormal') return records.filter((r) => r.licenseStatus === '异常').length;
return 0;
}