diff --git a/backend/Dockerfile b/backend/Dockerfile index 2d04a21..ff299f8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,6 +3,9 @@ FROM maven:3.8.6-openjdk-8 AS builder WORKDIR /app +# 配置阿里云 Maven 镜像(容器内加速依赖下载,避免直连境外中央仓库 SSL 失败) +COPY settings.xml /root/.m2/settings.xml + # 复制 pom.xml 并下载依赖(利用 Docker 缓存) COPY pom.xml . RUN mvn dependency:go-offline diff --git a/backend/settings.xml b/backend/settings.xml new file mode 100644 index 0000000..21671ae --- /dev/null +++ b/backend/settings.xml @@ -0,0 +1,17 @@ + + + + + + aliyun-public + * + Aliyun Public Repository + https://maven.aliyun.com/repository/public + + + diff --git a/backend/src/main/java/com/bycrm/config/AsyncConfig.java b/backend/src/main/java/com/bycrm/config/AsyncConfig.java new file mode 100644 index 0000000..1490be8 --- /dev/null +++ b/backend/src/main/java/com/bycrm/config/AsyncConfig.java @@ -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; + } +} diff --git a/backend/src/main/java/com/bycrm/controller/SystemConfigController.java b/backend/src/main/java/com/bycrm/controller/SystemConfigController.java index b768d69..0019781 100644 --- a/backend/src/main/java/com/bycrm/controller/SystemConfigController.java +++ b/backend/src/main/java/com/bycrm/controller/SystemConfigController.java @@ -4,6 +4,7 @@ import com.bycrm.common.Result; import com.bycrm.entity.SystemConfig; import com.bycrm.entity.User; import com.bycrm.mapper.UserMapper; +import com.bycrm.service.NotifyService; import com.bycrm.service.SystemConfigService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -23,11 +24,14 @@ public class SystemConfigController { private final SystemConfigService systemConfigService; private final UserMapper userMapper; + private final NotifyService notifyService; public SystemConfigController(SystemConfigService systemConfigService, - UserMapper userMapper) { + UserMapper userMapper, + NotifyService notifyService) { this.systemConfigService = systemConfigService; this.userMapper = userMapper; + this.notifyService = notifyService; } /** @@ -104,4 +108,19 @@ public class SystemConfigController { systemConfigService.batchUpdateConfigs(configs); return Result.success(); } + + /** + * 测试 pushplus 推送配置(仅管理员) + */ + @ApiOperation("测试 pushplus 推送") + @PostMapping("/pushplus/test") + public Result 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(); + } } diff --git a/backend/src/main/java/com/bycrm/dto/LoginDTO.java b/backend/src/main/java/com/bycrm/dto/LoginDTO.java index c42d459..137bff4 100644 --- a/backend/src/main/java/com/bycrm/dto/LoginDTO.java +++ b/backend/src/main/java/com/bycrm/dto/LoginDTO.java @@ -24,4 +24,9 @@ public class LoginDTO implements Serializable { */ @NotBlank(message = "密码不能为空") private String password; + + /** + * 记住我(true 时签发 7 天有效期的 token) + */ + private Boolean rememberMe = false; } diff --git a/backend/src/main/java/com/bycrm/service/NotifyService.java b/backend/src/main/java/com/bycrm/service/NotifyService.java new file mode 100644 index 0000000..ed8d4a2 --- /dev/null +++ b/backend/src/main/java/com/bycrm/service/NotifyService.java @@ -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(); +} diff --git a/backend/src/main/java/com/bycrm/service/impl/NotifyServiceImpl.java b/backend/src/main/java/com/bycrm/service/impl/NotifyServiceImpl.java new file mode 100644 index 0000000..2eddc97 --- /dev/null +++ b/backend/src/main/java/com/bycrm/service/impl/NotifyServiceImpl.java @@ -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 = "

泊云智销通推送测试

收到本消息说明 pushplus 配置正常,经销商提交报备时您将收到通知。

"; + send("泊云智销通推送测试", content); + } + + /** + * 实际调用 pushplus 接口。全程容错,失败仅记日志。 + */ + private void send(String title, String content) { + try { + Map 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("

有新的报备申请待审核

"); + sb.append(""); + 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("
"); + if (approvalUrl != null && !approvalUrl.trim().isEmpty()) { + sb.append("

请跳转 ") + .append(approvalUrl) + .append(" 去审批处理。

"); + } + return sb.toString(); + } + + private void row(StringBuilder sb, String label, String value) { + sb.append("") + .append(label) + .append("") + .append(value) + .append(""); + } + + private String safe(String s) { + return s == null ? "-" : s; + } +} diff --git a/backend/src/main/java/com/bycrm/service/impl/ReportServiceImpl.java b/backend/src/main/java/com/bycrm/service/impl/ReportServiceImpl.java index 36dd425..6f46db4 100644 --- a/backend/src/main/java/com/bycrm/service/impl/ReportServiceImpl.java +++ b/backend/src/main/java/com/bycrm/service/impl/ReportServiceImpl.java @@ -15,12 +15,15 @@ import com.bycrm.mapper.DealerMapper; import com.bycrm.mapper.ReportMapper; import com.bycrm.mapper.SchoolMapper; import com.bycrm.mapper.UserMapper; +import com.bycrm.service.NotifyService; import com.bycrm.service.ReportService; import com.bycrm.service.SystemConfigService; import com.bycrm.vo.ReportVO; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; 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.LocalDateTime; @@ -39,17 +42,20 @@ public class ReportServiceImpl implements ReportService { private final UserMapper userMapper; private final DealerMapper dealerMapper; private final SystemConfigService systemConfigService; + private final NotifyService notifyService; public ReportServiceImpl(ReportMapper reportMapper, SchoolMapper schoolMapper, UserMapper userMapper, DealerMapper dealerMapper, - SystemConfigService systemConfigService) { + SystemConfigService systemConfigService, + NotifyService notifyService) { this.reportMapper = reportMapper; this.schoolMapper = schoolMapper; this.userMapper = userMapper; this.dealerMapper = dealerMapper; this.systemConfigService = systemConfigService; + this.notifyService = notifyService; } @Override @@ -162,6 +168,16 @@ public class ReportServiceImpl implements ReportService { report.setUpdatedAt(LocalDateTime.now()); 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 diff --git a/backend/src/main/java/com/bycrm/service/impl/UserServiceImpl.java b/backend/src/main/java/com/bycrm/service/impl/UserServiceImpl.java index aa4144a..bbb283a 100644 --- a/backend/src/main/java/com/bycrm/service/impl/UserServiceImpl.java +++ b/backend/src/main/java/com/bycrm/service/impl/UserServiceImpl.java @@ -46,6 +46,10 @@ public class UserServiceImpl implements UserService { 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()); } diff --git a/backend/src/main/java/com/bycrm/util/JwtUtil.java b/backend/src/main/java/com/bycrm/util/JwtUtil.java index 85b099a..fedabc7 100644 --- a/backend/src/main/java/com/bycrm/util/JwtUtil.java +++ b/backend/src/main/java/com/bycrm/util/JwtUtil.java @@ -25,24 +25,45 @@ public class JwtUtil { @Value("${jwt.expiration}") private Long expiration; + @Value("${jwt.remember-expiration:604800000}") + private Long rememberExpiration; + + public Long getRememberExpiration() { + return rememberExpiration; + } + /** * 生成 Token */ 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 claims = new HashMap<>(); claims.put("userId", userId); claims.put("username", username); claims.put("role", role); claims.put("dealerId", dealerId); - return generateToken(claims); + return generateToken(claims, expirationMillis); } /** * 生成 Token */ public String generateToken(Map claims) { + return generateToken(claims, expiration); + } + + /** + * 生成指定有效期的 Token + */ + public String generateToken(Map claims, long expirationMillis) { Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expiration); + Date expiryDate = new Date(now.getTime() + expirationMillis); return Jwts.builder() .setClaims(claims) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 6f858ed..478dd0d 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -55,9 +55,11 @@ mybatis: # JWT 配置 jwt: # HS512 算法要求密钥至少 64 字节(512 位) - # 生产环境请使用环境变量或密钥管理系统存储此密钥 - secret: by-crm-jwt-secret-key-2024-hs512-requires-at-least-64-bytes-for-secure-signing-please-change-in-production-environment + # 环境变量 JWT_SECRET 优先;未设置时使用下方随机默认值(本地开发可直接运行) + # 生产部署请配置 JWT_SECRET 环境变量,真实密钥不要提交到代码库 + secret: "${JWT_SECRET:qX6m68jtYjbAu3UnwzJadxDfgdc5C62NL+S4HUo3M66eU7+nkbDiMijtN+cs/a9Mj1qm4IyDt2FTDpUA0GI2RQ==}" expiration: 86400000 # 24小时,单位:毫秒 + remember-expiration: 604800000 # 记住我:7天免登录,单位:毫秒 # CRM 业务配置 crm: @@ -65,6 +67,14 @@ crm: ttl-days: 90 # 保护期天数 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 配置 springfox: documentation: diff --git a/frontend/index.html b/frontend/index.html index 715ac80..f81f1a3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + 泊云智销通 diff --git a/frontend/package.json b/frontend/package.json index 3917c12..9f4b464 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,10 +11,12 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.0", + "@vueuse/core": "^14.4.0", "axios": "^1.6.0", "bcryptjs": "^3.0.3", "dayjs": "^1.11.0", "element-plus": "^2.5.0", + "js-sha256": "^1.0.0", "pinia": "^2.1.0", "pinia-plugin-persistedstate": "^3.2.0", "vue": "^3.4.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a06fc9c..e1f25be 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@element-plus/icons-vue': specifier: ^2.3.0 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: specifier: ^1.6.0 version: 1.13.2 @@ -23,6 +26,9 @@ importers: element-plus: specifier: ^2.5.0 version: 2.13.1(vue@3.5.27(typescript@5.9.3)) + js-sha256: + specifier: ^1.0.0 + version: 1.0.0 pinia: specifier: ^2.1.0 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': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@typescript-eslint/eslint-plugin@6.21.0': resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -644,12 +653,25 @@ packages: '@vueuse/core@10.11.1': 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': resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + '@vueuse/metadata@14.4.0': + resolution: {integrity: sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==} + '@vueuse/shared@10.11.1': 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: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1015,6 +1037,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-sha256@1.0.0: + resolution: {integrity: sha512-Bqxf6ENUzYIMzuELCmRNrJOVbjKH1oMgbfYJBKVr/W1Xf9fazpahqCbb24v1pR7XV1isuqhM+w9KWpK7zCyUQw==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -1699,6 +1724,8 @@ snapshots: '@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)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1880,8 +1907,17 @@ snapshots: - '@vue/composition-api' - 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@14.4.0': {} + '@vueuse/shared@10.11.1(vue@3.5.27(typescript@5.9.3))': dependencies: vue-demi: 0.14.10(vue@3.5.27(typescript@5.9.3)) @@ -1889,6 +1925,10 @@ snapshots: - '@vue/composition-api' - 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): dependencies: acorn: 8.15.0 @@ -2309,6 +2349,8 @@ snapshots: isexe@2.0.0: {} + js-sha256@1.0.0: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 diff --git a/frontend/src/components/MobileTabBar.vue b/frontend/src/components/MobileTabBar.vue new file mode 100644 index 0000000..b069353 --- /dev/null +++ b/frontend/src/components/MobileTabBar.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/frontend/src/composables/useResponsive.ts b/frontend/src/composables/useResponsive.ts new file mode 100644 index 0000000..93f9ffc --- /dev/null +++ b/frontend/src/composables/useResponsive.ts @@ -0,0 +1,17 @@ +import { useMediaQuery } from '@vueuse/core' + +/** + * 响应式断点判断(与 styles/variables.scss 的媒体查询保持一致)。 + * - isMobile : ≤768px 手机竖屏 + * - isTablet : 769–1024 平板 + * - isPC : ≥1025px 桌面(现有 PC 行为) + * + * 返回值均为 Ref,可在模板中直接 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 } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index aa806e3..d1e79f5 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,6 +3,7 @@ import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' +import '@/styles/mobile.scss' import * as ElementPlusIconsVue from '@element-plus/icons-vue' import App from './App.vue' import router from './router' diff --git a/frontend/src/styles/mobile.scss b/frontend/src/styles/mobile.scss new file mode 100644 index 0000000..72363a5 --- /dev/null +++ b/frontend/src/styles/mobile.scss @@ -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; + } +} diff --git a/frontend/src/styles/variables.scss b/frontend/src/styles/variables.scss new file mode 100644 index 0000000..b6aa8f0 --- /dev/null +++ b/frontend/src/styles/variables.scss @@ -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; + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 221dafb..50454ce 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -14,6 +14,7 @@ export interface User { export interface LoginRequest { username: string password: string + rememberMe?: boolean } export interface LoginResponse { diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index ae92651..4d28a1d 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -1,39 +1,44 @@ /** - * 密码加密工具 - * 使用 Web Crypto API 进行 SHA-256 哈希 + * 密码加密工具(SHA-256 → 小写 hex) + * + * 安全上下文(HTTPS 或 localhost)下使用原生 Web Crypto API; + * 非安全上下文(HTTP + 局域网/IP 访问,此时 crypto.subtle 为 undefined) + * 下回退到纯 JS 实现 js-sha256,保证产物一致,登录不再卡死。 + * 两条路径对同一字符串产出的 SHA-256 小写 hex 完全相同。 */ +import { sha256 } from 'js-sha256' -/** - * 将字符串转换为 ArrayBuffer - */ -function stringToArrayBuffer(str: string): ArrayBuffer { - const encoder = new TextEncoder() - return encoder.encode(str).buffer +/** 当前环境是否可用原生 Web Crypto(即处于安全上下文) */ +function hasSubtleCrypto(): boolean { + return typeof crypto !== 'undefined' && typeof crypto.subtle?.digest === 'function' } -/** - * 将 ArrayBuffer 转换为十六进制字符串 - */ -function arrayBufferToHex(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer) - return Array.from(bytes) - .map(b => b.toString(16).padStart(2, '0')) +/** 原生 Web Crypto SHA-256 → 小写 hex */ +async function subtleSha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) .join('') } +/** 统一入口:优先原生,回退纯 JS */ +async function sha256Hex(input: string): Promise { + if (hasSubtleCrypto()) { + return subtleSha256Hex(input) + } + return sha256(input) +} + /** * 使用 SHA-256 算法哈希密码 * @param password 明文密码 - * @returns 哈希后的十六进制字符串 + * @returns 哈希后的十六进制字符串(小写) */ export async function hashPassword(password: string): Promise { if (!password) return '' - try { - // 使用 Web Crypto API 进行 SHA-256 哈希 - const data = stringToArrayBuffer(password) - const hashBuffer = await crypto.subtle.digest('SHA-256', data) - return arrayBufferToHex(hashBuffer) + return await sha256Hex(password) } catch (error) { console.error('密码哈希失败:', error) throw new Error('密码加密失败') @@ -44,16 +49,12 @@ export async function hashPassword(password: string): Promise { * 简单的加盐哈希(可选,用于增强安全性) * @param password 明文密码 * @param salt 盐值 - * @returns 哈希后的十六进制字符串 + * @returns 哈希后的十六进制字符串(小写) */ export async function hashPasswordWithSalt(password: string, salt: string): Promise { if (!password) return '' - try { - const saltedPassword = password + salt - const data = stringToArrayBuffer(saltedPassword) - const hashBuffer = await crypto.subtle.digest('SHA-256', data) - return arrayBufferToHex(hashBuffer) + return await sha256Hex(password + salt) } catch (error) { console.error('密码哈希失败:', error) throw new Error('密码加密失败') diff --git a/frontend/src/views/Customer.vue b/frontend/src/views/Customer.vue index 5b4e8bc..9a3d54a 100644 --- a/frontend/src/views/Customer.vue +++ b/frontend/src/views/Customer.vue @@ -17,7 +17,7 @@ - + @@ -49,7 +49,7 @@ - + @@ -84,6 +84,31 @@ + +
+
+
+ {{ row.name }} + 可报备 + 保护中 + 已失效 +
+
联系电话{{ row.phone || '-' }}
+
所属行业{{ getIndustryLabel(row.industry) }}
+
地址{{ row.address || '-' }}
+ +
创建时间{{ row.createdAt }}
+
+ 编辑 + 删除 +
+
+ +
+ @@ -105,7 +130,7 @@ @@ -161,8 +186,10 @@ import { getConfigValue } from '@/api/system' import { searchSchoolByName } from '@/api/school' import type { Customer, CustomerForm } from '@/types' import type { School } from '@/api/school' +import { useResponsive } from '@/composables/useResponsive' const protectDays = ref(90) +const { isMobile } = useResponsive() const loading = ref(false) const tableData = ref([]) @@ -383,4 +410,59 @@ onMounted(() => { color: #909399; 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; + } +} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 7bf1f6b..b5b925c 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -15,7 +15,7 @@ --> - +
@@ -28,7 +28,7 @@
- +
@@ -41,7 +41,7 @@
- +
@@ -184,4 +184,20 @@ onMounted(() => { color: #606266; 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; + } +} diff --git a/frontend/src/views/Dealer.vue b/frontend/src/views/Dealer.vue index db80bdc..bd8cb7c 100644 --- a/frontend/src/views/Dealer.vue +++ b/frontend/src/views/Dealer.vue @@ -12,7 +12,7 @@ - + @@ -35,7 +35,7 @@ - + @@ -57,13 +57,36 @@ + + +
+
+
+ {{ row.name }} + + {{ row.status === 1 ? '启用' : '禁用' }} + +
+
账号{{ row.code }}
+
联系人{{ row.contactPerson || '-' }}
+
联系电话{{ row.contactPhone || '-' }}
+
邮箱{{ row.email || '-' }}
+
创建时间{{ row.createdAt }}
+
+ 编辑 + 重置密码 + 删除 +
+
+ +
@@ -110,7 +133,7 @@ @@ -149,6 +172,9 @@ import { getDealerList, createDealer, updateDealer, deleteDealer } from '@/api/d import { resetPassword } from '@/api/auth' import { hashPassword } from '@/utils/crypto' import type { Dealer } from '@/types' +import { useResponsive } from '@/composables/useResponsive' + +const { isMobile } = useResponsive() const loading = ref(false) const tableData = ref([]) @@ -383,4 +409,59 @@ onMounted(() => { .query-form { 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; + } +} diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 474c2ac..57836ba 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -1,7 +1,7 @@