feat(supplier-management): 同步供应商管理最新原型迭代

补齐新增表单校验、角色切换、删除/离开确认等交互组件,并更新列表 KPI、样例数据与需求评审文档。
This commit is contained in:
hsu06
2026-07-03 16:17:27 +08:00
parent ae597d8923
commit d458d63786
22 changed files with 1138 additions and 79 deletions

View File

@@ -0,0 +1,94 @@
# Prototype Review
- 审查目标:`src/prototypes/supplier-management`(列表页 `list` + 新增页 `create`
- 用户资料/参考资料:
- 本轮用户说明:按 `rules/prototype-review-guide.md` 对供应商管理做需求评审
- `.spec/prototype-comments.json`(供应商名称筛选项、联系人必填与电话位数校验等近期批注)
- `src/resources/oneos-web-legacy/需求说明/业务管理/供应商管理`
- `src/resources/oneos-web-legacy/业务管理/供应商管理-新增.jsx``供应商管理-查看.jsx``供应商管理-编辑.jsx`
- 源码:`index.tsx``pages/CreatePage.tsx``components/*``utils/*``types.ts``data/suppliers.json`
- `annotation-source.json``canvas.excalidraw`(未逐元素核对画布)
- `.axhub/make/project.json`(入口 `supplier-management``/prototypes/supplier-management`
- 生成时间2026-07-03
## 总体点评
供应商管理原型在**列表台账展示**方面与 Legacy 需求高度对齐筛选区含近期批注要求的供应商名称模糊搜索、8 张 KPI 统计卡片、宽表列(编码至创建时间)、分页与导入/导出入口均已具备,样例数据 `suppliers.json` 字段口径与表格列一致,列表侧可作为「供应商主数据台账」的有效演示。
当前最影响需求评审签字的问题,集中在**主流程出口与新增表单数据模型**。Legacy 与资源文档明确要求查看页、编辑页、删除二次确认;源码中三类操作均为 Toast`index.tsx`),无法完成「查—改—删」闭环。新增页已按加氢站/客户管理风格扩展为主体信息、联系人、开票、银行账户、资质证照五段式,但**缺少列表与 Legacy 新增页共有的合作状态、供应商类型、城市/区域、信用代码/身份证、电子邮箱、备注等字段**,提交后也不写回 `records` 状态,导致「新增 → 列表可见」链路在业务上断裂。
近期 `.spec/prototype-comments.json``annotation-source.json` 已记录筛选区名称搜索、联系人校验等决策,与实现基本一致;但 `.spec` 下**无独立 PRD Markdown**,与 Legacy 2026-03-12 版需求在新增表单结构、列表名称搜索交互(联动 vs 点查询)上存在明显分歧,评审时需产品确认以哪套为准。
## P0-P3 优先级问题
### P1 - 查看 / 编辑 / 删除主流程未实现Legacy 核心任务出口缺失
- 证据Legacy `需求说明/业务管理/供应商管理` §2.15§2.16 要求查看页、编辑页跳转及删除二次确认;`供应商管理.jsx` 含删除确认文案(历史数据保留规则);源码 `index.tsx``onView` / `onEdit` / `onDelete` 均仅 `showToast('…原型演示')``SupplierPageId``'list' | 'create'`,无 view/edit 路由。
- 影响:无法验证编辑态字段只读/可改规则、删除后对合同/账单选供应商的约束;与 OneOS 业务管理模块能力不对齐,需求评审无法确认主流程完整性。
- 修复方向:至少补充查看页(只读回显)与编辑页(编码不可改等 Legacy 规则)原型;删除增加确认弹窗并 Mock 从可选列表移除、历史保留说明。
### P1 - 新增表单字段与列表台账 / Legacy 新增页严重不对齐
- 证据Legacy `供应商管理-新增.jsx` 必填含合作状态、供应商名称、类型、城市(省-市级联)、供应商地址、联系人、联系人手机、信用代码/身份证;列表列含类型、区域、城市、信用代码、邮箱、备注(`SupplierTable.tsx`)。当前 `CreatePage.tsx` / `SupplierCreateForm` 含签约公司、地址解析、联系人、开票、银行、证照,但**无** `coopStatus``type``city`/`region``creditCodeOrId``email``remark`;校验(`validateCreateForm`)亦未覆盖上述字段。
- 影响:即使用户完整填写并提交,也无法生成与表格列一致的台账记录;与加氢站「新建供应商」区块(`站点信息.jsx`)部分对齐,但与 Legacy 供应商模块口径冲突,下游合同/对账场景无法演示字段来源。
- 修复方向:产品确认新增页以 Legacy 两卡片为准,还是以当前五段式(对齐加氢站)为准;若保留五段式,需补全台账关键字段及区域自动匹配逻辑,并在标注/PRD 写明与 Legacy 差异。
### P1 - 提交 / 保存不写回列表,新增主流程闭环断裂
- 证据:`index.tsx``records``useState` 初始 seed 且只读;`handleSubmitCreate` 校验通过后仅 Toast 并 `setView('list')`,不追加 `SupplierRecord``handleSaveCreate` 同样不落库。
- 影响:无法演示「新增供应商后在列表/KPI 中可见」筛选、KPI 计数、分页与新增联调失效;测试无法覆盖提交后编码生成、创建人/时间写入等规则。
- 修复方向:提交时将表单 Mock 映射为 `SupplierRecord` 写入本地 state含自动生成编码、创建人/时间);保存草稿可写入 session 或单独 drafts 列表。
### P2 - 删除缺少二次确认与业务规则表达
- 证据Legacy §2.16.3 要求删除确认及「已删除供应商无法在合同或账单中选择,历史数据仍显示」;`index.tsx` `onDelete` 直接 Toast无 Modal。
- 影响:业务约束(软删 vs 硬删、下游引用)无法在原型中感知,评审易遗漏合规讨论。
- 修复方向:增加确认对话框,文案对齐 LegacyMock 删除可标记 `coopStatus``deleted` 标志并过滤可选列表。
### P2 - KPI 卡片与筛选区口径分裂,且超出 Legacy 列表范围
- 证据:`KpiCards.tsx` + `utils/constants.ts` 定义 8 张卡片(含「资质异常」`licenseAbnormal`),点击即时过滤(`kpiTab`);筛选区 `pendingFilters` / `appliedFilters` 需点「查询」才生效(`FilterPanel.tsx` + `index.tsx`Legacy 需求说明无 KPI 卡片章节。`licenseStatus` 仅存在于 seed 数据,新增页无法维护。
- 影响KPI 与高级筛选组合时用户难以理解最终列表范围;「资质异常」卡片无对应新增/编辑维护路径,业务含义难验收。
- 修复方向:在 PRD/标注补充 KPI 与筛选优先级;或 KPI 激活时联动写入 `appliedFilters`;明确「资质异常」判定来源(证照上传/OCR/人工标记)。
## 完整性与项目对齐
| 维度 | 结论 |
|---|---|
| 核心用户 | Legacy 未细分角色;原型无权限模型 — **可接受为原型简化** |
| 主流程 | 列表筛选 → KPI → 表格 → 分页:**可演示**;新增 → 校验 → 返回列表:**部分可演示**(校验有、落库无);查看/编辑/删除:**未实现** |
| 入口/出口 | 入口:`project.json``/prototypes/supplier-management`**对齐**;新增页返回列表 — **有**;缺查看/编辑出口 |
| 范围边界 | 导入/导出 Toast 演示 — **符合原型惯例**8 KPI 卡片 — **实现有、Legacy 无**;五段式新增表单 — **与 Legacy 两卡片不一致** |
| 近期批注对齐 | 筛选区供应商名称(`FilterPanel.tsx` `nameKeyword` + 模糊匹配)— **已实现**;联系人必填 + 手机/座机至少一项 + 位数校验(`utils/phone.ts`)— **已实现** |
| 资料冲突/待确认 | ① 列表名称搜索Legacy §2 要求工具栏左上方**输入即联动**;批注与实现放在筛选区且需点「查询」— **以批注为准Legacy 待产品确认是否废弃**;② 新增表单结构Legacy 基本信息+开票 vs 当前主体+联系人+开票+银行+证照 — **待确认**;③ 联系人手机Legacy 新增页手机必填;批注与实现为手机/座机二选一 — **以实现+批注为准Legacy 待确认**;④ `.spec``requirements-prd-*.md`**规格缺口** |
## 业务逻辑连贯性
**流程顺序**:列表加载 →可选KPI 切换 → 筛选填写 → 查询 → 表格浏览 → 新增 — 顺序合理。名称链接与「查看」均指向 Toast未形成真实导航。
**状态迁移**:合作状态枚举(已合作/终止合作/洽谈中/合约过期)在列表、`CoopStatusBadge`、筛选与 KPI 间一致 — **自洽**。新增页不采集合作状态,新记录无法表达 KPI「已合作/洽谈中」等维度 — **断档**
**区域与城市**Legacy 要求城市级联后自动匹配区域;列表展示 `region` + `city`(省-市);筛选区区域多选 + 城市单选(扁平 `FilterPickerField`),新增页仅地址解析出 `provinceCity`,不写入 `SupplierRecord.region/city`**新增与列表字段链路不完整**
**跨模块一致性**:加氢站新建供应商含主体、开票、银行、证照(`oneos-web-legacy/加氢站管理/站点信息.jsx`与当前新增页结构相近Legacy 独立「供应商管理-新增」字段集更偏台账全字段 — **项目内存在两套供应商表单范式,需统一**
**数据生命周期**seed 数据含 `licenseStatus` 驱动 KPI「资质异常」但无编辑入口维护 — **只读演示**
## 状态、异常、边界与恢复
| 类别 | 检查结论 |
|---|---|
| 输入校验 | 新增:签约公司、名称、地址解析、联系人、电话格式 — **有**Toast 提示);缺类型/城市/信用代码等 — **见 P1** |
| 空/无数据 | 表格空态「暂无符合条件的供应商」— **有** |
| 权限不足 | 未建模 — **N/A** |
| 筛选边界 | 名称模糊匹配、编码精确、多选合作状态/类型/区域 — **有**;城市非级联 — **与 Legacy 差异** |
| KPI + 筛选重置 | 「重置」清空筛选并 KPI 回「全部供应商」— **有** |
| 删除/并发 | 无确认、无防抖 — **见 P2** |
| 网络/集成 | OCR、导入校验均为 Mock Toast — **原型范围内可接受** |
| 恢复路径 | 校验失败 Toast 可重填 — **有**;提交成功后列表无新数据 — **无业务恢复感知** |
## 证据与评估说明
- **用户资料优先级**:本轮无额外用户口头需求;`.spec/prototype-comments.json` 批注权重高于 Legacy 冲突点(名称筛选位置、联系人电话规则),已在「完整性与项目对齐」标为待确认处。
- **读取范围**`rules/prototype-review-guide.md``.spec/prototype-comments.json``src/resources/oneos-web-legacy/需求说明/业务管理/供应商管理`Legacy JSX 新增/查看/编辑;`index.tsx``CreatePage.tsx``FilterPanel.tsx``SupplierTable.tsx``KpiCards.tsx``CoopStatusBadge.tsx``utils/filters.ts``constants.ts``address.ts``phone.ts``types.ts``data/suppliers.json``annotation-source.json``.axhub/make/project.json`(摘要)。未引用 Impeccable 产物。
- **独立评估**`degraded` — 未拆分子代理业务基线Legacy + 批注)与源码证据在同一轮完成对照。

View File

@@ -36,7 +36,7 @@
"path": []
},
"aiPrompt": "KPI 卡片点击切换列表数据范围。",
"annotationText": "参照车辆管理列表页8 张统计卡片可点击筛选。",
"annotationText": "6 张统计卡片:全部、已合作、洽谈中、终止合作、合约过期、资质异常(未上传营业执照);已移除备件供应商/保险公司类型卡片。",
"hasMarkdown": true,
"color": "#0f766e",
"images": [],
@@ -82,24 +82,42 @@
{
"id": "sm-create-entity",
"index": 5,
"title": "主体信息",
"title": "地址信息",
"pageId": "create",
"locator": {
"selectors": ["[data-annotation-id=\"sm-create-entity\"]"],
"fingerprint": "sm-create-entity",
"path": []
},
"aiPrompt": "签约公司、供应商名称、地址解析字段。",
"annotationText": "签约公司下拉默认展示置灰占位「请选择羚牛签约主体」,不可作为有效选项。地址解析为必填项,填写完整地址后自动反写省市与详细地址。",
"aiPrompt": "签约公司、供应商名称、区域、城市、详细地址。",
"annotationText": "签约公司下拉默认展示置灰占位「请选择羚牛签约主体」。含签约公司、供应商名称、区域、城市(只读自动解析)、详细地址(无必填星号);填写详细地址后自动解析区域与城市。",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1740787200000
"updatedAt": 1783052400000
},
{
"id": "sm-create-contact",
"index": 6,
"title": "联系人信息",
"pageId": "create",
"locator": {
"selectors": ["[data-annotation-id=\"sm-create-contact\"]"],
"fingerprint": "sm-create-contact",
"path": []
},
"aiPrompt": "联系人、联系人手机、联系人座机。",
"annotationText": "联系人为必填联系人手机11位与联系人座机7-12位至少填写一项保存/提交时校验位数与格式。",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1783052400000
},
{
"id": "sm-create-invoice",
"index": 6,
"index": 7,
"title": "开票信息",
"pageId": "create",
"locator": {
@@ -108,7 +126,7 @@
"path": []
},
"aiPrompt": "纳税人识别号、注册电话、注册地址。",
"annotationText": "",
"annotationText": "纳税人识别号、注册电话、注册地址;所有有编辑权限的用户均可维护(不限于财务)。",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
@@ -117,7 +135,7 @@
},
{
"id": "sm-create-bank",
"index": 7,
"index": 8,
"title": "银行账户",
"pageId": "create",
"locator": {
@@ -126,7 +144,7 @@
"path": []
},
"aiPrompt": "开户行与银行账号。",
"annotationText": "",
"annotationText": "开户行、银行账号;所有有编辑权限的用户均可维护(不限于财务)。",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
@@ -135,7 +153,7 @@
},
{
"id": "sm-create-docs",
"index": 8,
"index": 9,
"title": "资质证照",
"pageId": "create",
"locator": {
@@ -144,7 +162,7 @@
"path": []
},
"aiPrompt": "营业执照 OCR 与其他证件上传。",
"annotationText": "营业执照上传后自动反写名称、税号、通讯地址。",
"annotationText": "营业执照与其他证件上传;所有有编辑权限的用户均可维护(不限于财务)。",
"hasMarkdown": true,
"color": "#2563eb",
"images": [],
@@ -153,7 +171,7 @@
},
{
"id": "sm-create-footer",
"index": 9,
"index": 10,
"title": "表单提交",
"pageId": "create",
"locator": {
@@ -162,7 +180,7 @@
"path": []
},
"aiPrompt": "返回、保存与提交操作。",
"annotationText": "底部固定操作栏:左侧「返回」回到列表;右侧「保存」暂存草稿,「提交」校验通过后提交并返回列表。",
"annotationText": "底部操作栏按钮右对齐:「返回」「保存」「提交」。未保存时点击「返回」或面包屑离开,需二次确认。",
"hasMarkdown": true,
"color": "#0f766e",
"images": [],

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { ShieldAlert, X } from 'lucide-react';
interface BankAccountConfirmModalProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function BankAccountConfirmModal({
open,
onClose,
onConfirm,
}: BankAccountConfirmModalProps) {
if (!open) return null;
return (
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="vm-modal vm-modal-sm"
role="dialog"
aria-modal="true"
aria-labelledby="sm-bank-title"
onClick={(event) => event.stopPropagation()}
>
<header className="vm-modal-header">
<h2 id="sm-bank-title"></h2>
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="sm-modal-body">
<div className="sm-modal-alert">
<ShieldAlert size={18} aria-hidden />
<div>
<p></p>
<p className="sm-modal-sub"></p>
</div>
</div>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm}></button>
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { X } from 'lucide-react';
import type { ChangeLogEntry } from '../types';
interface ChangeLogDrawerProps {
open: boolean;
supplierId: string;
supplierName: string;
entries: ChangeLogEntry[];
onClose: () => void;
}
export function ChangeLogDrawer({
open,
supplierId,
supplierName,
entries,
onClose,
}: ChangeLogDrawerProps) {
if (!open) return null;
const filtered = entries.filter((item) => item.supplierId === supplierId);
return (
<div className="sm-drawer-backdrop" role="presentation" onClick={onClose}>
<aside
className="sm-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="sm-changelog-title"
onClick={(event) => event.stopPropagation()}
data-annotation-id="sm-changelog-drawer"
>
<header className="sm-drawer-header">
<div>
<h2 id="sm-changelog-title"></h2>
<p className="sm-drawer-sub">{supplierName}</p>
</div>
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="sm-drawer-body">
{filtered.length === 0 ? (
<div className="vm-empty">
<p></p>
</div>
) : (
<ul className="sm-changelog-list">
{filtered.map((entry) => (
<li key={entry.id} className="sm-changelog-item">
<div className="sm-changelog-meta">
<span>{entry.fieldLabel}</span>
<time>{entry.operatedAt}</time>
</div>
<div className="sm-changelog-diff">
<span className="sm-changelog-old">{entry.oldValue}</span>
<span className="sm-changelog-arrow" aria-hidden></span>
<span className="sm-changelog-new">{entry.newValue}</span>
</div>
<div className="sm-changelog-operator">{entry.operator}</div>
</li>
))}
</ul>
)}
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
import type { SupplierRecord } from '../types';
import { canDeleteSupplier } from '../utils/permissions';
interface DeleteConfirmModalProps {
open: boolean;
record: SupplierRecord | null;
onClose: () => void;
onConfirm: () => void;
}
export function DeleteConfirmModal({
open,
record,
onClose,
onConfirm,
}: DeleteConfirmModalProps) {
if (!open || !record) return null;
const deletable = canDeleteSupplier(record);
return (
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="vm-modal vm-modal-sm"
role="dialog"
aria-modal="true"
aria-labelledby="sm-delete-title"
onClick={(event) => event.stopPropagation()}
>
<header className="vm-modal-header">
<h2 id="sm-delete-title">{deletable ? '确认删除供应商' : '确认停用供应商'}</h2>
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="sm-modal-body">
<div className="sm-modal-alert">
<AlertTriangle size={18} aria-hidden />
<div>
{deletable ? (
<p>
{record.supplierName}
</p>
) : (
<>
<p>
{record.supplierName}
{record.hasLinkedContract && '合同'}
{record.hasLinkedContract && record.hasLinkedPayment && '、'}
{record.hasLinkedPayment && '付款记录'}
</p>
<p className="sm-modal-sub"></p>
</>
)}
</div>
</div>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm}>
{deletable ? '确认删除' : '确认停用'}
</button>
</footer>
</div>
</div>
);
}

View File

@@ -6,8 +6,6 @@ import {
MessageSquare,
Ban,
Clock,
Package,
Shield,
FileWarning,
} from 'lucide-react';
import type { SupplierKpiKey } from '../types';
@@ -19,8 +17,6 @@ const ICONS: Record<SupplierKpiKey, React.ReactNode> = {
negotiating: <MessageSquare size={18} aria-hidden />,
terminated: <Ban size={18} aria-hidden />,
expired: <Clock size={18} aria-hidden />,
parts: <Package size={18} aria-hidden />,
insurance: <Shield size={18} aria-hidden />,
licenseAbnormal: <FileWarning size={18} aria-hidden />,
};
@@ -32,7 +28,7 @@ interface KpiCardsProps {
export function KpiCards({ counts, active, onChange }: KpiCardsProps) {
return (
<div className="vm-kpi-row">
<div className="vm-kpi-row sm-kpi-row">
{KPI_CARDS.map((card) => (
<button
key={card.key}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
interface LeaveConfirmModalProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function LeaveConfirmModal({ open, onClose, onConfirm }: LeaveConfirmModalProps) {
if (!open) return null;
return (
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="vm-modal vm-modal-sm"
role="dialog"
aria-modal="true"
aria-labelledby="sm-leave-title"
onClick={(event) => event.stopPropagation()}
>
<header className="vm-modal-header">
<h2 id="sm-leave-title"></h2>
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="sm-modal-body">
<div className="sm-modal-alert">
<AlertTriangle size={18} aria-hidden />
<div>
<p></p>
<p className="sm-modal-sub"></p>
</div>
</div>
</div>
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm}></button>
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import type { SupplierRole } from '../types';
import { SUPPLIER_ROLE_OPTIONS } from '../utils/constants';
import { canCreateSupplier } from '../utils/permissions';
interface RoleSwitcherProps {
role: SupplierRole;
onChange: (role: SupplierRole) => void;
}
export function RoleSwitcher({ role, onChange }: RoleSwitcherProps) {
const current = SUPPLIER_ROLE_OPTIONS.find((item) => item.value === role);
return (
<div className="sm-role-bar" data-annotation-id="sm-role-switch">
<span className="sm-role-bar-label"></span>
<select
className="vm-input sm-role-select"
value={role}
onChange={(event) => onChange(event.target.value as SupplierRole)}
aria-label="当前操作角色"
>
{SUPPLIER_ROLE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
<span className="sm-role-hint">{current?.desc}</span>
{!canCreateSupplier(role) && (
<span className="sm-role-hint sm-role-hint-warn"></span>
)}
</div>
);
}

View File

@@ -16,7 +16,8 @@
"remark": "",
"creator": "李四",
"createTime": "2025-01-10 09:30",
"licenseStatus": "正常"
"licenseStatus": "正常",
"businessLicenseUploaded": true
},
{
"id": "2",
@@ -35,7 +36,8 @@
"remark": "意向供应商",
"creator": "李专员",
"createTime": "2025-02-15 14:20",
"licenseStatus": "正常"
"licenseStatus": "正常",
"businessLicenseUploaded": true
},
{
"id": "3",
@@ -54,7 +56,8 @@
"remark": "",
"creator": "张经理",
"createTime": "2025-01-20 11:00",
"licenseStatus": "异常"
"licenseStatus": "异常",
"businessLicenseUploaded": true
},
{
"id": "4",
@@ -73,7 +76,8 @@
"remark": "已终止",
"creator": "李四",
"createTime": "2024-06-01 10:00",
"licenseStatus": "正常"
"licenseStatus": "正常",
"businessLicenseUploaded": true
},
{
"id": "5",
@@ -92,7 +96,8 @@
"remark": "待续约",
"creator": "王专员",
"createTime": "2025-03-01 08:45",
"licenseStatus": "异常"
"licenseStatus": "异常",
"businessLicenseUploaded": false
},
{
"id": "6",
@@ -111,7 +116,8 @@
"remark": "",
"creator": "张经理",
"createTime": "2025-03-12 16:10",
"licenseStatus": "正常"
"licenseStatus": "正常",
"businessLicenseUploaded": true
},
{
"id": "7",
@@ -130,7 +136,8 @@
"remark": "",
"creator": "李四",
"createTime": "2025-04-02 10:20",
"licenseStatus": "正常"
"licenseStatus": "正常",
"businessLicenseUploaded": true
},
{
"id": "8",
@@ -149,6 +156,7 @@
"remark": "评估中",
"creator": "李专员",
"createTime": "2025-04-18 11:35",
"licenseStatus": "正常"
"licenseStatus": "正常",
"businessLicenseUploaded": false
}
]

View File

@@ -17,6 +17,7 @@ import { FilterPanel } from './components/FilterPanel';
import { KpiCards } from './components/KpiCards';
import { SupplierTable } from './components/SupplierTable';
import { CreatePage } from './pages/CreatePage';
import { LeaveConfirmModal } from './components/LeaveConfirmModal';
import { ImportModal } from '../vehicle-management/components/ImportModal';
import { TablePagination } from '../vehicle-management/components/TablePagination';
import {
@@ -29,6 +30,8 @@ import {
type SupplierRecord,
} from './types';
import { applyFilters, countKpi } from './utils/filters';
import { validateContactPhones } from './utils/phone';
import { cloneCreateForm, isCreateFormDirty } from './utils/createForm';
import annotationSourceDocument from './annotation-source.json';
const DEFAULT_PAGE_SIZE = 10;
@@ -41,8 +44,8 @@ type NavigationRef = {
function validateCreateForm(form: SupplierCreateForm): string | null {
if (!form.contractingCompany) return '请选择签约公司';
if (!form.supplierName.trim()) return '请输入供应商名称';
if (!form.fullAddress.trim()) return '请填写地址解析';
return null;
if (!form.contact.trim()) return '请输入联系人';
return validateContactPhones(form.contactMobile, form.contactPhone);
}
export default function SupplierManagementApp() {
@@ -54,6 +57,8 @@ export default function SupplierManagementApp() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [createForm, setCreateForm] = useState<SupplierCreateForm>(EMPTY_CREATE_FORM);
const [createFormBaseline, setCreateFormBaseline] = useState<SupplierCreateForm>(EMPTY_CREATE_FORM);
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [toast, setToast] = useState('');
const navigationRef = useRef<NavigationRef>({});
@@ -66,6 +71,7 @@ export default function SupplierManagementApp() {
navigationRef.current = {
openCreate: () => {
setCreateForm(EMPTY_CREATE_FORM);
setCreateFormBaseline(cloneCreateForm(EMPTY_CREATE_FORM));
setView('create');
},
backToList: () => setView('list'),
@@ -77,8 +83,6 @@ export default function SupplierManagementApp() {
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]);
@@ -112,15 +116,32 @@ export default function SupplierManagementApp() {
const handleOpenCreate = () => {
setCreateForm(EMPTY_CREATE_FORM);
setCreateFormBaseline(cloneCreateForm(EMPTY_CREATE_FORM));
setView('create');
};
const leaveCreatePage = () => {
setLeaveConfirmOpen(false);
setCreateForm(EMPTY_CREATE_FORM);
setCreateFormBaseline(cloneCreateForm(EMPTY_CREATE_FORM));
setView('list');
};
const handleBackFromCreate = () => {
if (isCreateFormDirty(createForm, createFormBaseline)) {
setLeaveConfirmOpen(true);
return;
}
leaveCreatePage();
};
const handleSaveCreate = () => {
const error = validateCreateForm(createForm);
if (error) {
showToast(error);
return;
}
setCreateFormBaseline(cloneCreateForm(createForm));
showToast('已保存草稿(原型演示)');
};
@@ -131,7 +152,7 @@ export default function SupplierManagementApp() {
return;
}
showToast('供应商已提交(原型演示)');
setView('list');
leaveCreatePage();
};
const handleImport = (file: File) => {
@@ -171,7 +192,7 @@ export default function SupplierManagementApp() {
onChange={(patch) => setCreateForm((prev) => ({ ...prev, ...patch }))}
onSave={handleSaveCreate}
onSubmit={handleSubmitCreate}
onBack={() => setView('list')}
onBack={handleBackFromCreate}
/>
) : (
<>
@@ -241,6 +262,11 @@ export default function SupplierManagementApp() {
</div>
<ImportModal open={importOpen} onClose={() => setImportOpen(false)} onImport={handleImport} />
<LeaveConfirmModal
open={leaveConfirmOpen}
onClose={() => setLeaveConfirmOpen(false)}
onConfirm={leaveCreatePage}
/>
<AnnotationViewer
source={annotationSourceDocument as AnnotationSourceDocument}
@@ -296,19 +322,6 @@ if (typeof window !== 'undefined') {
}
}
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);

View File

@@ -2,7 +2,7 @@ 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';
import { resolveAddressFromDetail } from '../utils/address';
interface CreatePageProps {
form: SupplierCreateForm;
@@ -92,12 +92,13 @@ export function CreatePage({
onSubmit,
onBack,
}: CreatePageProps) {
const handleAddressParse = (fullAddress: string) => {
const parsed = parseFullAddress(fullAddress);
const handleDetailAddressChange = (value: string) => {
const resolved = resolveAddressFromDetail(value);
onChange({
fullAddress,
provinceCity: parsed.provinceCity,
detailAddress: parsed.detailAddress,
detailAddress: value,
region: resolved.region,
provinceCity: resolved.provinceCity,
cityDisplay: resolved.cityDisplay,
});
};
@@ -129,7 +130,8 @@ export function CreatePage({
</nav>
<section className="sm-form-card" data-annotation-id="sm-create-entity">
<h2 className="sm-form-card-title"></h2>
<h2 className="sm-form-card-title"></h2>
<p className="sm-section-hint">xx市xx区</p>
<div className="sm-form-grid">
<FormField label="签约公司" required>
<select
@@ -151,30 +153,60 @@ export function CreatePage({
onChange={(event) => onChange({ supplierName: event.target.value })}
/>
</FormField>
<FormField label="省市">
<FormField label="区域">
<input
className="vm-input sm-input-disabled"
value={form.provinceCity}
placeholder="自动从地址解析"
disabled
value={form.region}
placeholder="自动从详细地址解析"
readOnly
aria-readonly
/>
</FormField>
<FormField label="详细地址">
<FormField label="城市">
<input
className="vm-input sm-input-disabled"
value={form.detailAddress}
placeholder="自动从地址解析"
disabled
value={form.cityDisplay}
placeholder="自动从详细地址解析"
readOnly
aria-readonly
/>
</FormField>
<FormField label="地址解析" required fullWidth>
<FormField label="详细地址" fullWidth>
<input
className="vm-input"
value={form.fullAddress}
placeholder="填写完整地址自动解析省市和详细地址"
onChange={(event) => handleAddressParse(event.target.value)}
value={form.detailAddress}
placeholder="请输入完整地址浙江省嘉兴市南湖区科技大道1号"
onChange={(event) => handleDetailAddressChange(event.target.value)}
/>
</FormField>
</div>
</section>
<section className="sm-form-card" data-annotation-id="sm-create-contact">
<h2 className="sm-form-card-title"></h2>
<div className="sm-form-grid">
<FormField label="联系人姓名" required>
<input
className="vm-input"
value={form.contact}
placeholder="请输入联系人"
onChange={(event) => onChange({ contact: event.target.value })}
/>
</FormField>
<FormField label="联系人手机" hint="手机与座机至少填一项">
<input
className="vm-input"
value={form.contactMobile}
placeholder="11位手机号"
onChange={(event) => onChange({ contactMobile: event.target.value })}
/>
</FormField>
<FormField label="联系人座机">
<input
className="vm-input"
value={form.contactPhone}
placeholder="手机号或固定电话"
onChange={(event) => onChange({ contactPhone: event.target.value })}
/>
</FormField>
</div>
@@ -254,8 +286,8 @@ export function CreatePage({
</section>
<footer className="sm-create-footer" data-annotation-id="sm-create-footer">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onBack}></button>
<div className="sm-create-footer-actions">
<button type="button" className="vm-btn vm-btn-ghost" onClick={onBack}></button>
<button type="button" className="vm-btn vm-btn-secondary" onClick={onSave}></button>
<button type="button" className="vm-btn vm-btn-primary" onClick={onSubmit}></button>
</div>

View File

@@ -88,6 +88,258 @@
min-width: 168px;
}
.sm-inactive-tag {
display: inline-block;
margin-left: 6px;
padding: 0 6px;
font-size: 0.75rem;
color: var(--ln-muted);
background: var(--ln-canvas-soft);
border-radius: var(--ln-radius-pill);
}
.sm-kpi-row {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.sm-role-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--ln-surface-card);
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card);
}
.sm-role-bar-label {
font-size: 0.875rem;
color: var(--ln-body);
}
.sm-role-select {
min-width: 160px;
}
.sm-role-hint {
font-size: 0.8125rem;
color: var(--ln-muted);
}
.sm-role-hint-warn {
color: #b45309;
}
.sm-date-range {
display: flex;
align-items: center;
gap: 8px;
}
.sm-date-sep {
color: var(--ln-muted);
font-size: 0.875rem;
}
.sm-permission-banner {
padding: 10px 14px;
font-size: 0.8125rem;
color: #1e40af;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: var(--ln-radius-card);
}
.sm-section-hint {
margin: -12px 0 16px;
font-size: 0.8125rem;
color: var(--ln-muted);
}
.sm-lock-tag {
margin-left: 6px;
padding: 0 6px;
font-size: 0.75rem;
color: var(--ln-muted);
background: var(--ln-canvas-soft);
border-radius: var(--ln-radius-pill);
}
.sm-textarea {
min-height: 88px;
resize: vertical;
}
.sm-license-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.sm-license-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.sm-license-item {
padding: 16px;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card);
background: var(--ln-surface-pearl);
}
.sm-license-item-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sm-license-empty {
padding: 24px 0;
}
.sm-upload-zone-compact {
min-height: 88px;
}
.sm-modal-body {
padding: 0 24px 8px;
}
.sm-modal-alert {
display: flex;
gap: 12px;
padding: 14px;
border-radius: var(--ln-radius-card);
background: #fffbeb;
color: #92400e;
font-size: 0.875rem;
}
.sm-modal-sub {
margin-top: 6px;
font-size: 0.8125rem;
color: var(--ln-muted);
}
.sm-drawer-backdrop {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(15, 23, 42, 0.35);
}
.sm-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 101;
display: flex;
flex-direction: column;
width: min(480px, 100vw);
height: 100vh;
background: var(--ln-surface-card);
box-shadow: -4px 0 24px rgba(15, 23, 42, 0.12);
}
.sm-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 24px;
border-bottom: 1px solid var(--ln-hairline);
}
.sm-drawer-header h2 {
margin: 0;
font-size: 1.125rem;
}
.sm-drawer-sub {
margin: 4px 0 0;
font-size: 0.875rem;
color: var(--ln-muted);
}
.sm-drawer-body {
flex: 1;
overflow: auto;
padding: 16px 24px 24px;
}
.sm-changelog-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.sm-changelog-item {
padding: 12px 14px;
border: 1px solid var(--ln-hairline);
border-radius: var(--ln-radius-card);
background: var(--ln-surface-pearl);
}
.sm-changelog-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.875rem;
font-weight: 500;
}
.sm-changelog-meta time {
font-weight: 400;
color: var(--ln-muted);
font-size: 0.8125rem;
}
.sm-changelog-diff {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
font-size: 0.8125rem;
}
.sm-changelog-old {
color: var(--ln-muted);
text-decoration: line-through;
}
.sm-changelog-new {
color: var(--ln-ink);
font-weight: 500;
}
.sm-changelog-operator {
margin-top: 6px;
font-size: 0.75rem;
color: var(--ln-muted);
}
@media (max-width: 1200px) {
.sm-kpi-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.sm-kpi-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.sticky-col-left {
position: sticky;
left: 0;
@@ -289,7 +541,7 @@
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
margin: 0 -32px -32px;
@@ -301,7 +553,9 @@
.sm-create-footer-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
width: 100%;
}
@media (max-width: 960px) {

View File

@@ -16,8 +16,6 @@ export type SupplierKpiKey =
| 'negotiating'
| 'terminated'
| 'expired'
| 'parts'
| 'insurance'
| 'licenseAbnormal';
export interface SupplierRecord {
@@ -38,6 +36,7 @@ export interface SupplierRecord {
creator: string;
createTime: string;
licenseStatus: '正常' | '异常';
businessLicenseUploaded: boolean;
}
export interface SupplierFilters {
@@ -63,9 +62,13 @@ export const EMPTY_FILTERS: SupplierFilters = {
export interface SupplierCreateForm {
contractingCompany: string;
supplierName: string;
region: string;
provinceCity: string;
cityDisplay: string;
detailAddress: string;
fullAddress: string;
contact: string;
contactMobile: string;
contactPhone: string;
taxId: string;
regPhone: string;
regAddress: string;
@@ -78,9 +81,13 @@ export interface SupplierCreateForm {
export const EMPTY_CREATE_FORM: SupplierCreateForm = {
contractingCompany: '',
supplierName: '',
region: '',
provinceCity: '',
cityDisplay: '',
detailAddress: '',
fullAddress: '',
contact: '',
contactMobile: '',
contactPhone: '',
taxId: '',
regPhone: '',
regAddress: '',

View File

@@ -1,3 +1,5 @@
import { getRegionFromProvinceCity } from './region';
const PROVINCES = [
'北京市', '天津市', '河北省', '山西省', '内蒙古自治区',
'上海市', '江苏省', '浙江省', '安徽省', '福建省', '江西省', '山东省',
@@ -8,6 +10,8 @@ const PROVINCES = [
'陕西省', '甘肃省', '青海省', '宁夏回族自治区', '新疆维吾尔自治区',
];
const MUNICIPALITIES = new Set(['北京市', '天津市', '上海市', '重庆市']);
export function parseFullAddress(fullAddress: string): {
provinceCity: string;
detailAddress: string;
@@ -30,3 +34,30 @@ export function parseFullAddress(fullAddress: string): {
detailAddress: detailAddress || text,
};
}
export function formatCityDisplay(provinceCity: string): string {
if (!provinceCity.trim()) return '';
const [province, cityPart] = provinceCity.split('-');
if (!province) return '';
if (MUNICIPALITIES.has(province)) {
return cityPart ? `${province}${cityPart}` : province;
}
return cityPart ? `${province}${cityPart}` : province;
}
export function resolveAddressFromDetail(detailAddress: string): {
region: string;
provinceCity: string;
cityDisplay: string;
detailAddress: string;
} {
const parsed = parseFullAddress(detailAddress);
const region = getRegionFromProvinceCity(parsed.provinceCity);
const cityDisplay = formatCityDisplay(parsed.provinceCity);
return {
region,
provinceCity: parsed.provinceCity,
cityDisplay,
detailAddress: parsed.detailAddress || detailAddress.trim(),
};
}

View File

@@ -32,7 +32,5 @@ export const KPI_CARDS: Array<{
{ key: 'negotiating', title: '洽谈中', desc: '合作状态为洽谈中的供应商' },
{ key: 'terminated', title: '终止合作', desc: '合作状态为终止合作的供应商' },
{ key: 'expired', title: '合约过期', desc: '合作状态为合约过期的供应商' },
{ key: 'parts', title: '备件供应商', desc: '类型为备件供应商的记录' },
{ key: 'insurance', title: '保险公司', desc: '类型为保险公司的记录' },
{ key: 'licenseAbnormal', title: '资质异常', desc: '资质证照状态异常的供应商' },
{ key: 'licenseAbnormal', title: '资质异常', desc: '未上传供应商营业执照的记录' },
];

View File

@@ -0,0 +1,16 @@
import type { SupplierCreateForm } from '../types';
import { EMPTY_CREATE_FORM } from '../types';
export function cloneCreateForm(form: SupplierCreateForm): SupplierCreateForm {
return {
...form,
otherDocFiles: [...form.otherDocFiles],
};
}
export function isCreateFormDirty(
form: SupplierCreateForm,
baseline: SupplierCreateForm = EMPTY_CREATE_FORM,
): boolean {
return JSON.stringify(form) !== JSON.stringify(baseline);
}

View File

@@ -10,9 +10,7 @@ export function applyFilters(
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 (kpiTab === 'licenseAbnormal' && record.businessLicenseUploaded !== false) return false;
if (filters.code && record.supplierCode !== filters.code) return false;
if (filters.coopStatuses.length > 0 && !filters.coopStatuses.includes(record.coopStatus)) {
@@ -52,8 +50,6 @@ export function countKpi(records: SupplierRecord[], key: SupplierKpiKey): number
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;
if (key === 'licenseAbnormal') return records.filter((r) => !r.businessLicenseUploaded).length;
return 0;
}

View File

@@ -0,0 +1,41 @@
import type { SupplierRecord, SupplierRole } from '../types';
export type FormSection = 'basic' | 'address' | 'contact' | 'tax' | 'bank' | 'license' | 'remark';
export function canCreateSupplier(role: SupplierRole): boolean {
return role === 'businessAdmin';
}
export function canEditSection(role: SupplierRole, section: FormSection): boolean {
if (role === 'viewer') return false;
if (role === 'businessAdmin') {
return (
section === 'basic'
|| section === 'address'
|| section === 'contact'
|| section === 'remark'
|| section === 'license'
|| section === 'tax'
|| section === 'bank'
);
}
if (role === 'finance') {
return section === 'tax' || section === 'bank';
}
if (role === 'compliance') {
return section === 'license';
}
return false;
}
export function canReviewLicense(role: SupplierRole): boolean {
return role === 'compliance';
}
export function canDeleteSupplier(record: SupplierRecord): boolean {
return !record.hasLinkedContract && !record.hasLinkedPayment;
}
export function getDeleteActionLabel(record: SupplierRecord): '删除' | '停用' {
return canDeleteSupplier(record) ? '删除' : '停用';
}

View File

@@ -0,0 +1,32 @@
export function validateMobile(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (!/^1\d{10}$/.test(trimmed)) return '手机号格式不正确11位';
return null;
}
export function validateLandline(value: string): string | null {
const trimmed = value.trim();
if (!trimmed || trimmed === '-') return null;
const digits = trimmed.replace(/\D/g, '');
if (digits.length < 7 || digits.length > 12) return '座机号码格式不正确';
return null;
}
export function validateEmail(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return '邮箱格式不正确';
return null;
}
export function validateContactPhones(mobile: string, phone: string): string | null {
if (!mobile.trim() && (!phone.trim() || phone.trim() === '-')) {
return '联系人手机与座机至少填写一项';
}
const mobileError = validateMobile(mobile);
if (mobileError) return mobileError;
const phoneError = validateLandline(phone);
if (phoneError) return phoneError;
return null;
}

View File

@@ -0,0 +1,31 @@
export function getRegionByProvince(province: string): string {
const area = province.trim();
if (!area) return '';
if (['北京市', '天津市', '河北省', '山西省', '内蒙古', '内蒙古自治区'].some((item) => area.startsWith(item))) {
return '华北';
}
if (['上海市', '江苏省', '浙江省', '安徽省', '福建省', '江西省', '山东省', '台湾省'].some((item) => area.startsWith(item))) {
return '华东';
}
if (['广东省', '广西', '海南省', '澳门', '香港'].some((item) => area.startsWith(item))) {
return '华南';
}
if (['河南省', '湖北省', '湖南省'].some((item) => area.startsWith(item))) {
return '华中';
}
if (['辽宁省', '吉林省', '黑龙江省'].some((item) => area.startsWith(item))) {
return '东北';
}
if (['四川省', '云南省', '贵州省', '重庆市', '西藏'].some((item) => area.startsWith(item))) {
return '西南';
}
if (['陕西省', '甘肃省', '青海省', '宁夏', '新疆'].some((item) => area.startsWith(item))) {
return '西北';
}
return '';
}
export function getRegionFromProvinceCity(provinceCity: string): string {
const province = provinceCity.split('-')[0]?.trim() ?? provinceCity.trim();
return getRegionByProvince(province);
}

View File

@@ -0,0 +1,127 @@
import type { ChangeLogEntry, SupplierCreateForm, SupplierRecord } from '../types';
import { KEY_FIELD_LABELS } from './constants';
export function recordToForm(record: SupplierRecord): SupplierCreateForm {
return {
contractingCompany: record.contractingCompany,
supplierName: record.supplierName,
type: record.type,
coopStatus: record.coopStatus,
region: record.region,
provinceCity: record.city,
cityDisplay: record.cityDisplay,
detailAddress: record.detailAddress,
contact: record.contact,
contactMobile: record.contactMobile,
contactPhone: record.contactPhone,
email: record.email,
creditCodeOrId: record.creditCodeOrId,
taxId: record.taxId,
regPhone: record.regPhone,
regAddress: record.regAddress,
bankName: record.bankName,
bankAccount: record.bankAccount,
remark: record.remark,
licenses: record.licenses.map((item) => ({ ...item })),
};
}
export function formToRecord(
form: SupplierCreateForm,
base: Partial<SupplierRecord> & Pick<SupplierRecord, 'id' | 'supplierCode'>,
): SupplierRecord {
const creditCode = form.creditCodeOrId.trim() || base.creditCodeOrId || '';
return {
id: base.id,
supplierCode: base.supplierCode,
contractingCompany: form.contractingCompany,
supplierName: form.supplierName.trim(),
type: form.type as SupplierRecord['type'],
coopStatus: form.coopStatus as SupplierRecord['coopStatus'],
region: form.region,
city: form.provinceCity,
cityDisplay: form.cityDisplay,
detailAddress: form.detailAddress,
creditCodeOrId: creditCode,
contact: form.contact.trim(),
contactMobile: form.contactMobile.trim(),
contactPhone: form.contactPhone.trim() || '-',
email: form.email.trim(),
taxId: form.taxId.trim(),
regPhone: form.regPhone.trim(),
regAddress: form.regAddress.trim(),
bankName: form.bankName.trim(),
bankAccount: form.bankAccount.trim(),
remark: form.remark.trim(),
qualStatus: base.qualStatus ?? '正常',
riskStatus: base.riskStatus ?? '无风险',
licenseStatus: base.licenseStatus ?? '正常',
hasLinkedContract: base.hasLinkedContract ?? false,
hasLinkedPayment: base.hasLinkedPayment ?? false,
status: base.status ?? 'active',
creator: base.creator ?? '系统',
createTime: base.createTime ?? new Date().toISOString().slice(0, 16).replace('T', ' '),
updatedAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
licenses: form.licenses.map((item) => ({ ...item })),
};
}
const TRACKED_FIELDS: Array<keyof SupplierCreateForm> = [
'contractingCompany',
'supplierName',
'type',
'coopStatus',
'region',
'cityDisplay',
'detailAddress',
'contact',
'contactMobile',
'contactPhone',
'email',
'creditCodeOrId',
'taxId',
'regPhone',
'regAddress',
'bankName',
'bankAccount',
'remark',
];
export function buildChangeLogs(
before: SupplierCreateForm,
after: SupplierCreateForm,
supplier: Pick<SupplierRecord, 'id' | 'supplierName'>,
operator: string,
): ChangeLogEntry[] {
const now = new Date().toISOString().slice(0, 16).replace('T', ' ');
const entries: ChangeLogEntry[] = [];
TRACKED_FIELDS.forEach((field) => {
const oldValue = String(before[field] ?? '').trim();
const newValue = String(after[field] ?? '').trim();
if (oldValue === newValue) return;
entries.push({
id: `${supplier.id}-${field}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
supplierId: supplier.id,
supplierName: supplier.supplierName,
fieldLabel: KEY_FIELD_LABELS[field] ?? field,
oldValue: oldValue || '—',
newValue: newValue || '—',
operator,
operatedAt: now,
});
});
return entries;
}
export function nextSupplierCode(records: SupplierRecord[]): string {
const year = new Date().getFullYear();
const prefix = `GYS-${year}-`;
const maxSeq = records.reduce((max, record) => {
if (!record.supplierCode.startsWith(prefix)) return max;
const seq = Number.parseInt(record.supplierCode.slice(prefix.length), 10);
return Number.isNaN(seq) ? max : Math.max(max, seq);
}, 0);
return `${prefix}${String(maxSeq + 1).padStart(3, '0')}`;
}

View File

@@ -0,0 +1,100 @@
import type { LicenseRecord, SupplierCreateForm, SupplierRecord } from '../types';
import { validateContactPhones, validateEmail, validateMobile } from './phone';
const TAX_ID_PATTERN = /^[0-9A-HJ-NPQRTUWXY]{15}$|^[0-9A-HJ-NPQRTUWXY]{18}$/i;
const USCC_PATTERN = /^[0-9A-HJ-NPQRTUWXY]{18}$/i;
export function validateTaxId(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (!TAX_ID_PATTERN.test(trimmed)) return '纳税人识别号格式不正确';
return null;
}
export function validateCreditCode(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (!USCC_PATTERN.test(trimmed)) return '统一社会信用代码格式不正确18位';
return null;
}
export function validateBankAccount(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (!/^\d+$/.test(trimmed)) return '银行账号仅允许数字';
return null;
}
export function validateLicenses(licenses: LicenseRecord[]): string | null {
const businessLicense = licenses.find((item) => item.docType === '营业执照');
if (!businessLicense?.fileName.trim()) return '请上传营业执照';
for (const license of licenses) {
if (!license.validFrom || !license.validTo) continue;
if (license.validTo < license.validFrom) {
return `${license.docType}有效期结束时间不得早于开始时间`;
}
}
return null;
}
export function validateSupplierForm(
form: SupplierCreateForm,
records: SupplierRecord[],
editingId?: string,
): string | null {
if (!form.contractingCompany) return '请选择签约公司';
if (!form.supplierName.trim()) return '请输入供应商名称';
if (!form.type) return '请选择供应商类型';
if (!form.coopStatus) return '请选择合作状态';
const duplicateName = records.some(
(r) => r.supplierName.trim() === form.supplierName.trim() && r.id !== editingId,
);
if (duplicateName) return '供应商名称不可重复';
if (!form.contact.trim()) return '请输入联系人姓名';
const phoneError = validateContactPhones(form.contactMobile, form.contactPhone);
if (phoneError) return phoneError;
if (form.contactMobile.trim()) {
const mobileError = validateMobile(form.contactMobile);
if (mobileError) return mobileError;
}
if (form.email.trim()) {
const emailError = validateEmail(form.email);
if (emailError) return emailError;
}
const creditError = validateCreditCode(form.creditCodeOrId);
if (creditError) return creditError;
const taxError = validateTaxId(form.taxId);
if (taxError) return taxError;
const bankError = validateBankAccount(form.bankAccount);
if (bankError) return bankError;
const licenseError = validateLicenses(form.licenses);
if (licenseError) return licenseError;
return null;
}
export function validateCreditCodeUnique(
creditCode: string,
records: SupplierRecord[],
editingId?: string,
): string | null {
const trimmed = creditCode.trim();
if (!trimmed) return null;
const formatError = validateCreditCode(trimmed);
if (formatError) return formatError;
const duplicate = records.some(
(r) => r.creditCodeOrId.trim().toUpperCase() === trimmed.toUpperCase() && r.id !== editingId,
);
if (duplicate) return '统一社会信用代码不可重复';
return null;
}