Compare commits
2 Commits
b9dee3a78d
...
10b35d3da9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b35d3da9 | ||
|
|
24a9fabc88 |
@@ -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",
|
||||
|
||||
@@ -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.1–7.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` |
|
||||
|
||||
81
src/prototypes/customer-management/.spec/ui-review.md
Normal file
81
src/prototypes/customer-management/.spec/ui-review.md
Normal 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),但不利于日常高频扫读。
|
||||
- 修复方向:原型阶段可标注默认可见列集合并提供「列设置」入口;或将次要工商字段折叠进行内「详情」抽屉,列表保留 8–10 个高频列,与 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
10
src/prototypes/customer-management/canvas.excalidraw
Normal file
10
src/prototypes/customer-management/canvas.excalidraw
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "@axhub/make",
|
||||
"elements": [],
|
||||
"appState": {
|
||||
"viewBackgroundColor": "#ffffff"
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
308
src/prototypes/customer-management/components/RiskTagDrawer.tsx
Normal file
308
src/prototypes/customer-management/components/RiskTagDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
src/prototypes/customer-management/components/RiskTagForm.tsx
Normal file
242
src/prototypes/customer-management/components/RiskTagForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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. 付款及开票信息' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
98
src/prototypes/customer-management/types/riskTag.ts
Normal file
98
src/prototypes/customer-management/types/riskTag.ts
Normal 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[];
|
||||
}
|
||||
37
src/prototypes/customer-management/utils/address.ts
Normal file
37
src/prototypes/customer-management/utils/address.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 = [
|
||||
'华北地区',
|
||||
'华东地区',
|
||||
|
||||
73
src/prototypes/customer-management/utils/permissions.ts
Normal file
73
src/prototypes/customer-management/utils/permissions.ts
Normal 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];
|
||||
}
|
||||
55
src/prototypes/customer-management/utils/riskTagConstants.ts
Normal file
55
src/prototypes/customer-management/utils/riskTagConstants.ts
Normal 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: '管理员',
|
||||
};
|
||||
221
src/prototypes/customer-management/utils/riskTagStore.ts
Normal file
221
src/prototypes/customer-management/utils/riskTagStore.ts
Normal 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;
|
||||
}
|
||||
196
src/prototypes/supplier-management/annotation-source.json
Normal file
196
src/prototypes/supplier-management/annotation-source.json
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/prototypes/supplier-management/canvas.excalidraw
Normal file
10
src/prototypes/supplier-management/canvas.excalidraw
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "@axhub/make",
|
||||
"elements": [],
|
||||
"appState": {
|
||||
"viewBackgroundColor": "#ffffff"
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
180
src/prototypes/supplier-management/components/FilterPanel.tsx
Normal file
180
src/prototypes/supplier-management/components/FilterPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/prototypes/supplier-management/components/KpiCards.tsx
Normal file
65
src/prototypes/supplier-management/components/KpiCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
154
src/prototypes/supplier-management/data/suppliers.json
Normal file
154
src/prototypes/supplier-management/data/suppliers.json
Normal 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": "正常"
|
||||
}
|
||||
]
|
||||
319
src/prototypes/supplier-management/index.tsx
Normal file
319
src/prototypes/supplier-management/index.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
265
src/prototypes/supplier-management/pages/CreatePage.tsx
Normal file
265
src/prototypes/supplier-management/pages/CreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
320
src/prototypes/supplier-management/styles/index.css
Normal file
320
src/prototypes/supplier-management/styles/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
93
src/prototypes/supplier-management/types.ts
Normal file
93
src/prototypes/supplier-management/types.ts
Normal 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';
|
||||
32
src/prototypes/supplier-management/utils/address.ts
Normal file
32
src/prototypes/supplier-management/utils/address.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
38
src/prototypes/supplier-management/utils/constants.ts
Normal file
38
src/prototypes/supplier-management/utils/constants.ts
Normal 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: '资质证照状态异常的供应商' },
|
||||
];
|
||||
59
src/prototypes/supplier-management/utils/filters.ts
Normal file
59
src/prototypes/supplier-management/utils/filters.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user