main
parent
6ad8d41055
commit
592937db4e
|
|
@ -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。
|
||||
|
|
@ -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.
|
|
@ -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/`:历史迁移与修数脚本归档目录
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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`
|
||||
|
||||
Loading…
Reference in New Issue