main
kangwenjing 2026-04-03 17:46:58 +08:00
parent 6ad8d41055
commit 592937db4e
14 changed files with 2748 additions and 1054 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -1,526 +0,0 @@
# CRM业务表设计
## 1. 设计依据
本设计根据当前前端页面反推业务模型,涉及页面如下:
- `src/pages/Opportunities.tsx`:商机储备、商机详情、跟进记录
- `src/pages/Expansion.tsx`:销售人员拓展、渠道拓展、跟进记录
- `src/pages/Work.tsx`:外勤打卡、日报、历史记录、主管点评
- `src/pages/Profile.tsx`:个人资料、统计信息
- `src/pages/Dashboard.tsx`:首页统计、待办、动态
当前前端是展示型页面,未接入真实接口,因此以下设计属于“按现有前端信息推导出的第一版业务库设计”。
---
## 2. 业务模块拆分
建议按以下模块建表:
1. 组织与人员
2. 客户与商机
3. 拓展管理
4. 工作管理
5. 待办与动态(可选增强)
---
## 3. 核心实体关系
```text
部门 department
└─< 用户 user
客户 customer
└─< 商机 opportunity
└─< 商机跟进记录 opportunity_followup
用户 user
└─< 销售拓展 sales_expansion
└─< 拓展跟进记录 expansion_followup
用户 user
└─< 渠道拓展 channel_expansion
└─< 拓展跟进记录 expansion_followup
用户 user
└─< 外勤打卡 work_checkin
└─< 打卡附件 work_checkin_attachment
用户 user
└─< 日报 work_daily_report
└─< 日报点评 work_daily_report_comment
```
---
## 4. 表设计
## 4.1 `sys_department` 部门表
用于承接“华东大区、华北大区”等组织信息。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| dept_code | varchar(50) | 部门编码 |
| dept_name | varchar(100) | 部门名称 |
| parent_id | bigint | 上级部门ID |
| manager_user_id | bigint | 部门负责人 |
| status | tinyint | 1启用 0停用 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
---
## 4.2 `sys_user` 用户表
承接“张三、李四、王五”等销售人员,以及个人页中的员工资料。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| user_code | varchar(50) | 工号/员工编号 |
| username | varchar(50) | 登录账号 |
| real_name | varchar(50) | 姓名 |
| mobile | varchar(20) | 手机号 |
| email | varchar(100) | 邮箱 |
| dept_id | bigint | 所属部门 |
| job_title | varchar(100) | 职位,如高级销售 |
| status | tinyint | 1在职 0离职 |
| hire_date | date | 入职日期 |
| avatar_url | varchar(255) | 头像地址 |
| password_hash | varchar(255) | 登录密码摘要 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
索引建议:
- `idx_user_dept_id(dept_id)`
- `uk_user_username(username)`
- `uk_user_mobile(mobile)`
---
## 4.3 `crm_customer` 客户表
来源于商机页面中的“客户名称”,如医院、学校、集团。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| customer_code | varchar(50) | 客户编码 |
| customer_name | varchar(200) | 客户名称 |
| customer_type | varchar(50) | 客户类型,如医院/高校/企业 |
| industry | varchar(50) | 行业 |
| province | varchar(50) | 省份 |
| city | varchar(50) | 城市 |
| address | varchar(255) | 地址 |
| owner_user_id | bigint | 当前负责人 |
| source | varchar(50) | 来源,如渠道推荐/市场活动 |
| status | varchar(30) | 潜在/跟进中/成交/流失 |
| remark | text | 备注 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
索引建议:
- `idx_customer_owner(owner_user_id)`
- `idx_customer_name(customer_name)`
---
## 4.4 `crm_opportunity` 商机表
这是前端最核心业务表,对应“商机储备”页面。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| opportunity_code | varchar(50) | 商机编号,如 `HD-20231024-001` |
| opportunity_name | varchar(200) | 商机名称 |
| customer_id | bigint | 关联客户 |
| owner_user_id | bigint | 商机负责人 |
| amount | decimal(18,2) | 商机金额 |
| expected_close_date | date | 预计结单日期 |
| confidence_pct | tinyint | 把握度0-100 |
| stage | varchar(50) | 阶段,如初步沟通/方案交流/招投标/商务谈判/已成交 |
| opportunity_type | varchar(50) | 类型,如新建/扩容 |
| product_type | varchar(100) | 产品类别如VDI/VOI/IDV云桌面 |
| source | varchar(50) | 商机来源 |
| pushed_to_oms | tinyint | 是否已推送 OMS |
| oms_push_time | datetime | 推送 OMS 时间 |
| description | text | 商机说明/备注 |
| status | varchar(30) | 正常/赢单/输单/关闭 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
索引建议:
- `uk_opportunity_code(opportunity_code)`
- `idx_opportunity_customer(customer_id)`
- `idx_opportunity_owner(owner_user_id)`
- `idx_opportunity_stage(stage)`
- `idx_opportunity_expected_close(expected_close_date)`
---
## 4.5 `crm_opportunity_followup` 商机跟进记录表
对应商机详情里的“跟进记录”时间线。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| opportunity_id | bigint | 商机ID |
| followup_time | datetime | 跟进时间 |
| followup_type | varchar(50) | 跟进方式,如电话沟通/现场拜访/微信沟通 |
| content | text | 跟进内容 |
| next_action | varchar(255) | 下一步动作 |
| followup_user_id | bigint | 跟进人 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
索引建议:
- `idx_opp_followup_opportunity(opportunity_id, followup_time desc)`
- `idx_opp_followup_user(followup_user_id)`
---
## 4.6 `crm_sales_expansion` 销售拓展表
对应“销售人员拓展”。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| candidate_name | varchar(50) | 候选人姓名 |
| mobile | varchar(20) | 手机号 |
| email | varchar(100) | 邮箱 |
| target_dept_id | bigint | 目标归属部门 |
| industry | varchar(50) | 擅长行业 |
| title | varchar(100) | 当前或目标职位 |
| intent_level | varchar(20) | 意向度,高/中/低 |
| stage | varchar(50) | 阶段,如初步沟通/方案交流 |
| has_desktop_exp | tinyint | 是否有云桌面经验 |
| in_progress | tinyint | 是否持续跟进中 |
| employment_status | varchar(20) | 在职/离职/已入职/已放弃 |
| expected_join_date | date | 预计入职日期,可为空 |
| owner_user_id | bigint | 负责人 |
| remark | text | 备注 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
索引建议:
- `idx_sales_expansion_owner(owner_user_id)`
- `idx_sales_expansion_stage(stage)`
- `idx_sales_expansion_mobile(mobile)`
---
## 4.7 `crm_channel_expansion` 渠道拓展表
对应“渠道拓展”。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| channel_name | varchar(200) | 渠道名称 |
| province | varchar(50) | 所在省份 |
| industry | varchar(50) | 主要行业 |
| annual_revenue | decimal(18,2) | 年营收规模 |
| staff_size | int | 公司人数 |
| contact_name | varchar(50) | 联系人 |
| contact_title | varchar(100) | 联系人职务 |
| contact_mobile | varchar(20) | 联系电话 |
| stage | varchar(50) | 阶段,如初步接触/合作洽谈 |
| landed_flag | tinyint | 是否已落地 |
| expected_sign_date | date | 预计签约日期 |
| owner_user_id | bigint | 负责人 |
| remark | text | 备注 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
索引建议:
- `idx_channel_expansion_owner(owner_user_id)`
- `idx_channel_expansion_stage(stage)`
- `idx_channel_expansion_name(channel_name)`
---
## 4.8 `crm_expansion_followup` 拓展跟进记录表
销售拓展和渠道拓展都存在“跟进记录”,建议共用一张表,通过对象类型区分。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| biz_type | varchar(20) | `sales` / `channel` |
| biz_id | bigint | 对应拓展对象ID |
| followup_time | datetime | 跟进时间 |
| followup_type | varchar(50) | 电话/微信/面谈等 |
| content | text | 跟进内容 |
| next_action | varchar(255) | 下一步动作 |
| followup_user_id | bigint | 跟进人 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
索引建议:
- `idx_exp_followup_biz(biz_type, biz_id, followup_time desc)`
- `idx_exp_followup_user(followup_user_id)`
---
## 4.9 `work_checkin` 外勤打卡表
对应工作台里的“外勤打卡”。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| user_id | bigint | 打卡人 |
| checkin_date | date | 打卡日期 |
| checkin_time | datetime | 打卡时间 |
| longitude | decimal(10,6) | 经度 |
| latitude | decimal(10,6) | 纬度 |
| location_text | varchar(255) | 地址文本 |
| remark | varchar(500) | 备注说明 |
| status | varchar(30) | 正常/异常/补卡 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
索引建议:
- `idx_checkin_user_date(user_id, checkin_date desc)`
---
## 4.10 `work_checkin_attachment` 打卡附件表
前端要求“现场照片必填”,建议单独拆附件表。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| checkin_id | bigint | 打卡ID |
| file_url | varchar(255) | 文件地址 |
| file_type | varchar(30) | image/audio/video |
| file_name | varchar(255) | 原始文件名 |
| file_size | bigint | 文件大小 |
| created_at | datetime | 创建时间 |
索引建议:
- `idx_checkin_attachment_checkin(checkin_id)`
---
## 4.11 `work_daily_report` 日报表
对应“每日表”和右侧历史日报。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| user_id | bigint | 提交人 |
| report_date | date | 日报日期 |
| work_content | text | 今日工作内容 |
| tomorrow_plan | text | 明日工作计划 |
| source_type | varchar(30) | manual/voice |
| submit_time | datetime | 提交时间 |
| status | varchar(30) | 待提交/已提交/已阅/已点评 |
| score | int | 评分 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
索引建议:
- `idx_daily_report_user_date(user_id, report_date desc)`
- `idx_daily_report_status(status)`
- `uk_daily_report_user_date(user_id, report_date)`
---
## 4.12 `work_daily_report_comment` 日报点评表
对应历史记录中的“主管点评”。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| report_id | bigint | 日报ID |
| reviewer_user_id | bigint | 点评人 |
| score | int | 评分 |
| comment_content | text | 点评内容 |
| reviewed_at | datetime | 点评时间 |
| created_at | datetime | 创建时间 |
索引建议:
- `idx_report_comment_report(report_id)`
---
## 4.13 `work_todo` 待办表(建议补充)
首页有“待办事项”,虽然现在是静态数据,但真实业务通常需要。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| user_id | bigint | 所属用户 |
| title | varchar(200) | 待办标题 |
| biz_type | varchar(30) | opportunity / expansion / report / other |
| biz_id | bigint | 业务对象ID |
| due_date | datetime | 截止时间 |
| status | varchar(20) | todo / done / canceled |
| priority | varchar(20) | high / medium / low |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
---
## 4.14 `sys_activity_log` 动态日志表(建议补充)
首页“最新动态”适合由统一动态表驱动。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | 主键 |
| biz_type | varchar(30) | 业务类型 |
| biz_id | bigint | 业务ID |
| action_type | varchar(50) | 如商机阶段更新/日报点评/新增渠道 |
| title | varchar(200) | 动态标题 |
| content | varchar(500) | 动态描述 |
| operator_user_id | bigint | 操作人 |
| created_at | datetime | 创建时间 |
索引建议:
- `idx_activity_created(created_at desc)`
- `idx_activity_biz(biz_type, biz_id)`
---
## 5. 推荐枚举值
为了避免硬编码中文状态,建议状态字段统一使用编码值,前端再做字典映射。
### 商机阶段 `opportunity.stage`
- `initial_contact` 初步沟通
- `solution_discussion` 方案交流
- `bidding` 招投标
- `business_negotiation` 商务谈判
- `won` 已成交
- `lost` 已丢单
### 商机状态 `opportunity.status`
- `active`
- `won`
- `lost`
- `closed`
### 销售拓展意向 `sales_expansion.intent_level`
- `high`
- `medium`
- `low`
### 外勤打卡状态 `work_checkin.status`
- `normal`
- `abnormal`
- `reissue`
### 日报状态 `work_daily_report.status`
- `draft`
- `submitted`
- `read`
- `reviewed`
---
## 6. 第一版最小可落地表
如果先做 MVP建议优先落以下 8 张:
1. `sys_department`
2. `sys_user`
3. `crm_customer`
4. `crm_opportunity`
5. `crm_opportunity_followup`
6. `crm_channel_expansion`
7. `work_checkin`
8. `work_daily_report`
这 8 张就能支撑目前前端大部分核心展示。
如果要把“拓展管理”做完整,再补:
1. `crm_sales_expansion`
2. `crm_expansion_followup`
3. `work_checkin_attachment`
4. `work_daily_report_comment`
---
## 7. 建表原则建议
1. 所有业务表统一保留 `created_at`、`updated_at`。
2. 负责人、创建人、点评人等人员字段统一关联 `sys_user.id`
3. 跟进记录建议独立建表,不要直接塞进主表。
4. 金额统一用 `decimal(18,2)`,不要用字符串。
5. 时间类字段区分清楚 `date``datetime`
6. 中文状态不要直接写死在数据库,建议存编码值。
7. 如果后续要对接 OMS商机表中保留 `pushed_to_oms` 和外部系统单号字段会更稳妥。
---
## 8. 与前端页面的映射关系
### 商机页
- 列表:`crm_opportunity`
- 客户名称:`crm_customer`
- 详情跟进:`crm_opportunity_followup`
### 拓展页
- 销售拓展:`crm_sales_expansion`
- 渠道拓展:`crm_channel_expansion`
- 跟进记录:`crm_expansion_followup`
### 工作页
- 外勤打卡:`work_checkin`
- 打卡照片:`work_checkin_attachment`
- 日报:`work_daily_report`
- 主管点评:`work_daily_report_comment`
### 首页/我的
- 统计看板:由商机、拓展、打卡、日报聚合生成
- 待办事项:`work_todo`
- 最新动态:`sys_activity_log`
- 个人信息:`sys_user` + `sys_department`
---
## 9. 下一步建议
如果要继续往后端落地,建议按以下顺序推进:
1. 先定字段字典和状态流转
2. 再输出 MySQL DDL
3. 再补接口文档
4. 最后和前端页面一一对齐新增/编辑/详情接口
如果需要,我下一步可以直接继续帮你补一版 MySQL 建表 SQL。

641
docs/crm.md 100644
View File

@ -0,0 +1,641 @@
# 紫光汇智 CRM 技术系统规划与技术文档
## 1. 文档定位
本文档面向研发、架构、测试、运维与交付团队,说明紫光汇智 CRM 系统的技术现状、目标架构、模块规划、关键设计原则、实施路线与交付边界。
文档目标如下:
- 为当前系统提供统一的技术说明基线
- 为后续迭代提供可落地的技术规划
- 为环境部署、模块扩展、接口治理和性能治理提供参考
- 为研发协作、测试设计和运维交接提供统一口径
本文档基于当前仓库代码、SQL 初始化脚本与现有项目文档整理,整理日期为 `2026-04-03`
## 2. 系统概述
紫光汇智 CRM 是一个围绕销售过程管理建设的轻量 CRM 系统,当前主要覆盖以下业务域:
- 首页工作台聚合
- 销售拓展与渠道拓展
- 商机储备与推进
- 外勤打卡
- 销售日报
- 个人资料与账号安全
系统当前采用前后端分离模式:
- 前端:`frontend/`,基于 `Vite + React + TypeScript`
- 后端:`backend/`,基于 `Spring Boot + MyBatis Plus + PostgreSQL + Redis`
- 数据:通过 `sql/init_full_pg17.sql` 初始化业务表
## 3. 当前技术现状
### 3.1 前端现状
前端目录位于 `frontend/src/`,核心结构如下:
```text
frontend/src/
├── App.tsx
├── components/
├── hooks/
├── lib/
└── pages/
```
当前页面模块包括:
- `Login.tsx`:登录页
- `Dashboard.tsx`:首页工作台
- `Expansion.tsx`:销售拓展、渠道拓展
- `Opportunities.tsx`:商机管理
- `Work.tsx`:打卡、日报、历史记录
- `Profile.tsx`:个人中心
当前前端特点:
- 使用 `react-router-dom` 管理路由
- 通过 `lib/auth.ts` 封装统一 API 调用
- 已适配 PC 与移动端
- 页面交互完整,具备较强的可交付性
- 复杂页面采用单文件实现,后续存在进一步拆分空间
### 3.2 后端现状
后端目录位于 `backend/src/main/java/com/unis/crm/`,当前结构包括:
```text
backend/src/main/java/com/unis/crm/
├── common/
├── config/
├── controller/
├── dto/
├── mapper/
├── service/
└── service/impl/
```
当前已实现的控制器包括:
- `DashboardController`
- `ExpansionController`
- `OpportunityController`
- `WorkController`
- `ProfileController`
- `OpportunityIntegrationController`
- `WecomSsoController`
当前后端特点:
- 采用 Spring Boot 单体模式
- 基于 MyBatis Plus 组织数据访问
- DTO 分包清晰,按业务域拆分
- 已具备基础异常处理、统一响应和安全配置
- 业务仍集中在服务实现层,后续可继续增强领域边界和可测试性
### 3.3 数据层现状
当前项目已将业务初始化入口收敛为:
- `sql/init_full_pg17.sql`
脚本中已覆盖以下核心表:
- `crm_customer`
- `crm_opportunity`
- `crm_opportunity_followup`
- `crm_sales_expansion`
- `crm_channel_expansion`
- `crm_channel_expansion_contact`
- `crm_expansion_followup`
- `work_checkin`
- `work_daily_report`
- `work_daily_report_comment`
- `work_todo`
- `sys_activity_log`
当前数据层特点:
- 业务主表与过程表已较完整
- 初始化脚本已能作为统一部署入口
- 仍依赖基础平台表与基础数据
- 后续需继续收敛索引策略、归档策略和审计策略
## 4. 技术建设目标
未来技术建设建议围绕以下五个目标推进:
1. 稳定性目标:保证主链路功能可持续发布与回归验证。
2. 可维护性目标:降低单文件复杂度,提升模块边界清晰度。
3. 可扩展性目标:为后续客户管理、审批流、分析报表等能力预留扩展空间。
4. 可运维性目标:完善日志、监控、配置治理和部署标准。
5. 安全性目标:提升鉴权、配置、上传、接口访问和数据变更可控性。
## 5. 总体技术架构规划
建议系统继续保持“前后端分离 + 单体业务后端”的建设模式,在当前阶段不急于拆分微服务,以降低复杂度与运维成本。
推荐总体架构如下:
```mermaid
flowchart TD
U["用户端<br/>PC / Mobile"] --> FE["前端应用<br/>React + Vite"]
FE --> GW["应用接入层<br/>Nginx / HTTPS"]
GW --> BE["CRM 后端单体服务<br/>Spring Boot"]
BE --> PG["PostgreSQL"]
BE --> RD["Redis"]
BE --> BASE["基础平台能力<br/>用户/组织/参数/鉴权"]
BE --> OMS["外部系统集成<br/>OMS / 其他业务系统"]
BE --> FILE["文件存储目录<br/>上传附件 / 打卡照片"]
```
规划原则:
- 业务复杂度未达到微服务拆分必要性前,优先优化单体结构
- 前端优先按业务域拆分组件和状态
- 后端优先按业务模块做清晰分层,而不是过早做技术拆分
- 数据库优先保障一致性与查询性能
- 接口治理、日志治理、异常治理要优先于功能外延扩张
## 6. 模块规划
### 6.1 首页工作台模块
职责:
- 聚合展示欢迎语、统计指标、待办事项、最新动态
- 提供工作入口跳转
技术规划:
- 统计接口与待办接口继续保留聚合型接口风格
- 增加缓存策略,减少重复统计计算
- 动态与待办建议做统一事件源或统一日志源治理
### 6.2 拓展管理模块
职责:
- 管理销售拓展与渠道拓展主数据
- 支撑后续商机关联、日报关联和项目推进
技术规划:
- 拓展列表查询支持分页、筛选、导出、关键词搜索
- 跟进记录数据结构统一化
- 重复校验规则集中到服务层或领域规则层
- 前端表单组件逐步抽象,降低 `Expansion.tsx` 单文件复杂度
### 6.3 商机管理模块
职责:
- 管理商机主数据、推进阶段、竞争态势、售前协同和系统推送
技术规划:
- 商机状态机建议标准化
- 推送外部系统逻辑与本地业务保存逻辑解耦
- 推送记录、失败原因、重试机制单独建模
- 回写字段、自动跟进字段需进一步规范来源
### 6.4 工作管理模块
职责:
- 支撑打卡、日报、历史记录和过程数据留痕
技术规划:
- 打卡上传、定位、逆地理解析可进一步隔离为基础服务组件
- 日报行项目结构建议形成稳定 DTO 契约
- 历史记录建议增加分页游标和时间维度索引优化
- 主管点评能力后续可进一步显式建模为评审子域
### 6.5 个人中心模块
职责:
- 查看与修改个人资料
- 密码修改
- 展示个人月度工作概览
技术规划:
- 用户资料更新与基础平台用户数据同步策略需要明确
- 密码修改链路需与统一安全策略对齐
## 7. 分层设计规划
建议后端逐步收敛为以下分层:
### 7.1 Controller 层
职责:
- 接收 HTTP 请求
- 做基础参数校验
- 调用应用服务
- 返回统一响应体
约束:
- 不直接承载复杂业务规则
- 不直接拼接复杂数据库逻辑
### 7.2 Application Service 层
职责:
- 编排业务流程
- 协调多个领域对象、外部系统、事务边界
- 对接 Mapper/Repository
建议:
- 当前 `service/impl` 可逐步向“应用服务 + 领域服务”演进
### 7.3 Domain Rule 层
职责:
- 放置核心业务规则
- 例如:重复校验、状态切换、编辑权限判定、推送条件判断
建议:
- 先从复杂模块开始,例如商机推送、日报回写、拓展重复校验
### 7.4 Repository / Mapper 层
职责:
- 承接数据库查询和持久化
- 隔离 SQL 与业务逻辑
建议:
- 查询 SQL 与写入 SQL 适当拆分
- 对复杂列表查询建立专门 Mapper 方法,不在服务层拼装
## 8. 前端技术规划
### 8.1 路由与页面拆分
建议维持当前路由结构,但将复杂页面拆成子模块:
- `Expansion.tsx` 拆为列表区、表单区、详情区、导出逻辑
- `Opportunities.tsx` 拆为列表、详情、推送弹窗、编辑弹窗
- `Work.tsx` 拆为打卡面板、日报面板、历史面板、对象选择器
### 8.2 API 层治理
建议继续以 `lib/auth.ts` 作为 API 网关入口,但逐步按域拆文件:
- `api/dashboard.ts`
- `api/expansion.ts`
- `api/opportunity.ts`
- `api/work.ts`
- `api/profile.ts`
收益:
- 便于维护
- 更清晰的类型边界
- 更方便单元测试和 mock
### 8.3 组件规划
建议将高复用部分抽出:
- 弹窗容器
- 列表卡片
- 表单字段组件
- 状态标签组件
- 导出按钮组件
- 历史记录详情组件
### 8.4 前端质量治理
建议补齐以下内容:
- ESLint 与 Prettier 统一规范
- 类型检查纳入 CI
- 关键页面冒烟测试
- API mock 机制沉淀为正式脚本
## 9. 后端技术规划
### 9.1 单体后端演进路线
当前推荐继续使用单体服务,建议按模块增强而不是拆服务。
演进方向:
- 强化模块边界
- 减少跨模块直接访问
- 将外部系统集成收敛到独立适配层
- 增加服务层测试与接口测试
### 9.2 DTO 与接口契约
当前 DTO 已按业务域拆包,建议继续坚持:
- 输入 DTO 与输出 DTO 分离
- 复杂聚合返回专门建模
- 避免前端直接依赖数据库字段命名
### 9.3 事务与一致性
重点关注场景:
- 商机保存与外部系统推送
- 日报提交与商机回写
- 拓展编辑与关联信息刷新
- 打卡上传文件与数据库记录保存
建议:
- 本地事务和外部调用解耦
- 外部系统调用失败时保留本地状态与失败日志
- 对推送行为增加状态记录表或审计表
## 10. 数据库规划
### 10.1 设计原则
- 主表负责业务主状态
- 过程表负责轨迹沉淀
- 审计字段默认标准化
- 删除操作优先逻辑删除或状态化
- 高频查询字段提前建索引
### 10.2 索引规划建议
建议重点优化以下查询:
- 首页待办查询
- 首页动态查询
- 商机列表按负责人、阶段、归档状态查询
- 拓展列表按负责人、姓名、渠道名查询
- 打卡/日报历史按用户、日期倒序查询
### 10.3 归档与清理规划
建议对以下数据建立归档策略:
- 已归档商机
- 早期历史动态日志
- 历史打卡照片
- 大体量日报附件或冗余快照
## 11. 接口治理规划
### 11.1 接口规范
建议保持统一 API 风格:
- URL 使用业务域前缀
- GET 用于查询
- POST 用于新增或业务动作
- PUT 用于更新
- 统一返回结构 `code / msg / data`
### 11.2 错误处理
建议错误分层:
- 参数错误
- 业务规则错误
- 鉴权错误
- 外部系统错误
- 系统内部错误
并在日志中保留:
- traceId
- userId
- requestPath
- requestParams
- errorCode
- rootCause
### 11.3 版本治理
当前系统体量不大,可暂不引入 `/v1` 路径版本。
但建议提前约定:
- 外部开放接口预留版本化策略
- 集成接口变更必须同步更新文档
- 高风险字段变更必须保持向后兼容
## 12. 安全规划
### 12.1 鉴权
当前系统基于 JWT 鉴权,建议继续强化:
- Access Token 有效期控制
- Refresh Token 生命周期管理
- 登录失败次数控制
- 敏感操作二次确认
### 12.2 数据权限
建议明确至少三类权限边界:
- 仅本人可编辑
- 本部门可查看
- 管理员可全局查看
### 12.3 配置安全
建议将敏感配置从默认配置文件中进一步外置:
- 数据库密码
- Redis 密码
- JWT Secret
- 内部系统 Secret
- 外部系统 API Key
### 12.4 上传安全
打卡照片上传需重点关注:
- 文件类型限制
- 文件大小限制
- 文件名安全
- 存储目录隔离
- 访问权限控制
## 13. 日志、监控与可观测性规划
建议建立以下可观测能力:
### 13.1 日志
- 应用日志
- 接口访问日志
- 外部系统调用日志
- 业务动作日志
- 失败日志
### 13.2 监控指标
- 接口响应时间
- 错误率
- 慢 SQL
- PostgreSQL 连接数
- Redis 可用性
- 文件上传失败率
- 外部系统推送成功率
### 13.3 告警建议
- 登录异常激增
- 商机推送失败率异常
- 文件上传失败率异常
- 数据库连接异常
- 首页聚合接口超时
## 14. 测试规划
建议建立三层测试体系:
### 14.1 单元测试
覆盖重点:
- 业务规则判断
- 状态切换
- 参数转换
- 重复校验
### 14.2 接口测试
覆盖重点:
- 登录
- 首页接口
- 拓展新增/编辑
- 商机新增/编辑/推送
- 打卡与日报提交流程
### 14.3 前端冒烟测试
覆盖重点:
- 登录页
- 首页
- 拓展页
- 商机页
- 打卡页
- 日报页
- 个人中心页
## 15. 部署与环境规划
### 15.1 环境分层建议
建议至少分为:
- 本地开发环境
- 测试环境
- 预发布环境
- 生产环境
### 15.2 部署建议
前端建议:
- 使用 Nginx 托管静态资源
- 区分环境配置
- 启用 gzip 与缓存策略
后端建议:
- 使用独立配置文件或环境变量
- 使用进程守护或容器部署
- 按环境区分日志目录、上传目录和外部地址
数据库建议:
- 定期备份
- 版本化执行 SQL
- 升级前回滚预案
## 16. 迭代路线建议
### 第一阶段:稳态交付
目标:
- 完成现有主链路稳定交付
- 收敛环境配置
- 完成基础测试清单
建议事项:
- 修正高复杂度页面拆分
- 完善日志与异常信息
- 固化部署手册与回归手册
### 第二阶段:治理增强
目标:
- 增强可维护性与可观测性
建议事项:
- 引入 CI 检查
- 完成 API 分层拆分
- 增加推送日志和审计机制
- 增强权限模型
### 第三阶段:业务扩展
目标:
- 支撑更大规模的 CRM 业务演进
建议事项:
- 引入客户主档视图
- 引入审批/流程节点
- 引入经营分析与管理报表
- 引入更标准的数据字典中心
## 17. 当前主要技术风险
建议重点关注以下风险:
1. 前端复杂页面单文件过大,维护成本上升。
2. 外部系统集成失败场景的状态闭环仍需增强。
3. 当前配置文件中存在敏感参数,生产治理需加强。
4. 数据权限与角色权限模型还需要形成正式方案。
5. 自动化测试和持续集成能力仍有较大提升空间。
## 18. 技术文档交付建议
建议后续完整技术文档体系至少包括:
- 技术系统规划与技术文档
- 部署手册
- 数据库设计说明
- 接口文档
- 测试方案与回归清单
- 运维巡检与故障处理手册
## 19. 相关文件
- `docs/system-construction.md`
- `docs/business-schema-design.md`
- `docs/deployment-guide.md`
- `docs/opportunity-integration-api.md`
- `backend/README.md`
- `frontend/README.md`

Binary file not shown.

View File

@ -1,103 +0,0 @@
# 全新环境部署说明
## 目标
本仓库已经把 CRM 自有业务表整理为单一入口脚本:
- `sql/init_full_pg17.sql`
后续部署 CRM 表时,优先只执行这一份脚本即可。历史迁移脚本已归档到:
- `sql/archive/`
## 执行顺序
### 1. 准备基础环境
需要先准备:
- PostgreSQL 17
- Redis
- Java 17+
- Maven 3.9+
### 2. 先初始化基础框架UnisBase表和基础数据
这一步不在本仓库维护,需要先由基础框架提供方执行。
当前 CRM 代码运行时会依赖下列基础表或基础数据:
- `sys_user`
- `sys_org`
- `sys_dict_item`
- `sys_tenant_user`
- `sys_role`
- `sys_user_role`
- `sys_param`
- `sys_log`
- `device`
如果这些表或基础数据未准备完成CRM 即使业务表建好了,也会在登录、字典加载、组织信息、角色信息等链路上报错。
### 3. 执行 CRM 单文件初始化脚本
```bash
psql -h 127.0.0.1 -U postgres -d nex_auth -f sql/init_full_pg17.sql
```
说明:
- 这份脚本既适合空库初始化,也兼容大部分旧环境补齐结构
- 它已经吸收了仓库里原来的 DDL 迁移脚本
- `sql/archive/` 中的旧脚本仅保留作历史追溯,正常部署不再单独执行
### 4. 初始化完成后建议检查的 CRM 表
建议至少确认以下表已存在:
- `crm_customer`
- `crm_opportunity`
- `crm_opportunity_followup`
- `crm_sales_expansion`
- `crm_channel_expansion`
- `crm_channel_expansion_contact`
- `crm_expansion_followup`
- `work_checkin`
- `work_daily_report`
- `work_daily_report_comment`
- `work_todo`
- `sys_activity_log`
### 5. 启动后端服务
```bash
cd backend
mvn spring-boot:run
```
默认端口:
- `8080`
### 6. 启动前端并验证
建议至少验证以下链路:
- 登录
- 首页统计、待办、动态
- 拓展列表与详情
- 商机列表与详情
- 工作台打卡、日报、历史记录
## 升级已有环境时的建议
如果不是全新环境,而是已有库升级:
1. 先备份数据库
2. 直接执行 `sql/init_full_pg17.sql`
3. 验证登录、字典、组织、日报、商机、拓展几条主链路
## 当前目录约定
- `sql/init_full_pg17.sql`:唯一部署入口脚本
- `sql/archive/`:历史迁移与修数脚本归档目录

View File

@ -0,0 +1,758 @@
import fs from "fs/promises";
import path from "path";
import { chromium } from "playwright";
const baseUrl = process.env.CRM_DOC_BASE_URL || "http://127.0.0.1:3001";
const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..");
const outputDir = path.join(rootDir, "docs", "assets", "manual");
const currentUser = {
userId: 1,
tenantId: 1001,
username: "zhangsan",
displayName: "张三",
email: "zhangsan@uniscrm.example",
phone: "13800138000",
roleCodes: ["crm_sales"],
roles: [{ roleCode: "crm_sales", roleName: "销售经理" }],
};
const dashboardHome = {
userId: 1,
realName: "张三",
jobTitle: "销售经理",
deptName: "华东销售一部",
onboardingDays: 386,
stats: [
{ name: "本月新增商机", value: 12, metricKey: "monthlyOpportunities" },
{ name: "已推送OMS项目", value: 5, metricKey: "pushedOmsProjects" },
{ name: "本月新增渠道", value: 8, metricKey: "monthlyChannels" },
{ name: "本月打卡次数", value: 19, metricKey: "monthlyCheckins" },
],
todos: [
{ id: "todo-1", title: "跟进华东教育云桌面项目", status: "pending" },
{ id: "todo-2", title: "补充苏州金桥渠道资料", status: "pending" },
{ id: "todo-3", title: "完成3月销售日报点评回复", status: "done" },
{ id: "todo-4", title: "更新杭州政务项目推进记录", status: "done" },
],
activities: [
{ id: 1, title: "新增商机", content: "华东教育云桌面项目已完成立项登记。", timeText: "今天 09:18" },
{ id: 2, title: "渠道跟进", content: "苏州金桥渠道补充了联系人与办公地址。", timeText: "今天 10:42" },
{ id: 3, title: "日报提交", content: "李四已提交今日销售日报,等待点评。", timeText: "今天 18:11" },
{ id: 4, title: "OMS推送", content: "杭州政务云项目已推送至 OMS。", timeText: "昨天 16:25" },
{ id: 5, title: "打卡完成", content: "张三完成外勤拜访打卡,地点为上海市徐汇区。", timeText: "昨天 14:06" },
{ id: 6, title: "拓展新增", content: "新增销售拓展对象:王磊。", timeText: "昨天 11:30" },
],
};
const profileOverview = {
userId: 1,
monthlyOpportunityCount: 12,
monthlyExpansionCount: 8,
averageScore: 96,
onboardingDays: 386,
realName: "张三",
jobTitle: "销售经理",
deptName: "华东销售一部",
accountStatus: "正常",
};
const expansionOverview = {
salesItems: [
{
id: 11,
ownerUserId: 1,
owner: "张三",
type: "sales",
employeeNo: "S2024001",
name: "王磊",
officeName: "上海代表处",
phone: "13900001111",
dept: "教育行业部",
industry: "教育",
title: "销售总监",
intentLevel: "high",
intent: "高",
hasExp: true,
active: true,
relatedProjects: [
{ opportunityId: 101, opportunityCode: "OPP-2026-001", opportunityName: "华东教育云桌面项目", amount: 860000 },
],
followUps: [
{
id: 1,
date: "2026-04-02",
type: "拜访",
visitStartTime: "14:00",
evaluationContent: "已确认预算窗口与技术负责人。",
nextPlan: "下周安排方案交流。",
},
],
},
{
id: 12,
ownerUserId: 1,
owner: "张三",
type: "sales",
employeeNo: "S2024002",
name: "李敏",
officeName: "杭州办事处",
phone: "13900002222",
dept: "政企行业部",
industry: "政府",
title: "客户经理",
intentLevel: "medium",
intent: "中",
hasExp: false,
active: true,
relatedProjects: [],
followUps: [],
},
],
channelItems: [
{
id: 21,
ownerUserId: 1,
owner: "张三",
type: "channel",
channelCode: "CH-2026-001",
name: "苏州金桥科技有限公司",
province: "江苏省",
city: "苏州市",
officeAddress: "苏州市工业园区星湖街 188 号",
certificationLevel: "银牌",
channelIndustry: "教育、政府",
channelAttribute: "区域渠道",
internalAttribute: "重点合作伙伴",
intentLevel: "high",
intent: "高",
establishedDate: "2026-03-12",
revenue: "3000 万",
size: 120,
hasDesktopExp: true,
contacts: [
{ id: 1, name: "刘晨", mobile: "13700001111", title: "总经理" },
{ id: 2, name: "顾宇", mobile: "13700002222", title: "技术总监" },
],
relatedProjects: [
{ opportunityId: 102, opportunityCode: "OPP-2026-002", opportunityName: "杭州政务云项目", amount: 1250000 },
],
followUps: [
{
id: 11,
date: "2026-04-01",
type: "电话沟通",
visitStartTime: "10:30",
evaluationContent: "已同步合作模式与首批目标客户。",
nextPlan: "4月中旬安排联合拜访。",
},
],
},
{
id: 22,
ownerUserId: 1,
owner: "张三",
type: "channel",
channelCode: "CH-2026-002",
name: "宁波数智集成服务商",
province: "浙江省",
city: "宁波市",
officeAddress: "宁波市高新区研发园 B 座",
certificationLevel: "金牌",
channelIndustry: "制造",
channelAttribute: "行业渠道",
internalAttribute: "储备伙伴",
intentLevel: "medium",
intent: "中",
establishedDate: "2026-02-23",
revenue: "5000 万",
size: 80,
hasDesktopExp: false,
contacts: [{ id: 3, name: "郑航", mobile: "13700003333", title: "商务经理" }],
relatedProjects: [],
followUps: [],
},
],
};
const expansionMeta = {
officeOptions: [
{ label: "上海代表处", value: "上海代表处" },
{ label: "杭州办事处", value: "杭州办事处" },
{ label: "苏州办事处", value: "苏州办事处" },
],
industryOptions: [
{ label: "教育", value: "教育" },
{ label: "政府", value: "政府" },
{ label: "制造", value: "制造" },
{ label: "医疗", value: "医疗" },
],
provinceOptions: [
{ label: "上海市", value: "上海市" },
{ label: "江苏省", value: "江苏省" },
{ label: "浙江省", value: "浙江省" },
],
certificationLevelOptions: [
{ label: "金牌", value: "金牌" },
{ label: "银牌", value: "银牌" },
{ label: "注册", value: "注册" },
],
channelAttributeOptions: [
{ label: "区域渠道", value: "区域渠道" },
{ label: "行业渠道", value: "行业渠道" },
{ label: "其他", value: "其他" },
],
internalAttributeOptions: [
{ label: "重点合作伙伴", value: "重点合作伙伴" },
{ label: "储备伙伴", value: "储备伙伴" },
],
nextChannelCode: "CH-2026-003",
};
const opportunityOverview = {
items: [
{
id: 101,
ownerUserId: 1,
code: "OPP-2026-001",
name: "华东教育云桌面项目",
client: "华东某职业学院",
owner: "张三",
projectLocation: "上海市",
operatorName: "新华三+渠道",
amount: 860000,
date: "2026-05-20",
confidence: "A",
stageCode: "方案交流",
stage: "方案交流",
type: "新建",
archived: false,
pushedToOms: true,
product: "VDI云桌面",
source: "主动开发",
salesExpansionId: 11,
salesExpansionName: "王磊",
channelExpansionId: 21,
channelExpansionName: "苏州金桥科技有限公司",
preSalesId: 2001,
preSalesName: "赵工",
competitorName: "华为、深信服",
latestProgress: "客户已确认方案范围,准备进入报价阶段。",
nextPlan: "下周提交正式报价并安排演示环境。",
notes: "项目纳入二季度重点跟踪清单。",
followUps: [
{
id: 1,
date: "2026-04-02",
type: "拜访",
latestProgress: "完成需求澄清",
communicationContent: "客户希望 5 月底前完成采购流程。",
nextAction: "输出报价清单",
user: "张三",
},
],
},
{
id: 102,
ownerUserId: 1,
code: "OPP-2026-002",
name: "杭州政务云项目",
client: "杭州某政务中心",
owner: "张三",
projectLocation: "浙江省",
operatorName: "渠道",
amount: 1250000,
date: "2026-06-15",
confidence: "B",
stageCode: "商机储备",
stage: "商机储备",
type: "扩容",
archived: false,
pushedToOms: false,
product: "VDI云桌面",
source: "渠道引入",
channelExpansionId: 21,
channelExpansionName: "苏州金桥科技有限公司",
competitorName: "锐捷",
latestProgress: "等待客户确认立项时间。",
nextPlan: "继续维护客户关键人。",
notes: "渠道主导推进。",
followUps: [],
},
{
id: 103,
ownerUserId: 1,
code: "OPP-2026-003",
name: "宁波制造云桌面替换项目",
client: "宁波某制造集团",
owner: "张三",
projectLocation: "浙江省",
operatorName: "新华三",
amount: 530000,
date: "2026-03-18",
confidence: "C",
stageCode: "已归档",
stage: "已归档",
type: "替换",
archived: true,
pushedToOms: false,
product: "VDI云桌面",
source: "主动开发",
salesExpansionId: 12,
salesExpansionName: "李敏",
competitorName: "无",
latestProgress: "客户预算取消,项目归档。",
nextPlan: "",
notes: "保留历史记录。",
followUps: [],
},
],
};
const opportunityMeta = {
stageOptions: [
{ label: "商机储备", value: "商机储备" },
{ label: "方案交流", value: "方案交流" },
{ label: "报价申请", value: "报价申请" },
{ label: "合同审批", value: "合同审批" },
],
operatorOptions: [
{ label: "新华三", value: "新华三" },
{ label: "渠道", value: "渠道" },
{ label: "新华三+渠道", value: "新华三+渠道" },
],
projectLocationOptions: [
{ label: "上海市", value: "上海市" },
{ label: "江苏省", value: "江苏省" },
{ label: "浙江省", value: "浙江省" },
],
opportunityTypeOptions: [
{ label: "新建", value: "新建" },
{ label: "扩容", value: "扩容" },
{ label: "替换", value: "替换" },
],
};
const workOverview = {
todayCheckIn: {
id: 501,
bizType: "opportunity",
bizId: 101,
bizName: "华东教育云桌面项目",
userName: "张三",
deptName: "华东销售一部",
},
todayReport: {
id: 601,
status: "submitted",
workContent: "今日完成客户拜访、商机澄清与渠道协同推进。",
lineItems: [
{
workDate: "2026-04-03",
bizType: "opportunity",
bizId: 101,
bizName: "华东教育云桌面项目",
content: "客户已明确首批终端规模,进入报价准备阶段。",
latestProgress: "方案范围已确认",
nextPlan: "下周提交报价与演示计划",
},
{
workDate: "2026-04-03",
bizType: "channel",
bizId: 21,
bizName: "苏州金桥科技有限公司",
content: "同步联合拓展节奏确认4月联合拜访名单。",
evaluationContent: "渠道配合度较高",
nextPlan: "输出联合拜访安排",
},
],
planItems: [
{ content: "提交华东教育项目报价草案" },
{ content: "完成杭州政务项目客户关系梳理" },
],
tomorrowPlan: "围绕重点项目推进报价与客户触达。",
sourceType: "manual",
score: 98,
comment: "内容完整,继续保持。",
},
};
const checkinHistoryItems = [
{
id: 1,
type: "外勤打卡",
date: "2026-04-03",
time: "14:06",
status: "已提交",
content: "打卡人:张三\n关联对象华东教育云桌面项目\n地址上海市徐汇区桂平路 680 号\n备注已完成现场拜访。",
photoUrls: [],
},
{
id: 2,
type: "外勤打卡",
date: "2026-04-02",
time: "16:20",
status: "已提交",
content: "打卡人:张三\n关联对象苏州金桥科技有限公司\n地址苏州市工业园区星湖街 188 号\n备注渠道联合沟通。",
photoUrls: [],
},
];
const reportHistoryItems = [
{
id: 3,
type: "销售日报",
date: "2026-04-03",
time: "18:11",
status: "已点评",
score: 98,
comment: "内容完整,继续保持。",
content: "提交人:张三\n今日工作推进华东教育项目与渠道协同。\n明日计划准备报价并继续客户触达。",
},
{
id: 4,
type: "销售日报",
date: "2026-04-02",
time: "18:05",
status: "已提交",
score: 95,
comment: "",
content: "提交人:张三\n今日工作商机梳理与外勤拜访。\n明日计划整理客户需求与推进方案。",
},
];
function apiEnvelope(data, msg = "success") {
return {
code: "0",
msg,
data,
};
}
function createToken() {
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
const payload = Buffer.from(JSON.stringify({ userId: currentUser.userId, tenantId: currentUser.tenantId })).toString("base64url");
return `${header}.${payload}.mock-signature`;
}
function createCaptchaSvgBase64() {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="44" viewBox="0 0 120 44">
<rect width="120" height="44" rx="8" fill="#f8fafc"/>
<text x="18" y="29" font-size="22" fill="#6d28d9" font-family="Arial, sans-serif" font-weight="700">2468</text>
<path d="M8 35 C25 10, 40 10, 58 35" stroke="#c4b5fd" stroke-width="2" fill="none"/>
<path d="M62 10 C78 35, 96 35, 112 10" stroke="#a5b4fc" stroke-width="2" fill="none"/>
</svg>
`.trim();
return Buffer.from(svg).toString("base64");
}
async function installApiMocks(page) {
await page.route("**/*", async (route) => {
const requestUrl = route.request().url();
const url = new URL(requestUrl);
if (!url.pathname.startsWith("/api/")) {
if (
requestUrl.startsWith("https://mapapi.qq.com/") ||
requestUrl.startsWith("https://map.qq.com/") ||
requestUrl.startsWith("https://res.wx.qq.com/") ||
requestUrl.startsWith("https://open.work.weixin.qq.com/")
) {
await route.abort();
return;
}
await route.continue();
return;
}
const pathname = url.pathname;
const method = route.request().method().toUpperCase();
if (pathname === "/api/sys/auth/captcha") {
await route.fulfill({ json: apiEnvelope({ captchaId: "mock-captcha-id", imageBase64: createCaptchaSvgBase64() }) });
return;
}
if (pathname === "/api/sys/auth/login" && method === "POST") {
await route.fulfill({
json: apiEnvelope({
accessToken: createToken(),
refreshToken: "mock-refresh-token",
accessExpiresInMinutes: 120,
refreshExpiresInDays: 7,
}),
});
return;
}
if (pathname === "/api/sys/api/params/value") {
await route.fulfill({ json: apiEnvelope("true") });
return;
}
if (pathname === "/api/sys/api/open/platform/config") {
await route.fulfill({
json: apiEnvelope({
projectName: "紫光汇智CRM系统",
systemDescription: "聚焦客户拓展、商机推进与销售协同,让团队每天的工作节奏更清晰。",
}),
});
return;
}
if (pathname === "/api/sys/api/users/me") {
await route.fulfill({ json: apiEnvelope(currentUser) });
return;
}
if (pathname === "/api/dashboard/home") {
await route.fulfill({ json: apiEnvelope(dashboardHome) });
return;
}
if (pathname.startsWith("/api/dashboard/todos/") && pathname.endsWith("/complete")) {
await route.fulfill({ json: apiEnvelope(null) });
return;
}
if (pathname === "/api/profile/overview") {
await route.fulfill({ json: apiEnvelope(profileOverview) });
return;
}
if (pathname === "/api/sys/api/users/profile" && method === "PUT") {
await route.fulfill({ json: apiEnvelope(true) });
return;
}
if (pathname === "/api/sys/api/users/password" && method === "PUT") {
await route.fulfill({ json: apiEnvelope(true) });
return;
}
if (pathname === "/api/work/overview") {
await route.fulfill({ json: apiEnvelope(workOverview) });
return;
}
if (pathname === "/api/work/history") {
const type = url.searchParams.get("type") || "checkin";
const pageNo = Number(url.searchParams.get("page") || "1");
const allItems = type === "report" ? reportHistoryItems : checkinHistoryItems;
const items = pageNo === 1 ? allItems : [];
await route.fulfill({
json: apiEnvelope({
items,
hasMore: false,
page: pageNo,
size: 8,
}),
});
return;
}
if (pathname === "/api/work/reverse-geocode") {
await route.fulfill({ json: apiEnvelope("上海市徐汇区桂平路 680 号 创新大厦") });
return;
}
if (pathname === "/api/work/checkins" && method === "POST") {
await route.fulfill({ json: apiEnvelope(7001) });
return;
}
if (pathname === "/api/work/checkin-photos" && method === "POST") {
await route.fulfill({ json: apiEnvelope("/mock/checkin-photo.png") });
return;
}
if (pathname === "/api/work/daily-reports" && method === "POST") {
await route.fulfill({ json: apiEnvelope(7002) });
return;
}
if (pathname === "/api/expansion/overview") {
await route.fulfill({ json: apiEnvelope(expansionOverview) });
return;
}
if (pathname === "/api/expansion/meta") {
await route.fulfill({ json: apiEnvelope(expansionMeta) });
return;
}
if (pathname === "/api/expansion/areas/cities") {
const provinceName = url.searchParams.get("provinceName");
const cities = provinceName === "江苏省"
? [{ label: "苏州市", value: "苏州市" }, { label: "南京市", value: "南京市" }]
: provinceName === "浙江省"
? [{ label: "杭州市", value: "杭州市" }, { label: "宁波市", value: "宁波市" }]
: [{ label: "上海市", value: "上海市" }];
await route.fulfill({ json: apiEnvelope(cities) });
return;
}
if (
pathname === "/api/expansion/sales/duplicate-check" ||
pathname === "/api/expansion/channel/duplicate-check"
) {
await route.fulfill({ json: apiEnvelope({ duplicated: false, message: "" }) });
return;
}
if (pathname.startsWith("/api/expansion/") && ["POST", "PUT"].includes(method)) {
await route.fulfill({ json: apiEnvelope(1) });
return;
}
if (pathname === "/api/opportunities/overview") {
await route.fulfill({ json: apiEnvelope(opportunityOverview) });
return;
}
if (pathname === "/api/opportunities/meta") {
await route.fulfill({ json: apiEnvelope(opportunityMeta) });
return;
}
if (pathname === "/api/opportunities/oms/pre-sales") {
await route.fulfill({
json: apiEnvelope([
{ userId: 2001, loginName: "zhaogong", userName: "赵工" },
{ userId: 2002, loginName: "wanggong", userName: "王工" },
]),
});
return;
}
if (pathname === "/api/opportunities" && method === "POST") {
await route.fulfill({ json: apiEnvelope(9001) });
return;
}
if (pathname.startsWith("/api/opportunities/") && pathname.endsWith("/push-oms")) {
await route.fulfill({ json: apiEnvelope(1) });
return;
}
if (pathname.startsWith("/api/opportunities/") && ["PUT", "POST"].includes(method)) {
await route.fulfill({ json: apiEnvelope(1) });
return;
}
console.warn(`Unhandled API mock: ${method} ${pathname}`);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(apiEnvelope(null)),
});
});
}
async function seedAuth(page) {
const token = createToken();
await page.addInitScript(
({ user, accessToken }) => {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", "mock-refresh-token");
localStorage.setItem("username", user.username);
sessionStorage.setItem("userProfile", JSON.stringify(user));
},
{ user: currentUser, accessToken: token },
);
}
async function waitForStablePage(page) {
await page.waitForLoadState("networkidle");
await page.waitForTimeout(800);
}
async function capture(page, pathname, fileName, readyText, options = {}) {
console.log(`Capturing ${fileName} from ${pathname}`);
await page.goto(`${baseUrl}${pathname}`, { waitUntil: "domcontentloaded" });
if (readyText) {
await page.getByText(readyText, { exact: false }).first().waitFor({ state: "visible", timeout: 15000 });
}
await waitForStablePage(page);
await page.screenshot({
path: path.join(outputDir, fileName),
fullPage: true,
...options,
});
}
async function clickButtonByText(page, text) {
const locator = page.getByRole("button", { name: new RegExp(text) }).first();
await locator.waitFor({ state: "visible", timeout: 15000 });
await locator.click();
}
async function main() {
await fs.mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({
channel: "chrome",
headless: true,
});
const loginContext = await browser.newContext({
viewport: { width: 1440, height: 1200 },
deviceScaleFactor: 1,
locale: "zh-CN",
});
const loginPage = await loginContext.newPage();
await installApiMocks(loginPage);
await capture(loginPage, "/login", "01-login.png", "紫光汇智CRM系统");
const appContext = await browser.newContext({
viewport: { width: 1440, height: 1600 },
deviceScaleFactor: 1,
locale: "zh-CN",
colorScheme: "light",
geolocation: { latitude: 31.178744, longitude: 121.410428 },
permissions: ["geolocation"],
});
const appPage = await appContext.newPage();
await installApiMocks(appPage);
await seedAuth(appPage);
await capture(appPage, "/", "02-dashboard.png", "工作台");
await capture(appPage, "/expansion", "03-expansion-sales.png", "销售人员拓展");
await clickButtonByText(appPage, "新增");
await appPage.getByRole("heading", { name: "新增销售人员拓展" }).waitFor({ state: "visible", timeout: 15000 });
await waitForStablePage(appPage);
console.log("Capturing 04-expansion-create.png from modal");
await appPage.screenshot({ path: path.join(outputDir, "04-expansion-create.png"), fullPage: true });
await clickButtonByText(appPage, "取消");
await waitForStablePage(appPage);
await appPage.getByRole("button", { name: "渠道拓展" }).click();
await appPage.getByText("苏州金桥科技有限公司").waitFor({ state: "visible", timeout: 15000 });
await waitForStablePage(appPage);
console.log("Capturing 05-expansion-channel.png from channel tab");
await appPage.screenshot({ path: path.join(outputDir, "05-expansion-channel.png"), fullPage: true });
await capture(appPage, "/opportunities", "06-opportunities.png", "商机");
await clickButtonByText(appPage, "新增商机");
await appPage.getByRole("heading", { name: "新增商机" }).waitFor({ state: "visible", timeout: 15000 });
await waitForStablePage(appPage);
console.log("Capturing 07-opportunity-create.png from modal");
await appPage.screenshot({ path: path.join(outputDir, "07-opportunity-create.png"), fullPage: true });
await clickButtonByText(appPage, "取消");
await waitForStablePage(appPage);
await capture(appPage, "/work/checkin", "08-work-checkin.png", "外勤打卡");
await capture(appPage, "/work/report", "09-work-report.png", "销售日报");
await capture(appPage, "/profile", "10-profile.png", "账号安全");
await loginContext.close();
await appContext.close();
await browser.close();
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -0,0 +1,691 @@
import fs from "fs/promises";
import os from "os";
import path from "path";
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..");
const docsDir = path.join(rootDir, "docs");
const chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const currentDate = "2026-04-03";
const documents = [
{
source: path.join(docsDir, "system-operation-manual.md"),
output: path.join(docsDir, "system-operation-manual.pdf"),
docCode: "UNIS-CRM-UM-2026",
subtitle: "正式交付版",
audience: "业务用户 / 管理人员",
},
{
source: path.join(docsDir, "system-construction.md"),
output: path.join(docsDir, "system-construction.pdf"),
docCode: "UNIS-CRM-BUILD-2026",
subtitle: "正式交付版",
audience: "项目组 / 运维 / 交付人员",
},
];
function escapeHtml(value) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function inlineMarkdownToHtml(text, baseDir) {
let html = escapeHtml(text);
html = html.replace(/`([^`]+)`/g, (_, code) => `<code>${escapeHtml(code)}</code>`);
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
const resolved = resolveHref(href, baseDir);
return `<a href="${resolved}">${escapeHtml(label)}</a>`;
});
return html;
}
function resolveHref(href, baseDir) {
if (/^(https?:|file:|data:|#)/i.test(href)) {
return href;
}
const absolutePath = path.resolve(baseDir, href);
return `file://${absolutePath}`;
}
function slugify(text, fallbackIndex) {
const normalized = text
.toLowerCase()
.replace(/[`~!@#$%^&*()+=[\]{};:'"\\|,.<>/?,。;:()【】《》?、\s]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || `section-${fallbackIndex}`;
}
function extractTitle(markdown) {
const match = markdown.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : "文档";
}
function parseBlocks(markdown, baseDir) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
blocks.push({
type: "heading",
level: headingMatch[1].length,
text: headingMatch[2].trim(),
});
index += 1;
continue;
}
const codeMatch = trimmed.match(/^```(\w+)?$/);
if (codeMatch) {
const language = codeMatch[1] || "";
index += 1;
const content = [];
while (index < lines.length && !lines[index].trim().startsWith("```")) {
content.push(lines[index]);
index += 1;
}
if (index < lines.length) {
index += 1;
}
blocks.push({
type: "code",
language,
content: content.join("\n"),
});
continue;
}
const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (imageMatch) {
blocks.push({
type: "image",
alt: imageMatch[1].trim(),
src: resolveHref(imageMatch[2].trim(), baseDir),
});
index += 1;
continue;
}
if (
trimmed.includes("|") &&
index + 1 < lines.length &&
/^\|?[\s:-]+(\|[\s:-]+)+\|?$/.test(lines[index + 1].trim())
) {
const tableLines = [trimmed];
index += 2;
while (index < lines.length && lines[index].trim().includes("|")) {
tableLines.push(lines[index].trim());
index += 1;
}
blocks.push({
type: "table",
rows: tableLines.map((row) =>
row
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((cell) => cell.trim()),
),
});
continue;
}
const listMatch = trimmed.match(/^([-*]|\d+\.)\s+(.+)$/);
if (listMatch) {
const ordered = /\d+\./.test(listMatch[1]);
const items = [];
while (index < lines.length) {
const itemMatch = lines[index].trim().match(/^([-*]|\d+\.)\s+(.+)$/);
if (!itemMatch) {
break;
}
items.push(itemMatch[2].trim());
index += 1;
}
blocks.push({
type: "list",
ordered,
items,
});
continue;
}
const paragraphLines = [trimmed];
index += 1;
while (index < lines.length) {
const next = lines[index].trim();
if (
!next ||
/^#{1,6}\s+/.test(next) ||
/^```/.test(next) ||
/^!\[/.test(next) ||
/^([-*]|\d+\.)\s+/.test(next) ||
(
next.includes("|") &&
index + 1 < lines.length &&
/^\|?[\s:-]+(\|[\s:-]+)+\|?$/.test(lines[index + 1].trim())
)
) {
break;
}
paragraphLines.push(next);
index += 1;
}
blocks.push({
type: "paragraph",
text: paragraphLines.join(" "),
});
}
return blocks;
}
function buildContentAndToc(blocks, baseDir) {
const toc = [];
const html = [];
let headingIndex = 0;
let skippedTitle = false;
for (const block of blocks) {
if (block.type === "heading") {
headingIndex += 1;
if (block.level === 1 && !skippedTitle) {
skippedTitle = true;
continue;
}
const id = slugify(block.text, headingIndex);
if (block.level <= 3) {
toc.push({ level: block.level, text: block.text, id });
}
html.push(`<h${block.level} id="${id}">${escapeHtml(block.text)}</h${block.level}>`);
continue;
}
if (block.type === "paragraph") {
html.push(`<p>${inlineMarkdownToHtml(block.text, baseDir)}</p>`);
continue;
}
if (block.type === "list") {
const tag = block.ordered ? "ol" : "ul";
const items = block.items
.map((item) => `<li>${inlineMarkdownToHtml(item, baseDir)}</li>`)
.join("");
html.push(`<${tag}>${items}</${tag}>`);
continue;
}
if (block.type === "image") {
html.push(
`<figure><img src="${block.src}" alt="${escapeHtml(block.alt || "截图")}" /><figcaption>${escapeHtml(block.alt || "")}</figcaption></figure>`,
);
continue;
}
if (block.type === "code") {
if (block.language === "mermaid") {
html.push(
`<div class="mermaid-box"><div class="mermaid-title">架构示意</div><pre>${escapeHtml(block.content)}</pre></div>`,
);
} else {
html.push(
`<pre><code>${escapeHtml(block.content)}</code></pre>`,
);
}
continue;
}
if (block.type === "table") {
const [headerRow, ...bodyRows] = block.rows;
const thead = `<thead><tr>${headerRow.map((cell) => `<th>${inlineMarkdownToHtml(cell, baseDir)}</th>`).join("")}</tr></thead>`;
const tbody = `<tbody>${bodyRows.map((row) => `<tr>${row.map((cell) => `<td>${inlineMarkdownToHtml(cell, baseDir)}</td>`).join("")}</tr>`).join("")}</tbody>`;
html.push(`<table>${thead}${tbody}</table>`);
}
}
return {
html: html.join("\n"),
toc,
};
}
function renderToc(toc) {
const items = toc
.filter((item) => item.level >= 2 && item.level <= 3)
.map((item) => {
const cls = item.level === 3 ? "toc-item toc-sub" : "toc-item";
return `<li class="${cls}"><a href="#${item.id}">${escapeHtml(item.text)}</a></li>`;
})
.join("");
return `<section class="toc-page page-break"><h2>目录</h2><ul class="toc-list">${items}</ul></section>`;
}
function buildCover(title, options) {
return `
<section class="cover-page">
<div class="cover-inner">
<div class="cover-mark">UNIS CRM</div>
<div class="cover-accent"></div>
<p class="cover-kicker">紫光汇智客户关系管理平台</p>
<h1 class="cover-title">${escapeHtml(title)}</h1>
<p class="cover-subtitle">${escapeHtml(options.subtitle)}</p>
<div class="cover-meta">
<div class="meta-item"><span>文档编号</span><strong>${escapeHtml(options.docCode)}</strong></div>
<div class="meta-item"><span>适用对象</span><strong>${escapeHtml(options.audience)}</strong></div>
<div class="meta-item"><span>版本状态</span><strong></strong></div>
<div class="meta-item"><span>编制日期</span><strong>${currentDate}</strong></div>
</div>
</div>
</section>
`;
}
function buildHtml(title, bodyContent, tocHtml, options) {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<style>
@page {
size: A4;
margin: 16mm 16mm 16mm 16mm;
}
:root {
--brand: #5b21b6;
--brand-soft: #ede9fe;
--text: #0f172a;
--muted: #475569;
--line: #dbe2ea;
--page-width: 178mm;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
color: var(--text);
background: #fff;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 12px;
line-height: 1.72;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
a {
color: inherit;
text-decoration: none;
}
.document {
width: 100%;
max-width: var(--page-width);
margin: 0 auto;
padding: 0;
}
.cover-page {
min-height: 255mm;
display: flex;
align-items: center;
page-break-after: always;
position: relative;
overflow: hidden;
}
.cover-page::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 18%, rgba(109, 40, 217, 0.13), transparent 30%),
radial-gradient(circle at 82% 82%, rgba(14, 165, 233, 0.1), transparent 30%),
linear-gradient(180deg, #ffffff 0%, #faf7ff 100%);
border: 1px solid #ede9fe;
border-radius: 20px;
}
.cover-inner {
position: relative;
z-index: 1;
width: 100%;
padding: 24mm 18mm;
}
.cover-mark {
font-size: 11px;
letter-spacing: 0.22em;
color: var(--brand);
font-weight: 700;
margin-bottom: 14mm;
}
.cover-accent {
width: 52mm;
height: 3mm;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), #7c3aed, #38bdf8);
margin-bottom: 8mm;
}
.cover-kicker {
margin: 0 0 4mm;
color: #6b7280;
letter-spacing: 0.06em;
font-size: 12px;
}
.cover-title {
margin: 0;
font-size: 28px;
line-height: 1.3;
color: #111827;
}
.cover-subtitle {
margin: 6mm 0 14mm;
font-size: 14px;
color: #5b21b6;
font-weight: 600;
}
.cover-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8mm 10mm;
margin-top: 16mm;
max-width: 130mm;
}
.meta-item {
padding: 5mm 5.5mm;
border-radius: 12px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid #e9d5ff;
box-shadow: 0 10px 30px rgba(91, 33, 182, 0.06);
}
.meta-item span {
display: block;
font-size: 10px;
color: #6b7280;
margin-bottom: 2mm;
}
.meta-item strong {
display: block;
font-size: 12px;
color: #111827;
}
.toc-page {
min-height: 240mm;
}
.page-break {
page-break-before: always;
}
h1, h2, h3, h4 {
color: #111827;
page-break-after: avoid;
}
h2 {
font-size: 18px;
margin: 0 0 10px;
padding-left: 8px;
border-left: 4px solid var(--brand);
}
h3 {
font-size: 14px;
margin: 16px 0 8px;
color: #1f2937;
}
h4 {
font-size: 12px;
margin: 14px 0 6px;
}
p {
margin: 8px 0;
text-align: justify;
}
ul, ol {
margin: 8px 0 10px 18px;
padding: 0;
}
li {
margin: 3px 0;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
background: #f3f4f6;
border-radius: 4px;
padding: 1px 4px;
font-size: 11px;
}
pre {
margin: 10px 0 14px;
padding: 12px 14px;
border-radius: 10px;
background: #0f172a;
color: #e5e7eb;
white-space: pre-wrap;
word-break: break-word;
page-break-inside: avoid;
}
pre code {
background: transparent;
padding: 0;
color: inherit;
}
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0 14px;
page-break-inside: avoid;
}
th, td {
border: 1px solid #d7dee8;
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
th {
background: #f5f3ff;
color: #4c1d95;
font-weight: 700;
}
figure {
margin: 12px 0 18px;
page-break-inside: avoid;
}
img {
display: block;
width: 100%;
max-width: 170mm;
margin: 0 auto;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
figcaption {
text-align: center;
margin-top: 6px;
font-size: 10px;
color: #6b7280;
}
.content {
page-break-before: always;
padding-top: 0;
}
.content-head {
margin: 0 0 14px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #fafafa 100%);
page-break-inside: avoid;
}
.content-head-top,
.content-head-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.content-head-top {
margin-bottom: 8px;
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.content-head-bottom {
font-size: 11px;
color: #475569;
border-top: 1px solid #e5e7eb;
padding-top: 8px;
}
.toc-list {
list-style: none;
margin: 14px 0 0;
padding: 0;
border: 1px solid #e5e7eb;
border-radius: 14px;
overflow: hidden;
}
.toc-item {
margin: 0;
padding: 10px 14px;
border-bottom: 1px solid #eef2f7;
background: #fff;
}
.toc-item:last-child {
border-bottom: 0;
}
.toc-item a {
display: block;
color: #1f2937;
}
.toc-sub {
padding-left: 26px;
background: #fafafa;
color: #475569;
}
.mermaid-box {
margin: 12px 0 16px;
border: 1px solid #ddd6fe;
border-radius: 12px;
background: #faf7ff;
overflow: hidden;
page-break-inside: avoid;
}
.mermaid-title {
padding: 8px 12px;
background: #ede9fe;
color: #5b21b6;
font-weight: 700;
font-size: 11px;
letter-spacing: 0.04em;
}
.mermaid-box pre {
margin: 0;
border-radius: 0;
background: transparent;
color: #312e81;
}
.doc-note {
margin: 0 0 12px;
padding: 9px 12px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 11px;
}
</style>
</head>
<body>
<main class="document">
${buildCover(title, options)}
${tocHtml}
<section class="content">
<div class="content-head">
<div class="content-head-top">
<span>UNIS CRM</span>
<span>${escapeHtml(options.docCode)}</span>
</div>
<div class="content-head-bottom">
<span>${escapeHtml(title)}</span>
<span>${currentDate}</span>
</div>
</div>
<div class="doc-note">说明 PDF 为正式交付版排版内容依据仓库中的 Markdown 文档自动生成</div>
${bodyContent}
</section>
</main>
</body>
</html>`;
}
async function renderDocument(config) {
const markdown = await fs.readFile(config.source, "utf8");
const title = extractTitle(markdown);
const blocks = parseBlocks(markdown, path.dirname(config.source));
const { html, toc } = buildContentAndToc(blocks, path.dirname(config.source));
const fullHtml = buildHtml(title, html, renderToc(toc), config);
const tempHtml = path.join(os.tmpdir(), `${path.basename(config.output, ".pdf")}-${Date.now()}.html`);
await fs.writeFile(tempHtml, fullHtml, "utf8");
try {
await execFileAsync(
chromePath,
[
"--headless=new",
"--disable-gpu",
"--allow-file-access-from-files",
"--no-pdf-header-footer",
`--print-to-pdf=${config.output}`,
"--print-to-pdf-no-header",
`file://${tempHtml}`,
],
{
cwd: rootDir,
maxBuffer: 10 * 1024 * 1024,
},
);
} finally {
await fs.rm(tempHtml, { force: true });
}
}
async function main() {
const requested = process.argv[2];
const targetDocuments = requested
? documents.filter((documentConfig) => path.basename(documentConfig.output) === requested || path.basename(documentConfig.source) === requested)
: documents;
if (requested && targetDocuments.length === 0) {
throw new Error(`未找到需要导出的文档:${requested}`);
}
for (const documentConfig of targetDocuments) {
console.log(`Rendering ${path.basename(documentConfig.output)} ...`);
await renderDocument(documentConfig);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -1,46 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Ziguang Huizhi CRM Icon Logo</title>
<desc id="desc">Brand-oriented CRM logo icon with an abstract converging symbol, data nodes, and geometric structure in purple tones.</desc>
<defs>
<linearGradient id="bg" x1="72" y1="60" x2="448" y2="460" gradientUnits="userSpaceOnUse">
<stop stop-color="#744CF7"/>
<stop offset="1" stop-color="#2E1685"/>
</linearGradient>
<linearGradient id="mark" x1="170" y1="152" x2="342" y2="360" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#D4C4FF"/>
</linearGradient>
<linearGradient id="accent" x1="198" y1="184" x2="322" y2="322" gradientUnits="userSpaceOnUse">
<stop stop-color="#A98CFF"/>
<stop offset="1" stop-color="#6C44F2"/>
</linearGradient>
</defs>
<rect x="40" y="40" width="432" height="432" rx="112" fill="url(#bg)"/>
<path d="M130 256C130 185.307 185.307 130 256 130C326.693 130 382 185.307 382 256C382 326.693 326.693 382 256 382C185.307 382 130 326.693 130 256Z" stroke="#FFFFFF" stroke-opacity="0.1" stroke-width="12"/>
<path d="M256 150L344 202V310L256 362L168 310V202L256 150Z" fill="#FFFFFF" fill-opacity="0.08"/>
<path d="M256 174L323 213V299L256 338L189 299V213L256 174Z" stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="2"/>
<path d="M206 193L256 242L306 193" stroke="url(#mark)" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M206 319L256 270L306 319" stroke="url(#mark)" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M206 193V319" stroke="url(#mark)" stroke-width="18" stroke-linecap="round"/>
<path d="M306 193V319" stroke="url(#mark)" stroke-width="18" stroke-linecap="round"/>
<path d="M228 221L256 249L284 221" stroke="url(#accent)" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M228 291L256 263L284 291" stroke="url(#accent)" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="206" cy="193" r="14" fill="#FFFFFF"/>
<circle cx="306" cy="193" r="14" fill="#FFFFFF"/>
<circle cx="206" cy="319" r="14" fill="#FFFFFF"/>
<circle cx="306" cy="319" r="14" fill="#FFFFFF"/>
<circle cx="256" cy="242" r="14" fill="#FFFFFF"/>
<circle cx="256" cy="270" r="14" fill="#FFFFFF"/>
<circle cx="206" cy="193" r="5" fill="#7A57F8"/>
<circle cx="306" cy="193" r="5" fill="#7A57F8"/>
<circle cx="206" cy="319" r="5" fill="#7A57F8"/>
<circle cx="306" cy="319" r="5" fill="#7A57F8"/>
<circle cx="256" cy="242" r="5" fill="#7A57F8"/>
<circle cx="256" cy="270" r="5" fill="#7A57F8"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -1,37 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Ziguang Huizhi CRM Icon Minimal</title>
<desc id="desc">Minimal enterprise CRM icon with geometric links, converging data paths, and a premium purple palette.</desc>
<defs>
<linearGradient id="bg" x1="64" y1="56" x2="448" y2="456" gradientUnits="userSpaceOnUse">
<stop stop-color="#7B57F8"/>
<stop offset="1" stop-color="#4320B7"/>
</linearGradient>
<linearGradient id="line" x1="156" y1="156" x2="356" y2="356" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#D7C9FF"/>
</linearGradient>
</defs>
<rect x="40" y="40" width="432" height="432" rx="108" fill="url(#bg)"/>
<rect x="78" y="78" width="356" height="356" rx="88" stroke="#FFFFFF" stroke-opacity="0.12" stroke-width="2"/>
<circle cx="176" cy="176" r="20" fill="#F6F2FF"/>
<circle cx="336" cy="176" r="20" fill="#F6F2FF"/>
<circle cx="176" cy="336" r="20" fill="#F6F2FF"/>
<circle cx="336" cy="336" r="20" fill="#F6F2FF"/>
<path d="M176 176L224 224" stroke="url(#line)" stroke-width="18" stroke-linecap="round"/>
<path d="M336 176L288 224" stroke="url(#line)" stroke-width="18" stroke-linecap="round"/>
<path d="M176 336L224 288" stroke="url(#line)" stroke-width="18" stroke-linecap="round"/>
<path d="M336 336L288 288" stroke="url(#line)" stroke-width="18" stroke-linecap="round"/>
<path d="M232 176V336" stroke="#F4EEFF" stroke-width="24" stroke-linecap="round"/>
<path d="M280 176V336" stroke="#F4EEFF" stroke-width="24" stroke-linecap="round"/>
<path d="M232 256H280" stroke="#F4EEFF" stroke-width="24" stroke-linecap="round"/>
<rect x="220" y="220" width="72" height="72" rx="24" fill="#F8F5FF" fill-opacity="0.18"/>
<circle cx="256" cy="256" r="22" fill="#FFFFFF"/>
<circle cx="256" cy="256" r="8" fill="#7B57F8"/>
<path d="M128 256C128 185.308 185.308 128 256 128C326.692 128 384 185.308 384 256C384 326.692 326.692 384 256 384C185.308 384 128 326.692 128 256Z" stroke="#FFFFFF" stroke-opacity="0.1" stroke-width="10" stroke-dasharray="1 20"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -1,86 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Ziguang Huizhi CRM Icon Premium Glow</title>
<desc id="desc">Premium CRM icon with luminous data rings, glowing nodes, and a glass-like core on a purple enterprise background.</desc>
<defs>
<radialGradient id="bg" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(190 152) rotate(44.5) scale(410 430)">
<stop stop-color="#8B6BFF"/>
<stop offset="0.45" stop-color="#5E34E6"/>
<stop offset="1" stop-color="#20104D"/>
</radialGradient>
<radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(256 256) rotate(90) scale(150)">
<stop stop-color="#FFFFFF" stop-opacity="0.9"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
<linearGradient id="ring" x1="128" y1="128" x2="384" y2="384" gradientUnits="userSpaceOnUse">
<stop stop-color="#F8F4FF"/>
<stop offset="1" stop-color="#A887FF"/>
</linearGradient>
<linearGradient id="core" x1="210" y1="194" x2="314" y2="318" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#D8CCFF"/>
</linearGradient>
<filter id="blur40" x="84" y="84" width="344" height="344" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="20" result="effect1_foregroundBlur"/>
</filter>
<filter id="blur12" x="118" y="118" width="276" height="276" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="6" result="effect1_foregroundBlur"/>
</filter>
</defs>
<rect x="40" y="40" width="432" height="432" rx="112" fill="url(#bg)"/>
<rect x="74" y="74" width="364" height="364" rx="92" stroke="#FFFFFF" stroke-opacity="0.1" stroke-width="2"/>
<g filter="url(#blur40)">
<circle cx="256" cy="256" r="112" fill="url(#glow)" fill-opacity="0.5"/>
</g>
<circle cx="256" cy="256" r="136" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="2"/>
<circle cx="256" cy="256" r="104" stroke="url(#ring)" stroke-opacity="0.4" stroke-width="10"/>
<circle cx="256" cy="256" r="78" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="2" stroke-dasharray="4 14"/>
<g filter="url(#blur12)">
<path d="M172 184L228 228" stroke="#EDE6FF" stroke-width="16" stroke-linecap="round"/>
<path d="M340 184L284 228" stroke="#EDE6FF" stroke-width="16" stroke-linecap="round"/>
<path d="M172 328L228 284" stroke="#EDE6FF" stroke-width="16" stroke-linecap="round"/>
<path d="M340 328L284 284" stroke="#EDE6FF" stroke-width="16" stroke-linecap="round"/>
<path d="M196 256H224" stroke="#EDE6FF" stroke-width="16" stroke-linecap="round"/>
<path d="M288 256H316" stroke="#EDE6FF" stroke-width="16" stroke-linecap="round"/>
</g>
<path d="M172 184L228 228" stroke="#FFFFFF" stroke-opacity="0.92" stroke-width="10" stroke-linecap="round"/>
<path d="M340 184L284 228" stroke="#FFFFFF" stroke-opacity="0.92" stroke-width="10" stroke-linecap="round"/>
<path d="M172 328L228 284" stroke="#FFFFFF" stroke-opacity="0.92" stroke-width="10" stroke-linecap="round"/>
<path d="M340 328L284 284" stroke="#FFFFFF" stroke-opacity="0.92" stroke-width="10" stroke-linecap="round"/>
<path d="M196 256H224" stroke="#FFFFFF" stroke-opacity="0.92" stroke-width="10" stroke-linecap="round"/>
<path d="M288 256H316" stroke="#FFFFFF" stroke-opacity="0.92" stroke-width="10" stroke-linecap="round"/>
<g>
<rect x="204" y="204" width="104" height="104" rx="32" fill="#FFFFFF" fill-opacity="0.1"/>
<path d="M256 190L314 223V289L256 322L198 289V223L256 190Z" fill="url(#core)"/>
<path d="M256 212L294 234V278L256 300L218 278V234L256 212Z" fill="#5E34E6"/>
<path d="M237 245H275C282.18 245 288 250.82 288 258C288 265.18 282.18 271 275 271H237C229.82 271 224 265.18 224 258C224 250.82 229.82 245 237 245Z" fill="#F7F2FF"/>
<circle cx="256" cy="258" r="9" fill="#7E5AF8"/>
</g>
<g>
<circle cx="172" cy="184" r="16" fill="#FFFFFF"/>
<circle cx="340" cy="184" r="16" fill="#FFFFFF"/>
<circle cx="172" cy="328" r="16" fill="#FFFFFF"/>
<circle cx="340" cy="328" r="16" fill="#FFFFFF"/>
<circle cx="196" cy="256" r="14" fill="#FFFFFF"/>
<circle cx="316" cy="256" r="14" fill="#FFFFFF"/>
</g>
<g fill="#B495FF">
<circle cx="172" cy="184" r="6"/>
<circle cx="340" cy="184" r="6"/>
<circle cx="172" cy="328" r="6"/>
<circle cx="340" cy="328" r="6"/>
<circle cx="196" cy="256" r="5"/>
<circle cx="316" cy="256" r="5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -1,86 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">紫光汇智 CRM 系统图标增强版</title>
<desc id="desc">以客户节点、管理中枢、数据连接和CRM字样强化客户关系管理系统的识别度。</desc>
<defs>
<linearGradient id="bgGradient" x1="70" y1="56" x2="442" y2="456" gradientUnits="userSpaceOnUse">
<stop stop-color="#7D5BFA"/>
<stop offset="0.55" stop-color="#5D33E6"/>
<stop offset="1" stop-color="#2A146F"/>
</linearGradient>
<linearGradient id="panelGradient" x1="108" y1="96" x2="404" y2="404" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF" stop-opacity="0.18"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.04"/>
</linearGradient>
<linearGradient id="linkGradient" x1="148" y1="132" x2="362" y2="314" gradientUnits="userSpaceOnUse">
<stop stop-color="#F7F3FF"/>
<stop offset="1" stop-color="#B89DFF"/>
</linearGradient>
<linearGradient id="coreGradient" x1="194" y1="158" x2="318" y2="306" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#D7CAFF"/>
</linearGradient>
<filter id="softGlow" x="104" y="96" width="304" height="256" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="8" result="effect1_foregroundBlur"/>
</filter>
</defs>
<rect x="40" y="40" width="432" height="432" rx="112" fill="url(#bgGradient)"/>
<rect x="76" y="76" width="360" height="360" rx="92" fill="url(#panelGradient)" stroke="#FFFFFF" stroke-opacity="0.1" stroke-width="2"/>
<circle cx="256" cy="216" r="126" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="14"/>
<circle cx="256" cy="216" r="92" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="6" stroke-dasharray="3 18"/>
<g filter="url(#softGlow)">
<path d="M170 150L224 194" stroke="url(#linkGradient)" stroke-width="14" stroke-linecap="round"/>
<path d="M342 150L288 194" stroke="url(#linkGradient)" stroke-width="14" stroke-linecap="round"/>
<path d="M150 246L214 230" stroke="url(#linkGradient)" stroke-width="14" stroke-linecap="round"/>
<path d="M362 246L298 230" stroke="url(#linkGradient)" stroke-width="14" stroke-linecap="round"/>
<path d="M190 308L232 270" stroke="url(#linkGradient)" stroke-width="14" stroke-linecap="round"/>
<path d="M322 308L280 270" stroke="url(#linkGradient)" stroke-width="14" stroke-linecap="round"/>
</g>
<g>
<circle cx="170" cy="150" r="18" fill="#F4EEFF"/>
<circle cx="342" cy="150" r="18" fill="#F4EEFF"/>
<circle cx="150" cy="246" r="18" fill="#F4EEFF"/>
<circle cx="362" cy="246" r="18" fill="#F4EEFF"/>
<circle cx="190" cy="308" r="18" fill="#F4EEFF"/>
<circle cx="322" cy="308" r="18" fill="#F4EEFF"/>
</g>
<g fill="#B396FF">
<circle cx="170" cy="150" r="6"/>
<circle cx="342" cy="150" r="6"/>
<circle cx="150" cy="246" r="6"/>
<circle cx="362" cy="246" r="6"/>
<circle cx="190" cy="308" r="6"/>
<circle cx="322" cy="308" r="6"/>
</g>
<g>
<path d="M256 142L316 176V244L256 278L196 244V176L256 142Z" fill="url(#coreGradient)"/>
<path d="M256 162L298 186V234L256 258L214 234V186L256 162Z" fill="#5D33E6"/>
<circle cx="256" cy="200" r="18" fill="#FFFFFF"/>
<path d="M224 234C224 218.536 236.536 206 252 206H260C275.464 206 288 218.536 288 234V236H224V234Z" fill="#F2ECFF"/>
<path d="M236 188L248 200" stroke="#A386FF" stroke-width="6" stroke-linecap="round"/>
<path d="M276 188L264 200" stroke="#A386FF" stroke-width="6" stroke-linecap="round"/>
</g>
<g opacity="0.92">
<path d="M228 120C236 112 246 108 256 108C266 108 276 112 284 120" stroke="#FFFFFF" stroke-opacity="0.3" stroke-width="8" stroke-linecap="round"/>
<path d="M214 322C225 334 240 340 256 340C272 340 287 334 298 322" stroke="#FFFFFF" stroke-opacity="0.22" stroke-width="8" stroke-linecap="round"/>
</g>
<g>
<rect x="126" y="354" width="260" height="62" rx="31" fill="#FFFFFF" fill-opacity="0.12" stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="2"/>
<circle cx="162" cy="385" r="10" fill="#EDE6FF"/>
<path d="M178 385H196" stroke="#EDE6FF" stroke-width="8" stroke-linecap="round"/>
<path d="M224 372C214.059 372 206 380.059 206 390C206 399.941 214.059 408 224 408H232" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M252 372V408" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round"/>
<path d="M252 372H272C279.732 372 286 378.268 286 386C286 393.732 279.732 400 272 400H252" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M252 392L282 408" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round"/>
<path d="M306 408V372L324 394L342 372V408" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -1,76 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">紫光汇智 CRM 系统图标</title>
<desc id="desc">以紫色几何网络和数据流为核心,表达汇聚智慧、数据连接与客户关系管理的现代企业级图标。</desc>
<defs>
<linearGradient id="bgGradient" x1="76" y1="76" x2="436" y2="436" gradientUnits="userSpaceOnUse">
<stop stop-color="#6E41F5"/>
<stop offset="1" stop-color="#3D1FA8"/>
</linearGradient>
<linearGradient id="panelGradient" x1="120" y1="120" x2="392" y2="392" gradientUnits="userSpaceOnUse">
<stop stop-color="#8C67FF" stop-opacity="0.28"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.08"/>
</linearGradient>
<linearGradient id="linkGradient" x1="146" y1="164" x2="366" y2="348" gradientUnits="userSpaceOnUse">
<stop stop-color="#EEE8FF"/>
<stop offset="1" stop-color="#B99CFF"/>
</linearGradient>
<linearGradient id="coreGradient" x1="214" y1="214" x2="298" y2="298" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#D9CCFF"/>
</linearGradient>
<filter id="softGlow" x="96" y="96" width="320" height="320" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="10" result="effect1_foregroundBlur"/>
</filter>
</defs>
<rect x="40" y="40" width="432" height="432" rx="116" fill="url(#bgGradient)"/>
<rect x="76" y="76" width="360" height="360" rx="92" fill="url(#panelGradient)" stroke="#FFFFFF" stroke-opacity="0.12"/>
<circle cx="256" cy="256" r="142" stroke="#FFFFFF" stroke-opacity="0.12" stroke-width="20"/>
<circle cx="256" cy="256" r="104" stroke="#FFFFFF" stroke-opacity="0.1" stroke-width="10" stroke-dasharray="2 22"/>
<g filter="url(#softGlow)">
<path d="M160 178L224 226" stroke="url(#linkGradient)" stroke-width="18" stroke-linecap="round"/>
<path d="M160 334L224 286" stroke="url(#linkGradient)" stroke-width="18" stroke-linecap="round"/>
<path d="M352 178L288 226" stroke="url(#linkGradient)" stroke-width="18" stroke-linecap="round"/>
<path d="M352 334L288 286" stroke="url(#linkGradient)" stroke-width="18" stroke-linecap="round"/>
<path d="M184 256H214" stroke="url(#linkGradient)" stroke-width="18" stroke-linecap="round"/>
<path d="M298 256H328" stroke="url(#linkGradient)" stroke-width="18" stroke-linecap="round"/>
</g>
<path d="M196 170C218 147 239 136 256 136C273 136 294 147 316 170" stroke="#CDBBFF" stroke-opacity="0.95" stroke-width="14" stroke-linecap="round"/>
<path d="M196 342C218 365 239 376 256 376C273 376 294 365 316 342" stroke="#CDBBFF" stroke-opacity="0.95" stroke-width="14" stroke-linecap="round"/>
<g>
<rect x="220" y="220" width="72" height="72" rx="24" transform="rotate(45 256 256)" fill="url(#coreGradient)"/>
<path d="M256 204L302 230.5V281.5L256 308L210 281.5V230.5L256 204Z" fill="#5B33DE"/>
<path d="M256 226L283 241.5V272.5L256 288L229 272.5V241.5L256 226Z" fill="#EDE7FF"/>
<path d="M244 238H268C276.837 238 284 245.163 284 254V258C284 266.837 276.837 274 268 274H244C235.163 274 228 266.837 228 258V254C228 245.163 235.163 238 244 238Z" fill="#7B55F7"/>
<circle cx="256" cy="256" r="10" fill="#FFFFFF"/>
</g>
<g fill="#F4F0FF">
<circle cx="160" cy="178" r="18"/>
<circle cx="160" cy="334" r="18"/>
<circle cx="352" cy="178" r="18"/>
<circle cx="352" cy="334" r="18"/>
<circle cx="184" cy="256" r="16"/>
<circle cx="328" cy="256" r="16"/>
</g>
<g fill="#B99CFF">
<circle cx="160" cy="178" r="7"/>
<circle cx="160" cy="334" r="7"/>
<circle cx="352" cy="178" r="7"/>
<circle cx="352" cy="334" r="7"/>
<circle cx="184" cy="256" r="6"/>
<circle cx="328" cy="256" r="6"/>
</g>
<path d="M138 178C153 152 175 132 202 120" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="8" stroke-linecap="round"/>
<path d="M138 334C153 360 175 380 202 392" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="8" stroke-linecap="round"/>
<path d="M374 178C359 152 337 132 310 120" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="8" stroke-linecap="round"/>
<path d="M374 334C359 360 337 380 310 392" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -1,94 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">紫光汇智销售 CRM 系统图标</title>
<desc id="desc">以线索节点、商机漏斗、转化路径和成交中枢,表达销售 CRM 系统的业务特征。</desc>
<defs>
<linearGradient id="bgGradient" x1="68" y1="56" x2="444" y2="456" gradientUnits="userSpaceOnUse">
<stop stop-color="#7F61FF"/>
<stop offset="0.52" stop-color="#5A31E3"/>
<stop offset="1" stop-color="#231056"/>
</linearGradient>
<linearGradient id="panelGradient" x1="92" y1="86" x2="420" y2="420" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF" stop-opacity="0.18"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.05"/>
</linearGradient>
<linearGradient id="funnelGradient" x1="174" y1="132" x2="338" y2="310" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#D6C7FF"/>
</linearGradient>
<linearGradient id="flowGradient" x1="138" y1="132" x2="374" y2="296" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6F2FF"/>
<stop offset="1" stop-color="#AB8EFF"/>
</linearGradient>
<linearGradient id="dealGradient" x1="228" y1="252" x2="286" y2="320" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#E2D8FF"/>
</linearGradient>
<filter id="softGlow" x="104" y="94" width="304" height="254" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="8" result="effect1_foregroundBlur"/>
</filter>
</defs>
<rect x="40" y="40" width="432" height="432" rx="112" fill="url(#bgGradient)"/>
<rect x="76" y="76" width="360" height="360" rx="92" fill="url(#panelGradient)" stroke="#FFFFFF" stroke-opacity="0.1" stroke-width="2"/>
<circle cx="256" cy="212" r="126" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="14"/>
<circle cx="256" cy="212" r="94" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="6" stroke-dasharray="3 18"/>
<g filter="url(#softGlow)">
<path d="M158 146L204 180" stroke="url(#flowGradient)" stroke-width="14" stroke-linecap="round"/>
<path d="M256 122V168" stroke="url(#flowGradient)" stroke-width="14" stroke-linecap="round"/>
<path d="M354 146L308 180" stroke="url(#flowGradient)" stroke-width="14" stroke-linecap="round"/>
<path d="M174 242L214 228" stroke="url(#flowGradient)" stroke-width="14" stroke-linecap="round"/>
<path d="M338 242L298 228" stroke="url(#flowGradient)" stroke-width="14" stroke-linecap="round"/>
</g>
<g>
<circle cx="158" cy="146" r="18" fill="#F5F0FF"/>
<circle cx="256" cy="122" r="18" fill="#F5F0FF"/>
<circle cx="354" cy="146" r="18" fill="#F5F0FF"/>
<circle cx="174" cy="242" r="18" fill="#F5F0FF"/>
<circle cx="338" cy="242" r="18" fill="#F5F0FF"/>
</g>
<g fill="#AF92FF">
<circle cx="158" cy="146" r="6"/>
<circle cx="256" cy="122" r="6"/>
<circle cx="354" cy="146" r="6"/>
<circle cx="174" cy="242" r="6"/>
<circle cx="338" cy="242" r="6"/>
</g>
<path d="M194 166H318L294 226H218L194 166Z" fill="url(#funnelGradient)"/>
<path d="M212 194H300L286 230H226L212 194Z" fill="#7B57F8" fill-opacity="0.9"/>
<path d="M226 230H286L274 268H238L226 230Z" fill="#F1EAFF"/>
<path d="M244 268H268V302H244V268Z" fill="#FFFFFF"/>
<rect x="235" y="302" width="42" height="18" rx="9" fill="#CDBBFF"/>
<path d="M214 182H298" stroke="#FFFFFF" stroke-opacity="0.28" stroke-width="4" stroke-linecap="round"/>
<path d="M226 216H286" stroke="#FFFFFF" stroke-opacity="0.26" stroke-width="4" stroke-linecap="round"/>
<path d="M238 250H274" stroke="#7B57F8" stroke-opacity="0.4" stroke-width="4" stroke-linecap="round"/>
<g>
<circle cx="256" cy="292" r="30" fill="url(#dealGradient)"/>
<circle cx="256" cy="292" r="18" fill="#6A43EF"/>
<path d="M248 292L254 298L266 286" stroke="#FFFFFF" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g opacity="0.95">
<path d="M256 322V340" stroke="#F5F0FF" stroke-width="10" stroke-linecap="round"/>
<path d="M236 336L256 356L276 336" stroke="#F5F0FF" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g>
<rect x="116" y="360" width="280" height="58" rx="29" fill="#FFFFFF" fill-opacity="0.12" stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="2"/>
<path d="M148 389C148 379.611 155.611 372 165 372H177C186.389 372 194 379.611 194 389C194 398.389 186.389 406 177 406H165C155.611 406 148 398.389 148 389Z" fill="#F0E9FF"/>
<path d="M216 381C216 376.582 219.582 373 224 373H250" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round"/>
<path d="M216 397C216 392.582 219.582 389 224 389H244" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round"/>
<path d="M216 381V397" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round"/>
<path d="M270 373V405" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round"/>
<path d="M270 373H288C295.732 373 302 379.268 302 387C302 394.732 295.732 401 288 401H270" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M320 405V373L338 395L356 373V405" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -0,0 +1,658 @@
# 紫光汇智 CRM 数据库表设计文档
## 1. 文档说明
本文档用于说明紫光汇智 CRM 系统当前数据库表设计,主要依据以下真实文件整理:
- `sql/init_full_pg17.sql`
本文档目标:
- 作为数据库设计说明的统一输出
- 为开发、测试、运维、交付提供表结构参考
- 为后续字段扩展、索引优化和数据治理提供基线
说明:
- 本文档优先以当前初始化脚本中的真实表结构为准
- 若文档内容与历史设计稿不一致,应以 `sql/init_full_pg17.sql` 为最终准绳
## 2. 数据库概况
当前系统数据库使用 `PostgreSQL 17`,业务表主要分为以下几类:
- 用户基础信息
- 客户与商机
- 拓展管理
- 工作管理
- 首页待办与动态
当前 CRM 自有表包括:
- `sys_user`
- `crm_customer`
- `crm_opportunity`
- `crm_opportunity_followup`
- `crm_sales_expansion`
- `crm_channel_expansion`
- `crm_channel_expansion_contact`
- `crm_expansion_followup`
- `work_checkin`
- `work_daily_report`
- `work_daily_report_comment`
- `work_todo`
- `sys_activity_log`
## 3. 核心关系说明
主要业务关系如下:
```mermaid
erDiagram
SYS_USER ||--o{ CRM_CUSTOMER : owns
SYS_USER ||--o{ CRM_OPPORTUNITY : owns
SYS_USER ||--o{ CRM_SALES_EXPANSION : owns
SYS_USER ||--o{ CRM_CHANNEL_EXPANSION : owns
SYS_USER ||--o{ WORK_CHECKIN : submits
SYS_USER ||--o{ WORK_DAILY_REPORT : submits
SYS_USER ||--o{ WORK_TODO : owns
SYS_USER ||--o{ SYS_ACTIVITY_LOG : operates
CRM_CUSTOMER ||--o{ CRM_OPPORTUNITY : contains
CRM_OPPORTUNITY ||--o{ CRM_OPPORTUNITY_FOLLOWUP : has
CRM_CHANNEL_EXPANSION ||--o{ CRM_CHANNEL_EXPANSION_CONTACT : has
WORK_DAILY_REPORT ||--o{ WORK_DAILY_REPORT_COMMENT : has
```
补充说明:
- `crm_opportunity` 可关联 `crm_sales_expansion``crm_channel_expansion`
- `crm_expansion_followup` 通过 `biz_type + biz_id` 关联销售拓展或渠道拓展
- `work_checkin` 通过 `biz_type + biz_id` 关联销售拓展、渠道拓展或商机
## 4. 通用设计约定
### 4.1 主键设计
所有业务表主键均采用:
- `bigint generated by default as identity`
优点:
- 与 PostgreSQL 原生自增机制兼容
- 适合单库单体系统场景
### 4.2 时间字段
大多数业务表统一包含:
- `created_at`
- `updated_at`
并通过统一触发器函数 `set_updated_at()` 自动维护更新时间。
### 4.3 审计与状态字段
多数表包含状态字段,例如:
- `status`
- `priority`
- `archived`
- `pushed_to_oms`
- `landed_flag`
这类字段用于支撑页面展示、筛选与流程状态控制。
### 4.4 约束设计
当前约束主要包括:
- 主键约束
- 唯一约束
- 外键约束
- `check` 约束
用途:
- 保证状态枚举合法
- 保证业务编码唯一性
- 保证级联关系完整
## 5. 表设计明细
### 5.1 `sys_user`
表用途:
- 存储系统用户基础信息
- 为业务模块提供负责人、提交人、操作人基础身份数据
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 用户主键 |
| `user_code` | varchar(50) | 工号/员工编号 |
| `username` | varchar(50) | 登录账号 |
| `real_name` | varchar(50) | 姓名 |
| `mobile` | varchar(20) | 手机号 |
| `email` | varchar(100) | 邮箱 |
| `org_id` | bigint | 所属组织 ID |
| `job_title` | varchar(100) | 职位 |
| `status` | smallint | 状态,默认 1 |
| `hire_date` | date | 入职日期 |
| `avatar_url` | varchar(255) | 头像地址 |
| `password_hash` | varchar(255) | 密码摘要 |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `uk_sys_user_username`
- `uk_sys_user_mobile`
索引:
- `idx_sys_user_org_id`
### 5.2 `crm_customer`
表用途:
- 存储客户主档信息
- 为商机表提供客户归属
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 客户主键 |
| `customer_code` | varchar(50) | 客户编码 |
| `customer_name` | varchar(200) | 客户名称 |
| `customer_type` | varchar(50) | 客户类型 |
| `industry` | varchar(50) | 所属行业 |
| `province` | varchar(50) | 省份 |
| `city` | varchar(50) | 城市 |
| `address` | varchar(255) | 详细地址 |
| `owner_user_id` | bigint | 当前负责人 |
| `source` | varchar(50) | 客户来源 |
| `status` | varchar(30) | 状态:`potential/following/won/lost` |
| `remark` | text | 备注 |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `uk_crm_customer_code`
- `status check (status in ('potential', 'following', 'won', 'lost'))`
索引:
- `idx_crm_customer_owner`
- `idx_crm_customer_name`
### 5.3 `crm_opportunity`
表用途:
- 存储商机主数据
- 是 CRM 业务的核心主表
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 商机主键 |
| `opportunity_code` | varchar(50) | 商机编号 |
| `opportunity_name` | varchar(200) | 商机名称 |
| `customer_id` | bigint | 客户 ID |
| `owner_user_id` | bigint | 商机负责人 ID |
| `sales_expansion_id` | bigint | 关联销售拓展 ID |
| `channel_expansion_id` | bigint | 关联渠道拓展 ID |
| `pre_sales_id` | bigint | 售前 ID |
| `pre_sales_name` | varchar(100) | 售前姓名 |
| `project_location` | varchar(100) | 项目所在地 |
| `operator_name` | varchar(100) | 运作方 |
| `amount` | numeric(18,2) | 商机金额 |
| `expected_close_date` | date | 预计结单日期 |
| `confidence_pct` | varchar(1) | 把握度:`A/B/C` |
| `stage` | varchar(50) | 商机阶段 |
| `opportunity_type` | varchar(50) | 商机类型 |
| `product_type` | varchar(100) | 产品类型 |
| `source` | varchar(50) | 商机来源 |
| `competitor_name` | varchar(200) | 竞品名称 |
| `archived` | boolean | 是否归档 |
| `pushed_to_oms` | boolean | 是否已推送 OMS |
| `oms_push_time` | timestamptz | 推送时间 |
| `description` | text | 说明/备注 |
| `status` | varchar(30) | 状态:`active/won/lost/closed` |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `uk_crm_opportunity_code`
- `fk_crm_opportunity_customer`
- `fk_crm_opportunity_sales_expansion`
- `fk_crm_opportunity_channel_expansion`
- `confidence_pct check (confidence_pct in ('A', 'B', 'C'))`
- `status check (status in ('active', 'won', 'lost', 'closed'))`
索引:
- `idx_crm_opportunity_customer`
- `idx_crm_opportunity_owner`
- `idx_crm_opportunity_sales_expansion`
- `idx_crm_opportunity_channel_expansion`
- `idx_crm_opportunity_stage`
- `idx_crm_opportunity_expected_close`
- `idx_crm_opportunity_archived`
### 5.4 `crm_opportunity_followup`
表用途:
- 存储商机跟进记录
- 支撑商机详情页时间线与推进历史
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 跟进记录主键 |
| `opportunity_id` | bigint | 商机 ID |
| `followup_time` | timestamptz | 跟进时间 |
| `followup_type` | varchar(50) | 跟进方式 |
| `content` | text | 跟进内容 |
| `next_action` | varchar(255) | 下一步动作 |
| `followup_user_id` | bigint | 跟进人 ID |
| `source_type` | varchar(30) | 来源类型 |
| `source_id` | bigint | 来源记录 ID |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `fk_crm_opportunity_followup_opportunity on delete cascade`
索引:
- `idx_crm_opportunity_followup_opportunity_time`
- `idx_crm_opportunity_followup_user`
- `idx_crm_opportunity_followup_source`
### 5.5 `crm_sales_expansion`
表用途:
- 存储销售人员拓展信息
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 销售拓展主键 |
| `employee_no` | varchar(50) | 工号/员工编号 |
| `candidate_name` | varchar(50) | 候选人姓名 |
| `office_name` | varchar(100) | 办事处/代表处 |
| `mobile` | varchar(20) | 手机号 |
| `email` | varchar(100) | 邮箱 |
| `target_dept` | varchar(100) | 所属部门 |
| `industry` | varchar(50) | 所属行业 |
| `title` | varchar(100) | 职务 |
| `intent_level` | varchar(20) | 合作意向:`high/medium/low` |
| `stage` | varchar(50) | 跟进阶段 |
| `has_desktop_exp` | boolean | 是否有云桌面经验 |
| `in_progress` | boolean | 是否持续跟进 |
| `employment_status` | varchar(20) | 状态:`active/left/joined/abandoned` |
| `expected_join_date` | date | 预计入职日期 |
| `owner_user_id` | bigint | 负责人 ID |
| `remark` | text | 备注 |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `uk_crm_sales_expansion_owner_employee_no`
- `intent_level check (intent_level in ('high', 'medium', 'low'))`
- `employment_status check (employment_status in ('active', 'left', 'joined', 'abandoned'))`
索引:
- `idx_crm_sales_expansion_owner`
- `idx_crm_sales_expansion_stage`
- `idx_crm_sales_expansion_mobile`
### 5.6 `crm_channel_expansion`
表用途:
- 存储渠道拓展信息
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 渠道拓展主键 |
| `channel_code` | varchar(50) | 渠道编码 |
| `province` | varchar(50) | 省份 |
| `city` | varchar(50) | 城市 |
| `channel_name` | varchar(200) | 渠道名称 |
| `office_address` | varchar(255) | 办公地址 |
| `channel_industry` | varchar(100) | 聚焦行业 |
| `certification_level` | varchar(100) | 认证级别 |
| `annual_revenue` | numeric(18,2) | 年营收 |
| `staff_size` | integer | 人员规模 |
| `contact_established_date` | date | 建立联系日期 |
| `intent_level` | varchar(20) | 合作意向:`high/medium/low` |
| `has_desktop_exp` | boolean | 是否有云桌面经验 |
| `contact_name` | varchar(50) | 主联系人姓名,兼容旧结构 |
| `contact_title` | varchar(100) | 主联系人职务,兼容旧结构 |
| `contact_mobile` | varchar(20) | 主联系人电话,兼容旧结构 |
| `channel_attribute` | varchar(100) | 渠道属性 |
| `internal_attribute` | varchar(100) | 新华三内部属性 |
| `stage` | varchar(50) | 渠道合作阶段 |
| `landed_flag` | boolean | 是否已落地 |
| `expected_sign_date` | date | 预计签约日期 |
| `owner_user_id` | bigint | 负责人 ID |
| `remark` | text | 备注 |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `uk_crm_channel_expansion_code`,仅对非空 `channel_code` 生效
- `intent_level check (intent_level in ('high', 'medium', 'low'))`
- `staff_size check (staff_size is null or staff_size >= 0)`
索引:
- `idx_crm_channel_expansion_owner`
- `idx_crm_channel_expansion_stage`
- `idx_crm_channel_expansion_name`
### 5.7 `crm_channel_expansion_contact`
表用途:
- 存储渠道拓展联系人明细
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 联系人主键 |
| `channel_expansion_id` | bigint | 渠道拓展 ID |
| `contact_name` | varchar(50) | 联系人姓名 |
| `contact_mobile` | varchar(20) | 联系人电话 |
| `contact_title` | varchar(100) | 联系人职务 |
| `sort_order` | integer | 排序号 |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `fk_crm_channel_expansion_contact_channel on delete cascade`
索引:
- `idx_crm_channel_expansion_contact_channel`
### 5.8 `crm_expansion_followup`
表用途:
- 存储销售拓展和渠道拓展的统一跟进记录
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 跟进记录主键 |
| `biz_type` | varchar(20) | 业务类型:`sales/channel` |
| `biz_id` | bigint | 业务对象 ID |
| `followup_time` | timestamptz | 跟进时间 |
| `followup_type` | varchar(50) | 跟进方式 |
| `content` | text | 跟进内容 |
| `next_action` | varchar(255) | 下一步动作 |
| `followup_user_id` | bigint | 跟进人 ID |
| `visit_start_time` | timestamptz | 拜访开始时间 |
| `evaluation_content` | text | 评估内容 |
| `next_plan` | text | 后续规划 |
| `source_type` | varchar(30) | 来源类型 |
| `source_id` | bigint | 来源记录 ID |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `biz_type check (biz_type in ('sales', 'channel'))`
索引:
- `idx_crm_expansion_followup_biz_time`
- `idx_crm_expansion_followup_user`
- `idx_crm_expansion_followup_source`
### 5.9 `work_checkin`
表用途:
- 存储外勤打卡记录
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 打卡主键 |
| `user_id` | bigint | 打卡人 ID |
| `checkin_date` | date | 打卡日期 |
| `checkin_time` | timestamptz | 打卡时间 |
| `biz_type` | varchar(20) | 关联对象类型:`sales/channel/opportunity` |
| `biz_id` | bigint | 关联对象 ID |
| `biz_name` | varchar(200) | 关联对象名称 |
| `longitude` | numeric(10,6) | 经度 |
| `latitude` | numeric(10,6) | 纬度 |
| `location_text` | varchar(255) | 打卡地点 |
| `remark` | varchar(500) | 备注说明 |
| `user_name` | varchar(100) | 打卡人姓名快照 |
| `dept_name` | varchar(200) | 部门快照 |
| `status` | varchar(30) | 状态:`normal/abnormal/reissue` |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `work_checkin_biz_type_check`
- `status check (status in ('normal', 'abnormal', 'reissue'))`
索引:
- `idx_work_checkin_user_date`
### 5.10 `work_daily_report`
表用途:
- 存储销售日报主记录
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 日报主键 |
| `user_id` | bigint | 提交人 ID |
| `report_date` | date | 日报日期 |
| `work_content` | text | 今日工作内容 |
| `tomorrow_plan` | text | 明日工作计划 |
| `source_type` | varchar(30) | 提交来源:`manual/voice` |
| `submit_time` | timestamptz | 提交时间 |
| `status` | varchar(30) | 状态:`draft/submitted/read/reviewed` |
| `score` | integer | 评分0-100 |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `uk_work_daily_report_user_date`
- `source_type check (source_type in ('manual', 'voice'))`
- `status check (status in ('draft', 'submitted', 'read', 'reviewed'))`
- `score check (score between 0 and 100)`
索引:
- `idx_work_daily_report_user_date`
- `idx_work_daily_report_status`
### 5.11 `work_daily_report_comment`
表用途:
- 存储日报点评记录
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 点评主键 |
| `report_id` | bigint | 日报 ID |
| `reviewer_user_id` | bigint | 点评人 ID |
| `score` | integer | 点评分数 |
| `comment_content` | text | 点评内容 |
| `reviewed_at` | timestamptz | 点评时间 |
| `created_at` | timestamptz | 创建时间 |
关键约束:
- `fk_work_daily_report_comment_report on delete cascade`
- `score check (score between 0 and 100)`
索引:
- `idx_work_daily_report_comment_report`
### 5.12 `work_todo`
表用途:
- 存储首页待办事项
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 待办主键 |
| `user_id` | bigint | 所属用户 ID |
| `title` | varchar(200) | 待办标题 |
| `biz_type` | varchar(30) | 业务类型:`opportunity/expansion/report/other` |
| `biz_id` | bigint | 业务对象 ID |
| `due_date` | timestamptz | 截止时间 |
| `status` | varchar(20) | 状态:`todo/done/canceled` |
| `priority` | varchar(20) | 优先级:`high/medium/low` |
| `created_at` | timestamptz | 创建时间 |
| `updated_at` | timestamptz | 更新时间 |
关键约束:
- `biz_type check (biz_type in ('opportunity', 'expansion', 'report', 'other'))`
- `status check (status in ('todo', 'done', 'canceled'))`
- `priority check (priority in ('high', 'medium', 'low'))`
### 5.13 `sys_activity_log`
表用途:
- 存储首页最新动态日志
主要字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigint PK | 动态主键 |
| `biz_type` | varchar(30) | 业务类型 |
| `biz_id` | bigint | 业务对象 ID |
| `action_type` | varchar(50) | 动作类型 |
| `title` | varchar(200) | 动态标题 |
| `content` | varchar(500) | 动态内容 |
| `operator_user_id` | bigint | 操作人 ID |
| `created_at` | timestamptz | 创建时间 |
索引:
- `idx_sys_activity_log_created`
- `idx_sys_activity_log_biz`
## 6. 索引设计汇总
当前索引设计主要围绕以下查询场景展开:
- 客户按负责人、名称查询
- 商机按客户、负责人、阶段、归档状态、预计结单日查询
- 商机跟进按商机和时间倒序查询
- 销售拓展按负责人、手机号查询
- 渠道拓展按负责人、名称查询
- 渠道联系人按主表查询
- 拓展跟进按业务对象和时间倒序查询
- 打卡和日报按用户、日期倒序查询
- 动态按时间倒序查询
## 7. 触发器与自动维护机制
当前脚本中定义了统一触发器:
- `set_updated_at()`
并为以下表创建了自动更新时间触发器:
- `sys_user`
- `crm_customer`
- `crm_opportunity`
- `crm_opportunity_followup`
- `crm_sales_expansion`
- `crm_channel_expansion`
- `crm_channel_expansion_contact`
- `crm_expansion_followup`
- `work_checkin`
- `work_daily_report`
- `work_todo`
说明:
- 在表记录更新时,`updated_at` 会自动刷新为当前时间
## 8. 兼容性设计说明
当前初始化脚本不仅支持全新环境,也兼容旧环境升级,主要处理包括:
- `sys_user.dept_id``org_id` 迁移
- `crm_sales_expansion.target_dept_id``target_dept` 文本字段迁移
- `crm_channel_expansion` 补齐 `channel_code`、`city`、`certification_level` 等字段
- 从旧版 `crm_channel_expansion` 联系人字段迁移到 `crm_channel_expansion_contact`
- `crm_opportunity` 补齐销售拓展、渠道拓展、售前、运作方、竞品等字段
- `work_checkin` 补齐关联对象与人员快照字段
- 商机和拓展跟进表补齐来源字段
这意味着:
- 当前数据库设计既考虑了新建环境,也考虑了历史演进连续性
- 部署时优先执行统一入口脚本,而不是分散执行历史 DDL
## 9. 数据设计建议
结合当前设计,后续建议关注以下方向:
1. 为外键字段逐步补齐显式外键约束,提升数据一致性。
2. 对 `work_content`、`tomorrow_plan` 等承载结构化内容的文本字段,明确 JSON 存储规范。
3. 对高频查询场景继续做慢 SQL 监控与索引优化。
4. 对商机推送、日报回写等关键动作增加审计表或事件表。
5. 对历史动态、历史图片和归档商机建立数据归档策略。
## 10. 相关文件
- `sql/init_full_pg17.sql`
- `docs/business-schema-design.md`
- `docs/system-construction.md`
- `docs/technical-system-planning.md`
- `docs/deployment-guide.md`