feat: 新增客户管理全模块功能
实现客户信息、客户部门、客户账号三个子模块的完整CRUD,包含: 1. 数据库实体、DTO、控制器、服务层代码 2. RBAC权限映射与菜单种子数据扩展 3. 前端路由与API封装 4. 数据权限隔离与鉴权逻辑 5. 配套工具脚本与测试验证清单
This commit is contained in:
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 ──┘
|
||||
```
|
||||
Generated
+5670
File diff suppressed because it is too large.
Load diff
@@ -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 }),
|
||||
};
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+32
-5
@@ -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 管理员 + 绑定关系)
|
||||
-- ============================================================
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user