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
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" >
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 位" />
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'
86105import { 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
88115const authStore = useAuthStore ()
89116const loading = ref (false )
@@ -102,19 +129,26 @@ const options = ref<AuthOptions>(fallbackOptions)
102129const mode = ref <' login' | ' register' >(' login' )
103130const loginType = ref <AuthLoginType >(' username' )
104131const registerType = ref <AuthRegisterType >(' email' )
132+ const loginCooldown = ref (0 )
133+ const registerCooldown = ref (0 )
105134
106135const loginForm = reactive ({
107136 account: ' admin' ,
108137 password: ' Admin123!' ,
138+ smsCode: ' ' ,
109139})
110140
111141const 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+
118152const 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' ))
156189const 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
158194watch (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+
174224onMounted (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+
185240async 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 : 24 rpx;
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 : 16 rpx;
463+ }
464+
465+ .sms-btn {
466+ margin : 0 ;
467+ padding : 0 24 rpx;
468+ border : none ;
469+ border-radius : 24 rpx;
470+ background : rgba (15 , 118 , 110 , 0.12 );
471+ color : #0f766e ;
472+ font-size : 24 rpx;
473+ line-height : 88 rpx;
474+ }
475+
476+ .sms-btn [disabled ] {
477+ opacity : 0.55 ;
478+ }
325479 </style >
0 commit comments