Skip to content

Commit ee6c05b

Browse files
author
韩继向 crm
committed
feat: add sms verification to miniapp auth
1 parent 425a303 commit ee6c05b

File tree

2 files changed

+191
-15
lines changed

2 files changed

+191
-15
lines changed

src/api/auth.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,27 @@ export interface LoginPayload {
1515
account: string
1616
password: string
1717
loginType: AuthLoginType
18+
smsCode?: string
1819
}
1920

2021
export interface RegisterPayload {
2122
account: string
2223
password: string
2324
nickname?: string
2425
registerType: AuthRegisterType
26+
smsCode?: string
27+
}
28+
29+
export interface SendSMSCodePayload {
30+
phone: string
31+
purpose: 'login' | 'register'
32+
}
33+
34+
export interface SMSCodeResponse {
35+
provider: string
36+
expiresIn: number
37+
cooldownIn: number
38+
debugCode?: string
2539
}
2640

2741
export interface ProfileUser {
@@ -66,6 +80,14 @@ export function registerApi(payload: RegisterPayload) {
6680
})
6781
}
6882

83+
export function sendSMSCodeApi(payload: SendSMSCodePayload) {
84+
return request<SMSCodeResponse>({
85+
url: '/auth/sms-codes',
86+
method: 'POST',
87+
data: payload,
88+
})
89+
}
90+
6991
export function profileApi() {
7092
return request<ProfileUser>({
7193
url: '/auth/profile',

src/pages/login/index.vue

Lines changed: 169 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<view class="page-shell login-page">
33
<view class="card login-card">
44
<text class="eyebrow">统一认证模板</text>
5-
<text class="title">同一套小程序模板,同时支持用户名、手机号、邮箱认证。</text>
5+
<text class="title">支持用户名、邮箱、手机号登录注册,并为手机号接入短信验证码。</text>
66
<text class="hint">接口地址:{{ apiBaseUrl }}</text>
77
<text v-if="deviceHint" class="hint hint--warning">{{ deviceHint }}</text>
88

@@ -37,9 +37,19 @@
3737
<text>密码</text>
3838
<input v-model="loginForm.password" password placeholder="至少 6 位" />
3939
</view>
40+
<view v-if="loginNeedsSMS" class="field">
41+
<text>短信验证码</text>
42+
<view class="sms-row">
43+
<input v-model="loginForm.smsCode" placeholder="请输入短信验证码" maxlength="8" />
44+
<button class="sms-btn" :disabled="loading || loginCooldown > 0" @click="sendSMSCode('login')">
45+
{{ loginCooldown > 0 ? `${loginCooldown}s` : '发送验证码' }}
46+
</button>
47+
</view>
48+
</view>
4049
<button class="action-btn" :disabled="loading" @click="submitLogin">
4150
{{ loading ? '登录中...' : '立即登录' }}
4251
</button>
52+
<text class="hint">{{ loginNeedsSMS ? '手机号登录需要同时校验密码和短信验证码。' : '演示账号:admin / Admin123!' }}</text>
4353
</view>
4454

4555
<view v-else class="panel">
@@ -62,6 +72,15 @@
6272
<text>昵称</text>
6373
<input v-model="registerForm.nickname" placeholder="可选,不填则自动生成" />
6474
</view>
75+
<view v-if="registerNeedsSMS" class="field">
76+
<text>短信验证码</text>
77+
<view class="sms-row">
78+
<input v-model="registerForm.smsCode" placeholder="请输入短信验证码" maxlength="8" />
79+
<button class="sms-btn" :disabled="loading || registerCooldown > 0" @click="sendSMSCode('register')">
80+
{{ registerCooldown > 0 ? `${registerCooldown}s` : '发送验证码' }}
81+
</button>
82+
</view>
83+
</view>
6584
<view class="field">
6685
<text>密码</text>
6786
<input v-model="registerForm.password" password placeholder="至少 6 位" />
@@ -73,17 +92,25 @@
7392
<button class="action-btn" :disabled="loading" @click="submitRegister">
7493
{{ loading ? '注册中...' : '创建账号' }}
7594
</button>
76-
<text class="hint">注册入口会根据后端 `/auth/options` 配置动态变化。</text>
95+
<text class="hint">
96+
{{ registerNeedsSMS ? '手机号注册需要先完成短信验证码校验。' : '注册入口会根据 /auth/options 配置动态变化。' }}
97+
</text>
7798
</view>
7899
</view>
79100
</view>
80101
</template>
81102

82103
<script setup lang="ts">
83-
import { computed, onMounted, reactive, ref, watch } from 'vue'
84-
import { getAuthOptionsApi, type AuthLoginType, type AuthOptions, type AuthRegisterType } from '@/api/auth'
85-
import { useAuthStore } from '@/store/auth'
104+
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
86105
import { API_BASE_URL, API_BASE_URL_DEVICE_HINT } from '@/common/constants'
106+
import {
107+
getAuthOptionsApi,
108+
sendSMSCodeApi,
109+
type AuthLoginType,
110+
type AuthOptions,
111+
type AuthRegisterType,
112+
} from '@/api/auth'
113+
import { useAuthStore } from '@/store/auth'
87114
88115
const authStore = useAuthStore()
89116
const loading = ref(false)
@@ -102,19 +129,26 @@ const options = ref<AuthOptions>(fallbackOptions)
102129
const mode = ref<'login' | 'register'>('login')
103130
const loginType = ref<AuthLoginType>('username')
104131
const registerType = ref<AuthRegisterType>('email')
132+
const loginCooldown = ref(0)
133+
const registerCooldown = ref(0)
105134
106135
const loginForm = reactive({
107136
account: 'admin',
108137
password: 'Admin123!',
138+
smsCode: '',
109139
})
110140
111141
const registerForm = reactive({
112142
account: '',
113143
nickname: '',
114144
password: '',
115145
confirmPassword: '',
146+
smsCode: '',
116147
})
117148
149+
let loginTimer: ReturnType<typeof setInterval> | null = null
150+
let registerTimer: ReturnType<typeof setInterval> | null = null
151+
118152
const loginTabs = computed(() => {
119153
const tabs: Array<{ value: AuthLoginType; label: string }> = []
120154
if (options.value.enableUsernameLogin) tabs.push({ value: 'username', label: '用户名' })
@@ -130,30 +164,32 @@ const registerTabs = computed(() => {
130164
return tabs
131165
})
132166
133-
const loginPlaceholder = computed(() => {
167+
const loginLabel = computed(() => {
134168
switch (loginType.value) {
135169
case 'email':
136-
return 'name@example.com'
170+
return '邮箱'
137171
case 'phone':
138-
return '18800000000'
172+
return '手机号'
139173
default:
140-
return 'admin'
174+
return '用户名'
141175
}
142176
})
143177
144-
const loginLabel = computed(() => {
178+
const loginPlaceholder = computed(() => {
145179
switch (loginType.value) {
146180
case 'email':
147-
return '邮箱'
181+
return 'name@example.com'
148182
case 'phone':
149-
return '手机号'
183+
return '18800000000'
150184
default:
151-
return '用户名'
185+
return 'admin'
152186
}
153187
})
154188
155-
const registerPlaceholder = computed(() => (registerType.value === 'email' ? 'name@example.com' : '18800000000'))
156189
const registerLabel = computed(() => (registerType.value === 'email' ? '邮箱' : '手机号'))
190+
const registerPlaceholder = computed(() => (registerType.value === 'email' ? 'name@example.com' : '18800000000'))
191+
const loginNeedsSMS = computed(() => loginType.value === 'phone')
192+
const registerNeedsSMS = computed(() => registerType.value === 'phone')
157193
158194
watch(loginTabs, (tabs) => {
159195
if (!tabs.some((tab) => tab.value === loginType.value) && tabs[0]) {
@@ -171,6 +207,20 @@ watch(registerTabs, (tabs) => {
171207
}
172208
}, { immediate: true })
173209
210+
watch(loginNeedsSMS, (value) => {
211+
if (!value) {
212+
loginForm.smsCode = ''
213+
clearCooldown('login')
214+
}
215+
})
216+
217+
watch(registerNeedsSMS, (value) => {
218+
if (!value) {
219+
registerForm.smsCode = ''
220+
clearCooldown('register')
221+
}
222+
})
223+
174224
onMounted(async () => {
175225
try {
176226
options.value = await getAuthOptionsApi()
@@ -182,18 +232,28 @@ onMounted(async () => {
182232
}
183233
})
184234
235+
onUnmounted(() => {
236+
clearCooldown('login')
237+
clearCooldown('register')
238+
})
239+
185240
async function submitLogin() {
186241
if (!loginForm.account || !loginForm.password) {
187242
uni.showToast({ title: '请先填写完整账号和密码', icon: 'none' })
188243
return
189244
}
245+
if (loginNeedsSMS.value && !loginForm.smsCode.trim()) {
246+
uni.showToast({ title: '请先输入短信验证码', icon: 'none' })
247+
return
248+
}
190249
191250
loading.value = true
192251
try {
193252
await authStore.login({
194253
account: loginForm.account.trim(),
195254
password: loginForm.password,
196255
loginType: loginType.value,
256+
smsCode: loginNeedsSMS.value ? loginForm.smsCode.trim() : undefined,
197257
})
198258
uni.switchTab({ url: '/pages/index/index' })
199259
} catch (error) {
@@ -219,6 +279,10 @@ async function submitRegister() {
219279
uni.showToast({ title: '两次输入的密码不一致', icon: 'none' })
220280
return
221281
}
282+
if (registerNeedsSMS.value && !registerForm.smsCode.trim()) {
283+
uni.showToast({ title: '请先输入短信验证码', icon: 'none' })
284+
return
285+
}
222286
223287
loading.value = true
224288
try {
@@ -227,6 +291,7 @@ async function submitRegister() {
227291
nickname: registerForm.nickname.trim(),
228292
password: registerForm.password,
229293
registerType: registerType.value,
294+
smsCode: registerNeedsSMS.value ? registerForm.smsCode.trim() : undefined,
230295
})
231296
uni.switchTab({ url: '/pages/index/index' })
232297
} catch (error) {
@@ -238,6 +303,73 @@ async function submitRegister() {
238303
loading.value = false
239304
}
240305
}
306+
307+
async function sendSMSCode(kind: 'login' | 'register') {
308+
const phone = kind === 'login' ? normalizePhone(loginForm.account) : normalizePhone(registerForm.account)
309+
if (!phone) {
310+
uni.showToast({ title: '请先输入有效手机号', icon: 'none' })
311+
return
312+
}
313+
314+
try {
315+
const payload = await sendSMSCodeApi({ phone, purpose: kind })
316+
if (kind === 'login' && payload.debugCode) {
317+
loginForm.smsCode = payload.debugCode
318+
}
319+
if (kind === 'register' && payload.debugCode) {
320+
registerForm.smsCode = payload.debugCode
321+
}
322+
startCooldown(kind, payload.cooldownIn || 60)
323+
uni.showToast({
324+
title: payload.debugCode ? `验证码:${payload.debugCode}` : '验证码已发送',
325+
icon: 'none',
326+
duration: 2500,
327+
})
328+
} catch (error) {
329+
uni.showToast({
330+
title: error instanceof Error ? error.message : '验证码发送失败',
331+
icon: 'none',
332+
})
333+
}
334+
}
335+
336+
function startCooldown(kind: 'login' | 'register', seconds: number) {
337+
clearCooldown(kind)
338+
const target = kind === 'login' ? loginCooldown : registerCooldown
339+
target.value = Math.max(0, Math.round(seconds))
340+
341+
const timer = setInterval(() => {
342+
if (target.value <= 1) {
343+
clearCooldown(kind)
344+
return
345+
}
346+
target.value -= 1
347+
}, 1000)
348+
349+
if (kind === 'login') {
350+
loginTimer = timer
351+
} else {
352+
registerTimer = timer
353+
}
354+
}
355+
356+
function clearCooldown(kind: 'login' | 'register') {
357+
if (kind === 'login' && loginTimer) {
358+
clearInterval(loginTimer)
359+
loginTimer = null
360+
loginCooldown.value = 0
361+
}
362+
if (kind === 'register' && registerTimer) {
363+
clearInterval(registerTimer)
364+
registerTimer = null
365+
registerCooldown.value = 0
366+
}
367+
}
368+
369+
function normalizePhone(value: string) {
370+
const normalized = value.trim().replace(/[()\s-]/g, '')
371+
return /^\+?[0-9]{6,20}$/.test(normalized) ? normalized : ''
372+
}
241373
</script>
242374

243375
<style scoped lang="scss">
@@ -297,7 +429,8 @@ async function submitRegister() {
297429
}
298430
299431
.mode-btn::after,
300-
.channel-btn::after {
432+
.channel-btn::after,
433+
.sms-btn::after {
301434
border: none;
302435
}
303436
@@ -322,4 +455,25 @@ async function submitRegister() {
322455
border-radius: 24rpx;
323456
background: rgba(244, 247, 252, 0.92);
324457
}
458+
459+
.sms-row {
460+
display: grid;
461+
grid-template-columns: minmax(0, 1fr) auto;
462+
gap: 16rpx;
463+
}
464+
465+
.sms-btn {
466+
margin: 0;
467+
padding: 0 24rpx;
468+
border: none;
469+
border-radius: 24rpx;
470+
background: rgba(15, 118, 110, 0.12);
471+
color: #0f766e;
472+
font-size: 24rpx;
473+
line-height: 88rpx;
474+
}
475+
476+
.sms-btn[disabled] {
477+
opacity: 0.55;
478+
}
325479
</style>

0 commit comments

Comments
 (0)