Compare commits
6 Commits
4c6970c32d
...
12738e20db
| Author | SHA1 | Date | |
|---|---|---|---|
| 12738e20db | |||
| ca00727cd0 | |||
| 8cf9e208e4 | |||
| 058e1fb8a7 | |||
| f400a93bdd | |||
| fd89c31640 |
|
|
@ -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
|
||||||
|
|
|
||||||
17
backend/settings.xml
Normal file
17
backend/settings.xml
Normal file
|
|
@ -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>
|
||||||
34
backend/src/main/java/com/bycrm/config/AsyncConfig.java
Normal file
34
backend/src/main/java/com/bycrm/config/AsyncConfig.java
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
backend/src/main/java/com/bycrm/service/NotifyService.java
Normal file
22
backend/src/main/java/com/bycrm/service/NotifyService.java
Normal file
|
|
@ -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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,12 +15,15 @@ import com.bycrm.mapper.DealerMapper;
|
||||||
import com.bycrm.mapper.ReportMapper;
|
import com.bycrm.mapper.ReportMapper;
|
||||||
import com.bycrm.mapper.SchoolMapper;
|
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;
|
||||||
|
|
@ -39,17 +42,20 @@ public class ReportServiceImpl implements ReportService {
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final DealerMapper dealerMapper;
|
private final DealerMapper dealerMapper;
|
||||||
private final SystemConfigService systemConfigService;
|
private final SystemConfigService systemConfigService;
|
||||||
|
private final NotifyService notifyService;
|
||||||
|
|
||||||
public ReportServiceImpl(ReportMapper reportMapper,
|
public ReportServiceImpl(ReportMapper reportMapper,
|
||||||
SchoolMapper schoolMapper,
|
SchoolMapper schoolMapper,
|
||||||
UserMapper userMapper,
|
UserMapper userMapper,
|
||||||
DealerMapper dealerMapper,
|
DealerMapper dealerMapper,
|
||||||
SystemConfigService systemConfigService) {
|
SystemConfigService systemConfigService,
|
||||||
|
NotifyService notifyService) {
|
||||||
this.reportMapper = reportMapper;
|
this.reportMapper = reportMapper;
|
||||||
this.schoolMapper = schoolMapper;
|
this.schoolMapper = schoolMapper;
|
||||||
this.userMapper = userMapper;
|
this.userMapper = userMapper;
|
||||||
this.dealerMapper = dealerMapper;
|
this.dealerMapper = dealerMapper;
|
||||||
this.systemConfigService = systemConfigService;
|
this.systemConfigService = systemConfigService;
|
||||||
|
this.notifyService = notifyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -162,6 +168,16 @@ public class ReportServiceImpl implements ReportService {
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -46,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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
76
frontend/src/components/MobileTabBar.vue
Normal file
76
frontend/src/components/MobileTabBar.vue
Normal file
|
|
@ -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>
|
||||||
17
frontend/src/composables/useResponsive.ts
Normal file
17
frontend/src/composables/useResponsive.ts
Normal file
|
|
@ -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'
|
||||||
|
|
|
||||||
44
frontend/src/styles/mobile.scss
Normal file
44
frontend/src/styles/mobile.scss
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/src/styles/variables.scss
Normal file
25
frontend/src/styles/variables.scss
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,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 {
|
||||||
|
|
|
||||||
|
|
@ -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('密码加密失败')
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
</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>
|
||||||
|
|
@ -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,6 +84,31 @@
|
||||||
</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-config-provider :locale="zhCn">
|
<el-config-provider :locale="zhCn">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
|
|
@ -91,12 +116,12 @@
|
||||||
v-model:page-size="queryForm.size"
|
v-model:page-size="queryForm.size"
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||||
prev-text="上一页"
|
prev-text="上一页"
|
||||||
next-text="下一页"
|
next-text="下一页"
|
||||||
@size-change="fetchData"
|
@size-change="fetchData"
|
||||||
@current-change="fetchData"
|
@current-change="fetchData"
|
||||||
style="margin-top: 20px; justify-content: flex-end"
|
:style="{ marginTop: '20px', justifyContent: isMobile ? 'center' : 'flex-end' }"
|
||||||
/>
|
/>
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
@ -105,7 +130,7 @@
|
||||||
<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">
|
||||||
|
|
@ -161,8 +186,10 @@ 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[]>([])
|
||||||
|
|
@ -383,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>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
-->
|
-->
|
||||||
<el-col :span="userStore.isAdmin ? 8 : 12">
|
<el-col :xs="24" :sm="userStore.isAdmin ? 8 : 12">
|
||||||
<el-card class="stat-card" @click="handleReportCountClick">
|
<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">
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="userStore.isAdmin ? 8 : 12">
|
<el-col :xs="24" :sm="userStore.isAdmin ? 8 : 12">
|
||||||
<el-card class="stat-card" @click="handlePendingClick">
|
<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">
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8" v-if="userStore.isAdmin">
|
<el-col :xs="24" :sm="8" v-if="userStore.isAdmin">
|
||||||
<el-card class="stat-card" @click="handleDealerCountClick">
|
<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">
|
||||||
|
|
@ -184,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,7 +12,7 @@
|
||||||
</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>
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -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[]>([])
|
||||||
|
|
@ -383,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
|
||||||
|
|
@ -34,13 +34,16 @@
|
||||||
<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?.dealerName }}
|
{{ userStore.userInfo?.dealerName }}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -89,7 +109,8 @@ const handleLogin = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.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>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
</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>
|
||||||
|
|
@ -40,7 +40,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="dealerName" label="经销商" v-if="userStore.isAdmin"/>
|
<el-table-column prop="dealerName" label="经销商" v-if="userStore.isAdmin"/>
|
||||||
<el-table-column prop="schoolName" label="学校名称" />
|
<el-table-column prop="schoolName" label="学校名称" />
|
||||||
<el-table-column prop="product" label="所属产品" show-overflow-tooltip />
|
<el-table-column prop="product" label="所属产品" show-overflow-tooltip />
|
||||||
|
|
@ -102,6 +102,36 @@
|
||||||
</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-config-provider :locale="zhCn">
|
<el-config-provider :locale="zhCn">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
|
|
@ -109,18 +139,18 @@
|
||||||
v-model:page-size="queryForm.size"
|
v-model:page-size="queryForm.size"
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||||
prev-text="上一页"
|
prev-text="上一页"
|
||||||
next-text="下一页"
|
next-text="下一页"
|
||||||
@size-change="fetchData"
|
@size-change="fetchData"
|
||||||
@current-change="fetchData"
|
@current-change="fetchData"
|
||||||
style="margin-top: 20px; justify-content: flex-end"
|
:style="{ marginTop: '20px', justifyContent: isMobile ? 'center' : 'flex-end' }"
|
||||||
/>
|
/>
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 提交报备对话框 -->
|
<!-- 提交报备对话框 -->
|
||||||
<el-dialog v-model="dialogVisible" title="提交报备" width="650px" @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="schoolName">
|
<el-form-item label="学校" prop="schoolName">
|
||||||
<el-autocomplete
|
<el-autocomplete
|
||||||
|
|
@ -183,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">
|
||||||
|
|
@ -207,7 +237,7 @@
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 编辑报备对话框 -->
|
<!-- 编辑报备对话框 -->
|
||||||
<el-dialog v-model="editDialogVisible" title="编辑报备" width="600px" @close="handleEditDialogClose">
|
<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 ref="editFormRef" :model="editFormData" :rules="editRules" label-width="100px">
|
||||||
<el-form-item label="学校名称">
|
<el-form-item label="学校名称">
|
||||||
<el-input :model-value="currentReport?.schoolName" disabled />
|
<el-input :model-value="currentReport?.schoolName" disabled />
|
||||||
|
|
@ -270,8 +300,8 @@
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 报备详情对话框 -->
|
<!-- 报备详情对话框 -->
|
||||||
<el-dialog v-model="detailDialogVisible" title="报备详情" width="700px">
|
<el-dialog v-model="detailDialogVisible" title="报备详情" :width="isMobile ? '92%' : '700px'">
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="isMobile ? 1 : 2" border>
|
||||||
<el-descriptions-item label="经销商">
|
<el-descriptions-item label="经销商">
|
||||||
{{ currentReport?.dealerName || '-' }}
|
{{ currentReport?.dealerName || '-' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
|
@ -330,7 +360,7 @@
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 进展记录对话框 -->
|
<!-- 进展记录对话框 -->
|
||||||
<el-dialog v-model="progressDialogVisible" title="报备进展记录" width="1000px" @close="handleProgressDialogClose">
|
<el-dialog v-model="progressDialogVisible" title="报备进展记录" :width="isMobile ? '92%' : '1000px'" :fullscreen="isMobile" @close="handleProgressDialogClose">
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|
@ -387,11 +417,13 @@ import { getProgressByReportId, createProgress, updateProgress } from '@/api/rep
|
||||||
import { searchSchoolByName } from '@/api/school'
|
import { searchSchoolByName } from '@/api/school'
|
||||||
import { getConfigValue } from '@/api/system'
|
import { getConfigValue } from '@/api/system'
|
||||||
import type { Report, ReportForm, ReportProgress, ReportProgressForm, ReportUpdateForm } from '@/types'
|
import type { Report, ReportForm, ReportProgress, ReportProgressForm, ReportUpdateForm } from '@/types'
|
||||||
|
import { useResponsive } from '@/composables/useResponsive'
|
||||||
|
|
||||||
const route = useRoute()
|
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[]>([])
|
||||||
|
|
@ -792,4 +824,66 @@ onMounted(() => {
|
||||||
:deep(.el-descriptions__content) {
|
:deep(.el-descriptions__content) {
|
||||||
color: #333;
|
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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user