| | |
| | | ## tcm-training |
| | | # tcm-training |
| | | |
| | | 中医在线培训 |
| | | 中医在线培训项目仓库,当前包含后台管理后端、后台管理前端,以及新增的 APP 端 `uni-app` 工程。 |
| | | |
| | | ## 项目结构 |
| | | |
| | | ```text |
| | | tcm-training/ |
| | | ├── tcm-training-admin-backend/ # Spring Boot 2.7 后端 |
| | | ├── tcm-training-admin/ # React 18 + Umi 管理后台 |
| | | ├── traditionalApp/ # uni-app APP 前端工程 |
| | | ├── doc/ # 业务文档与原型说明 |
| | | └── AGENTS.md # 仓库协作说明 |
| | | ``` |
| | | |
| | | ## 子项目说明 |
| | | |
| | | ### 1. `tcm-training-admin-backend` |
| | | |
| | | - 技术栈:Spring Boot 2.7.18、MyBatis-Plus、Sa-Token、Redis、MySQL |
| | | - 主要代码目录:`src/main/java/com/tcm/training/admin/` |
| | | - 配置目录:`src/main/resources/` |
| | | - SQL 初始化脚本:`sql/init.sql` |
| | | |
| | | 常用命令: |
| | | |
| | | ```bash |
| | | cd "tcm-training-admin-backend" |
| | | mvn -DskipTests spring-boot:run |
| | | ``` |
| | | |
| | | ```bash |
| | | cd "tcm-training-admin-backend" |
| | | mvn test |
| | | ``` |
| | | |
| | | ### 2. `tcm-training-admin` |
| | | |
| | | - 技术栈:React 18、Umi Max、Ant Design Pro、Jest、TypeScript |
| | | - 主要代码目录:`src/` |
| | | - 配置目录:`config/` |
| | | |
| | | 常用命令: |
| | | |
| | | ```bash |
| | | cd "tcm-training-admin" |
| | | npm install |
| | | npm run start:dev |
| | | ``` |
| | | |
| | | ```bash |
| | | cd "tcm-training-admin" |
| | | npm run build:prod |
| | | npm run test |
| | | npm run prettier |
| | | npm run tsc |
| | | ``` |
| | | |
| | | ### 3. `traditionalApp` |
| | | |
| | | - 技术栈:`uni-app`、Vue 3、Pinia、`uview-pro` |
| | | - 入口文件:`main.js`、`App.vue` |
| | | - 页面配置:`pages.json` |
| | | - 应用配置:`manifest.json` |
| | | - 页面目录:`pages/` |
| | | - 公共组件目录:`components/` |
| | | - 公共工具目录:`utils/` |
| | | |
| | | 说明: |
| | | |
| | | - 当前工程为标准 `uni-app` 项目结构,适合使用 HBuilderX 导入和运行。 |
| | | - `traditionalApp/package.json` 目前是 `uni_modules` 组件包元数据,不是常规的工程脚本入口;因此仓库内没有统一的 `npm run dev`/`npm run build` 命令。 |
| | | - `manifest.json` 已包含 App 打包配置,同时配置了微信小程序相关字段,后续若扩展多端发布,请以该文件为准维护。 |
| | | |
| | | 推荐开发方式: |
| | | |
| | | 1. 使用 HBuilderX 打开 `traditionalApp/` |
| | | 2. 通过 HBuilderX 运行到浏览器、模拟器或真机 |
| | | 3. 如需补充 npm 化构建流程,再单独整理脚本与依赖管理方案 |
| | | |
| | | ## 开发建议 |
| | | |
| | | - 优先按子项目分别安装依赖,避免在仓库根目录执行包管理命令。 |
| | | - 不要提交 `node_modules/`、`target/`、`unpackage/`、运行日志和本地环境配置。 |
| | | - 前后端联调时,建议先启动后端,再启动管理后台或 APP 端。 |
| | | - 每个函数或方法都需要编写注释,说明用途、参数或关键行为。 |
| | | - 关键业务代码必须补充注释,重点解释业务规则、分支原因和约束条件。 |
| | | - 代码注释语言应与所在文件现有注释语言保持一致。 |
| | | |
| | | ## 文档目录 |
| | | |
| | | - `doc/`:业务需求、原型说明、字段文档 |
| | | - `AGENTS.md`:面向协作代理/自动化工具的仓库约定 |
| | | |
| | | ## 当前状态说明 |
| | | |
| | | 仓库当前实际生效的前端包含两套: |
| | | |
| | | - `tcm-training-admin/`:管理后台前端 |
| | | - `traditionalApp/`:APP 端前端(新增 `uni-app` 工程) |
| | | |
| | | 后续新增子项目时,请同步更新本文件与 `AGENTS.md`,确保目录和启动方式始终与仓库一致。 |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.config; |
| | | |
| | | import org.springframework.boot.context.properties.ConfigurationProperties; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | /** |
| | | * APP 短信验证码登录配置。 |
| | | */ |
| | | @Component |
| | | @ConfigurationProperties(prefix = "app.auth.sms") |
| | | public class AppSmsAuthProperties { |
| | | |
| | | private int expireSeconds = 300; |
| | | private boolean debugEnabled = false; |
| | | private String debugCode = ""; |
| | | |
| | | /** |
| | | * 获取验证码有效期秒数。 |
| | | */ |
| | | public int getExpireSeconds() { |
| | | return expireSeconds; |
| | | } |
| | | |
| | | /** |
| | | * 设置验证码有效期秒数。 |
| | | */ |
| | | public void setExpireSeconds(int expireSeconds) { |
| | | this.expireSeconds = expireSeconds; |
| | | } |
| | | |
| | | /** |
| | | * 获取是否启用开发调试验证码。 |
| | | */ |
| | | public boolean isDebugEnabled() { |
| | | return debugEnabled; |
| | | } |
| | | |
| | | /** |
| | | * 设置是否启用开发调试验证码。 |
| | | */ |
| | | public void setDebugEnabled(boolean debugEnabled) { |
| | | this.debugEnabled = debugEnabled; |
| | | } |
| | | |
| | | /** |
| | | * 获取开发调试验证码。 |
| | | */ |
| | | public String getDebugCode() { |
| | | return debugCode; |
| | | } |
| | | |
| | | /** |
| | | * 设置开发调试验证码。 |
| | | */ |
| | | public void setDebugCode(String debugCode) { |
| | | this.debugCode = debugCode; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.controller; |
| | | |
| | | import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest; |
| | | import com.tcm.training.admin.app.auth.dto.AppSmsLoginRequest; |
| | | import com.tcm.training.admin.app.auth.service.AppAuthService; |
| | | import com.tcm.training.admin.app.auth.vo.AppLoginResponse; |
| | | import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse; |
| | | import com.tcm.training.admin.common.ApiResponse; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.RequestBody; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | import javax.validation.Valid; |
| | | |
| | | /** |
| | | * APP 登录认证接口。 |
| | | */ |
| | | @Validated |
| | | @RestController |
| | | @RequestMapping({"/api/app/auth", "/api/v1/app/auth"}) |
| | | public class AppAuthController { |
| | | |
| | | private final AppAuthService appAuthService; |
| | | |
| | | /** |
| | | * 注入 APP 登录服务。 |
| | | */ |
| | | public AppAuthController(AppAuthService appAuthService) { |
| | | this.appAuthService = appAuthService; |
| | | } |
| | | |
| | | /** |
| | | * 发送 APP 登录短信验证码。 |
| | | */ |
| | | @PostMapping("/sms-code") |
| | | public ApiResponse<AppSmsCodeSendResponse> sendSmsCode(@Valid @RequestBody AppSmsCodeSendRequest request) { |
| | | return ApiResponse.ok(appAuthService.sendLoginCode(request)); |
| | | } |
| | | |
| | | /** |
| | | * 使用短信验证码完成 APP 登录。 |
| | | */ |
| | | @PostMapping("/sms-login") |
| | | public ApiResponse<AppLoginResponse> smsLogin(@Valid @RequestBody AppSmsLoginRequest request) { |
| | | return ApiResponse.ok(appAuthService.smsLogin(request)); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.dto; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.NoArgsConstructor; |
| | | import lombok.Setter; |
| | | |
| | | import javax.validation.constraints.NotBlank; |
| | | |
| | | /** |
| | | * APP 发送短信验证码请求。 |
| | | */ |
| | | @Getter |
| | | @Setter |
| | | @NoArgsConstructor |
| | | public class AppSmsCodeSendRequest { |
| | | |
| | | @NotBlank(message = "请输入正确的手机号") |
| | | private String phone; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.dto; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.NoArgsConstructor; |
| | | import lombok.Setter; |
| | | |
| | | import javax.validation.constraints.NotBlank; |
| | | |
| | | /** |
| | | * APP 短信验证码登录请求。 |
| | | */ |
| | | @Getter |
| | | @Setter |
| | | @NoArgsConstructor |
| | | public class AppSmsLoginRequest { |
| | | |
| | | @NotBlank(message = "请输入正确的手机号") |
| | | private String phone; |
| | | |
| | | @NotBlank(message = "请输入验证码") |
| | | private String code; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.service; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest; |
| | | import com.tcm.training.admin.app.auth.dto.AppSmsLoginRequest; |
| | | import com.tcm.training.admin.app.auth.support.StpAppUserUtil; |
| | | import com.tcm.training.admin.app.auth.vo.AppLoginResponse; |
| | | import com.tcm.training.admin.app.auth.vo.AppLoginUserVO; |
| | | import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse; |
| | | import com.tcm.training.admin.member.entity.AppUser; |
| | | import com.tcm.training.admin.member.mapper.UserMapper; |
| | | import cn.dev33.satoken.stp.SaTokenInfo; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.regex.Pattern; |
| | | |
| | | /** |
| | | * APP 短信验证码登录服务。 |
| | | */ |
| | | @Service |
| | | public class AppAuthService { |
| | | |
| | | private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$"); |
| | | |
| | | private final UserMapper userMapper; |
| | | private final AppSmsCodeService appSmsCodeService; |
| | | |
| | | /** |
| | | * 注入 APP 用户查询能力与短信验证码服务。 |
| | | */ |
| | | public AppAuthService(UserMapper userMapper, AppSmsCodeService appSmsCodeService) { |
| | | this.userMapper = userMapper; |
| | | this.appSmsCodeService = appSmsCodeService; |
| | | } |
| | | |
| | | /** |
| | | * 为已注册 APP 用户生成登录验证码。 |
| | | */ |
| | | public AppSmsCodeSendResponse sendLoginCode(AppSmsCodeSendRequest request) { |
| | | String phone = normalizePhone(request.getPhone()); |
| | | requireEnabledUserByPhone(phone); |
| | | return appSmsCodeService.createLoginCode(phone); |
| | | } |
| | | |
| | | /** |
| | | * 使用短信验证码完成 APP 用户登录。 |
| | | */ |
| | | public AppLoginResponse smsLogin(AppSmsLoginRequest request) { |
| | | String phone = normalizePhone(request.getPhone()); |
| | | AppUser user = requireEnabledUserByPhone(phone); |
| | | appSmsCodeService.validateLoginCode(phone, normalizeCode(request.getCode())); |
| | | |
| | | StpAppUserUtil.login(user.getId()); |
| | | SaTokenInfo tokenInfo = StpAppUserUtil.getTokenInfo(); |
| | | return new AppLoginResponse( |
| | | tokenInfo.getTokenValue(), |
| | | "Bearer", |
| | | tokenInfo.getTokenTimeout(), |
| | | buildLoginUser(user) |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 将 APP 用户实体转换为登录响应中的用户摘要。 |
| | | */ |
| | | private AppLoginUserVO buildLoginUser(AppUser user) { |
| | | AppLoginUserVO loginUser = new AppLoginUserVO(); |
| | | loginUser.setId(user.getId()); |
| | | loginUser.setNickname(resolveNickname(user)); |
| | | loginUser.setPhone(user.getPhone()); |
| | | loginUser.setAvatar(user.getAvatar()); |
| | | loginUser.setType(user.getType()); |
| | | loginUser.setStatus(user.getStatus()); |
| | | loginUser.setClinicId(user.getClinicId()); |
| | | loginUser.setMemberExpireAt(user.getMemberExpireAt()); |
| | | return loginUser; |
| | | } |
| | | |
| | | /** |
| | | * 按手机号查询并校验 APP 用户状态。 |
| | | */ |
| | | private AppUser requireEnabledUserByPhone(String phone) { |
| | | AppUser user = userMapper.selectOne(new LambdaQueryWrapper<AppUser>() |
| | | .eq(AppUser::getPhone, phone) |
| | | .eq(AppUser::getDeleted, 0) |
| | | .last("limit 1")); |
| | | if (user == null) { |
| | | throw new IllegalArgumentException("账号不存在,请先注册"); |
| | | } |
| | | if (user.getStatus() == null || user.getStatus() != 1) { |
| | | throw new IllegalArgumentException("账号已被禁用"); |
| | | } |
| | | return user; |
| | | } |
| | | |
| | | /** |
| | | * 归一化并校验手机号格式。 |
| | | */ |
| | | private String normalizePhone(String phone) { |
| | | String normalized = trimToNull(phone); |
| | | if (normalized == null || !PHONE_PATTERN.matcher(normalized).matches()) { |
| | | throw new IllegalArgumentException("请输入正确的手机号"); |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 归一化并校验验证码。 |
| | | */ |
| | | private String normalizeCode(String code) { |
| | | String normalized = trimToNull(code); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("请输入验证码"); |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 优先使用昵称,缺失时回退到手机号作为展示名称。 |
| | | */ |
| | | private String resolveNickname(AppUser user) { |
| | | String nickname = trimToNull(user.getNickname()); |
| | | return nickname == null ? user.getPhone() : nickname; |
| | | } |
| | | |
| | | /** |
| | | * 将空白字符串归一化为 null。 |
| | | */ |
| | | private String trimToNull(String value) { |
| | | if (value == null) { |
| | | return null; |
| | | } |
| | | String trimmed = value.trim(); |
| | | return trimmed.isEmpty() ? null : trimmed; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.service; |
| | | |
| | | import com.tcm.training.admin.app.auth.config.AppSmsAuthProperties; |
| | | import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse; |
| | | import org.springframework.data.redis.core.StringRedisTemplate; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.concurrent.ThreadLocalRandom; |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | /** |
| | | * APP 登录短信验证码服务,负责验证码的生成、缓存和校验。 |
| | | */ |
| | | @Service |
| | | public class AppSmsCodeService { |
| | | |
| | | private static final String LOGIN_CODE_CACHE_PREFIX = "app:sms:login:"; |
| | | |
| | | private final StringRedisTemplate stringRedisTemplate; |
| | | private final AppSmsSender appSmsSender; |
| | | private final AppSmsAuthProperties appSmsAuthProperties; |
| | | |
| | | /** |
| | | * 注入验证码缓存、发送器与调试配置。 |
| | | */ |
| | | public AppSmsCodeService(StringRedisTemplate stringRedisTemplate, |
| | | AppSmsSender appSmsSender, |
| | | AppSmsAuthProperties appSmsAuthProperties) { |
| | | this.stringRedisTemplate = stringRedisTemplate; |
| | | this.appSmsSender = appSmsSender; |
| | | this.appSmsAuthProperties = appSmsAuthProperties; |
| | | } |
| | | |
| | | /** |
| | | * 生成并发送登录验证码,同时写入缓存。 |
| | | */ |
| | | public AppSmsCodeSendResponse createLoginCode(String phone) { |
| | | String code = generateCode(); |
| | | stringRedisTemplate.opsForValue().set( |
| | | buildCacheKey(phone), |
| | | code, |
| | | appSmsAuthProperties.getExpireSeconds(), |
| | | TimeUnit.SECONDS |
| | | ); |
| | | appSmsSender.sendLoginCode(phone, code); |
| | | return new AppSmsCodeSendResponse(appSmsAuthProperties.getExpireSeconds()); |
| | | } |
| | | |
| | | /** |
| | | * 校验登录验证码,成功后销毁缓存中的一次性验证码。 |
| | | */ |
| | | public void validateLoginCode(String phone, String code) { |
| | | if (matchesDebugCode(code)) { |
| | | // 开发环境允许固定验证码直通,便于前后端联调。 |
| | | return; |
| | | } |
| | | String cacheKey = buildCacheKey(phone); |
| | | String cachedCode = stringRedisTemplate.opsForValue().get(cacheKey); |
| | | if (cachedCode == null || cachedCode.trim().isEmpty()) { |
| | | throw new IllegalArgumentException("验证码已失效,请重新获取"); |
| | | } |
| | | if (!cachedCode.equals(code)) { |
| | | throw new IllegalArgumentException("验证码错误"); |
| | | } |
| | | // 验证码登录成功后立即删除,避免重复使用。 |
| | | stringRedisTemplate.delete(cacheKey); |
| | | } |
| | | |
| | | /** |
| | | * 生成 6 位数字验证码。 |
| | | */ |
| | | private String generateCode() { |
| | | int code = ThreadLocalRandom.current().nextInt(100000, 1000000); |
| | | return String.valueOf(code); |
| | | } |
| | | |
| | | /** |
| | | * 判断是否命中本地调试验证码。 |
| | | */ |
| | | private boolean matchesDebugCode(String code) { |
| | | if (!appSmsAuthProperties.isDebugEnabled()) { |
| | | return false; |
| | | } |
| | | String debugCode = appSmsAuthProperties.getDebugCode(); |
| | | return debugCode != null && !debugCode.trim().isEmpty() && debugCode.equals(code); |
| | | } |
| | | |
| | | /** |
| | | * 构造手机号维度的验证码缓存 key。 |
| | | */ |
| | | private String buildCacheKey(String phone) { |
| | | return LOGIN_CODE_CACHE_PREFIX + phone; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.service; |
| | | |
| | | /** |
| | | * 短信发送抽象,后续接入具体供应商时仅替换实现。 |
| | | */ |
| | | public interface AppSmsSender { |
| | | |
| | | /** |
| | | * 发送 APP 登录验证码。 |
| | | */ |
| | | void sendLoginCode(String phone, String code); |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.service; |
| | | |
| | | import com.tcm.training.admin.common.PhoneMasker; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | /** |
| | | * 当前阶段的短信发送占位实现,仅记录日志用于联调调试。 |
| | | */ |
| | | @Service |
| | | public class LoggingAppSmsSender implements AppSmsSender { |
| | | |
| | | private static final Logger log = LoggerFactory.getLogger(LoggingAppSmsSender.class); |
| | | |
| | | /** |
| | | * 记录验证码日志,待供应商确定后替换为真实发送逻辑。 |
| | | */ |
| | | @Override |
| | | public void sendLoginCode(String phone, String code) { |
| | | log.info("APP登录验证码已生成,手机号={}, code={}", PhoneMasker.mask(phone), code); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.support; |
| | | |
| | | import cn.dev33.satoken.stp.SaTokenInfo; |
| | | import cn.dev33.satoken.stp.StpLogic; |
| | | |
| | | /** |
| | | * APP 用户独立登录态工具,避免与后台管理员 token 混用。 |
| | | */ |
| | | public final class StpAppUserUtil { |
| | | |
| | | private static final StpLogic STP_LOGIC = new StpLogic("app-user"); |
| | | |
| | | private StpAppUserUtil() { |
| | | } |
| | | |
| | | /** |
| | | * 为 APP 用户建立登录态。 |
| | | */ |
| | | public static void login(Object loginId) { |
| | | STP_LOGIC.login(loginId); |
| | | } |
| | | |
| | | /** |
| | | * 校验当前请求是否已登录 APP 用户。 |
| | | */ |
| | | public static void checkLogin() { |
| | | STP_LOGIC.checkLogin(); |
| | | } |
| | | |
| | | /** |
| | | * 判断当前请求是否存在 APP 登录态。 |
| | | */ |
| | | public static boolean isLogin() { |
| | | return STP_LOGIC.isLogin(); |
| | | } |
| | | |
| | | /** |
| | | * 获取当前 APP 用户登录 ID。 |
| | | */ |
| | | public static Long getLoginIdAsLong() { |
| | | return STP_LOGIC.getLoginIdAsLong(); |
| | | } |
| | | |
| | | /** |
| | | * 获取当前 APP 登录态的 token 信息。 |
| | | */ |
| | | public static SaTokenInfo getTokenInfo() { |
| | | return STP_LOGIC.getTokenInfo(); |
| | | } |
| | | |
| | | /** |
| | | * 注销当前 APP 登录态。 |
| | | */ |
| | | public static void logout() { |
| | | STP_LOGIC.logout(); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.vo; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.Setter; |
| | | |
| | | /** |
| | | * APP 登录响应。 |
| | | */ |
| | | @Getter |
| | | @Setter |
| | | public class AppLoginResponse { |
| | | |
| | | private String token; |
| | | private String tokenType; |
| | | private long expiresIn; |
| | | private AppLoginUserVO user; |
| | | |
| | | public AppLoginResponse() { |
| | | } |
| | | |
| | | /** |
| | | * 使用完整字段构造 APP 登录响应。 |
| | | */ |
| | | public AppLoginResponse(String token, String tokenType, long expiresIn, AppLoginUserVO user) { |
| | | this.token = token; |
| | | this.tokenType = tokenType; |
| | | this.expiresIn = expiresIn; |
| | | this.user = user; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.vo; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.Setter; |
| | | |
| | | import java.time.LocalDateTime; |
| | | |
| | | /** |
| | | * APP 登录成功后返回的用户摘要。 |
| | | */ |
| | | @Getter |
| | | @Setter |
| | | public class AppLoginUserVO { |
| | | |
| | | private Long id; |
| | | private String nickname; |
| | | private String phone; |
| | | private String avatar; |
| | | private Integer type; |
| | | private Integer status; |
| | | private Long clinicId; |
| | | private LocalDateTime memberExpireAt; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.vo; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.Setter; |
| | | |
| | | /** |
| | | * APP 发送验证码响应。 |
| | | */ |
| | | @Getter |
| | | @Setter |
| | | public class AppSmsCodeSendResponse { |
| | | |
| | | private int expireInSeconds; |
| | | |
| | | public AppSmsCodeSendResponse() { |
| | | } |
| | | |
| | | /** |
| | | * 使用验证码有效期构造响应。 |
| | | */ |
| | | public AppSmsCodeSendResponse(int expireInSeconds) { |
| | | this.expireInSeconds = expireInSeconds; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.statistics.controller; |
| | | |
| | | import com.tcm.training.admin.app.auth.support.StpAppUserUtil; |
| | | import com.tcm.training.admin.common.ApiResponse; |
| | | import com.tcm.training.admin.statistics.service.AppUserEntryLogAppService; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | /** |
| | | * APP 用户进入记录接口。 |
| | | */ |
| | | @Validated |
| | | @RestController |
| | | @RequestMapping({"/api/app", "/api/v1/app"}) |
| | | public class AppUserEntryLogController { |
| | | |
| | | private final AppUserEntryLogAppService appUserEntryLogAppService; |
| | | |
| | | /** |
| | | * 注入 APP 用户进入记录服务。 |
| | | */ |
| | | public AppUserEntryLogController(AppUserEntryLogAppService appUserEntryLogAppService) { |
| | | this.appUserEntryLogAppService = appUserEntryLogAppService; |
| | | } |
| | | |
| | | /** |
| | | * 记录 APP 用户进入行为。 |
| | | */ |
| | | @PostMapping("/user-entry-logs") |
| | | public ApiResponse<Void> record() { |
| | | appUserEntryLogAppService.record(StpAppUserUtil.getLoginIdAsLong()); |
| | | return ApiResponse.ok(null); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.system.controller; |
| | | |
| | | import com.tcm.training.admin.common.ApiResponse; |
| | | import com.tcm.training.admin.system.service.AppVersionSettingsAppService; |
| | | import com.tcm.training.admin.system.vo.AppVersionCheckVO; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.GetMapping; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RequestParam; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | /** |
| | | * APP 版本检查接口。 |
| | | */ |
| | | @Validated |
| | | @RestController |
| | | @RequestMapping({"/api/app/version", "/api/v1/app/version"}) |
| | | public class AppVersionController { |
| | | |
| | | private final AppVersionSettingsAppService appVersionSettingsAppService; |
| | | |
| | | /** |
| | | * 注入 APP 版本检查服务。 |
| | | */ |
| | | public AppVersionController(AppVersionSettingsAppService appVersionSettingsAppService) { |
| | | this.appVersionSettingsAppService = appVersionSettingsAppService; |
| | | } |
| | | |
| | | /** |
| | | * 检查 APP 是否存在可升级版本。 |
| | | */ |
| | | @GetMapping("/check") |
| | | public ApiResponse<AppVersionCheckVO> checkUpdate(@RequestParam String platform, |
| | | @RequestParam Integer currentVersionCode) { |
| | | return ApiResponse.ok(appVersionSettingsAppService.checkUpdate(platform, currentVersionCode)); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.user.controller; |
| | | |
| | | import com.tcm.training.admin.app.auth.support.StpAppUserUtil; |
| | | import com.tcm.training.admin.common.ApiResponse; |
| | | import com.tcm.training.admin.system.dto.UserFeedbackSubmitRequest; |
| | | import com.tcm.training.admin.system.service.UserFeedbackAppService; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.RequestBody; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | import javax.validation.Valid; |
| | | |
| | | /** |
| | | * APP 用户意见反馈接口。 |
| | | */ |
| | | @Validated |
| | | @RestController |
| | | @RequestMapping({"/api/app", "/api/v1/app"}) |
| | | public class AppUserFeedbackController { |
| | | |
| | | private final UserFeedbackAppService userFeedbackAppService; |
| | | |
| | | /** |
| | | * 注入 APP 用户反馈服务。 |
| | | */ |
| | | public AppUserFeedbackController(UserFeedbackAppService userFeedbackAppService) { |
| | | this.userFeedbackAppService = userFeedbackAppService; |
| | | } |
| | | |
| | | /** |
| | | * 提交 APP 用户意见反馈。 |
| | | */ |
| | | @PostMapping("/user-feedbacks") |
| | | public ApiResponse<Void> submit(@Valid @RequestBody UserFeedbackSubmitRequest request) { |
| | | userFeedbackAppService.submit(StpAppUserUtil.getLoginIdAsLong(), request); |
| | | return ApiResponse.ok(null); |
| | | } |
| | | } |
| | |
| | | package com.tcm.training.admin.config; |
| | | |
| | | import com.tcm.training.admin.app.auth.support.StpAppUserUtil; |
| | | import cn.dev33.satoken.interceptor.SaInterceptor; |
| | | import cn.dev33.satoken.context.SaHolder; |
| | | import cn.dev33.satoken.stp.StpUtil; |
| | |
| | | this.xssParamInterceptor = xssParamInterceptor; |
| | | } |
| | | |
| | | /** |
| | | * 注册 Sa-Token 与 XSS 拦截器。 |
| | | */ |
| | | @Override |
| | | public void addInterceptors(InterceptorRegistry registry) { |
| | | registry.addInterceptor(new SaInterceptor(handle -> { |
| | | if ("OPTIONS".equalsIgnoreCase(SaHolder.getRequest().getMethod())) { |
| | | return; |
| | | } |
| | | StpUtil.checkLogin(); |
| | | })) |
| | | registry.addInterceptor(new SaInterceptor(handle -> handleAuth())) |
| | | .addPathPatterns("/api/**", "/api/v1/**") |
| | | .excludePathPatterns( |
| | | "/api/auth/login", |
| | | "/api/auth/captcha", |
| | | "/api/app-version-settings/check", |
| | | "/api/v1/app-version-settings/check" |
| | | pathPrefix + "/**" |
| | | ); |
| | | |
| | | registry.addInterceptor(xssParamInterceptor) |
| | | .addPathPatterns("/api/**", "/api/v1/**"); |
| | | } |
| | | |
| | | /** 将本地上传目录映射为静态资源,可通过 /uploads/** 访问 */ |
| | | /** |
| | | * 按请求路径切换管理员鉴权与 APP 鉴权。 |
| | | */ |
| | | private void handleAuth() { |
| | | if ("OPTIONS".equalsIgnoreCase(SaHolder.getRequest().getMethod())) { |
| | | return; |
| | | } |
| | | String requestPath = SaHolder.getRequest().getRequestPath(); |
| | | if (isAppApi(requestPath)) { |
| | | if (isPublicAppApi(requestPath)) { |
| | | return; |
| | | } |
| | | StpAppUserUtil.checkLogin(); |
| | | return; |
| | | } |
| | | if (isPublicAdminApi(requestPath)) { |
| | | return; |
| | | } |
| | | StpUtil.checkLogin(); |
| | | } |
| | | |
| | | /** |
| | | * 判断是否属于 APP 路由空间。 |
| | | */ |
| | | private boolean isAppApi(String requestPath) { |
| | | return requestPath.startsWith("/api/app/") || requestPath.startsWith("/api/v1/app/"); |
| | | } |
| | | |
| | | /** |
| | | * 判断当前 APP 路由是否允许匿名访问。 |
| | | */ |
| | | private boolean isPublicAppApi(String requestPath) { |
| | | return requestPath.startsWith("/api/app/auth/") |
| | | || requestPath.startsWith("/api/v1/app/auth/") |
| | | || "/api/app/version/check".equals(requestPath) |
| | | || "/api/v1/app/version/check".equals(requestPath); |
| | | } |
| | | |
| | | /** |
| | | * 判断当前后台路由是否允许匿名访问。 |
| | | */ |
| | | private boolean isPublicAdminApi(String requestPath) { |
| | | return "/api/auth/login".equals(requestPath) |
| | | || "/api/auth/captcha".equals(requestPath); |
| | | } |
| | | |
| | | /** |
| | | * 将本地上传目录映射为静态资源,可通过 /uploads/** 访问。 |
| | | */ |
| | | @Override |
| | | public void addResourceHandlers(ResourceHandlerRegistry registry) { |
| | | // 转为绝对路径,确保 file: 协议正确 |
| | | // 转为绝对路径,确保 file: 协议正确。 |
| | | String absPath = new File(uploadDir).getAbsolutePath(); |
| | | registry.addResourceHandler(pathPrefix + "/**") |
| | | .addResourceLocations("file:" + absPath + "/"); |
| | |
| | | import com.tcm.training.admin.common.PageResponse; |
| | | import com.tcm.training.admin.system.dto.AppVersionCreateRequest; |
| | | import com.tcm.training.admin.system.service.AppVersionSettingsAppService; |
| | | import com.tcm.training.admin.system.vo.AppVersionCheckVO; |
| | | import com.tcm.training.admin.system.vo.AppVersionDetailVO; |
| | | import com.tcm.training.admin.system.vo.AppVersionListItemVO; |
| | | import org.springframework.validation.annotation.Validated; |
| | |
| | | |
| | | private final AppVersionSettingsAppService appVersionSettingsAppService; |
| | | |
| | | /** |
| | | * 注入 APP 版本后台管理服务。 |
| | | */ |
| | | public AppVersionSettingsController(AppVersionSettingsAppService appVersionSettingsAppService) { |
| | | this.appVersionSettingsAppService = appVersionSettingsAppService; |
| | | } |
| | | |
| | | /** |
| | | * 分页查询 APP 版本配置。 |
| | | */ |
| | | @GetMapping("/app-version-settings") |
| | | public ApiResponse<PageResponse<AppVersionListItemVO>> page( |
| | | @RequestParam(required = false, defaultValue = "1") int page, |
| | |
| | | return ApiResponse.ok(appVersionSettingsAppService.page(page, pageSize)); |
| | | } |
| | | |
| | | @GetMapping("/app-version-settings/check") |
| | | public ApiResponse<AppVersionCheckVO> checkUpdate( |
| | | @RequestParam String platform, |
| | | @RequestParam Integer currentVersionCode) { |
| | | return ApiResponse.ok(appVersionSettingsAppService.checkUpdate(platform, currentVersionCode)); |
| | | } |
| | | |
| | | /** |
| | | * 查询指定版本配置详情。 |
| | | */ |
| | | @GetMapping("/app-version-settings/{id}") |
| | | public ApiResponse<AppVersionDetailVO> detail(@PathVariable("id") Long id) { |
| | | return ApiResponse.ok(appVersionSettingsAppService.detail(id)); |
| | | } |
| | | |
| | | /** |
| | | * 新增 APP 版本配置。 |
| | | */ |
| | | @PostMapping("/app-version-settings") |
| | | public ApiResponse<AppVersionDetailVO> create(@Valid @RequestBody AppVersionCreateRequest request) { |
| | | return ApiResponse.ok(appVersionSettingsAppService.create(request)); |
| | |
| | | package com.tcm.training.admin.system.controller; |
| | | |
| | | import cn.dev33.satoken.stp.StpUtil; |
| | | import com.tcm.training.admin.common.ApiResponse; |
| | | import com.tcm.training.admin.common.PageResponse; |
| | | import com.tcm.training.admin.system.dto.UserFeedbackSubmitRequest; |
| | | import com.tcm.training.admin.system.service.UserFeedbackAppService; |
| | | import com.tcm.training.admin.system.vo.UserFeedbackListItemVO; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.GetMapping; |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.RequestBody; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RequestParam; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | import javax.validation.Valid; |
| | | |
| | | @Validated |
| | | @RestController |
| | |
| | | |
| | | private final UserFeedbackAppService userFeedbackAppService; |
| | | |
| | | /** |
| | | * 注入用户反馈后台查询服务。 |
| | | */ |
| | | public UserFeedbackController(UserFeedbackAppService userFeedbackAppService) { |
| | | this.userFeedbackAppService = userFeedbackAppService; |
| | | } |
| | | |
| | | /** |
| | | * 分页查询用户反馈列表,供后台管理端使用。 |
| | | */ |
| | | @GetMapping("/user-feedbacks") |
| | | public ApiResponse<PageResponse<UserFeedbackListItemVO>> page( |
| | | @RequestParam(required = false, defaultValue = "1") int page, |
| | | @RequestParam(required = false, defaultValue = "10") int pageSize, |
| | | @RequestParam(required = false) String content) { |
| | | return ApiResponse.ok(userFeedbackAppService.page(page, pageSize, content)); |
| | | } |
| | | |
| | | @PostMapping("/user-feedbacks") |
| | | public ApiResponse<Void> submit(@Valid @RequestBody UserFeedbackSubmitRequest request) { |
| | | userFeedbackAppService.submit(StpUtil.getLoginIdAsLong(), request); |
| | | return ApiResponse.ok(null); |
| | | } |
| | | } |
| | |
| | | export default { |
| | | BASE_URL: 'http://192.168.110.111:9100', |
| | | BASE_URL: 'http://192.168.110.188:48080', |
| | | // BASE_URL: 'https://jiashikang.cn/applet', |
| | | uploadUrl: "https://jiashikang.cn/applet/open/file/upload", |
| | | imageUrl: 'http://221.182.45.100:11224/images/' |
| | |
| | | <template> |
| | | <view class="container"> |
| | | <!-- Close Icon --> |
| | | <image class="close-icon" src="@/static/image/login/close@2x.png" mode="aspectFit"></image> |
| | | <!-- Title --> |
| | | <image class="close-icon" src="@/static/image/login/close@2x.png" mode="aspectFit" @tap="handleClose"></image> |
| | | <view class="title">欢迎登录</view> |
| | | |
| | | <!-- Tabs --> |
| | | <view class="tabs"> |
| | | <view class="tab" :class="loginType === 'code' ? 'active' : 'normal'" @tap="loginType = 'code'">验证码登录</view> |
| | | <view class="tab" :class="loginType === 'password' ? 'active' : 'normal'" @tap="loginType = 'password'">密码登录</view> |
| | | <view class="tab" :class="loginType === 'code' ? 'active' : 'normal'" @tap="switchLoginType('code')">验证码登录</view> |
| | | <view class="tab" :class="loginType === 'password' ? 'active' : 'normal'" @tap="switchLoginType('password')">密码登录</view> |
| | | </view> |
| | | |
| | | <!-- Inputs --> |
| | | <view class="form"> |
| | | <view class="input-group"> |
| | | <input class="input" type="number" placeholder="输入手机号" placeholder-class="placeholder-style" /> |
| | | <view class="get-code-btn" v-if="loginType === 'code'">获取验证码</view> |
| | | <input |
| | | class="input" |
| | | type="number" |
| | | maxlength="11" |
| | | v-model="phone" |
| | | placeholder="输入手机号" |
| | | placeholder-class="placeholder-style" |
| | | /> |
| | | <view |
| | | class="get-code-btn" |
| | | :class="{ disabled: countdown > 0 }" |
| | | v-if="loginType === 'code'" |
| | | @tap="handleSendCode" |
| | | > |
| | | {{ countdown > 0 ? `${countdown}s` : '获取验证码' }} |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="input-group"> |
| | | <input class="input" :type="loginType === 'code' ? 'number' : 'text'" :password="loginType === 'password'" :placeholder="loginType === 'code' ? '输入验证码' : '输入密码'" placeholder-class="placeholder-style" /> |
| | | <input |
| | | class="input" |
| | | :type="loginType === 'code' ? 'number' : 'text'" |
| | | :password="loginType === 'password'" |
| | | :maxlength="loginType === 'code' ? 6 : 32" |
| | | v-model="credential" |
| | | :placeholder="loginType === 'code' ? '输入验证码' : '输入密码'" |
| | | placeholder-class="placeholder-style" |
| | | /> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- Login Button --> |
| | | <view class="login-btn">登录</view> |
| | | <view class="login-btn" @tap="handleLogin">{{ loading ? '登录中...' : '登录' }}</view> |
| | | |
| | | <!-- Action Links --> |
| | | <view class="action-links" :class="{ 'center': loginType === 'code', 'space-between': loginType === 'password' }"> |
| | | <text class="register-link" @tap="goRegister">没有账号?点击注册</text> |
| | | <text class="forgot-password" v-if="loginType === 'password'" @tap="goForgotPassword">忘记密码?</text> |
| | | </view> |
| | | |
| | | <!-- WeChat Login --> |
| | | <view class="wechat-btn" @tap="handleWechatLogin"> |
| | | <image class="wechat-icon" src="/static/image/login/login@2x.png" mode="aspectFit"></image> |
| | | <text class="wechat-text">微信登录</text> |
| | |
| | | </template> |
| | | |
| | | <script setup lang="ts"> |
| | | import { ref, reactive, onUnmounted } from "vue"; |
| | | import { onShow, onLoad } from "@dcloudio/uni-app"; |
| | | import { login, getCode, loginCode, confirmSuperior } from "./service.js"; |
| | | import { onLoad } from '@dcloudio/uni-app' |
| | | import { onUnmounted, ref } from 'vue' |
| | | import { sendSmsCode, smsLogin } from './service.js' |
| | | |
| | | // 登录类型:'code' 验证码登录,'password' 密码登录 |
| | | const loginType = ref<'code' | 'password'>('code'); |
| | | const loginType = ref<'code' | 'password'>('code') |
| | | const phone = ref('') |
| | | const credential = ref('') |
| | | const countdown = ref(0) |
| | | const loading = ref(false) |
| | | const inviteUserId = ref('') |
| | | const inviteType = ref('') |
| | | |
| | | let countdownTimer: ReturnType<typeof setInterval> | null = null |
| | | |
| | | interface AppLoginUser { |
| | | id: number |
| | | nickname: string |
| | | phone: string |
| | | avatar?: string |
| | | type?: number |
| | | status?: number |
| | | clinicId?: number |
| | | memberExpireAt?: string |
| | | } |
| | | |
| | | interface AppLoginPayload { |
| | | token: string |
| | | tokenType: string |
| | | expiresIn: number |
| | | user: AppLoginUser |
| | | } |
| | | |
| | | interface ApiResponse<T> { |
| | | code: number |
| | | msg?: string |
| | | message?: string |
| | | data: T |
| | | } |
| | | |
| | | /** |
| | | * 页面加载时记录来源参数,便于后续扩展回跳逻辑。 |
| | | */ |
| | | onLoad((options) => { |
| | | inviteUserId.value = options?.inviteUserId || '' |
| | | inviteType.value = options?.inviteType || '' |
| | | }) |
| | | |
| | | /** |
| | | * 页面销毁时清理倒计时,避免重复定时器泄漏。 |
| | | */ |
| | | onUnmounted(() => { |
| | | clearCountdown() |
| | | }) |
| | | |
| | | /** |
| | | * 切换登录方式时重置当前输入态。 |
| | | */ |
| | | const switchLoginType = (type: 'code' | 'password') => { |
| | | loginType.value = type |
| | | credential.value = '' |
| | | } |
| | | |
| | | /** |
| | | * 关闭登录页,优先返回上一页,没有页面栈时回首页。 |
| | | */ |
| | | const handleClose = () => { |
| | | if (getCurrentPages().length > 1) { |
| | | uni.navigateBack() |
| | | return |
| | | } |
| | | uni.switchTab({ |
| | | url: '/pages/home/home' |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 跳转到注册页。 |
| | | */ |
| | | const goRegister = () => { |
| | | uni.navigateTo({ |
| | | url: '/pages/register/register' |
| | | }); |
| | | }; |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 跳转到忘记密码页。 |
| | | */ |
| | | const goForgotPassword = () => { |
| | | uni.navigateTo({ url: '/pages/forgotPassword/forgotPassword' }); |
| | | }; |
| | | uni.navigateTo({ |
| | | url: '/pages/forgotPassword/forgotPassword' |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 执行微信登录占位逻辑。 |
| | | */ |
| | | const handleWechatLogin = () => { |
| | | // #ifdef APP-PLUS |
| | | uni.login({ |
| | | provider: 'weixin', |
| | | success: (res) => { |
| | | console.log('Wechat login success:', res); |
| | | // 获取用户信息 |
| | | uni.getUserInfo({ |
| | | provider: 'weixin', |
| | | success: (infoRes) => { |
| | | console.log('Wechat user info:', infoRes); |
| | | uni.showToast({ |
| | | title: '微信授权成功', |
| | | icon: 'success' |
| | | }); |
| | | |
| | | // 假设这里后端返回该微信未绑定手机号,跳转到绑定手机号页面 |
| | | setTimeout(() => { |
| | | uni.navigateTo({ |
| | | url: '/pages/bindPhone/bindPhone' |
| | | }); |
| | | }, 1000); |
| | | |
| | | // 在这里将 res.authResult 或者 infoRes.userInfo 发送给后端接口进行登录 |
| | | // 例如:api.wechatLogin({ openid: infoRes.userInfo.openId, ... }) |
| | | }, |
| | | fail: (err) => { |
| | | console.error('Get user info failed:', err); |
| | | uni.showToast({ |
| | | title: '获取用户信息失败', |
| | | icon: 'none' |
| | | }); |
| | | } |
| | | }); |
| | | success: () => { |
| | | uni.showToast({ |
| | | title: '微信登录暂未开放', |
| | | icon: 'none' |
| | | }) |
| | | }, |
| | | fail: (err) => { |
| | | console.error('Wechat login failed:', err); |
| | | fail: () => { |
| | | uni.showToast({ |
| | | title: '微信登录失败', |
| | | icon: 'none' |
| | | }); |
| | | }) |
| | | } |
| | | }); |
| | | }) |
| | | // #endif |
| | | |
| | | |
| | | // #ifndef APP-PLUS |
| | | uni.showToast({ |
| | | title: '请在App中使用微信登录', |
| | | icon: 'none' |
| | | }); |
| | | }) |
| | | // #endif |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * 发送短信验证码并启动倒计时。 |
| | | */ |
| | | const handleSendCode = async () => { |
| | | if (countdown.value > 0 || loading.value) { |
| | | return |
| | | } |
| | | const normalizedPhone = normalizePhone() |
| | | if (!normalizedPhone) { |
| | | return |
| | | } |
| | | const res = await sendSmsCode({ phone: normalizedPhone }) |
| | | if (!isSuccessResponse(res)) { |
| | | return |
| | | } |
| | | startCountdown() |
| | | uni.showToast({ |
| | | title: '验证码已发送', |
| | | icon: 'success' |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 执行登录动作,当前仅开放验证码登录。 |
| | | */ |
| | | const handleLogin = async () => { |
| | | if (loading.value) { |
| | | return |
| | | } |
| | | if (loginType.value === 'password') { |
| | | uni.showToast({ |
| | | title: '密码登录暂未开放', |
| | | icon: 'none' |
| | | }) |
| | | return |
| | | } |
| | | const normalizedPhone = normalizePhone() |
| | | if (!normalizedPhone) { |
| | | return |
| | | } |
| | | const normalizedCode = normalizeCode() |
| | | if (!normalizedCode) { |
| | | return |
| | | } |
| | | loading.value = true |
| | | try { |
| | | const res = await smsLogin({ |
| | | phone: normalizedPhone, |
| | | code: normalizedCode |
| | | }) |
| | | if (!isSuccessResponse(res)) { |
| | | return |
| | | } |
| | | saveLoginSession(res.data) |
| | | uni.showToast({ |
| | | title: '登录成功', |
| | | icon: 'success' |
| | | }) |
| | | setTimeout(() => { |
| | | uni.switchTab({ |
| | | url: '/pages/home/home' |
| | | }) |
| | | }, 300) |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 将登录响应写入本地缓存,供后续请求与页面读取。 |
| | | */ |
| | | const saveLoginSession = (loginData: AppLoginPayload) => { |
| | | if (!loginData) { |
| | | return |
| | | } |
| | | const authToken = `${loginData.tokenType || 'Bearer'} ${loginData.token || ''}`.trim() |
| | | uni.setStorageSync('token', authToken) |
| | | uni.setStorageSync('appUserInfo', loginData.user || {}) |
| | | uni.setStorageSync('showLogoutModal', 1) |
| | | // 先保留来源参数,后续如需登录后回跳可直接复用。 |
| | | uni.setStorageSync('loginRedirectMeta', { |
| | | inviteUserId: inviteUserId.value, |
| | | inviteType: inviteType.value |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 校验手机号输入是否合法。 |
| | | */ |
| | | const normalizePhone = () => { |
| | | const normalizedPhone = (phone.value || '').trim() |
| | | if (!/^1\d{10}$/.test(normalizedPhone)) { |
| | | uni.showToast({ |
| | | title: '请输入正确的手机号', |
| | | icon: 'none' |
| | | }) |
| | | return '' |
| | | } |
| | | return normalizedPhone |
| | | } |
| | | |
| | | /** |
| | | * 校验验证码输入是否合法。 |
| | | */ |
| | | const normalizeCode = () => { |
| | | const normalizedCode = (credential.value || '').trim() |
| | | if (!/^\d{6}$/.test(normalizedCode)) { |
| | | uni.showToast({ |
| | | title: '请输入6位验证码', |
| | | icon: 'none' |
| | | }) |
| | | return '' |
| | | } |
| | | return normalizedCode |
| | | } |
| | | |
| | | /** |
| | | * 判断接口响应是否为成功态。 |
| | | */ |
| | | const isSuccessResponse = (response: ApiResponse<unknown> | undefined) => { |
| | | return !!response && (response.code === 0 || response.code === 200) |
| | | } |
| | | |
| | | /** |
| | | * 启动 60 秒验证码发送倒计时。 |
| | | */ |
| | | const startCountdown = () => { |
| | | clearCountdown() |
| | | countdown.value = 60 |
| | | countdownTimer = setInterval(() => { |
| | | if (countdown.value <= 1) { |
| | | clearCountdown() |
| | | return |
| | | } |
| | | countdown.value -= 1 |
| | | }, 1000) |
| | | } |
| | | |
| | | /** |
| | | * 清理验证码倒计时。 |
| | | */ |
| | | const clearCountdown = () => { |
| | | if (countdownTimer) { |
| | | clearInterval(countdownTimer) |
| | | countdownTimer = null |
| | | } |
| | | countdown.value = 0 |
| | | } |
| | | </script> |
| | | <style></style> |
| | | <style lang="scss" scoped> |
| | | .container { |
| | | padding: 0 40rpx; |
| | | /* 距离状态栏151rpx, assuming var(--status-bar-height) is available */ |
| | | padding-top: calc(var(--status-bar-height, 0px) + 151rpx); |
| | | box-sizing: border-box; |
| | | min-height: 100vh; |
| | |
| | | font-size: 26rpx; |
| | | border-radius: 8rpx; |
| | | margin-right: 24rpx; |
| | | |
| | | &.disabled { |
| | | background-color: #D9D9D9; |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | .wechat-icon { |
| | | width: 56rpx; |
| | | height: 56rpx; |
| | | margin-right: 48rpx; |
| | | margin-right: 16rpx; |
| | | } |
| | | |
| | | .wechat-text { |
| | | font-size: 24rpx; |
| | | color: #010F07; |
| | | font-size: 30rpx; |
| | | color: #333333; |
| | | } |
| | | } |
| | | </style> |
| | | </style> |
| | |
| | | import request from '@/utils/request.js' |
| | | |
| | | //一键登录 |
| | | /** |
| | | * 微信一键登录接口占位。 |
| | | */ |
| | | export const login = (data) => { |
| | | return request.post(`/openIdByJsCode`, data) |
| | | return request.post('/openIdByJsCode', data) |
| | | } |
| | | |
| | | //短信验证码登录 |
| | | export const loginCode = (data) => { |
| | | return request.post(`/loginCode`, data) |
| | | /** |
| | | * 发送 APP 短信登录验证码。 |
| | | */ |
| | | export const sendSmsCode = (data) => { |
| | | return request.post('/api/app/auth/sms-code', data) |
| | | } |
| | | |
| | | //获取验证码 |
| | | export const getCode = (params) => { |
| | | return request.get(`/getCode?phone=${params}`) |
| | | /** |
| | | * 使用短信验证码完成 APP 登录。 |
| | | */ |
| | | export const smsLogin = (data) => { |
| | | return request.post('/api/app/auth/sms-login', data) |
| | | } |
| | | //确认上级用户-接受邀请 |
| | | |
| | | /** |
| | | * 确认上级用户邀请。 |
| | | */ |
| | | export const confirmSuperior = (params) => { |
| | | return request.get(`/index/confirmSuperior`,params, 1) |
| | | } |
| | | return request.get('/index/confirmSuperior', params, 1) |
| | | } |
| | |
| | | const waitingList = [] //等待队列 |
| | | const excutingList = [] //执行队列 |
| | | |
| | | /** |
| | | * 判断当前响应码是否属于成功态。 |
| | | */ |
| | | function isSuccessCode(code) { |
| | | return code === 0 || code === 200 |
| | | } |
| | | |
| | | /** |
| | | * 从响应体中提取可展示的错误文案。 |
| | | */ |
| | | function resolveMessage(payload) { |
| | | if (!payload) { |
| | | return '服务器错误' |
| | | } |
| | | if (typeof payload.data === 'string' && payload.data) { |
| | | return payload.data |
| | | } |
| | | return payload.msg || payload.message || '服务器错误' |
| | | } |
| | | |
| | | /** |
| | | * 统一发起请求,并兼容当前仓库 `code=0` 的后端成功协议。 |
| | | */ |
| | | function request(method, url, data, type, lodingFlag = true) { |
| | | if (lodingFlag) { |
| | | uni.showLoading({ |
| | |
| | | data: data, |
| | | header: header, |
| | | success: (res) => { |
| | | //判断状态码 |
| | | if (lodingFlag) { |
| | | uni.hideLoading() |
| | | } |
| | | if (res.data.code == 200) { |
| | | resolve(res.data); |
| | | uni.hideLoading() |
| | | const payload = res.data || {} |
| | | if (isSuccessCode(payload.code)) { |
| | | resolve(payload); |
| | | return |
| | | } |
| | | if (res.data.code == 502) { |
| | | resolve(res.data); |
| | | if (payload.code == 502) { |
| | | resolve(payload); |
| | | uni.showToast({ |
| | | title: res.data.msg, |
| | | title: resolveMessage(payload), |
| | | duration: 2000, |
| | | icon: 'none', |
| | | mask: true, |
| | | }) |
| | | uni.hideLoading() |
| | | return |
| | | } |
| | | if (res.data.code == 1 || res.data.code == 500) { |
| | | if (payload.code == 1 || payload.code == 500) { |
| | | uni.showToast({ |
| | | title: res.data.data || res.data.msg, |
| | | title: resolveMessage(payload), |
| | | duration: 2000, |
| | | icon: 'none', |
| | | mask: true, |
| | | }) |
| | | uni.hideLoading() |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (res.data.code == 401 || res.data.code == 510 || res.data.code == 504 || res |
| | | .data |
| | | .code == 505) { |
| | | if (payload.code == 401 || payload.code == 510 || payload.code == 504 || payload.code == 505) { |
| | | // uni.clearStorageSync() |
| | | // handleLogout('登录过期,请重新登录') |
| | | // uni.navigateTo({ |
| | | // url: '/pages/login/login' |
| | | // }) |
| | | // return |
| | | resolve(payload) |
| | | return |
| | | } |
| | | |
| | | if (res.data.code == 501) { |
| | | if (payload.code == 501) { |
| | | uni.showToast({ |
| | | title: res.data.msg, |
| | | title: resolveMessage(payload), |
| | | duration: 2000, |
| | | icon: 'none', |
| | | mask: true, |
| | | }) |
| | | uni.hideLoading() |
| | | resolve(payload) |
| | | return |
| | | } |
| | | if (res.data.code == 506) { |
| | | resolve(res.data); |
| | | uni.hideLoading() |
| | | if (payload.code == 506) { |
| | | resolve(payload); |
| | | return |
| | | } |
| | | |
| | | uni.showToast({ |
| | | title: res.data.msg || '服务器错误', |
| | | title: resolveMessage(payload), |
| | | duration: 2000, |
| | | icon: 'none', |
| | | mask: true, |
| | | }) |
| | | uni.hideLoading() |
| | | |
| | | resolve(payload) |
| | | }, |
| | | fail(err) { |
| | | if (lodingFlag) { |
| | |
| | | |
| | | } |
| | | |
| | | // 被冻结跳转到登录页并取消后续请求 |
| | | /** |
| | | * 被冻结时跳转到登录页并取消后续请求。 |
| | | */ |
| | | function handleLogout(str) { |
| | | let routeStr = getCurrentPages()[getCurrentPages().length - 1].route |
| | | // 登录成功后需要设置 showLogoutModal 为 1 |
| | | if (uni.getStorageSync('showLogoutModal') == 1) { |
| | | uni.setStorageSync('showLogoutModal', 0) |
| | | uni.showModal({ |
| | |
| | | } |
| | | } |
| | | |
| | | // 清除请求队列 |
| | | /** |
| | | * 清除请求队列。 |
| | | */ |
| | | function clearAllRequest() { |
| | | if (excutingList.length > 0) { |
| | | for (var i = 0; i < excutingList.length; i++) { |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 发起 GET 请求。 |
| | | */ |
| | | function get(url, data, type = false, lodingFlag = true) { |
| | | return request(GET, url, data, type, lodingFlag) |
| | | } |
| | | |
| | | /** |
| | | * 发起 POST 请求。 |
| | | */ |
| | | function post(url, data, type = false, lodingFlag = true) { |
| | | return request(POST, url, data, type, lodingFlag) |
| | | } |
| | | |
| | | /** |
| | | * 发起 PUT 请求。 |
| | | */ |
| | | function put(url, data, type = false, lodingFlag = true) { |
| | | return request(PUT, url, data, type, lodingFlag) |
| | | } |
| | | |
| | | /** |
| | | * 发起表单编码请求。 |
| | | */ |
| | | function form(url, data, type = false, lodingFlag = true) { |
| | | return request(FORM, url, data, type, lodingFlag) |
| | | } |
| | | |
| | | /** |
| | | * 发起 DELETE 请求。 |
| | | */ |
| | | function remove(url, data, type = false, lodingFlag = true) { |
| | | return request(DELETE, url, data, type, lodingFlag) |
| | | } |
| | | |
| | | export default { |
| | | get: (url, data, type = false, lodingFlag = true) => request(GET, url, data, type, lodingFlag), |
| | | post: (url, data, type = false, lodingFlag = true) => request(POST, url, data, type, lodingFlag), |
| | | put: (url, data, type = false, lodingFlag = true) => request(PUT, url, data, type, lodingFlag), |
| | | FORM: (url, data, type = false, lodingFlag = true) => request(FORM, url, data, type, lodingFlag), |
| | | delete: (url, data, type = false, lodingFlag = true) => request(DELETE, url, data, type, lodingFlag), |
| | | } |
| | | get, |
| | | post, |
| | | put, |
| | | FORM: form, |
| | | delete: remove, |
| | | } |