Compare commits
24
Commits
afb65d685a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27aa2c2dfe | ||
|
|
12738e20db | ||
|
|
ca00727cd0 | ||
|
|
8cf9e208e4 | ||
|
|
058e1fb8a7 | ||
|
|
f400a93bdd | ||
|
|
fd89c31640 | ||
|
|
4c6970c32d | ||
|
|
c21deacfb8 | ||
|
|
e39c6816fc | ||
|
|
8d82781bb3 | ||
|
|
daacd7d742 | ||
|
|
8aa3af986f | ||
|
|
0cf708bfaa | ||
|
|
1759de5af3 | ||
|
|
ac74fa9204 | ||
|
|
6ea38be9d0 | ||
|
|
ed859652c1 | ||
|
|
af9d488a4a | ||
|
|
767d2b8401 | ||
|
|
6046bc1d24 | ||
|
|
ce9348d764 | ||
|
|
264788b54c | ||
|
|
3a99eec597 |
No files matched your search
@@ -8,7 +8,10 @@
|
|||||||
"Bash(git rm:*)",
|
"Bash(git rm:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(git push:*)",
|
"Bash(git push:*)",
|
||||||
"Bash(git config:*)"
|
"Bash(git config:*)",
|
||||||
|
"Bash(pnpm run build)",
|
||||||
|
"Bash(mvn compile:*)",
|
||||||
|
"Bash(dir:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-21
@@ -1,29 +1,9 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
/backend/target/
|
/backend/target/
|
||||||
|
/frontend/dist/
|
||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
/frontend/node_modules/*
|
/frontend/node_modules/*
|
||||||
/KEY_GENERATOR.md
|
/KEY_GENERATOR.md
|
||||||
/backend/tmpclaude-*
|
|
||||||
/frontend/tmpclaude-*
|
|
||||||
/tmpclaude-*
|
|
||||||
/tmpclaude-6f5d-cwd
|
|
||||||
/tmpclaude-006c-cwd
|
|
||||||
/tmpclaude-27a4-cwd
|
|
||||||
/tmpclaude-2246-cwd
|
|
||||||
/tmpclaude-a7d6-cwd
|
|
||||||
/tmpclaude-b78e-cwd
|
|
||||||
/tmpclaude-2a25-cwd
|
|
||||||
/tmpclaude-45c6-cwd
|
|
||||||
/tmpclaude-62fc-cwd
|
|
||||||
/tmpclaude-65b0-cwd
|
|
||||||
/tmpclaude-4540-cwd
|
|
||||||
/tmpclaude-b9f7-cwd
|
|
||||||
/tmpclaude-b842-cwd
|
|
||||||
/tmpclaude-c399-cwd
|
|
||||||
/tmpclaude-d1b4-cwd
|
|
||||||
|
|
||||||
|
|
||||||
/backend/target/
|
|
||||||
|
|
||||||
/mysql/data/
|
/mysql/data/
|
||||||
|
|
||||||
@@ -55,3 +35,9 @@ Thumbs.db
|
|||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
**/tmpclaude-*
|
||||||
|
/backend/tmpclaude-*
|
||||||
|
/frontend/tmpclaude-*
|
||||||
|
/tmpclaude-*
|
||||||
|
|
||||||
|
/by-crm-crm-backend-**.tar
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
此文件为 Claude Code (claude.ai/code) 在此仓库中工作时提供指导。
|
此文件为 Claude Code (claude.ai/code) 在此仓库中工作时提供指导。
|
||||||
|
|
||||||
## 项目名称
|
## 项目名称
|
||||||
经销商管理系统
|
泊云智销通
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
经销商管理系统是一款面向企业渠道管理的CRM系统,核心解决“客户报备冲突”与“保护期归属”问题。系统通过**唯一有效报备机制**与**自动到期释放机制**,确保同一客户在同一时间只能被一个经销商跟进,并在保护期结束后自动回归可报备池,避免长期占坑,提升渠道效率与公平性。
|
泊云智销通是一款面向企业渠道管理的CRM系统,核心解决“客户报备冲突”与“保护期归属”问题。系统通过**唯一有效报备机制**与**自动到期释放机制**,确保同一客户在同一时间只能被一个经销商跟进,并在保护期结束后自动回归可报备池,避免长期占坑,提升渠道效率与公平性。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
- 前端:Vue 3 + TypeScript + Vite + Element Plus
|
- 前端:Vue 3 + TypeScript + Vite + Element Plus
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ FROM maven:3.8.6-openjdk-8 AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 配置阿里云 Maven 镜像(容器内加速依赖下载,避免直连境外中央仓库 SSL 失败)
|
||||||
|
COPY settings.xml /root/.m2/settings.xml
|
||||||
|
|
||||||
# 复制 pom.xml 并下载依赖(利用 Docker 缓存)
|
# 复制 pom.xml 并下载依赖(利用 Docker 缓存)
|
||||||
COPY pom.xml .
|
COPY pom.xml .
|
||||||
RUN mvn dependency:go-offline
|
RUN mvn dependency:go-offline
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
<artifactId>by-crm-backend</artifactId>
|
<artifactId>by-crm-backend</artifactId>
|
||||||
<version>1.0.0</version>
|
<version>1.0.0</version>
|
||||||
<name>BY-CRM Backend</name>
|
<name>BY-CRM Backend</name>
|
||||||
<description>经销商管理系统后端服务</description>
|
<description>泊云智销通后端服务</description>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>1.8</java.version>
|
<java.version>1.8</java.version>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
专供 Docker 构建容器内 Maven 使用:将所有仓库请求镜像到阿里云,
|
||||||
|
避免直连境外 Maven 中央仓库(repo.maven.apache.org)出现 SSL 握手失败 / 超时。
|
||||||
|
-->
|
||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||||
|
<mirrors>
|
||||||
|
<mirror>
|
||||||
|
<id>aliyun-public</id>
|
||||||
|
<mirrorOf>*</mirrorOf>
|
||||||
|
<name>Aliyun Public Repository</name>
|
||||||
|
<url>https://maven.aliyun.com/repository/public</url>
|
||||||
|
</mirror>
|
||||||
|
</mirrors>
|
||||||
|
</settings>
|
||||||
@@ -5,7 +5,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 经销商管理系统主启动类
|
* 泊云智销通主启动类
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@@ -14,7 +14,7 @@ public class ByCrmApplication {
|
|||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(ByCrmApplication.class, args);
|
SpringApplication.run(ByCrmApplication.class, args);
|
||||||
System.out.println("\n========================================");
|
System.out.println("\n========================================");
|
||||||
System.out.println("经销商管理系统启动成功!");
|
System.out.println("泊云智销通启动成功!");
|
||||||
System.out.println("Swagger文档地址: http://localhost:8080/api/swagger-ui/index.html");
|
System.out.println("Swagger文档地址: http://localhost:8080/api/swagger-ui/index.html");
|
||||||
System.out.println("========================================\n");
|
System.out.println("========================================\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ public class Constants {
|
|||||||
*/
|
*/
|
||||||
public static final int REPORT_STATUS_EXPIRED = 3;
|
public static final int REPORT_STATUS_EXPIRED = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报备状态 - 已作废
|
||||||
|
*/
|
||||||
|
public static final int REPORT_STATUS_CANCELED = 4;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户角色 - 管理员
|
* 用户角色 - 管理员
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.bycrm.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步任务配置:为消息推送等异步操作提供线程池。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pushplus 推送专用线程池。
|
||||||
|
* 满载时降级为调用线程同步执行(CallerRunsPolicy),宁可变慢也不丢弃推送任务。
|
||||||
|
*/
|
||||||
|
@Bean("pushplusExecutor")
|
||||||
|
public Executor pushplusExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(2);
|
||||||
|
executor.setMaxPoolSize(4);
|
||||||
|
executor.setQueueCapacity(100);
|
||||||
|
executor.setThreadNamePrefix("pushplus-");
|
||||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||||
|
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,8 @@ public class SwaggerConfig {
|
|||||||
|
|
||||||
private ApiInfo apiInfo() {
|
private ApiInfo apiInfo() {
|
||||||
return new ApiInfoBuilder()
|
return new ApiInfoBuilder()
|
||||||
.title("经销商管理系统 API 文档")
|
.title("泊云智销通 API 文档")
|
||||||
.description("经销商管理系统 RESTful API 接口文档")
|
.description("泊云智销通 RESTful API 接口文档")
|
||||||
.contact(new Contact("BY CRM", "", ""))
|
.contact(new Contact("BY CRM", "", ""))
|
||||||
.version("1.0.0")
|
.version("1.0.0")
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.bycrm.common.Result;
|
|||||||
import com.bycrm.dto.PageQuery;
|
import com.bycrm.dto.PageQuery;
|
||||||
import com.bycrm.dto.ReportAuditDTO;
|
import com.bycrm.dto.ReportAuditDTO;
|
||||||
import com.bycrm.dto.ReportDTO;
|
import com.bycrm.dto.ReportDTO;
|
||||||
|
import com.bycrm.dto.ReportUpdateDTO;
|
||||||
import com.bycrm.service.ReportService;
|
import com.bycrm.service.ReportService;
|
||||||
import com.bycrm.vo.ReportVO;
|
import com.bycrm.vo.ReportVO;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
@@ -72,6 +73,20 @@ public class ReportController {
|
|||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新报备
|
||||||
|
*/
|
||||||
|
@ApiOperation("更新报备")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Result<Void> updateReport(
|
||||||
|
@ApiParam("报备ID") @PathVariable Long id,
|
||||||
|
@RequestBody ReportUpdateDTO updateDTO,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long currentUserId = (Long) request.getAttribute("currentUserId");
|
||||||
|
reportService.updateReport(id, updateDTO, currentUserId);
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审核报备
|
* 审核报备
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.bycrm.controller;
|
||||||
|
|
||||||
|
import com.bycrm.common.Result;
|
||||||
|
import com.bycrm.dto.ReportProgressDTO;
|
||||||
|
import com.bycrm.entity.ReportProgress;
|
||||||
|
import com.bycrm.service.ReportProgressService;
|
||||||
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import io.swagger.annotations.ApiParam;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报备进展记录控制器
|
||||||
|
*/
|
||||||
|
@Api(tags = "报备进展管理")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/report-progress")
|
||||||
|
public class ReportProgressController {
|
||||||
|
|
||||||
|
private final ReportProgressService reportProgressService;
|
||||||
|
|
||||||
|
public ReportProgressController(ReportProgressService reportProgressService) {
|
||||||
|
this.reportProgressService = reportProgressService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据报备ID获取进展记录列表
|
||||||
|
*/
|
||||||
|
@ApiOperation("根据报备ID获取进展记录列表")
|
||||||
|
@GetMapping("/report/{reportId}")
|
||||||
|
public Result<List<ReportProgress>> getProgressByReportId(
|
||||||
|
@ApiParam("报备ID") @PathVariable Long reportId) {
|
||||||
|
List<ReportProgress> progressList = reportProgressService.getProgressByReportId(reportId);
|
||||||
|
return Result.success(progressList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取进展记录
|
||||||
|
*/
|
||||||
|
@ApiOperation("根据ID获取进展记录")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<ReportProgress> getProgressById(
|
||||||
|
@ApiParam("进展ID") @PathVariable Long id) {
|
||||||
|
ReportProgress progress = reportProgressService.getProgressById(id);
|
||||||
|
return Result.success(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建进展记录
|
||||||
|
*/
|
||||||
|
@ApiOperation("创建进展记录")
|
||||||
|
@PostMapping
|
||||||
|
public Result<Void> createProgress(
|
||||||
|
@ApiParam("进展记录数据") @RequestBody @Valid ReportProgressDTO progressDTO,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long currentUserId = (Long) request.getAttribute("currentUserId");
|
||||||
|
reportProgressService.createProgress(progressDTO, currentUserId);
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新进展记录
|
||||||
|
*/
|
||||||
|
@ApiOperation("更新进展记录")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Result<Void> updateProgress(
|
||||||
|
@ApiParam("进展ID") @PathVariable Long id,
|
||||||
|
@ApiParam("进展记录数据") @RequestBody @Valid ReportProgressDTO progressDTO,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
Long currentUserId = (Long) request.getAttribute("currentUserId");
|
||||||
|
reportProgressService.updateProgress(id, progressDTO, currentUserId);
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除进展记录(根据需求不可删除,所以不提供该接口)
|
||||||
|
*/
|
||||||
|
// @ApiOperation("删除进展记录")
|
||||||
|
// @DeleteMapping("/{id}")
|
||||||
|
// public Result<Void> deleteProgress(
|
||||||
|
// @ApiParam("进展ID") @PathVariable Long id,
|
||||||
|
// HttpServletRequest request) {
|
||||||
|
// Long currentUserId = (Long) request.getAttribute("currentUserId");
|
||||||
|
// reportProgressService.deleteProgress(id, currentUserId);
|
||||||
|
// return Result.success();
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.bycrm.common.Result;
|
|||||||
import com.bycrm.entity.SystemConfig;
|
import com.bycrm.entity.SystemConfig;
|
||||||
import com.bycrm.entity.User;
|
import com.bycrm.entity.User;
|
||||||
import com.bycrm.mapper.UserMapper;
|
import com.bycrm.mapper.UserMapper;
|
||||||
|
import com.bycrm.service.NotifyService;
|
||||||
import com.bycrm.service.SystemConfigService;
|
import com.bycrm.service.SystemConfigService;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
@@ -23,11 +24,14 @@ public class SystemConfigController {
|
|||||||
|
|
||||||
private final SystemConfigService systemConfigService;
|
private final SystemConfigService systemConfigService;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
|
private final NotifyService notifyService;
|
||||||
|
|
||||||
public SystemConfigController(SystemConfigService systemConfigService,
|
public SystemConfigController(SystemConfigService systemConfigService,
|
||||||
UserMapper userMapper) {
|
UserMapper userMapper,
|
||||||
|
NotifyService notifyService) {
|
||||||
this.systemConfigService = systemConfigService;
|
this.systemConfigService = systemConfigService;
|
||||||
this.userMapper = userMapper;
|
this.userMapper = userMapper;
|
||||||
|
this.notifyService = notifyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,4 +108,19 @@ public class SystemConfigController {
|
|||||||
systemConfigService.batchUpdateConfigs(configs);
|
systemConfigService.batchUpdateConfigs(configs);
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 pushplus 推送配置(仅管理员)
|
||||||
|
*/
|
||||||
|
@ApiOperation("测试 pushplus 推送")
|
||||||
|
@PostMapping("/pushplus/test")
|
||||||
|
public Result<Void> testPushplus(HttpServletRequest request) {
|
||||||
|
Long currentUserId = (Long) request.getAttribute("currentUserId");
|
||||||
|
User currentUser = userMapper.selectById(currentUserId);
|
||||||
|
if (currentUser == null || currentUser.getRole() != 0) {
|
||||||
|
return Result.error("只有管理员才能测试推送");
|
||||||
|
}
|
||||||
|
notifyService.sendTest();
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,4 +24,9 @@ public class LoginDTO implements Serializable {
|
|||||||
*/
|
*/
|
||||||
@NotBlank(message = "密码不能为空")
|
@NotBlank(message = "密码不能为空")
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记住我(true 时签发 7 天有效期的 token)
|
||||||
|
*/
|
||||||
|
private Boolean rememberMe = false;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ package com.bycrm.dto;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotBlank;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,13 +14,40 @@ public class ReportDTO implements Serializable {
|
|||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户ID
|
* 学校ID(可选,从数据源中选择时提供)
|
||||||
*/
|
*/
|
||||||
@NotNull(message = "客户ID不能为空")
|
private Long schoolId;
|
||||||
private Long customerId;
|
|
||||||
|
/**
|
||||||
|
* 学校名称(必填)
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "学校名称不能为空")
|
||||||
|
private String schoolName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所属产品
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "所属产品不能为空")
|
||||||
|
private String product;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目类型
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "项目类型不能为空")
|
||||||
|
private String projectType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 报备说明
|
* 报备说明
|
||||||
*/
|
*/
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目负责人姓名
|
||||||
|
*/
|
||||||
|
private String contactPerson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目负责人电话
|
||||||
|
*/
|
||||||
|
private String contactPhone;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.bycrm.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报备进展记录 DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ReportProgressDTO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联报备ID
|
||||||
|
*/
|
||||||
|
@NotNull(message = "报备ID不能为空")
|
||||||
|
private Long reportId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进展内容
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "进展内容不能为空")
|
||||||
|
private String progressContent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.bycrm.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报备更新 DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ReportUpdateDTO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所属产品
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "所属产品不能为空")
|
||||||
|
private String product;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目类型
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "项目类型不能为空")
|
||||||
|
private String projectType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报备说明
|
||||||
|
*/
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目负责人姓名
|
||||||
|
*/
|
||||||
|
private String contactPerson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目负责人电话
|
||||||
|
*/
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态
|
||||||
|
*/
|
||||||
|
@NotNull(message = "状态不能为空")
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作废原因(当状态为作废时必填)
|
||||||
|
*/
|
||||||
|
private String cancelReason;
|
||||||
|
}
|
||||||
@@ -26,9 +26,24 @@ public class Report implements Serializable {
|
|||||||
private Long dealerId;
|
private Long dealerId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户ID
|
* 学校ID(从 crm_school 选择)
|
||||||
*/
|
*/
|
||||||
private Long customerId;
|
private Long schoolId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校名称(冗余存储,方便查询)
|
||||||
|
*/
|
||||||
|
private String schoolName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所属产品
|
||||||
|
*/
|
||||||
|
private String product;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目类型
|
||||||
|
*/
|
||||||
|
private String projectType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 报备说明
|
* 报备说明
|
||||||
@@ -36,7 +51,17 @@ public class Report implements Serializable {
|
|||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态:0-待审核 1-已通过 2-已驳回 3-已失效
|
* 项目负责人姓名
|
||||||
|
*/
|
||||||
|
private String contactPerson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目负责人电话
|
||||||
|
*/
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-待审核 1-已通过 2-已驳回 3-已失效 4-已作废
|
||||||
*/
|
*/
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
@@ -45,6 +70,11 @@ public class Report implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String rejectReason;
|
private String rejectReason;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作废原因
|
||||||
|
*/
|
||||||
|
private String cancelReason;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保护期开始日期
|
* 保护期开始日期
|
||||||
*/
|
*/
|
||||||
@@ -73,14 +103,4 @@ public class Report implements Serializable {
|
|||||||
* 关联查询字段 - 经销商名称
|
* 关联查询字段 - 经销商名称
|
||||||
*/
|
*/
|
||||||
private String dealerName;
|
private String dealerName;
|
||||||
|
|
||||||
/**
|
|
||||||
* 关联查询字段 - 客户名称
|
|
||||||
*/
|
|
||||||
private String customerName;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关联查询字段 - 客户电话
|
|
||||||
*/
|
|
||||||
private String customerPhone;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.bycrm.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报备进展记录实体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ReportProgress implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进展ID
|
||||||
|
*/
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联报备ID
|
||||||
|
*/
|
||||||
|
private Long reportId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进展内容
|
||||||
|
*/
|
||||||
|
private String progressContent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建人ID
|
||||||
|
*/
|
||||||
|
private Long createdBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联查询字段 - 创建人姓名
|
||||||
|
*/
|
||||||
|
private String creatorName;
|
||||||
|
}
|
||||||
@@ -19,9 +19,18 @@ public interface ReportMapper {
|
|||||||
Report selectById(@Param("id") Long id);
|
Report selectById(@Param("id") Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询客户的有效报备
|
* 查询学校+产品+项目类型的有效报备(根据学校ID)
|
||||||
*/
|
*/
|
||||||
Report selectValidByCustomerId(@Param("customerId") Long customerId);
|
Report selectValidBySchoolAndProduct(@Param("schoolId") Long schoolId,
|
||||||
|
@Param("product") String product,
|
||||||
|
@Param("projectType") String projectType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询学校名称+产品+项目类型的有效报备(根据学校名称)
|
||||||
|
*/
|
||||||
|
Report selectValidBySchoolNameAndProduct(@Param("schoolName") String schoolName,
|
||||||
|
@Param("product") String product,
|
||||||
|
@Param("projectType") String projectType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分页查询报备
|
* 分页查询报备
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.bycrm.mapper;
|
||||||
|
|
||||||
|
import com.bycrm.entity.ReportProgress;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报备进展记录 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface ReportProgressMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询进展记录
|
||||||
|
*/
|
||||||
|
ReportProgress selectById(@Param("id") Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据报备ID查询进展记录列表
|
||||||
|
*/
|
||||||
|
List<ReportProgress> selectByReportId(@Param("reportId") Long reportId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入进展记录
|
||||||
|
*/
|
||||||
|
int insert(ReportProgress reportProgress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新进展记录
|
||||||
|
*/
|
||||||
|
int update(ReportProgress reportProgress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID删除进展记录
|
||||||
|
*/
|
||||||
|
int deleteById(@Param("id") Long id);
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ import java.util.List;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface SchoolMapper {
|
public interface SchoolMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询学校
|
||||||
|
*/
|
||||||
|
School selectById(@Param("id") Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 插入学校
|
* 插入学校
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.bycrm.service;
|
||||||
|
|
||||||
|
import com.bycrm.entity.Report;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息通知服务(pushplus 微信推送)
|
||||||
|
*/
|
||||||
|
public interface NotifyService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报备创建后通知管理员。
|
||||||
|
*
|
||||||
|
* @param report 新建的报备(需含 id 及各业务字段)
|
||||||
|
* @param dealerName 经销商名称
|
||||||
|
*/
|
||||||
|
void notifyNewReport(Report report, String dealerName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送一条测试消息,用于验证 pushplus 配置是否正确。
|
||||||
|
*/
|
||||||
|
void sendTest();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.bycrm.service;
|
||||||
|
|
||||||
|
import com.bycrm.entity.ReportProgress;
|
||||||
|
import com.bycrm.dto.ReportProgressDTO;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报备进展记录服务接口
|
||||||
|
*/
|
||||||
|
public interface ReportProgressService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据报备ID获取进展记录列表
|
||||||
|
*/
|
||||||
|
List<ReportProgress> getProgressByReportId(Long reportId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取进展记录
|
||||||
|
*/
|
||||||
|
ReportProgress getProgressById(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建进展记录
|
||||||
|
*/
|
||||||
|
void createProgress(ReportProgressDTO progressDTO, Long currentUserId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新进展记录
|
||||||
|
*/
|
||||||
|
void updateProgress(Long id, ReportProgressDTO progressDTO, Long currentUserId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除进展记录(不提供删除功能,根据需求不可删除)
|
||||||
|
*/
|
||||||
|
// void deleteProgress(Long id, Long currentUserId);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.bycrm.common.PageResult;
|
|||||||
import com.bycrm.dto.PageQuery;
|
import com.bycrm.dto.PageQuery;
|
||||||
import com.bycrm.dto.ReportAuditDTO;
|
import com.bycrm.dto.ReportAuditDTO;
|
||||||
import com.bycrm.dto.ReportDTO;
|
import com.bycrm.dto.ReportDTO;
|
||||||
|
import com.bycrm.dto.ReportUpdateDTO;
|
||||||
import com.bycrm.vo.ReportVO;
|
import com.bycrm.vo.ReportVO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +28,11 @@ public interface ReportService {
|
|||||||
*/
|
*/
|
||||||
void createReport(ReportDTO reportDTO, Long currentUserId);
|
void createReport(ReportDTO reportDTO, Long currentUserId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新报备
|
||||||
|
*/
|
||||||
|
void updateReport(Long id, ReportUpdateDTO updateDTO, Long currentUserId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审核报备
|
* 审核报备
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public class DealerServiceImpl implements DealerService {
|
|||||||
// 设置密码:如果提供了密码则使用提供的密码,否则使用默认密码
|
// 设置密码:如果提供了密码则使用提供的密码,否则使用默认密码
|
||||||
String password = dealerDTO.getPassword();
|
String password = dealerDTO.getPassword();
|
||||||
if (password == null || password.trim().isEmpty()) {
|
if (password == null || password.trim().isEmpty()) {
|
||||||
password = "123456"; // 默认密码
|
password = "a123456"; // 默认密码
|
||||||
}
|
}
|
||||||
user.setPassword(passwordEncoder.encode(password));
|
user.setPassword(passwordEncoder.encode(password));
|
||||||
user.setCreatedAt(LocalDateTime.now());
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.bycrm.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.http.HttpRequest;
|
||||||
|
import cn.hutool.http.HttpResponse;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.bycrm.entity.Report;
|
||||||
|
import com.bycrm.service.NotifyService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pushplus 消息推送实现。
|
||||||
|
* 异步执行(pushplusExecutor),任何异常仅记日志,绝不影响主业务。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class NotifyServiceImpl implements NotifyService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(NotifyServiceImpl.class);
|
||||||
|
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
@Value("${pushplus.enabled:false}")
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Value("${pushplus.token:}")
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
@Value("${pushplus.topic:}")
|
||||||
|
private String topic;
|
||||||
|
|
||||||
|
@Value("${pushplus.url:https://www.pushplus.plus/send}")
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
@Value("${pushplus.approval-url:}")
|
||||||
|
private String approvalUrl;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Async("pushplusExecutor")
|
||||||
|
public void notifyNewReport(Report report, String dealerName) {
|
||||||
|
if (!enabled || token == null || token.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String title = "新报备申请:" + safe(report.getSchoolName());
|
||||||
|
String content = buildReportContent(report, dealerName);
|
||||||
|
send(title, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Async("pushplusExecutor")
|
||||||
|
public void sendTest() {
|
||||||
|
if (!enabled || token == null || token.trim().isEmpty()) {
|
||||||
|
log.warn("pushplus 未启用或 token 为空,测试推送已跳过。请在 application.yml 配置 pushplus.enabled/token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String content = "<h3>泊云智销通推送测试</h3><p>收到本消息说明 pushplus 配置正常,经销商提交报备时您将收到通知。</p>";
|
||||||
|
send("泊云智销通推送测试", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际调用 pushplus 接口。全程容错,失败仅记日志。
|
||||||
|
*/
|
||||||
|
private void send(String title, String content) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("token", token);
|
||||||
|
body.put("title", title);
|
||||||
|
body.put("content", content);
|
||||||
|
body.put("template", "html");
|
||||||
|
if (topic != null && !topic.trim().isEmpty()) {
|
||||||
|
body.put("topic", topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
String json = JSONUtil.toJsonStr(body);
|
||||||
|
HttpResponse response = HttpRequest.post(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(json)
|
||||||
|
.timeout(5000)
|
||||||
|
.execute();
|
||||||
|
log.info("pushplus 推送结果: status={}, body={}", response.getStatus(), response.body());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("pushplus 推送失败(不影响业务): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildReportContent(Report report, String dealerName) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("<h3>有新的报备申请待审核</h3>");
|
||||||
|
sb.append("<table border='1' cellspacing='0' cellpadding='6' ")
|
||||||
|
.append("style='border-collapse:collapse;font-size:14px;'>");
|
||||||
|
row(sb, "报备编号", String.valueOf(report.getId()));
|
||||||
|
row(sb, "经销商", safe(dealerName));
|
||||||
|
row(sb, "学校名称", safe(report.getSchoolName()));
|
||||||
|
row(sb, "所属产品", safe(report.getProduct()));
|
||||||
|
row(sb, "项目类型", safe(report.getProjectType()));
|
||||||
|
row(sb, "负责人", safe(report.getContactPerson()));
|
||||||
|
row(sb, "联系电话", safe(report.getContactPhone()));
|
||||||
|
row(sb, "报备说明", safe(report.getDescription()));
|
||||||
|
LocalDateTime createdAt = report.getCreatedAt();
|
||||||
|
row(sb, "提交时间", createdAt == null ? "-" : createdAt.format(FMT));
|
||||||
|
sb.append("</table>");
|
||||||
|
if (approvalUrl != null && !approvalUrl.trim().isEmpty()) {
|
||||||
|
sb.append("<p style='margin-top:12px;'>请跳转 <a href='")
|
||||||
|
.append(approvalUrl)
|
||||||
|
.append("'>")
|
||||||
|
.append(approvalUrl)
|
||||||
|
.append("</a> 去审批处理。</p>");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void row(StringBuilder sb, String label, String value) {
|
||||||
|
sb.append("<tr><td style='background:#fafafa;font-weight:bold;'>")
|
||||||
|
.append(label)
|
||||||
|
.append("</td><td>")
|
||||||
|
.append(value)
|
||||||
|
.append("</td></tr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safe(String s) {
|
||||||
|
return s == null ? "-" : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.bycrm.service.impl;
|
||||||
|
|
||||||
|
import com.bycrm.common.Constants;
|
||||||
|
import com.bycrm.dto.ReportProgressDTO;
|
||||||
|
import com.bycrm.entity.Report;
|
||||||
|
import com.bycrm.entity.ReportProgress;
|
||||||
|
import com.bycrm.entity.User;
|
||||||
|
import com.bycrm.exception.BusinessException;
|
||||||
|
import com.bycrm.mapper.ReportMapper;
|
||||||
|
import com.bycrm.mapper.ReportProgressMapper;
|
||||||
|
import com.bycrm.mapper.UserMapper;
|
||||||
|
import com.bycrm.service.ReportProgressService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报备进展记录服务实现
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ReportProgressServiceImpl implements ReportProgressService {
|
||||||
|
|
||||||
|
private final ReportProgressMapper reportProgressMapper;
|
||||||
|
private final ReportMapper reportMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
|
||||||
|
public ReportProgressServiceImpl(ReportProgressMapper reportProgressMapper,
|
||||||
|
ReportMapper reportMapper,
|
||||||
|
UserMapper userMapper) {
|
||||||
|
this.reportProgressMapper = reportProgressMapper;
|
||||||
|
this.reportMapper = reportMapper;
|
||||||
|
this.userMapper = userMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ReportProgress> getProgressByReportId(Long reportId) {
|
||||||
|
return reportProgressMapper.selectByReportId(reportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ReportProgress getProgressById(Long id) {
|
||||||
|
return reportProgressMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void createProgress(ReportProgressDTO progressDTO, Long currentUserId) {
|
||||||
|
// 获取当前用户
|
||||||
|
User currentUser = userMapper.selectById(currentUserId);
|
||||||
|
|
||||||
|
// 检查报备是否存在
|
||||||
|
Report report = reportMapper.selectById(progressDTO.getReportId());
|
||||||
|
if (report == null) {
|
||||||
|
throw new BusinessException("报备不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查权限:经销商只能操作自己的报备
|
||||||
|
if (currentUser.getRole() == Constants.USER_ROLE_DEALER) {
|
||||||
|
if (!report.getDealerId().equals(currentUser.getDealerId())) {
|
||||||
|
throw new BusinessException("您只能操作自己报备的进展记录");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查报备状态是否为已通过
|
||||||
|
if (report.getStatus() != Constants.REPORT_STATUS_APPROVED) {
|
||||||
|
throw new BusinessException("只有审核通过的报备才能添加进展记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建进展记录
|
||||||
|
ReportProgress progress = new ReportProgress();
|
||||||
|
progress.setReportId(progressDTO.getReportId());
|
||||||
|
progress.setProgressContent(progressDTO.getProgressContent());
|
||||||
|
progress.setCreatedBy(currentUserId);
|
||||||
|
progress.setCreatedAt(LocalDateTime.now());
|
||||||
|
progress.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
reportProgressMapper.insert(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void updateProgress(Long id, ReportProgressDTO progressDTO, Long currentUserId) {
|
||||||
|
// 获取当前用户
|
||||||
|
User currentUser = userMapper.selectById(currentUserId);
|
||||||
|
|
||||||
|
// 检查进展记录是否存在
|
||||||
|
ReportProgress existingProgress = reportProgressMapper.selectById(id);
|
||||||
|
if (existingProgress == null) {
|
||||||
|
throw new BusinessException("进展记录不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查报备是否存在
|
||||||
|
Report report = reportMapper.selectById(existingProgress.getReportId());
|
||||||
|
if (report == null) {
|
||||||
|
throw new BusinessException("关联的报备不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查权限:经销商只能操作自己的报备
|
||||||
|
if (currentUser.getRole() == Constants.USER_ROLE_DEALER) {
|
||||||
|
if (!report.getDealerId().equals(currentUser.getDealerId())) {
|
||||||
|
throw new BusinessException("您只能操作自己报备的进展记录");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新进展记录
|
||||||
|
existingProgress.setProgressContent(progressDTO.getProgressContent());
|
||||||
|
existingProgress.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
reportProgressMapper.update(existingProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据需求,不提供删除功能
|
||||||
|
// @Override
|
||||||
|
// public void deleteProgress(Long id, Long currentUserId) {
|
||||||
|
// throw new BusinessException("进展记录不可删除");
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -5,19 +5,25 @@ import com.bycrm.common.PageResult;
|
|||||||
import com.bycrm.dto.PageQuery;
|
import com.bycrm.dto.PageQuery;
|
||||||
import com.bycrm.dto.ReportAuditDTO;
|
import com.bycrm.dto.ReportAuditDTO;
|
||||||
import com.bycrm.dto.ReportDTO;
|
import com.bycrm.dto.ReportDTO;
|
||||||
|
import com.bycrm.dto.ReportUpdateDTO;
|
||||||
|
import com.bycrm.entity.Dealer;
|
||||||
import com.bycrm.entity.Report;
|
import com.bycrm.entity.Report;
|
||||||
import com.bycrm.entity.Customer;
|
import com.bycrm.entity.School;
|
||||||
import com.bycrm.entity.User;
|
import com.bycrm.entity.User;
|
||||||
import com.bycrm.exception.BusinessException;
|
import com.bycrm.exception.BusinessException;
|
||||||
import com.bycrm.mapper.CustomerMapper;
|
import com.bycrm.mapper.DealerMapper;
|
||||||
import com.bycrm.mapper.ReportMapper;
|
import com.bycrm.mapper.ReportMapper;
|
||||||
|
import com.bycrm.mapper.SchoolMapper;
|
||||||
import com.bycrm.mapper.UserMapper;
|
import com.bycrm.mapper.UserMapper;
|
||||||
|
import com.bycrm.service.NotifyService;
|
||||||
import com.bycrm.service.ReportService;
|
import com.bycrm.service.ReportService;
|
||||||
import com.bycrm.service.SystemConfigService;
|
import com.bycrm.service.SystemConfigService;
|
||||||
import com.bycrm.vo.ReportVO;
|
import com.bycrm.vo.ReportVO;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -32,18 +38,24 @@ import java.util.stream.Collectors;
|
|||||||
public class ReportServiceImpl implements ReportService {
|
public class ReportServiceImpl implements ReportService {
|
||||||
|
|
||||||
private final ReportMapper reportMapper;
|
private final ReportMapper reportMapper;
|
||||||
private final CustomerMapper customerMapper;
|
private final SchoolMapper schoolMapper;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
|
private final DealerMapper dealerMapper;
|
||||||
private final SystemConfigService systemConfigService;
|
private final SystemConfigService systemConfigService;
|
||||||
|
private final NotifyService notifyService;
|
||||||
|
|
||||||
public ReportServiceImpl(ReportMapper reportMapper,
|
public ReportServiceImpl(ReportMapper reportMapper,
|
||||||
CustomerMapper customerMapper,
|
SchoolMapper schoolMapper,
|
||||||
UserMapper userMapper,
|
UserMapper userMapper,
|
||||||
SystemConfigService systemConfigService) {
|
DealerMapper dealerMapper,
|
||||||
|
SystemConfigService systemConfigService,
|
||||||
|
NotifyService notifyService) {
|
||||||
this.reportMapper = reportMapper;
|
this.reportMapper = reportMapper;
|
||||||
this.customerMapper = customerMapper;
|
this.schoolMapper = schoolMapper;
|
||||||
this.userMapper = userMapper;
|
this.userMapper = userMapper;
|
||||||
|
this.dealerMapper = dealerMapper;
|
||||||
this.systemConfigService = systemConfigService;
|
this.systemConfigService = systemConfigService;
|
||||||
|
this.notifyService = notifyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -86,29 +98,86 @@ public class ReportServiceImpl implements ReportService {
|
|||||||
throw new BusinessException("您未关联经销商,无法提交报备");
|
throw new BusinessException("您未关联经销商,无法提交报备");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查客户是否存在
|
// 防撞单校验:根据是否有 schoolId 选择不同的校验方式
|
||||||
Customer customer = customerMapper.selectById(reportDTO.getCustomerId());
|
Report existingReport = null;
|
||||||
if (customer == null) {
|
if (reportDTO.getSchoolId() != null) {
|
||||||
throw new BusinessException("客户不存在");
|
// 有学校ID,按学校ID+产品+项目类型校验
|
||||||
|
existingReport = reportMapper.selectValidBySchoolAndProduct(
|
||||||
|
reportDTO.getSchoolId(),
|
||||||
|
reportDTO.getProduct(),
|
||||||
|
reportDTO.getProjectType()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 没有学校ID,按学校名称+产品+项目类型校验
|
||||||
|
existingReport = reportMapper.selectValidBySchoolNameAndProduct(
|
||||||
|
reportDTO.getSchoolName(),
|
||||||
|
reportDTO.getProduct(),
|
||||||
|
reportDTO.getProjectType()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 防撞单校验:检查该客户是否已存在有效报备
|
|
||||||
Report existingReport = reportMapper.selectValidByCustomerId(reportDTO.getCustomerId());
|
|
||||||
Boolean allowOverlap = systemConfigService.getBooleanConfigValue("report.allow.overlap", false);
|
Boolean allowOverlap = systemConfigService.getBooleanConfigValue("report.allow.overlap", false);
|
||||||
if (existingReport != null && !allowOverlap) {
|
if (existingReport != null && !allowOverlap) {
|
||||||
throw new BusinessException("该客户已被其他经销商报备,无法重复报备");
|
throw new BusinessException("该项目已报备");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有学校ID,查询学校信息
|
||||||
|
String schoolName = reportDTO.getSchoolName();
|
||||||
|
Long schoolId = reportDTO.getSchoolId();
|
||||||
|
|
||||||
|
if (schoolId != null) {
|
||||||
|
School school = schoolMapper.selectById(schoolId);
|
||||||
|
if (school != null) {
|
||||||
|
schoolName = school.getSchoolName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询经销商信息,获取默认的联系人信息
|
||||||
|
String defaultContactPerson = null;
|
||||||
|
String defaultContactPhone = null;
|
||||||
|
if (currentUser.getDealerId() != null) {
|
||||||
|
Dealer dealer = dealerMapper.selectById(currentUser.getDealerId());
|
||||||
|
if (dealer != null) {
|
||||||
|
defaultContactPerson = dealer.getContactPerson();
|
||||||
|
defaultContactPhone = dealer.getContactPhone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果用户没有填写联系人信息,使用经销商信息作为默认值
|
||||||
|
String contactPerson = reportDTO.getContactPerson();
|
||||||
|
if (contactPerson == null || contactPerson.trim().isEmpty()) {
|
||||||
|
contactPerson = defaultContactPerson;
|
||||||
|
}
|
||||||
|
String contactPhone = reportDTO.getContactPhone();
|
||||||
|
if (contactPhone == null || contactPhone.trim().isEmpty()) {
|
||||||
|
contactPhone = defaultContactPhone;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建报备
|
// 创建报备
|
||||||
Report report = new Report();
|
Report report = new Report();
|
||||||
report.setDealerId(currentUser.getDealerId());
|
report.setDealerId(currentUser.getDealerId());
|
||||||
report.setCustomerId(reportDTO.getCustomerId());
|
report.setSchoolId(schoolId); // 可以为 NULL
|
||||||
|
report.setSchoolName(schoolName);
|
||||||
|
report.setProduct(reportDTO.getProduct());
|
||||||
|
report.setProjectType(reportDTO.getProjectType());
|
||||||
report.setDescription(reportDTO.getDescription());
|
report.setDescription(reportDTO.getDescription());
|
||||||
|
report.setContactPerson(contactPerson);
|
||||||
|
report.setContactPhone(contactPhone);
|
||||||
report.setStatus(Constants.REPORT_STATUS_PENDING);
|
report.setStatus(Constants.REPORT_STATUS_PENDING);
|
||||||
report.setCreatedAt(LocalDateTime.now());
|
report.setCreatedAt(LocalDateTime.now());
|
||||||
report.setUpdatedAt(LocalDateTime.now());
|
report.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
reportMapper.insert(report);
|
reportMapper.insert(report);
|
||||||
|
|
||||||
|
// 事务提交后异步推送通知管理员(事务回滚则不推送;推送失败不影响报备提交)
|
||||||
|
Dealer notifyDealer = dealerMapper.selectById(currentUser.getDealerId());
|
||||||
|
final String dealerName = notifyDealer != null ? notifyDealer.getName() : "";
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
notifyService.notifyNewReport(report, dealerName);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -138,11 +207,6 @@ public class ReportServiceImpl implements ReportService {
|
|||||||
report.setProtectStartDate(today);
|
report.setProtectStartDate(today);
|
||||||
Integer protectDays = systemConfigService.getIntegerConfigValue("report.protect.days", Constants.DEFAULT_PROTECT_DAYS);
|
Integer protectDays = systemConfigService.getIntegerConfigValue("report.protect.days", Constants.DEFAULT_PROTECT_DAYS);
|
||||||
report.setProtectEndDate(today.plusDays(protectDays));
|
report.setProtectEndDate(today.plusDays(protectDays));
|
||||||
|
|
||||||
// 更新客户状态为保护中
|
|
||||||
Customer customer = customerMapper.selectById(report.getCustomerId());
|
|
||||||
customer.setStatus(Constants.CUSTOMER_STATUS_PROTECTED);
|
|
||||||
customerMapper.update(customer);
|
|
||||||
} else {
|
} else {
|
||||||
// 审核驳回
|
// 审核驳回
|
||||||
report.setStatus(Constants.REPORT_STATUS_REJECTED);
|
report.setStatus(Constants.REPORT_STATUS_REJECTED);
|
||||||
@@ -153,6 +217,45 @@ public class ReportServiceImpl implements ReportService {
|
|||||||
reportMapper.update(report);
|
reportMapper.update(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void updateReport(Long id, ReportUpdateDTO updateDTO, Long currentUserId) {
|
||||||
|
// 获取当前用户
|
||||||
|
User currentUser = userMapper.selectById(currentUserId);
|
||||||
|
if (currentUser.getRole() != Constants.USER_ROLE_ADMIN) {
|
||||||
|
throw new BusinessException("只有管理员才能编辑报备");
|
||||||
|
}
|
||||||
|
|
||||||
|
Report report = reportMapper.selectById(id);
|
||||||
|
if (report == null) {
|
||||||
|
throw new BusinessException("报备不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有已通过的报备才能编辑
|
||||||
|
if (report.getStatus() != Constants.REPORT_STATUS_APPROVED) {
|
||||||
|
throw new BusinessException("只能编辑已通过的报备");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果状态改为作废,必须填写作废原因
|
||||||
|
if (updateDTO.getStatus() == Constants.REPORT_STATUS_CANCELED) {
|
||||||
|
if (updateDTO.getCancelReason() == null || updateDTO.getCancelReason().trim().isEmpty()) {
|
||||||
|
throw new BusinessException("作废时必须填写作废原因");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新报备信息
|
||||||
|
report.setProduct(updateDTO.getProduct());
|
||||||
|
report.setProjectType(updateDTO.getProjectType());
|
||||||
|
report.setDescription(updateDTO.getDescription());
|
||||||
|
report.setContactPerson(updateDTO.getContactPerson());
|
||||||
|
report.setContactPhone(updateDTO.getContactPhone());
|
||||||
|
report.setStatus(updateDTO.getStatus());
|
||||||
|
report.setCancelReason(updateDTO.getCancelReason());
|
||||||
|
report.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
reportMapper.update(report);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void withdrawReport(Long id, Long currentUserId) {
|
public void withdrawReport(Long id, Long currentUserId) {
|
||||||
@@ -189,15 +292,6 @@ public class ReportServiceImpl implements ReportService {
|
|||||||
// 批量更新报备状态
|
// 批量更新报备状态
|
||||||
List<Long> ids = expiringReports.stream().map(Report::getId).collect(Collectors.toList());
|
List<Long> ids = expiringReports.stream().map(Report::getId).collect(Collectors.toList());
|
||||||
reportMapper.batchUpdateExpired(ids);
|
reportMapper.batchUpdateExpired(ids);
|
||||||
|
|
||||||
// 批量更新客户状态为可报备
|
|
||||||
expiringReports.forEach(report -> {
|
|
||||||
Customer customer = customerMapper.selectById(report.getCustomerId());
|
|
||||||
if (customer != null && customer.getStatus() == Constants.CUSTOMER_STATUS_PROTECTED) {
|
|
||||||
customer.setStatus(Constants.CUSTOMER_STATUS_AVAILABLE);
|
|
||||||
customerMapper.update(customer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -236,6 +330,9 @@ public class ReportServiceImpl implements ReportService {
|
|||||||
case Constants.REPORT_STATUS_EXPIRED:
|
case Constants.REPORT_STATUS_EXPIRED:
|
||||||
vo.setStatusDesc("已失效");
|
vo.setStatusDesc("已失效");
|
||||||
break;
|
break;
|
||||||
|
case Constants.REPORT_STATUS_CANCELED:
|
||||||
|
vo.setStatusDesc("已作废");
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
vo.setStatusDesc("未知");
|
vo.setStatusDesc("未知");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package com.bycrm.service.impl;
|
|||||||
import com.bycrm.common.Constants;
|
import com.bycrm.common.Constants;
|
||||||
import com.bycrm.dto.ChangePasswordDTO;
|
import com.bycrm.dto.ChangePasswordDTO;
|
||||||
import com.bycrm.dto.ResetPasswordDTO;
|
import com.bycrm.dto.ResetPasswordDTO;
|
||||||
|
import com.bycrm.entity.Dealer;
|
||||||
import com.bycrm.entity.User;
|
import com.bycrm.entity.User;
|
||||||
import com.bycrm.exception.BusinessException;
|
import com.bycrm.exception.BusinessException;
|
||||||
|
import com.bycrm.mapper.DealerMapper;
|
||||||
import com.bycrm.mapper.UserMapper;
|
import com.bycrm.mapper.UserMapper;
|
||||||
import com.bycrm.service.UserService;
|
import com.bycrm.service.UserService;
|
||||||
import com.bycrm.util.JwtUtil;
|
import com.bycrm.util.JwtUtil;
|
||||||
@@ -20,11 +22,13 @@ public class UserServiceImpl implements UserService {
|
|||||||
|
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
|
private final DealerMapper dealerMapper;
|
||||||
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||||
|
|
||||||
public UserServiceImpl(UserMapper userMapper, JwtUtil jwtUtil) {
|
public UserServiceImpl(UserMapper userMapper, JwtUtil jwtUtil, DealerMapper dealerMapper) {
|
||||||
this.userMapper = userMapper;
|
this.userMapper = userMapper;
|
||||||
this.jwtUtil = jwtUtil;
|
this.jwtUtil = jwtUtil;
|
||||||
|
this.dealerMapper = dealerMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -42,6 +46,10 @@ public class UserServiceImpl implements UserService {
|
|||||||
throw new BusinessException("用户名或密码错误");
|
throw new BusinessException("用户名或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Boolean.TRUE.equals(loginDTO.getRememberMe())) {
|
||||||
|
return jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole(),
|
||||||
|
user.getDealerId(), jwtUtil.getRememberExpiration());
|
||||||
|
}
|
||||||
return jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole(), user.getDealerId());
|
return jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole(), user.getDealerId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +79,16 @@ public class UserServiceImpl implements UserService {
|
|||||||
vo.setDealerName(user.getDealerName());
|
vo.setDealerName(user.getDealerName());
|
||||||
vo.setRole(user.getRole());
|
vo.setRole(user.getRole());
|
||||||
vo.setRoleDesc(user.getRole() == Constants.USER_ROLE_ADMIN ? "管理员" : "经销商用户");
|
vo.setRoleDesc(user.getRole() == Constants.USER_ROLE_ADMIN ? "管理员" : "经销商用户");
|
||||||
|
|
||||||
|
// 如果是经销商用户,查询经销商的联系人信息
|
||||||
|
if (user.getDealerId() != null) {
|
||||||
|
Dealer dealer = dealerMapper.selectById(user.getDealerId());
|
||||||
|
if (dealer != null) {
|
||||||
|
vo.setDealerContactPerson(dealer.getContactPerson());
|
||||||
|
vo.setDealerContactPhone(dealer.getContactPhone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,24 +25,45 @@ public class JwtUtil {
|
|||||||
@Value("${jwt.expiration}")
|
@Value("${jwt.expiration}")
|
||||||
private Long expiration;
|
private Long expiration;
|
||||||
|
|
||||||
|
@Value("${jwt.remember-expiration:604800000}")
|
||||||
|
private Long rememberExpiration;
|
||||||
|
|
||||||
|
public Long getRememberExpiration() {
|
||||||
|
return rememberExpiration;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 Token
|
* 生成 Token
|
||||||
*/
|
*/
|
||||||
public String generateToken(Long userId, String username, Integer role, Long dealerId) {
|
public String generateToken(Long userId, String username, Integer role, Long dealerId) {
|
||||||
|
return generateToken(userId, username, role, dealerId, expiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成指定有效期的 Token(用于"记住我"延长有效期)
|
||||||
|
*/
|
||||||
|
public String generateToken(Long userId, String username, Integer role, Long dealerId, long expirationMillis) {
|
||||||
Map<String, Object> claims = new HashMap<>();
|
Map<String, Object> claims = new HashMap<>();
|
||||||
claims.put("userId", userId);
|
claims.put("userId", userId);
|
||||||
claims.put("username", username);
|
claims.put("username", username);
|
||||||
claims.put("role", role);
|
claims.put("role", role);
|
||||||
claims.put("dealerId", dealerId);
|
claims.put("dealerId", dealerId);
|
||||||
return generateToken(claims);
|
return generateToken(claims, expirationMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 Token
|
* 生成 Token
|
||||||
*/
|
*/
|
||||||
public String generateToken(Map<String, Object> claims) {
|
public String generateToken(Map<String, Object> claims) {
|
||||||
|
return generateToken(claims, expiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成指定有效期的 Token
|
||||||
|
*/
|
||||||
|
public String generateToken(Map<String, Object> claims, long expirationMillis) {
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
Date expiryDate = new Date(now.getTime() + expiration);
|
Date expiryDate = new Date(now.getTime() + expirationMillis);
|
||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.setClaims(claims)
|
.setClaims(claims)
|
||||||
|
|||||||
@@ -31,19 +31,24 @@ public class ReportVO implements Serializable {
|
|||||||
private String dealerName;
|
private String dealerName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户ID
|
* 学校ID
|
||||||
*/
|
*/
|
||||||
private Long customerId;
|
private Long schoolId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户名称
|
* 学校名称
|
||||||
*/
|
*/
|
||||||
private String customerName;
|
private String schoolName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户电话
|
* 所属产品
|
||||||
*/
|
*/
|
||||||
private String customerPhone;
|
private String product;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目类型
|
||||||
|
*/
|
||||||
|
private String projectType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 报备说明
|
* 报备说明
|
||||||
@@ -51,7 +56,17 @@ public class ReportVO implements Serializable {
|
|||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态:0-待审核 1-已通过 2-已驳回 3-已失效
|
* 项目负责人姓名
|
||||||
|
*/
|
||||||
|
private String contactPerson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目负责人电话
|
||||||
|
*/
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-待审核 1-已通过 2-已驳回 3-已失效 4-已作废
|
||||||
*/
|
*/
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
@@ -65,6 +80,11 @@ public class ReportVO implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String rejectReason;
|
private String rejectReason;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作废原因
|
||||||
|
*/
|
||||||
|
private String cancelReason;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保护期开始日期
|
* 保护期开始日期
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ public class UserInfoVO implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String dealerName;
|
private String dealerName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 经销商联系人
|
||||||
|
*/
|
||||||
|
private String dealerContactPerson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 经销商联系电话
|
||||||
|
*/
|
||||||
|
private String dealerContactPhone;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 角色:0-管理员 1-经销商用户
|
* 角色:0-管理员 1-经销商用户
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -55,9 +55,11 @@ mybatis:
|
|||||||
# JWT 配置
|
# JWT 配置
|
||||||
jwt:
|
jwt:
|
||||||
# HS512 算法要求密钥至少 64 字节(512 位)
|
# HS512 算法要求密钥至少 64 字节(512 位)
|
||||||
# 生产环境请使用环境变量或密钥管理系统存储此密钥
|
# 环境变量 JWT_SECRET 优先;未设置时使用下方随机默认值(本地开发可直接运行)
|
||||||
secret: by-crm-jwt-secret-key-2024-hs512-requires-at-least-64-bytes-for-secure-signing-please-change-in-production-environment
|
# 生产部署请配置 JWT_SECRET 环境变量,真实密钥不要提交到代码库
|
||||||
|
secret: "${JWT_SECRET:qX6m68jtYjbAu3UnwzJadxDfgdc5C62NL+S4HUo3M66eU7+nkbDiMijtN+cs/a9Mj1qm4IyDt2FTDpUA0GI2RQ==}"
|
||||||
expiration: 86400000 # 24小时,单位:毫秒
|
expiration: 86400000 # 24小时,单位:毫秒
|
||||||
|
remember-expiration: 604800000 # 记住我:7天免登录,单位:毫秒
|
||||||
|
|
||||||
# CRM 业务配置
|
# CRM 业务配置
|
||||||
crm:
|
crm:
|
||||||
@@ -65,6 +67,14 @@ crm:
|
|||||||
ttl-days: 90 # 保护期天数
|
ttl-days: 90 # 保护期天数
|
||||||
allow-overlap: false # 是否允许重叠报备(生产环境必须为 false)
|
allow-overlap: false # 是否允许重叠报备(生产环境必须为 false)
|
||||||
|
|
||||||
|
# pushplus 消息推送配置(经销商提交报备时通知管理员微信)
|
||||||
|
pushplus:
|
||||||
|
enabled: true # 配好 token 后改为 true
|
||||||
|
token: f832934c1b224db3a49306bc8214dc85 # pushplus 用户 token(官网登录后获取)
|
||||||
|
topic: ops # 群组编码(一对多消息中创建群组得到,管理员扫码订阅)
|
||||||
|
url: https://www.pushplus.plus/send
|
||||||
|
approval-url: https://crm.itrackvr.com/ # 推送消息中提示前往审批的系统地址
|
||||||
|
|
||||||
# Swagger 配置
|
# Swagger 配置
|
||||||
springfox:
|
springfox:
|
||||||
documentation:
|
documentation:
|
||||||
|
|||||||
@@ -6,45 +6,54 @@
|
|||||||
<resultMap id="BaseResultMap" type="com.bycrm.entity.Report">
|
<resultMap id="BaseResultMap" type="com.bycrm.entity.Report">
|
||||||
<id column="id" property="id"/>
|
<id column="id" property="id"/>
|
||||||
<result column="dealer_id" property="dealerId"/>
|
<result column="dealer_id" property="dealerId"/>
|
||||||
<result column="customer_id" property="customerId"/>
|
<result column="school_id" property="schoolId"/>
|
||||||
|
<result column="school_name" property="schoolName"/>
|
||||||
|
<result column="product" property="product"/>
|
||||||
|
<result column="project_type" property="projectType"/>
|
||||||
<result column="description" property="description"/>
|
<result column="description" property="description"/>
|
||||||
|
<result column="contact_person" property="contactPerson"/>
|
||||||
|
<result column="contact_phone" property="contactPhone"/>
|
||||||
<result column="status" property="status"/>
|
<result column="status" property="status"/>
|
||||||
<result column="reject_reason" property="rejectReason"/>
|
<result column="reject_reason" property="rejectReason"/>
|
||||||
|
<result column="cancel_reason" property="cancelReason"/>
|
||||||
<result column="protect_start_date" property="protectStartDate"/>
|
<result column="protect_start_date" property="protectStartDate"/>
|
||||||
<result column="protect_end_date" property="protectEndDate"/>
|
<result column="protect_end_date" property="protectEndDate"/>
|
||||||
<result column="created_at" property="createdAt"/>
|
<result column="created_at" property="createdAt"/>
|
||||||
<result column="updated_at" property="updatedAt"/>
|
<result column="updated_at" property="updatedAt"/>
|
||||||
<result column="dealer_name" property="dealerName"/>
|
<result column="dealer_name" property="dealerName"/>
|
||||||
<result column="customer_name" property="customerName"/>
|
|
||||||
<result column="customer_phone" property="customerPhone"/>
|
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="selectById" resultMap="BaseResultMap">
|
<select id="selectById" resultMap="BaseResultMap">
|
||||||
SELECT r.*,
|
SELECT r.*,
|
||||||
d.name AS dealer_name,
|
d.name AS dealer_name
|
||||||
c.name AS customer_name,
|
|
||||||
c.phone AS customer_phone
|
|
||||||
FROM crm_report r
|
FROM crm_report r
|
||||||
LEFT JOIN crm_dealer d ON r.dealer_id = d.id
|
LEFT JOIN crm_dealer d ON r.dealer_id = d.id
|
||||||
LEFT JOIN crm_customer c ON r.customer_id = c.id
|
|
||||||
WHERE r.id = #{id}
|
WHERE r.id = #{id}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectValidByCustomerId" resultMap="BaseResultMap">
|
<select id="selectValidBySchoolAndProduct" resultMap="BaseResultMap">
|
||||||
SELECT * FROM crm_report
|
SELECT * FROM crm_report
|
||||||
WHERE customer_id = #{customerId}
|
WHERE school_id = #{schoolId}
|
||||||
|
AND product = #{product}
|
||||||
|
AND project_type = #{projectType}
|
||||||
|
AND status IN (0, 1)
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectValidBySchoolNameAndProduct" resultMap="BaseResultMap">
|
||||||
|
SELECT * FROM crm_report
|
||||||
|
WHERE school_name = #{schoolName}
|
||||||
|
AND product = #{product}
|
||||||
|
AND project_type = #{projectType}
|
||||||
AND status IN (0, 1)
|
AND status IN (0, 1)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectPage" resultMap="BaseResultMap">
|
<select id="selectPage" resultMap="BaseResultMap">
|
||||||
SELECT r.*,
|
SELECT r.*,
|
||||||
d.name AS dealer_name,
|
d.name AS dealer_name
|
||||||
c.name AS customer_name,
|
|
||||||
c.phone AS customer_phone
|
|
||||||
FROM crm_report r
|
FROM crm_report r
|
||||||
LEFT JOIN crm_dealer d ON r.dealer_id = d.id
|
LEFT JOIN crm_dealer d ON r.dealer_id = d.id
|
||||||
LEFT JOIN crm_customer c ON r.customer_id = c.id
|
|
||||||
<where>
|
<where>
|
||||||
<if test="dealerId != null">
|
<if test="dealerId != null">
|
||||||
AND r.dealer_id = #{dealerId}
|
AND r.dealer_id = #{dealerId}
|
||||||
@@ -53,7 +62,7 @@
|
|||||||
AND d.name LIKE CONCAT('%', #{dealerName}, '%')
|
AND d.name LIKE CONCAT('%', #{dealerName}, '%')
|
||||||
</if>
|
</if>
|
||||||
<if test="customerName != null and customerName != ''">
|
<if test="customerName != null and customerName != ''">
|
||||||
AND c.name LIKE CONCAT('%', #{customerName}, '%')
|
AND r.school_name LIKE CONCAT('%', #{customerName}, '%')
|
||||||
</if>
|
</if>
|
||||||
<if test="status != null">
|
<if test="status != null">
|
||||||
AND r.status = #{status}
|
AND r.status = #{status}
|
||||||
@@ -67,7 +76,6 @@
|
|||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM crm_report r
|
FROM crm_report r
|
||||||
LEFT JOIN crm_dealer d ON r.dealer_id = d.id
|
LEFT JOIN crm_dealer d ON r.dealer_id = d.id
|
||||||
LEFT JOIN crm_customer c ON r.customer_id = c.id
|
|
||||||
<where>
|
<where>
|
||||||
<if test="dealerId != null">
|
<if test="dealerId != null">
|
||||||
AND r.dealer_id = #{dealerId}
|
AND r.dealer_id = #{dealerId}
|
||||||
@@ -76,7 +84,7 @@
|
|||||||
AND d.name LIKE CONCAT('%', #{dealerName}, '%')
|
AND d.name LIKE CONCAT('%', #{dealerName}, '%')
|
||||||
</if>
|
</if>
|
||||||
<if test="customerName != null and customerName != ''">
|
<if test="customerName != null and customerName != ''">
|
||||||
AND c.name LIKE CONCAT('%', #{customerName}, '%')
|
AND r.school_name LIKE CONCAT('%', #{customerName}, '%')
|
||||||
</if>
|
</if>
|
||||||
<if test="status != null">
|
<if test="status != null">
|
||||||
AND r.status = #{status}
|
AND r.status = #{status}
|
||||||
@@ -85,17 +93,24 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insert" parameterType="com.bycrm.entity.Report" useGeneratedKeys="true" keyProperty="id">
|
<insert id="insert" parameterType="com.bycrm.entity.Report" useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO crm_report (dealer_id, customer_id, description, status, protect_start_date, protect_end_date)
|
INSERT INTO crm_report (dealer_id, school_id, school_name, product, project_type, description, contact_person, contact_phone, status, protect_start_date, protect_end_date)
|
||||||
VALUES (#{dealerId}, #{customerId}, #{description}, #{status}, #{protectStartDate}, #{protectEndDate})
|
VALUES (#{dealerId}, #{schoolId}, #{schoolName}, #{product}, #{projectType}, #{description}, #{contactPerson}, #{contactPhone}, #{status}, #{protectStartDate}, #{protectEndDate})
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<update id="update" parameterType="com.bycrm.entity.Report">
|
<update id="update" parameterType="com.bycrm.entity.Report">
|
||||||
UPDATE crm_report
|
UPDATE crm_report
|
||||||
<set>
|
<set>
|
||||||
|
<if test="product != null">product = #{product},</if>
|
||||||
|
<if test="projectType != null">project_type = #{projectType},</if>
|
||||||
|
<if test="description != null">description = #{description},</if>
|
||||||
|
<if test="contactPerson != null">contact_person = #{contactPerson},</if>
|
||||||
|
<if test="contactPhone != null">contact_phone = #{contactPhone},</if>
|
||||||
<if test="status != null">status = #{status},</if>
|
<if test="status != null">status = #{status},</if>
|
||||||
<if test="rejectReason != null">reject_reason = #{rejectReason},</if>
|
<if test="rejectReason != null">reject_reason = #{rejectReason},</if>
|
||||||
|
<if test="cancelReason != null">cancel_reason = #{cancelReason},</if>
|
||||||
<if test="protectStartDate != null">protect_start_date = #{protectStartDate},</if>
|
<if test="protectStartDate != null">protect_start_date = #{protectStartDate},</if>
|
||||||
<if test="protectEndDate != null">protect_end_date = #{protectEndDate},</if>
|
<if test="protectEndDate != null">protect_end_date = #{protectEndDate},</if>
|
||||||
|
updated_at = NOW()
|
||||||
</set>
|
</set>
|
||||||
WHERE id = #{id}
|
WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
|
||||||
|
<mapper namespace="com.bycrm.mapper.ReportProgressMapper">
|
||||||
|
|
||||||
|
<!-- 结果映射 -->
|
||||||
|
<resultMap id="BaseResultMap" type="com.bycrm.entity.ReportProgress">
|
||||||
|
<id column="id" property="id"/>
|
||||||
|
<result column="report_id" property="reportId"/>
|
||||||
|
<result column="progress_content" property="progressContent"/>
|
||||||
|
<result column="created_by" property="createdBy"/>
|
||||||
|
<result column="created_at" property="createdAt"/>
|
||||||
|
<result column="updated_at" property="updatedAt"/>
|
||||||
|
<result column="creator_name" property="creatorName"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<!-- 基础字段 -->
|
||||||
|
<sql id="Base_Column_List">
|
||||||
|
id, report_id, progress_content, created_by, created_at, updated_at
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- 根据ID查询 -->
|
||||||
|
<select id="selectById" resultMap="BaseResultMap">
|
||||||
|
SELECT
|
||||||
|
rp.*, u.real_name as creator_name
|
||||||
|
FROM
|
||||||
|
crm_report_progress rp
|
||||||
|
LEFT JOIN
|
||||||
|
crm_user u ON rp.created_by = u.id
|
||||||
|
WHERE
|
||||||
|
rp.id = #{id}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 根据报备ID查询 -->
|
||||||
|
<select id="selectByReportId" resultMap="BaseResultMap">
|
||||||
|
SELECT
|
||||||
|
rp.*, u.real_name as creator_name
|
||||||
|
FROM
|
||||||
|
crm_report_progress rp
|
||||||
|
LEFT JOIN
|
||||||
|
crm_user u ON rp.created_by = u.id
|
||||||
|
WHERE
|
||||||
|
rp.report_id = #{reportId}
|
||||||
|
ORDER BY
|
||||||
|
rp.created_at DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 插入 -->
|
||||||
|
<insert id="insert" parameterType="com.bycrm.entity.ReportProgress" useGeneratedKeys="true" keyProperty="id">
|
||||||
|
INSERT INTO crm_report_progress (
|
||||||
|
report_id, progress_content, created_by, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
#{reportId}, #{progressContent}, #{createdBy}, #{createdAt}, #{updatedAt}
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 更新 -->
|
||||||
|
<update id="update" parameterType="com.bycrm.entity.ReportProgress">
|
||||||
|
UPDATE crm_report_progress
|
||||||
|
SET
|
||||||
|
progress_content = #{progressContent},
|
||||||
|
updated_at = #{updatedAt}
|
||||||
|
WHERE
|
||||||
|
id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 删除 -->
|
||||||
|
<delete id="deleteById">
|
||||||
|
DELETE FROM crm_report_progress WHERE id = #{id}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -12,6 +12,12 @@
|
|||||||
<result column="updated_at" property="updatedAt"/>
|
<result column="updated_at" property="updatedAt"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
|
<select id="selectById" resultMap="BaseResultMap">
|
||||||
|
SELECT id, school_code, school_name, location, created_at, updated_at
|
||||||
|
FROM crm_school
|
||||||
|
WHERE id = #{id}
|
||||||
|
</select>
|
||||||
|
|
||||||
<insert id="insert" parameterType="com.bycrm.entity.School" useGeneratedKeys="true" keyProperty="id">
|
<insert id="insert" parameterType="com.bycrm.entity.School" useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO crm_school (school_code, school_name, location, created_at, updated_at)
|
INSERT INTO crm_school (school_code, school_name, location, created_at, updated_at)
|
||||||
VALUES (#{schoolCode}, #{schoolName}, #{location}, #{createdAt}, #{updatedAt})
|
VALUES (#{schoolCode}, #{schoolName}, #{location}, #{createdAt}, #{updatedAt})
|
||||||
|
|||||||
Binary file not shown.
+22
-23
@@ -1,11 +1,9 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
crm_network:
|
crm_network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: 192.168.100.0/24
|
- subnet: 192.244.0.0/24
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# MySQL 数据库
|
# MySQL 数据库
|
||||||
@@ -22,7 +20,7 @@ services:
|
|||||||
'--collation-server=utf8mb4_unicode_ci',
|
'--collation-server=utf8mb4_unicode_ci',
|
||||||
]
|
]
|
||||||
ports:
|
ports:
|
||||||
- "3307:3306"
|
- "39948:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- ./mysql/data:/var/lib/mysql
|
- ./mysql/data:/var/lib/mysql
|
||||||
- ./sql:/docker-entrypoint-initdb.d:ro
|
- ./sql:/docker-entrypoint-initdb.d:ro
|
||||||
@@ -37,6 +35,7 @@ services:
|
|||||||
|
|
||||||
# 后端服务
|
# 后端服务
|
||||||
crm-backend:
|
crm-backend:
|
||||||
|
image: by-crm/crm-backend:2026.01.27
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -59,7 +58,7 @@ services:
|
|||||||
SERVER_PORT: 8080
|
SERVER_PORT: 8080
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "9948:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/logs:/app/logs
|
- ./backend/logs:/app/logs
|
||||||
networks:
|
networks:
|
||||||
@@ -71,21 +70,21 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
# 前端服务(Nginx)
|
# # 前端服务(Nginx)
|
||||||
crm-frontend:
|
# crm-frontend:
|
||||||
build:
|
# build:
|
||||||
context: ./frontend
|
# context: ./frontend
|
||||||
dockerfile: Dockerfile
|
# dockerfile: Dockerfile
|
||||||
container_name: crm-frontend
|
# container_name: crm-frontend
|
||||||
restart: always
|
# restart: always
|
||||||
depends_on:
|
# depends_on:
|
||||||
- crm-backend
|
# - crm-backend
|
||||||
ports:
|
# ports:
|
||||||
- "80:80"
|
# - "9948:80"
|
||||||
networks:
|
# networks:
|
||||||
- crm_network
|
# - crm_network
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"]
|
# test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"]
|
||||||
interval: 30s
|
# interval: 30s
|
||||||
timeout: 5s
|
# timeout: 5s
|
||||||
retries: 3
|
# retries: 3
|
||||||
+272
-237
@@ -1,15 +1,16 @@
|
|||||||
# 经销商管理系统
|
# 泊云智销通 CRM 系统
|
||||||
|
|
||||||
## 项目简介
|
## 项目简介
|
||||||
|
|
||||||
经销商管理系统是一款面向企业渠道管理的 CRM 系统,旨在解决渠道管理中的核心问题——**客户报备冲突**与**保护期归属**。系统通过唯一有效报备机制与自动到期释放机制,确保同一客户在同一时间只能被一个经销商跟进,并在保护期结束后自动回归可报备池,避免长期占坑,提升渠道效率与公平性。
|
泊云智销通是一款面向企业渠道管理的 CRM 系统,核心解决**报备冲突**与**保护期归属**问题。系统通过**唯一有效报备机制**与**自动到期释放机制**,确保同一学校的同一项目在同一时间只能被一个经销商跟进,并在保护期结束后自动释放资源,提升渠道效率与公平性。
|
||||||
|
|
||||||
### 核心价值
|
### 核心价值
|
||||||
|
|
||||||
- **防撞单机制**:同一客户同一时间只能有一个有效报备
|
- **防撞单机制**:基于"学校+产品+项目类型"组合的唯一性校验,避免恶意抢占
|
||||||
- **保护期管理**:自动计算和跟踪保护期,保护期到期自动释放
|
- **保护期管理**:自动计算保护期,到期自动释放,无需人工干预
|
||||||
- **公平竞争**:避免经销商长期占用客户资源
|
- **公平竞争**:防止资源长期占用,所有经销商公平竞争
|
||||||
- **全程可追溯**:所有报备记录永久保留,支持审计
|
- **全程可追溯**:报备记录永久保留,支持进展记录追踪
|
||||||
|
- **灵活配置**:支持手动输入学校名称,降低报备门槛
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,12 +24,13 @@
|
|||||||
- **安全**: Spring Security + JWT + BCrypt
|
- **安全**: Spring Security + JWT + BCrypt
|
||||||
- **API 文档**: Swagger
|
- **API 文档**: Swagger
|
||||||
- **构建工具**: Maven
|
- **构建工具**: Maven
|
||||||
|
- **定时任务**: Spring Task Scheduler
|
||||||
|
|
||||||
### 前端技术栈
|
### 前端技术栈
|
||||||
|
|
||||||
- **框架**: Vue 3 + TypeScript
|
- **框架**: Vue 3 + TypeScript
|
||||||
- **UI 组件**: Element Plus
|
- **UI 组件**: Element Plus
|
||||||
- **路由**: Vue Router
|
- **路由**: Vue Router 4
|
||||||
- **状态管理**: Pinia
|
- **状态管理**: Pinia
|
||||||
- **HTTP 客户端**: Axios
|
- **HTTP 客户端**: Axios
|
||||||
- **构建工具**: Vite
|
- **构建工具**: Vite
|
||||||
@@ -39,7 +41,7 @@
|
|||||||
- **前后端分离**: RESTful API 架构
|
- **前后端分离**: RESTful API 架构
|
||||||
- **MVC 分层**: Controller → Service → Mapper → Entity
|
- **MVC 分层**: Controller → Service → Mapper → Entity
|
||||||
- **数据库设计**: 遵循范式,合理使用索引
|
- **数据库设计**: 遵循范式,合理使用索引
|
||||||
- **安全设计**: 前端 SHA-256 哈希 + 后端 BCrypt 双重加密
|
- **安全设计**: JWT 认证,BCrypt 密码加密
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -50,15 +52,13 @@
|
|||||||
**角色标识**: `role = 0`
|
**角色标识**: `role = 0`
|
||||||
|
|
||||||
**权限范围**:
|
**权限范围**:
|
||||||
- ✅ 客户管理(全部客户的查看、新增、编辑、删除)
|
- ✅ 报备管理(全部报备的查看、审核、编辑、作废)
|
||||||
- ✅ 报备管理(全部报备的查看、审核、撤回)
|
|
||||||
- ✅ 经销商管理(经销商的增删改查、重置密码)
|
- ✅ 经销商管理(经销商的增删改查、重置密码)
|
||||||
|
- ✅ 学校管理(查询、批量导入学校数据)
|
||||||
- ✅ 系统配置(保护期天数等参数配置)
|
- ✅ 系统配置(保护期天数等参数配置)
|
||||||
- ✅ 数据报表(经销商活跃度、客户转化率等)
|
|
||||||
- ✅ 查看所有运营数据
|
- ✅ 查看所有运营数据
|
||||||
|
|
||||||
**主要职责**:
|
**主要职责**:
|
||||||
- 维护客户信息
|
|
||||||
- 审核报备申请
|
- 审核报备申请
|
||||||
- 管理经销商账号
|
- 管理经销商账号
|
||||||
- 配置系统参数
|
- 配置系统参数
|
||||||
@@ -69,30 +69,29 @@
|
|||||||
**角色标识**: `role = 1`
|
**角色标识**: `role = 1`
|
||||||
|
|
||||||
**权限范围**:
|
**权限范围**:
|
||||||
- ✅ 查看自己的客户列表
|
|
||||||
- ✅ 提交报备申请(选择客户进行报备)
|
|
||||||
- ✅ 查看自己的报备记录
|
- ✅ 查看自己的报备记录
|
||||||
|
- ✅ 提交报备申请
|
||||||
- ✅ 撤回待审核的报备
|
- ✅ 撤回待审核的报备
|
||||||
- ✅ 查看报备审核状态
|
- ✅ 为已通过的报备添加进展记录
|
||||||
- ✅ 修改个人密码
|
- ✅ 修改个人密码
|
||||||
|
|
||||||
**主要职责**:
|
**主要职责**:
|
||||||
- 报备意向客户
|
- 报备意向学校项目
|
||||||
- 跟进保护期内的客户
|
- 跟进保护期内的项目
|
||||||
- 维护客户信息
|
- 维护进展记录
|
||||||
- 管理报备记录
|
|
||||||
|
|
||||||
### 权限矩阵
|
### 权限矩阵
|
||||||
|
|
||||||
| 功能模块 | 管理员 | 经销商 |
|
| 功能模块 | 管理员 | 经销商 |
|
||||||
|---------|--------|--------|
|
|---------|--------|--------|
|
||||||
| 客户管理 | ✅ 全部 | ✅ 仅查看 |
|
| 报备管理 | 全部 | 自己的报备 |
|
||||||
| 报备管理 | ✅ 全部 | ✅ 自己的报备 |
|
| 提交报备 | ✅ | ✅ |
|
||||||
| 报备审核 | ✅ | ❌ |
|
| 报备审核 | ✅ | ❌ |
|
||||||
|
| 报备编辑/作废 | ✅ | ❌ |
|
||||||
|
| 进展记录 | 全部 | 自己报备的进展 |
|
||||||
| 经销商管理 | ✅ | ❌ |
|
| 经销商管理 | ✅ | ❌ |
|
||||||
|
| 学校管理 | ✅ | ❌ |
|
||||||
| 系统配置 | ✅ | ❌ |
|
| 系统配置 | ✅ | ❌ |
|
||||||
| 数据报表 | ✅ | ❌ |
|
|
||||||
| 密码管理 | ✅ 自己的 | ✅ 自己的 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,111 +102,92 @@
|
|||||||
#### 1.1 用户登录
|
#### 1.1 用户登录
|
||||||
- **路径**: `POST /api/auth/login`
|
- **路径**: `POST /api/auth/login`
|
||||||
- **描述**: 用户登录系统,获取 JWT Token
|
- **描述**: 用户登录系统,获取 JWT Token
|
||||||
- **安全**: 前端 SHA-256 哈希 + 后端 BCrypt 加密
|
- **安全**: BCrypt 加密存储
|
||||||
- **返回**: Token + 用户基本信息
|
- **返回**: Token + 用户基本信息
|
||||||
|
|
||||||
**默认账号**:
|
**默认账号**:
|
||||||
- 管理员: `admin` / `admin123`
|
- 管理员: `admin` / `admin123`
|
||||||
- 经销商1: `user001` / `admin123`
|
- 经销商用户: 根据经销商数据
|
||||||
- 经销商2: `user002` / `admin123`
|
|
||||||
- 经销商3: `user003` / `admin123`
|
|
||||||
|
|
||||||
#### 1.2 修改密码
|
#### 1.2 修改密码
|
||||||
- **路径**: `POST /api/auth/change-password`
|
- **路径**: `POST /api/auth/change-password`
|
||||||
- **权限**: 所有用户
|
- **权限**: 所有用户
|
||||||
- **功能**: 用户修改自己的登录密码
|
- **功能**: 用户修改自己的登录密码
|
||||||
- **验证**: 需要输入原密码验证
|
- **验证**: 需要输入原密码验证
|
||||||
- **安全**: 密码 6-20 位,传输前哈希加密
|
|
||||||
|
|
||||||
#### 1.3 重置密码
|
#### 1.3 重置密码
|
||||||
- **路径**: `POST /api/auth/reset-password`
|
- **路径**: `POST /api/auth/reset-password`
|
||||||
- **权限**: 仅管理员
|
- **权限**: 仅管理员
|
||||||
- **功能**: 管理员重置经销商用户的密码
|
- **功能**: 管理员重置经销商用户的密码
|
||||||
- **限制**: 不能重置管理员账号的密码
|
|
||||||
|
|
||||||
#### 1.4 退出登录
|
|
||||||
- **路径**: `POST /api/auth/logout`
|
|
||||||
- **功能**: 清除客户端 Token
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. 客户管理
|
### 2. 报备管理
|
||||||
|
|
||||||
#### 2.1 客户列表
|
#### 2.1 报备列表
|
||||||
- **路径**: `GET /api/customer/page`
|
|
||||||
- **权限**: 管理员查看全部,经销商查看全部
|
|
||||||
- **功能**: 分页查询客户信息
|
|
||||||
- **搜索字段**: 客户名称、所属行业、状态
|
|
||||||
- **状态说明**:
|
|
||||||
- `0 - 可报备`: 未被保护,可报备
|
|
||||||
- `1 - 保护中`: 正在保护期内
|
|
||||||
- `2 - 已失效`: 保护期已过
|
|
||||||
|
|
||||||
#### 2.2 新增客户
|
|
||||||
- **路径**: `POST /api/customer`
|
|
||||||
- **权限**: 仅管理员
|
|
||||||
- **功能**: 创建新客户信息
|
|
||||||
- **必填字段**: 客户名称
|
|
||||||
- **可选字段**: 联系电话、地址、所属行业
|
|
||||||
- **学校联动**: 支持从学校库自动完成选择
|
|
||||||
|
|
||||||
#### 2.3 编辑客户
|
|
||||||
- **路径**: `PUT /api/customer/{id}`
|
|
||||||
- **权限**: 仅管理员
|
|
||||||
- **功能**: 修改客户信息
|
|
||||||
|
|
||||||
#### 2.4 删除客户
|
|
||||||
- **路径**: `DELETE /api/customer/{id}`
|
|
||||||
- **权限**: 仅管理员
|
|
||||||
- **功能**: 删除客户记录
|
|
||||||
- **限制**: 有报备记录的客户需谨慎处理
|
|
||||||
|
|
||||||
#### 2.5 客户搜索
|
|
||||||
- **路径**: `GET /api/customer/search`
|
|
||||||
- **功能**: 模糊搜索客户名称(用于报备时的客户选择)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 报备管理
|
|
||||||
|
|
||||||
#### 3.1 报备列表
|
|
||||||
- **路径**: `GET /api/report/page`
|
- **路径**: `GET /api/report/page`
|
||||||
- **权限**: 管理员查看全部,经销商查看自己的
|
- **权限**: 管理员查看全部,经销商查看自己的
|
||||||
- **功能**: 分页查询报备记录
|
- **功能**: 分页查询报备记录
|
||||||
|
- **搜索字段**: 学校名称、状态
|
||||||
- **状态说明**:
|
- **状态说明**:
|
||||||
- `0 - 待审核`: 等待管理员审核
|
- `0 - 待审核`: 等待管理员审核
|
||||||
- `1 - 已通过`: 审核通过,保护期内
|
- `1 - 已通过`: 审核通过,保护期内
|
||||||
- `2 - 已驳回`: 审核未通过
|
- `2 - 已驳回`: 审核未通过
|
||||||
- `3 - 已失效`: 保护期已过
|
- `3 - 已失效`: 保护期已过
|
||||||
|
- `4 - 已作废`: 管理员主动作废
|
||||||
|
|
||||||
#### 3.2 提交报备
|
#### 2.2 提交报备
|
||||||
- **路径**: `POST /api/report`
|
- **路径**: `POST /api/report`
|
||||||
- **权限**: 经销商
|
- **权限**: 经销商
|
||||||
- **功能**: 经销商报备意向客户
|
- **功能**: 经销商报备意向学校项目
|
||||||
- **必填字段**: 客户ID、报备说明
|
- **必填字段**: 学校名称、所属产品、项目类型
|
||||||
- **限制**:
|
- **可选字段**: 报备说明
|
||||||
- 同一客户同一时间只能有一个有效报备
|
- **防撞单规则**:
|
||||||
- 已保护的客户无法再次报备
|
- 同一学校的同一产品+项目类型组合,只能有一个有效报备
|
||||||
- 待审核的报备可撤回
|
- 有效报备定义:状态为"待审核(0)"或"已通过(1)"
|
||||||
|
- 已失效、已驳回、已作废的报备不影响新报备
|
||||||
|
|
||||||
#### 3.3 撤回报备
|
#### 2.3 撤回报备
|
||||||
- **路径**: `DELETE /api/report/{id}`
|
- **路径**: `DELETE /api/report/{id}`
|
||||||
- **权限**: 报备所属经销商
|
- **权限**: 报备所属经销商
|
||||||
- **功能**: 撤回待审核的报备申请
|
- **功能**: 撤回待审核的报备申请
|
||||||
- **限制**: 已通过和已驳回的报备无法撤回
|
- **限制**: 只能撤回状态为"待审核"的报备
|
||||||
|
|
||||||
#### 3.4 审核报备
|
#### 2.4 审核报备
|
||||||
- **路径**: `PUT /api/report/{id}/audit`
|
- **路径**: `PUT /api/report/{id}/audit`
|
||||||
- **权限**: 仅管理员
|
- **权限**: 仅管理员
|
||||||
- **功能**: 审核报备申请
|
- **功能**: 审核报备申请
|
||||||
- **操作**:
|
- **操作**:
|
||||||
- 通过:设置保护期(默认90天),客户状态改为"保护中"
|
- 通过:设置保护期(默认90天),状态变为"已通过"
|
||||||
- 驳回:填写驳回原因,客户保持可报备状态
|
- 驳回:填写驳回原因,状态变为"已驳回"
|
||||||
|
|
||||||
#### 3.5 报备详情
|
#### 2.5 编辑报备
|
||||||
|
- **路径**: `PUT /api/report/{id}`
|
||||||
|
- **权限**: 仅管理员
|
||||||
|
- **功能**: 编辑已通过的报备
|
||||||
|
- **可编辑字段**: 所属产品、项目类型、报备说明、状态
|
||||||
|
- **作废操作**: 状态改为"已作废"时必须填写作废原因
|
||||||
|
|
||||||
|
#### 2.6 报备详情
|
||||||
- **路径**: `GET /api/report/{id}`
|
- **路径**: `GET /api/report/{id}`
|
||||||
- **权限**: 根据权限查看
|
- **权限**: 根据权限查看
|
||||||
- **功能**: 查看报备详细信息
|
- **功能**: 查看报备详细信息和进展记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 进展记录管理
|
||||||
|
|
||||||
|
#### 3.1 添加进展记录
|
||||||
|
- **路径**: `POST /api/report-progress`
|
||||||
|
- **权限**: 经销商只能为自己的报备添加,管理员可查看所有
|
||||||
|
- **功能**: 为已通过的报备添加进展记录
|
||||||
|
- **限制**: 只能为状态为"已通过"的报备添加进展
|
||||||
|
|
||||||
|
#### 3.2 编辑进展记录
|
||||||
|
- **路径**: `PUT /api/report-progress/{id}`
|
||||||
|
- **权限**: 进展记录创建者
|
||||||
|
- **功能**: 修改进展记录内容
|
||||||
|
- **保留历史**: 不支持删除,确保数据完整性
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -217,18 +197,12 @@
|
|||||||
- **路径**: `GET /api/dealer/list`
|
- **路径**: `GET /api/dealer/list`
|
||||||
- **权限**: 仅管理员
|
- **权限**: 仅管理员
|
||||||
- **功能**: 查询所有经销商信息
|
- **功能**: 查询所有经销商信息
|
||||||
- **状态**: 启用/禁用
|
|
||||||
|
|
||||||
#### 4.2 新增经销商
|
#### 4.2 新增经销商
|
||||||
- **路径**: `POST /api/dealer`
|
- **路径**: `POST /api/dealer`
|
||||||
- **权限**: 仅管理员
|
- **权限**: 仅管理员
|
||||||
- **必填字段**: 经销商名称、经销商账号(用作登录用户名)、联系人、联系电话
|
- **必填字段**: 经销商名称、经销商账号、联系人、联系电话
|
||||||
- **可选字段**: 邮箱、初始密码
|
- **自动创建**: 创建经销商记录的同时创建对应的登录用户
|
||||||
- **自动创建**:
|
|
||||||
- 创建经销商记录
|
|
||||||
- 创建对应的登录用户
|
|
||||||
- 用户名 = 经销商账号
|
|
||||||
- 默认密码 = 123456(可自定义)
|
|
||||||
|
|
||||||
#### 4.3 编辑经销商
|
#### 4.3 编辑经销商
|
||||||
- **路径**: `PUT /api/dealer/{id}`
|
- **路径**: `PUT /api/dealer/{id}`
|
||||||
@@ -240,11 +214,6 @@
|
|||||||
- **权限**: 仅管理员
|
- **权限**: 仅管理员
|
||||||
- **功能**: 删除经销商及其关联用户
|
- **功能**: 删除经销商及其关联用户
|
||||||
|
|
||||||
#### 4.5 重置密码
|
|
||||||
- **路径**: `POST /api/auth/reset-password`
|
|
||||||
- **权限**: 仅管理员
|
|
||||||
- **功能**: 重置经销商用户的登录密码
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. 学校管理
|
### 5. 学校管理
|
||||||
@@ -257,8 +226,8 @@
|
|||||||
#### 5.2 学校搜索
|
#### 5.2 学校搜索
|
||||||
- **路径**: `GET /api/school/search?keyword=xxx`
|
- **路径**: `GET /api/school/search?keyword=xxx`
|
||||||
- **权限**: 所有用户
|
- **权限**: 所有用户
|
||||||
- **功能**: 模糊搜索学校名称(用于客户名称自动完成)
|
- **功能**: 模糊搜索学校名称
|
||||||
- **应用场景**: 新增客户时,输入学校名称,自动匹配学校库
|
- **应用场景**: 报备时学校名称的自动完成提示
|
||||||
|
|
||||||
#### 5.3 批量导入学校
|
#### 5.3 批量导入学校
|
||||||
- **路径**: `POST /api/school/import`
|
- **路径**: `POST /api/school/import`
|
||||||
@@ -266,13 +235,8 @@
|
|||||||
- **功能**: 上传 Excel 文件批量导入学校数据
|
- **功能**: 上传 Excel 文件批量导入学校数据
|
||||||
- **文件格式**: `.xls` 或 `.xlsx`
|
- **文件格式**: `.xls` 或 `.xlsx`
|
||||||
|
|
||||||
#### 5.4 从 docs 目录导入
|
|
||||||
- **路径**: `POST /api/school/import-from-docs`
|
|
||||||
- **权限**: 仅管理员
|
|
||||||
- **功能**: 直接导入项目 docs 目录下的 `school.xls` 文件
|
|
||||||
|
|
||||||
**学校数据结构**:
|
**学校数据结构**:
|
||||||
- 学校标识码
|
- 学校标识码(唯一)
|
||||||
- 学校名称
|
- 学校名称
|
||||||
- 所在地(省份 + 城市)
|
- 所在地(省份 + 城市)
|
||||||
|
|
||||||
@@ -284,21 +248,18 @@
|
|||||||
- **配置项**: `report.protect.days`
|
- **配置项**: `report.protect.days`
|
||||||
- **默认值**: `90` 天
|
- **默认值**: `90` 天
|
||||||
- **说明**: 控制报备通过后的保护期时长
|
- **说明**: 控制报备通过后的保护期时长
|
||||||
- **影响**:
|
|
||||||
- 保护期计算
|
|
||||||
- 自动失效定时任务
|
|
||||||
|
|
||||||
#### 6.2 报备冲突控制
|
#### 6.2 报备冲突控制
|
||||||
- **配置项**: `report.allow-overlap`
|
- **配置项**: `report.allow.overlap`
|
||||||
- **默认值**: `false`
|
- **默认值**: `false`
|
||||||
- **说明**: 是否允许同一客户的报备重叠(仅用于测试)
|
- **说明**: 是否允许重叠报备(仅用于测试)
|
||||||
- **生产**: 必须为 `false`
|
- **生产环境**: 必须为 `false`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 业务流程
|
## 业务流程
|
||||||
|
|
||||||
### 报备生命周期
|
### 报备完整生命周期
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
@@ -306,50 +267,61 @@
|
|||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
1. 经销商提交报备
|
1. 经销商提交报备
|
||||||
├─ 选择客户(只能选择"可报备"状态的客户)
|
├─ 学校名称(可从学校库选择或手动输入)
|
||||||
├─ 填写报备说明
|
├─ 所属产品(必填)
|
||||||
|
├─ 项目类型(必填)
|
||||||
|
├─ 报备说明(可选)
|
||||||
└─ 提交审核
|
└─ 提交审核
|
||||||
↓
|
↓
|
||||||
状态:待审核(0)
|
状态:待审核(0)
|
||||||
客户状态:可报备(0)
|
|
||||||
|
|
||||||
2. 管理员审核
|
2. 管理员审核
|
||||||
├─ 通过 → 设置保护期(如90天)
|
├─ 通过 → 设置保护期(默认90天)
|
||||||
│ ├─ 报备状态:已通过(1)
|
│ ├─ 报备状态:已通过(1)
|
||||||
│ └─ 客户状态:保护中(1)
|
│ └─ 保护期倒计时开始
|
||||||
│
|
│
|
||||||
└─ 驳回 → 填写驳回原因
|
└─ 驳回 → 填写驳回原因
|
||||||
├─ 报备状态:已驳回(2)
|
└─ 报备状态:已驳回(2)
|
||||||
└─ 客户状态:可报备(0)
|
|
||||||
|
|
||||||
3. 保护期内
|
3. 保护期内
|
||||||
├─ 客户被锁定,其他经销商无法报备
|
├─ 该"学校+产品+项目类型"组合被锁定
|
||||||
├─ 原报备经销商可跟进客户
|
├─ 其他经销商无法报备相同组合
|
||||||
|
├─ 经销商可添加进展记录
|
||||||
└─ 剩余保护天数倒计时
|
└─ 剩余保护天数倒计时
|
||||||
|
|
||||||
4. 保护期到期(定时任务)
|
4. 管理员操作(可选)
|
||||||
|
├─ 编辑报备:修改产品、项目类型、说明
|
||||||
|
└─ 作废报备:状态改为已作废(4),填写原因
|
||||||
|
└─ 作废后该组合可重新报备
|
||||||
|
|
||||||
|
5. 保护期到期(定时任务)
|
||||||
└─ 每天凌晨1点自动执行
|
└─ 每天凌晨1点自动执行
|
||||||
├─ 报备状态:已通过 → 已失效(3)
|
├─ 报备状态:已通过 → 已失效(3)
|
||||||
└─ 客户状态:保护中 → 可报备(0)
|
└─ 该"学校+产品+项目类型"组合可再次报备
|
||||||
|
|
||||||
5. 客户释放
|
|
||||||
└─ 所有经销商可再次报备该客户
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 客户状态流转
|
### 报备状态转换规则
|
||||||
|
|
||||||
```
|
```
|
||||||
可报备(0) ──[提交报备]──> 锁定(其他经销商不可报备)
|
待审核(0)
|
||||||
│
|
↓ [管理员审核通过]
|
||||||
[管理员审核通过]
|
已通过(1) ←───────┐
|
||||||
↓
|
↓ │
|
||||||
保护中(1) ──[90天后]──> 可报备(0)
|
│ [保护期到期] │ [管理员编辑恢复]
|
||||||
↑
|
↓ │
|
||||||
[报备被驳回]
|
已失效(3) ─────────┘
|
||||||
│
|
|
||||||
[管理员驳回]
|
待审核(0)
|
||||||
↓
|
↓ [管理员驳回]
|
||||||
可报备(0)
|
已驳回(2) ←─── 终态
|
||||||
|
|
||||||
|
待审核(0)
|
||||||
|
↓ [经销商撤回]
|
||||||
|
删除记录 ←─── 物理删除
|
||||||
|
|
||||||
|
已通过(1)
|
||||||
|
↓ [管理员作废]
|
||||||
|
已作废(4) ←─── 终态
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -358,39 +330,31 @@
|
|||||||
|
|
||||||
### 核心表结构
|
### 核心表结构
|
||||||
|
|
||||||
#### 1. 客户表 (crm_customer)
|
#### 1. 报备表 (crm_report)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | BIGINT | 客户ID(主键) |
|
|
||||||
| name | VARCHAR(200) | 客户名称 |
|
|
||||||
| phone | VARCHAR(20) | 联系电话 |
|
|
||||||
| address | VARCHAR(500) | 地址 |
|
|
||||||
| industry | VARCHAR(50) | 所属行业 |
|
|
||||||
| status | TINYINT | 状态:0-可报备 1-保护中 2-已失效 |
|
|
||||||
| current_dealer_id | BIGINT | 当前报备经销商ID |
|
|
||||||
| protect_end_date | DATE | 保护期结束日期 |
|
|
||||||
| created_at | DATETIME | 创建时间 |
|
|
||||||
| updated_at | DATETIME | 更新时间 |
|
|
||||||
|
|
||||||
#### 2. 报备表 (crm_report)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| id | BIGINT | 报备ID(主键) |
|
| id | BIGINT | 报备ID(主键) |
|
||||||
| dealer_id | BIGINT | 经销商ID |
|
| dealer_id | BIGINT | 经销商ID |
|
||||||
| customer_id | BIGINT | 客户ID |
|
| school_id | BIGINT | 学校ID(可为NULL,手动输入时为空) |
|
||||||
|
| school_name | VARCHAR(255) | 学校名称(冗余存储,方便查询) |
|
||||||
|
| product | VARCHAR(255) | 所属产品 |
|
||||||
|
| project_type | VARCHAR(255) | 项目类型 |
|
||||||
| description | VARCHAR(500) | 报备说明 |
|
| description | VARCHAR(500) | 报备说明 |
|
||||||
| status | TINYINT | 状态:0-待审核 1-已通过 2-已驳回 3-已失效 |
|
| status | TINYINT | 状态:0-待审核 1-已通过 2-已驳回 3-已失效 4-已作废 |
|
||||||
| reject_reason | VARCHAR(255) | 驳回原因 |
|
| reject_reason | VARCHAR(255) | 驳回原因 |
|
||||||
|
| cancel_reason | VARCHAR(500) | 作废原因 |
|
||||||
| protect_start_date | DATE | 保护期开始日期 |
|
| protect_start_date | DATE | 保护期开始日期 |
|
||||||
| protect_end_date | DATE | 保护期结束日期 |
|
| protect_end_date | DATE | 保护期结束日期 |
|
||||||
| created_at | DATETIME | 创建时间 |
|
| created_at | DATETIME | 创建时间 |
|
||||||
| updated_at | DATETIME | 更新时间 |
|
| updated_at | DATETIME | 更新时间 |
|
||||||
|
|
||||||
**唯一索引**: `uk_customer_valid (customer_id, status)` - 同一客户只能有一个有效报备
|
**索引**:
|
||||||
|
- `idx_dealer_id` - 经销商ID索引
|
||||||
|
- `idx_status` - 状态索引
|
||||||
|
- `idx_protect_end_date` - 保护期结束日期索引(定时任务使用)
|
||||||
|
|
||||||
#### 3. 经销商表 (crm_dealer)
|
#### 2. 经销商表 (crm_dealer)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@@ -404,28 +368,58 @@
|
|||||||
| created_at | DATETIME | 创建时间 |
|
| created_at | DATETIME | 创建时间 |
|
||||||
| updated_at | DATETIME | 更新时间 |
|
| updated_at | DATETIME | 更新时间 |
|
||||||
|
|
||||||
#### 4. 用户表 (crm_user)
|
#### 3. 用户表 (crm_user)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| id | BIGINT | 用户ID(主键) |
|
| id | BIGINT | 用户ID(主键) |
|
||||||
| username | VARCHAR(50) | 用户名(登录账号) |
|
| username | VARCHAR(50) | 用户名(登录账号) |
|
||||||
| password | VARCHAR(255) | 密码(BCrypt加密) |
|
| password | VARCHAR(255) | 密码(BCrypt加密) |
|
||||||
| real_name | VARCHAR(50) | 真实姓名 |
|
|
||||||
| dealer_id | BIGINT | 关联经销商ID(管理员为NULL) |
|
| dealer_id | BIGINT | 关联经销商ID(管理员为NULL) |
|
||||||
| role | TINYINT | 角色:0-管理员 1-经销商用户 |
|
| role | TINYINT | 角色:0-管理员 1-经销商用户 |
|
||||||
| status | TINYINT | 状态:0-禁用 1-启用 |
|
| status | TINYINT | 状态:0-禁用 1-启用 |
|
||||||
| created_at | DATETIME | 创建时间 |
|
| created_at | DATETIME | 创建时间 |
|
||||||
| updated_at | DATETIME | 更新时间 |
|
| updated_at | DATETIME | 更新时间 |
|
||||||
|
|
||||||
#### 5. 学校表 (crm_school)
|
#### 4. 学校表 (crm_school)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| id | BIGINT | 学校ID(主键) |
|
| id | BIGINT | 学校ID(主键) |
|
||||||
| school_code | VARCHAR(50) | 学校标识码(唯一) |
|
| school_code | VARCHAR(50) | 学校标识码(唯一) |
|
||||||
| school_name | VARCHAR(200) | 学校名称 |
|
| school_name | VARCHAR(200) | 学校名称 |
|
||||||
| location | VARCHAR(255) | 所在地(省份+城市) |
|
| location | VARCHAR(255) | 所在地 |
|
||||||
|
| created_at | DATETIME | 创建时间 |
|
||||||
|
| updated_at | DATETIME | 更新时间 |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- `uk_school_code` - 学校标识码唯一索引
|
||||||
|
- `idx_school_name` - 学校名称索引
|
||||||
|
|
||||||
|
#### 5. 报备进展记录表 (crm_report_progress)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | BIGINT | 主键ID |
|
||||||
|
| report_id | BIGINT | 报备ID |
|
||||||
|
| progress_content | VARCHAR(500) | 进展内容 |
|
||||||
|
| created_by | BIGINT | 创建人ID |
|
||||||
|
| created_at | DATETIME | 创建时间 |
|
||||||
|
| updated_at | DATETIME | 更新时间 |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- `idx_report_id` - 报备ID索引
|
||||||
|
- `idx_created_by` - 创建人索引
|
||||||
|
|
||||||
|
#### 6. 系统配置表 (crm_system_config)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | BIGINT | 主键ID |
|
||||||
|
| config_key | VARCHAR(100) | 配置键(唯一) |
|
||||||
|
| config_value | VARCHAR(500) | 配置值 |
|
||||||
|
| config_type | VARCHAR(20) | 配置类型(string/integer/boolean) |
|
||||||
|
| is_editable | TINYINT | 是否可编辑(1-是 0-否) |
|
||||||
| created_at | DATETIME | 创建时间 |
|
| created_at | DATETIME | 创建时间 |
|
||||||
| updated_at | DATETIME | 更新时间 |
|
| updated_at | DATETIME | 更新时间 |
|
||||||
|
|
||||||
@@ -441,8 +435,87 @@
|
|||||||
**功能**:
|
**功能**:
|
||||||
1. 查询所有保护期已到的报备(`protect_end_date <= 今天` 且 `status = 1`)
|
1. 查询所有保护期已到的报备(`protect_end_date <= 今天` 且 `status = 1`)
|
||||||
2. 批量更新报备状态为"已失效"(`status = 3`)
|
2. 批量更新报备状态为"已失效"(`status = 3`)
|
||||||
3. 批量更新关联客户状态为"可报备"(`status = 0`)
|
3. 释放资源,允许相同"学校+产品+项目类型"组合重新报备
|
||||||
4. 保证渠道资源公平分配
|
|
||||||
|
**核心代码**: `ReportExpireTask.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Scheduled(cron = "0 0 1 * * ?")
|
||||||
|
public void handleExpiredReports() {
|
||||||
|
log.info("开始处理过期报备...");
|
||||||
|
try {
|
||||||
|
reportService.handleExpiredReports();
|
||||||
|
log.info("过期报备处理完成");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理过期报备失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 防撞单机制详解
|
||||||
|
|
||||||
|
### 核心规则
|
||||||
|
|
||||||
|
**唯一性维度**: 学校 + 产品 + 项目类型
|
||||||
|
|
||||||
|
**有效报备定义**: 状态为"待审核(0)"或"已通过(1)"的报备
|
||||||
|
|
||||||
|
### 校验逻辑
|
||||||
|
|
||||||
|
```java
|
||||||
|
// 1. 如果选择了学校(有 school_id)
|
||||||
|
// 按 school_id + product + project_type 精确匹配
|
||||||
|
WHERE school_id = #{schoolId}
|
||||||
|
AND product = #{product}
|
||||||
|
AND project_type = #{projectType}
|
||||||
|
AND status IN (0, 1)
|
||||||
|
|
||||||
|
// 2. 如果手动输入学校(无 school_id)
|
||||||
|
// 按 school_name + product + project_type 匹配
|
||||||
|
WHERE school_name = #{schoolName}
|
||||||
|
AND product = #{product}
|
||||||
|
AND project_type = #{projectType}
|
||||||
|
AND status IN (0, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 业务意义
|
||||||
|
|
||||||
|
- **防止撞单**: 同一项目不能被多个经销商同时报备
|
||||||
|
- **公平竞争**: 保护期内锁定,到期后自动释放
|
||||||
|
- **灵活性**: 支持手动输入学校名称,降低报备门槛
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 学校选择机制
|
||||||
|
|
||||||
|
### 学校数据来源
|
||||||
|
|
||||||
|
学校数据存储在 `crm_school` 表中,包含:
|
||||||
|
- 学校标识码(唯一)
|
||||||
|
- 学校名称
|
||||||
|
- 所在地
|
||||||
|
|
||||||
|
### 前端交互
|
||||||
|
|
||||||
|
1. **自动完成搜索**
|
||||||
|
- 用户输入学校名称
|
||||||
|
- 前端调用 `/api/school/search?keyword=xxx` 接口
|
||||||
|
- 后端模糊匹配 `school_name`
|
||||||
|
- 返回匹配列表供用户选择
|
||||||
|
|
||||||
|
2. **手动输入**
|
||||||
|
- 用户可以直接输入学校名称
|
||||||
|
- 如果不从下拉列表选择,`school_id` 为 NULL
|
||||||
|
- `school_name` 保存用户输入的文本
|
||||||
|
- 防撞单校验时按 `school_name` 进行匹配
|
||||||
|
|
||||||
|
### 设计优势
|
||||||
|
|
||||||
|
- **降低门槛**: 学校不在库中也能报备,不影响业务
|
||||||
|
- **数据规范化**: 优先使用学校库数据,保证数据质量
|
||||||
|
- **灵活扩展**: 支持批量导入学校数据
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -459,36 +532,15 @@
|
|||||||
#### 权限拦截
|
#### 权限拦截
|
||||||
- 前端路由守卫:未登录重定向到登录页
|
- 前端路由守卫:未登录重定向到登录页
|
||||||
- 后端拦截器:验证 Token 有效性
|
- 后端拦截器:验证 Token 有效性
|
||||||
- 接口权限注解:`@AuthRequired`
|
|
||||||
- 数据权限:经销商只能查看自己的报备
|
- 数据权限:经销商只能查看自己的报备
|
||||||
|
|
||||||
### 2. 密码安全
|
### 2. 密码安全
|
||||||
|
|
||||||
#### 双重加密
|
|
||||||
```
|
|
||||||
用户输入 → [前端 SHA-256 哈希] → HTTPS 传输 → [后端 BCrypt 加密] → 存储
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 密码规则
|
#### 密码规则
|
||||||
- 长度:6-20 位
|
- 长度:6-20 位
|
||||||
- 加密:BCrypt(自动加盐)
|
- 加密:BCrypt(自动加盐)
|
||||||
- 传输:SHA-256 哈希后传输
|
|
||||||
- 修改密码:需验证原密码
|
- 修改密码:需验证原密码
|
||||||
|
|
||||||
### 3. 数据安全
|
|
||||||
|
|
||||||
#### SQL 注入防护
|
|
||||||
- 使用 MyBatis 参数化查询
|
|
||||||
- 所有用户输入都经过参数绑定
|
|
||||||
|
|
||||||
#### XSS 防护
|
|
||||||
- 前端输出自动转义
|
|
||||||
- 后端返回 JSON 格式
|
|
||||||
|
|
||||||
#### CSRF 防护
|
|
||||||
- 使用 JWT 无状态认证
|
|
||||||
- 不依赖 Session Cookie
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -502,10 +554,10 @@ by-crm/
|
|||||||
│ │ ├── config/ # 配置类(CORS、MyBatis、Swagger等)
|
│ │ ├── config/ # 配置类(CORS、MyBatis、Swagger等)
|
||||||
│ │ ├── controller/ # 控制器层
|
│ │ ├── controller/ # 控制器层
|
||||||
│ │ │ ├── AuthController.java
|
│ │ │ ├── AuthController.java
|
||||||
│ │ │ ├── CustomerController.java
|
|
||||||
│ │ │ ├── ReportController.java
|
│ │ │ ├── ReportController.java
|
||||||
│ │ │ ├── DealerController.java
|
│ │ │ ├── DealerController.java
|
||||||
│ │ │ └── SchoolController.java
|
│ │ │ ├── SchoolController.java
|
||||||
|
│ │ │ └── ReportProgressController.java
|
||||||
│ │ ├── dto/ # 数据传输对象
|
│ │ ├── dto/ # 数据传输对象
|
||||||
│ │ ├── entity/ # 实体类
|
│ │ ├── entity/ # 实体类
|
||||||
│ │ ├── exception/ # 异常处理
|
│ │ ├── exception/ # 异常处理
|
||||||
@@ -533,7 +585,6 @@ by-crm/
|
|||||||
│ │ │ ├── Login.vue # 登录页
|
│ │ │ ├── Login.vue # 登录页
|
||||||
│ │ │ ├── Layout.vue # 主布局
|
│ │ │ ├── Layout.vue # 主布局
|
||||||
│ │ │ ├── Dashboard.vue # 首页
|
│ │ │ ├── Dashboard.vue # 首页
|
||||||
│ │ │ ├── Customer.vue # 客户管理
|
|
||||||
│ │ │ ├── Report.vue # 报备管理
|
│ │ │ ├── Report.vue # 报备管理
|
||||||
│ │ │ └── Dealer.vue # 经销商管理
|
│ │ │ └── Dealer.vue # 经销商管理
|
||||||
│ │ ├── App.vue # 根组件
|
│ │ ├── App.vue # 根组件
|
||||||
@@ -542,14 +593,12 @@ by-crm/
|
|||||||
│ ├── package.json # 依赖配置
|
│ ├── package.json # 依赖配置
|
||||||
│ └── tsconfig.json # TS 配置
|
│ └── tsconfig.json # TS 配置
|
||||||
│
|
│
|
||||||
├── sql/ # 数据库脚本
|
├── docs/ # 文档目录
|
||||||
│ ├── init.sql # 初始化脚本
|
│ └── README.md # 项目文档
|
||||||
│ ├── school_table.sql # 学校表
|
|
||||||
│ └── *.sql # 其他 SQL 脚本
|
|
||||||
│
|
│
|
||||||
└── docs/ # 文档目录
|
└── sql/ # 数据库脚本
|
||||||
├── api.md # API 文档(Swagger 导出)
|
├── init.sql # 初始化脚本
|
||||||
└── password-security.md # 密码安全说明
|
└── *.sql # 其他 SQL 脚本
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -581,7 +630,7 @@ jwt:
|
|||||||
# 保护期配置
|
# 保护期配置
|
||||||
crm:
|
crm:
|
||||||
report:
|
report:
|
||||||
ttl-days: 90 # 保护期天数(默认90天)
|
protect-days: 90 # 保护期天数(默认90天)
|
||||||
allow-overlap: false # 是否允许报备重叠(测试用,生产必须false)
|
allow-overlap: false # 是否允许报备重叠(测试用,生产必须false)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -593,9 +642,6 @@ const BASE_URL = 'http://localhost:8080/api'
|
|||||||
|
|
||||||
// Token 存储
|
// Token 存储
|
||||||
const TOKEN_KEY = 'token'
|
const TOKEN_KEY = 'token'
|
||||||
|
|
||||||
// 路由模式:history 或 hash
|
|
||||||
const routerMode = 'history'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -660,21 +706,19 @@ mysql -u root -p by_crm < sql/init.sql
|
|||||||
- `POST /api/auth/change-password` - 修改密码
|
- `POST /api/auth/change-password` - 修改密码
|
||||||
- `POST /api/auth/reset-password` - 重置密码
|
- `POST /api/auth/reset-password` - 重置密码
|
||||||
|
|
||||||
### 客户接口
|
|
||||||
- `GET /api/customer/page` - 分页查询客户
|
|
||||||
- `GET /api/customer/{id}` - 获取客户详情
|
|
||||||
- `POST /api/customer` - 创建客户
|
|
||||||
- `PUT /api/customer/{id}` - 更新客户
|
|
||||||
- `DELETE /api/customer/{id}` - 删除客户
|
|
||||||
- `GET /api/customer/search?keyword=xxx` - 搜索客户
|
|
||||||
|
|
||||||
### 报备接口
|
### 报备接口
|
||||||
- `GET /api/report/page` - 分页查询报备
|
- `GET /api/report/page` - 分页查询报备
|
||||||
- `GET /api/report/{id}` - 获取报备详情
|
- `GET /api/report/{id}` - 获取报备详情
|
||||||
- `POST /api/report` - 创建报备
|
- `POST /api/report` - 创建报备
|
||||||
|
- `PUT /api/report/{id}` - 更新报备
|
||||||
- `DELETE /api/report/{id}` - 撤回报备
|
- `DELETE /api/report/{id}` - 撤回报备
|
||||||
- `PUT /api/report/{id}/audit` - 审核报备
|
- `PUT /api/report/{id}/audit` - 审核报备
|
||||||
|
|
||||||
|
### 进展记录接口
|
||||||
|
- `GET /api/report-progress/list?reportId={id}` - 查询进展记录
|
||||||
|
- `POST /api/report-progress` - 添加进展记录
|
||||||
|
- `PUT /api/report-progress/{id}` - 编辑进展记录
|
||||||
|
|
||||||
### 经销商接口(仅管理员)
|
### 经销商接口(仅管理员)
|
||||||
- `GET /api/dealer/list` - 查询所有经销商
|
- `GET /api/dealer/list` - 查询所有经销商
|
||||||
- `POST /api/dealer` - 创建经销商
|
- `POST /api/dealer` - 创建经销商
|
||||||
@@ -686,7 +730,6 @@ mysql -u root -p by_crm < sql/init.sql
|
|||||||
- `GET /api/school/search?keyword=xxx` - 搜索学校
|
- `GET /api/school/search?keyword=xxx` - 搜索学校
|
||||||
- `POST /api/school` - 创建学校
|
- `POST /api/school` - 创建学校
|
||||||
- `POST /api/school/import` - 文件上传导入
|
- `POST /api/school/import` - 文件上传导入
|
||||||
- `POST /api/school/import-from-docs` - 从 docs 目录导入
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -694,20 +737,19 @@ mysql -u root -p by_crm < sql/init.sql
|
|||||||
|
|
||||||
### v1.0.0 (当前版本)
|
### v1.0.0 (当前版本)
|
||||||
|
|
||||||
**新增功能**:
|
**核心功能**:
|
||||||
- ✅ 客户管理(增删改查)
|
- ✅ 报备管理(提交、审核、撤回、编辑、作废)
|
||||||
- ✅ 报备管理(提交、审核、撤回)
|
|
||||||
- ✅ 经销商管理(增删改查、重置密码)
|
- ✅ 经销商管理(增删改查、重置密码)
|
||||||
- ✅ 学校管理(查询、搜索、导入)
|
- ✅ 学校管理(查询、搜索、导入)
|
||||||
- ✅ 用户认证(登录、密码管理)
|
- ✅ 用户认证(登录、密码管理)
|
||||||
|
- ✅ 进展记录(添加、编辑、查询)
|
||||||
- ✅ 自动保护期到期处理
|
- ✅ 自动保护期到期处理
|
||||||
- ✅ 学校数据自动完成功能
|
- ✅ 防撞单机制(学校+产品+项目类型)
|
||||||
- ✅ 密码传输加密(SHA-256 + BCrypt 双重加密)
|
- ✅ 灵活的学校输入(库选择+手动输入)
|
||||||
|
|
||||||
**安全特性**:
|
**安全特性**:
|
||||||
- ✅ JWT Token 认证
|
- ✅ JWT Token 认证
|
||||||
- ✅ BCrypt 密码加密存储
|
- ✅ BCrypt 密码加密存储
|
||||||
- ✅ SHA-256 前端哈希传输
|
|
||||||
- ✅ 权限注解拦截
|
- ✅ 权限注解拦截
|
||||||
- ✅ SQL 注入防护
|
- ✅ SQL 注入防护
|
||||||
- ✅ XSS 防护
|
- ✅ XSS 防护
|
||||||
@@ -716,40 +758,33 @@ mysql -u root -p by_crm < sql/init.sql
|
|||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
### 1. 默认密码是什么?
|
### 1. 默认账号是什么?
|
||||||
- 新增经销商时,如果不设置初始密码,默认为 `123456`
|
|
||||||
- 管理员账号:`admin` / `admin123`
|
- 管理员账号:`admin` / `admin123`
|
||||||
- 经销商账号:`user001~003` / `admin123`
|
- 经销商账号:根据经销商数据创建
|
||||||
|
|
||||||
### 2. 保护期可以修改吗?
|
### 2. 保护期可以修改吗?
|
||||||
- 可以,通过修改系统配置 `report.protect.days`
|
- 可以,通过修改系统配置 `report.protect.days`
|
||||||
- 默认为 90 天,可根据业务需求调整
|
- 默认为 90 天,可根据业务需求调整
|
||||||
|
|
||||||
### 3. 同一客户可以同时被多个经销商报备吗?
|
### 3. 同一学校项目可以同时被多个经销商报备吗?
|
||||||
- **不可以**,这是系统的核心规则
|
- **不可以**,这是系统的核心规则
|
||||||
- 同一时间只能有一个"已通过"的报备
|
- 同一"学校+产品+项目类型"组合只能有一个"有效"报备
|
||||||
- 其他经销商报备该客户会被拒绝
|
- 有效报备定义:状态为"待审核(0)"或"已通过(1)"
|
||||||
|
|
||||||
### 4. 保护期到期后会发生什么?
|
### 4. 保护期到期后会发生什么?
|
||||||
- 定时任务每天凌晨1点自动执行
|
- 定时任务每天凌晨1点自动执行
|
||||||
- 将过期报备状态改为"已失效"
|
- 将过期报备状态改为"已失效"
|
||||||
- 客户状态自动变为"可报备"
|
- 该"学校+产品+项目类型"组合可重新报备
|
||||||
- 其他经销商可以再次报备
|
|
||||||
|
|
||||||
### 5. 如何确保传输安全?
|
### 5. 学校名称可以手动输入吗?
|
||||||
- 前端:使用 Web Crypto API 进行 SHA-256 哈希
|
- 可以,支持从学校库选择或手动输入
|
||||||
- 传输:HTTPS + 哈希后的密码
|
- 从学校库选择时会保存 `school_id` 和标准学校名称
|
||||||
- 后端:BCrypt 二次加密存储
|
- 手动输入时 `school_id` 为 NULL,保存用户输入的文本
|
||||||
- 三重安全保障
|
|
||||||
|
|
||||||
### 6. 管理员可以重置其他管理员密码吗?
|
### 6. 报备可以作废吗?
|
||||||
- **不可以**,只能重置经销商用户的密码
|
- 可以,只有管理员可以作废已通过的报备
|
||||||
- 管理员修改自己的密码通过"修改密码"功能
|
- 作废时必须填写作废原因
|
||||||
|
- 作废后该"学校+产品+项目类型"组合可重新报备
|
||||||
### 7. 学校数据从哪里来?
|
|
||||||
- 存放在 `docs/school.xls` 文件中
|
|
||||||
- 包含:学校标识码、学校名称、所在地
|
|
||||||
- 可通过 API 导入到数据库
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ docker-compose ps
|
|||||||
- **前端应用**: http://localhost
|
- **前端应用**: http://localhost
|
||||||
- **后端 API**: http://localhost:8080/api
|
- **后端 API**: http://localhost:8080/api
|
||||||
- **默认账号**:
|
- **默认账号**:
|
||||||
- 管理员: `admin` / `admin123`
|
- 管理员: `admin` / `Bycrmadmin123`
|
||||||
- 经销商: `user001` / `admin123`
|
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
|
||||||
<title>经销商管理系统</title>
|
<title>泊云智销通</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "by-crm-frontend",
|
"name": "by-crm-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "经销商管理系统前端",
|
"description": "泊云智销通前端",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.0",
|
"@element-plus/icons-vue": "^2.3.0",
|
||||||
|
"@vueuse/core": "^14.4.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dayjs": "^1.11.0",
|
"dayjs": "^1.11.0",
|
||||||
"element-plus": "^2.5.0",
|
"element-plus": "^2.5.0",
|
||||||
|
"js-sha256": "^1.0.0",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"pinia-plugin-persistedstate": "^3.2.0",
|
"pinia-plugin-persistedstate": "^3.2.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
|
|||||||
Generated
+42
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@element-plus/icons-vue':
|
'@element-plus/icons-vue':
|
||||||
specifier: ^2.3.0
|
specifier: ^2.3.0
|
||||||
version: 2.3.2(vue@3.5.27(typescript@5.9.3))
|
version: 2.3.2(vue@3.5.27(typescript@5.9.3))
|
||||||
|
'@vueuse/core':
|
||||||
|
specifier: ^14.4.0
|
||||||
|
version: 14.4.0(vue@3.5.27(typescript@5.9.3))
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.6.0
|
specifier: ^1.6.0
|
||||||
version: 1.13.2
|
version: 1.13.2
|
||||||
@@ -23,6 +26,9 @@ importers:
|
|||||||
element-plus:
|
element-plus:
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.13.1(vue@3.5.27(typescript@5.9.3))
|
version: 2.13.1(vue@3.5.27(typescript@5.9.3))
|
||||||
|
js-sha256:
|
||||||
|
specifier: ^1.0.0
|
||||||
|
version: 1.0.0
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.3.1(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
|
version: 2.3.1(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
|
||||||
@@ -529,6 +535,9 @@ packages:
|
|||||||
'@types/web-bluetooth@0.0.20':
|
'@types/web-bluetooth@0.0.20':
|
||||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||||
|
|
||||||
|
'@types/web-bluetooth@0.0.21':
|
||||||
|
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@6.21.0':
|
'@typescript-eslint/eslint-plugin@6.21.0':
|
||||||
resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==}
|
resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==}
|
||||||
engines: {node: ^16.0.0 || >=18.0.0}
|
engines: {node: ^16.0.0 || >=18.0.0}
|
||||||
@@ -644,12 +653,25 @@ packages:
|
|||||||
'@vueuse/core@10.11.1':
|
'@vueuse/core@10.11.1':
|
||||||
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
||||||
|
|
||||||
|
'@vueuse/core@14.4.0':
|
||||||
|
resolution: {integrity: sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.5.0
|
||||||
|
|
||||||
'@vueuse/metadata@10.11.1':
|
'@vueuse/metadata@10.11.1':
|
||||||
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||||
|
|
||||||
|
'@vueuse/metadata@14.4.0':
|
||||||
|
resolution: {integrity: sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==}
|
||||||
|
|
||||||
'@vueuse/shared@10.11.1':
|
'@vueuse/shared@10.11.1':
|
||||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||||
|
|
||||||
|
'@vueuse/shared@14.4.0':
|
||||||
|
resolution: {integrity: sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.5.0
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1015,6 +1037,9 @@ packages:
|
|||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
js-sha256@1.0.0:
|
||||||
|
resolution: {integrity: sha512-Bqxf6ENUzYIMzuELCmRNrJOVbjKH1oMgbfYJBKVr/W1Xf9fazpahqCbb24v1pR7XV1isuqhM+w9KWpK7zCyUQw==}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1699,6 +1724,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/web-bluetooth@0.0.20': {}
|
'@types/web-bluetooth@0.0.20': {}
|
||||||
|
|
||||||
|
'@types/web-bluetooth@0.0.21': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -1880,8 +1907,17 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@vueuse/core@14.4.0(vue@3.5.27(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@types/web-bluetooth': 0.0.21
|
||||||
|
'@vueuse/metadata': 14.4.0
|
||||||
|
'@vueuse/shared': 14.4.0(vue@3.5.27(typescript@5.9.3))
|
||||||
|
vue: 3.5.27(typescript@5.9.3)
|
||||||
|
|
||||||
'@vueuse/metadata@10.11.1': {}
|
'@vueuse/metadata@10.11.1': {}
|
||||||
|
|
||||||
|
'@vueuse/metadata@14.4.0': {}
|
||||||
|
|
||||||
'@vueuse/shared@10.11.1(vue@3.5.27(typescript@5.9.3))':
|
'@vueuse/shared@10.11.1(vue@3.5.27(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue-demi: 0.14.10(vue@3.5.27(typescript@5.9.3))
|
vue-demi: 0.14.10(vue@3.5.27(typescript@5.9.3))
|
||||||
@@ -1889,6 +1925,10 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@vueuse/shared@14.4.0(vue@3.5.27(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.27(typescript@5.9.3)
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
@@ -2309,6 +2349,8 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
js-sha256@1.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
|
esbuild: true
|
||||||
|
vue-demi: true
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { http } from '@/utils/request'
|
||||||
|
import type { ReportProgress, ReportProgressForm } from '@/types'
|
||||||
|
|
||||||
|
// 根据报备ID获取进展记录列表
|
||||||
|
export const getProgressByReportId = (reportId: number) => {
|
||||||
|
return http.get<ReportProgress[]>(`/report-progress/report/${reportId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据ID获取进展记录
|
||||||
|
export const getProgressById = (id: number) => {
|
||||||
|
return http.get<ReportProgress>(`/report-progress/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建进展记录
|
||||||
|
export const createProgress = (data: ReportProgressForm) => {
|
||||||
|
return http.post('/report-progress', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新进展记录
|
||||||
|
export const updateProgress = (id: number, data: ReportProgressForm) => {
|
||||||
|
return http.put(`/report-progress/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除进展记录(根据需求不可删除,所以不提供该接口)
|
||||||
|
// export const deleteProgress = (id: number) => {
|
||||||
|
// return http.delete(`/report-progress/${id}`)
|
||||||
|
// }
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { http } from '@/utils/request'
|
import { http } from '@/utils/request'
|
||||||
import type { Report, ReportForm, ReportAuditForm, PageQuery, PageResult } from '@/types'
|
import type { Report, ReportForm, ReportAuditForm, ReportUpdateForm, PageQuery, PageResult } from '@/types'
|
||||||
|
|
||||||
// 分页查询报备
|
// 分页查询报备
|
||||||
export const getReportPage = (params: PageQuery & {
|
export const getReportPage = (params: PageQuery & {
|
||||||
@@ -21,6 +21,11 @@ export const createReport = (data: ReportForm) => {
|
|||||||
return http.post('/report', data)
|
return http.post('/report', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新报备
|
||||||
|
export const updateReport = (id: number, data: ReportUpdateForm) => {
|
||||||
|
return http.put(`/report/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
// 审核报备
|
// 审核报备
|
||||||
export const auditReport = (id: number, data: ReportAuditForm) => {
|
export const auditReport = (id: number, data: ReportAuditForm) => {
|
||||||
return http.put(`/report/${id}/audit`, data)
|
return http.put(`/report/${id}/audit`, data)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="mobile-tab-bar">
|
||||||
|
<router-link
|
||||||
|
v-for="item in menus"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: isActive(item.path) }"
|
||||||
|
>
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
<span class="tab-label">{{ item.label }}</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { HomeFilled, Document, Shop, Setting } from '@element-plus/icons-vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 与 Layout 侧边栏菜单保持一致,仅 admin 项按角色显隐
|
||||||
|
const allMenus = [
|
||||||
|
{ path: '/dashboard', label: '首页', icon: HomeFilled, adminOnly: false },
|
||||||
|
{ path: '/report', label: '报备', icon: Document, adminOnly: false },
|
||||||
|
{ path: '/dealer', label: '经销商', icon: Shop, adminOnly: true },
|
||||||
|
{ path: '/system-config', label: '设置', icon: Setting, adminOnly: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const menus = computed(() => allMenus.filter((m) => !m.adminOnly || userStore.isAdmin))
|
||||||
|
|
||||||
|
const isActive = (path: string) => route.path === path
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mobile-tab-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
height: 56px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-top: 1px solid #e6e6e6;
|
||||||
|
/* 兼容 iPhone 底部安全区 */
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: #909399;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item .el-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-label {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应式断点判断(与 styles/variables.scss 的媒体查询保持一致)。
|
||||||
|
* - isMobile : ≤768px 手机竖屏
|
||||||
|
* - isTablet : 769–1024 平板
|
||||||
|
* - isPC : ≥1025px 桌面(现有 PC 行为)
|
||||||
|
*
|
||||||
|
* 返回值均为 Ref<boolean>,可在模板中直接 v-if。
|
||||||
|
*/
|
||||||
|
export function useResponsive() {
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||||
|
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
|
||||||
|
const isPC = useMediaQuery('(min-width: 1025px)')
|
||||||
|
|
||||||
|
return { isMobile, isTablet, isPC }
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
|
|||||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
|
import '@/styles/mobile.scss'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ router.beforeEach((to, _from, next) => {
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// 设置页面标题
|
// 设置页面标题
|
||||||
document.title = `${to.meta.title || ''} - 经销商管理系统`
|
document.title = `${to.meta.title || ''} - 泊云智销通`
|
||||||
|
|
||||||
// 如果访问登录页,已登录则跳转到首页
|
// 如果访问登录页,已登录则跳转到首页
|
||||||
if (to.path === '/login') {
|
if (to.path === '/login') {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@use './variables.scss' as *;
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 移动端全局兜底样式
|
||||||
|
* 仅在 ≤768px 生效,PC(≥1025px)零影响。
|
||||||
|
* 处理无法在组件内逐个绑定的命令式弹层(ElMessageBox 等)
|
||||||
|
* 与通用触摸/溢出问题。
|
||||||
|
* ========================================================= */
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
// 防止页面横向溢出(宽表格/固定宽度元素)
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令式确认框(ElMessageBox.confirm)无法逐个绑宽度,统一兜底
|
||||||
|
.el-message-box {
|
||||||
|
width: 86% !important;
|
||||||
|
max-width: 86vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知/消息更贴顶,避免遮挡底部 Tab
|
||||||
|
.el-message {
|
||||||
|
min-width: auto !important;
|
||||||
|
max-width: 92vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页器触摸热区加大
|
||||||
|
.el-pagination {
|
||||||
|
.btn-prev,
|
||||||
|
.btn-next,
|
||||||
|
.el-pager li {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单标签更紧凑
|
||||||
|
.el-form-item__label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// 断点常量 —— 与 composables/useResponsive.ts 的媒体查询保持一致
|
||||||
|
// 改动断点时两处需同步。
|
||||||
|
$bp-mobile: 768px;
|
||||||
|
$bp-tablet: 1024px;
|
||||||
|
|
||||||
|
/// ≤768px 移动端
|
||||||
|
@mixin mobile {
|
||||||
|
@media (max-width: $bp-mobile) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 769–1024 平板
|
||||||
|
@mixin tablet {
|
||||||
|
@media (min-width: 769px) and (max-width: $bp-tablet) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ≤1024px(移动 + 平板)
|
||||||
|
@mixin mobile-and-tablet {
|
||||||
|
@media (max-width: $bp-tablet) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ export interface User {
|
|||||||
realName: string
|
realName: string
|
||||||
dealerId?: number
|
dealerId?: number
|
||||||
dealerName?: string
|
dealerName?: string
|
||||||
|
dealerContactPerson?: string
|
||||||
|
dealerContactPhone?: string
|
||||||
role: number
|
role: number
|
||||||
roleDesc: string
|
roleDesc: string
|
||||||
}
|
}
|
||||||
@@ -12,6 +14,7 @@ export interface User {
|
|||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
|
rememberMe?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
@@ -60,13 +63,17 @@ export interface Report {
|
|||||||
id: number
|
id: number
|
||||||
dealerId: number
|
dealerId: number
|
||||||
dealerName: string
|
dealerName: string
|
||||||
customerId: number
|
schoolId?: number
|
||||||
customerName: string
|
schoolName?: string
|
||||||
customerPhone?: string
|
product?: string
|
||||||
|
projectType?: string
|
||||||
description?: string
|
description?: string
|
||||||
|
contactPerson?: string
|
||||||
|
contactPhone?: string
|
||||||
status: number
|
status: number
|
||||||
statusDesc: string
|
statusDesc: string
|
||||||
rejectReason?: string
|
rejectReason?: string
|
||||||
|
cancelReason?: string
|
||||||
protectStartDate?: string
|
protectStartDate?: string
|
||||||
protectEndDate?: string
|
protectEndDate?: string
|
||||||
remainDays?: number
|
remainDays?: number
|
||||||
@@ -75,8 +82,13 @@ export interface Report {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportForm {
|
export interface ReportForm {
|
||||||
customerId: number
|
schoolId?: number
|
||||||
|
schoolName: string
|
||||||
|
product: string
|
||||||
|
projectType: string
|
||||||
description?: string
|
description?: string
|
||||||
|
contactPerson?: string
|
||||||
|
contactPhone?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportAuditForm {
|
export interface ReportAuditForm {
|
||||||
@@ -84,6 +96,31 @@ export interface ReportAuditForm {
|
|||||||
rejectReason?: string
|
rejectReason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReportUpdateForm {
|
||||||
|
product: string
|
||||||
|
projectType: string
|
||||||
|
description?: string
|
||||||
|
contactPerson?: string
|
||||||
|
contactPhone?: string
|
||||||
|
status: number
|
||||||
|
cancelReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportProgress {
|
||||||
|
id: number
|
||||||
|
reportId: number
|
||||||
|
progressContent: string
|
||||||
|
createdBy?: number
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
creatorName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportProgressForm {
|
||||||
|
reportId: number
|
||||||
|
progressContent: string
|
||||||
|
}
|
||||||
|
|
||||||
// 分页相关类型
|
// 分页相关类型
|
||||||
export interface PageQuery {
|
export interface PageQuery {
|
||||||
current: number
|
current: number
|
||||||
|
|||||||
@@ -1,39 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* 密码加密工具
|
* 密码加密工具(SHA-256 → 小写 hex)
|
||||||
* 使用 Web Crypto API 进行 SHA-256 哈希
|
*
|
||||||
|
* 安全上下文(HTTPS 或 localhost)下使用原生 Web Crypto API;
|
||||||
|
* 非安全上下文(HTTP + 局域网/IP 访问,此时 crypto.subtle 为 undefined)
|
||||||
|
* 下回退到纯 JS 实现 js-sha256,保证产物一致,登录不再卡死。
|
||||||
|
* 两条路径对同一字符串产出的 SHA-256 小写 hex 完全相同。
|
||||||
*/
|
*/
|
||||||
|
import { sha256 } from 'js-sha256'
|
||||||
|
|
||||||
/**
|
/** 当前环境是否可用原生 Web Crypto(即处于安全上下文) */
|
||||||
* 将字符串转换为 ArrayBuffer
|
function hasSubtleCrypto(): boolean {
|
||||||
*/
|
return typeof crypto !== 'undefined' && typeof crypto.subtle?.digest === 'function'
|
||||||
function stringToArrayBuffer(str: string): ArrayBuffer {
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
return encoder.encode(str).buffer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 原生 Web Crypto SHA-256 → 小写 hex */
|
||||||
* 将 ArrayBuffer 转换为十六进制字符串
|
async function subtleSha256Hex(input: string): Promise<string> {
|
||||||
*/
|
const data = new TextEncoder().encode(input)
|
||||||
function arrayBufferToHex(buffer: ArrayBuffer): string {
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||||
const bytes = new Uint8Array(buffer)
|
return Array.from(new Uint8Array(hashBuffer))
|
||||||
return Array.from(bytes)
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
|
||||||
.join('')
|
.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 统一入口:优先原生,回退纯 JS */
|
||||||
|
async function sha256Hex(input: string): Promise<string> {
|
||||||
|
if (hasSubtleCrypto()) {
|
||||||
|
return subtleSha256Hex(input)
|
||||||
|
}
|
||||||
|
return sha256(input)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 SHA-256 算法哈希密码
|
* 使用 SHA-256 算法哈希密码
|
||||||
* @param password 明文密码
|
* @param password 明文密码
|
||||||
* @returns 哈希后的十六进制字符串
|
* @returns 哈希后的十六进制字符串(小写)
|
||||||
*/
|
*/
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
if (!password) return ''
|
if (!password) return ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用 Web Crypto API 进行 SHA-256 哈希
|
return await sha256Hex(password)
|
||||||
const data = stringToArrayBuffer(password)
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
||||||
return arrayBufferToHex(hashBuffer)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('密码哈希失败:', error)
|
console.error('密码哈希失败:', error)
|
||||||
throw new Error('密码加密失败')
|
throw new Error('密码加密失败')
|
||||||
@@ -44,16 +49,12 @@ export async function hashPassword(password: string): Promise<string> {
|
|||||||
* 简单的加盐哈希(可选,用于增强安全性)
|
* 简单的加盐哈希(可选,用于增强安全性)
|
||||||
* @param password 明文密码
|
* @param password 明文密码
|
||||||
* @param salt 盐值
|
* @param salt 盐值
|
||||||
* @returns 哈希后的十六进制字符串
|
* @returns 哈希后的十六进制字符串(小写)
|
||||||
*/
|
*/
|
||||||
export async function hashPasswordWithSalt(password: string, salt: string): Promise<string> {
|
export async function hashPasswordWithSalt(password: string, salt: string): Promise<string> {
|
||||||
if (!password) return ''
|
if (!password) return ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const saltedPassword = password + salt
|
return await sha256Hex(password + salt)
|
||||||
const data = stringToArrayBuffer(saltedPassword)
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
||||||
return arrayBufferToHex(hashBuffer)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('密码哈希失败:', error)
|
console.error('密码哈希失败:', error)
|
||||||
throw new Error('密码加密失败')
|
throw new Error('密码加密失败')
|
||||||
|
|||||||
+104
-19
@@ -17,11 +17,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 查询表单 -->
|
<!-- 查询表单 -->
|
||||||
<el-form :inline="true" :model="queryForm" class="query-form">
|
<el-form :inline="!isMobile" :model="queryForm" class="query-form">
|
||||||
<el-form-item label="客户名称">
|
<el-form-item label="客户名称">
|
||||||
<el-input v-model="queryForm.name" placeholder="请输入客户名称" clearable />
|
<el-input v-model="queryForm.name" placeholder="请输入客户名称" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="所属行业">
|
<el-form-item label="所属行业" style="width: 190px;">
|
||||||
<el-select v-model="queryForm.industry" placeholder="请选择行业" clearable>
|
<el-select v-model="queryForm.industry" placeholder="请选择行业" clearable>
|
||||||
<el-option label="制造业" value="manufacturing" />
|
<el-option label="制造业" value="manufacturing" />
|
||||||
<el-option label="互联网" value="internet" />
|
<el-option label="互联网" value="internet" />
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<el-option label="其他" value="other" />
|
<el-option label="其他" value="other" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态">
|
<el-form-item label="状态" style="width: 160px;">
|
||||||
<el-select v-model="queryForm.status" placeholder="请选择状态" clearable>
|
<el-select v-model="queryForm.status" placeholder="请选择状态" clearable>
|
||||||
<el-option label="可报备" :value="0" />
|
<el-option label="可报备" :value="0" />
|
||||||
<el-option label="保护中" :value="1" />
|
<el-option label="保护中" :value="1" />
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<!-- 数据表格 -->
|
<!-- 数据表格 -->
|
||||||
<el-table :data="tableData" border stripe v-loading="loading">
|
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading">
|
||||||
<el-table-column prop="name" label="客户名称" />
|
<el-table-column prop="name" label="客户名称" />
|
||||||
<el-table-column prop="phone" label="联系电话" />
|
<el-table-column prop="phone" label="联系电话" />
|
||||||
<el-table-column prop="address" label="地址" />
|
<el-table-column prop="address" label="地址" />
|
||||||
@@ -84,26 +84,53 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 移动端卡片视图 -->
|
||||||
|
<div v-if="isMobile" class="mobile-card-list" v-loading="loading">
|
||||||
|
<div v-for="row in tableData" :key="row.id" class="mobile-card">
|
||||||
|
<div class="card-top">
|
||||||
|
<span class="card-title">{{ row.name }}</span>
|
||||||
|
<el-tag v-if="row.status === 0" type="success" size="small">可报备</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 1" type="warning" size="small">保护中</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">已失效</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="card-row"><span class="label">联系电话</span><span>{{ row.phone || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">所属行业</span><span>{{ getIndustryLabel(row.industry) }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">地址</span><span>{{ row.address || '-' }}</span></div>
|
||||||
|
<template v-if="row.status === 1">
|
||||||
|
<div class="card-row"><span class="label">当前经销商</span><span>{{ row.currentDealerName || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">保护期截止</span><span>{{ row.protectEndDate ? formatDate(row.protectEndDate) : '-' }}</span></div>
|
||||||
|
</template>
|
||||||
|
<div class="card-row"><span class="label">创建时间</span><span>{{ row.createdAt }}</span></div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!loading && tableData.length === 0" description="暂无客户数据" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<el-pagination
|
<el-config-provider :locale="zhCn">
|
||||||
v-model:current-page="queryForm.current"
|
<el-pagination
|
||||||
v-model:page-size="queryForm.size"
|
v-model:current-page="queryForm.current"
|
||||||
:total="total"
|
v-model:page-size="queryForm.size"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
:total="total"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
prev-text="上一页"
|
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||||
next-text="下一页"
|
prev-text="上一页"
|
||||||
@size-change="fetchData"
|
next-text="下一页"
|
||||||
@current-change="fetchData"
|
@size-change="fetchData"
|
||||||
style="margin-top: 20px; justify-content: flex-end"
|
@current-change="fetchData"
|
||||||
/>
|
:style="{ marginTop: '20px', justifyContent: isMobile ? 'center' : 'flex-end' }"
|
||||||
|
/>
|
||||||
|
</el-config-provider>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 新增/编辑对话框 -->
|
<!-- 新增/编辑对话框 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="dialogTitle"
|
:title="dialogTitle"
|
||||||
width="600px"
|
:width="isMobile ? '92%' : '600px'"
|
||||||
@close="handleDialogClose"
|
@close="handleDialogClose"
|
||||||
>
|
>
|
||||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
|
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
|
||||||
@@ -153,13 +180,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
import { getCustomerPage, createCustomer, updateCustomer, deleteCustomer } from '@/api/customer'
|
import { getCustomerPage, createCustomer, updateCustomer, deleteCustomer } from '@/api/customer'
|
||||||
import { getConfigValue } from '@/api/system'
|
import { getConfigValue } from '@/api/system'
|
||||||
import { searchSchoolByName } from '@/api/school'
|
import { searchSchoolByName } from '@/api/school'
|
||||||
import type { Customer, CustomerForm } from '@/types'
|
import type { Customer, CustomerForm } from '@/types'
|
||||||
import type { School } from '@/api/school'
|
import type { School } from '@/api/school'
|
||||||
|
import { useResponsive } from '@/composables/useResponsive'
|
||||||
|
|
||||||
const protectDays = ref<number>(90)
|
const protectDays = ref<number>(90)
|
||||||
|
const { isMobile } = useResponsive()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tableData = ref<Customer[]>([])
|
const tableData = ref<Customer[]>([])
|
||||||
@@ -227,7 +257,7 @@ const handleSchoolSelect = (item: School) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取行业标签
|
// 获取行业标签
|
||||||
const getIndustryLabel = (industry: string) => {
|
const getIndustryLabel = (industry: string | undefined) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
manufacturing: '制造业',
|
manufacturing: '制造业',
|
||||||
internet: '互联网',
|
internet: '互联网',
|
||||||
@@ -237,7 +267,7 @@ const getIndustryLabel = (industry: string) => {
|
|||||||
healthcare: '医疗',
|
healthcare: '医疗',
|
||||||
other: '其他'
|
other: '其他'
|
||||||
}
|
}
|
||||||
return map[industry] || industry
|
return map[industry ?? ''] || industry || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询数据
|
// 查询数据
|
||||||
@@ -380,4 +410,59 @@ onMounted(() => {
|
|||||||
color: #909399;
|
color: #909399;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 移动端卡片视图 ===== */
|
||||||
|
.mobile-card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row {
|
||||||
|
display: flex;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row .label {
|
||||||
|
width: 76px;
|
||||||
|
color: #909399;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="6">
|
<!-- <el-col :span="userStore.isAdmin ? 6 : 8">
|
||||||
<el-card class="stat-card">
|
<el-card class="stat-card">
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-icon" style="background-color: #409eff">
|
<div class="stat-icon" style="background-color: #409eff">
|
||||||
@@ -14,8 +14,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
-->
|
||||||
<el-card class="stat-card">
|
<el-col :xs="24" :sm="userStore.isAdmin ? 8 : 12">
|
||||||
|
<el-card class="stat-card" @click="handleReportCountClick">
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-icon" style="background-color: #67c23a">
|
<div class="stat-icon" style="background-color: #67c23a">
|
||||||
<el-icon :size="30"><Document /></el-icon>
|
<el-icon :size="30"><Document /></el-icon>
|
||||||
@@ -27,8 +28,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :xs="24" :sm="userStore.isAdmin ? 8 : 12">
|
||||||
<el-card class="stat-card">
|
<el-card class="stat-card" @click="handlePendingClick">
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-icon" style="background-color: #e6a23c">
|
<div class="stat-icon" style="background-color: #e6a23c">
|
||||||
<el-icon :size="30"><Clock /></el-icon>
|
<el-icon :size="30"><Clock /></el-icon>
|
||||||
@@ -40,8 +41,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :xs="24" :sm="8" v-if="userStore.isAdmin">
|
||||||
<el-card class="stat-card">
|
<el-card class="stat-card" @click="handleDealerCountClick">
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-icon" style="background-color: #f56c6c">
|
<div class="stat-icon" style="background-color: #f56c6c">
|
||||||
<el-icon :size="30"><Shop /></el-icon>
|
<el-icon :size="30"><Shop /></el-icon>
|
||||||
@@ -56,19 +57,21 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-card class="welcome-card" style="margin-top: 20px">
|
<el-card class="welcome-card" style="margin-top: 20px">
|
||||||
<h3>欢迎使用经销商管理系统</h3>
|
<h3>欢迎使用泊云智销通</h3>
|
||||||
<p v-if="userStore.isAdmin">您是管理员,可以管理所有客户、报备和经销商信息。</p>
|
<p v-if="userStore.isAdmin">您是管理员,可以管理所有报备和经销商信息。</p>
|
||||||
<p v-else>您是经销商用户,可以管理客户和提交报备申请。</p>
|
<p v-else>您是经销商用户,可以提交报备申请。</p>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { getStatistics } from '@/api/dashboard'
|
import { getStatistics } from '@/api/dashboard'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
@@ -78,6 +81,32 @@ const stats = ref({
|
|||||||
dealerCount: 0
|
dealerCount: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 点击报备总数卡片
|
||||||
|
const handleReportCountClick = () => {
|
||||||
|
router.push({
|
||||||
|
path: '/report',
|
||||||
|
query: {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击待审核卡片
|
||||||
|
const handlePendingClick = () => {
|
||||||
|
router.push({
|
||||||
|
path: '/report',
|
||||||
|
query: {
|
||||||
|
status: '0'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击经销商数卡片
|
||||||
|
const handleDealerCountClick = () => {
|
||||||
|
router.push({
|
||||||
|
path: '/dealer',
|
||||||
|
query: {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 加载统计数据
|
// 加载统计数据
|
||||||
const fetchStatistics = async () => {
|
const fetchStatistics = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -155,4 +184,20 @@ onMounted(() => {
|
|||||||
color: #606266;
|
color: #606266;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.welcome-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -12,14 +12,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 查询表单 -->
|
<!-- 查询表单 -->
|
||||||
<el-form :inline="true" :model="queryForm" class="query-form">
|
<el-form :inline="!isMobile" :model="queryForm" class="query-form">
|
||||||
<el-form-item label="经销商名称">
|
<el-form-item label="经销商名称">
|
||||||
<el-input v-model="queryForm.name" placeholder="请输入经销商名称" clearable />
|
<el-input v-model="queryForm.name" placeholder="请输入经销商名称" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="经销商账号">
|
<el-form-item label="经销商账号">
|
||||||
<el-input v-model="queryForm.code" placeholder="请输入经销商账号" clearable />
|
<el-input v-model="queryForm.code" placeholder="请输入经销商账号" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态">
|
<el-form-item label="状态" style="width: 180px;">
|
||||||
<el-select v-model="queryForm.status" placeholder="请选择状态" clearable>
|
<el-select v-model="queryForm.status" placeholder="请选择状态" clearable>
|
||||||
<el-option label="启用" :value="1" />
|
<el-option label="启用" :value="1" />
|
||||||
<el-option label="禁用" :value="0" />
|
<el-option label="禁用" :value="0" />
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<!-- 数据表格 -->
|
<!-- 数据表格 -->
|
||||||
<el-table :data="tableData" border stripe v-loading="loading">
|
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading">
|
||||||
<el-table-column prop="name" label="经销商名称" />
|
<el-table-column prop="name" label="经销商名称" />
|
||||||
<el-table-column prop="code" label="经销商账号" />
|
<el-table-column prop="code" label="经销商账号" />
|
||||||
<el-table-column prop="contactPerson" label="联系人" />
|
<el-table-column prop="contactPerson" label="联系人" />
|
||||||
@@ -57,13 +57,36 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 移动端卡片视图 -->
|
||||||
|
<div v-if="isMobile" class="mobile-card-list" v-loading="loading">
|
||||||
|
<div v-for="row in tableData" :key="row.id" class="mobile-card">
|
||||||
|
<div class="card-top">
|
||||||
|
<span class="card-title">{{ row.name }}</span>
|
||||||
|
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="card-row"><span class="label">账号</span><span>{{ row.code }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">联系人</span><span>{{ row.contactPerson || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">联系电话</span><span>{{ row.contactPhone || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">邮箱</span><span>{{ row.email || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">创建时间</span><span>{{ row.createdAt }}</span></div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button link type="warning" @click="handleResetPassword(row)">重置密码</el-button>
|
||||||
|
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!loading && tableData.length === 0" description="暂无经销商数据" />
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 新增/编辑对话框 -->
|
<!-- 新增/编辑对话框 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="dialogTitle"
|
:title="dialogTitle"
|
||||||
width="600px"
|
:width="isMobile ? '92%' : '600px'"
|
||||||
@close="handleDialogClose"
|
@close="handleDialogClose"
|
||||||
>
|
>
|
||||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
|
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
|
||||||
@@ -92,11 +115,11 @@
|
|||||||
<el-input
|
<el-input
|
||||||
v-model="formData.password"
|
v-model="formData.password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入初始密码(不填默认为123456)"
|
placeholder="请输入初始密码(不填默认为a123456)"
|
||||||
show-password
|
show-password
|
||||||
/>
|
/>
|
||||||
<div style="color: #909399; font-size: 12px; margin-top: 4px">
|
<div style="color: #909399; font-size: 12px; margin-top: 4px">
|
||||||
长度6-20位,不填则默认密码为 123456
|
长度6-20位,不填则默认密码为 a123456
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -110,7 +133,7 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="resetPasswordDialogVisible"
|
v-model="resetPasswordDialogVisible"
|
||||||
title="重置密码"
|
title="重置密码"
|
||||||
width="500px"
|
:width="isMobile ? '92%' : '500px'"
|
||||||
@close="handleResetPasswordDialogClose"
|
@close="handleResetPasswordDialogClose"
|
||||||
>
|
>
|
||||||
<el-form ref="resetPasswordFormRef" :model="resetPasswordForm" :rules="resetPasswordRules" label-width="100px">
|
<el-form ref="resetPasswordFormRef" :model="resetPasswordForm" :rules="resetPasswordRules" label-width="100px">
|
||||||
@@ -149,6 +172,9 @@ import { getDealerList, createDealer, updateDealer, deleteDealer } from '@/api/d
|
|||||||
import { resetPassword } from '@/api/auth'
|
import { resetPassword } from '@/api/auth'
|
||||||
import { hashPassword } from '@/utils/crypto'
|
import { hashPassword } from '@/utils/crypto'
|
||||||
import type { Dealer } from '@/types'
|
import type { Dealer } from '@/types'
|
||||||
|
import { useResponsive } from '@/composables/useResponsive'
|
||||||
|
|
||||||
|
const { isMobile } = useResponsive()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tableData = ref<Dealer[]>([])
|
const tableData = ref<Dealer[]>([])
|
||||||
@@ -289,7 +315,15 @@ const handleSubmit = async () => {
|
|||||||
await updateDealer(formData.id, formData)
|
await updateDealer(formData.id, formData)
|
||||||
ElMessage.success('更新成功')
|
ElMessage.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await createDealer(formData)
|
// 处理密码:如果未填写则使用默认密码 a123456,并进行加密
|
||||||
|
const submitData = { ...formData }
|
||||||
|
if (!submitData.password || submitData.password.trim() === '') {
|
||||||
|
submitData.password = 'a123456'
|
||||||
|
}
|
||||||
|
// 对密码进行 SHA-256 哈希后再传输
|
||||||
|
submitData.password = await hashPassword(submitData.password)
|
||||||
|
|
||||||
|
await createDealer(submitData)
|
||||||
ElMessage.success('创建成功')
|
ElMessage.success('创建成功')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
@@ -375,4 +409,59 @@ onMounted(() => {
|
|||||||
.query-form {
|
.query-form {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 移动端卡片视图 ===== */
|
||||||
|
.mobile-card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row {
|
||||||
|
display: flex;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row .label {
|
||||||
|
width: 76px;
|
||||||
|
color: #909399;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="layout-container">
|
<el-container class="layout-container">
|
||||||
<el-aside width="200px">
|
<el-aside v-if="!isMobile" width="200px">
|
||||||
<div class="logo">经销商管理系统</div>
|
<div class="logo">{{ APP_TITLE }}</div>
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="activeMenu"
|
:default-active="activeMenu"
|
||||||
router
|
router
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
<el-icon><HomeFilled /></el-icon>
|
<el-icon><HomeFilled /></el-icon>
|
||||||
<span>首页</span>
|
<span>首页</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/customer">
|
<!-- <el-menu-item index="/customer">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
<span>客户管理</span>
|
<span>客户管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item> -->
|
||||||
<el-menu-item index="/report">
|
<el-menu-item index="/report">
|
||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
<span>报备管理</span>
|
<span>报备管理</span>
|
||||||
@@ -34,20 +34,23 @@
|
|||||||
<el-container>
|
<el-container>
|
||||||
<el-header>
|
<el-header>
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="breadcrumb">
|
<div class="header-left">
|
||||||
<el-breadcrumb separator="/">
|
<span class="mobile-logo">{{ APP_TITLE }}</span>
|
||||||
<el-breadcrumb-item>{{ currentPageTitle }}</el-breadcrumb-item>
|
<div class="breadcrumb">
|
||||||
</el-breadcrumb>
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item>{{ currentPageTitle }}</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<el-dropdown>
|
<el-dropdown :trigger="isMobile ? 'click' : 'hover'">
|
||||||
<span class="user-name">
|
<span class="user-name">
|
||||||
<el-icon><Avatar /></el-icon>
|
<el-icon><Avatar /></el-icon>
|
||||||
{{ userStore.userInfo?.realName }}
|
{{ userStore.userInfo?.dealerName }}
|
||||||
<el-tag v-if="userStore.isAdmin" type="danger" size="small" style="margin-left: 8px">
|
<el-tag v-if="userStore.isAdmin" type="danger" size="small" style="margin-left: 8px">
|
||||||
管理员
|
管理员
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<el-tag v-else type="success" size="small" style="margin-left: 8px">经销商</el-tag>
|
<!-- <el-tag v-else type="success" size="small" style="margin-left: 8px">经销商</el-tag> -->
|
||||||
</span>
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
@@ -70,11 +73,14 @@
|
|||||||
</el-main>
|
</el-main>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
|
<!-- 移动端底部导航 -->
|
||||||
|
<MobileTabBar v-if="isMobile" />
|
||||||
|
|
||||||
<!-- 修改密码对话框 -->
|
<!-- 修改密码对话框 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showChangePasswordDialog"
|
v-model="showChangePasswordDialog"
|
||||||
title="修改密码"
|
title="修改密码"
|
||||||
width="500px"
|
:width="isMobile ? '92%' : '500px'"
|
||||||
@close="handleDialogClose"
|
@close="handleDialogClose"
|
||||||
>
|
>
|
||||||
<el-form ref="formRef" :model="passwordForm" :rules="passwordRules" label-width="100px">
|
<el-form ref="formRef" :model="passwordForm" :rules="passwordRules" label-width="100px">
|
||||||
@@ -118,10 +124,15 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
|
|||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { changePassword } from '@/api/auth'
|
import { changePassword } from '@/api/auth'
|
||||||
import { hashPassword } from '@/utils/crypto'
|
import { hashPassword } from '@/utils/crypto'
|
||||||
|
import { useResponsive } from '@/composables/useResponsive'
|
||||||
|
import MobileTabBar from '@/components/MobileTabBar.vue'
|
||||||
|
|
||||||
|
const APP_TITLE = '泊云智销通'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const { isMobile } = useResponsive()
|
||||||
|
|
||||||
const activeMenu = computed(() => route.path)
|
const activeMenu = computed(() => route.path)
|
||||||
const currentPageTitle = computed(() => route.meta.title as string || '首页')
|
const currentPageTitle = computed(() => route.meta.title as string || '首页')
|
||||||
@@ -244,6 +255,19 @@ const handleLogout = async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 默认(PC):隐藏移动端 logo,显示面包屑 */
|
||||||
|
.mobile-logo {
|
||||||
|
display: none;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
.user-name {
|
.user-name {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -258,4 +282,29 @@ const handleLogout = async () => {
|
|||||||
background-color: #f0f2f5;
|
background-color: #f0f2f5;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 移动端(≤768px) ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.el-header {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-logo {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-main {
|
||||||
|
padding: 12px;
|
||||||
|
/* 给底部 TabBar(56px + 安全区)留白 */
|
||||||
|
padding-bottom: calc(56px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name .el-tag {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<h2 class="login-title">经销商管理系统</h2>
|
<h2 class="login-title">泊云智销通</h2>
|
||||||
<el-form ref="loginFormRef" :model="loginForm" :rules="rules" class="login-form">
|
<el-form ref="loginFormRef" :model="loginForm" :rules="rules" class="login-form">
|
||||||
<el-form-item prop="username">
|
<el-form-item prop="username">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
@keyup.enter="handleLogin"
|
@keyup.enter="handleLogin"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-checkbox v-model="remember">记住我(7天免登录)</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-btn">
|
<el-button type="primary" size="large" :loading="loading" @click="handleLogin" class="login-btn">
|
||||||
登录
|
登录
|
||||||
@@ -32,7 +35,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@@ -49,6 +52,16 @@ const loginForm = reactive({
|
|||||||
password: ''
|
password: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const remember = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const savedUsername = localStorage.getItem('remember-username')
|
||||||
|
if (savedUsername) {
|
||||||
|
loginForm.username = savedUsername
|
||||||
|
remember.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||||
@@ -64,8 +77,15 @@ const handleLogin = async () => {
|
|||||||
const hashedPassword = await hashPassword(loginForm.password)
|
const hashedPassword = await hashPassword(loginForm.password)
|
||||||
await userStore.login({
|
await userStore.login({
|
||||||
username: loginForm.username,
|
username: loginForm.username,
|
||||||
password: hashedPassword
|
password: hashedPassword,
|
||||||
|
rememberMe: remember.value
|
||||||
})
|
})
|
||||||
|
// 记住用户名(勾选"记住我"时保存,否则清除)
|
||||||
|
if (remember.value) {
|
||||||
|
localStorage.setItem('remember-username', loginForm.username)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('remember-username')
|
||||||
|
}
|
||||||
ElMessage.success('登录成功')
|
ElMessage.success('登录成功')
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -85,11 +105,12 @@ const handleLogin = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #FFFFFF 0%, #409EFF 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-box {
|
.login-box {
|
||||||
width: 400px;
|
width: 90%;
|
||||||
|
max-width: 400px;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -109,4 +130,14 @@ const handleLogin = async () => {
|
|||||||
.login-btn {
|
.login-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-box {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+586
-79
@@ -17,16 +17,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 查询表单 -->
|
<!-- 查询表单 -->
|
||||||
<el-form :inline="true" :model="queryForm" class="query-form">
|
<el-form :inline="!isMobile" :model="queryForm" class="query-form">
|
||||||
<el-form-item label="客户名称">
|
<el-form-item label="学校名称">
|
||||||
<el-input v-model="queryForm.customerName" placeholder="请输入客户名称" clearable />
|
<el-input v-model="queryForm.customerName" placeholder="请输入学校名称" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态">
|
<el-form-item label="状态" style="width: 180px;">
|
||||||
<el-select v-model="queryForm.status" placeholder="请选择状态" clearable>
|
<el-select v-model="queryForm.status" placeholder="请选择状态" clearable>
|
||||||
<el-option label="待审核" :value="0" />
|
<el-option label="待审核" :value="0" />
|
||||||
<el-option label="已通过" :value="1" />
|
<el-option label="已通过" :value="1" />
|
||||||
<el-option label="已驳回" :value="2" />
|
<el-option label="已驳回" :value="2" />
|
||||||
<el-option label="已失效" :value="3" />
|
<el-option label="已失效" :value="3" />
|
||||||
|
<el-option label="已作废" :value="4" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
@@ -39,17 +40,21 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<!-- 数据表格 -->
|
<!-- 数据表格 -->
|
||||||
<el-table :data="tableData" border stripe v-loading="loading">
|
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading">
|
||||||
<el-table-column prop="dealerName" label="经销商" />
|
<el-table-column prop="dealerName" label="经销商" v-if="userStore.isAdmin"/>
|
||||||
<el-table-column prop="customerName" label="客户名称" />
|
<el-table-column prop="schoolName" label="学校名称" />
|
||||||
<el-table-column prop="customerPhone" label="联系电话" />
|
<el-table-column prop="product" label="所属产品" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="projectType" label="项目类型" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="contactPerson" label="负责人" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="contactPhone" label="负责人电话" show-overflow-tooltip />
|
||||||
<el-table-column prop="description" label="报备说明" show-overflow-tooltip />
|
<el-table-column prop="description" label="报备说明" show-overflow-tooltip />
|
||||||
<el-table-column prop="status" label="状态">
|
<el-table-column prop="status" label="状态">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.status === 0" type="warning">待审核</el-tag>
|
<el-tag v-if="row.status === 0" type="warning">待审核</el-tag>
|
||||||
<el-tag v-else-if="row.status === 1" type="success">已通过</el-tag>
|
<el-tag v-else-if="row.status === 1" type="success">已通过</el-tag>
|
||||||
<el-tag v-else-if="row.status === 2" type="danger">已驳回</el-tag>
|
<el-tag v-else-if="row.status === 2" type="danger">已驳回</el-tag>
|
||||||
<el-tag v-else type="info">已失效</el-tag>
|
<el-tag v-else-if="row.status === 3" type="info">已失效</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 4" type="warning">已作废</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="protectEndDate" label="保护期截止" width="180">
|
<el-table-column prop="protectEndDate" label="保护期截止" width="180">
|
||||||
@@ -58,9 +63,17 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||||
<el-table-column label="操作" width="200" fixed="right">
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="handleView(row)">查看</el-button>
|
<el-button link type="primary" @click="handleView(row)">查看</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 1"
|
||||||
|
link
|
||||||
|
type="info"
|
||||||
|
@click="handleShowProgress(row)"
|
||||||
|
>
|
||||||
|
进展
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="userStore.isAdmin && row.status === 0"
|
v-if="userStore.isAdmin && row.status === 0"
|
||||||
link
|
link
|
||||||
@@ -69,6 +82,14 @@
|
|||||||
>
|
>
|
||||||
审核
|
审核
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="userStore.isAdmin && row.status === 1"
|
||||||
|
link
|
||||||
|
type="warning"
|
||||||
|
@click="handleEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="!userStore.isAdmin && row.status === 0"
|
v-if="!userStore.isAdmin && row.status === 0"
|
||||||
link
|
link
|
||||||
@@ -81,41 +102,100 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 移动端卡片视图 -->
|
||||||
|
<div v-if="isMobile" class="mobile-card-list" v-loading="loading">
|
||||||
|
<div v-for="row in tableData" :key="row.id" class="mobile-card">
|
||||||
|
<div class="card-top">
|
||||||
|
<span class="card-title">{{ row.schoolName }}</span>
|
||||||
|
<el-tag v-if="row.status === 0" type="warning" size="small">待审核</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 1" type="success" size="small">已通过</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 2" type="danger" size="small">已驳回</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 3" type="info" size="small">已失效</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 4" type="warning" size="small">已作废</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="userStore.isAdmin" class="card-row"><span class="label">经销商</span><span class="val">{{ row.dealerName || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">所属产品</span><span class="val">{{ row.product || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">项目类型</span><span class="val">{{ row.projectType || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">负责人</span><span class="val">{{ row.contactPerson || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">负责人电话</span><span class="val">{{ row.contactPhone || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">报备说明</span><span class="val">{{ row.description || '-' }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">保护期截止</span><span class="val">{{ formatDate(row.protectEndDate) }}</span></div>
|
||||||
|
<div class="card-row"><span class="label">创建时间</span><span class="val">{{ row.createdAt }}</span></div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<el-button link type="primary" @click="handleView(row)">查看</el-button>
|
||||||
|
<el-button v-if="row.status === 1" link type="info" @click="handleShowProgress(row)">进展</el-button>
|
||||||
|
<el-button v-if="userStore.isAdmin && row.status === 0" link type="success" @click="handleAudit(row)">审核</el-button>
|
||||||
|
<el-button v-if="userStore.isAdmin && row.status === 1" link type="warning" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button v-if="!userStore.isAdmin && row.status === 0" link type="danger" @click="handleWithdraw(row)">撤回</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!loading && tableData.length === 0" description="暂无报备数据" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<el-pagination
|
<el-config-provider :locale="zhCn">
|
||||||
v-model:current-page="queryForm.current"
|
<el-pagination
|
||||||
v-model:page-size="queryForm.size"
|
v-model:current-page="queryForm.current"
|
||||||
:total="total"
|
v-model:page-size="queryForm.size"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
:total="total"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
prev-text="上一页"
|
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||||
next-text="下一页"
|
prev-text="上一页"
|
||||||
@size-change="fetchData"
|
next-text="下一页"
|
||||||
@current-change="fetchData"
|
@size-change="fetchData"
|
||||||
style="margin-top: 20px; justify-content: flex-end"
|
@current-change="fetchData"
|
||||||
/>
|
:style="{ marginTop: '20px', justifyContent: isMobile ? 'center' : 'flex-end' }"
|
||||||
|
/>
|
||||||
|
</el-config-provider>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 提交报备对话框 -->
|
<!-- 提交报备对话框 -->
|
||||||
<el-dialog v-model="dialogVisible" title="提交报备" width="600px" @close="handleDialogClose">
|
<el-dialog v-model="dialogVisible" title="提交报备" :width="isMobile ? '92%' : '650px'" @close="handleDialogClose">
|
||||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
|
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
|
||||||
<el-form-item label="客户" prop="customerId">
|
<el-form-item label="学校" prop="schoolName">
|
||||||
<el-select
|
<el-autocomplete
|
||||||
v-model="formData.customerId"
|
v-model="formData.schoolName"
|
||||||
placeholder="请选择客户"
|
:fetch-suggestions="searchSchool"
|
||||||
filterable
|
placeholder="请输入学校名称"
|
||||||
remote
|
:trigger-on-focus="false"
|
||||||
:remote-method="searchCustomer"
|
|
||||||
:loading="searchLoading"
|
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
|
@select="handleSchoolSelect"
|
||||||
|
value-key="schoolName"
|
||||||
|
clearable
|
||||||
>
|
>
|
||||||
<el-option
|
<template #default="{ item }">
|
||||||
v-for="item in customerOptions"
|
<div>{{ item.schoolName }}</div>
|
||||||
:key="item.id"
|
<div style="font-size: 12px; color: #999;">{{ item.location || '无地址信息' }}</div>
|
||||||
:label="item.name"
|
</template>
|
||||||
:value="item.id"
|
</el-autocomplete>
|
||||||
/>
|
</el-form-item>
|
||||||
</el-select>
|
<el-form-item label="所属产品" prop="product">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.product"
|
||||||
|
placeholder="请输入所属产品"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="项目类型" prop="projectType">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.projectType"
|
||||||
|
placeholder="请输入项目类型"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="负责人" prop="contactPerson">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.contactPerson"
|
||||||
|
placeholder="请输入负责人姓名"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="负责人电话" prop="contactPhone">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.contactPhone"
|
||||||
|
placeholder="请输入负责人电话"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="报备说明" prop="description">
|
<el-form-item label="报备说明" prop="description">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -133,7 +213,7 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 审核对话框 -->
|
<!-- 审核对话框 -->
|
||||||
<el-dialog v-model="auditDialogVisible" title="审核报备" width="600px">
|
<el-dialog v-model="auditDialogVisible" title="审核报备" :width="isMobile ? '92%' : '600px'">
|
||||||
<el-form :model="auditForm" label-width="100px">
|
<el-form :model="auditForm" label-width="100px">
|
||||||
<el-form-item label="审核结果">
|
<el-form-item label="审核结果">
|
||||||
<el-radio-group v-model="auditForm.approved">
|
<el-radio-group v-model="auditForm.approved">
|
||||||
@@ -155,31 +235,224 @@
|
|||||||
<el-button type="primary" @click="handleAuditSubmit">确定</el-button>
|
<el-button type="primary" @click="handleAuditSubmit">确定</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 编辑报备对话框 -->
|
||||||
|
<el-dialog v-model="editDialogVisible" title="编辑报备" :width="isMobile ? '92%' : '600px'" @close="handleEditDialogClose">
|
||||||
|
<el-form ref="editFormRef" :model="editFormData" :rules="editRules" label-width="100px">
|
||||||
|
<el-form-item label="学校名称">
|
||||||
|
<el-input :model-value="currentReport?.schoolName" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="所属产品" prop="product">
|
||||||
|
<el-input
|
||||||
|
v-model="editFormData.product"
|
||||||
|
placeholder="请输入所属产品"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="项目类型" prop="projectType">
|
||||||
|
<el-input
|
||||||
|
v-model="editFormData.projectType"
|
||||||
|
placeholder="请输入项目类型"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="负责人" prop="contactPerson">
|
||||||
|
<el-input
|
||||||
|
v-model="editFormData.contactPerson"
|
||||||
|
placeholder="请输入负责人姓名"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="负责人电话" prop="contactPhone">
|
||||||
|
<el-input
|
||||||
|
v-model="editFormData.contactPhone"
|
||||||
|
placeholder="请输入负责人电话"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="报备说明" prop="description">
|
||||||
|
<el-input
|
||||||
|
v-model="editFormData.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="请输入报备说明"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-select v-model="editFormData.status" placeholder="请选择状态" style="width: 100%">
|
||||||
|
<el-option label="已通过" :value="1" />
|
||||||
|
<el-option label="已作废" :value="4" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="作废原因" prop="cancelReason" v-if="editFormData.status === 4">
|
||||||
|
<el-input
|
||||||
|
v-model="editFormData.cancelReason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="请填写作废原因"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleEditSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 报备详情对话框 -->
|
||||||
|
<el-dialog v-model="detailDialogVisible" title="报备详情" :width="isMobile ? '92%' : '700px'">
|
||||||
|
<el-descriptions :column="isMobile ? 1 : 2" border>
|
||||||
|
<el-descriptions-item label="经销商">
|
||||||
|
{{ currentReport?.dealerName || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="学校名称">
|
||||||
|
{{ currentReport?.schoolName || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="所属产品">
|
||||||
|
{{ currentReport?.product || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="项目类型">
|
||||||
|
{{ currentReport?.projectType || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="项目负责人">
|
||||||
|
{{ currentReport?.contactPerson || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="项目电话">
|
||||||
|
{{ currentReport?.contactPhone || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态" :span="2">
|
||||||
|
<el-tag v-if="currentReport?.status === 0" type="warning">待审核</el-tag>
|
||||||
|
<el-tag v-else-if="currentReport?.status === 1" type="success">已通过</el-tag>
|
||||||
|
<el-tag v-else-if="currentReport?.status === 2" type="danger">已驳回</el-tag>
|
||||||
|
<el-tag v-else-if="currentReport?.status === 3" type="info">已失效</el-tag>
|
||||||
|
<el-tag v-else-if="currentReport?.status === 4" type="warning">已作废</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="保护期开始" :span="1">
|
||||||
|
{{ formatDate(currentReport?.protectStartDate) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="保护期结束" :span="1">
|
||||||
|
{{ formatDate(currentReport?.protectEndDate) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="剩余保护天数" v-if="currentReport?.status === 1 && currentReport?.remainDays !== undefined">
|
||||||
|
<el-tag :type="currentReport.remainDays > 7 ? 'success' : currentReport.remainDays > 0 ? 'warning' : 'danger'">
|
||||||
|
{{ currentReport.remainDays }} 天
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="驳回原因" v-if="currentReport?.status === 2" :span="2">
|
||||||
|
<span style="color: #f56c6c;">{{ currentReport?.rejectReason || '-' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="作废原因" v-if="currentReport?.status === 4" :span="2">
|
||||||
|
<span style="color: #e6a23c;">{{ currentReport?.cancelReason || '-' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="报备说明" :span="2">
|
||||||
|
<div style="white-space: pre-wrap;">{{ currentReport?.description || '-' }}</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间" :span="2">
|
||||||
|
{{ currentReport?.createdAt || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新时间" :span="2">
|
||||||
|
{{ currentReport?.updatedAt || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<template #footer>
|
||||||
|
<el-button type="primary" @click="detailDialogVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 进展记录对话框 -->
|
||||||
|
<el-dialog v-model="progressDialogVisible" title="报备进展记录" :width="isMobile ? '92%' : '1000px'" :fullscreen="isMobile" @close="handleProgressDialogClose">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>添加进展记录</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form ref="progressFormRef" :model="progressFormData" :rules="progressRules" label-width="100px">
|
||||||
|
<el-form-item label="进展内容" prop="progressContent">
|
||||||
|
<el-input
|
||||||
|
v-model="progressFormData.progressContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="请输入进展内容"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button v-if="editingProgressId" @click="handleCancelEdit">取消编辑</el-button>
|
||||||
|
<el-button type="primary" @click="handleSaveProgress">{{ editingProgressId ? '保存修改' : '添加' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-divider content-position="left">进展记录列表</el-divider>
|
||||||
|
|
||||||
|
<el-table :data="progressList" border stripe v-loading="progressLoading">
|
||||||
|
<el-table-column prop="progressContent" label="进展内容" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||||
|
<el-table-column prop="updatedAt" label="更新时间" width="180" />
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="!userStore.isAdmin"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="handleEditProgress(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { getReportPage, createReport, auditReport, withdrawReport } from '@/api/report'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
import { searchCustomerByName } from '@/api/customer'
|
import { getReportPage, createReport, auditReport, withdrawReport, updateReport } from '@/api/report'
|
||||||
|
import { getProgressByReportId, createProgress, updateProgress } from '@/api/report-progress'
|
||||||
|
import { searchSchoolByName } from '@/api/school'
|
||||||
import { getConfigValue } from '@/api/system'
|
import { getConfigValue } from '@/api/system'
|
||||||
import type { Report, ReportForm } from '@/types'
|
import type { Report, ReportForm, ReportProgress, ReportProgressForm, ReportUpdateForm } from '@/types'
|
||||||
|
import { useResponsive } from '@/composables/useResponsive'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const protectDays = ref<number>(90)
|
const protectDays = ref<number>(90)
|
||||||
|
const { isMobile } = useResponsive()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tableData = ref<Report[]>([])
|
const tableData = ref<Report[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const auditDialogVisible = ref(false)
|
const auditDialogVisible = ref(false)
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const editDialogVisible = ref(false)
|
||||||
const auditReportId = ref<number>()
|
const auditReportId = ref<number>()
|
||||||
|
const editingReportId = ref<number>()
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const searchLoading = ref(false)
|
const editFormRef = ref<FormInstance>()
|
||||||
const customerOptions = ref<Array<{ id: number; name: string }>>([])
|
const selectedSchool = ref<any>(null)
|
||||||
|
const currentReport = ref<Report | null>(null)
|
||||||
|
|
||||||
|
// 进展记录相关
|
||||||
|
const progressDialogVisible = ref(false)
|
||||||
|
const progressFormRef = ref<FormInstance>()
|
||||||
|
const progressFormData = reactive<ReportProgressForm>({
|
||||||
|
reportId: 0,
|
||||||
|
progressContent: ''
|
||||||
|
})
|
||||||
|
const progressList = ref<ReportProgress[]>([])
|
||||||
|
const editingProgressId = ref<number | undefined>()
|
||||||
|
const progressLoading = ref(false)
|
||||||
|
|
||||||
|
const progressRules: FormRules = {
|
||||||
|
progressContent: [{ required: true, message: '请输入进展内容', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
const queryForm = reactive({
|
const queryForm = reactive({
|
||||||
current: 1,
|
current: 1,
|
||||||
@@ -189,8 +462,13 @@ const queryForm = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const formData = reactive<ReportForm>({
|
const formData = reactive<ReportForm>({
|
||||||
customerId: 0,
|
schoolId: undefined,
|
||||||
description: ''
|
schoolName: '',
|
||||||
|
product: '',
|
||||||
|
projectType: '',
|
||||||
|
description: '',
|
||||||
|
contactPerson: '',
|
||||||
|
contactPhone: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const auditForm = reactive({
|
const auditForm = reactive({
|
||||||
@@ -198,8 +476,27 @@ const auditForm = reactive({
|
|||||||
rejectReason: ''
|
rejectReason: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const editFormData = reactive<ReportUpdateForm>({
|
||||||
|
product: '',
|
||||||
|
projectType: '',
|
||||||
|
description: '',
|
||||||
|
contactPerson: '',
|
||||||
|
contactPhone: '',
|
||||||
|
status: 1,
|
||||||
|
cancelReason: ''
|
||||||
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }]
|
schoolName: [{ required: true, message: '请输入学校名称', trigger: 'blur' }],
|
||||||
|
product: [{ required: true, message: '请输入所属产品', trigger: 'blur' }],
|
||||||
|
projectType: [{ required: true, message: '请输入项目类型', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const editRules: FormRules = {
|
||||||
|
product: [{ required: true, message: '请输入所属产品', trigger: 'blur' }],
|
||||||
|
projectType: [{ required: true, message: '请输入项目类型', trigger: 'blur' }],
|
||||||
|
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||||
|
cancelReason: [{ required: true, message: '请填写作废原因', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化日期,去掉时分秒
|
// 格式化日期,去掉时分秒
|
||||||
@@ -208,6 +505,77 @@ const formatDate = (dateStr: string | null | undefined) => {
|
|||||||
return dateStr.split(' ')[0] // 只保留日期部分,去掉时间
|
return dateStr.split(' ')[0] // 只保留日期部分,去掉时间
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示进展记录对话框
|
||||||
|
const handleShowProgress = async (row: Report) => {
|
||||||
|
progressFormData.reportId = row.id
|
||||||
|
progressFormData.progressContent = ''
|
||||||
|
editingProgressId.value = undefined
|
||||||
|
await fetchProgressList(row.id)
|
||||||
|
progressDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取进展记录列表
|
||||||
|
const fetchProgressList = async (reportId: number) => {
|
||||||
|
progressLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getProgressByReportId(reportId)
|
||||||
|
progressList.value = res
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取进展记录失败', error)
|
||||||
|
progressList.value = []
|
||||||
|
} finally {
|
||||||
|
progressLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存进展记录
|
||||||
|
const handleSaveProgress = async () => {
|
||||||
|
if (!progressFormRef.value) return
|
||||||
|
await progressFormRef.value.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
if (editingProgressId.value) {
|
||||||
|
// 编辑模式
|
||||||
|
await updateProgress(editingProgressId.value, progressFormData)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
// 新增模式
|
||||||
|
await createProgress(progressFormData)
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
// 重新获取进展记录列表
|
||||||
|
await fetchProgressList(progressFormData.reportId)
|
||||||
|
// 重置表单
|
||||||
|
progressFormData.progressContent = ''
|
||||||
|
editingProgressId.value = undefined
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存进展记录失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑进展记录
|
||||||
|
const handleEditProgress = (row: ReportProgress) => {
|
||||||
|
editingProgressId.value = row.id
|
||||||
|
progressFormData.progressContent = row.progressContent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消编辑
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
editingProgressId.value = undefined
|
||||||
|
progressFormData.progressContent = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭进展记录对话框
|
||||||
|
const handleProgressDialogClose = () => {
|
||||||
|
progressDialogVisible.value = false
|
||||||
|
progressFormRef.value?.resetFields()
|
||||||
|
progressFormData.progressContent = ''
|
||||||
|
editingProgressId.value = undefined
|
||||||
|
progressList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
// 查询数据
|
// 查询数据
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -222,18 +590,29 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索客户
|
// 搜索学校
|
||||||
const searchCustomer = async (query: string) => {
|
const searchSchool = async (queryString: string, cb: any) => {
|
||||||
if (!query) return
|
if (!queryString || queryString.trim().length < 1) {
|
||||||
searchLoading.value = true
|
cb([])
|
||||||
try {
|
return
|
||||||
const res = await searchCustomerByName(query)
|
|
||||||
customerOptions.value = res
|
|
||||||
} catch (error) {
|
|
||||||
console.error('搜索客户失败', error)
|
|
||||||
} finally {
|
|
||||||
searchLoading.value = false
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const schools = await searchSchoolByName(queryString)
|
||||||
|
const suggestions = schools.map((school: any) => ({
|
||||||
|
...school,
|
||||||
|
value: school.schoolName
|
||||||
|
}))
|
||||||
|
cb(suggestions)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索学校失败', error)
|
||||||
|
cb([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学校选择回调
|
||||||
|
const handleSchoolSelect = (item: any) => {
|
||||||
|
selectedSchool.value = item
|
||||||
|
formData.schoolId = item.id
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询按钮
|
// 查询按钮
|
||||||
@@ -252,29 +631,20 @@ const handleReset = () => {
|
|||||||
|
|
||||||
// 新增按钮
|
// 新增按钮
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
|
// 自动填充经销商的联系人和电话
|
||||||
|
if (userStore.userInfo?.dealerContactPerson) {
|
||||||
|
formData.contactPerson = userStore.userInfo.dealerContactPerson
|
||||||
|
}
|
||||||
|
if (userStore.userInfo?.dealerContactPhone) {
|
||||||
|
formData.contactPhone = userStore.userInfo.dealerContactPhone
|
||||||
|
}
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看详情
|
// 查看详情
|
||||||
const handleView = (row: Report) => {
|
const handleView = (row: Report) => {
|
||||||
ElMessageBox.alert(
|
currentReport.value = row
|
||||||
`
|
detailDialogVisible.value = true
|
||||||
<div style="line-height: 2;">
|
|
||||||
<p><strong>经销商:</strong>${row.dealerName}</p>
|
|
||||||
<p><strong>客户名称:</strong>${row.customerName}</p>
|
|
||||||
<p><strong>联系电话:</strong>${row.customerPhone || '-'}</p>
|
|
||||||
<p><strong>报备说明:</strong>${row.description || '-'}</p>
|
|
||||||
<p><strong>状态:</strong>${row.statusDesc}</p>
|
|
||||||
<p><strong>驳回原因:</strong>${row.rejectReason || '-'}</p>
|
|
||||||
<p><strong>保护期:</strong>${formatDate(row.protectStartDate)} ~ ${formatDate(row.protectEndDate)}</p>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
'报备详情',
|
|
||||||
{
|
|
||||||
dangerouslyUseHTMLString: true,
|
|
||||||
confirmButtonText: '关闭'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 审核按钮
|
// 审核按钮
|
||||||
@@ -285,6 +655,55 @@ const handleAudit = (row: Report) => {
|
|||||||
auditDialogVisible.value = true
|
auditDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 编辑按钮
|
||||||
|
const handleEdit = (row: Report) => {
|
||||||
|
editingReportId.value = row.id
|
||||||
|
currentReport.value = row
|
||||||
|
editFormData.product = row.product || ''
|
||||||
|
editFormData.projectType = row.projectType || ''
|
||||||
|
editFormData.description = row.description || ''
|
||||||
|
editFormData.contactPerson = row.contactPerson || ''
|
||||||
|
editFormData.contactPhone = row.contactPhone || ''
|
||||||
|
editFormData.status = row.status
|
||||||
|
editFormData.cancelReason = row.cancelReason || ''
|
||||||
|
editDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑对话框关闭
|
||||||
|
const handleEditDialogClose = () => {
|
||||||
|
editFormRef.value?.resetFields()
|
||||||
|
Object.assign(editFormData, {
|
||||||
|
product: '',
|
||||||
|
projectType: '',
|
||||||
|
description: '',
|
||||||
|
contactPerson: '',
|
||||||
|
contactPhone: '',
|
||||||
|
status: 1,
|
||||||
|
cancelReason: ''
|
||||||
|
})
|
||||||
|
editingReportId.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑提交
|
||||||
|
const handleEditSubmit = async () => {
|
||||||
|
if (!editingReportId.value) return
|
||||||
|
|
||||||
|
// 验证表单
|
||||||
|
if (editFormData.status === 4 && !editFormData.cancelReason) {
|
||||||
|
ElMessage.warning('请填写作废原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateReport(editingReportId.value, editFormData)
|
||||||
|
ElMessage.success('编辑成功')
|
||||||
|
editDialogVisible.value = false
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('编辑失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 撤回按钮
|
// 撤回按钮
|
||||||
const handleWithdraw = async (row: Report) => {
|
const handleWithdraw = async (row: Report) => {
|
||||||
try {
|
try {
|
||||||
@@ -343,10 +762,15 @@ const handleAuditSubmit = async () => {
|
|||||||
const handleDialogClose = () => {
|
const handleDialogClose = () => {
|
||||||
formRef.value?.resetFields()
|
formRef.value?.resetFields()
|
||||||
Object.assign(formData, {
|
Object.assign(formData, {
|
||||||
customerId: 0,
|
schoolId: undefined,
|
||||||
description: ''
|
schoolName: '',
|
||||||
|
product: '',
|
||||||
|
projectType: '',
|
||||||
|
description: '',
|
||||||
|
contactPerson: '',
|
||||||
|
contactPhone: ''
|
||||||
})
|
})
|
||||||
customerOptions.value = []
|
selectedSchool.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取保护期配置
|
// 获取保护期配置
|
||||||
@@ -361,6 +785,13 @@ const fetchProtectDays = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchProtectDays()
|
fetchProtectDays()
|
||||||
|
|
||||||
|
// 从路由查询参数中读取初始查询条件
|
||||||
|
const { status } = route.query
|
||||||
|
if (status !== undefined) {
|
||||||
|
queryForm.status = status === '' ? undefined : parseInt(status as string)
|
||||||
|
}
|
||||||
|
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -379,4 +810,80 @@ onMounted(() => {
|
|||||||
.query-form {
|
.query-form {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 详情对话框样式优化 */
|
||||||
|
:deep(.el-descriptions) {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-descriptions__label) {
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-descriptions__content) {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 移动端卡片视图 ===== */
|
||||||
|
.mobile-card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row .label {
|
||||||
|
width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row .val {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
修改配置后立即生效,请谨慎操作。保护期天数的修改仅影响新生成的报备。
|
修改配置后立即生效,请谨慎操作。保护期天数的修改仅影响新生成的报备。
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<el-form :model="formData" label-width="150px">
|
<el-form :model="formData" :label-position="isMobile ? 'top' : 'right'" label-width="150px">
|
||||||
<el-divider content-position="left">报备配置</el-divider>
|
<el-divider content-position="left">报备配置</el-divider>
|
||||||
|
|
||||||
<el-form-item label="保护期天数">
|
<el-form-item label="保护期天数">
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
:max="365"
|
:max="365"
|
||||||
:step="1"
|
:step="1"
|
||||||
controls-position="right"
|
controls-position="right"
|
||||||
style="width: 200px"
|
:style="{ width: isMobile ? '100%' : '200px' }"
|
||||||
/>
|
/>
|
||||||
<span style="margin-left: 10px; color: #909399">天</span>
|
<span style="margin-left: 10px; color: #909399">天</span>
|
||||||
<div style="margin-top: 5px; color: #909399; font-size: 12px">
|
<div style="margin-top: 5px; color: #909399; font-size: 12px">
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
<el-divider content-position="left">当前配置状态</el-divider>
|
<el-divider content-position="left">当前配置状态</el-divider>
|
||||||
|
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="isMobile ? 1 : 2" border>
|
||||||
<el-descriptions-item label="当前保护期天数">
|
<el-descriptions-item label="当前保护期天数">
|
||||||
{{ configs.find(c => c.configKey === 'report.protect.days')?.configValue || 90 }} 天
|
{{ configs.find(c => c.configKey === 'report.protect.days')?.configValue || 90 }} 天
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -72,6 +72,9 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { Check } from '@element-plus/icons-vue'
|
import { Check } from '@element-plus/icons-vue'
|
||||||
import { getAllConfigs, batchUpdateConfigs } from '@/api/system'
|
import { getAllConfigs, batchUpdateConfigs } from '@/api/system'
|
||||||
import type { SystemConfig } from '@/types'
|
import type { SystemConfig } from '@/types'
|
||||||
|
import { useResponsive } from '@/composables/useResponsive'
|
||||||
|
|
||||||
|
const { isMobile } = useResponsive()
|
||||||
|
|
||||||
const configs = ref<SystemConfig[]>([])
|
const configs = ref<SystemConfig[]>([])
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -143,4 +146,11 @@ onMounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: '0.0.0.0', // 或写成 host: true
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
+22
-5
@@ -1,4 +1,4 @@
|
|||||||
-- 经销商管理系统数据库初始化脚本
|
-- 泊云智销通数据库初始化脚本
|
||||||
-- 数据库版本:MySQL 8.0
|
-- 数据库版本:MySQL 8.0
|
||||||
|
|
||||||
CREATE DATABASE IF NOT EXISTS crm_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE IF NOT EXISTS crm_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -41,19 +41,23 @@ CREATE TABLE IF NOT EXISTS crm_customer (
|
|||||||
CREATE TABLE IF NOT EXISTS crm_report (
|
CREATE TABLE IF NOT EXISTS crm_report (
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '报备ID',
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '报备ID',
|
||||||
dealer_id BIGINT NOT NULL COMMENT '经销商ID',
|
dealer_id BIGINT NOT NULL COMMENT '经销商ID',
|
||||||
customer_id BIGINT NOT NULL COMMENT '客户ID',
|
|
||||||
description VARCHAR(500) COMMENT '报备说明',
|
description VARCHAR(500) COMMENT '报备说明',
|
||||||
|
contact_person VARCHAR(100) COMMENT '负责人姓名',
|
||||||
|
contact_phone VARCHAR(20) COMMENT '负责人电话',
|
||||||
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待审核 1-已通过 2-已驳回 3-已失效',
|
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待审核 1-已通过 2-已驳回 3-已失效',
|
||||||
reject_reason VARCHAR(255) COMMENT '驳回原因',
|
reject_reason VARCHAR(255) COMMENT '驳回原因',
|
||||||
protect_start_date DATE DEFAULT NULL COMMENT '保护期开始日期',
|
protect_start_date DATE DEFAULT NULL COMMENT '保护期开始日期',
|
||||||
protect_end_date DATE DEFAULT NULL COMMENT '保护期结束日期',
|
protect_end_date DATE DEFAULT NULL COMMENT '保护期结束日期',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
school_id BIGINT DEFAULT NULL COMMENT '学校ID',
|
||||||
|
school_name VARCHAR(255) COMMENT '学校名称(冗余存储)',
|
||||||
|
product VARCHAR(255) COMMENT '所属产品',
|
||||||
|
project_type VARCHAR(255) COMMENT '项目类型',
|
||||||
|
cancel_reason VARCHAR(500) COMMENT '作废原因',
|
||||||
INDEX idx_dealer_id (dealer_id),
|
INDEX idx_dealer_id (dealer_id),
|
||||||
INDEX idx_customer_id (customer_id),
|
|
||||||
INDEX idx_status (status),
|
INDEX idx_status (status),
|
||||||
INDEX idx_protect_end_date (protect_end_date),
|
INDEX idx_protect_end_date (protect_end_date)
|
||||||
UNIQUE KEY uk_customer_valid (customer_id, status) COMMENT '同一客户只能有一个有效报备'
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报备表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报备表';
|
||||||
|
|
||||||
-- 用户表(管理员和经销商用户)
|
-- 用户表(管理员和经销商用户)
|
||||||
@@ -174,3 +178,16 @@ CREATE TABLE IF NOT EXISTS crm_school (
|
|||||||
INDEX idx_school_name (school_name),
|
INDEX idx_school_name (school_name),
|
||||||
INDEX idx_location (location)
|
INDEX idx_location (location)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学校表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学校表';
|
||||||
|
|
||||||
|
-- 报备进展记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS crm_report_progress (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '进展ID',
|
||||||
|
report_id BIGINT NOT NULL COMMENT '关联报备ID',
|
||||||
|
progress_content VARCHAR(500) NOT NULL COMMENT '进展内容',
|
||||||
|
created_by BIGINT NOT NULL COMMENT '创建人ID',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
INDEX idx_report_id (report_id),
|
||||||
|
INDEX idx_created_by (created_by),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报备进展记录表';
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# BY-CRM 部署说明(前端非 Docker,后端镜像导入更新)
|
||||||
|
|
||||||
|
本文档描述:前端仅构建出静态文件 dist 部署到服务器;后端在本地构建 Docker 镜像,导出传到服务器并更新运行容器。不使用前端 Docker。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
- 本地开发机:安装 Docker(可使用 Docker Desktop);可执行 `docker build`、`docker save`。
|
||||||
|
- 服务器:
|
||||||
|
- 已安装 Docker(可执行 `docker load`、`docker run`)。
|
||||||
|
- 已安装并运行 Nginx(用于前端静态资源与反向代理后端)。
|
||||||
|
- 可访问的 MySQL 数据库(本机或外部),并记录连接信息。
|
||||||
|
- 项目结构参考:
|
||||||
|
- Compose 编排与环境变量见 [docker-compose.yml](file:///e:/EduWeb/by-crm/docker-compose.yml),后端环境变量在 [L49-L61](file:///e:/EduWeb/by-crm/docker-compose.yml#L49-L61),日志挂载在 [L63-L65](file:///e:/EduWeb/by-crm/docker-compose.yml#L63-L65)。
|
||||||
|
- 后端镜像构建规则见 [backend/Dockerfile](file:///e:/EduWeb/by-crm/backend/Dockerfile)。
|
||||||
|
|
||||||
|
## 目标机器目录与文件放置
|
||||||
|
- 目标主机:`root@1.192.190.177`
|
||||||
|
- 统一目录:`/boyun/crm`
|
||||||
|
- 目录结构建议:
|
||||||
|
- `/boyun/crm/web/`:前端静态文件(dist)
|
||||||
|
- `/boyun/crm/images/`:后端镜像 tar 包
|
||||||
|
- `/boyun/crm/sql/`:MySQL 初始化脚本(来自本项目 `sql` 目录)
|
||||||
|
- `/boyun/crm/mysql/data/`:MySQL 数据持久化目录(首次为空,便于初始化)
|
||||||
|
- `/boyun/crm/docker-compose.yml`:编排文件副本(可选,用于参考或后续切换为 Compose)
|
||||||
|
- `/boyun/crm/部署说明.md`:本文档副本,便于现场运维
|
||||||
|
- `/boyun/crm/backend/logs/`:后端日志挂载目录
|
||||||
|
- `/boyun/crm/backend/.env`:后端环境变量文件(可选)
|
||||||
|
- 初始化目录创建:
|
||||||
|
```bash
|
||||||
|
ssh root@1.192.190.177 'mkdir -p /boyun/crm/web /boyun/crm/images /boyun/crm/backend/logs /boyun/crm/sql /boyun/crm/mysql/data'
|
||||||
|
```
|
||||||
|
- 同步部署文件:
|
||||||
|
```bash
|
||||||
|
scp docker-compose.yml root@1.192.190.177:/boyun/crm/
|
||||||
|
scp 部署说明.md root@1.192.190.177:/boyun/crm/
|
||||||
|
scp -r sql/* root@1.192.190.177:/boyun/crm/sql/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 一、前端构建与部署(非 Docker)
|
||||||
|
1. 在本地生成前端静态文件:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
pnpm i # 或 pnpm install
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
- 构建产物目录:`frontend/dist/`
|
||||||
|
2. 将 `dist` 上传到服务器指定目录:`/boyun/crm/web/`
|
||||||
|
- Windows 可用 `scp` 或图形化工具(WinSCP),示例:
|
||||||
|
```bash
|
||||||
|
scp -r frontend/dist/* root@1.192.190.177:/boyun/crm/web/
|
||||||
|
```
|
||||||
|
3. Nginx 配置(示例):将静态文件目录作为根目录,并将 `/api` 反向代理到后端容器端口
|
||||||
|
```nginx
|
||||||
|
详见天翼云 conf.d/nginxeducation-crm.conf
|
||||||
|
```
|
||||||
|
- 重载 Nginx:
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
4. 前端请求后端的地址请与 Nginx 约定一致(推荐前端以 `/api` 为基准路径,避免跨域问题)。
|
||||||
|
|
||||||
|
## 二、后端:本地构建镜像并导出
|
||||||
|
1. 在本地构建后端镜像(建议固定仓库名与版本标签,便于管理与回滚):
|
||||||
|
```bash
|
||||||
|
cd e:/EduWeb/by-crm
|
||||||
|
docker build --no-cache -t by-crm/crm-backend:2026.01.27 -f backend/Dockerfile ./backend
|
||||||
|
```
|
||||||
|
- 可将版本号替换为语义化版本(如 `v1.2.3`)或日期。
|
||||||
|
- 应用的数据源配置已改为优先读取环境变量,并默认使用 Docker 服务名 `crm-mysql` 作为主机名(详见 backend/src/main/resources/application.yml)。
|
||||||
|
2. 导出镜像为 tar:
|
||||||
|
```bash
|
||||||
|
docker save -o by-crm-crm-backend-2026.01.27.tar by-crm/crm-backend:2026.01.27
|
||||||
|
```
|
||||||
|
3. 传输到服务器:
|
||||||
|
```bash
|
||||||
|
scp by-crm-crm-backend-2026.01.27.tar root@1.192.190.177:/boyun/crm/images/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、服务器:导入镜像并更新容器
|
||||||
|
1. 导入镜像:
|
||||||
|
```bash
|
||||||
|
docker load -i /boyun/crm/images/by-crm-crm-backend-2026.01.27.tar
|
||||||
|
docker image ls by-crm/crm-backend
|
||||||
|
```
|
||||||
|
2. 使用 docker-compose.yml 运行(无需在服务器构建):
|
||||||
|
- 推荐为 `crm-backend` 指定镜像标签(便于复用已导入镜像),在 `docker-compose.yml` 的该服务中增加:
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
crm-backend:
|
||||||
|
image: by-crm/crm-backend:2026.01.27
|
||||||
|
# build 可保留用于本地开发构建;服务器运行时不构建
|
||||||
|
# build:
|
||||||
|
# context: ./backend
|
||||||
|
# dockerfile: Dockerfile
|
||||||
|
```
|
||||||
|
- 如不修改仓库文件,亦可在服务器本地编辑 `/boyun/crm/docker-compose.yml` 添加上述 `image` 字段。
|
||||||
|
- 运行数据库与后端(前端不使用容器):
|
||||||
|
```bash
|
||||||
|
cd /boyun/crm
|
||||||
|
docker compose up -d --no-build crm-mysql crm-backend
|
||||||
|
```
|
||||||
|
- `--no-build` 确保服务器不上构建流程,直接使用已导入镜像。
|
||||||
|
- 首次启动数据库时,`./sql` 中的脚本会自动执行(取决于 `mysql/data` 是否为空)。
|
||||||
|
3. 准备日志目录(与镜像内 `/app/logs` 对应):
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /boyun/crm/backend/logs
|
||||||
|
sudo chown -R $(id -u):$(id -g) /boyun/crm/backend/logs
|
||||||
|
```
|
||||||
|
4. 停止并移除旧容器(如存在):
|
||||||
|
```bash
|
||||||
|
docker stop crm-backend || true
|
||||||
|
docker rm crm-backend || true
|
||||||
|
```
|
||||||
|
5. 运行新容器(示例端口映射为 `9948:8080`,按需调整;环境变量与 Compose 保持一致):
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name crm-backend \
|
||||||
|
--restart always \
|
||||||
|
-p 9948:8080 \
|
||||||
|
-v /boyun/crm/backend/logs:/app/logs \
|
||||||
|
-e SPRING_DATASOURCE_URL="jdbc:mysql://<db_host>:<db_port>/crm_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai" \
|
||||||
|
-e SPRING_DATASOURCE_USERNAME="root" \
|
||||||
|
-e SPRING_DATASOURCE_PASSWORD="MySQL123s56" \
|
||||||
|
-e JWT_SECRET="请替换为生产环境安全随机密钥" \
|
||||||
|
-e JWT_EXPIRATION="86400000" \
|
||||||
|
-e SERVER_PORT="8080" \
|
||||||
|
-e TZ="Asia/Shanghai" \
|
||||||
|
by-crm/crm-backend:2026.01.27
|
||||||
|
```
|
||||||
|
- 如使用服务器上的 MySQL 容器或托管 MySQL,请将 `<db_host>`、`<db_port>` 替换为实际值(例如 `127.0.0.1:3306`)。
|
||||||
|
- 后端健康检查地址:`http://127.0.0.1:9948/actuator/health`。
|
||||||
|
6. 验证运行状态:
|
||||||
|
```bash
|
||||||
|
docker ps
|
||||||
|
curl -fsSL http://127.0.0.1:9948/actuator/health
|
||||||
|
docker logs -f crm-backend
|
||||||
|
```
|
||||||
|
- 验证数据库:
|
||||||
|
```bash
|
||||||
|
docker logs -f crm-mysql
|
||||||
|
mysql -h 127.0.0.1 -P 39948 -u root -p
|
||||||
|
```
|
||||||
|
- 确认初始化脚本(位于 `/boyun/crm/sql`)是否执行成功(仅在数据目录为空时)。
|
||||||
|
|
||||||
|
## 四、升级与回滚流程
|
||||||
|
- 升级:
|
||||||
|
1) 本地重新 `docker build` 生成新标签镜像
|
||||||
|
2) `docker save` 导出并传输到服务器
|
||||||
|
3) 服务器 `docker load` 导入
|
||||||
|
4) 停掉旧容器,按新标签重新 `docker run`
|
||||||
|
- 回滚:
|
||||||
|
- 保留上一版本 tar 文件或确保服务器仍保留旧标签镜像:
|
||||||
|
```bash
|
||||||
|
docker stop crm-backend && docker rm crm-backend
|
||||||
|
docker run -d ... by-crm/crm-backend:<previous-tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、可选优化
|
||||||
|
- 使用 `--env-file` 管理环境变量(避免明文写在命令行):
|
||||||
|
- 创建 `/boyun/crm/backend/.env`:
|
||||||
|
```
|
||||||
|
SPRING_DATASOURCE_URL=jdbc:mysql://127.0.0.1:3306/crm_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
|
||||||
|
SPRING_DATASOURCE_USERNAME=root
|
||||||
|
SPRING_DATASOURCE_PASSWORD=请替换
|
||||||
|
JWT_SECRET=请替换为安全足够的随机字符串
|
||||||
|
JWT_EXPIRATION=86400000
|
||||||
|
SERVER_PORT=8080
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
```
|
||||||
|
- 运行容器:
|
||||||
|
```bash
|
||||||
|
docker run -d --name crm-backend --restart always -p 9948:8080 \
|
||||||
|
--env-file /boyun/crm/backend/.env \
|
||||||
|
-v /boyun/crm/backend/logs:/app/logs \
|
||||||
|
by-crm/crm-backend:2026.01.27
|
||||||
|
```
|
||||||
|
- 日志轮转:使用 `logrotate` 对 `/boyun/crm/backend/logs` 做轮转。
|
||||||
|
- 镜像命名约定:`by-crm/crm-backend:<semver>` 或 `<yyyy.mm.dd>`,保持稳定仓库名。
|
||||||
|
|
||||||
|
## 六、注意事项
|
||||||
|
- 安全:严禁将生产环境密钥与数据库密码提交到仓库。使用环境变量或外部密钥管理。
|
||||||
|
- 端口:前端 Nginx 默认 `80`;后端容器示例映射 `9948:8080`。如端口冲突,请调整 `-p` 或 Nginx 监听端口。
|
||||||
|
- CORS:推荐前端通过 Nginx 将 API 代理为同源路径 `/api`,避免跨域问题。
|
||||||
|
- 数据库初始化:若需要初始化脚本,可参考 Compose 的挂载方式 [docker-compose.yml:L27-L33](file:///e:/EduWeb/by-crm/docker-compose.yml#L27-L33),在服务器上独立管理数据库初始化。
|
||||||
|
- 使用 Compose 启动数据库时必须将本项目的 `sql` 目录内容同步到服务器路径 `/boyun/crm/sql/`,并保证 `/boyun/crm/mysql/data/` 在首次启动时为空,以便初始化脚本生效。后续运行不再重复执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
参考文件:
|
||||||
|
- [docker-compose.yml](file:///e:/EduWeb/by-crm/docker-compose.yml)
|
||||||
|
- [backend/Dockerfile](file:///e:/EduWeb/by-crm/backend/Dockerfile)
|
||||||
Reference in new issue
Block a user