diff --git a/.DS_Store b/.DS_Store
index a2f3500c..4c6310ab 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/docs/business-schema-design.md b/docs/business-schema-design.md
deleted file mode 100644
index 8b1e5b43..00000000
--- a/docs/business-schema-design.md
+++ /dev/null
@@ -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。
diff --git a/docs/crm.md b/docs/crm.md
new file mode 100644
index 00000000..84f7c754
--- /dev/null
+++ b/docs/crm.md
@@ -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["用户端
PC / Mobile"] --> FE["前端应用
React + Vite"]
+ FE --> GW["应用接入层
Nginx / HTTPS"]
+ GW --> BE["CRM 后端单体服务
Spring Boot"]
+ BE --> PG["PostgreSQL"]
+ BE --> RD["Redis"]
+ BE --> BASE["基础平台能力
用户/组织/参数/鉴权"]
+ BE --> OMS["外部系统集成
OMS / 其他业务系统"]
+ BE --> FILE["文件存储目录
上传附件 / 打卡照片"]
+```
+
+规划原则:
+
+- 业务复杂度未达到微服务拆分必要性前,优先优化单体结构
+- 前端优先按业务域拆分组件和状态
+- 后端优先按业务模块做清晰分层,而不是过早做技术拆分
+- 数据库优先保障一致性与查询性能
+- 接口治理、日志治理、异常治理要优先于功能外延扩张
+
+## 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`
+
diff --git a/docs/crm操作手册.pdf b/docs/crm操作手册.pdf
new file mode 100644
index 00000000..c6d2b38e
Binary files /dev/null and b/docs/crm操作手册.pdf differ
diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md
deleted file mode 100644
index ad62fe66..00000000
--- a/docs/deployment-guide.md
+++ /dev/null
@@ -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/`:历史迁移与修数脚本归档目录
diff --git a/docs/scripts/capture-manual-screenshots.mjs b/docs/scripts/capture-manual-screenshots.mjs
new file mode 100644
index 00000000..b07096f6
--- /dev/null
+++ b/docs/scripts/capture-manual-screenshots.mjs
@@ -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 = `
+
+ `.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;
+});
diff --git a/docs/scripts/render-formal-pdfs.mjs b/docs/scripts/render-formal-pdfs.mjs
new file mode 100644
index 00000000..e7ad9e26
--- /dev/null
+++ b/docs/scripts/render-formal-pdfs.mjs
@@ -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, "'");
+}
+
+function inlineMarkdownToHtml(text, baseDir) {
+ let html = escapeHtml(text);
+
+ html = html.replace(/`([^`]+)`/g, (_, code) => `${escapeHtml(code)}`);
+ html = html.replace(/\*\*([^*]+)\*\*/g, "$1");
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
+ const resolved = resolveHref(href, baseDir);
+ return `${escapeHtml(label)}`;
+ });
+
+ 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(`
${inlineMarkdownToHtml(block.text, baseDir)}
`); + continue; + } + + if (block.type === "list") { + const tag = block.ordered ? "ol" : "ul"; + const items = block.items + .map((item) => `${escapeHtml(block.content)}${escapeHtml(block.content)}`,
+ );
+ }
+ continue;
+ }
+
+ if (block.type === "table") {
+ const [headerRow, ...bodyRows] = block.rows;
+ const thead = `紫光汇智客户关系管理平台
+${escapeHtml(options.subtitle)}
+ +