feat: 登录添加验证码

This commit is contained in:
Ray.Hao
2026-03-07 10:09:44 +08:00
parent 8628415351
commit c210ebb44d
4 changed files with 157 additions and 45 deletions

View File

@@ -6,6 +6,8 @@ const WECHAT_MINIAPP_AUTH_BASE_URL = "/api/v1/wechat/miniapp/auth";
export interface LoginData { export interface LoginData {
username: string; username: string;
password: string; password: string;
captchaId?: string;
captchaCode?: string;
} }
export interface LoginResult { export interface LoginResult {
@@ -18,6 +20,11 @@ export interface LoginResult {
openid?: string; openid?: string;
} }
export interface CaptchaVO {
captchaId: string;
captchaBase64: string;
}
export interface SmsLoginData { export interface SmsLoginData {
mobile: string; mobile: string;
code: string; code: string;
@@ -45,6 +52,16 @@ export interface WechatMiniappBindMobileData {
} }
const AuthAPI = { const AuthAPI = {
/**
* 获取图形验证码
*/
getCaptcha(): Promise<CaptchaVO> {
return publicRequest<CaptchaVO>({
url: `${AUTH_BASE_URL}/captcha`,
method: "GET",
});
},
/** /**
* 账号密码登录 * 账号密码登录
*/ */
@@ -58,7 +75,7 @@ const AuthAPI = {
/** /**
* 发送短信验证码 * 发送短信验证码
* *
* 演示环境说明:短信服务未配置,验证码固定为 123456 * 演示环境说明:短信服务未配置,验证码固定为 123456
*/ */
sendSmsLoginCode(mobile: string): Promise<void> { sendSmsLoginCode(mobile: string): Promise<void> {
@@ -83,7 +100,7 @@ const AuthAPI = {
/** /**
* 微信小程序静默登录 * 微信小程序静默登录
* *
* 适用场景:个人小程序 * 适用场景:个人小程序
* - 已绑定手机号的用户:直接返回 token,登录成功 * - 已绑定手机号的用户:直接返回 token,登录成功
* - 未绑定手机号的用户:返回 openid,需调用绑定手机号接口 * - 未绑定手机号的用户:返回 openid,需调用绑定手机号接口
@@ -97,7 +114,7 @@ const AuthAPI = {
/** /**
* 微信小程序手机号快捷登录 * 微信小程序手机号快捷登录
* *
* 适用场景:企业认证小程序(已开通手机号快捷登录权限) * 适用场景:企业认证小程序(已开通手机号快捷登录权限)
* 一步完成登录,无需绑定流程,自动创建新用户 * 一步完成登录,无需绑定流程,自动创建新用户
*/ */
@@ -112,7 +129,7 @@ const AuthAPI = {
/** /**
* 微信小程序绑定手机号 * 微信小程序绑定手机号
* *
* 演示环境说明:短信服务未配置,验证码固定为 123456 * 演示环境说明:短信服务未配置,验证码固定为 123456
*/ */
wechatMiniappBindMobile(data: WechatMiniappBindMobileData): Promise<LoginResult> { wechatMiniappBindMobile(data: WechatMiniappBindMobileData): Promise<LoginResult> {

View File

@@ -14,7 +14,7 @@
"type": "page", "type": "page",
"name": "login", "name": "login",
"style": { "style": {
"navigationStyle": "custom" "navigationBarTitleText": "登录"
} }
}, },
{ {

View File

@@ -11,7 +11,7 @@
<!-- Logo --> <!-- Logo -->
<view class="login-logo"> <view class="login-logo">
<image class="logo-image" src="/static/logo.png" mode="aspectFit" /> <image class="logo-image" src="/static/logo.png" mode="aspectFit" />
<text class="logo-text">YouLai Admin</text> <text class="logo-text">youlai-app</text>
</view> </view>
<!-- 登录卡片 --> <!-- 登录卡片 -->
@@ -43,7 +43,7 @@
class="input-field" class="input-field"
:password="!showPassword" :password="!showPassword"
placeholder="请输入密码" placeholder="请输入密码"
maxlength="50" :maxlength="50"
@confirm="handleLogin" @confirm="handleLogin"
/> />
<view class="input-suffix" @click="showPassword = !showPassword"> <view class="input-suffix" @click="showPassword = !showPassword">
@@ -51,6 +51,25 @@
</view> </view>
</view> </view>
<!-- 图形验证码(密码登录时显示) -->
<view v-if="loginMode === 'PASSWORD'" class="input-box">
<wd-icon name="shield" size="20" color="#9ca3af" />
<input
v-model="formData.captchaCode"
class="input-field"
placeholder="请输入验证码"
:maxlength="6"
@confirm="handleLogin"
/>
<image
v-if="captchaBase64"
class="captcha-img"
:src="captchaBase64"
mode="aspectFit"
@click="fetchCaptcha"
/>
</view>
<!-- 验证码 --> <!-- 验证码 -->
<view v-else class="input-box"> <view v-else class="input-box">
<wd-icon name="shield" size="20" color="#9ca3af" /> <wd-icon name="shield" size="20" color="#9ca3af" />
@@ -59,7 +78,7 @@
class="input-field" class="input-field"
placeholder="请输入验证码" placeholder="请输入验证码"
type="number" type="number"
maxlength="6" :maxlength="6"
@confirm="handleLogin" @confirm="handleLogin"
/> />
<view <view
@@ -77,7 +96,7 @@
</view> </view>
<!-- 登录按钮 --> <!-- 登录按钮 -->
<button class="btn-primary" :disabled="loading || !canSubmit" @click="handleLogin"> <button class="btn-primary" :disabled="loading" @click="handleLogin">
{{ loading ? "登录中..." : "登 录" }} {{ loading ? "登录中..." : "登 录" }}
</button> </button>
@@ -130,13 +149,11 @@
@click="loginMode = 'WECHAT'" @click="loginMode = 'WECHAT'"
/> />
</view> </view>
<!-- #endif -->
<!-- 协议 -->
<view class="agreement"> <view class="agreement">
<wd-checkbox v-model="isAgreePolicy" shape="square" size="16px"> <wd-checkbox v-model="isAgreePolicy" shape="square" size="16px">
<text class="agreement-text"> <text class="agreement-text">
登录即代表同意 我已阅读并同意
<text class="agreement-link" @click.stop="navigateToAgreement('user')"> <text class="agreement-link" @click.stop="navigateToAgreement('user')">
《用户协议》 《用户协议》
</text> </text>
@@ -147,6 +164,7 @@
</text> </text>
</wd-checkbox> </wd-checkbox>
</view> </view>
<!-- #endif -->
</view> </view>
</view> </view>
@@ -169,7 +187,7 @@
class="input-field" class="input-field"
placeholder="请输入手机号" placeholder="请输入手机号"
type="number" type="number"
maxlength="11" :maxlength="11"
/> />
</view> </view>
@@ -180,7 +198,7 @@
class="input-field" class="input-field"
placeholder="请输入验证码" placeholder="请输入验证码"
type="number" type="number"
maxlength="6" :maxlength="6"
/> />
<view <view
class="sms-btn" class="sms-btn"
@@ -203,6 +221,15 @@
</view> </view>
</wd-popup> </wd-popup>
<!-- 协议确认弹窗 -->
<wd-message-box selector="policy-box">
<view class="text-center text-sm text-gray-500 leading-relaxed">
请阅读并同意有来技术
<text class="text-blue-500" @click.stop="navigateToAgreement('user')">《用户协议》</text>
<text class="text-blue-500" @click.stop="navigateToAgreement('privacy')">《隐私政策》</text>
</view>
</wd-message-box>
<wd-toast /> <wd-toast />
</view> </view>
</template> </template>
@@ -210,17 +237,18 @@
<route lang="json"> <route lang="json">
{ {
"name": "login", "name": "login",
"style": { "navigationStyle": "custom" } "style": { "navigationBarTitleText": "登录" }
} }
</route> </route>
<script lang="ts" setup> <script lang="ts" setup>
import { onLoad, onUnload } from "@dcloudio/uni-app"; import { onLoad, onUnload } from "@dcloudio/uni-app";
import { useToast } from "wot-design-uni"; import { useToast, useMessage } from "wot-design-uni";
import { useUserStore } from "@/store/modules/user"; import { useUserStore } from "@/store/modules/user";
import AuthAPI from "@/api/auth"; import AuthAPI from "@/api/auth";
const toast = useToast(); const toast = useToast();
const message = useMessage("policy-box");
const userStore = useUserStore(); const userStore = useUserStore();
// 状态 // 状态
@@ -235,8 +263,13 @@ const formData = ref({
username: "admin", username: "admin",
password: "123456", password: "123456",
code: "123456", code: "123456",
captchaCode: "",
}); });
// 图形验证码
const captchaId = ref("");
const captchaBase64 = ref("");
const redirect = ref("/pages/index/index"); const redirect = ref("/pages/index/index");
// 绑定手机号相关 // 绑定手机号相关
@@ -251,6 +284,20 @@ const bindMobileForm = ref({
code: "", code: "",
}); });
const pendingLoginAction = ref<"FORM" | "WECHAT_PHONE" | null>(null);
const pendingWechatPhoneCode = ref<string>("");
// 获取图形验证码
const fetchCaptcha = async () => {
try {
const res = await AuthAPI.getCaptcha();
captchaId.value = res.captchaId;
captchaBase64.value = res.captchaBase64;
} catch (e) {
console.error("获取验证码失败", e);
}
};
// 计算属性 // 计算属性
const loginModeDesc = computed(() => { const loginModeDesc = computed(() => {
const modeMap = { const modeMap = {
@@ -264,7 +311,6 @@ const loginModeDesc = computed(() => {
const isValidMobile = (mobile: string) => /^1\d{10}$/.test((mobile || "").trim()); const isValidMobile = (mobile: string) => /^1\d{10}$/.test((mobile || "").trim());
const canSubmit = computed(() => { const canSubmit = computed(() => {
if (!isAgreePolicy.value) return false;
if (loginMode.value === "PASSWORD") { if (loginMode.value === "PASSWORD") {
return (formData.value.username || "").trim() && (formData.value.password || "").trim(); return (formData.value.username || "").trim() && (formData.value.password || "").trim();
} }
@@ -286,6 +332,8 @@ onLoad((options: any) => {
loginMode.value = "PASSWORD"; loginMode.value = "PASSWORD";
} }
// #endif // #endif
// 获取图形验证码
fetchCaptcha();
}); });
onUnload(() => { onUnload(() => {
@@ -305,13 +353,43 @@ const toggleLoginMode = () => {
formData.value.code = ""; formData.value.code = "";
}; };
const handleLogin = async () => { const openPolicyDialog = (action: "FORM" | "WECHAT_PHONE", phoneCode: string = "") => {
if (loading.value || !canSubmit.value) return; pendingLoginAction.value = action;
if (!isAgreePolicy.value) { pendingWechatPhoneCode.value = phoneCode;
toast.error("请先同意用户协议");
message
.confirm({ title: "提示" })
.then(async () => {
isAgreePolicy.value = true;
const act = pendingLoginAction.value;
const code = pendingWechatPhoneCode.value;
pendingLoginAction.value = null;
pendingWechatPhoneCode.value = "";
if (act === "WECHAT_PHONE") {
await doWechatPhoneLogin(code);
} else if (act === "FORM") {
await doFormLogin();
}
})
.catch(() => {
pendingLoginAction.value = null;
pendingWechatPhoneCode.value = "";
});
};
async function doFormLogin() {
if (!canSubmit.value) {
if (loginMode.value === "PASSWORD") {
toast.error("请输入用户名和密码");
} else if (loginMode.value === "SMS") {
toast.error("请输入正确的手机号和验证码");
}
return; return;
} }
if (loading.value) return;
loading.value = true; loading.value = true;
try { try {
@@ -319,6 +397,8 @@ const handleLogin = async () => {
await userStore.login({ await userStore.login({
username: formData.value.username, username: formData.value.username,
password: formData.value.password, password: formData.value.password,
captchaId: captchaId.value,
captchaCode: formData.value.captchaCode,
}); });
} else { } else {
await userStore.loginBySms({ await userStore.loginBySms({
@@ -332,9 +412,21 @@ const handleLogin = async () => {
setTimeout(() => uni.reLaunch({ url: redirect.value }), 800); setTimeout(() => uni.reLaunch({ url: redirect.value }), 800);
} catch (error: any) { } catch (error: any) {
toast.error(error?.message || "登录失败"); toast.error(error?.message || "登录失败");
// 登录失败刷新验证码
if (loginMode.value === "PASSWORD") {
fetchCaptcha();
}
} finally { } finally {
loading.value = false; loading.value = false;
} }
}
const handleLogin = async () => {
if (!isAgreePolicy.value) {
openPolicyDialog("FORM");
return;
}
await doFormLogin();
}; };
const handleSendCode = async () => { const handleSendCode = async () => {
@@ -378,17 +470,24 @@ const startSmsCountdown = (
// 微信登录 // 微信登录
const handleWechatPhoneLogin = async (e: any) => { const handleWechatPhoneLogin = async (e: any) => {
const phoneCode = e.detail.code;
if (!isAgreePolicy.value) { if (!isAgreePolicy.value) {
toast.error("请先同意用户协议"); openPolicyDialog("WECHAT_PHONE", phoneCode);
return; return;
} }
const phoneCode = e.detail.code;
if (!phoneCode) { if (!phoneCode) {
// 用户拒绝授权,尝试个人小程序登录 // 用户拒绝授权,尝试个人小程序登录
await handleWechatSilentLogin(); await handleWechatSilentLogin();
return; return;
} }
await doWechatPhoneLogin(phoneCode);
};
async function doWechatPhoneLogin(phoneCode: string) {
if (!phoneCode) {
await handleWechatSilentLogin();
return;
}
loading.value = true; loading.value = true;
@@ -398,14 +497,13 @@ const handleWechatPhoneLogin = async (e: any) => {
await userStore.getInfo(); await userStore.getInfo();
toast.success("登录成功"); toast.success("登录成功");
setTimeout(() => uni.reLaunch({ url: redirect.value }), 800); setTimeout(() => uni.reLaunch({ url: redirect.value }), 800);
} catch (error: any) { } catch {
// 企业登录失败,尝试个人小程序登录
toast.info("正在尝试其他登录方式..."); toast.info("正在尝试其他登录方式...");
await handleWechatSilentLogin(); await handleWechatSilentLogin();
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; }
const handleWechatSilentLogin = async () => { const handleWechatSilentLogin = async () => {
loading.value = true; loading.value = true;
@@ -472,7 +570,7 @@ const handleBindMobile = async () => {
await userStore.bindMobileForWechatMiniapp({ await userStore.bindMobileForWechatMiniapp({
openid: wechatOpenid.value, openid: wechatOpenid.value,
mobile, mobile,
code, smsCode: code,
}); });
await userStore.getInfo(); await userStore.getInfo();
showBindMobilePopup.value = false; showBindMobilePopup.value = false;
@@ -542,7 +640,7 @@ const navigateToAgreement = (type: string) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 160px 48px 0; padding: 24px 48px 0;
} }
// Logo // Logo
@@ -613,8 +711,11 @@ const navigateToAgreement = (type: string) => {
// 表单区域 // 表单区域
.form-area { .form-area {
& > view + view { > view,
margin-top: 16px; > button {
&:not(:first-child) {
margin-top: 16px;
}
} }
} }
@@ -745,6 +846,12 @@ const navigateToAgreement = (type: string) => {
background-color: #e5e7eb; background-color: #e5e7eb;
} }
.captcha-img {
width: 100px;
height: 36px;
border-radius: 6px;
}
:global(.dark) .sms-btn-active { :global(.dark) .sms-btn-active {
background-color: rgba(30, 58, 138, 0.3); background-color: rgba(30, 58, 138, 0.3);
} }
@@ -822,37 +929,25 @@ const navigateToAgreement = (type: string) => {
} }
} }
// 协议
.agreement { .agreement {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: center;
padding-top: 16px; padding-top: 16px;
margin-top: 24px; margin-top: 24px;
border-top: 1px solid #f3f4f6; border-top: 1px solid #f3f4f6;
} }
:global(.dark) .agreement {
border-top-color: #374151;
}
.agreement-text { .agreement-text {
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
color: #6b7280; color: #6b7280;
} }
:global(.dark) .agreement-text {
color: #9ca3af;
}
.agreement-link { .agreement-link {
color: #2563eb; color: #2563eb;
} }
:global(.dark) .agreement-link {
color: #60a5fa;
}
// 绑定手机号弹窗 // 绑定手机号弹窗
.bind-popup { .bind-popup {
padding: 24px; padding: 24px;

View File

@@ -54,7 +54,7 @@
</view> </view>
</view> </view>
<view class="section-card"> <view v-if="isLogin" class="section-card">
<view class="section-header"> <view class="section-header">
<view class="section-title"> <view class="section-title">
<wd-icon name="user" size="18" :color="currentThemeColor" /> <wd-icon name="user" size="18" :color="currentThemeColor" />