feat: 新增客户管理全模块功能

实现客户信息、客户部门、客户账号三个子模块的完整CRUD,包含:
1. 数据库实体、DTO、控制器、服务层代码
2. RBAC权限映射与菜单种子数据扩展
3. 前端路由与API封装
4. 数据权限隔离与鉴权逻辑
5. 配套工具脚本与测试验证清单
This commit is contained in:
andyesfly committed 2026-08-23 19:15:54 +08:00
1 parent 026ec34471
commit 1c80a24d1d
31 files changed
+26321 -653

No files matched your search

@@ -0,0 +1,108 @@
# Checklist - 客户管理核心模块(客户信息 + 客户部门 + 客户账号)
## 数据库 Entity 与表结构检查
- [ ] `server/src/entity/customer.entity.ts` 存在,字段齐全:id/customer_no(UNIQUE)/customer_name/province/city/district/address/contact_person/contact_phone/postal_code/fax/customer_level/status/salesman_id(FK)/created_at/updated_at
- [ ] `customer_department.entity.ts` 字段齐全:id/customer_id(FK)/dept_name/contact_person/contact_phone/function_desc/created_at;BelongsTo Customer 关联
- [ ] `customer_account.entity.ts` 字段齐全:id/customer_id(FK)/phone(UNIQUE)/password_hash/wx_openid/status/last_login_at/created_at;BelongsTo Customer 关联
- [ ] 3 张 Entity 均定义正确索引:customer(customer_name, salesman_id, status, customer_no unique) / department(customer_id) / account(customer_id, phone unique)
- [ ] Midway 启动时 3 张 Entity 正确加载,PostgreSQL 中 `\d customer / \d customer_department / \d customer_account` 建表成功,外键约束存在,字段类型/长度/默认值与设计一致
- [ ] Sequelize Entity 字段均加了中文 `comment`,snake_case 列名通过 `field` 映射,无驼峰列误建
## db_seed.sql 菜单与权限种子数据检查
- [ ] 脚本幂等:连续执行 2 次无报错,序列 id 不漂移
- [ ] id=1「客户管理」menu_type=1(目录),parent_id=NULL
- [ ] 新增 3 条子菜单:id=12 客户信息 /customer/info (permission=customer:list) / id=13 客户部门 /customer/department (customer:department) / id=14 客户账号 /customer/account (customer:account),parent_id=1,component 分别为 customer/info/index 等
- [ ] 新增 9 条按钮权限(menu_type=3)id=15~23:customer:create/update/delete × 1、department: × 1、account: × 1,permission 标识命名正确
- [ ] `sys_role_menu` 中 role_id=1(ADMIN)绑定 id 1~23 全部;role_id=2(SALESMAN)绑定 id 1, 12~23(客户域全部)
- [ ] SELECT count(*) FROM sys_menu = 23(原 11 条 + 新增 12 条);SELECT count(*) FROM sys_role_menu 数量正确
## 鉴权中间件与 RBAC 检查
- [ ] `rbac.middleware.ts` 的 `PERMISSION_MAP` 新增 3 条:`/api/customers→customer:list`、`/api/customer-departments→customer:department`、`/api/customer-accounts→customer:account`
- [ ] 未登录访问 3 组接口 → 返回 401 code=401 message=未登录
- [ ] ADMIN token 访问所有接口 → 200 放行
- [ ] SALESMAN token 访问列表接口 → 返回 salesman_id 过滤后的行级数据;访问他人客户的 PUT/DELETE → 403 无权限
- [ ] SALESMAN 创建客户时,传入 salesman_id=3 被覆盖为自己的 id,保存后数据库 salesman_id 正确
- [ ] WORKER/CUSTOMER 角色 token 访问客户域接口 → 403 无权限
## 客户信息 CRUD 接口检查(/api/customers)
- [ ] GET / 分页搜索:page=1&pageSize=10&keyword=xx&status=1&salesmanId=1 → 返回 `{code:200,data:{list,total,page,pageSize}}` 结构正确
- [ ] customer_no 生成规则:同日连建 3 条 → 编号 KH+YYYYMMDD+001/002/003;UNIQUE 冲突时无崩溃
- [ ] POST 创建:必填项缺失 → 400 参数错误(DTO 校验工作);contact_phone 非手机号 regex 拒绝
- [ ] GET /:id 不存在 → 404;存在但 SALESMAN 无权限 → 403
- [ ] PUT /:id 更新:customer_no 不可改;非 ADMIN 无法修改 salesman_id
- [ ] DELETE /:id:存在 customer_department 或 customer_account 关联时 → 返回 400 禁止删除;无关联删除成功
- [ ] PUT /:id/status 状态切换(1→2→3→4)成功
- [ ] 列表和详情返回字段中 **绝不** 出现 `password_hash` / `wx_openid` / `password` 任何敏感列
## 客户部门 CRUD 接口检查(/api/customer-departments)
- [ ] 按 customer_id 过滤:GET /?customerId=N → 返回只属于该客户的部门
- [ ] SALESMAN 越权创建:传 customer_id=(别人客户)→ 403;传自己客户 → 创建成功
- [ ] 更新/删除归属校验:SALESMAN 不能改别人客户下的部门
- [ ] DTO 校验:dept_name 空值 → 400;字段长度超限 → 400
## 客户账号 CRUD 接口检查(/api/customer-accounts)
- [ ] 创建账号:phone=13800001234 → 默认密码 hash 通过 bcrypt.compare('001234', hash) === true;phone 不足 6 位时默认密码为 123456
- [ ] 创建重复 phone → 返回 400 '手机号已存在'
- [ ] 列表 / 详情接口响应 JSON 中字段白名单:id/customerId/phone/status/createdAt/lastLoginAt(不含 password_hash / wx_openid)
- [ ] 更新状态成功;resetPassword=true 触发重新哈希默认密码
- [ ] SALESMAN 归属校验:只允许操作自己客户下的账号,否则 403
- [ ] customer_account.status = 0 禁用账号后,若未来有登录接口需拒绝(本期无登录实现,但字段必须正确写入)
## DTO 参数校验与类型检查
- [ ] 非法 pageSize(>200、非数字)→ 返回 400 校验错误
- [ ] 注入型 keyword(`<script>alert(1)</script>` / `' OR 1=1 --`)→ DTO 长度/regex 校验或正常参数化查询,接口无 500
- [ ] 所有接口均不使用 `any` 类型(Controller @Body/@Query 绑定具体 DTO 类,Service 参数具名 interface 类型)
- [ ] server package.json 安装的 class-validator / class-transformer 已正确应用装饰器(@IsString/@IsInt/@Max/@Min/@Matches 等)
## 前端路由、菜单、API 封装检查
- [ ] router/index.ts 中 /customer 拆成 3 条子路由:/info /department /account,component 指向各自 vue,meta.permission 与菜单对应
- [ ] 登录后侧边栏「客户管理」目录可展开为 3 个子菜单,点击分别跳转;面包屑正确
- [ ] 3 个 api/*.ts 文件存在:customer.ts / customerDepartment.ts / customerAccount.ts;全部走 @/utils/request(Axios)、携带 Authorization 拦截器、错误统一提示
- [ ] TypeScript 类型定义完整:CustomerInfo / CustomerQuery / DepartmentInfo 等接口导出,页面使用时无隐式 any
## 客户信息前端页面(customer/info/index.vue)
- [ ] UI 风格与 system/user/index.vue 完全一致:a-card 标题 / 顶部新增批量删除按钮 / 搜索栏 / 表格 / 弹窗
- [ ] 搜索:关键词输入、状态下拉、业务员下拉(ADMIN 显示、SALESMAN 隐藏/禁用);查询/重置按钮
- [ ] 表格列:客户编号/名称/省市区(拼接)/地址/联系人/电话/客户级别/状态(a-switch)/创建时间/操作;列宽合理、ellipsis 生效
- [ ] 新增/编辑弹窗:a-cascader 省市区级联可用、数据来源于 regionOptions(region.ts);表单必填校验 + 手机号 regex + 长度限制;保存成功 → 弹窗关闭 → page=1 → 刷新列表(保留筛选)
- [ ] 编辑时 customer_no 字段只读(禁用或隐藏)
- [ ] 删除确认弹框(a-popconfirm)生效;批量删除按钮选中时启用
- [ ] useTableScroll 正确应用:wrapperRef + tableHeight,表格 body 内部滚动,整页无垂直滚动条溢出
- [ ] 状态 switch 点开关即时调用 updateStatus 接口,成功后 message.success
## 客户部门/账号前端页面检查
- [ ] 部门页:客户下拉可用(ADMIN 全量/SALESMAN 仅自己客户);新增/编辑表单客户字段必填校验
- [ ] 账号页:顶部展示「默认密码=手机号后 6 位」文字提示;重置密码按钮/复选框存在并生效
- [ ] 三页面列表刷新链路一致:新增/编辑/删除成功后统一 reset page to 1 → refetch,筛选条件保留
- [ ] SALESMAN 登录时,页面 UI 与权限一致:客户下拉过滤、越权操作按钮隐藏(若通过接口已 403,则按钮隐藏更友好)
## 安全防护检查
- [ ] 前端防 XSS:所有表格列使用 AntDV 默认文本渲染,无一处使用 v-html;表单 maxlength 限制 + 正则过滤 script 标签;注入输入保存后列表纯文本显示、浏览器无 alert 弹窗
- [ ] 后端防 SQL 注入:grep server/src 无任何 `sequelize.query()` 字符串拼接 SQL(若有必须用 replacements 参数化);所有查询走 Sequelize Model 方法 + where 对象;用 Postman 发 `' OR 1=1 -- ` keyword 不返回全表数据
- [ ] 接口鉴权覆盖:3 个模块 × 6 方法共 18 个接口,均经过 auth + rbac 中间件,无匿名可写接口
- [ ] 密码加密:customer_account 创建和重置密码时使用 hashPassword 生成 bcrypt(saltRounds≥10),数据库仅存哈希,无明文登录密码任何位置落盘/日志
- [ ] 敏感字段脱敏:customer_account 列表/详情响应不返回 password_hash 与 wx_openid(浏览器 DevTools Network 面板确认)
## 业务员 APP 接口兼容性检查
- [ ] 用 Postman/curl 带相同 JWT 头(Authorization: Bearer xxx)调用 3 个模块的 GET/POST/PUT/DELETE 接口,返回结构与 Web 端完全相同(code/message/data 字段一致)
- [ ] 分页参数 page/pageSize、筛选 keyword/status 行为完全相同;分页上限保护 APP 侧调超大 pageSize 时 400 正确拒绝
- [ ] 错误码映射:401/403/400/404/500 都有明确 message,APP 侧可据此弹窗提示
- [ ] 同一 SALESMAN token,Web 和 APP 端同时调用 list,返回数据集完全一致(同一行级隔离)
## 集成与端到端流程检查
- [ ] ADMIN 完整走通:登录 → 客户管理展开 → 客户信息新增 2 条 → 给每条新增 1 个部门 + 1 个账号 → 列表分页翻到第 2 页 → 搜索关键词命中 → 编辑修改 → 删除 1 条客户(先删部门和账号再删客户成功,未删时报 400)→ 全程无 401/403/500
- [ ] SALESMAN 完整走通:登录 → 客户列表仅见归属自己的 → 新增客户(salesman_id 自动写自己)→ 新增自己客户的部门/账号成功 → 尝试在 URL 手改别人客户 ID PUT 接口 → 返回 403 且数据未变
- [ ] db_seed.sql 重新执行不影响已录入的客户域业务数据(仅系统表按 id 幂等更新,customer 等业务表无 TRUNCATE)
## 代码质量与构建检查
- [ ] Server:`yarn lint` 无 ESLint 错误;`yarn build`(midway-bin build -c)构建成功,tsc 无类型错误
- [ ] Client:`yarn build`(vite build)无 TS 错误、无警告;ESLint 无告警
- [ ] 无硬编码敏感信息(JWT secret、db password)仍留在代码中的问题;统一从 config 读取
- [ ] 新增代码文件命名/目录与现有约定一致:entity/*.entity.ts、dto/*.dto.ts、service/*.service.ts、controller/*.controller.ts、views/customer/*/index.vue
- [ ] 前后端字段名映射正确:后端 snake_case(DB)→ camelCase(Entity + JSON 响应)→ 前端 camelCase 使用,无错配(例:password_hash → passwordHash → 返回 JSON 无此字段即白名单过滤生效)
## 业务一致性 / 边界用例检查
- [ ] 删除客户:先建客户→建部门→直接删客户→接口返回400禁止→先删部门/账号→再删客户成功(外键保护链路)
- [ ] customer_no 跨天:模拟 23:59 和 00:01 分别创建,序号从 001 重新起
- [ ] 空列表:新建干净数据库客户域无数据,三页面均展示 a-empty 无报错
- [ ] 超长字段:客户地址传 256+ 字符 → DTO 校验截断或报错,数据库不静默截断导致数据丢失
@@ -0,0 +1,185 @@
# 鑫潼智电系统 · 客户管理核心模块 (V1.0) - Product Requirement Document
## Overview
- **Summary**: 基于数据库设计文档 D2 客户域,实现「客户信息 + 客户部门 + 客户访问账号」三个核心子模块的完整前后端功能,覆盖 CRUD、分页搜索、数据权限隔离、基础安全防护,并兼容业务员移动端 APP(复用同一套 REST API 和 sys_user 账号体系)。
- **Purpose**: 为业务员和运营管理员提供结构化的客户档案管理能力,是后续项目管理、派单管理、设备管理、用电统计等所有下游业务模块的数据基础。
- **Target Users**: 运营管理员(ADMIN 角色,全量数据)、业务员(SALESMAN 角色,仅本人客户数据)、业务员 APP(同 SALESMAN 权限,移动端使用)。
## Goals
- 实现客户信息(customer)完整 CRUD,含业务编号自动生成、省市区地址、状态流转、负责业务员分配
- 实现客户部门(customer_department)完整 CRUD,按客户归属过滤
- 实现客户访问账号(customer_account)完整 CRUD,按客户归属过滤,默认密码自动生成与 bcrypt 加密
- 接入现有 RBAC 鉴权体系:菜单/按钮权限 + 行级数据隔离(ADMIN 全量、SALESMAN 按 salesman_id 过滤)
- 前端 UI 与现有用户管理页保持一致风格(搜索栏 + 表格 + 弹窗),省市区级联、表格内部滚动
- 接口防 SQL 注入(参数化查询),前端防 XSS(输入校验 + 避免 v-html),敏感字段脱敏
- 同一套 API 无差别兼容 Web 管理后台和业务员 APP
## Non-Goals (Out of Scope)
- 客户拜访(customer_visit)、客户建筑(customer_building)、客户房间(customer_room)、客户设备(customer_equipment)四个子模块(后续迭代)
- 共享电工 APP(WORKER 角色)和客户微信小程序(CUSTOMER 角色)的专属接口(文档确认本期不含)
- 业务员 APP 的移动端 UI 页面(本期仅保证 API 可用,UI 由独立 uni-app 项目负责)
- 客户状态驱动的自动流转、客户画像/报表分析(后续统计报表模块)
- 文件上传(设备照片、合同文件)、短信通知初始密码、首次登录强制改密码
## Background & Context
- **现有架构**: 后端 Midway.js (Koa) + Sequelize-typescript + PostgreSQL;前端 Vue3 + Ant Design Vue 4 + Vite;鉴权 JWT + RBAC 中间件(`rbac.middleware.ts` 基于路径前缀匹配权限码)
- **已有数据库种子**(`database/db_seed.sql`):4 个角色(ADMIN/SALESMAN/WORKER/CUSTOMER)、6 条顶级菜单(含「客户管理」parent_id=NULL id=1 permission=customer:list)、SALESMAN 角色已绑定客户/项目/统计报表 3 个顶级菜单
- **已有客户管理路由占位**(`client/src/router/index.ts`):`/customer` 路径指向 Placeholder.vue,permission=customer:list
- **RBAC 中间件映射表**(`rbac.middleware.ts` PERMISSION_MAP):当前仅含 /api/users、/api/roles 等系统管理路由,需新增客户域 3 条映射
- **数据隔离要求**:SALESMAN 角色只能看到自己负责的客户;customer、customer_department、customer_account 三张表均需支持 salesman_id 行级过滤(customer_account 通过 customer_id → customer.salesman_id 关联)
## Functional Requirements
### FR-1:客户信息管理(customer)
系统 SHALL 提供客户信息的完整 CRUD 和查询能力:
- 列表:分页(page/pageSize)、关键词搜索(客户名称/联系人/电话)、状态筛选(1意向/2签约/3运维中/4终止)、负责业务员筛选
- 字段:id、customer_no(自动生成,KH+YYYYMMDD+3位序号,UNIQUE)、customer_name(必填)、province/city/district(省市区级联)、address、contact_person、contact_phone、postal_code、fax、customer_level(学校/医院/企业等)、status(默认1意向)、salesman_id(关联 sys_user.id,允许 NULL 由 ADMIN 分配)、created_at、updated_at
- 创建:必填校验、customer_no 自动生成、写入 salesman_id(若当前角色为 SALESMAN 则强制=当前用户ID)
- 更新:字段白名单更新、customer_no 只读不可改
- 删除:硬删除,若存在关联 customer_department / customer_account 则提示禁止删除或级联删除(参照外键约束)
- 列表返回不包含敏感字段(无密码类字段)
### FR-2:客户部门管理(customer_department)
系统 SHALL 提供客户部门的完整 CRUD,数据按所属客户过滤:
- 列表:分页、关键词搜索(部门名称/联系人/电话)、按 customer_id 过滤(必选或必带权限过滤)
- 字段:id、customer_id(FK customer,必填)、dept_name(必填)、contact_person、contact_phone、function_desc、created_at
- 行级权限:SALESMAN 仅可操作自己客户下的部门(通过 customer.salesman_id 关联校验)
- 删除:若存在关联 customer_room(虽本期不实现,但需保留外键约束友好提示)
### FR-3:客户访问账号管理(customer_account)
系统 SHALL 提供客户侧访问账号的完整 CRUD,数据按所属客户过滤:
- 列表:分页、关键词搜索(电话)、按 customer_id 过滤、状态筛选(1启用/0禁用)
- 字段:id、customer_id(FK customer,必填)、phone(UNIQUE,必填,登录账号)、password_hash(bcrypt)、wx_openid(本期可NULL)、status(默认1启用)、last_login_at、created_at
- 创建:默认密码 = 手机号后6位(不足6位用 123456),自动 bcrypt 后存 password_hash
- 更新:可改状态;修改密码需单独字段触发并重新 bcrypt
- 行级权限:SALESMAN 仅可操作自己客户下的账号
- 列表返回不得携带 password_hash / wx_openid 等敏感字段
### FR-4:接口鉴权与数据权限(复用现有体系扩展)
系统 SHALL 将客户域接口接入现有认证与权限体系:
- 所有客户域接口要求登录(走 auth.middleware → JWT 校验)
- RBAC 中间件新增 3 条路径映射:`/api/customers→customer:list`、`/api/customer-departments→customer:department`、`/api/customer-accounts→customer:account`
- 超级管理员(ADMIN)直接放行
- 行级数据隔离:SALESMAN 角色的 customer 列表自动加 `salesman_id = ctx.state.user.userId` 条件;customer_department / customer_account 通过 customer_id JOIN customer.salesman_id 过滤
- 越权访问控制:SALESMAN 更新/删除时校验目标记录是否归属自己,否则返回 403
### FR-5:菜单与按钮权限(db_seed.sql 扩展)
系统 SHALL 在种子数据中补全客户域菜单与按钮权限:
- 将「客户管理」顶级菜单(id=1)改为目录类型 menu_type=1,作为父节点
- 新增 3 条子菜单:客户信息(/customer/info / customer/info/index / permission=customer:list)、客户部门(/customer/department / customer/department/index / permission=customer:department)、客户账号(/customer/account / customer/account/index / permission=customer:account)
- 新增若干按钮权限(menu_type=3):各模块的 create / update / delete 按钮权限(如 customer:create、customer:update、customer:delete)
- 角色-菜单绑定:ADMIN 全部绑定,SALESMAN 绑定客户域全部菜单和按钮
### FR-6:前端页面(Web 端 3 个页面)
系统 SHALL 提供 3 个 Vue 页面,风格与现有用户管理完全一致:
- 客户信息页(customer/info/index.vue):搜索栏(关键词、状态、业务员下拉)+ 表格(列含客户编号、名称、省市区、联系人、电话、级别、状态、创建时间、操作)+ 新增/编辑弹窗(含省市区 a-cascader + 业务员下拉 + 表单校验)
- 客户部门页(customer/department/index.vue):搜索栏(关键词、客户下拉)+ 表格 + 弹窗(部门名称、联系人、电话、职能描述)
- 客户账号页(customer/account/index.vue):搜索栏(关键词、客户下拉、状态)+ 表格(列含客户名称、电话、状态、创建时间、操作;提示默认密码=手机后6位)+ 弹窗(客户下拉、电话、状态;编辑时提供"重置密码为手机后6位"快捷操作)
- 通用:表格内部滚动(复用 useTableScroll composable)、新增/编辑成功后页码重置到第一页并重新拉取列表、删除二次确认、状态 switch 一键切换
### FR-7:前后端接口契约一致性(兼容 APP)
系统 SHALL 提供统一的 REST 接口,Web 后台和业务员 APP 共用:
- 路径前缀:`/api/customers`、`/api/customer-departments`、`/api/customer-accounts`(与现有 /api/users 风格一致)
- 通用返回结构:`{ code: 200, message: '...', data: { list, total, page, pageSize } }`(列表)或 `{ code: 200, message, data: {...} | null }`(单条/写操作)
- 鉴权方式:统一 Authorization: Bearer <access_token>(JWT),APP 端和 Web 端完全相同
- 错误码:401 未登录 / 403 无权限 / 400 参数错误 / 404 不存在 / 500 服务异常
- 分页参数:page、pageSize;搜索 keyword;筛选字段均为 Query 参数,后端统一做数字类型校验和上限保护(pageSize ≤ 200)
- 接口幂等:写操作均通过 id 区分,UNIQUE 字段(customer_no、customer_account.phone)冲突返回 400 + 明确 message
## Non-Functional Requirements
### NFR-1:防 SQL 注入
所有数据库查询必须通过 Sequelize 参数化 API(`findAndCountAll`、`create`、`update`、`destroy` + `where` 对象),禁止任何字符串拼接 SQL;LIKE 模糊查询使用 `{ [Op.like]: \`%\${keyword}%\` }` 而非字符串模板。
### NFR-2:前端防 XSS
- 前端不使用 `v-html` 渲染任何用户输入字段,全部使用 `v-text` 或 AntDV 默认文本渲染(AntDV 默认转义)
- 表单输入添加长度限制(VARCHAR 字段按数据库长度上限 + regex 过滤 `<` `>` `"` `'` `<script>` 等关键字)
- 富文本/HTML 内容本期不存在,无需 DOMPurify
### NFR-3:接口鉴权覆盖
客户域 3 个 Controller 的每个请求方法均需经过 auth.middleware 和 rbac.middleware;白名单仅 `ignore` 指定的公开路由(客户域无公开接口)。
### NFR-4:性能
- 列表接口均走 Sequelize `limit + offset` 分页,避免一次性加载全表
- customer 表需在 `customer_name`、`salesman_id`、`status` 字段建索引;customer_department / customer_account 在 `customer_id` 建索引(Sequelize Entity 定义 indexes)
- 单接口响应时间目标:< 500ms(开发环境、本地数据库)
### NFR-5:字段脱敏与敏感信息
- 列表/详情接口永远不返回 `password_hash`、`wx_openid`(customer_account)等敏感字段
- `contact_phone` 列表展示可保留完整值(内部系统),但需确保不写入前端错误日志中
### NFR-6:TypeScript 类型安全
- Entity / DTO / 前端 API 类型均用显式 interface 定义,禁止滥用 `any`(Controller 层 @Body / @Query 参数尽量使用 DTO class + class-validator 装饰器校验)
## Constraints
- **Technical**: Midway.js + Sequelize-typescript + PostgreSQL 15+(不能换 TypeORM,沿用现有 Sequelize 体系);Vue3 + Ant Design Vue 4 + Vite;JWT 鉴权 + 现有 RBAC 中间件体系
- **Business**: 鑫潼单一运营主体,customer_id 逻辑隔离(非严格多租户);SALESMAN 角色行级可见范围 = salesman_id = 自身用户ID
- **Dependencies**: 依赖现有表 sys_user(salesman_id 外键)、sys_role、sys_menu、sys_role_menu、sys_user_role、sys_dict(customer_status 字典已存在 db_seed.sql 中)
## Assumptions
- 省市区级联数据来源:引入开源轻量省市区 JSON(china-division 或等效),无需对接外部 API
- 客户编号 customer_no 每日序号的并发安全:通过 PostgreSQL UNIQUE 约束 + 捕获冲突重试或日期序号表管理(本期简化为取当日最大序号+1,极端并发下重试)
- 业务员 APP 项目为独立 uni-app 工程(不在当前仓库 client 目录),本期仅保证后端 API 契约稳定可被 HTTP 调用
- customer_account 表的 wx_openid 字段本期不做微信登录实现,仅保留字段允许 NULL
- 数据库建表方式:Sequelize Entity sync 自动建表(与现有 sys_user 方式一致),不手写 SQL 到 db_init.sql
## Acceptance Criteria
### AC-1:客户信息 CRUD 完整可用
- **Given**: 使用 ADMIN 账号登录后台,进入客户信息页
- **When**: 填写完整的新增客户表单(必填项:名称、省市区、联系人、电话、级别、状态、业务员)并提交
- **Then**: 返回 code=200,列表刷新显示新增客户,customer_no 格式符合 KH+YYYYMMDD+序号;编辑修改联系人后列表刷新;删除未关联部门/账号的客户后列表移除;尝试删除已关联数据的客户返回 400 提示
- **Verification**: `programmatic`
### AC-2:SALESMAN 行级数据隔离生效
- **Given**: 使用 SALESMAN 角色的 salesman01(id=2)登录;ADMIN 创建了客户 A(salesman_id=2)和客户 B(salesman_id=NULL 或其他)
- **When**: salesman01 调用 GET /api/customers
- **Then**: 返回列表仅包含客户 A;尝试 PUT/DELETE 客户 B 返回 403;创建新客户时 salesman_id 自动写入=2(即使请求体传了其他值也被忽略)
- **Verification**: `programmatic`
### AC-3:客户部门/账号的归属校验
- **Given**: 客户 C 归 salesman01 所有,客户 D 归其他人所有
- **When**: salesman01 POST /api/customer-departments 传 customer_id=D
- **Then**: 返回 403 禁止越权操作;传 customer_id=C 则创建成功并归属正确
- **Verification**: `programmatic`
### AC-4:RBAC 菜单与按钮权限生效
- **Given**: ADMIN 和 SALESMAN 登录后获取菜单树
- **Then**: ADMIN 看到客户管理目录下 3 个子菜单;SALESMAN 也看到相同 3 个子菜单(本期种子已绑定);无权限角色(WORKER/CUSTOMER)登录访问 /customer 路由跳 403
- **Verification**: `human-judgment`(UI 菜单可见性 + `programmatic` 接口权限码校验)
### AC-5:客户账号默认密码规则正确
- **Given**: 新建客户访问账号,电话 13800001234
- **When**: 保存后数据库 password_hash 字段存在 bcrypt 哈希值
- **Then**: 该哈希值与 bcrypt('001234') 对比为 true(用工具脚本或单元测试验证);列表接口不返回 password_hash
- **Verification**: `programmatic`
### AC-6:防 SQL 注入
- **Given**: 列表接口 keyword 参数传入 `' OR 1=1 -- `
- **When**: 后端执行查询
- **Then**: 参数被安全转义,查询结果只返回正常的模糊匹配(无匹配则返回空列表),不会返回全表数据;SQL 日志不出现原始注入字符串拼接
- **Verification**: `programmatic`
### AC-7:前端防 XSS
- **Given**: 新增客户表单客户名称输入 `<script>alert('xss')</script>`
- **When**: 保存后列表页渲染该客户名称
- **Then**: 名称以纯文本展示(脚本不执行);浏览器 Console 无异常报错
- **Verification**: `human-judgment`(页面观察)
### AC-8:业务员 APP 接口兼容
- **Given**: 使用任意 HTTP 客户端(curl/Postman)模拟 APP 请求,Authorization 头携带 SALESMAN 的有效 JWT
- **When**: 调用客户域 GET/POST/PUT/DELETE 接口
- **Then**: 返回结构与 Web 端调用完全一致(同 code/message/data 结构);分页/搜索/鉴权行为无差别
- **Verification**: `programmatic`
### AC-9:前端列表刷新链路
- **Given**: 当前在客户列表第 3 页
- **When**: 新增/编辑/删除一条记录成功
- **Then**: 分页页码重置为 1,列表重新拉取显示最新数据;搜索筛选条件保留(不清空 keyword/status)
- **Verification**: `human-judgment` + `programmatic`(网络面板确认请求 page=1)
## Open Questions
- [ ] (已确认)功能范围:仅客户信息 + 客户部门 + 客户账号 3 个模块
- [ ] (已确认)customer.salesman_id 行级隔离 + ADMIN 全量
- [ ] (已确认)APP 兼容 = 业务员 APP 与后台共用 API,复用 sys_user 登录
- [ ] (已确认)customer.status 枚举 1意向/2签约/3运维中/4终止;customer_no 自动生成
- [ ] (已确认)customer_account 默认密码=手机后6位,bcrypt 加密
- [ ] (已确认)前端 UI:a-cascader 省市区 + 弹窗模式 + 表格内滚动
@@ -0,0 +1,212 @@
# 鑫潼智电系统 · 客户管理核心模块 - The Implementation Plan (Decomposed and Prioritized Task List)
## [x] Task 1: 新增 3 个 Sequelize Entity 模型文件
- **Priority**: high
- **Depends On**: None
- **Description**:
- 在 `server/src/entity/` 下新增 `customer.entity.ts`:字段严格对齐 spec.md + DB 设计文档,新增 `customer_no` (VARCHAR(64) UNIQUE) 和 `salesman_id` (BIGINT FK→sys_user.id),province/city/district/address/contact_person/contact_phone/postal_code/fax/customer_level/status(SMALLINT default 1)/created_at/updated_at
- 新增 `customer_department.entity.ts`:id/customer_id(FK)/dept_name/contact_person/contact_phone/function_desc/created_at,BelongsTo Customer 关联
- 新增 `customer_account.entity.ts`:id/customer_id(FK)/phone(UNIQUE)/password_hash/wx_openid/status/last_login_at/created_at,BelongsTo Customer 关联
- 每个 Entity 定义必要索引(customer: customer_name, salesman_id, status, customer_no unique; department: customer_id; account: customer_id, phone unique)
- 字段使用 `field` 映射 snake_case 列名,`comment` 加中文注释,`@CreatedAt @UpdatedAt` 装饰器
- **Acceptance Criteria Addressed**: FR-1, FR-2, FR-3, NFR-1
- **Test Requirements**:
- `programmatic` TR-1.1: Sequelize sync 后 PostgreSQL 中三张表成功建立,字段类型/长度/默认值/索引与定义一致,外键约束存在
- `programmatic` TR-1.2: customer 表插入一条含 phone 的 customer_account,尝试重号 phone 抛唯一约束错误
- `human-judgement` TR-1.3: 代码 review 确认字段未遗漏 customer_no / salesman_id / password_hash / status 等关键列
- **Notes**: 沿用现有 `sysUser.entity.ts` 风格,DataType + 注释 + timestamps 配置一致
## [x] Task 2: 扩展 db_seed.sql 客户域菜单与权限
- **Priority**: high
- **Depends On**: None
- **Description**:
- 把现有 id=1「客户管理」顶级菜单 menu_type 从 2 改为 1(目录),parent_id=NULL 不变
- 新增 3 条子菜单(id=12/13/14),parent_id=1:12=客户信息(/customer/info component=customer/info/index permission=customer:list)、13=客户部门(/customer/department component=customer/department/index permission=customer:department)、14=客户账号(/customer/account component=customer/account/index permission=customer:account)
- 新增 9 条按钮权限菜单(menu_type=3)id=15~23:customer:create / customer:update / customer:delete / department:create / department:update / department:delete / account:create / account:update / account:delete
- UPDATE `sys_role_menu`:ADMIN(role_id=1) 新增绑定 12~23 全部;SALESMAN(role_id=2) 新增绑定 12~23 全部(删除原只绑定 id=1 的记录,或新增并保留 id=1)
- 保持 ON CONFLICT DO UPDATE / DO NOTHING 幂等性
- **Acceptance Criteria Addressed**: FR-5, AC-4
- **Test Requirements**:
- `programmatic` TR-2.1: 执行 psql -f db_seed.sql 后 sys_menu 表 id 1~23 全部存在,id=1 menu_type=1,id=12/13/14 parent_id=1
- `programmatic` TR-2.2: sys_role_menu 中 role_id=1 包含 menu_id 1~23 全部;role_id=2 至少包含 1,12,13,14,15..23
- **Notes**: 先 TRUNCATE 或逐条 DELETE ON CONFLICT 前需注意不要误删系统管理菜单,建议所有 INSERT 使用显式 id + ON CONFLICT (id) DO UPDATE
## [x] Task 3: 扩展 RBAC 中间件 + 注册 Entity 到配置
- **Priority**: high
- **Depends On**: [Task 1, Task 2]
- **Description**:
- 在 `server/src/middleware/rbac.middleware.ts` 的 `PERMISSION_MAP` 中新增 3 条:
- `/api/customers` → `customer:list`
- `/api/customer-departments` → `customer:department`
- `/api/customer-accounts` → `customer:account`
- 在 `server/src/configuration.ts` 或 Sequelize 配置 `config.default.ts` 的 `models` 数组中,显式 import 并加入 `Customer` / `CustomerDepartment` / `CustomerAccount` 三个 Entity(确保 sync 时加载)
- **Acceptance Criteria Addressed**: FR-4, AC-2, AC-4
- **Test Requirements**:
- `programmatic` TR-3.1: 用无权限 token 访问 GET /api/customers 返回 403(rbac 中间件工作);ADMIN token 返回 200
- `programmatic` TR-3.2: 服务启动无 Entity 加载报错,sync 后 3 张表存在
- **Notes**: 若 models 采用目录扫描自动加载,Task 1 把 Entity 放对目录即可;如显式 import 列表则需手动追加
## [x] Task 4: 新增客户域 DTO 参数校验类
- **Priority**: high
- **Depends On**: [Task 1]
- **Description**:
- 新建 `server/src/dto/customer.dto.ts`:
- `CustomerQueryDto`:page/ pageSize(number, min 1, max 200)/ keyword(string, max 64)/ status(SMALLINT 1-4)/ salesmanId(optional)
- `CustomerCreateDto`:customerName(required, max 128, regex 禁 script tag)/ province/city/district/address(max 256)/ contactPerson/ contactPhone(regex 手机号)/ postalCode?/ fax?/ customerLevel?/ status(default 1)/ salesmanId?
- `CustomerUpdateDto`:字段可选,与 create 共用正则
- 新建 `server/src/dto/customer.department.dto.ts`:CustomerDepartmentQueryDto / Create / Update(customerId required 数字)
- 新建 `server/src/dto/customer.account.dto.ts`:CustomerAccountQueryDto / Create / Update;CreateDto 无 password 字段(由 service 自动生成),UpdateDto 含可选 resetPassword 标志位 + status
- **Acceptance Criteria Addressed**: FR-1, FR-2, FR-3, NFR-6
- **Test Requirements**:
- `programmatic` TR-4.1: 传空 customerName 创建 → 返回 400 参数错误
- `programmatic` TR-4.2: 传 pageSize=500 → 校验失败,返回 max 200 提示
- `programmatic` TR-4.3: contactPhone 传 'abc' → regex 校验失败
- **Notes**: 使用 @midwayjs/validate + class-validator 装饰器,与现有 dto/*.ts 风格一致;禁止使用 any
## [x] Task 5: 实现 CustomerService(含 customer_no 生成 + 行级过滤)
- **Priority**: high
- **Depends On**: [Task 1, Task 4]
- **Description**:
- 新建 `server/src/service/customer.service.ts`,@Provide() 类
- list(query, ctx):分页 + 关键词模糊查询 Op.or [customer_name, contact_person, contact_phone] + status 筛选;SALESMAN 角色强制追加 where salesman_id = ctx.state.user.userId;ADMIN 不加
- detail(id, ctx):按 id 查询,SALESMAN 校验 salesman_id 归属,否则 403
- generateCustomerNo():查询当日 customer_no 最大序号,规则 `KH + YYYYMMDD + 3位序号`(001 起始);捕获 UNIQUE 冲突重试 1 次
- create(data, ctx):校验必要字段;若 SALESMAN 创建则 data.salesmanId 强制覆盖为 ctx.state.user.userId;调用 generateCustomerNo();写入后返回完整创建对象
- update(id, data, ctx):归属校验;字段白名单更新(customer_no / salesman_id(非ADMIN)不可改)
- delete(id, ctx):归属校验;先查是否存在 customer_department 或 customer_account 关联,有则返回 {code:400, message:'该客户存在关联部门/账号,无法删除'};否则删除
- updateStatus(id, status, ctx):快捷更新状态
- **Acceptance Criteria Addressed**: FR-1, FR-4, AC-1, AC-2
- **Test Requirements**:
- `programmatic` TR-5.1: ADMIN 创建 2 个客户后,SALESMAN(id=2) list 返回 total ≤ ADMIN 看到的 total,且返回每条 salesman_id=2
- `programmatic` TR-5.2: 同一天连续 create 3 条客户,customer_no 分别为 KH<today>001 / 002 / 003,格式正确
- `programmatic` TR-5.3: SALESMAN 尝试传 salesman_id=3 创建,保存后实际 salesman_id=2(被覆盖)
- `programmatic` TR-5.4: 删除有关联部门的客户 → 返回 400 禁止
- **Notes**: 所有查询用 Sequelize 参数化 where 对象,禁止拼接;关联 existence 检查用 `findOne({ where: { customerId: id }, limit: 1 })` 而非 countAll,提高性能
## [x] Task 6: 实现 CustomerDepartmentService + CustomerAccountService
- **Priority**: high
- **Depends On**: [Task 1, Task 4, Task 5]
- **Description**:
- `customer.department.service.ts`:list(关键词搜索 + 按 customer_id 过滤;SALESMAN 通过 customer_id JOIN customer.salesman_id 过滤);create(校验 customer_id 归属:SALESMAN 需确保目标 customer.salesman_id = 自己);update / delete(归属校验 + 外键关联提示)
- `customer.account.service.ts`:list(按 customer_id / status / keyword phone 模糊;SALESMAN JOIN 过滤;返回 exclude password_hash, wx_openid);create(默认密码=phone 后 6 位 → bcrypt 加密;phone UNIQUE 校验);update(字段白名单;若 resetPassword=true 则重新计算密码 hash;返回值永远不带 password_hash);delete(归属校验);updateStatus(快捷切换)
- 复用 hashPassword 工具:`server/src/util/crypto.ts`
- **Acceptance Criteria Addressed**: FR-2, FR-3, AC-3, AC-5
- **Test Requirements**:
- `programmatic` TR-6.1: SALESMAN 对自己客户创建部门 → 成功;对别人客户创建部门 → 403
- `programmatic` TR-6.2: 创建 account phone=13800001234,数据库 password_hash 经过 bcrypt 验证等于 '001234'(可用单元测试对比)
- `programmatic` TR-6.3: 同 phone 创建第二个 account → 返回 400 '手机号已存在'
- `programmatic` TR-6.4: list 接口响应 JSON 中不含 password_hash / wx_openid 字段
- **Notes**: bcrypt 比较使用 `bcryptjs.compare`;密码生成函数单独抽出便于单元测试
## [x] Task 7: 实现客户域 3 个 Controller
- **Priority**: high
- **Depends On**: [Task 5, Task 6]
- **Description**:
- 新建 `server/src/controller/customer.controller.ts`:
- @Controller('/api/customers');@Inject ctx + customerService
- GET '/' list(@Query() query) → service.list(query, ctx)
- GET '/:id' detail(@Param('id') id) → service.detail(id, ctx)
- POST '/' create(@Body() body) → service.create(body, ctx)
- PUT '/:id' update(@Param('id') id, @Body() body) → service.update(id, body, ctx)
- DELETE '/:id' delete(@Param('id') id) → service.delete(id, ctx)
- PUT '/:id/status' updateStatus → service.updateStatus
- 新建 `customer.department.controller.ts`:@Controller('/api/customer-departments'),同上 6 个方法
- 新建 `customer.account.controller.ts`:@Controller('/api/customer-accounts'),同上 6 个方法 + 可选 POST '/:id/reset-password'
- 所有写操作:@UseMiddleware(AuthMiddleware, RbacMiddleware) 或沿用全局注册方式
- 统一返回结构:`{ code, message, data }`,与现有 user.service 对齐
- **Acceptance Criteria Addressed**: FR-7, AC-8
- **Test Requirements**:
- `programmatic` TR-7.1: 用 curl 调 ADMIN token GET /api/customers?page=1&pageSize=10 → 返回结构 {code:200,message,data:{list,total,page,pageSize}}
- `programmatic` TR-7.2: 无 Authorization 头 → 401
- `programmatic` TR-7.3: PUT /api/customers/99999(不存在) → 404
- **Notes**: 检查全局中间件是否已经自动 apply(auth + rbac),如全局已注册则 Controller 不再重复装饰;沿用现有 `user.controller.ts` 风格
## [x] Task 8: 前端 API 封装 + 路由改造
- **Priority**: high
- **Depends On**: [Task 7]
- **Description**:
- 新建 `client/src/api/customer.ts`:`getCustomerList` / `getCustomerDetail` / `createCustomer` / `updateCustomer` / `deleteCustomer` / `updateCustomerStatus`;类型接口 CustomerInfo / CustomerQuery / CustomerCreate 等
- 新建 `client/src/api/customerDepartment.ts`:对应 CRUD + 类型
- 新建 `client/src/api/customerAccount.ts`:CRUD + `resetAccountPassword` + 类型
- 更新 `client/src/router/index.ts`:将原 `/customer` Placeholder 路由拆为 3 条嵌套子路由:`/customer/info` → views/customer/info/index.vue,`/customer/department` → views/customer/department/index.vue,`/customer/account` → views/customer/account/index.vue;父级 component 仍使用 Layout,权限 customer:list/department/account 分别设置
- **Acceptance Criteria Addressed**: FR-6, FR-7
- **Test Requirements**:
- `programmatic` TR-8.1: 登录后访问浏览器 /customer/info → 路由正确命中(非 404/403,权限验证通过时)
- `programmatic` TR-8.2: 触发 createCustomer 请求 → Network 面板 url/method/payload 正确,拦截器注入 Authorization
- **Notes**: 所有 API 方法复用 `@/utils/request` 实例(Axios 封装);类型定义 export 供页面使用
## [x] Task 9: 前端省市区数据 + 客户信息页面实现
- **Priority**: high
- **Depends On**: [Task 8]
- **Description**:
- 新建 `client/src/assets/data/region.ts`:轻量省→市→区 3 级 JSON 树(可引入开源数据或手写示例子集至少 10 省+常用市+区),导出 `regionOptions: CascaderOption[]`
- 新建 `client/src/views/customer/info/index.vue`:
- 完全对齐现有 `system/user/index.vue` 布局风格
- 搜索栏:关键词输入框 + 状态 a-select(字典 customer_status 1~4)+ 业务员 a-select(拉 sys_user 简化列表)+ 查询/重置按钮
- 表格列:客户编号(ellipsis)、客户名称、省市区(拼接显示)、详细地址、联系人、电话、客户级别、状态(a-switch)、创建时间(formatDateTime)、操作(编辑/删除)
- 弹窗:客户基本信息表单 + 省市区 a-cascader(options=regionOptions, 双向绑定数组 [province,city,district])+ 业务员下拉(仅 ADMIN 可见可改,SALESMAN 登录时隐藏/只读)+ AntD form 校验规则(名称必填、电话 regex、长度限制、XSS 关键字过滤)
- 表格内部滚动:复用 `useTableScroll` composable(wrapperRef, tableHeight)
- 增删成功后:关闭弹窗 → `searchForm.page = 1` → `fetchList()`(保留筛选条件)
- **Acceptance Criteria Addressed**: FR-6, AC-7, AC-9
- **Test Requirements**:
- `human-judgement` TR-9.1: 页面 UI 与用户管理页风格一致,表格滚动正常,弹窗表单校验生效
- `programmatic` TR-9.2: 新增客户后 Network 返回 code=200,列表自动刷新并回到 page=1,搜索栏关键词未清空
- `human-judgement` TR-9.3: 输入客户名称 `<script>alert(1)</script>` 保存后列表以纯文本显示,脚本不执行
## [x] Task 10: 客户部门 + 客户账号前端页面实现
- **Priority**: high
- **Depends On**: [Task 9]
- **Description**:
- `customer/department/index.vue`:
- 搜索栏:关键词 + 客户下拉(调用 customerApi.getCustomerList({pageSize: 999}) 取简表,ADMIN 显示全部,SALESMAN 只显示自己客户)+ 查询/重置
- 表格列:所属客户、部门名称、联系人、联系电话、职能描述、创建时间、操作
- 弹窗:客户下拉(必填)+ 部门名称(必填)+ 联系人 + 电话(regex)+ 职能描述 a-textarea
- 行级归属:SALESMAN 新增/编辑时客户下拉只显示自己客户选项
- `customer/account/index.vue`:
- 搜索栏:关键词(电话)+ 客户下拉 + 状态 + 查询/重置
- 表格列:所属客户、登录电话、状态(a-switch)、创建时间、操作(编辑/删除/重置密码按钮)
- 弹窗:客户下拉(必填)+ 登录电话(必填 regex)+ 状态;编辑表单提示「默认密码=手机号后6位」;编辑页底部提供「重置为默认密码」a-checkbox 勾选,勾选后请求体传 resetPassword:true
- 列表顶部提示:默认密码规则说明
- **Acceptance Criteria Addressed**: FR-6, AC-3, AC-5
- **Test Requirements**:
- `human-judgement` TR-10.1: SALESMAN 登录新增部门,客户下拉只显示归属自己的客户
- `programmatic` TR-10.2: 重置默认密码 → 后端更新 hash,用 bcrypt 验证对应密码正确(可后端打临时日志验证或通过 customer_account 登录接口间接测)
- `human-judgement` TR-10.3: 删除确认二次弹框正常工作
## [x] Task 11: 集成联调 + 权限/隔离验证
- **Priority**: high
- **Depends On**: [Task 7, Task 8, Task 9, Task 10]
- **Description**:
- 启动 server(midway dev)+ client(vite dev)
- 场景 1 ADMIN:客户信息/部门/账号 三模块完整 CRUD 走通一遍(新增2条 → 编辑 → 列表搜索/分页/筛选 → 删除1条 → 状态切换)
- 场景 2 SALESMAN:登录 salesman01 → list 仅见自己客户;尝试通过 Postman 越权 PUT /api/customers/<他人客户ID> → 403
- 场景 3 数据一致性:customer_id 外键删除保护、customer_no UNIQUE、customer_account phone UNIQUE 三项约束均触发成功
- 场景 4 APP 兼容:用 Postman 模拟 APP 请求(同 JWT token 同 header)→ 所有列表/写操作返回结构与浏览器一致
- 场景 5 前端 XSS:在 3 个模块的文本输入字段分别注入 `<img src=x onerror=alert(1)>` + `<script>...</script>` → 保存后列表纯文本展示不执行
- **Acceptance Criteria Addressed**: AC-1, AC-2, AC-3, AC-7, AC-8
- **Test Requirements**:
- `programmatic` TR-11.1: 5 个场景全部通过,接口无 500
- `human-judgement` TR-11.2: 浏览器 Console 无 JS 报错,XSS 注入脚本不执行
## [x] Task 12: SQL 注入安全验证 + 构建/Lint 检查
- **Priority**: medium
- **Depends On**: [Task 11]
- **Description**:
- 用 Postman 对 3 个列表接口发送 keyword = `' OR 1=1 -- ` 以及 `' OR '1'='1` → 返回结果数量与正常模糊匹配一致(可能=0),不出现全表数据泄露
- 验证后端所有 Sequelize 代码均未使用 `sequelize.query()` 拼接字符串 SQL(grep 检查)
- 运行 `cd server && yarn lint` + `cd client && yarn build` 确保无 TS 类型错误和 Lint 告警
- 运行 db_seed.sql 脚本幂等:连续执行 2 次无异常,菜单/角色数据无重复漂移
- **Acceptance Criteria Addressed**: AC-6, NFR-1, NFR-2, NFR-6
- **Test Requirements**:
- `programmatic` TR-12.1: SQL 注入测试返回 total 符合预期(无全表)
- `programmatic` TR-12.2: yarn build 两端均通过,0 Error
- `programmatic` TR-12.3: db_seed.sql 第二次执行仍成功,SELECT count(*) FROM sys_menu = 23
- **Notes**: 若有 `sequelize.query()` 用于 customer_no 生成等场景,强制使用 replacements 参数化,禁止 `\`\${var}\`\`` 字符串拼接
## Task Dependencies (DAG)
```
Task1 ─┐
Task2 ─┼─→ Task3 ─┐
Task4 ─┼─→ Task5 ─→ Task7 ─→ Task8 ─→ Task9 ─→ Task10 ─→ Task11 ─→ Task12
└─→ Task6 ──┘
```
+5670
View File
File diff suppressed because it is too large. Load diff
+92
View File
@@ -0,0 +1,92 @@
import request from '@/utils/request';
export interface CustomerInfo {
id: number;
customerNo: string;
customerName: string;
province?: string;
city?: string;
district?: string;
address?: string;
contactPerson?: string;
contactPhone?: string;
postalCode?: string;
fax?: string;
customerLevel?: string;
status: number;
salesmanId?: number;
createdAt: string;
updatedAt: string;
}
export interface CustomerQuery {
page?: number;
pageSize?: number;
keyword?: string;
status?: number;
salesmanId?: number;
}
export interface CustomerListResult {
list: CustomerInfo[];
total: number;
page: number;
pageSize: number;
}
export interface CustomerCreateParams {
customerName: string;
province?: string;
city?: string;
district?: string;
address?: string;
contactPerson?: string;
contactPhone?: string;
postalCode?: string;
fax?: string;
customerLevel?: string;
status?: number;
salesmanId?: number;
}
export interface CustomerUpdateParams {
customerName?: string;
province?: string;
city?: string;
district?: string;
address?: string;
contactPerson?: string;
contactPhone?: string;
postalCode?: string;
fax?: string;
customerLevel?: string;
status?: number;
salesmanId?: number;
}
export interface CustomerStatusParams {
status: number;
}
export const customerApi = {
getCustomerList: (params: CustomerQuery) =>
request.get<CustomerListResult>('/customers', { params }),
getCustomerDetail: (id: number) =>
request.get<CustomerInfo>(`/customers/${id}`),
getCustomerOptions: () =>
request.get<CustomerInfo[]>('/customers/options/all'),
createCustomer: (data: CustomerCreateParams) =>
request.post('/customers', data),
updateCustomer: (id: number, data: CustomerUpdateParams) =>
request.put(`/customers/${id}`, data),
deleteCustomer: (id: number) =>
request.delete(`/customers/${id}`),
updateCustomerStatus: (id: number, status: number) =>
request.put(`/customers/${id}/status`, { status }),
};
+64
View File
@@ -0,0 +1,64 @@
import request from '@/utils/request';
export interface CustomerAccountInfo {
id: number;
customerId: number;
phone: string;
status: number;
lastLoginAt?: string | null;
createdAt: string;
}
export interface AccountQuery {
page?: number;
pageSize?: number;
keyword?: string;
customerId?: number;
status?: number;
}
export interface AccountListResult {
list: CustomerAccountInfo[];
total: number;
page: number;
pageSize: number;
}
export interface AccountCreateParams {
customerId: number;
phone: string;
status?: number;
}
export interface AccountUpdateParams {
phone?: string;
status?: number;
resetPassword?: boolean;
}
export interface AccountStatusParams {
status: number;
}
export const customerAccountApi = {
getAccountList: (params: AccountQuery) =>
request.get<AccountListResult>('/customer-accounts', { params }),
getAccountDetail: (id: number) =>
request.get<CustomerAccountInfo>(`/customer-accounts/${id}`),
createAccount: (data: AccountCreateParams) =>
request.post('/customer-accounts', data),
updateAccount: (id: number, data: AccountUpdateParams) =>
request.put(`/customer-accounts/${id}`, data),
deleteAccount: (id: number) =>
request.delete(`/customer-accounts/${id}`),
updateAccountStatus: (id: number, status: number) =>
request.put(`/customer-accounts/${id}/status`, { status }),
resetAccountPassword: (id: number) =>
request.post(`/customer-accounts/${id}/reset-password`),
};
+58
View File
@@ -0,0 +1,58 @@
import request from '@/utils/request';
export interface CustomerDepartmentInfo {
id: number;
customerId: number;
deptName: string;
contactPerson?: string;
contactPhone?: string;
functionDesc?: string;
createdAt: string;
}
export interface DepartmentQuery {
page?: number;
pageSize?: number;
keyword?: string;
customerId?: number;
}
export interface DepartmentListResult {
list: CustomerDepartmentInfo[];
total: number;
page: number;
pageSize: number;
}
export interface DepartmentCreateParams {
customerId: number;
deptName: string;
contactPerson?: string;
contactPhone?: string;
functionDesc?: string;
}
export interface DepartmentUpdateParams {
customerId?: number;
deptName?: string;
contactPerson?: string;
contactPhone?: string;
functionDesc?: string;
}
export const customerDepartmentApi = {
getDepartmentList: (params: DepartmentQuery) =>
request.get<DepartmentListResult>('/customer-departments', { params }),
getDepartmentDetail: (id: number) =>
request.get<CustomerDepartmentInfo>(`/customer-departments/${id}`),
createDepartment: (data: DepartmentCreateParams) =>
request.post('/customer-departments', data),
updateDepartment: (id: number, data: DepartmentUpdateParams) =>
request.put(`/customer-departments/${id}`, data),
deleteDepartment: (id: number) =>
request.delete(`/customer-departments/${id}`),
};
File diff suppressed because it is too large. Load diff
+17 -5
View File
@@ -30,12 +30,24 @@ const routes: RouteRecordRaw[] = [
title: "仪表盘"
}
},
// 业务模块(未开发,先使用占位页;菜单 component 落地后替换为真实组件)
// 业务模块
{
path: "customer",
name: "Customer",
component: () => import("@/views/Placeholder.vue"),
meta: { requiresAuth: true, title: "客户管理", permission: "customer:list" }
path: "customer/info",
name: "CustomerInfo",
component: () => import("@/views/customer/info/index.vue"),
meta: { requiresAuth: true, title: "客户信息", permission: "customer:list" }
},
{
path: "customer/department",
name: "CustomerDept",
component: () => import("@/views/customer/department/index.vue"),
meta: { requiresAuth: true, title: "客户部门", permission: "customer:department" }
},
{
path: "customer/account",
name: "CustomerAccount",
component: () => import("@/views/customer/account/index.vue"),
meta: { requiresAuth: true, title: "客户账号", permission: "customer:account" }
},
{
path: "project",
+459
View File
@@ -0,0 +1,459 @@
<template>
<div class="customer-account">
<a-card title="客户账号管理">
<template #extra>
<a-space>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增账号
</a-button>
</a-space>
</template>
<a-alert
type="info"
message="初始密码规则:默认密码为手机号后 6 位,如手机号不足 6 位则为 123456;编辑时可勾选重置为默认密码"
style="margin-bottom: 16px"
/>
<div class="search-bar">
<a-input
v-model:value="searchForm.keyword"
placeholder="登录手机号"
allow-clear
style="width: 220px"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-select
v-model:value="searchForm.customerId"
placeholder="所属客户"
allow-clear
style="width: 220px"
:options="customerOptions"
/>
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 120px">
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'customerId'">
{{ customerNameMap[record.customerId] || '-' }}
</template>
<template v-if="column.key === 'status'">
<a-switch
:checked="record.status === 1"
checked-children="启用"
un-checked-children="禁用"
@change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)"
/>
</template>
<template v-if="column.key === 'createdAt'">
{{ formatDateTime(record.createdAt) }}
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm title="确定要重置该账号的密码吗?" ok-text="确定" cancel-text="取消" @confirm="handleResetPassword(record.id)">
<a-button type="link" size="small">
<template #icon><KeyOutlined /></template>
重置密码
</a-button>
</a-popconfirm>
<a-popconfirm title="确定要删除该账号吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无账号数据" />
</template>
</a-table>
</div>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="600px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="所属客户" name="customerId">
<a-select
v-model:value="formData.customerId"
placeholder="请选择所属客户"
:options="customerOptions"
/>
</a-form-item>
<a-form-item label="登录电话" name="phone">
<a-input v-model:value="formData.phone" placeholder="请输入登录电话" maxlength="20" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="isEdit">
<a-checkbox v-model:checked="formData.resetPassword">重置为默认密码(手机号后 6 位)</a-checkbox>
<div style="font-size: 12px; color: #999; margin-top: 4px; padding-left: 24px">
勾选后保存即重置,下次登录请用新密码
</div>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
KeyOutlined,
} from '@ant-design/icons-vue';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { customerAccountApi, type CustomerAccountInfo, type AccountQuery } from '@/api/customerAccount';
import { useTableScroll } from '@/composables/useTableScroll';
import dayjs from 'dayjs';
const { wrapperRef, tableHeight } = useTableScroll();
const loading = ref(false);
const dataSource = ref<CustomerAccountInfo[]>([]);
const total = ref(0);
const searchForm = reactive<AccountQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as any,
status: undefined as any,
});
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const customerOptions = ref<{ label: string; value: number }[]>([]);
const customerNameMap = reactive<Record<number, string>>({});
const formData = reactive({
customerId: undefined as number | undefined,
phone: '',
status: 1,
resetPassword: false,
});
const formRules = {
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
phone: [
{ required: true, message: '请输入登录电话', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
};
const columns = [
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '登录电话', dataIndex: 'phone', key: 'phone', ellipsis: true, width: 140 },
{
title: '状态',
key: 'status',
align: 'center' as const,
width: 100,
},
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', ellipsis: true, width: 180 },
{ title: '操作', key: 'action', width: 260, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
async function fetchList() {
loading.value = true;
try {
const res: any = await customerAccountApi.getAccountList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取账号列表失败');
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
const list: CustomerInfo[] = res.data;
customerOptions.value = list.map((item) => ({
label: `${item.customerNo} ${item.customerName}`,
value: item.id,
}));
list.forEach((item) => {
customerNameMap[item.id] = `${item.customerNo} ${item.customerName}`;
});
}
} catch (error) {
console.error('获取客户选项失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined as any;
searchForm.status = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
function handleAdd() {
isEdit.value = false;
editId.value = null;
modalTitle.value = '新增账号';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: CustomerAccountInfo) {
isEdit.value = true;
editId.value = record.id;
modalTitle.value = '编辑账号';
resetForm();
formData.customerId = record.customerId;
formData.phone = record.phone;
formData.status = record.status;
formData.resetPassword = false;
modalVisible.value = true;
}
function resetForm() {
formData.customerId = undefined;
formData.phone = '';
formData.status = 1;
formData.resetPassword = false;
if (formRef.value) {
formRef.value.resetFields();
}
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
let res: any;
if (isEdit.value && editId.value) {
const updateData: any = {
phone: formData.phone,
status: formData.status,
};
if (formData.resetPassword) {
updateData.resetPassword = true;
}
res = await customerAccountApi.updateAccount(editId.value, updateData);
} else {
res = await customerAccountApi.createAccount({
customerId: formData.customerId as number,
phone: formData.phone,
status: formData.status,
});
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await customerAccountApi.deleteAccount(id);
if (res.code === 200) {
message.success('删除成功');
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
async function handleStatusChange(id: number, status: number) {
try {
const res: any = await customerAccountApi.updateAccountStatus(id, status);
if (res.code === 200) {
message.success(status === 1 ? '已启用' : '已禁用');
fetchList();
} else {
message.error(res.message || '状态更新失败');
}
} catch (error) {
message.error('状态更新失败');
}
}
async function handleResetPassword(id: number) {
try {
const res: any = await customerAccountApi.resetAccountPassword(id);
if (res.code === 200) {
message.success('密码已重置为手机号后6位');
} else {
message.error(res.message || '密码重置失败');
}
} catch (error) {
message.error('密码重置失败');
}
}
function formatDateTime(dateStr: string) {
if (!dateStr) return '-';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
</script>
<style scoped>
.customer-account {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
box-sizing: border-box;
}
:deep(.ant-card) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
:deep(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.search-bar {
flex-shrink: 0;
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
</style>
@@ -0,0 +1,401 @@
<template>
<div class="customer-department">
<a-card title="部门管理">
<template #extra>
<a-space>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增部门
</a-button>
</a-space>
</template>
<div class="search-bar">
<a-input
v-model:value="searchForm.keyword"
placeholder="部门名称/联系人/电话"
allow-clear
style="width: 220px"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-select
v-model:value="searchForm.customerId"
placeholder="所属客户"
allow-clear
style="width: 220px"
:options="customerOptions"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'customerId'">
{{ customerNameMap[record.customerId] || '-' }}
</template>
<template v-if="column.key === 'createdAt'">
{{ formatDateTime(record.createdAt) }}
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm title="确定要删除该部门吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无部门数据" />
</template>
</a-table>
</div>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="600px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="所属客户" name="customerId">
<a-select
v-model:value="formData.customerId"
placeholder="请选择所属客户"
:options="customerOptions"
/>
</a-form-item>
<a-form-item label="部门名称" name="deptName">
<a-input v-model:value="formData.deptName" placeholder="请输入部门名称" maxlength="64" />
</a-form-item>
<a-form-item label="联系人" name="contactPerson">
<a-input v-model:value="formData.contactPerson" placeholder="请输入联系人" maxlength="32" />
</a-form-item>
<a-form-item label="联系电话" name="contactPhone">
<a-input v-model:value="formData.contactPhone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="职能描述" name="functionDesc">
<a-textarea v-model:value="formData.functionDesc" placeholder="请输入职能描述" :rows="3" maxlength="256" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { customerDepartmentApi, type CustomerDepartmentInfo, type DepartmentQuery } from '@/api/customerDepartment';
import { useTableScroll } from '@/composables/useTableScroll';
import dayjs from 'dayjs';
const { wrapperRef, tableHeight } = useTableScroll();
const loading = ref(false);
const dataSource = ref<CustomerDepartmentInfo[]>([]);
const total = ref(0);
const searchForm = reactive<DepartmentQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as any,
});
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const customerOptions = ref<{ label: string; value: number }[]>([]);
const customerNameMap = reactive<Record<number, string>>({});
const formData = reactive({
customerId: undefined as number | undefined,
deptName: '',
contactPerson: '',
contactPhone: '',
functionDesc: '',
});
const formRules = {
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
deptName: [{ required: true, message: '请输入部门名称', trigger: 'blur' }],
contactPhone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
};
const columns = [
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '部门名称', dataIndex: 'deptName', key: 'deptName', ellipsis: true },
{ title: '联系人', dataIndex: 'contactPerson', key: 'contactPerson', ellipsis: true, width: 120 },
{ title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', ellipsis: true, width: 140 },
{ title: '职能描述', dataIndex: 'functionDesc', key: 'functionDesc', ellipsis: true },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', ellipsis: true, width: 180 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
async function fetchList() {
loading.value = true;
try {
const res: any = await customerDepartmentApi.getDepartmentList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取部门列表失败');
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
const list: CustomerInfo[] = res.data;
customerOptions.value = list.map((item) => ({
label: `${item.customerNo} ${item.customerName}`,
value: item.id,
}));
list.forEach((item) => {
customerNameMap[item.id] = `${item.customerNo} ${item.customerName}`;
});
}
} catch (error) {
console.error('获取客户选项失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
function handleAdd() {
isEdit.value = false;
editId.value = null;
modalTitle.value = '新增部门';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: CustomerDepartmentInfo) {
isEdit.value = true;
editId.value = record.id;
modalTitle.value = '编辑部门';
resetForm();
formData.customerId = record.customerId;
formData.deptName = record.deptName;
formData.contactPerson = record.contactPerson || '';
formData.contactPhone = record.contactPhone || '';
formData.functionDesc = record.functionDesc || '';
modalVisible.value = true;
}
function resetForm() {
formData.customerId = undefined;
formData.deptName = '';
formData.contactPerson = '';
formData.contactPhone = '';
formData.functionDesc = '';
if (formRef.value) {
formRef.value.resetFields();
}
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
let res: any;
if (isEdit.value && editId.value) {
res = await customerDepartmentApi.updateDepartment(editId.value, {
customerId: formData.customerId,
deptName: formData.deptName,
contactPerson: formData.contactPerson,
contactPhone: formData.contactPhone,
functionDesc: formData.functionDesc,
});
} else {
res = await customerDepartmentApi.createDepartment({
customerId: formData.customerId as number,
deptName: formData.deptName,
contactPerson: formData.contactPerson,
contactPhone: formData.contactPhone,
functionDesc: formData.functionDesc,
});
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await customerDepartmentApi.deleteDepartment(id);
if (res.code === 200) {
message.success('删除成功');
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
function formatDateTime(dateStr: string) {
if (!dateStr) return '-';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
</script>
<style scoped>
.customer-department {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
box-sizing: border-box;
}
:deep(.ant-card) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
:deep(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.search-bar {
flex-shrink: 0;
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
</style>
+496
View File
@@ -0,0 +1,496 @@
<template>
<div class="customer-management">
<a-card title="客户信息管理">
<template #extra>
<a-space>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增客户
</a-button>
</a-space>
</template>
<div class="search-bar">
<a-input
v-model:value="searchForm.keyword"
placeholder="客户名称/联系人/电话"
allow-clear
style="width: 240px"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-select v-model:value="searchForm.status" placeholder="客户状态" allow-clear style="width: 140px">
<a-select-option :value="1">意向</a-select-option>
<a-select-option :value="2">签约</a-select-option>
<a-select-option :value="3">运维中</a-select-option>
<a-select-option :value="4">终止</a-select-option>
</a-select>
<a-select
v-if="isAdmin"
v-model:value="searchForm.salesmanId"
placeholder="负责业务员"
allow-clear
style="width: 160px"
:options="salesmanOptions"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'region'">
{{ [record.province, record.city, record.district].filter(Boolean).join(' / ') }}
</template>
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-if="column.key === 'createdAt'">
{{ formatDateTime(record.createdAt) }}
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm title="确定要删除该客户吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无客户数据" />
</template>
</a-table>
</div>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="680px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="客户名称" name="customerName">
<a-input v-model:value="formData.customerName" placeholder="请输入客户名称" maxlength="128" />
</a-form-item>
<a-form-item label="省市区" name="region">
<a-cascader
v-model:value="formData.region"
:options="regionOptions"
placeholder="请选择省市区"
change-on-select
/>
</a-form-item>
<a-form-item label="详细地址" name="address">
<a-input v-model:value="formData.address" placeholder="请输入详细地址" maxlength="256" />
</a-form-item>
<a-form-item label="联系人" name="contactPerson">
<a-input v-model:value="formData.contactPerson" placeholder="请输入联系人" maxlength="32" />
</a-form-item>
<a-form-item label="联系电话" name="contactPhone">
<a-input v-model:value="formData.contactPhone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="邮编" name="postalCode">
<a-input v-model:value="formData.postalCode" placeholder="请输入邮编" maxlength="10" />
</a-form-item>
<a-form-item label="传真" name="fax">
<a-input v-model:value="formData.fax" placeholder="请输入传真" maxlength="32" />
</a-form-item>
<a-form-item label="客户级别" name="customerLevel">
<a-input v-model:value="formData.customerLevel" placeholder="请输入客户级别" maxlength="32" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">意向</a-radio>
<a-radio :value="2">签约</a-radio>
<a-radio :value="3">运维中</a-radio>
<a-radio :value="4">终止</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="isAdmin" label="负责业务员" name="salesmanId">
<a-select
v-model:value="formData.salesmanId"
placeholder="请选择负责业务员"
:options="salesmanOptions"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import { customerApi, type CustomerInfo, type CustomerQuery } from '@/api/customer';
import { userApi } from '@/api/user';
import { useTableScroll } from '@/composables/useTableScroll';
import { usePermissionStore } from '@/stores/permission';
import { regionOptions } from '@/assets/data/region';
import dayjs from 'dayjs';
const { wrapperRef, tableHeight } = useTableScroll();
const permissionStore = usePermissionStore();
const loading = ref(false);
const dataSource = ref<CustomerInfo[]>([]);
const total = ref(0);
const searchForm = reactive<CustomerQuery>({
page: 1,
pageSize: 10,
keyword: '',
status: undefined as any,
salesmanId: undefined as any,
});
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const salesmanOptions = ref<{ label: string; value: number }[]>([]);
const isAdmin = computed(() => permissionStore.hasRole('ADMIN'));
const formData = reactive({
customerName: '',
region: [] as string[],
address: '',
contactPerson: '',
contactPhone: '',
postalCode: '',
fax: '',
customerLevel: '',
status: 1,
salesmanId: undefined as number | undefined,
});
const formRules = {
customerName: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
region: [{ required: true, message: '请选择省市区', trigger: 'change', type: 'array' as const }],
contactPhone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
};
const columns = [
{ title: '客户编号', dataIndex: 'customerNo', key: 'customerNo', ellipsis: true, width: 140 },
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', ellipsis: true },
{ title: '省市区', key: 'region' },
{ title: '详细地址', dataIndex: 'address', key: 'address', ellipsis: true },
{ title: '联系人', dataIndex: 'contactPerson', key: 'contactPerson' },
{ title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', ellipsis: true },
{ title: '客户级别', dataIndex: 'customerLevel', key: 'customerLevel', ellipsis: true },
{ title: '状态', key: 'status', align: 'center' as const, width: 100 },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', ellipsis: true },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchList();
if (isAdmin.value) {
fetchSalesmanList();
}
});
async function fetchList() {
loading.value = true;
try {
const res: any = await customerApi.getCustomerList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取客户列表失败');
} finally {
loading.value = false;
}
}
async function fetchSalesmanList() {
try {
const res: any = await userApi.getUserList({ page: 1, pageSize: 999 });
if (res.code === 200) {
salesmanOptions.value = res.data.list.map((item: any) => ({
label: item.realName,
value: item.id,
}));
}
} catch (error) {
console.error('获取业务员列表失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.status = undefined as any;
searchForm.salesmanId = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
function handleAdd() {
isEdit.value = false;
editId.value = null;
modalTitle.value = '新增客户';
resetForm();
formData.status = 1;
formData.region = [];
modalVisible.value = true;
}
function handleEdit(record: CustomerInfo) {
isEdit.value = true;
editId.value = record.id;
modalTitle.value = '编辑客户';
resetForm();
formData.customerName = record.customerName;
formData.region = [record.province, record.city, record.district].filter(Boolean) as string[];
formData.address = record.address || '';
formData.contactPerson = record.contactPerson || '';
formData.contactPhone = record.contactPhone || '';
formData.postalCode = record.postalCode || '';
formData.fax = record.fax || '';
formData.customerLevel = record.customerLevel || '';
formData.status = record.status;
formData.salesmanId = record.salesmanId;
modalVisible.value = true;
}
function resetForm() {
formData.customerName = '';
formData.region = [];
formData.address = '';
formData.contactPerson = '';
formData.contactPhone = '';
formData.postalCode = '';
formData.fax = '';
formData.customerLevel = '';
formData.status = 1;
formData.salesmanId = undefined;
if (formRef.value) {
formRef.value.resetFields();
}
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const submitData: any = {
customerName: formData.customerName,
province: formData.region[0] || undefined,
city: formData.region[1] || undefined,
district: formData.region[2] || undefined,
address: formData.address,
contactPerson: formData.contactPerson,
contactPhone: formData.contactPhone,
postalCode: formData.postalCode,
fax: formData.fax,
customerLevel: formData.customerLevel,
status: formData.status,
};
if (isAdmin.value && formData.salesmanId !== undefined) {
submitData.salesmanId = formData.salesmanId;
}
let res: any;
if (isEdit.value && editId.value) {
res = await customerApi.updateCustomer(editId.value, submitData);
} else {
res = await customerApi.createCustomer(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await customerApi.deleteCustomer(id);
if (res.code === 200) {
message.success('删除成功');
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
function getStatusColor(status: number): string {
const colorMap: Record<number, string> = {
1: 'blue',
2: 'green',
3: 'orange',
4: 'default',
};
return colorMap[status] || 'default';
}
function getStatusText(status: number): string {
const textMap: Record<number, string> = {
1: '意向',
2: '签约',
3: '运维中',
4: '终止',
};
return textMap[status] || '未知';
}
function formatDateTime(dateStr: string) {
if (!dateStr) return '-';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
</script>
<style scoped>
.customer-management {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
box-sizing: border-box;
}
:deep(.ant-card) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
:deep(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.search-bar {
flex-shrink: 0;
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
</style>
+410 -633
View File
File diff suppressed because it is too large. Load diff
+32 -5
View File
@@ -46,7 +46,7 @@ SELECT setval('sys_role_id_seq', 4, true);
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理为目录类型 menu_type=1,其余为 menu_type=2)
-- ------------------------------------------------------------
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(1, NULL, '客户管理', '/customer', NULL, 'TeamOutlined', 1, 2, 'customer:list', 1),
(1, NULL, '客户管理', '/customer', NULL, 'TeamOutlined', 1, 1, 'customer:list', 1),
(2, NULL, '项目管理', '/project', NULL, 'ProjectOutlined', 2, 2, 'project:list', 1),
(3, NULL, '运营派单', '/dispatch', NULL, 'ScheduleOutlined', 3, 2, 'dispatch:list', 1),
(4, NULL, '设备监控', '/device', NULL, 'MonitorOutlined', 4, 2, 'device:list', 1),
@@ -81,8 +81,33 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 初始菜单(客户管理子菜单,parent_id=1,显式指定 id:12-23)
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(12, 1, '客户信息', '/customer/info', 'customer/info/index', 'UserOutlined', 1, 2, 'customer:list', 1),
(13, 1, '客户部门', '/customer/department', 'customer/department/index', 'TeamOutlined', 2, 2, 'customer:department', 1),
(14, 1, '客户账号', '/customer/account', 'customer/account/index', 'SafetyOutlined', 3, 2, 'customer:account', 1),
(15, 1, '新增客户', NULL, NULL, NULL, 1, 3, 'customer:create', 1),
(16, 1, '编辑客户', NULL, NULL, NULL, 2, 3, 'customer:update', 1),
(17, 1, '删除客户', NULL, NULL, NULL, 3, 3, 'customer:delete', 1),
(18, 1, '新增部门', NULL, NULL, NULL, 4, 3, 'department:create', 1),
(19, 1, '编辑部门', NULL, NULL, NULL, 5, 3, 'department:update', 1),
(20, 1, '删除部门', NULL, NULL, NULL, 6, 3, 'department:delete', 1),
(21, 1, '新增账号', NULL, NULL, NULL, 7, 3, 'account:create', 1),
(22, 1, '编辑账号', NULL, NULL, NULL, 8, 3, 'account:update', 1),
(23, 1, '删除账号', NULL, NULL, NULL, 9, 3, 'account:delete', 1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
menu_path = EXCLUDED.menu_path,
component = EXCLUDED.component,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
menu_type = EXCLUDED.menu_type,
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 重置 sys_menu 自增序列
SELECT setval('sys_menu_id_seq', 11, true);
SELECT setval('sys_menu_id_seq', 23, true);
-- ------------------------------------------------------------
-- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行)
@@ -135,16 +160,18 @@ INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1)
ON CONFLICT (user_id, role_id) DO NOTHING;
-- ------------------------------------------------------------
-- 6. 角色-菜单绑定:ADMIN 拥有全部 11 条菜单,SALESMAN 拥有业务菜单(客户/项目/统计报表)
-- 6. 角色-菜单绑定:ADMIN 拥有全部 23 条菜单,SALESMAN 拥有客户域+项目+统计报表
-- WORKER、CUSTOMER 不绑定任何菜单
-- ------------------------------------------------------------
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11),
(2, 1), (2, 2), (2, 5)
(1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23),
(2, 1), (2, 2), (2, 5),
(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23)
ON CONFLICT (role_id, menu_id) DO NOTHING;
COMMIT;
-- ============================================================
-- 完 · 7 张系统表种子数据(4 角色 + 11 菜单 + 20 字典 + 1 管理员 + 绑定关系)
-- 完 · 7 张系统表种子数据(4 角色 + 23 菜单 + 20 字典 + 1 管理员 + 绑定关系)
-- ============================================================
+24
View File
@@ -0,0 +1,24 @@
// 解码 JWT token 看 payload
const jwt = require('jsonwebtoken');
const token = process.argv[2];
if (!token) {
console.error('请提供 token 作为参数');
process.exit(1);
}
const decoded = jwt.decode(token);
console.log('=== Token Payload ===');
console.log(JSON.stringify(decoded, null, 2));
// 用 config 里的 secret 尝试验证
const secret = 'smart-grid-iot-admin-jwt-secret-key-2024';
try {
const verified = jwt.verify(token, secret);
console.log('\n=== Verify 成功 ===');
console.log(JSON.stringify(verified, null, 2));
} catch (err) {
console.log('\n=== Verify 失败 ===');
console.log('Error name:', err.name);
console.log('Error message:', err.message);
}
+24
View File
@@ -0,0 +1,24 @@
// 解码 JWT token 看 payload
const jwt = require('jsonwebtoken');
const token = process.argv[2];
if (!token) {
console.error('请提供 token 作为参数');
process.exit(1);
}
const decoded = jwt.decode(token);
console.log('=== Token Payload ===');
console.log(JSON.stringify(decoded, null, 2));
// 用 config 里的 secret 尝试验证
const secret = 'smart-grid-iot-admin-jwt-secret-key-2024';
try {
const verified = jwt.verify(token, secret);
console.log('\n=== Verify 成功 ===');
console.log(JSON.stringify(verified, null, 2));
} catch (err) {
console.log('\n=== Verify 失败 ===');
console.log('Error name:', err.name);
console.log('Error message:', err.message);
}
@@ -0,0 +1,54 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { CustomerAccountService } from "../service/customer.account.service";
@Controller("/api/customer-accounts")
export class CustomerAccountController {
@Inject()
ctx!: Context;
@Inject()
customerAccountService!: CustomerAccountService;
@Get("/")
async list(@Query() query: any) {
const result = await this.customerAccountService.list(query, this.ctx);
return result;
}
@Get("/:id")
async detail(@Param("id") id: string) {
const result = await this.customerAccountService.detail(Number(id), this.ctx);
return result;
}
@Post("/")
async create(@Body() body: any) {
const result = await this.customerAccountService.create(body, this.ctx);
return result;
}
@Put("/:id")
async update(@Param("id") id: string, @Body() body: any) {
const result = await this.customerAccountService.update(Number(id), body, this.ctx);
return result;
}
@Del("/:id")
async delete(@Param("id") id: string) {
const result = await this.customerAccountService.delete(Number(id), this.ctx);
return result;
}
@Put("/:id/status")
async updateStatus(@Param("id") id: string, @Body() body: { status: number }) {
const result = await this.customerAccountService.updateStatus(Number(id), body.status, this.ctx);
return result;
}
@Post("/:id/reset-password")
async resetPassword(@Param("id") id: string) {
const result = await this.customerAccountService.resetPassword(Number(id), this.ctx);
return result;
}
}
@@ -0,0 +1,54 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { CustomerService } from "../service/customer.service";
@Controller("/api/customers")
export class CustomerController {
@Inject()
ctx!: Context;
@Inject()
customerService!: CustomerService;
@Get("/")
async list(@Query() query: any) {
const result = await this.customerService.list(query, this.ctx);
return result;
}
@Get("/options/all")
async getSimpleList() {
const result = await this.customerService.getSimpleList(this.ctx);
return result;
}
@Get("/:id")
async detail(@Param("id") id: string) {
const result = await this.customerService.detail(Number(id), this.ctx);
return result;
}
@Post("/")
async create(@Body() body: any) {
const result = await this.customerService.create(body, this.ctx);
return result;
}
@Put("/:id")
async update(@Param("id") id: string, @Body() body: any) {
const result = await this.customerService.update(Number(id), body, this.ctx);
return result;
}
@Del("/:id")
async delete(@Param("id") id: string) {
const result = await this.customerService.delete(Number(id), this.ctx);
return result;
}
@Put("/:id/status")
async updateStatus(@Param("id") id: string, @Body() body: { status: number }) {
const result = await this.customerService.updateStatus(Number(id), body.status, this.ctx);
return result;
}
}
@@ -0,0 +1,42 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { CustomerDepartmentService } from "../service/customer.department.service";
@Controller("/api/customer-departments")
export class CustomerDepartmentController {
@Inject()
ctx!: Context;
@Inject()
customerDepartmentService!: CustomerDepartmentService;
@Get("/")
async list(@Query() query: any) {
const result = await this.customerDepartmentService.list(query, this.ctx);
return result;
}
@Get("/:id")
async detail(@Param("id") id: string) {
const result = await this.customerDepartmentService.detail(Number(id), this.ctx);
return result;
}
@Post("/")
async create(@Body() body: any) {
const result = await this.customerDepartmentService.create(body, this.ctx);
return result;
}
@Put("/:id")
async update(@Param("id") id: string, @Body() body: any) {
const result = await this.customerDepartmentService.update(Number(id), body, this.ctx);
return result;
}
@Del("/:id")
async delete(@Param("id") id: string) {
const result = await this.customerDepartmentService.delete(Number(id), this.ctx);
return result;
}
}
+60
View File
@@ -0,0 +1,60 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, IsIn, Matches, Min, Max, MaxLength } from "class-validator";
export class CustomerAccountQueryDTO {
@IsOptional()
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@IsInt()
@Min(1)
@Max(200, { message: '分页大小不能超过 200 条' })
pageSize?: number = 10;
@IsOptional()
@IsString()
@MaxLength(32)
keyword?: string;
@IsOptional()
@IsInt()
customerId?: number;
@IsOptional()
@IsIn([0, 1])
status?: number;
}
export class CustomerAccountCreateDTO {
@IsNotEmpty({ message: '所属客户不能为空' })
@IsInt()
customerId!: number;
@IsNotEmpty({ message: '手机号不能为空' })
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
phone!: string;
@IsOptional()
@IsIn([0, 1])
status?: number;
}
export class CustomerAccountUpdateDTO {
@IsOptional()
@IsIn([0, 1])
status?: number;
@IsOptional()
resetPassword?: boolean;
@IsOptional()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
phone?: string;
}
export class CustomerAccountUpdateStatusDTO {
@IsNotEmpty({ message: '状态不能为空' })
@IsIn([0, 1])
status!: number;
}
+81
View File
@@ -0,0 +1,81 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, Matches, Min, Max, MinLength, MaxLength } from "class-validator";
export class CustomerDepartmentQueryDTO {
@IsOptional()
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@IsInt()
@Min(1)
@Max(200, { message: '分页大小不能超过 200 条' })
pageSize?: number = 10;
@IsOptional()
@IsString()
@MaxLength(64)
keyword?: string;
@IsOptional()
@IsInt()
customerId?: number;
}
export class CustomerDepartmentCreateDTO {
@IsNotEmpty({ message: '所属客户不能为空' })
@IsInt()
customerId!: number;
@IsNotEmpty({ message: '部门名称不能为空' })
@IsString()
@MinLength(2)
@MaxLength(64)
@Matches(/^[^<>]*$/, { message: '部门名称不允许包含尖括号' })
deptName!: string;
@IsOptional()
@IsString()
@MaxLength(32)
@Matches(/^[^<>]*$/, { message: '联系人不允许包含尖括号' })
contactPerson?: string;
@IsOptional()
@Matches(/^1[3-9]\d{9}$/, { message: '联系电话格式不正确' })
contactPhone?: string;
@IsOptional()
@IsString()
@MaxLength(256)
@Matches(/^[^<>]*$/, { message: '职能描述不允许包含尖括号' })
functionDesc?: string;
}
export class CustomerDepartmentUpdateDTO {
@IsOptional()
@IsInt()
customerId?: number;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(64)
@Matches(/^[^<>]*$/, { message: '部门名称不允许包含尖括号' })
deptName?: string;
@IsOptional()
@IsString()
@MaxLength(32)
@Matches(/^[^<>]*$/, { message: '联系人不允许包含尖括号' })
contactPerson?: string;
@IsOptional()
@Matches(/^1[3-9]\d{9}$/, { message: '联系电话格式不正确' })
contactPhone?: string;
@IsOptional()
@IsString()
@MaxLength(256)
@Matches(/^[^<>]*$/, { message: '职能描述不允许包含尖括号' })
functionDesc?: string;
}
+153
View File
@@ -0,0 +1,153 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, IsIn, Matches, Min, Max, MinLength, MaxLength } from "class-validator";
export class CustomerQueryDTO {
@IsOptional()
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@IsInt()
@Min(1)
@Max(200, { message: '分页大小不能超过 200 条' })
pageSize?: number = 10;
@IsOptional()
@IsString()
@MaxLength(64, { message: '关键词最大 64 字符' })
keyword?: string;
@IsOptional()
@IsIn([1, 2, 3, 4], { message: '客户状态值非法' })
status?: number;
@IsOptional()
@IsInt()
salesmanId?: number;
}
export class CustomerCreateDTO {
@IsNotEmpty({ message: '客户名称不能为空' })
@IsString()
@MinLength(2)
@MaxLength(128)
@Matches(/^[^<>]*$/, { message: '客户名称不允许包含尖括号' })
customerName!: string;
@IsOptional()
@IsString()
@MaxLength(32)
province?: string;
@IsOptional()
@IsString()
@MaxLength(32)
city?: string;
@IsOptional()
@IsString()
@MaxLength(32)
district?: string;
@IsOptional()
@IsString()
@MaxLength(256)
@Matches(/^[^<>]*$/, { message: '地址不允许包含尖括号' })
address?: string;
@IsOptional()
@IsString()
@MaxLength(32)
@Matches(/^[^<>]*$/, { message: '联系人不允许包含尖括号' })
contactPerson?: string;
@IsOptional()
@Matches(/^1[3-9]\d{9}$/, { message: '联系电话格式不正确' })
contactPhone?: string;
@IsOptional()
@Matches(/^\d{0,10}$/, { message: '邮编格式不正确' })
postalCode?: string;
@IsOptional()
@IsString()
@MaxLength(32)
fax?: string;
@IsOptional()
@IsString()
@MaxLength(32)
@Matches(/^[^<>]*$/, { message: '客户级别不允许包含尖括号' })
customerLevel?: string;
@IsOptional()
@IsIn([1, 2, 3, 4])
status?: number;
@IsOptional()
@IsInt()
salesmanId?: number;
}
export class CustomerUpdateDTO {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(128)
@Matches(/^[^<>]*$/, { message: '客户名称不允许包含尖括号' })
customerName?: string;
@IsOptional()
@IsString()
@MaxLength(32)
province?: string;
@IsOptional()
@IsString()
@MaxLength(32)
city?: string;
@IsOptional()
@IsString()
@MaxLength(32)
district?: string;
@IsOptional()
@IsString()
@MaxLength(256)
@Matches(/^[^<>]*$/, { message: '地址不允许包含尖括号' })
address?: string;
@IsOptional()
@IsString()
@MaxLength(32)
@Matches(/^[^<>]*$/, { message: '联系人不允许包含尖括号' })
contactPerson?: string;
@IsOptional()
@Matches(/^1[3-9]\d{9}$/, { message: '联系电话格式不正确' })
contactPhone?: string;
@IsOptional()
@Matches(/^\d{0,10}$/, { message: '邮编格式不正确' })
postalCode?: string;
@IsOptional()
@IsString()
@MaxLength(32)
fax?: string;
@IsOptional()
@IsString()
@MaxLength(32)
@Matches(/^[^<>]*$/, { message: '客户级别不允许包含尖括号' })
customerLevel?: string;
@IsOptional()
@IsIn([1, 2, 3, 4])
status?: number;
@IsOptional()
@IsInt()
salesmanId?: number;
}
+126
View File
@@ -0,0 +1,126 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, HasMany } from "sequelize-typescript";
import CustomerDepartment from "./customer_department.entity";
import CustomerAccount from "./customer_account.entity";
@Table({
tableName: "customer",
comment: "客户信息表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{ name: "idx_customer_name", fields: ["customer_name"] },
{ name: "idx_customer_salesman", fields: ["salesman_id"] },
{ name: "idx_customer_status", fields: ["status"] },
{ unique: true, name: "uk_customer_no", fields: ["customer_no"] }
]
})
export default class Customer extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@Column({
type: DataType.STRING(64),
allowNull: false,
unique: true,
field: "customer_no",
comment: "客户业务编号(KH+YYYYMMDD+序号)"
})
customerNo!: string;
@Column({
type: DataType.STRING(128),
allowNull: false,
comment: "客户名称"
})
customerName!: string;
@Column({
type: DataType.STRING(32),
comment: "省"
})
province!: string;
@Column({
type: DataType.STRING(32),
comment: "市"
})
city!: string;
@Column({
type: DataType.STRING(32),
comment: "区"
})
district!: string;
@Column({
type: DataType.STRING(256),
comment: "详细地址"
})
address!: string;
@Column({
type: DataType.STRING(32),
field: "contact_person",
comment: "联系人"
})
contactPerson!: string;
@Column({
type: DataType.STRING(20),
field: "contact_phone",
comment: "联系电话"
})
contactPhone!: string;
@Column({
type: DataType.STRING(10),
field: "postal_code",
comment: "邮编"
})
postalCode!: string;
@Column({
type: DataType.STRING(32),
comment: "传真"
})
fax!: string;
@Column({
type: DataType.STRING(32),
field: "customer_level",
comment: "客户级别(学校/医院/企业等)"
})
customerLevel!: string;
@Column({
type: DataType.SMALLINT,
allowNull: false,
defaultValue: 1,
comment: "状态:1意向/2签约/3运维中/4终止"
})
status!: number;
@Column({
type: DataType.BIGINT,
field: "salesman_id",
comment: "负责业务员(关联sys_user.id),references->sys_user.id"
})
salesmanId!: number;
@HasMany(() => CustomerDepartment, { foreignKey: "customerId" })
customerDepartments!: CustomerDepartment[];
@HasMany(() => CustomerAccount, { foreignKey: "customerId" })
customerAccounts!: CustomerAccount[];
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
@@ -0,0 +1,78 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, BelongsTo, ForeignKey } from "sequelize-typescript";
import Customer from "./customer.entity";
@Table({
tableName: "customer_account",
comment: "客户访问账号表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{ name: "idx_account_customer", fields: ["customer_id"] },
{ unique: true, name: "uk_account_phone", fields: ["phone"] }
]
})
export default class CustomerAccount extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@ForeignKey(() => Customer)
@Column({
type: DataType.BIGINT,
allowNull: false,
field: "customer_id",
comment: "所属客户ID"
})
customerId!: number;
@Column({
type: DataType.STRING(20),
allowNull: false,
unique: true,
comment: "登录手机号(账号)"
})
phone!: string;
@Column({
type: DataType.STRING(256),
allowNull: false,
field: "password_hash",
comment: "bcrypt密码哈希"
})
passwordHash!: string;
@Column({
type: DataType.STRING(128),
field: "wx_openid",
comment: "微信openid"
})
wxOpenid!: string;
@Column({
type: DataType.SMALLINT,
allowNull: false,
defaultValue: 1,
comment: "状态:0禁用/1启用"
})
status!: number;
@Column({
type: DataType.DATE,
field: "last_login_at",
comment: "最后登录时间"
})
lastLoginAt!: Date;
@BelongsTo(() => Customer)
customer!: Customer;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
@@ -0,0 +1,68 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, BelongsTo, ForeignKey } from "sequelize-typescript";
import Customer from "./customer.entity";
@Table({
tableName: "customer_department",
comment: "客户部门表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{ name: "idx_dept_customer", fields: ["customer_id"] }
]
})
export default class CustomerDepartment extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@ForeignKey(() => Customer)
@Column({
type: DataType.BIGINT,
allowNull: false,
field: "customer_id",
comment: "所属客户ID"
})
customerId!: number;
@Column({
type: DataType.STRING(64),
allowNull: false,
field: "dept_name",
comment: "部门名称"
})
deptName!: string;
@Column({
type: DataType.STRING(32),
field: "contact_person",
comment: "联系人"
})
contactPerson!: string;
@Column({
type: DataType.STRING(20),
field: "contact_phone",
comment: "联系电话"
})
contactPhone!: string;
@Column({
type: DataType.STRING(256),
field: "function_desc",
comment: "职能描述"
})
functionDesc!: string;
@BelongsTo(() => Customer)
customer!: Customer;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
+12 -10
View File
@@ -26,17 +26,9 @@ export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
const token = authHeader.substring(7);
let decoded: any;
try {
const decoded = this.tokenUtil.verifyToken(token);
ctx.state.user = {
userId: decoded.userId,
username: decoded.username,
};
ctx.user = ctx.state.user;
await next();
decoded = this.tokenUtil.verifyToken(token);
} catch (error: any) {
if (error.name === 'TokenExpiredError') {
ctx.status = 401;
@@ -52,7 +44,17 @@ export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
message: '无效的认证令牌',
};
}
return;
}
ctx.state.user = {
userId: decoded.userId,
username: decoded.username,
};
ctx.user = ctx.state.user;
await next();
};
}
}
+3
View File
@@ -13,6 +13,9 @@ const PERMISSION_MAP: Record<string, string> = {
'/api/menus': 'system:menu',
'/api/dicts': 'system:dict',
'/api/logs': 'system:log',
'/api/customers': 'customer:list',
'/api/customer-departments': 'customer:department',
'/api/customer-accounts': 'customer:account',
};
// 超级管理员角色编码,拥有全部权限
@@ -0,0 +1,343 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import CustomerAccount from "../entity/customer_account.entity";
import Customer from "../entity/customer.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { hashPassword } from "../util/crypto";
import { Context } from "@midwayjs/koa";
function getDefaultPassword(phone: string): string {
return phone.length >= 6 ? phone.slice(-6) : "123456";
}
@Provide()
export class CustomerAccountService {
@InjectRepository(CustomerAccount)
accountModel!: Repository<CustomerAccount>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await SysUserRole.findAll({
where: { userId },
include: [SysRole]
});
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
private async isCustomerOwner(userId: number, customerId: number): Promise<boolean> {
if (await this.isAdmin(userId)) {
return true;
}
const customer = await Customer.findByPk(customerId);
if (!customer) {
return false;
}
return customer.salesmanId === userId;
}
private cleanSensitiveFields(obj: any): any {
const result = obj.toJSON ? obj.toJSON() : { ...obj };
delete result.passwordHash;
delete result.wxOpenid;
return result;
}
async list(query: { page?: any; pageSize?: any; keyword?: string; customerId?: number; status?: number }, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
const where: any = {};
if (query.keyword) {
where.phone = { [Op.like]: `%${query.keyword}%` };
}
if (query.customerId !== undefined) {
where.customerId = query.customerId;
}
if (query.status !== undefined) {
where.status = query.status;
}
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({
where: { salesmanId: userId },
attributes: ["id"]
});
const myCustomerIds = myCustomers.map((c) => c.id);
if (myCustomerIds.length === 0) {
return {
code: 200,
message: "获取客户账号列表成功",
data: {
list: [],
total: 0,
page: Number(page),
pageSize: Number(pageSize)
}
};
}
if (where.customerId !== undefined) {
if (!myCustomerIds.includes(where.customerId)) {
return {
code: 200,
message: "获取客户账号列表成功",
data: {
list: [],
total: 0,
page: Number(page),
pageSize: Number(pageSize)
}
};
}
} else {
where.customerId = { [Op.in]: myCustomerIds };
}
}
const { count, rows } = await this.accountModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["created_at", "DESC"]]
});
const list = rows.map((row) => this.cleanSensitiveFields(row));
return {
code: 200,
message: "获取客户账号列表成功",
data: {
list,
total: count,
page: Number(page),
pageSize: Number(pageSize)
}
};
}
async detail(id: number, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const record = await this.accountModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户账号不存在",
data: null
};
}
if (!(await this.isCustomerOwner(userId, record.customerId))) {
return {
code: 403,
message: "无权限访问该账号",
data: null
};
}
return {
code: 200,
message: "获取客户账号详情成功",
data: this.cleanSensitiveFields(record)
};
}
async create(data: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
if (!(await this.isCustomerOwner(userId, data.customerId))) {
return {
code: 403,
message: "无权限为该客户创建账号",
data: null
};
}
const existing = await this.accountModel.findOne({
where: { phone: data.phone }
});
if (existing) {
return {
code: 400,
message: "手机号已存在",
data: null
};
}
const password = getDefaultPassword(data.phone);
const passwordHash = await hashPassword(password);
const record = await this.accountModel.create({
customerId: data.customerId,
phone: data.phone,
passwordHash,
status: data.status ?? 1
});
return {
code: 200,
message: "创建客户账号成功",
data: this.cleanSensitiveFields(record)
};
}
async update(id: number, data: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const record = await this.accountModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户账号不存在",
data: null
};
}
if (!(await this.isCustomerOwner(userId, record.customerId))) {
return {
code: 403,
message: "无权限修改该账号",
data: null
};
}
if (data.resetPassword === true) {
const password = getDefaultPassword(record.phone);
record.passwordHash = await hashPassword(password);
await record.save();
}
if (data.phone !== undefined && data.phone !== record.phone) {
const conflict = await this.accountModel.findOne({
where: { phone: data.phone }
});
if (conflict) {
return {
code: 400,
message: "手机号已存在",
data: null
};
}
}
const cleanedData: any = {};
if (data.phone !== undefined) cleanedData.phone = data.phone;
if (data.status !== undefined) cleanedData.status = data.status;
const isAdmin = await this.isAdmin(userId);
if (isAdmin && data.customerId !== undefined) {
cleanedData.customerId = data.customerId;
}
await record.update(cleanedData);
return {
code: 200,
message: "更新客户账号成功",
data: this.cleanSensitiveFields(record)
};
}
async updateStatus(id: number, status: number, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const record = await this.accountModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户账号不存在",
data: null
};
}
if (!(await this.isCustomerOwner(userId, record.customerId))) {
return {
code: 403,
message: "无权限修改该账号状态",
data: null
};
}
await record.update({ status });
return {
code: 200,
message: "状态更新成功",
data: this.cleanSensitiveFields(record)
};
}
async delete(id: number, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const record = await this.accountModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户账号不存在",
data: null
};
}
if (!(await this.isCustomerOwner(userId, record.customerId))) {
return {
code: 403,
message: "无权限删除该账号",
data: null
};
}
await record.destroy();
return {
code: 200,
message: "删除客户账号成功",
data: null
};
}
async resetPassword(id: number, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const record = await this.accountModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户账号不存在",
data: null
};
}
if (!(await this.isCustomerOwner(userId, record.customerId))) {
return {
code: 403,
message: "无权限重置该账号密码",
data: null
};
}
const password = getDefaultPassword(record.phone);
record.passwordHash = await hashPassword(password);
await record.save();
return {
code: 200,
message: "重置密码成功",
data: this.cleanSensitiveFields(record)
};
}
}
@@ -0,0 +1,237 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import CustomerDepartment from "../entity/customer_department.entity";
import Customer from "../entity/customer.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { Context } from "@midwayjs/koa";
@Provide()
export class CustomerDepartmentService {
@InjectRepository(CustomerDepartment)
depModel!: Repository<CustomerDepartment>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await SysUserRole.findAll({
where: { userId },
include: [SysRole]
});
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
private async isCustomerOwner(userId: number, customerId: number): Promise<boolean> {
if (await this.isAdmin(userId)) {
return true;
}
const customer = await Customer.findByPk(customerId);
if (!customer) {
return false;
}
return customer.salesmanId === userId;
}
async list(query: { page?: any; pageSize?: any; keyword?: string; customerId?: number }, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ deptName: { [Op.like]: `%${query.keyword}%` } },
{ contactPerson: { [Op.like]: `%${query.keyword}%` } },
{ contactPhone: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined) {
where.customerId = query.customerId;
}
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({
where: { salesmanId: userId },
attributes: ["id"]
});
const myCustomerIds = myCustomers.map((c) => c.id);
if (myCustomerIds.length === 0) {
return {
code: 200,
message: "获取客户部门列表成功",
data: {
list: [],
total: 0,
page: Number(page),
pageSize: Number(pageSize)
}
};
}
if (where.customerId !== undefined) {
if (!myCustomerIds.includes(where.customerId)) {
return {
code: 200,
message: "获取客户部门列表成功",
data: {
list: [],
total: 0,
page: Number(page),
pageSize: Number(pageSize)
}
};
}
} else {
where.customerId = { [Op.in]: myCustomerIds };
}
}
const { count, rows } = await this.depModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["created_at", "DESC"]]
});
return {
code: 200,
message: "获取客户部门列表成功",
data: {
list: rows.map((r) => r.toJSON()),
total: count,
page: Number(page),
pageSize: Number(pageSize)
}
};
}
async detail(id: number, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const record = await this.depModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户部门不存在",
data: null
};
}
if (!(await this.isCustomerOwner(userId, record.customerId))) {
return {
code: 403,
message: "无权限访问该部门",
data: null
};
}
return {
code: 200,
message: "获取客户部门详情成功",
data: record.toJSON()
};
}
async create(data: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
if (!(await this.isCustomerOwner(userId, data.customerId))) {
return {
code: 403,
message: "无权限为该客户创建部门",
data: null
};
}
const record = await this.depModel.create(data);
return {
code: 200,
message: "创建客户部门成功",
data: record.toJSON()
};
}
async update(id: number, data: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const record = await this.depModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户部门不存在",
data: null
};
}
if (!(await this.isCustomerOwner(userId, record.customerId))) {
return {
code: 403,
message: "无权限修改该部门",
data: null
};
}
delete data.customerId;
await record.update(data);
return {
code: 200,
message: "更新客户部门成功",
data: record.toJSON()
};
}
async delete(id: number, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const record = await this.depModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户部门不存在",
data: null
};
}
if (!(await this.isCustomerOwner(userId, record.customerId))) {
return {
code: 403,
message: "无权限删除该部门",
data: null
};
}
await record.destroy();
return {
code: 200,
message: "删除客户部门成功",
data: null
};
}
async findByCustomerId(customerId: number): Promise<any> {
const records = await this.depModel.findAll({
where: { customerId },
order: [["created_at", "DESC"]]
});
return {
code: 200,
message: "获取客户部门列表成功",
data: records.map((r) => r.toJSON())
};
}
}
+321
View File
@@ -0,0 +1,321 @@
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
import { Context } from "@midwayjs/koa";
import Customer from "../entity/customer.entity";
import CustomerDepartment from "../entity/customer_department.entity";
import CustomerAccount from "../entity/customer_account.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
@Provide()
export class CustomerService {
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerDepartment)
customerDepModel!: Repository<CustomerDepartment>;
@InjectRepository(CustomerAccount)
customerAccModel!: Repository<CustomerAccount>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
async list(
query: { page?: any; pageSize?: any; keyword?: string; status?: number; salesmanId?: number },
ctx: Context
): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ customerName: { [Op.like]: `%${query.keyword}%` } },
{ contactPerson: { [Op.like]: `%${query.keyword}%` } },
{ contactPhone: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.status !== undefined) {
where.status = query.status;
}
if (query.salesmanId) {
where.salesmanId = query.salesmanId;
}
const isAdmin = await this.isAdmin(ctx.state.user.userId);
if (!isAdmin) {
where.salesmanId = ctx.state.user.userId;
}
const { count, rows } = await this.customerModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["created_at", "DESC"]]
});
return {
code: 200,
message: "获取客户列表成功",
data: {
list: rows,
total: count,
page: Number(page),
pageSize: Number(pageSize)
}
};
}
async detail(id: number, ctx: Context): Promise<any> {
const record = await this.customerModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户不存在",
data: null
};
}
const isAdmin = await this.isAdmin(ctx.state.user.userId);
if (!isAdmin && record.salesmanId !== ctx.state.user.userId) {
return {
code: 403,
message: "无权限访问该客户",
data: null
};
}
return {
code: 200,
message: "获取客户详情成功",
data: record
};
}
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({
where: { userId },
include: [SysRole]
});
return userRoles.some((ur) => {
const role = (ur as any).role as SysRole;
return role && role.roleCode === "ADMIN";
});
}
private async generateCustomerNo(retry: number = 0): Promise<string> {
const now = new Date();
const offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
const dateLocal = new Date(offsetMs);
const yyyy = dateLocal.getUTCFullYear();
const mm = String(dateLocal.getUTCMonth() + 1).padStart(2, "0");
const dd = String(dateLocal.getUTCDate()).padStart(2, "0");
const yyyymmdd = `${yyyy}${mm}${dd}`;
const lastRecord = await this.customerModel.findOne({
where: { customerNo: { [Op.like]: `KH${yyyymmdd}%` } },
order: [["customer_no", "DESC"]]
});
let newSeq = 1;
if (lastRecord) {
const lastNo = lastRecord.customerNo;
const seqStr = lastNo.slice(-3);
const seq = parseInt(seqStr, 10) || 0;
newSeq = seq + 1;
}
const newSeqStr = String(newSeq).padStart(3, "0");
const result = `KH${yyyymmdd}${newSeqStr}`;
const exists = await this.customerModel.findOne({
where: { customerNo: result }
});
if (exists) {
if (retry < 1) {
return this.generateCustomerNo(retry + 1);
} else {
throw new Error("客户编号生成冲突,请重试");
}
}
return result;
}
async create(data: any, ctx: Context): Promise<any> {
const isAdmin = await this.isAdmin(ctx.state.user.userId);
if (!isAdmin) {
data.salesmanId = ctx.state.user.userId;
}
try {
data.customerNo = await this.generateCustomerNo();
const created = await this.customerModel.create(data);
return {
code: 200,
message: "创建客户成功",
data: created.toJSON()
};
} catch (err: any) {
if (err instanceof UniqueConstraintError && err.errors?.some((e) => e.path === "customer_no" || e.path === "uk_customer_no")) {
try {
data.customerNo = await this.generateCustomerNo();
const created = await this.customerModel.create(data);
return {
code: 200,
message: "创建客户成功",
data: created.toJSON()
};
} catch (_e) {
return {
code: 400,
message: "客户编号冲突,请重试",
data: null
};
}
}
throw err;
}
}
async update(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.customerModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户不存在",
data: null
};
}
const isAdmin = await this.isAdmin(ctx.state.user.userId);
if (!isAdmin && record.salesmanId !== ctx.state.user.userId) {
return {
code: 403,
message: "无权限操作该客户",
data: null
};
}
delete data.customerNo;
delete data.createdAt;
delete data.updatedAt;
if (!isAdmin) {
delete data.salesmanId;
}
await record.update(data);
return {
code: 200,
message: "更新客户成功",
data: record
};
}
async delete(id: number, ctx: Context): Promise<any> {
const record = await this.customerModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户不存在",
data: null
};
}
const isAdmin = await this.isAdmin(ctx.state.user.userId);
if (!isAdmin && record.salesmanId !== ctx.state.user.userId) {
return {
code: 403,
message: "无权限操作该客户",
data: null
};
}
const hasDep = await this.customerDepModel.findOne({ where: { customerId: id }, limit: 1 });
const hasAcc = await this.customerAccModel.findOne({ where: { customerId: id }, limit: 1 });
if (hasDep || hasAcc) {
return {
code: 400,
message: "该客户存在关联的部门或账号,无法删除,请先删除关联数据",
data: null
};
}
await record.destroy();
return {
code: 200,
message: "删除客户成功",
data: null
};
}
async updateStatus(id: number, status: number, ctx: Context): Promise<any> {
const record = await this.customerModel.findByPk(id);
if (!record) {
return {
code: 404,
message: "客户不存在",
data: null
};
}
const isAdmin = await this.isAdmin(ctx.state.user.userId);
if (!isAdmin && record.salesmanId !== ctx.state.user.userId) {
return {
code: 403,
message: "无权限操作该客户",
data: null
};
}
await record.update({ status });
return {
code: 200,
message: "更新客户状态成功",
data: null
};
}
async getSimpleList(ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const isAdmin = await this.isAdmin(userId);
const where: any = {};
if (!isAdmin) {
where.salesmanId = userId;
}
const rows = await this.customerModel.findAll({
where,
attributes: ["id", "customerNo", "customerName", "status"],
order: [["created_at", "DESC"]]
});
return {
code: 200,
message: "获取客户选项成功",
data: rows.map((r) => r.toJSON())
};
}
}
+97
View File
@@ -0,0 +1,97 @@
// 客户管理模块验证脚本
const http = require('http');
function request(options, body) {
return new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
(async () => {
console.log('=== 步骤 1: 登录获取 admin token ===');
const loginRes = await request(
{
hostname: '127.0.0.1',
port: 7001,
path: '/api/auth/login',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
JSON.stringify({ username: 'admin', password: 'admin123456' })
);
const loginData = JSON.parse(loginRes.body);
console.log('HTTP:', loginRes.statusCode, '| code:', loginData.code, '| message:', loginData.message);
console.log('roles:', loginData.data.roles);
console.log('menus count:', loginData.data.menus.length);
console.log('top menu[0]:', loginData.data.menus[0].menuName, 'type:', loginData.data.menus[0].menuType, 'children:', loginData.data.menus[0].children?.length);
const token = loginData.data.accessToken;
console.log('token 长度:', token.length);
console.log('token 前 80 字符:', token.substring(0, 80));
const authHeader = `Bearer ${token}`;
console.log('Authorization header 长度:', authHeader.length);
console.log('\n=== 步骤 2: 调用 GET /api/customers 客户列表 ===');
console.log('发送的 Authorization:', authHeader.substring(0, 100) + '...');
const listRes = await request({
hostname: '127.0.0.1',
port: 7001,
path: '/api/customers?page=1&pageSize=10',
method: 'GET',
headers: { Authorization: authHeader },
});
console.log('HTTP:', listRes.statusCode);
console.log('Body:', listRes.body);
console.log('\n=== 步骤 3: 调用 GET /api/customers/options/all 精简列表 ===');
const optRes = await request({
hostname: '127.0.0.1',
port: 7001,
path: '/api/customers/options/all',
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
console.log('HTTP:', optRes.statusCode);
console.log('Body:', optRes.body.substring(0, 500));
console.log('\n=== 步骤 4: 调用 GET /api/customer-departments 部门列表 ===');
const depRes = await request({
hostname: '127.0.0.1',
port: 7001,
path: '/api/customer-departments?page=1&pageSize=10',
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
console.log('HTTP:', depRes.statusCode);
console.log('Body:', depRes.body);
console.log('\n=== 步骤 5: 调用 GET /api/customer-accounts 账号列表 ===');
const accRes = await request({
hostname: '127.0.0.1',
port: 7001,
path: '/api/customer-accounts?page=1&pageSize=10',
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
console.log('HTTP:', accRes.statusCode);
console.log('Body:', accRes.body);
console.log('\n=== 步骤 6: 验证无 token 访问 → 401 ===');
const noTokenRes = await request({
hostname: '127.0.0.1',
port: 7001,
path: '/api/customers',
method: 'GET',
});
console.log('HTTP:', noTokenRes.statusCode, '| Body:', noTokenRes.body);
})().catch((err) => {
console.error('脚本异常:', err.message);
process.exit(1);
});