diff --git a/src/prototypes/customer-management/.spec/requirements-prd-list.md b/src/prototypes/customer-management/.spec/requirements-prd-list.md index 21a3668..0340927 100644 --- a/src/prototypes/customer-management/.spec/requirements-prd-list.md +++ b/src/prototypes/customer-management/.spec/requirements-prd-list.md @@ -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` | diff --git a/src/prototypes/customer-management/.spec/ui-review.md b/src/prototypes/customer-management/.spec/ui-review.md new file mode 100644 index 0000000..848b2ff --- /dev/null +++ b/src/prototypes/customer-management/.spec/ui-review.md @@ -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` 面包屑仅渲染 `业务管理`;新增页则完整展示「业务管理 / 客户管理 / 新增」且前两级可点击返回。 +- 影响:用户在列表页无法从导航结构确认当前位置;与 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,缺少浏览器可视化与响应式实测,结论以静态审查为主。 diff --git a/src/prototypes/customer-management/annotation-source.json b/src/prototypes/customer-management/annotation-source.json index cfc8ea8..2594d62 100644 --- a/src/prototypes/customer-management/annotation-source.json +++ b/src/prototypes/customer-management/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "customer-management", "pageId": "list", - "updatedAt": 1782871095268, + "updatedAt": 1782896901748, "nodes": [ { "id": "cm-list-filter", @@ -28,8 +28,28 @@ "updatedAt": 1740787200000 }, { - "id": "cm-list-toolbar", + "id": "cm-risk-role-switch", "index": 2, + "title": "操作角色切换", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"cm-risk-role-switch\"]" + ], + "fingerprint": "cm-risk-role-switch", + "path": [] + }, + "aiPrompt": "模拟法务/财务/运维/风控/销售角色,驱动打标权限。", + "annotationText": "切换角色后抽屉内标签类型与可选标签联动更新。", + "hasMarkdown": true, + "color": "#64748b", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1740787200000 + }, + { + "id": "cm-list-toolbar", + "index": 3, "title": "列表工具栏", "pageId": "list", "locator": { @@ -39,8 +59,8 @@ "fingerprint": "cm-list-toolbar", "path": [] }, - "aiPrompt": "新增、导入、导出入口。", - "annotationText": "「新增客户」进入分组表单页。", + "aiPrompt": "新增、导入、导出、批量打标入口。", + "annotationText": "勾选客户后显示「批量打标」。", "hasMarkdown": true, "color": "#0f766e", "images": [], @@ -49,7 +69,7 @@ }, { "id": "cm-list-table", - "index": 3, + "index": 4, "title": "客户台账列表", "pageId": "list", "locator": { @@ -59,17 +79,77 @@ "fingerprint": "cm-list-table", "path": [] }, - "aiPrompt": "客户台账表格列与行操作。", - "annotationText": "", + "aiPrompt": "客户台账表格列与行操作,含风险标签列。", + "annotationText": "操作列含「管理标签」;标签列按风险级别着色。", "hasMarkdown": true, "color": "#2563eb", "images": [], "createdAt": 1740787200000, "updatedAt": 1740787200000 }, + { + "id": "cm-risk-tag-drawer", + "index": 5, + "title": "风险标签管理抽屉", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"cm-risk-tag-drawer\"]" + ], + "fingerprint": "cm-risk-tag-drawer", + "path": [] + }, + "aiPrompt": "单客户打标、审批、移除、日志。", + "annotationText": "仅列表页维护标签,不在新增/编辑表单维护。", + "hasMarkdown": true, + "color": "#d97706", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1740787200000 + }, + { + "id": "cm-batch-risk-tag-btn", + "index": 6, + "title": "批量打标入口", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"cm-batch-risk-tag-btn\"]" + ], + "fingerprint": "cm-batch-risk-tag-btn", + "path": [] + }, + "aiPrompt": "勾选多客户后批量提交同一标签。", + "annotationText": "", + "hasMarkdown": true, + "color": "#0f766e", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1740787200000 + }, + { + "id": "cm-batch-risk-tag-modal", + "index": 7, + "title": "批量打标弹窗", + "pageId": "list", + "locator": { + "selectors": [ + "[data-annotation-id=\"cm-batch-risk-tag-modal\"]" + ], + "fingerprint": "cm-batch-risk-tag-modal", + "path": [] + }, + "aiPrompt": "批量打标表单与提交逻辑。", + "annotationText": "", + "hasMarkdown": true, + "color": "#0f766e", + "images": [], + "createdAt": 1740787200000, + "updatedAt": 1740787200000 + }, { "id": "cm-create-base", - "index": 4, + "index": 8, "title": "客户信息", "pageId": "create", "locator": { @@ -79,8 +159,8 @@ "fingerprint": "cm-create-base", "path": [] }, - "aiPrompt": "客户类型、分级、全称、城市、部门负责人等。", - "annotationText": "客户全称变更时同步发票抬头。", + "aiPrompt": "客户类型、全称、城市、部门负责人、合作状态等。", + "annotationText": "通讯地址自动识别省市;不含风险标签字段。", "hasMarkdown": true, "color": "#2563eb", "images": [], @@ -89,7 +169,7 @@ }, { "id": "cm-create-contact", - "index": 5, + "index": 9, "title": "联系人信息", "pageId": "create", "locator": { @@ -109,7 +189,7 @@ }, { "id": "cm-create-invoice", - "index": 6, + "index": 10, "title": "付款及开票信息", "pageId": "create", "locator": { @@ -129,7 +209,7 @@ }, { "id": "cm-create-footer", - "index": 7, + "index": 11, "title": "底部操作栏", "pageId": "create", "locator": { @@ -150,187 +230,21 @@ ] }, "markdownMap": { - "cm-list-filter": "## 4. 筛选区\n\n默认展示前 4 项(一行),点击「更多筛选」显示全部。\n\n| 字段 | 控件 | 匹配规则 |\n|---|---|---|\n| 客户编号 | 可搜索单选 | 精确匹配 `code` |\n| 客户名称 | 可搜索单选 | 精确匹配 `name` |\n| 标签 | 多选 | 客户 `labels` 包含任一选中标签 |\n| 区域 | 可搜索单选 | 精确匹配 `region` |\n| 客户状态 | 多选 | `status` 在选中集合内 |\n| 业务部门 | 多选 | `department` 在选中集合内 |\n| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |\n| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |\n\n### 4.1 操作逻辑\n\n| 操作 | 行为 |\n|---|---|\n| **查询** | 将当前筛选项写入已应用条件,页码重置为 1 |\n| **重置** | 清空待应用与已应用条件,页码重置为 1 |\n| 修改筛选项未点查询 | 不影响当前列表 |\n\n---", - "cm-list-toolbar": "## 5. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增客户 | 进入新增客户页(分组表单) |\n| 批量导入 | Toast:批量导入(原型演示) |\n| 批量导出 | Toast:导出当前列表条数 |\n\n---", - "cm-list-table": "## 6. 列表字段说明\n\n| 列 | 说明 |\n|---|---|\n| 客户编号 | 左固定列 |\n| 客户名称 | 超长省略 |\n| 标签 | 仅展示第一个标签;多于 1 个显示「+N」,悬浮展示全部 |\n| 客户区域 | 大区名称 |\n| 客户状态 | 绿点「正常」/ 红点「黑名单」 |\n| 业务部门 / 业务负责人 | 文本 |\n| 客户类型 / 所属行业 | 文本 |\n| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |\n| 详细地址 / 创建时间 | 文本;创建时间不换行 |\n| 操作 | 详情、编辑、删除(原型 Toast) |\n\n支持行多选(checkbox);横向滚动;操作列右固定。\n\n---", - "cm-create-base": "## 2. 客户信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |\n| 客户分级 | 否 | 下拉 | A / B / C |\n| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |\n| 客户简称 | 否 | 文本 | 选填 |\n| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |\n| 通讯地址 | 否 | 文本 | |\n| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |\n| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |\n| 备注 | 否 | 多行文本 | |\n\n---", - "cm-create-contact": "## 3. 联系人信息\n\n| 字段 | 必填 | 控件 |\n|---|---|---|\n| 姓名 | 否 | 文本 |\n| 手机号 | 否 | 文本 |\n| 职位 | 否 | 文本 |\n\n---", - "cm-create-invoice": "## 4. 付款及开票信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |\n| 纳税人识别号 | 是 | 文本 | |\n| 注册地址 | 否 | 文本 | |\n| 注册电话 | 否 | 文本 | |\n| 开户银行 | 是 | 文本 | |\n| 账号 | 否 | 文本 | |\n\n---", - "cm-create-footer": "## 5. 校验与提交\n\n- 提交前校验所有必填项\n- 通过后 Toast「客户已保存(原型演示)」并返回列表\n- 重置清空全部字段,客户类型默认「企业」" + "cm-list-filter": "## 4. 筛选区\r\n\r\n默认展示前 4 项(一行),点击「更多筛选」显示全部。\r\n\r\n| 字段 | 控件 | 匹配规则 |\r\n|---|---|---|\r\n| 客户编号 | 可搜索单选 | 精确匹配 `code` |\r\n| 客户名称 | 可搜索单选 | 精确匹配 `name` |\r\n| 标签 | 多选 | 客户 `labels` 包含任一选中标签(OR) |\r\n| 区域 | 可搜索单选 | 精确匹配 `region` |\r\n| 客户状态 | 多选 | `status` 在选中集合内 |\r\n| 业务部门 | 多选 | `department` 在选中集合内 |\r\n| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |\r\n| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |\r\n\r\n### 4.1 操作逻辑\r\n\r\n| 操作 | 行为 |\r\n|---|---|\r\n| **查询** | 将当前筛选项写入已应用条件,页码重置为 1,清空行选中 |\r\n| **重置** | 清空待应用与已应用条件,页码重置为 1,清空行选中 |\r\n| 修改筛选项未点查询 | 不影响当前列表 |\r\n\r\n---", + "cm-risk-role-switch": "### 5.1 操作角色切换(原型演示)\r\n\r\n列表顶部提供「当前操作角色」下拉,用于模拟不同部门用户:\r\n\r\n| 角色 | 部门 | 打标权限 |\r\n|---|---|---|\r\n| 王风控 | 风控部 | 全部标签 |\r\n| 李法务 | 法务部 | 法务类标签 |\r\n| 张财务 | 财务部 | 财务类标签 |\r\n| 赵运维 | 运维部 | 运维类标签 |\r\n| 陈销售 | 销售一部 | 仅查看,无打标入口 |\r\n\r\n切换角色后,风险标签管理抽屉内的**标签类型**、**可选标签**、**审批/移除按钮**随之联动更新。\r\n\r\n---", + "cm-list-toolbar": "## 5. 列表工具栏\r\n\r\n| 按钮 | 行为 |\r\n|---|---|\r\n| 新增客户 | 进入新增客户页(分组表单) |\r\n| 批量导入 | Toast:批量导入(原型演示) |\r\n| **批量打标** | 勾选 ≥1 行且当前角色有打标权限时显示;打开批量打标弹窗 |\r\n| 批量导出 | Toast:导出当前列表条数 |\r\n\r\n### 5.1 操作角色切换(原型演示)\r\n\r\n列表顶部提供「当前操作角色」下拉,用于模拟不同部门用户:\r\n\r\n| 角色 | 部门 | 打标权限 |\r\n|---|---|---|\r\n| 王风控 | 风控部 | 全部标签 |\r\n| 李法务 | 法务部 | 法务类标签 |\r\n| 张财务 | 财务部 | 财务类标签 |\r\n| 赵运维 | 运维部 | 运维类标签 |\r\n| 陈销售 | 销售一部 | 仅查看,无打标入口 |\r\n\r\n切换角色后,风险标签管理抽屉内的**标签类型**、**可选标签**、**审批/移除按钮**随之联动更新。\r\n\r\n---", + "cm-list-table": "## 6. 列表字段说明\r\n\r\n| 列 | 说明 |\r\n|---|---|\r\n| 客户编号 | 左固定列 |\r\n| 客户名称 | 超长省略 |\r\n| **风险标签** | 展示「生效中」标签;按风险级别着色(低/中/高/极高);首标签 + `+N` 悬浮全部;有待审批时显示「审」标识;可点击打开管理抽屉 |\r\n| 客户区域 | 大区名称 |\r\n| 客户状态 | 绿点「正常」/ 红点「黑名单」 |\r\n| 业务部门 / 业务负责人 | 文本 |\r\n| 客户类型 / 所属行业 | 文本 |\r\n| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |\r\n| 详细地址 / 创建时间 | 文本;创建时间不换行 |\r\n| 操作 | 详情、编辑、**管理标签**(有权限时)、删除(原型 Toast) |\r\n\r\n支持行多选(checkbox,仅当前页);横向滚动;操作列右固定。\r\n\r\n---", + "cm-risk-tag-drawer": "## 8. 风险标签维护(列表页专属)\r\n\r\n### 8.1 入口\r\n\r\n| 入口 | 说明 |\r\n|---|---|\r\n| 行操作「管理标签」 | 打开右侧抽屉,管理单个客户 |\r\n| 点击标签列 | 有权限时同「管理标签」 |\r\n| 工具栏「批量打标」 | 对当前勾选客户批量提交同一标签 |\r\n\r\n**不在**新增页、编辑页维护标签。\r\n\r\n### 8.2 管理抽屉结构\r\n\r\n| Tab | 内容 |\r\n|---|---|\r\n| 当前标签 | 已打标签列表:名称、类型、级别、状态、原因摘要;支持查看详情、移除、审批 |\r\n| 新增标签 | 打标表单(见 8.3) |\r\n| 操作日志 | 该客户全部标签相关审计记录 |\r\n\r\n### 8.3 打标表单字段\r\n\r\n| 字段 | 必填 | 规则 |\r\n|---|---|---|\r\n| 标签名称 | 是 | 按 7.1–7.4 分组下拉;仅当前角色有权限的标签可选;已存在同名有效标签不可重复选 |\r\n| 标签类型 | 是 | **根据当前操作角色自动带出**(财务→财务、法务→法务、运维→运维、风控→风控),**置灰不可编辑** |\r\n| 风险级别 | 是 | **选择标签名称后按目录自动带出**,**置灰不可编辑** |\r\n| 生效时间 | 是 | 日期,默认当天 |\r\n| 有效期至 | 是 | 日期,须 ≥ 生效时间;默认一年后 |\r\n| 原因说明 | 是 | 至少 10 字,供法务/财务/风控审批留痕 |\r\n| 附件 | 否 | 支持 .pdf / .jpg / .png / .xlsx,可多个 |\r\n\r\n### 8.4 批量打标\r\n\r\n- 展示已选客户 chips(超过 6 个显示 +N)\r\n- 表单字段与单客户打标一致\r\n- 已存在同名有效标签的客户自动跳过\r\n- 提交后写入批量操作日志\r\n\r\n### 8.5 标签生命周期\r\n\r\n| 状态 | 说明 |\r\n|---|---|\r\n| 生效中 | 参与列表展示,同步至 `labels` |\r\n| 待审批 | 高风险标签提交后进入;列表暂不展示为生效 |\r\n| 待确认 | 预留 |\r\n| 已驳回 | 审批未通过,不同步至 `labels` |\r\n| 已移除 | 操作人主动移除,需填写原因 |\r\n| 已过期 | 预留 |\r\n\r\n**高风险规则:** 风险级别为「高」或「极高」的标签提交后状态为「待审批」,须法务或风控审批通过后变为「生效中」。\r\n\r\n### 8.6 移除标签\r\n\r\n- 仅「生效中」且当前角色有移除权限的标签可移除\r\n- 须填写移除原因(至少 5 字)\r\n- 写入操作日志,并从列表 `labels` 同步清除\r\n\r\n---", + "cm-batch-risk-tag-btn": "### 8.1 入口\r\n\r\n| 入口 | 说明 |\r\n|---|---|\r\n| 行操作「管理标签」 | 打开右侧抽屉,管理单个客户 |\r\n| 点击标签列 | 有权限时同「管理标签」 |\r\n| 工具栏「批量打标」 | 对当前勾选客户批量提交同一标签 |\r\n\r\n**不在**新增页、编辑页维护标签。", + "cm-batch-risk-tag-modal": "### 8.4 批量打标\r\n\r\n- 展示已选客户 chips(超过 6 个显示 +N)\r\n- 表单字段与单客户打标一致\r\n- 已存在同名有效标签的客户自动跳过\r\n- 提交后写入批量操作日志", + "cm-create-base": "## 2. 客户信息\r\n\r\n| 字段 | 必填 | 控件 | 说明 |\r\n|---|---|---|---|\r\n| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |\r\n| 客户分级 | 否 | 下拉 | A / B / C |\r\n| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |\r\n| 客户简称 | 否 | 文本 | 选填 |\r\n| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |\r\n| 通讯地址 | 否 | 文本 | |\r\n| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |\r\n| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |\r\n| 备注 | 否 | 多行文本 | |\r\n\r\n---", + "cm-create-contact": "## 3. 联系人信息\r\n\r\n| 字段 | 必填 | 控件 |\r\n|---|---|---|\r\n| 姓名 | 否 | 文本 |\r\n| 手机号 | 否 | 文本 |\r\n| 职位 | 否 | 文本 |\r\n\r\n---", + "cm-create-invoice": "## 4. 付款及开票信息\r\n\r\n| 字段 | 必填 | 控件 | 说明 |\r\n|---|---|---|---|\r\n| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |\r\n| 纳税人识别号 | 是 | 文本 | |\r\n| 注册地址 | 否 | 文本 | |\r\n| 注册电话 | 否 | 文本 | |\r\n| 开户银行 | 是 | 文本 | |\r\n| 账号 | 否 | 文本 | |\r\n\r\n---", + "cm-create-footer": "## 5. 校验与提交\r\n\r\n- 提交前校验所有必填项\r\n- 通过后 Toast「客户已保存(原型演示)」并返回列表\r\n- 重置清空全部字段,客户类型默认「企业」" }, "assetMap": {}, "directory": { "nodes": [ - { - "type": "folder", - "id": "oneos-project-nav", - "title": "ONE-OS 原型导航", - "defaultExpanded": true, - "children": [ - { - "type": "link", - "id": "nav-link-contract-template-management", - "title": "合同模板管理", - "href": "/prototypes/contract-template-management", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-insurance-procurement", - "title": "保险采购", - "href": "/prototypes/insurance-procurement", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-vehicle-management", - "title": "车辆管理", - "href": "/prototypes/vehicle-management", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-lease-contract-management", - "title": "租赁合同管理", - "href": "/prototypes/lease-contract-management", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-customer-management", - "title": "客户管理(当前)", - "href": "/prototypes/customer-management", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-lease-business-ledger", - "title": "租赁业务台账", - "href": "/prototypes/lease-business-ledger", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-vehicle-h2-fee-ledger", - "title": "车辆氢费明细", - "href": "/prototypes/vehicle-h2-fee-ledger", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-self-operated-business-ledger", - "title": "自营业务台账", - "href": "/prototypes/self-operated-business-ledger", - "target": "self" - }, - { - "type": "folder", - "id": "nav-folder-prototypes-oneos-web", - "title": "ONE-OS Web 端", - "defaultExpanded": true, - "children": [ - { - "type": "link", - "id": "nav-link-oneos-web-workbench", - "title": "工作台", - "href": "/prototypes/oneos-web-workbench", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-login", - "title": "登录", - "href": "/prototypes/oneos-web-login", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-vehicle-asset", - "title": "车辆管理", - "href": "/prototypes/oneos-web-vehicle-asset", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-contract-template", - "title": "合同模板管理", - "href": "/prototypes/oneos-web-contract-template", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-help-center", - "title": "帮助中心", - "href": "/prototypes/oneos-web-help-center", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-finance", - "title": "财务管理", - "href": "/prototypes/oneos-web-finance", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-procurement", - "title": "采购管理", - "href": "/prototypes/oneos-web-procurement", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-lease-contract", - "title": "车辆租赁合同", - "href": "/prototypes/oneos-web-lease-contract", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-h2-station", - "title": "加氢站管理", - "href": "/prototypes/oneos-web-h2-station", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-data-analysis", - "title": "数据分析", - "href": "/prototypes/oneos-web-data-analysis", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-ledger-data", - "title": "台账数据", - "href": "/prototypes/oneos-web-ledger-data", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-business", - "title": "业务管理", - "href": "/prototypes/oneos-web-business", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-ops", - "title": "运维管理", - "href": "/prototypes/oneos-web-ops", - "target": "self" - }, - { - "type": "link", - "id": "nav-link-oneos-web-requirements", - "title": "需求说明", - "href": "/prototypes/oneos-web-requirements", - "target": "self" - } - ] - } - ] - }, { "type": "folder", "id": "cm-doc-root", @@ -341,13 +255,13 @@ "type": "markdown", "id": "cm-doc-overview", "title": "模块总览", - "markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 客户管理 — 列表页 |\n| 目标用户 | 销售、业务负责人、运营、风控 |\n| 核心任务 | 按多维度筛选客户台账,查看标签与状态,新增客户或进入详情/编辑 |\n| 页面结构 | 面包屑 + 筛选区 + 工具栏 + 表格 + 分页 |\n\n---" + "markdown": "## 1. 模块定位\r\n\r\n| 项 | 说明 |\r\n|---|---|\r\n| 模块名称 | 客户管理 — 列表页 |\r\n| 目标用户 | 销售、业务负责人、运营、**法务、财务、风控** |\r\n| 核心任务 | 按多维度筛选客户台账,查看风险标签与状态,**在列表页维护客户风险标签**,新增客户或进入详情/编辑 |\r\n| 页面结构 | 面包屑 + **操作角色切换** + 筛选区 + 工具栏 + 表格 + 分页 |\r\n\r\n**重要约束:** 风险标签**不在**客户新增页、客户编辑表单中维护,仅在列表页作为操作入口。\r\n\r\n---" }, { "type": "link", "id": "cm-link-self", "title": "客户管理原型", - "href": "./index.html", + "href": "/prototypes/customer-management", "target": "self" }, { @@ -360,13 +274,13 @@ "type": "markdown", "id": "cm-doc-prd-list", "title": "列表页 PRD", - "markdown": "# 客户管理 · 列表页产品需求说明(PRD)\n\n> 本页为客户主数据台账列表。标注「原型演示」的跳转、导入、导出仅 Toast 提示。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 客户管理 — 列表页 |\n| 目标用户 | 销售、业务负责人、运营、风控 |\n| 核心任务 | 按多维度筛选客户台账,查看标签与状态,新增客户或进入详情/编辑 |\n| 页面结构 | 面包屑 + 筛选区 + 工具栏 + 表格 + 分页 |\n\n---\n\n## 2. 客户编号规则(集团多子公司)\n\n- 总长度 7,仅含大写字母 A–Z 与数字 0–9\n- 结构:第 1 位 = 签约/归属子公司代码;第 2–7 位 = 6 位数字流水号(000001–999999)\n- 流水号在「同一子公司代码」下递增\n- 校验正则:`/^[A-Z][0-9]{6}$/`\n- 示例:A000001(华东)、B000001(华南)、C000001(华北)等\n\n---\n\n## 3. 数据来源\n\n| 数据 | 来源 | 用途 |\n|---|---|---|\n| 客户台账 | `data/customers.json`(20 条样例) | 列表、筛选选项 |\n| 客户标签 | `CUSTOMER_LABEL_OPTIONS` 常量 | 筛选与列表展示 |\n| 部门-负责人 | 由台账聚合 + `DEPT_MANAGER_MAP` | 筛选联动 |\n\n---\n\n## 4. 筛选区\n\n默认展示前 4 项(一行),点击「更多筛选」显示全部。\n\n| 字段 | 控件 | 匹配规则 |\n|---|---|---|\n| 客户编号 | 可搜索单选 | 精确匹配 `code` |\n| 客户名称 | 可搜索单选 | 精确匹配 `name` |\n| 标签 | 多选 | 客户 `labels` 包含任一选中标签 |\n| 区域 | 可搜索单选 | 精确匹配 `region` |\n| 客户状态 | 多选 | `status` 在选中集合内 |\n| 业务部门 | 多选 | `department` 在选中集合内 |\n| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |\n| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |\n\n### 4.1 操作逻辑\n\n| 操作 | 行为 |\n|---|---|\n| **查询** | 将当前筛选项写入已应用条件,页码重置为 1 |\n| **重置** | 清空待应用与已应用条件,页码重置为 1 |\n| 修改筛选项未点查询 | 不影响当前列表 |\n\n---\n\n## 5. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增客户 | 进入新增客户页(分组表单) |\n| 批量导入 | Toast:批量导入(原型演示) |\n| 批量导出 | Toast:导出当前列表条数 |\n\n---\n\n## 6. 列表字段说明\n\n| 列 | 说明 |\n|---|---|\n| 客户编号 | 左固定列 |\n| 客户名称 | 超长省略 |\n| 标签 | 仅展示第一个标签;多于 1 个显示「+N」,悬浮展示全部 |\n| 客户区域 | 大区名称 |\n| 客户状态 | 绿点「正常」/ 红点「黑名单」 |\n| 业务部门 / 业务负责人 | 文本 |\n| 客户类型 / 所属行业 | 文本 |\n| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |\n| 详细地址 / 创建时间 | 文本;创建时间不换行 |\n| 操作 | 详情、编辑、删除(原型 Toast) |\n\n支持行多选(checkbox);横向滚动;操作列右固定。\n\n---\n\n## 7. 客户标签枚举\n\n逾期预警、严重拖欠、频繁改账期、资质存疑、涉诉/仲裁、制裁/高风险地区、频繁争议、车辆/资产高风险、信息不实、开票异常、多头签约。\n\n标签样式:白底 + 橙色描边(`#f77234`)。\n" + "markdown": "# 客户管理 · 列表页产品需求说明(PRD)\r\n\r\n> 本页为客户主数据台账列表。标注「原型演示」的跳转、导入、导出仅 Toast 提示。\r\n\r\n---\r\n\r\n## 1. 模块定位\r\n\r\n| 项 | 说明 |\r\n|---|---|\r\n| 模块名称 | 客户管理 — 列表页 |\r\n| 目标用户 | 销售、业务负责人、运营、**法务、财务、风控** |\r\n| 核心任务 | 按多维度筛选客户台账,查看风险标签与状态,**在列表页维护客户风险标签**,新增客户或进入详情/编辑 |\r\n| 页面结构 | 面包屑 + **操作角色切换** + 筛选区 + 工具栏 + 表格 + 分页 |\r\n\r\n**重要约束:** 风险标签**不在**客户新增页、客户编辑表单中维护,仅在列表页作为操作入口。\r\n\r\n---\r\n\r\n## 2. 客户编号规则(集团多子公司)\r\n\r\n- 总长度 7,仅含大写字母 A–Z 与数字 0–9\r\n- 结构:第 1 位 = 签约/归属子公司代码;第 2–7 位 = 6 位数字流水号(000001–999999)\r\n- 流水号在「同一子公司代码」下递增\r\n- 校验正则:`/^[A-Z][0-9]{6}$/`\r\n- 示例:A000001(华东)、B000001(华南)、C000001(华北)等\r\n\r\n---\r\n\r\n## 3. 数据来源\r\n\r\n| 数据 | 来源 | 用途 |\r\n|---|---|---|\r\n| 客户台账 | `data/customers.json`(20 条样例) | 列表、筛选选项 |\r\n| 客户标签(展示) | 客户 `labels` 字段 | 列表「风险标签」列、筛选区「标签」多选 |\r\n| 风险标签明细 | `riskTagsByCustomer` 运行时状态 + 种子迁移 | 标签管理抽屉、审批、日志 |\r\n| 标签目录 | `utils/riskTagConstants.ts` → `TAG_CATALOG` | 打标表单选项、级别、分组 |\r\n| 操作日志 | `data/risk-tag-operation-logs.json` + 运行时追加 | 抽屉「操作日志」Tab |\r\n| 部门-负责人 | 由台账聚合 + `DEPT_MANAGER_MAP` | 筛选联动 |\r\n\r\n列表 `labels` 与「生效中」风险标签自动同步,保证筛选器与列表展示一致。\r\n\r\n---\r\n\r\n## 4. 筛选区\r\n\r\n默认展示前 4 项(一行),点击「更多筛选」显示全部。\r\n\r\n| 字段 | 控件 | 匹配规则 |\r\n|---|---|---|\r\n| 客户编号 | 可搜索单选 | 精确匹配 `code` |\r\n| 客户名称 | 可搜索单选 | 精确匹配 `name` |\r\n| 标签 | 多选 | 客户 `labels` 包含任一选中标签(OR) |\r\n| 区域 | 可搜索单选 | 精确匹配 `region` |\r\n| 客户状态 | 多选 | `status` 在选中集合内 |\r\n| 业务部门 | 多选 | `department` 在选中集合内 |\r\n| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |\r\n| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |\r\n\r\n### 4.1 操作逻辑\r\n\r\n| 操作 | 行为 |\r\n|---|---|\r\n| **查询** | 将当前筛选项写入已应用条件,页码重置为 1,清空行选中 |\r\n| **重置** | 清空待应用与已应用条件,页码重置为 1,清空行选中 |\r\n| 修改筛选项未点查询 | 不影响当前列表 |\r\n\r\n---\r\n\r\n## 5. 列表工具栏\r\n\r\n| 按钮 | 行为 |\r\n|---|---|\r\n| 新增客户 | 进入新增客户页(分组表单) |\r\n| 批量导入 | Toast:批量导入(原型演示) |\r\n| **批量打标** | 勾选 ≥1 行且当前角色有打标权限时显示;打开批量打标弹窗 |\r\n| 批量导出 | Toast:导出当前列表条数 |\r\n\r\n### 5.1 操作角色切换(原型演示)\r\n\r\n列表顶部提供「当前操作角色」下拉,用于模拟不同部门用户:\r\n\r\n| 角色 | 部门 | 打标权限 |\r\n|---|---|---|\r\n| 王风控 | 风控部 | 全部标签 |\r\n| 李法务 | 法务部 | 法务类标签 |\r\n| 张财务 | 财务部 | 财务类标签 |\r\n| 赵运维 | 运维部 | 运维类标签 |\r\n| 陈销售 | 销售一部 | 仅查看,无打标入口 |\r\n\r\n切换角色后,风险标签管理抽屉内的**标签类型**、**可选标签**、**审批/移除按钮**随之联动更新。\r\n\r\n---\r\n\r\n## 6. 列表字段说明\r\n\r\n| 列 | 说明 |\r\n|---|---|\r\n| 客户编号 | 左固定列 |\r\n| 客户名称 | 超长省略 |\r\n| **风险标签** | 展示「生效中」标签;按风险级别着色(低/中/高/极高);首标签 + `+N` 悬浮全部;有待审批时显示「审」标识;可点击打开管理抽屉 |\r\n| 客户区域 | 大区名称 |\r\n| 客户状态 | 绿点「正常」/ 红点「黑名单」 |\r\n| 业务部门 / 业务负责人 | 文本 |\r\n| 客户类型 / 所属行业 | 文本 |\r\n| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |\r\n| 详细地址 / 创建时间 | 文本;创建时间不换行 |\r\n| 操作 | 详情、编辑、**管理标签**(有权限时)、删除(原型 Toast) |\r\n\r\n支持行多选(checkbox,仅当前页);横向滚动;操作列右固定。\r\n\r\n---\r\n\r\n## 7. 客户标签枚举与分级目录\r\n\r\n标签按业务分类维护,打标时下拉按 **optgroup 分组** 展示。风险级别由目录**自动带出且不可手改**。\r\n\r\n### 7.1 信用与回款\r\n\r\n| 标签名称 | 标签类型 | 风险级别 | 典型触发 |\r\n|---|---|---|---|\r\n| 逾期预警 | 财务 | 中 | 当前逾期或近 6 个月内曾逾期 |\r\n| 严重拖欠 | 财务 | 高 | 逾期 > 30 天未结清或历史坏账 |\r\n| 频繁改账期 | 财务 | 低 | 一年内申请延长账期 ≥ 3 次 |\r\n\r\n### 7.2 合规与法律\r\n\r\n| 标签名称 | 标签类型 | 风险级别 | 典型触发 |\r\n|---|---|---|---|\r\n| 资质存疑 | 法务 | 中 | 证照过期、抬头与付款主体不一致等 |\r\n| 涉诉/仲裁 | 法务 | 高 | 与我司或第三方重大未结诉讼 |\r\n| 制裁/高风险地区 | 法务 | 高 | 名单筛查命中(依公司合规清单) |\r\n\r\n### 7.3 经营与履约\r\n\r\n| 标签名称 | 标签类型 | 风险级别 | 典型触发 |\r\n|---|---|---|---|\r\n| 频繁争议 | 法务 | 中 | 合同或扣款争议次数超阈值 |\r\n| 车辆/资产高风险 | 运维 | 高 | 违章、事故、疑似骗保等 |\r\n| 信息不实 | 风控 | 中 | 联系人/主体核查不实、空壳公司等 |\r\n\r\n### 7.4 操作与流程\r\n\r\n| 标签名称 | 标签类型 | 风险级别 | 典型触发 |\r\n|---|---|---|---|\r\n| 开票异常 | 财务 | 低 | 频繁退票、抬头异常变更 |\r\n| 多头签约 | 风控 | 低 | 同一实控人多主体分别签约 |\r\n\r\n### 7.5 列表展示样式\r\n\r\n- 低:灰色边框\r\n- 中:橙色浅底\r\n- 高:红色浅底\r\n- 极高:深红浅底\r\n- 单元格最大宽度受限,避免撑乱表格\r\n\r\n---\r\n\r\n## 8. 风险标签维护(列表页专属)\r\n\r\n### 8.1 入口\r\n\r\n| 入口 | 说明 |\r\n|---|---|\r\n| 行操作「管理标签」 | 打开右侧抽屉,管理单个客户 |\r\n| 点击标签列 | 有权限时同「管理标签」 |\r\n| 工具栏「批量打标」 | 对当前勾选客户批量提交同一标签 |\r\n\r\n**不在**新增页、编辑页维护标签。\r\n\r\n### 8.2 管理抽屉结构\r\n\r\n| Tab | 内容 |\r\n|---|---|\r\n| 当前标签 | 已打标签列表:名称、类型、级别、状态、原因摘要;支持查看详情、移除、审批 |\r\n| 新增标签 | 打标表单(见 8.3) |\r\n| 操作日志 | 该客户全部标签相关审计记录 |\r\n\r\n### 8.3 打标表单字段\r\n\r\n| 字段 | 必填 | 规则 |\r\n|---|---|---|\r\n| 标签名称 | 是 | 按 7.1–7.4 分组下拉;仅当前角色有权限的标签可选;已存在同名有效标签不可重复选 |\r\n| 标签类型 | 是 | **根据当前操作角色自动带出**(财务→财务、法务→法务、运维→运维、风控→风控),**置灰不可编辑** |\r\n| 风险级别 | 是 | **选择标签名称后按目录自动带出**,**置灰不可编辑** |\r\n| 生效时间 | 是 | 日期,默认当天 |\r\n| 有效期至 | 是 | 日期,须 ≥ 生效时间;默认一年后 |\r\n| 原因说明 | 是 | 至少 10 字,供法务/财务/风控审批留痕 |\r\n| 附件 | 否 | 支持 .pdf / .jpg / .png / .xlsx,可多个 |\r\n\r\n### 8.4 批量打标\r\n\r\n- 展示已选客户 chips(超过 6 个显示 +N)\r\n- 表单字段与单客户打标一致\r\n- 已存在同名有效标签的客户自动跳过\r\n- 提交后写入批量操作日志\r\n\r\n### 8.5 标签生命周期\r\n\r\n| 状态 | 说明 |\r\n|---|---|\r\n| 生效中 | 参与列表展示,同步至 `labels` |\r\n| 待审批 | 高风险标签提交后进入;列表暂不展示为生效 |\r\n| 待确认 | 预留 |\r\n| 已驳回 | 审批未通过,不同步至 `labels` |\r\n| 已移除 | 操作人主动移除,需填写原因 |\r\n| 已过期 | 预留 |\r\n\r\n**高风险规则:** 风险级别为「高」或「极高」的标签提交后状态为「待审批」,须法务或风控审批通过后变为「生效中」。\r\n\r\n### 8.6 移除标签\r\n\r\n- 仅「生效中」且当前角色有移除权限的标签可移除\r\n- 须填写移除原因(至少 5 字)\r\n- 写入操作日志,并从列表 `labels` 同步清除\r\n\r\n---\r\n\r\n## 9. 权限与审批\r\n\r\n### 9.1 打标权限\r\n\r\n| 角色 | 可选标签范围 | 标签类型(自动) |\r\n|---|---|---|\r\n| 财务 | 财务类(信用与回款、操作与流程中的财务标签) | 财务 |\r\n| 法务 | 法务类(合规与法律、经营与履约中的法务标签) | 法务 |\r\n| 运维 | 运维类(车辆/资产高风险) | 运维 |\r\n| 风控 / 管理员 | 全部 | 风控 |\r\n| 销售 | 无 | — |\r\n\r\n### 9.2 审批权限\r\n\r\n- 法务、风控、管理员可审批「待审批」标签\r\n- 支持通过 / 驳回;驳回须填写原因\r\n\r\n### 9.3 移除权限\r\n\r\n与打标权限一致:各角色仅能移除本类型标签;风控 / 管理员可移除全部类型。\r\n\r\n---\r\n\r\n## 10. 操作日志\r\n\r\n所有标签 **新增、移除、提交审批、审批通过、审批驳回、批量打标** 均写入日志。\r\n\r\n| 字段 | 说明 |\r\n|---|---|\r\n| 操作类型 | 新增 / 移除 / 提交审批 / 审批通过 / 审批驳回 / 批量打标 |\r\n| 客户 | 客户名称 + ID |\r\n| 标签名称 | 涉及标签 |\r\n| 详情 | 原因、审批结论等 |\r\n| 操作人 | 姓名 + 角色 |\r\n| 操作时间 | `YYYY-MM-DD HH:mm:ss` |\r\n\r\n抽屉「操作日志」Tab 按客户过滤;原型预置样例日志见 `data/risk-tag-operation-logs.json`。\r\n\r\n---\r\n\r\n## 11. 原型实现索引\r\n\r\n| 能力 | 代码位置 |\r\n|---|---|\r\n| 列表编排 | `index.tsx` |\r\n| 风险标签列 | `components/RiskLabelTags.tsx` |\r\n| 管理抽屉 | `components/RiskTagDrawer.tsx` |\r\n| 批量打标 | `components/BatchRiskTagModal.tsx` |\r\n| 打标表单 | `components/RiskTagForm.tsx` |\r\n| 标签目录 | `utils/riskTagConstants.ts` |\r\n| 权限 | `utils/permissions.ts` |\r\n| 业务逻辑 | `utils/riskTagStore.ts` |\r\n| 类型定义 | `types/riskTag.ts` |\r\n" }, { "type": "markdown", "id": "cm-doc-prd-create", "title": "新增页 PRD", - "markdown": "# 客户管理 · 新增页产品需求说明(PRD)\n\n> 从列表页「新增客户」进入。保存为原型 Toast,不对接真实接口。\n\n---\n\n## 1. 页面框架\n\n| 项 | 说明 |\n|---|---|\n| 面包屑 | 业务管理 / 客户管理 / 新增 |\n| 布局 | 三组卡片表单 + 底部 sticky 操作栏 |\n| 操作 | 重置(清空表单)、提交(校验后 Toast 成功并返回列表) |\n\n---\n\n## 2. 客户信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |\n| 客户分级 | 否 | 下拉 | A / B / C |\n| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |\n| 客户简称 | 否 | 文本 | 选填 |\n| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |\n| 通讯地址 | 否 | 文本 | |\n| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |\n| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |\n| 备注 | 否 | 多行文本 | |\n\n---\n\n## 3. 联系人信息\n\n| 字段 | 必填 | 控件 |\n|---|---|---|\n| 姓名 | 否 | 文本 |\n| 手机号 | 否 | 文本 |\n| 职位 | 否 | 文本 |\n\n---\n\n## 4. 付款及开票信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |\n| 纳税人识别号 | 是 | 文本 | |\n| 注册地址 | 否 | 文本 | |\n| 注册电话 | 否 | 文本 | |\n| 开户银行 | 是 | 文本 | |\n| 账号 | 否 | 文本 | |\n\n---\n\n## 5. 校验与提交\n\n- 提交前校验所有必填项\n- 通过后 Toast「客户已保存(原型演示)」并返回列表\n- 重置清空全部字段,客户类型默认「企业」\n" + "markdown": "# 客户管理 · 新增页产品需求说明(PRD)\r\n\r\n> 从列表页「新增客户」进入。保存为原型 Toast,不对接真实接口。\r\n\r\n---\r\n\r\n## 1. 页面框架\r\n\r\n| 项 | 说明 |\r\n|---|---|\r\n| 面包屑 | 业务管理 / 客户管理 / 新增 |\r\n| 布局 | 三组卡片表单 + 底部 sticky 操作栏 |\r\n| 操作 | 重置(清空表单)、提交(校验后 Toast 成功并返回列表) |\r\n\r\n---\r\n\r\n## 2. 客户信息\r\n\r\n| 字段 | 必填 | 控件 | 说明 |\r\n|---|---|---|---|\r\n| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |\r\n| 客户分级 | 否 | 下拉 | A / B / C |\r\n| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |\r\n| 客户简称 | 否 | 文本 | 选填 |\r\n| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |\r\n| 通讯地址 | 否 | 文本 | |\r\n| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |\r\n| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |\r\n| 备注 | 否 | 多行文本 | |\r\n\r\n---\r\n\r\n## 3. 联系人信息\r\n\r\n| 字段 | 必填 | 控件 |\r\n|---|---|---|\r\n| 姓名 | 否 | 文本 |\r\n| 手机号 | 否 | 文本 |\r\n| 职位 | 否 | 文本 |\r\n\r\n---\r\n\r\n## 4. 付款及开票信息\r\n\r\n| 字段 | 必填 | 控件 | 说明 |\r\n|---|---|---|---|\r\n| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |\r\n| 纳税人识别号 | 是 | 文本 | |\r\n| 注册地址 | 否 | 文本 | |\r\n| 注册电话 | 否 | 文本 | |\r\n| 开户银行 | 是 | 文本 | |\r\n| 账号 | 否 | 文本 | |\r\n\r\n---\r\n\r\n## 5. 校验与提交\r\n\r\n- 提交前校验所有必填项\r\n- 通过后 Toast「客户已保存(原型演示)」并返回列表\r\n- 重置清空全部字段,客户类型默认「企业」\r\n" } ] }, @@ -389,19 +303,43 @@ "type": "markdown", "id": "cm-doc-node-cm-list-filter", "title": "筛选条件", - "markdown": "## 4. 筛选区\n\n默认展示前 4 项(一行),点击「更多筛选」显示全部。\n\n| 字段 | 控件 | 匹配规则 |\n|---|---|---|\n| 客户编号 | 可搜索单选 | 精确匹配 `code` |\n| 客户名称 | 可搜索单选 | 精确匹配 `name` |\n| 标签 | 多选 | 客户 `labels` 包含任一选中标签 |\n| 区域 | 可搜索单选 | 精确匹配 `region` |\n| 客户状态 | 多选 | `status` 在选中集合内 |\n| 业务部门 | 多选 | `department` 在选中集合内 |\n| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |\n| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |\n\n### 4.1 操作逻辑\n\n| 操作 | 行为 |\n|---|---|\n| **查询** | 将当前筛选项写入已应用条件,页码重置为 1 |\n| **重置** | 清空待应用与已应用条件,页码重置为 1 |\n| 修改筛选项未点查询 | 不影响当前列表 |\n\n---" + "markdown": "## 4. 筛选区\r\n\r\n默认展示前 4 项(一行),点击「更多筛选」显示全部。\r\n\r\n| 字段 | 控件 | 匹配规则 |\r\n|---|---|---|\r\n| 客户编号 | 可搜索单选 | 精确匹配 `code` |\r\n| 客户名称 | 可搜索单选 | 精确匹配 `name` |\r\n| 标签 | 多选 | 客户 `labels` 包含任一选中标签(OR) |\r\n| 区域 | 可搜索单选 | 精确匹配 `region` |\r\n| 客户状态 | 多选 | `status` 在选中集合内 |\r\n| 业务部门 | 多选 | `department` 在选中集合内 |\r\n| 业务负责人 | 多选 | 随部门联动选项;`manager` 在选中集合内 |\r\n| 创建时间 | 日期范围 | `createTime` 落在区间内(含边界日) |\r\n\r\n### 4.1 操作逻辑\r\n\r\n| 操作 | 行为 |\r\n|---|---|\r\n| **查询** | 将当前筛选项写入已应用条件,页码重置为 1,清空行选中 |\r\n| **重置** | 清空待应用与已应用条件,页码重置为 1,清空行选中 |\r\n| 修改筛选项未点查询 | 不影响当前列表 |\r\n\r\n---" + }, + { + "type": "markdown", + "id": "cm-doc-node-cm-risk-role-switch", + "title": "操作角色切换", + "markdown": "### 5.1 操作角色切换(原型演示)\r\n\r\n列表顶部提供「当前操作角色」下拉,用于模拟不同部门用户:\r\n\r\n| 角色 | 部门 | 打标权限 |\r\n|---|---|---|\r\n| 王风控 | 风控部 | 全部标签 |\r\n| 李法务 | 法务部 | 法务类标签 |\r\n| 张财务 | 财务部 | 财务类标签 |\r\n| 赵运维 | 运维部 | 运维类标签 |\r\n| 陈销售 | 销售一部 | 仅查看,无打标入口 |\r\n\r\n切换角色后,风险标签管理抽屉内的**标签类型**、**可选标签**、**审批/移除按钮**随之联动更新。\r\n\r\n---" }, { "type": "markdown", "id": "cm-doc-node-cm-list-toolbar", "title": "列表工具栏", - "markdown": "## 5. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 新增客户 | 进入新增客户页(分组表单) |\n| 批量导入 | Toast:批量导入(原型演示) |\n| 批量导出 | Toast:导出当前列表条数 |\n\n---" + "markdown": "## 5. 列表工具栏\r\n\r\n| 按钮 | 行为 |\r\n|---|---|\r\n| 新增客户 | 进入新增客户页(分组表单) |\r\n| 批量导入 | Toast:批量导入(原型演示) |\r\n| **批量打标** | 勾选 ≥1 行且当前角色有打标权限时显示;打开批量打标弹窗 |\r\n| 批量导出 | Toast:导出当前列表条数 |\r\n\r\n### 5.1 操作角色切换(原型演示)\r\n\r\n列表顶部提供「当前操作角色」下拉,用于模拟不同部门用户:\r\n\r\n| 角色 | 部门 | 打标权限 |\r\n|---|---|---|\r\n| 王风控 | 风控部 | 全部标签 |\r\n| 李法务 | 法务部 | 法务类标签 |\r\n| 张财务 | 财务部 | 财务类标签 |\r\n| 赵运维 | 运维部 | 运维类标签 |\r\n| 陈销售 | 销售一部 | 仅查看,无打标入口 |\r\n\r\n切换角色后,风险标签管理抽屉内的**标签类型**、**可选标签**、**审批/移除按钮**随之联动更新。\r\n\r\n---" }, { "type": "markdown", "id": "cm-doc-node-cm-list-table", "title": "客户台账列表", - "markdown": "## 6. 列表字段说明\n\n| 列 | 说明 |\n|---|---|\n| 客户编号 | 左固定列 |\n| 客户名称 | 超长省略 |\n| 标签 | 仅展示第一个标签;多于 1 个显示「+N」,悬浮展示全部 |\n| 客户区域 | 大区名称 |\n| 客户状态 | 绿点「正常」/ 红点「黑名单」 |\n| 业务部门 / 业务负责人 | 文本 |\n| 客户类型 / 所属行业 | 文本 |\n| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |\n| 详细地址 / 创建时间 | 文本;创建时间不换行 |\n| 操作 | 详情、编辑、删除(原型 Toast) |\n\n支持行多选(checkbox);横向滚动;操作列右固定。\n\n---" + "markdown": "## 6. 列表字段说明\r\n\r\n| 列 | 说明 |\r\n|---|---|\r\n| 客户编号 | 左固定列 |\r\n| 客户名称 | 超长省略 |\r\n| **风险标签** | 展示「生效中」标签;按风险级别着色(低/中/高/极高);首标签 + `+N` 悬浮全部;有待审批时显示「审」标识;可点击打开管理抽屉 |\r\n| 客户区域 | 大区名称 |\r\n| 客户状态 | 绿点「正常」/ 红点「黑名单」 |\r\n| 业务部门 / 业务负责人 | 文本 |\r\n| 客户类型 / 所属行业 | 文本 |\r\n| 统一社会信用代码 / 法人 / 注册资本 / 经营状态 | 文本 |\r\n| 详细地址 / 创建时间 | 文本;创建时间不换行 |\r\n| 操作 | 详情、编辑、**管理标签**(有权限时)、删除(原型 Toast) |\r\n\r\n支持行多选(checkbox,仅当前页);横向滚动;操作列右固定。\r\n\r\n---" + }, + { + "type": "markdown", + "id": "cm-doc-node-cm-risk-tag-drawer", + "title": "风险标签管理抽屉", + "markdown": "## 8. 风险标签维护(列表页专属)\r\n\r\n### 8.1 入口\r\n\r\n| 入口 | 说明 |\r\n|---|---|\r\n| 行操作「管理标签」 | 打开右侧抽屉,管理单个客户 |\r\n| 点击标签列 | 有权限时同「管理标签」 |\r\n| 工具栏「批量打标」 | 对当前勾选客户批量提交同一标签 |\r\n\r\n**不在**新增页、编辑页维护标签。\r\n\r\n### 8.2 管理抽屉结构\r\n\r\n| Tab | 内容 |\r\n|---|---|\r\n| 当前标签 | 已打标签列表:名称、类型、级别、状态、原因摘要;支持查看详情、移除、审批 |\r\n| 新增标签 | 打标表单(见 8.3) |\r\n| 操作日志 | 该客户全部标签相关审计记录 |\r\n\r\n### 8.3 打标表单字段\r\n\r\n| 字段 | 必填 | 规则 |\r\n|---|---|---|\r\n| 标签名称 | 是 | 按 7.1–7.4 分组下拉;仅当前角色有权限的标签可选;已存在同名有效标签不可重复选 |\r\n| 标签类型 | 是 | **根据当前操作角色自动带出**(财务→财务、法务→法务、运维→运维、风控→风控),**置灰不可编辑** |\r\n| 风险级别 | 是 | **选择标签名称后按目录自动带出**,**置灰不可编辑** |\r\n| 生效时间 | 是 | 日期,默认当天 |\r\n| 有效期至 | 是 | 日期,须 ≥ 生效时间;默认一年后 |\r\n| 原因说明 | 是 | 至少 10 字,供法务/财务/风控审批留痕 |\r\n| 附件 | 否 | 支持 .pdf / .jpg / .png / .xlsx,可多个 |\r\n\r\n### 8.4 批量打标\r\n\r\n- 展示已选客户 chips(超过 6 个显示 +N)\r\n- 表单字段与单客户打标一致\r\n- 已存在同名有效标签的客户自动跳过\r\n- 提交后写入批量操作日志\r\n\r\n### 8.5 标签生命周期\r\n\r\n| 状态 | 说明 |\r\n|---|---|\r\n| 生效中 | 参与列表展示,同步至 `labels` |\r\n| 待审批 | 高风险标签提交后进入;列表暂不展示为生效 |\r\n| 待确认 | 预留 |\r\n| 已驳回 | 审批未通过,不同步至 `labels` |\r\n| 已移除 | 操作人主动移除,需填写原因 |\r\n| 已过期 | 预留 |\r\n\r\n**高风险规则:** 风险级别为「高」或「极高」的标签提交后状态为「待审批」,须法务或风控审批通过后变为「生效中」。\r\n\r\n### 8.6 移除标签\r\n\r\n- 仅「生效中」且当前角色有移除权限的标签可移除\r\n- 须填写移除原因(至少 5 字)\r\n- 写入操作日志,并从列表 `labels` 同步清除\r\n\r\n---" + }, + { + "type": "markdown", + "id": "cm-doc-node-cm-batch-risk-tag-btn", + "title": "批量打标入口", + "markdown": "### 8.1 入口\r\n\r\n| 入口 | 说明 |\r\n|---|---|\r\n| 行操作「管理标签」 | 打开右侧抽屉,管理单个客户 |\r\n| 点击标签列 | 有权限时同「管理标签」 |\r\n| 工具栏「批量打标」 | 对当前勾选客户批量提交同一标签 |\r\n\r\n**不在**新增页、编辑页维护标签。" + }, + { + "type": "markdown", + "id": "cm-doc-node-cm-batch-risk-tag-modal", + "title": "批量打标弹窗", + "markdown": "### 8.4 批量打标\r\n\r\n- 展示已选客户 chips(超过 6 个显示 +N)\r\n- 表单字段与单客户打标一致\r\n- 已存在同名有效标签的客户自动跳过\r\n- 提交后写入批量操作日志" } ] }, @@ -424,25 +362,25 @@ "type": "markdown", "id": "cm-doc-node-cm-create-base", "title": "客户信息", - "markdown": "## 2. 客户信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |\n| 客户分级 | 否 | 下拉 | A / B / C |\n| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |\n| 客户简称 | 否 | 文本 | 选填 |\n| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |\n| 通讯地址 | 否 | 文本 | |\n| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |\n| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |\n| 备注 | 否 | 多行文本 | |\n\n---" + "markdown": "## 2. 客户信息\r\n\r\n| 字段 | 必填 | 控件 | 说明 |\r\n|---|---|---|---|\r\n| 客户类型 | 是 | 下拉 | 企业 / 个人 / 事业单位 |\r\n| 客户分级 | 否 | 下拉 | A / B / C |\r\n| 客户全称 | 是 | 文本 | 与客户证照一致;变更时同步发票抬头 |\r\n| 客户简称 | 否 | 文本 | 选填 |\r\n| 所属城市 | 是 | 省-市级联 | 示例省市区数据 |\r\n| 通讯地址 | 否 | 文本 | |\r\n| 业务负责部门 | 是 | 下拉 | 变更时清空负责人 |\r\n| 业务负责人员 | 是 | 下拉 | 随部门联动,未选部门时禁用 |\r\n| 备注 | 否 | 多行文本 | |\r\n\r\n---" }, { "type": "markdown", "id": "cm-doc-node-cm-create-contact", "title": "联系人信息", - "markdown": "## 3. 联系人信息\n\n| 字段 | 必填 | 控件 |\n|---|---|---|\n| 姓名 | 否 | 文本 |\n| 手机号 | 否 | 文本 |\n| 职位 | 否 | 文本 |\n\n---" + "markdown": "## 3. 联系人信息\r\n\r\n| 字段 | 必填 | 控件 |\r\n|---|---|---|\r\n| 姓名 | 否 | 文本 |\r\n| 手机号 | 否 | 文本 |\r\n| 职位 | 否 | 文本 |\r\n\r\n---" }, { "type": "markdown", "id": "cm-doc-node-cm-create-invoice", "title": "付款及开票信息", - "markdown": "## 4. 付款及开票信息\n\n| 字段 | 必填 | 控件 | 说明 |\n|---|---|---|---|\n| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |\n| 纳税人识别号 | 是 | 文本 | |\n| 注册地址 | 否 | 文本 | |\n| 注册电话 | 否 | 文本 | |\n| 开户银行 | 是 | 文本 | |\n| 账号 | 否 | 文本 | |\n\n---" + "markdown": "## 4. 付款及开票信息\r\n\r\n| 字段 | 必填 | 控件 | 说明 |\r\n|---|---|---|---|\r\n| 发票抬头 | 是 | 文本(禁用) | 自动带入客户全称,不可手改 |\r\n| 纳税人识别号 | 是 | 文本 | |\r\n| 注册地址 | 否 | 文本 | |\r\n| 注册电话 | 否 | 文本 | |\r\n| 开户银行 | 是 | 文本 | |\r\n| 账号 | 否 | 文本 | |\r\n\r\n---" }, { "type": "markdown", "id": "cm-doc-node-cm-create-footer", "title": "底部操作栏", - "markdown": "## 5. 校验与提交\n\n- 提交前校验所有必填项\n- 通过后 Toast「客户已保存(原型演示)」并返回列表\n- 重置清空全部字段,客户类型默认「企业」" + "markdown": "## 5. 校验与提交\r\n\r\n- 提交前校验所有必填项\r\n- 通过后 Toast「客户已保存(原型演示)」并返回列表\r\n- 重置清空全部字段,客户类型默认「企业」" } ] } diff --git a/src/prototypes/customer-management/canvas.excalidraw b/src/prototypes/customer-management/canvas.excalidraw new file mode 100644 index 0000000..50522d0 --- /dev/null +++ b/src/prototypes/customer-management/canvas.excalidraw @@ -0,0 +1,10 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "@axhub/make", + "elements": [], + "appState": { + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} \ No newline at end of file diff --git a/src/prototypes/customer-management/components/BatchRiskTagModal.tsx b/src/prototypes/customer-management/components/BatchRiskTagModal.tsx new file mode 100644 index 0000000..231479c --- /dev/null +++ b/src/prototypes/customer-management/components/BatchRiskTagModal.tsx @@ -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; + 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(() => ({ + ...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 ( + + e.stopPropagation()} + > + + + 批量打标 + 已选 {selectedRecords.length} 个客户 · 操作人 {currentUser.name}({currentUser.department}) + + + + + + + + {error && {error}} + + + {selectedRecords.slice(0, 6).map((record) => ( + {record.name} + ))} + {selectedRecords.length > 6 && ( + +{selectedRecords.length - 6} + )} + + + {form.tagName && skippedCount > 0 && ( + + 其中 {skippedCount} 个客户已存在「{form.tagName}」,提交时将自动跳过。 + + )} + + setForm((prev) => ({ ...prev, ...patch }))} + /> + + + + + + ); +} diff --git a/src/prototypes/customer-management/components/CustomerTable.tsx b/src/prototypes/customer-management/components/CustomerTable.tsx index ba53e61..b304d19 100644 --- a/src/prototypes/customer-management/components/CustomerTable.tsx +++ b/src/prototypes/customer-management/components/CustomerTable.tsx @@ -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; + riskTagsByCustomer: Record; + 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(null); + const canManage = canManageRiskTags(currentUser); if (records.length === 0) { return ( @@ -48,7 +57,7 @@ export function CustomerTable({ 客户编号 客户名称 - 标签 + 风险标签 客户区域 客户状态 业务部门 @@ -82,7 +91,12 @@ export function CustomerTable({ {record.code} {record.name} - + + onManageTags(record) : undefined} + /> + {record.region} {record.department} @@ -100,6 +114,12 @@ export function CustomerTable({ onView(record)}>详情 | onEdit(record)}>编辑 + {canManage && ( + <> + | + onManageTags(record)}>管理标签 + > + )} | onDelete(record)}>删除 diff --git a/src/prototypes/customer-management/components/RiskLabelTags.tsx b/src/prototypes/customer-management/components/RiskLabelTags.tsx new file mode 100644 index 0000000..b10be46 --- /dev/null +++ b/src/prototypes/customer-management/components/RiskLabelTags.tsx @@ -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 —; + } + + const primary = displayTags[0]; + const overflow = displayTags.length - 1; + + return ( + + + {primary.tagName} + + {pending.length > 0 && active.length === 0 && ( + 审 + )} + {overflow > 0 && ( + setOpen(true)} + onMouseLeave={() => setOpen(false)} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + tabIndex={0} + role="button" + aria-label={`还有 ${overflow} 个标签`} + > + +{overflow} + {open && ( + + {displayTags.map((tag) => ( + + {tag.tagName} + {tag.status !== '生效中' && ( + {tag.status} + )} + + ))} + + )} + + )} + {pending.length > 0 && active.length > 0 && ( + + +{pending.length}审 + + )} + + ); +} diff --git a/src/prototypes/customer-management/components/RiskTagDrawer.tsx b/src/prototypes/customer-management/components/RiskTagDrawer.tsx new file mode 100644 index 0000000..27de564 --- /dev/null +++ b/src/prototypes/customer-management/components/RiskTagDrawer.tsx @@ -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('tags'); + const [detailId, setDetailId] = useState(null); + const [form, setForm] = useState(EMPTY_RISK_TAG_FORM); + const [removeReason, setRemoveReason] = useState(''); + const [removeTargetId, setRemoveTargetId] = useState(null); + const [rejectReason, setRejectReason] = useState(''); + const [approveTargetId, setApproveTargetId] = useState(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 ( + + + + ); +} diff --git a/src/prototypes/customer-management/components/RiskTagForm.tsx b/src/prototypes/customer-management/components/RiskTagForm.tsx new file mode 100644 index 0000000..df1cc2d --- /dev/null +++ b/src/prototypes/customer-management/components/RiskTagForm.tsx @@ -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) => void; + disabledTagNames?: string[]; +} + +export function RiskTagApplyFormSection({ + form, + currentUser, + onChange, + disabledTagNames = [], +}: RiskTagApplyFormSectionProps) { + const inputRef = useRef(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 ( + + + + 标签名称 * + handleTagNameChange(e.target.value)} + > + 请选择标签 + {selectableGroups.map((group) => ( + + {group.tags.map((item) => ( + + {item.tagName} + {disabledTagNames.includes(item.tagName) ? '(已存在)' : ''} + + ))} + + ))} + + {selectableGroups.length === 0 && ( + 当前角色无可选标签,请切换法务/财务/运维/风控角色 + )} + + + 标签类型 * + + {tagType} + + + + 风险级别 * + onChange({ riskLevel: e.target.value as RiskTagApplyForm['riskLevel'] })} + > + {form.riskLevel} + + {levelLocked && ( + 根据标签规范自动带出 + )} + + + 生效时间 * + onChange({ effectiveFrom: e.target.value })} + /> + + + 有效期至 * + onChange({ effectiveTo: e.target.value })} + /> + + + + + 原因说明 * + onChange({ reason: e.target.value })} + /> + + + + 附件(选填) + inputRef.current?.click()} + > + + 上传审批依据、合同、对账单等 + 支持 .pdf、.jpg、.png、.xlsx + { + const file = e.target.files?.[0]; + if (file) handleUpload(file); + e.target.value = ''; + }} + /> + + {form.attachments.length > 0 && ( + + {form.attachments.map((file) => ( + + {file.name} + {file.size} + removeAttachment(file.id)}> + 移除 + + + ))} + + )} + + + {needsApproval && ( + + 高风险标签提交后将进入审批流程,需法务或风控人员确认后才会在列表生效。 + + )} + + ); +} + +interface RiskTagDetailCardProps { + tag: import('../types/riskTag').CustomerRiskTag; + onClose?: () => void; +} + +export function RiskTagDetailCard({ tag, onClose }: RiskTagDetailCardProps) { + const groupName = TAG_CATALOG_MAP[tag.tagName]?.groupName; + + return ( + + + + {tag.tagName} + {tag.status} + + {onClose && ( + + + + )} + + + {groupName && 标签分类{groupName}} + 标签类型{tag.tagType} + 风险级别{tag.riskLevel} + 生效时间{tag.effectiveFrom} + 有效期至{tag.effectiveTo} + 操作人{tag.operator} + 提交时间{tag.createdAt} + {tag.approver && 审批人{tag.approver}} + {tag.approvedAt && 审批时间{tag.approvedAt}} + {tag.rejectReason && 驳回原因{tag.rejectReason}} + 原因说明{tag.reason} + {tag.attachments.length > 0 && ( + + 附件 + + + {tag.attachments.map((file) => ( + {file.name}({file.size}) + ))} + + + + )} + + + ); +} diff --git a/src/prototypes/customer-management/data/risk-tag-operation-logs.json b/src/prototypes/customer-management/data/risk-tag-operation-logs.json new file mode 100644 index 0000000..80a929d --- /dev/null +++ b/src/prototypes/customer-management/data/risk-tag-operation-logs.json @@ -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" + } +] diff --git a/src/prototypes/customer-management/index.tsx b/src/prototypes/customer-management/index.tsx index 0dc6b09..0f406b5 100644 --- a/src/prototypes/customer-management/index.tsx +++ b/src/prototypes/customer-management/index.tsx @@ -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(seedCustomers as CustomerRecord[]); + const seedRecords = seedCustomers as CustomerRecord[]; + const initialRiskTags = useMemo(() => buildSeedRiskTags(seedRecords), []); + + const [records, setRecords] = useState(() => + syncRecordLabels(seedRecords, initialRiskTags), + ); + const [riskTagsByCustomer, setRiskTagsByCustomer] = useState>(initialRiskTags); + const [operationLogs, setOperationLogs] = useState(INITIAL_OPERATION_LOGS); + const [currentUser, setCurrentUser] = useState(MOCK_USERS[0]); + const [view, setView] = useState('list'); const [pendingFilters, setPendingFilters] = useState(EMPTY_FILTERS); const [appliedFilters, setAppliedFilters] = useState(EMPTY_FILTERS); @@ -57,6 +81,8 @@ export default function CustomerManagementApp() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [createForm, setCreateForm] = useState(EMPTY_CREATE_FORM); const [toast, setToast] = useState(''); + const [tagDrawerRecord, setTagDrawerRecord] = useState(null); + const [batchTagOpen, setBatchTagOpen] = useState(false); const navigationRef = useRef({}); const showToast = useCallback((msg: string) => { @@ -64,6 +90,10 @@ export default function CustomerManagementApp() { window.setTimeout(() => setToast(''), 3000); }, []); + const refreshRecordLabels = useCallback((nextTags: Record) => { + 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 ( <> @@ -173,6 +334,27 @@ export default function CustomerManagementApp() { 业务管理 + + 当前操作角色(原型演示): + { + const user = MOCK_USERS.find((item) => item.name === e.target.value); + if (user) setCurrentUser(user); + }} + > + {MOCK_USERS.map((user) => ( + + {user.name} · {user.department} + + ))} + + {!canManageRiskTags(currentUser) && ( + 销售角色仅可查看,不可打标 + )} + + 批量导入 + {canBatchTag(currentUser) && selectedIds.size > 0 && ( + setBatchTagOpen(true)} + data-annotation-id="cm-batch-risk-tag-btn" + > + + 批量打标({selectedIds.size}) + + )} showToast(`详情:${record.name}(原型演示)`)} onEdit={(record) => showToast(`编辑:${record.name}(原型演示)`)} onDelete={(record) => showToast(`删除:${record.name}(原型演示)`)} + onManageTags={setTagDrawerRecord} /> 共 {filtered.length} 条 @@ -234,6 +430,29 @@ export default function CustomerManagementApp() { {toast && {toast}} + + setTagDrawerRecord(null)} + onApply={handleApplyTag} + onRemove={handleRemoveTag} + onApprove={handleApproveTag} + /> + + setBatchTagOpen(false)} + onSubmit={handleBatchApply} + /> + { - onChange({ cityProvince, cityName: '' }); + const handleAddressChange = (address: string) => { + const { cityProvince, cityName } = parseAddressToRegion(address); + onChange({ address, cityProvince, cityName }); }; return ( @@ -88,17 +90,6 @@ export function CreatePage({ ))} - - onChange({ customerLevel: event.target.value })} - > - {CUSTOMER_LEVEL_OPTIONS.map((option) => ( - {option} - ))} - - onChange({ customerShortName: event.target.value })} /> - + handleProvinceChange(event.target.value)} + disabled > 请选择省份 {REGION_TREE.map((item) => ( @@ -128,10 +119,9 @@ export function CreatePage({ ))} onChange({ cityName: event.target.value })} + disabled > 请选择城市 {cityOptions.map((city) => ( @@ -140,12 +130,12 @@ export function CreatePage({ - + onChange({ address: event.target.value })} + onChange={(event) => handleAddressChange(event.target.value)} /> @@ -173,6 +163,18 @@ export function CreatePage({ ))} + + onChange({ coopStatus: event.target.value })} + > + 请选择合作状态 + {COOP_STATUS_OPTIONS.map((option) => ( + {option} + ))} + + ; + +export interface RiskTagContext { + records: CustomerRecord[]; + riskTagsByCustomer: RiskTagsByCustomer; + operationLogs: RiskTagOperationLog[]; +} diff --git a/src/prototypes/customer-management/utils/address.ts b/src/prototypes/customer-management/utils/address.ts new file mode 100644 index 0000000..110133a --- /dev/null +++ b/src/prototypes/customer-management/utils/address.ts @@ -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 }; +} diff --git a/src/prototypes/customer-management/utils/constants.ts b/src/prototypes/customer-management/utils/constants.ts index 28afabb..2dfb425 100644 --- a/src/prototypes/customer-management/utils/constants.ts +++ b/src/prototypes/customer-management/utils/constants.ts @@ -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 = [ '华北地区', '华东地区', diff --git a/src/prototypes/customer-management/utils/permissions.ts b/src/prototypes/customer-management/utils/permissions.ts new file mode 100644 index 0000000..5d8623f --- /dev/null +++ b/src/prototypes/customer-management/utils/permissions.ts @@ -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 = { + 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 = { + finance: '财务', + legal: '法务', + risk: '风控', + ops: '运维', + sales: '销售', + admin: '管理员', + }; + return map[role]; +} diff --git a/src/prototypes/customer-management/utils/riskTagConstants.ts b/src/prototypes/customer-management/utils/riskTagConstants.ts new file mode 100644 index 0000000..664f0ca --- /dev/null +++ b/src/prototypes/customer-management/utils/riskTagConstants.ts @@ -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; + +/** 下拉分组展示顺序 */ +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 = { + finance: '财务', + legal: '法务', + risk: '风控', + ops: '运维', + sales: '销售', + admin: '管理员', +}; diff --git a/src/prototypes/customer-management/utils/riskTagStore.ts b/src/prototypes/customer-management/utils/riskTagStore.ts new file mode 100644 index 0000000..9842ce8 --- /dev/null +++ b/src/prototypes/customer-management/utils/riskTagStore.ts @@ -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 { + 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 { + 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; +} diff --git a/src/prototypes/supplier-management/annotation-source.json b/src/prototypes/supplier-management/annotation-source.json new file mode 100644 index 0000000..2283bed --- /dev/null +++ b/src/prototypes/supplier-management/annotation-source.json @@ -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" } + } + ] + } + ] + } + } +} diff --git a/src/prototypes/supplier-management/canvas.excalidraw b/src/prototypes/supplier-management/canvas.excalidraw new file mode 100644 index 0000000..50522d0 --- /dev/null +++ b/src/prototypes/supplier-management/canvas.excalidraw @@ -0,0 +1,10 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "@axhub/make", + "elements": [], + "appState": { + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} \ No newline at end of file diff --git a/src/prototypes/supplier-management/components/CoopStatusBadge.tsx b/src/prototypes/supplier-management/components/CoopStatusBadge.tsx new file mode 100644 index 0000000..6a1b8d7 --- /dev/null +++ b/src/prototypes/supplier-management/components/CoopStatusBadge.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import type { CoopStatus } from '../types'; + +const STATUS_CLASS: Record = { + 已合作: 'sm-status-cooperated', + 洽谈中: 'sm-status-negotiating', + 终止合作: 'sm-status-terminated', + 合约过期: 'sm-status-expired', +}; + +interface CoopStatusBadgeProps { + status: CoopStatus; +} + +export function CoopStatusBadge({ status }: CoopStatusBadgeProps) { + return ( + + + {status} + + ); +} diff --git a/src/prototypes/supplier-management/components/FilterPanel.tsx b/src/prototypes/supplier-management/components/FilterPanel.tsx new file mode 100644 index 0000000..a452886 --- /dev/null +++ b/src/prototypes/supplier-management/components/FilterPanel.tsx @@ -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) => 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: ( + <> + onChange({ nameKeyword: event.target.value })} + /> + + {nameOptions.map((name) => ( + + ))} + + > + ), + }, + { + label: '供应商编码', + control: ( + onChange({ code: codeLabelToValue[label] ?? '' })} + /> + ), + }, + { + label: '合作状态', + control: ( + onChange({ coopStatuses: coopStatuses as SupplierFilters['coopStatuses'] })} + /> + ), + }, + { + label: '类型', + control: ( + onChange({ types: types as SupplierFilters['types'] })} + /> + ), + }, + { + label: '区域', + control: ( + onChange({ regions })} + /> + ), + }, + { + label: '城市', + control: ( + onChange({ city })} + /> + ), + }, + { + label: '创建人', + control: ( + onChange({ creator })} + /> + ), + }, + ]; + + const visibleFields = expanded ? fields : fields.slice(0, DEFAULT_VISIBLE_COUNT); + + return ( + + + 筛选条件 + + + {visibleFields.map((field) => ( + + {field.label} + {field.control} + + ))} + + + {fields.length > DEFAULT_VISIBLE_COUNT && ( + setExpanded(!expanded)} + > + {expanded ? '收起' : '更多筛选'} + + + )} + 重置 + + + 查询 + + + + ); +} diff --git a/src/prototypes/supplier-management/components/KpiCards.tsx b/src/prototypes/supplier-management/components/KpiCards.tsx new file mode 100644 index 0000000..3e48053 --- /dev/null +++ b/src/prototypes/supplier-management/components/KpiCards.tsx @@ -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 = { + all: , + cooperated: , + negotiating: , + terminated: , + expired: , + parts: , + insurance: , + licenseAbnormal: , +}; + +interface KpiCardsProps { + counts: Record; + active: SupplierKpiKey; + onChange: (key: SupplierKpiKey) => void; +} + +export function KpiCards({ counts, active, onChange }: KpiCardsProps) { + return ( + + {KPI_CARDS.map((card) => ( + onChange(card.key)} + aria-pressed={active === card.key} + > + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + > + + {card.desc} + + {ICONS[card.key]} + + {card.title} + {counts[card.key]} + + + ))} + + ); +} diff --git a/src/prototypes/supplier-management/components/SupplierTable.tsx b/src/prototypes/supplier-management/components/SupplierTable.tsx new file mode 100644 index 0000000..90d3853 --- /dev/null +++ b/src/prototypes/supplier-management/components/SupplierTable.tsx @@ -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(null); + + return ( + <> + {records.length === 0 ? ( + + 暂无符合条件的供应商 + + ) : ( + + + + + 供应商编码 + 合作状态 + 供应商名称 + 类型 + 区域 + 城市 + 信用代码/身份证 + 联系人 + 联系人手机 + 联系人座机 + 供应商地址 + 电子邮箱 + 备注 + 创建人 + 创建时间 + 操作 + + + + {records.map((record) => ( + setHoverId(record.id)} + onMouseLeave={() => setHoverId(null)} + className={hoverId === record.id ? 'is-hovered' : undefined} + > + {record.supplierCode} + + + onView(record)} + > + {record.supplierName} + + + {record.type} + {record.region} + {record.city} + + {record.creditCodeOrId} + + {record.contact} + {record.contactMobile} + {record.contactPhone === '-' || !record.contactPhone ? '—' : record.contactPhone} + {record.address} + {record.email} + {record.remark || '—'} + {record.creator} + {record.createTime} + + + onView(record)}>查看 + | + onEdit(record)}>编辑 + | + onDelete(record)}>删除 + + + + ))} + + + + )} + > + ); +} diff --git a/src/prototypes/supplier-management/data/suppliers.json b/src/prototypes/supplier-management/data/suppliers.json new file mode 100644 index 0000000..9def188 --- /dev/null +++ b/src/prototypes/supplier-management/data/suppliers.json @@ -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": "正常" + } +] diff --git a/src/prototypes/supplier-management/index.tsx b/src/prototypes/supplier-management/index.tsx new file mode 100644 index 0000000..acef4de --- /dev/null +++ b/src/prototypes/supplier-management/index.tsx @@ -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(seedSuppliers as SupplierRecord[]); + const [view, setView] = useState('list'); + const [pendingFilters, setPendingFilters] = useState(EMPTY_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(EMPTY_FILTERS); + const [kpiTab, setKpiTab] = useState('all'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [createForm, setCreateForm] = useState(EMPTY_CREATE_FORM); + const [importOpen, setImportOpen] = useState(false); + const [toast, setToast] = useState(''); + const navigationRef = useRef({}); + + 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( + () => ({ + showToolbar: true, + showThemeToggle: true, + showColorFilter: true, + emptyWhenNoData: false, + toolbarEdge: 'right', + currentPageId: view, + onDirectoryRoute: handleDirectoryRoute, + }), + [view, handleDirectoryRoute], + ); + + return ( + <> + + {view === 'create' ? ( + setCreateForm((prev) => ({ ...prev, ...patch }))} + onSave={handleSaveCreate} + onSubmit={handleSubmitCreate} + onBack={() => setView('list')} + /> + ) : ( + <> + + 业务管理 + + + setPendingFilters((prev) => ({ ...prev, ...patch }))} + onReset={handleReset} + onSearch={handleSearch} + /> + + { setKpiTab(key); setPage(1); }} + /> + + + + + + + 新增供应商 + + setImportOpen(true)}> + + 批量导入 + + + showToast(`导出功能(原型演示):当前列表共 ${filtered.length} 条`)} + > + + 导出 + + + + + showToast(`查看:${record.supplierName}(原型演示)`)} + onEdit={(record) => showToast(`编辑:${record.supplierName}(原型演示)`)} + onDelete={(record) => showToast(`删除:${record.supplierName}(原型演示)`)} + /> + + 共 {filtered.length} 条 + + + + + > + )} + + {toast && {toast}} + + + setImportOpen(false)} onImport={handleImport} /> + + + > + ); +} +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, + }); +} diff --git a/src/prototypes/supplier-management/pages/CreatePage.tsx b/src/prototypes/supplier-management/pages/CreatePage.tsx new file mode 100644 index 0000000..b5a76b5 --- /dev/null +++ b/src/prototypes/supplier-management/pages/CreatePage.tsx @@ -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) => 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} + {required && *} + + {children} + {hint && {hint}} + + ); +} + +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(null); + + return ( + + {label} + inputRef.current?.click()} + > + + 点击或拖拽上传 + {hint} + onUpload(event.target.files)} + /> + + {fileNames.length > 0 && ( + + {fileNames.map((name) => ( + {name} + ))} + + )} + + ); +} + +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 ( + + + 业务管理 + / + 供应商管理 + / + 新增 + + + + 主体信息 + + + onChange({ contractingCompany: event.target.value })} + > + 请选择羚牛签约主体 + {CONTRACTING_COMPANY_OPTIONS.map((option) => ( + {option} + ))} + + + + onChange({ supplierName: event.target.value })} + /> + + + + + + + + + handleAddressParse(event.target.value)} + /> + + + + + + 开票信息 + + + onChange({ taxId: event.target.value })} + /> + + + onChange({ regPhone: event.target.value })} + /> + + + onChange({ regAddress: event.target.value })} + /> + + + + + + 银行账户 + + + onChange({ bankName: event.target.value })} + /> + + + onChange({ bankAccount: event.target.value })} + /> + + + + + + 资质证照 + + + + + + + + + ); +} diff --git a/src/prototypes/supplier-management/styles/index.css b/src/prototypes/supplier-management/styles/index.css new file mode 100644 index 0000000..92e983c --- /dev/null +++ b/src/prototypes/supplier-management/styles/index.css @@ -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; + } +} diff --git a/src/prototypes/supplier-management/types.ts b/src/prototypes/supplier-management/types.ts new file mode 100644 index 0000000..487001f --- /dev/null +++ b/src/prototypes/supplier-management/types.ts @@ -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'; diff --git a/src/prototypes/supplier-management/utils/address.ts b/src/prototypes/supplier-management/utils/address.ts new file mode 100644 index 0000000..dad5d70 --- /dev/null +++ b/src/prototypes/supplier-management/utils/address.ts @@ -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, + }; +} diff --git a/src/prototypes/supplier-management/utils/constants.ts b/src/prototypes/supplier-management/utils/constants.ts new file mode 100644 index 0000000..a586de3 --- /dev/null +++ b/src/prototypes/supplier-management/utils/constants.ts @@ -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: '资质证照状态异常的供应商' }, +]; diff --git a/src/prototypes/supplier-management/utils/filters.ts b/src/prototypes/supplier-management/utils/filters.ts new file mode 100644 index 0000000..fb03507 --- /dev/null +++ b/src/prototypes/supplier-management/utils/filters.ts @@ -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(); + 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; +}
已选 {selectedRecords.length} 个客户 · 操作人 {currentUser.name}({currentUser.department})
上传审批依据、合同、对账单等
支持 .pdf、.jpg、.png、.xlsx
暂无符合条件的供应商
点击或拖拽上传
{hint}