support sms login

This commit is contained in:
Timi
2026-08-21 18:18:14 +08:00
parent 5f7247b8a6
commit 651b0e1f74
5 changed files with 448 additions and 213 deletions
-212
View File
@@ -1,212 +0,0 @@
<template>
<div class="login-page">
<section class="login-panel">
<div class="brand">
<div class="logo">
<span>糕</span>
</div>
<h1>糕店客户管理</h1>
</div>
<t-form class="form" label-align="left">
<t-form-item label="账号">
<t-input
v-model.trim="formModel.user"
type="tel"
inputmode="tel"
placeholder="请输入账号或手机号"
borderless
/>
</t-form-item>
<t-form-item label="密码">
<t-input
v-model.trim="formModel.password"
type="password"
placeholder="请输入密码"
borderless
/>
</t-form-item>
<t-form-item label="验证码">
<t-input
v-model.trim="formModel.captcha"
placeholder="请输入验证码"
borderless
/>
</t-form-item>
<div class="captcha-wrapper">
<captcha class="captcha" ref="captchaRef" />
</div>
<t-button class="submit" theme="primary" size="medium" :loading="isSubmitting" @click="login">登录</t-button>
</t-form>
</section>
</div>
</template>
<script setup lang="ts">
import {Toast} from "tdesign-mobile-vue";
import {Captcha, useUserStore} from "timi-web";
import {useCurrentStoreStore} from "@/store/currentStoreStore";
type CaptchaExpose = {
captchaId: string;
update: () => Promise<void>;
};
defineOptions({
name: "Login"
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const formModel = reactive({
user: "",
password: "",
captcha: ""
});
const isSubmitting = ref(false);
const captchaRef = ref<CaptchaExpose | null>(null);
function getCaptchaId(): string {
return captchaRef.value?.captchaId || "";
}
async function login(): Promise<void> {
const captchaId = getCaptchaId();
if (!formModel.user || !formModel.password || !formModel.captcha || !captchaId) {
Toast({
theme: "warning",
direction: "row",
message: "请输入账号、密码和验证码"
});
return;
}
isSubmitting.value = true;
try {
await userStore.login({
captchaId,
captcha: formModel.captcha,
data: {
user: formModel.user,
password: formModel.password
}
});
await currentStoreStore.loadAfterLogin();
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/management";
await router.replace(redirect);
} catch (error) {
formModel.captcha = "";
await captchaRef.value?.update();
throw error;
} finally {
isSubmitting.value = false;
}
}
</script>
<style scoped lang="less">
.login-page {
width: 100%;
padding: 4.5rem 1rem 1.5rem 1rem;
display: flex;
min-height: 100vh;
background: #F5F5F8;
align-items: flex-start;
justify-content: center;
.login-panel {
gap: 1.5rem;
width: 100%;
padding: 1.5rem;
display: flex;
max-width: 28rem;
background: rgba(255, 255, 255, .94);
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
border-radius: 1.4rem;
flex-direction: column;
.captcha-wrapper {
display: flex;
margin-top: .5rem;
justify-content: end;
.captcha {
border: 1px solid var(--td-border-level-1-color);
}
}
}
.brand {
display: grid;
place-items: center;
h1 {
margin: .875rem 0 0 0;
color: #0f172a;
font-size: 1.5rem;
font-weight: 700;
}
}
.logo {
color: #FFF;
width: 5rem;
height: 5rem;
display: grid;
background: #0f766e;
box-shadow: 0 .75rem 1.5rem rgba(15, 118, 110, .22);
place-items: center;
border-radius: 1.25rem;
span {
font-size: 2rem;
font-weight: 800;
}
}
.form {
margin: 0;
padding: .25rem 0;
background: transparent;
.captcha-row {
gap: .75rem;
width: 100%;
display: flex;
align-items: center;
}
:deep(.tui-captcha) {
flex: 0 0 7.5rem;
}
.submit {
gap: .5rem;
width: 8rem;
margin: 2rem auto 0 auto;
display: flex;
}
}
}
:global(html.theme-dark) {
.login-page {
background: #10161f;
.login-panel {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
.brand h1 {
color: #e2e8f0;
}
:deep(.tui-captcha) {
color: #94a3b8;
background: #0f172a;
}
}
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="login-page">
<section class="login-panel">
<div class="brand">
<div class="logo"><span>糕</span></div>
<h1>糕店客户管理</h1>
</div>
<t-segmented class="login-type" v-model="loginType" :options="loginTypeOptions" />
<password-login v-if="loginType === 'password'" class="form" />
<sms-login v-else class="form" />
</section>
</div>
</template>
<script setup lang="ts">
import PasswordLogin from "@/pages/user/component/PasswordLogin.vue";
import SmsLogin from "@/pages/user/component/SmsLogin.vue";
defineOptions({
name: "LoginIndex"
});
type LoginType = "password" | "sms";
const loginType = ref<LoginType>("password");
const loginTypeOptions = [
{ value: "password" as LoginType, label: "密码登录" },
{ value: "sms" as LoginType, label: "短信登录" }
];
</script>
<style scoped lang="less">
.login-page {
width: 100%;
padding: 4.5rem 1rem 1.5rem;
display: flex;
min-height: 100vh;
background: #F5F5F8;
align-items: flex-start;
justify-content: center;
.login-panel {
width: 100%;
padding: 1.5rem;
display: flex;
max-width: 28rem;
background: rgba(255, 255, 255, .94);
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
border-radius: 1.4rem;
flex-direction: column;
.form {
margin: 0;
:deep(.t-form__label) {
width: 5.5rem;
}
}
.login-type {
width: fit-content;
margin: 1rem auto 0 auto;
}
}
.brand {
display: grid;
place-items: center;
h1 {
margin: .875rem 0 0;
color: #0f172a;
font-size: 1.5rem;
font-weight: 700;
}
}
.logo {
width: 5rem;
height: 5rem;
color: #FFF;
display: grid;
background: #0f766e;
box-shadow: 0 .75rem 1.5rem rgba(15, 118, 110, .22);
place-items: center;
border-radius: 1.25rem;
span {
font-size: 2rem;
font-weight: 800;
}
}
}
:global(html.theme-dark) {
.login-page {
background: #10161f;
.login-panel {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
.brand h1 {
color: #e2e8f0;
}
}
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<template>
<t-form class="password-login" @submit="login">
<t-form-item label="账号">
<t-input v-model.trim="formModel.user" type="tel" inputmode="tel" placeholder="请输入账号或手机号" borderless />
</t-form-item>
<t-form-item label="密码">
<t-input v-model="formModel.password" type="password" placeholder="请输入密码" borderless />
</t-form-item>
<t-form-item label="验证码">
<t-input v-model.trim="formModel.captcha" placeholder="请输入验证码" borderless />
</t-form-item>
<div class="captcha-wrapper">
<captcha class="captcha" ref="captchaRef" :width="120" :height="40" />
</div>
<div class="exec">
<t-button theme="primary" class="button" type="submit" :loading="isSubmitting" :disabled="isSubmitting">登录</t-button>
</div>
</t-form>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { Captcha, useUserStore } from "timi-web";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
type CaptchaExpose = {
captchaId: string;
update: () => Promise<void>;
};
type PasswordForm = {
user: string;
password: string;
captcha: string;
};
defineOptions({
name: "PasswordLogin"
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const formModel = reactive<PasswordForm>({
user: "",
password: "",
captcha: ""
});
const isSubmitting = ref(false);
const captchaRef = ref<CaptchaExpose | null>(null);
function getRedirectPath(): string {
return typeof route.query.redirect === "string" ? route.query.redirect : "/management";
}
function validateForm(): boolean {
if (!formModel.user || !formModel.password || !formModel.captcha || !captchaRef.value?.captchaId) {
Toast.warning("请输入账号、密码和验证码");
return false;
}
return true;
}
async function login(): Promise<void> {
if (isSubmitting.value || !validateForm()) {
return;
}
isSubmitting.value = true;
try {
await userStore.login({
captchaId: captchaRef.value?.captchaId || "",
captcha: formModel.captcha,
data: {
user: formModel.user,
password: formModel.password
}
});
await currentStoreStore.loadAfterLogin();
await router.replace(getRedirectPath());
} catch {
formModel.captcha = "";
await captchaRef.value?.update();
} finally {
isSubmitting.value = false;
}
}
</script>
<style scoped lang="less">
.password-login {
padding: .25rem 0;
background: transparent;
.captcha-wrapper {
display: flex;
margin-top: .5rem;
justify-content: end;
.captcha {
border: 1px solid var(--td-border-level-1-color);
}
}
.exec {
display: flex;
margin-top: 1rem;
justify-content: center;
.button {
width: 10rem;
}
}
}
:global(html.theme-dark) {
.password-login {
.captcha {
background: #0f172a;
}
}
}
</style>
+214
View File
@@ -0,0 +1,214 @@
<template>
<t-form class="sms-login" @submit="login" label-width="6rem">
<t-form-item label="手机号">
<t-input v-model.trim="formModel.mobilePhone" type="tel" inputmode="tel" placeholder="请输入手机号" borderless />
</t-form-item>
<t-form-item label="图形验证码">
<t-input v-model.trim="formModel.captcha" class="captcha-input" placeholder="请输入验证码" borderless />
</t-form-item>
<div class="captcha-wrapper">
<captcha ref="captchaRef" class="captcha" :width="120" :height="40" />
</div>
<t-form-item label="短信验证码">
<t-input v-model.trim="formModel.smsCode" class="sms-code" placeholder="请输入短信验证码" borderless />
</t-form-item>
<div class="send-wrapper">
<t-button
type="button"
class="send"
size="extra-small"
theme="primary"
:content="sendSmsText"
:disabled="!canSendSms"
@click="sendSms"
/>
</div>
<div class="exec">
<t-button theme="primary" class="button" type="submit" :loading="isSubmitting" :disabled="isSubmitting">登录</t-button>
</div>
</t-form>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { Captcha, NotifyAPI, UserAPI, useUserStore } from "timi-web";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
type CaptchaExpose = {
captchaId: string;
update: () => Promise<void>;
};
type SmsForm = {
mobilePhone: string;
captcha: string;
smsCode: string;
notifyDetailId: string;
};
defineOptions({
name: "SmsLogin"
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const formModel = reactive<SmsForm>({
mobilePhone: "",
captcha: "",
smsCode: "",
notifyDetailId: ""
});
const captchaRef = ref<CaptchaExpose | null>(null);
const isSubmitting = ref(false);
const sendSmsCountdown = ref(0);
let sendSmsTimer: number | undefined;
const isSendingSms = computed(() => 0 < sendSmsCountdown.value);
const sendSmsText = computed(() => isSendingSms.value ? `${sendSmsCountdown.value} 秒后重新发送` : "发送验证码");
const canSendSms = computed(() => (
!isSendingSms.value
&& /^1\d{10}$/.test(formModel.mobilePhone)
&& 4 === formModel.captcha.length
));
function getRedirectPath(): string {
return typeof route.query.redirect === "string" ? route.query.redirect : "/";
}
function getCaptchaId(): string {
return captchaRef.value?.captchaId || "";
}
function clearSmsTimer(): void {
if (sendSmsTimer === undefined) {
return;
}
window.clearInterval(sendSmsTimer);
sendSmsTimer = undefined;
}
function startSmsTimer(): void {
clearSmsTimer();
sendSmsCountdown.value = 60;
sendSmsTimer = window.setInterval(() => {
sendSmsCountdown.value -= 1;
if (!(0 < sendSmsCountdown.value)) {
clearSmsTimer();
}
}, 1000);
}
async function sendSms(): Promise<void> {
if (!canSendSms.value) {
return;
}
const captchaId = getCaptchaId();
if (!captchaId) {
Toast.warning("图形验证码尚未加载,请稍后重试");
await captchaRef.value?.update();
return;
}
try {
formModel.notifyDetailId = await NotifyAPI.smsSend({
captchaId,
captcha: formModel.captcha,
data: {
detailList: [
{
sendTo: formModel.mobilePhone
}
]
}
});
startSmsTimer();
Toast.success(`验证码已发送,请检查 ${formModel.mobilePhone} 手机短信`);
} catch {
formModel.notifyDetailId = "";
} finally {
formModel.captcha = "";
await captchaRef.value?.update();
}
}
function validateForm(): boolean {
if (!/^1\d{10}$/.test(formModel.mobilePhone)) {
Toast.warning("请输入正确的 11 位手机号");
return false;
}
if (!formModel.notifyDetailId || !formModel.smsCode) {
Toast.warning("请先获取并输入短信验证码");
return false;
}
return true;
}
async function login(): Promise<void> {
if (isSubmitting.value || !validateForm()) {
return;
}
isSubmitting.value = true;
try {
await userStore.updateLoginUser(await UserAPI.loginBySms({
detailId: formModel.notifyDetailId,
captcha: formModel.smsCode
}));
await currentStoreStore.loadAfterLogin();
await router.replace(getRedirectPath());
} catch {
formModel.smsCode = "";
} finally {
isSubmitting.value = false;
}
}
onUnmounted(() => {
clearSmsTimer();
});
</script>
<style scoped lang="less">
.sms-login {
padding: .25rem 0;
background: transparent;
.send-wrapper,
.captcha-wrapper {
display: flex;
margin-top: .5rem;
justify-content: end;
.captcha {
border: 1px solid var(--td-border-level-1-color);
}
}
.sms-code {
.send {
height: 1.5rem;
}
}
.exec {
display: flex;
margin-top: .5rem;
justify-content: center;
.button {
width: 10rem;
}
}
}
:global(html.theme-dark) {
.sms-login {
.captcha {
background: #0f172a;
}
}
}
</style>