@liujitcn/kratos-uni-app-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +46 -0
  2. package/dist/module.mjs +84 -0
  3. package/dist/vite.mjs +405 -0
  4. package/package.json +58 -0
  5. package/src/api/base/config.ts +20 -0
  6. package/src/api/base/file.ts +79 -0
  7. package/src/api/base/login.ts +87 -0
  8. package/src/api/base/menu.ts +17 -0
  9. package/src/api/base/oauth.ts +149 -0
  10. package/src/api/system/auth.ts +54 -0
  11. package/src/api/system/base_area.ts +34 -0
  12. package/src/api/system/base_dict.ts +24 -0
  13. package/src/bootstrap.ts +23 -0
  14. package/src/components/BootstrapStatus.vue +39 -0
  15. package/src/components/KratosTabBar.vue +73 -0
  16. package/src/index.ts +6 -0
  17. package/src/module.ts +85 -0
  18. package/src/navigation-pattern.d.mts +2 -0
  19. package/src/navigation-pattern.mjs +29 -0
  20. package/src/navigation-tree.mjs +45 -0
  21. package/src/navigation.ts +330 -0
  22. package/src/pages.ts +26 -0
  23. package/src/rpc/base/v1/config.ts +34 -0
  24. package/src/rpc/base/v1/file.ts +66 -0
  25. package/src/rpc/base/v1/login.ts +128 -0
  26. package/src/rpc/base/v1/mcp.ts +20 -0
  27. package/src/rpc/base/v1/oauth.ts +199 -0
  28. package/src/rpc/base/v1/sse.ts +22 -0
  29. package/src/rpc/common/v1/common.ts +71 -0
  30. package/src/rpc/common/v1/enum.ts +71 -0
  31. package/src/rpc/common/v1/types.ts +77 -0
  32. package/src/rpc/google/protobuf/empty.ts +19 -0
  33. package/src/rpc/google/protobuf/wrappers.ts +124 -0
  34. package/src/rpc/system/app/v1/auth.ts +54 -0
  35. package/src/rpc/system/app/v1/base_area.ts +24 -0
  36. package/src/rpc/system/app/v1/base_dict.ts +37 -0
  37. package/src/rpc/system/app/v1/base_menu.ts +61 -0
  38. package/src/rpc/system/common/v1/enum.ts +80 -0
  39. package/src/static/images/avatar.png +0 -0
  40. package/src/static/images/center_bg.png +0 -0
  41. package/src/static/images/logo_icon.png +0 -0
  42. package/src/static/images/navigator_bg.png +0 -0
  43. package/src/static/tabs/home_default.png +0 -0
  44. package/src/static/tabs/home_selected.png +0 -0
  45. package/src/static/tabs/user_default.png +0 -0
  46. package/src/static/tabs/user_selected.png +0 -0
  47. package/src/stores/index.ts +14 -0
  48. package/src/stores/modules/setting.ts +56 -0
  49. package/src/stores/modules/user.ts +266 -0
  50. package/src/styles/base.scss +42 -0
  51. package/src/types/env.d.ts +24 -0
  52. package/src/types/go-captcha-uni.d.ts +7 -0
  53. package/src/types/uni-helper.d.ts +54 -0
  54. package/src/uni.scss +76 -0
  55. package/src/utils/auth.ts +67 -0
  56. package/src/utils/file.ts +95 -0
  57. package/src/utils/http.ts +430 -0
  58. package/src/utils/index.ts +79 -0
  59. package/src/utils/navigation.ts +81 -0
  60. package/src/utils/passwordCrypto.ts +275 -0
  61. package/src/views/pages/index/index.vue +227 -0
  62. package/src/views/pages/login/login.vue +1133 -0
  63. package/src/views/pages/login/protocal.vue +176 -0
  64. package/src/views/pages/status/index.vue +36 -0
  65. package/src/views/pages/webview/webview.vue +136 -0
  66. package/src/vite.ts +474 -0
@@ -0,0 +1,1133 @@
1
+ <script setup lang="ts">
2
+ import { useSettingStore, useUserStore } from '../../../stores'
3
+ import type { LoginRequest } from '../../../rpc/base/v1/login'
4
+ import { onLoad } from '@dcloudio/uni-app'
5
+ import { computed, defineAsyncComponent, reactive, ref } from 'vue'
6
+ import { defLoginService } from '../../../api/base/login'
7
+ import defaultLogo from '../../../static/images/logo_icon.png'
8
+ import { homeTabPage } from '../../../utils/navigation'
9
+ import { PASSWORD_CRYPTO_SCENE, encryptPassword } from '../../../utils/passwordCrypto'
10
+ import { navigateAppRoute } from '../../../navigation'
11
+
12
+ // go-captcha-uni 仅在 H5 端使用,小程序端不支持 defineAsyncComponent,需按平台裁剪。
13
+ // #ifdef H5
14
+ const GoCaptchaUni = defineAsyncComponent(() => import('go-captcha-uni'))
15
+ // #endif
16
+
17
+ const userStore = useUserStore()
18
+ const settingStore = useSettingStore()
19
+ const wechatMiniProvider = 'wechatmini'
20
+ const loginSettingsReady = ref(false)
21
+ let loginSettingsPromise: Promise<boolean> | undefined
22
+
23
+ const configuredMainTitle = computed(() => settingStore.getData('mainTitle') || '应用框架示例')
24
+ const configuredSubTitle = computed(() => settingStore.getData('subTitle') || '欢迎使用本应用')
25
+ const configuredAppLogo = computed(() => settingStore.getData('appLogo') || defaultLogo)
26
+ const showTenantCode = computed(() => settingStore.getData('showTenantCode') !== 'false')
27
+
28
+ // 是否同意协议
29
+ const isAgreePrivacy = ref(false)
30
+ const isAgreePrivacyShakeY = ref(false)
31
+ const loading = ref(false)
32
+
33
+ const toggleAgreePrivacy = () => {
34
+ isAgreePrivacy.value = !isAgreePrivacy.value
35
+ }
36
+
37
+ const triggerAgreePrivacyShake = () => {
38
+ isAgreePrivacyShakeY.value = true
39
+ setTimeout(() => {
40
+ isAgreePrivacyShakeY.value = false
41
+ }, 500)
42
+ }
43
+
44
+ // 打开服务条款
45
+ const onOpenServiceProtocol = () => {
46
+ navigateAppRoute('app/protocol/service')
47
+ }
48
+
49
+ // 打开隐私协议
50
+ const onOpenPrivacyContract = () => {
51
+ navigateAppRoute('app/protocol/privacy')
52
+ }
53
+
54
+ // #ifdef MP-WEIXIN
55
+ const miniBinding = ref(false)
56
+ const miniCaptchaImage = ref('')
57
+ const miniForm = reactive({
58
+ tenant_code: '',
59
+ user_name: '',
60
+ captcha_code: '',
61
+ captcha_id: '',
62
+ })
63
+ const miniPasswordValue = ref('')
64
+
65
+ type MiniFileSystemManager = {
66
+ writeFile(options: {
67
+ filePath: string
68
+ data: string
69
+ encoding: 'base64'
70
+ success: () => void
71
+ fail: () => void
72
+ }): void
73
+ }
74
+
75
+ type MiniWxRuntime = typeof wx & {
76
+ env: { USER_DATA_PATH: string }
77
+ getFileSystemManager: () => MiniFileSystemManager
78
+ }
79
+
80
+ /** 将接口返回的验证码 Data URI 转为小程序可渲染的临时文件。 */
81
+ const resolveMiniCaptchaImage = (payload: string, captchaId: string) => {
82
+ if (!payload.startsWith('data:image/')) {
83
+ return Promise.resolve(payload)
84
+ }
85
+ const commaIndex = payload.indexOf(',')
86
+ if (commaIndex < 0) {
87
+ return Promise.resolve(payload)
88
+ }
89
+ const wxRuntime = wx as MiniWxRuntime
90
+ // 每次使用不同路径,避免微信小程序复用同一 image src 的缓存。
91
+ const filePath = `${wxRuntime.env.USER_DATA_PATH}/kratos-login-captcha-${captchaId}.png`
92
+ return new Promise<string>((resolve, reject) => {
93
+ wxRuntime.getFileSystemManager().writeFile({
94
+ filePath,
95
+ data: payload.slice(commaIndex + 1),
96
+ encoding: 'base64',
97
+ success: () => resolve(filePath),
98
+ fail: () => reject(new Error('验证码图片写入失败')),
99
+ })
100
+ })
101
+ }
102
+
103
+ /** 加载小程序绑定所需的数字验证码。 */
104
+ const refreshMiniCaptcha = async () => {
105
+ try {
106
+ const captcha = await defLoginService.Captcha({ type: 'digit' })
107
+ miniCaptchaImage.value = await resolveMiniCaptchaImage(
108
+ captcha.captcha_base64,
109
+ captcha.captcha_id,
110
+ )
111
+ miniForm.captcha_id = captcha.captcha_id
112
+ miniForm.captcha_code = ''
113
+ } catch {
114
+ await uni.showToast({ icon: 'none', title: '验证码加载失败' })
115
+ }
116
+ }
117
+
118
+ /** 仅将后端明确返回的未绑定错误转为账号绑定流程。 */
119
+ const isWechatUnboundError = (error: unknown) => {
120
+ if (!error || typeof error !== 'object') return false
121
+ const response = error as {
122
+ statusCode?: number
123
+ data?: {
124
+ reason?: string | number
125
+ message?: string
126
+ binding_required?: boolean
127
+ error?: { reason?: string | number; message?: string }
128
+ }
129
+ }
130
+ const reason = response.data?.reason ?? response.data?.error?.reason
131
+ const message = response.data?.message ?? response.data?.error?.message
132
+ return (
133
+ response.data?.binding_required === true ||
134
+ String(reason || '') === 'UNAUTHENTICATED' ||
135
+ message === '微信账号未绑定,请先绑定已有账号'
136
+ )
137
+ }
138
+
139
+ // 微信授权失败时保留请求层提示,避免异步登录异常冒泡到小程序调试器。
140
+ const wxLogin = async () => {
141
+ const isAgreed = await checkedAgreePrivacy()
142
+ if (!isAgreed) {
143
+ return
144
+ }
145
+ if (loading.value) {
146
+ return
147
+ }
148
+ loading.value = true
149
+ let loginCode = ''
150
+ try {
151
+ loginCode = (await wx.login()).code
152
+ } catch {
153
+ await uni.showToast({ icon: 'none', title: '微信登录失败,请稍后重试' })
154
+ loading.value = false
155
+ return
156
+ }
157
+ try {
158
+ const session = await userStore.createOauthSession({
159
+ provider: wechatMiniProvider,
160
+ code: loginCode,
161
+ })
162
+ if (session.binding_required) {
163
+ miniBinding.value = true
164
+ void refreshMiniCaptcha()
165
+ return
166
+ }
167
+ await loginSuccess()
168
+ } catch (error) {
169
+ if (!isWechatUnboundError(error)) {
170
+ await uni.showToast({ icon: 'none', title: '微信登录失败,请稍后重试' })
171
+ return
172
+ }
173
+ miniBinding.value = true
174
+ void refreshMiniCaptcha()
175
+ } finally {
176
+ loading.value = false
177
+ }
178
+ }
179
+
180
+ // 提交已有账号并绑定当前微信。授权码在每次调用后失效,绑定时重新获取。
181
+ const bindMiniAccount = async () => {
182
+ if (loading.value) return
183
+ if (!(await checkedAgreePrivacy())) return
184
+ if (showTenantCode.value && !miniForm.tenant_code) {
185
+ await uni.showToast({ icon: 'none', title: '请输入租户编号' })
186
+ return
187
+ }
188
+ if (!miniForm.user_name || !miniPasswordValue.value) {
189
+ await uni.showToast({ icon: 'none', title: '请输入用户名和密码' })
190
+ return
191
+ }
192
+ if (!miniForm.captcha_id || !miniForm.captcha_code) {
193
+ await uni.showToast({ icon: 'none', title: '请输入验证码' })
194
+ return
195
+ }
196
+ loading.value = true
197
+ try {
198
+ const password = await encryptPassword(miniPasswordValue.value, PASSWORD_CRYPTO_SCENE.LOGIN)
199
+ const code = (await wx.login()).code
200
+ await userStore.bindOauthSession({
201
+ provider: wechatMiniProvider,
202
+ code,
203
+ tenant_code: showTenantCode.value ? miniForm.tenant_code : '0000',
204
+ user_name: miniForm.user_name,
205
+ password,
206
+ captcha_code: miniForm.captcha_code,
207
+ captcha_id: miniForm.captcha_id,
208
+ })
209
+ await loginSuccess()
210
+ } catch (error) {
211
+ await refreshMiniCaptcha()
212
+ if (error instanceof Error && error.message) {
213
+ await uni.showToast({ icon: 'none', title: error.message })
214
+ }
215
+ } finally {
216
+ loading.value = false
217
+ }
218
+ }
219
+ // #endif
220
+
221
+ // #ifdef H5
222
+ const captcha_base64 = ref() // 验证码图片Base64字符串
223
+ const defaultCaptchaImageWidth = 170
224
+ const captchaImageWidth = ref(`${defaultCaptchaImageWidth}rpx`)
225
+ const behaviorDialogVisible = ref(false)
226
+ const behaviorLoading = ref(false)
227
+ type CaptchaImageLoadDetail = {
228
+ detail: {
229
+ width: number
230
+ height: number
231
+ }
232
+ }
233
+ type BehaviorCaptchaPayload = {
234
+ type?: string
235
+ image: string
236
+ thumb: string
237
+ width?: number
238
+ height?: number
239
+ thumbX?: number
240
+ thumbY?: number
241
+ thumbWidth?: number
242
+ thumbHeight?: number
243
+ thumbSize?: number
244
+ }
245
+ type CaptchaPoint = {
246
+ x: number
247
+ y: number
248
+ }
249
+ type ClickCaptchaPoint = CaptchaPoint & {
250
+ key?: number
251
+ index?: number
252
+ }
253
+ type BehaviorCaptchaData = {
254
+ image: string
255
+ thumb: string
256
+ thumbX?: number
257
+ thumbY?: number
258
+ thumbWidth?: number
259
+ thumbHeight?: number
260
+ thumbSize?: number
261
+ angle?: number
262
+ }
263
+ const behaviorCaptchaTypeSet = new Set(['slide', 'click', 'rotate'])
264
+ const configuredCaptchaType = computed(() => settingStore.getData('captchaType'))
265
+ const currentCaptchaType = ref('')
266
+ const isBehaviorCaptcha = computed(() => behaviorCaptchaTypeSet.has(currentCaptchaType.value))
267
+ const behaviorCaptchaData = reactive<BehaviorCaptchaData>({
268
+ image: '',
269
+ thumb: '',
270
+ })
271
+ const behaviorCaptchaWindowWidth = uni.getSystemInfoSync().windowWidth || 375
272
+ const behaviorCaptchaImageWidth = Math.round(
273
+ Math.min(320, Math.max(280, behaviorCaptchaWindowWidth * 0.86)),
274
+ )
275
+ const rotateCaptchaImageSize = Math.round(Math.min(300, behaviorCaptchaImageWidth * 0.94))
276
+ const behaviorCaptchaSource = reactive({
277
+ width: 300,
278
+ height: 220,
279
+ thumbWidth: 60,
280
+ thumbHeight: 60,
281
+ thumbSize: 150,
282
+ })
283
+ const behaviorCaptchaImageHeight = computed(() =>
284
+ Math.round(
285
+ (behaviorCaptchaSource.height * behaviorCaptchaImageWidth) / behaviorCaptchaSource.width,
286
+ ),
287
+ )
288
+ const behaviorCaptchaScaleX = computed(
289
+ () => behaviorCaptchaImageWidth / behaviorCaptchaSource.width,
290
+ )
291
+ const behaviorCaptchaScaleY = computed(
292
+ () => behaviorCaptchaImageHeight.value / behaviorCaptchaSource.height,
293
+ )
294
+ const rotateCaptchaScale = computed(() => rotateCaptchaImageSize / behaviorCaptchaSource.width)
295
+
296
+ // 行为验证码按展示尺寸渲染,提交前再换算回服务端原始坐标。
297
+ const toDisplayCaptchaX = (value: number) => Math.round(value * behaviorCaptchaScaleX.value)
298
+ const toDisplayCaptchaY = (value: number) => Math.round(value * behaviorCaptchaScaleY.value)
299
+ const toDisplayRotateSize = (value: number) => Math.round(value * rotateCaptchaScale.value)
300
+ const toOriginalCaptchaX = (value: number) =>
301
+ Math.min(
302
+ behaviorCaptchaSource.width,
303
+ Math.max(0, Math.round(value / behaviorCaptchaScaleX.value)),
304
+ )
305
+ const toOriginalCaptchaY = (value: number) =>
306
+ Math.min(
307
+ behaviorCaptchaSource.height,
308
+ Math.max(0, Math.round(value / behaviorCaptchaScaleY.value)),
309
+ )
310
+ const behaviorCaptchaConfig = computed(() => {
311
+ if (currentCaptchaType.value === 'rotate') {
312
+ return {
313
+ width: behaviorCaptchaImageWidth,
314
+ height: rotateCaptchaImageSize,
315
+ size: rotateCaptchaImageSize,
316
+ showTheme: false,
317
+ verticalPadding: 0,
318
+ horizontalPadding: 0,
319
+ iconSize: 20,
320
+ title: '拖动滑块,将内圈图片转正',
321
+ }
322
+ }
323
+ return {
324
+ width: behaviorCaptchaImageWidth,
325
+ height: behaviorCaptchaImageHeight.value,
326
+ thumbWidth: toDisplayCaptchaX(behaviorCaptchaSource.thumbWidth),
327
+ thumbHeight: toDisplayCaptchaY(behaviorCaptchaSource.thumbHeight),
328
+ showTheme: false,
329
+ verticalPadding: 0,
330
+ horizontalPadding: 0,
331
+ buttonText: '确认',
332
+ iconSize: 20,
333
+ dotSize: 20,
334
+ title: currentCaptchaType.value === 'click' ? '请按顺序点击文字' : '请拖动滑块完成拼图',
335
+ }
336
+ })
337
+ const behaviorCaptchaTheme = {
338
+ textColor: '#1f2937',
339
+ iconColor: '#4b5563',
340
+ btnBgColor: '#28bb9c',
341
+ btnBorderColor: '#28bb9c',
342
+ activeColor: '#28bb9c',
343
+ dragBarColor: '#dbe7e3',
344
+ dragBgColor: '#28bb9c',
345
+ dragIconColor: '#ffffff',
346
+ loadingIconColor: '#28bb9c',
347
+ bodyBgColor: '#f1f5f9',
348
+ dotColor: '#ffffff',
349
+ dotBgColor: 'rgba(40, 187, 156, 0.68)',
350
+ dotBorderColor: 'rgba(255, 255, 255, 0.92)',
351
+ }
352
+ // 获取验证码
353
+ const getCaptcha = async () => {
354
+ const requestedCaptchaType = configuredCaptchaType.value
355
+ if (!requestedCaptchaType) {
356
+ throw new Error('登录验证码类型未配置')
357
+ }
358
+ const data = await defLoginService.Captcha({ type: requestedCaptchaType })
359
+ if (!data.type) {
360
+ throw new Error('验证码接口未返回类型')
361
+ }
362
+ currentCaptchaType.value = data.type
363
+ if (!isBehaviorCaptcha.value) {
364
+ behaviorDialogVisible.value = false
365
+ }
366
+ form.value.captcha_id = data.captcha_id
367
+ form.value.captcha_code = ''
368
+ captchaImageWidth.value = `${defaultCaptchaImageWidth}rpx`
369
+ captcha_base64.value = isBehaviorCaptcha.value ? '' : data.captcha_base64
370
+ if (isBehaviorCaptcha.value) {
371
+ applyBehaviorCaptchaPayload(data.captcha_base64)
372
+ }
373
+ }
374
+
375
+ /** 刷新验证码并将接口错误转为页面提示。 */
376
+ const refreshCaptcha = async () => {
377
+ try {
378
+ await getCaptcha()
379
+ return true
380
+ } catch (error) {
381
+ behaviorDialogVisible.value = false
382
+ await uni.showToast({
383
+ icon: 'none',
384
+ title: error instanceof Error ? error.message : '验证码加载失败',
385
+ })
386
+ return false
387
+ }
388
+ }
389
+
390
+ // 页面加载或普通表单刷新验证码,行为验证码延迟到登录弹窗打开时再请求。
391
+ const loadPageCaptcha = async () => {
392
+ if (isBehaviorCaptcha.value) {
393
+ form.value.captcha_id = ''
394
+ form.value.captcha_code = ''
395
+ captcha_base64.value = ''
396
+ return
397
+ }
398
+ await getCaptcha()
399
+ }
400
+ // 解析行为验证码图片载荷并映射为官方组件数据。
401
+ const applyBehaviorCaptchaPayload = (payloadText: string) => {
402
+ const payload = JSON.parse(payloadText || '{}') as BehaviorCaptchaPayload
403
+ const payloadType = payload.type || currentCaptchaType.value
404
+ behaviorCaptchaSource.width = payload.width || 300
405
+ behaviorCaptchaSource.height = payload.height || (payloadType === 'rotate' ? 300 : 220)
406
+ behaviorCaptchaSource.thumbWidth = payload.thumbWidth || (payloadType === 'click' ? 180 : 60)
407
+ behaviorCaptchaSource.thumbHeight = payload.thumbHeight || (payloadType === 'click' ? 48 : 60)
408
+ behaviorCaptchaSource.thumbSize = payload.thumbSize || 150
409
+ behaviorCaptchaData.image = payload.image || ''
410
+ behaviorCaptchaData.thumb = payload.thumb || ''
411
+ behaviorCaptchaData.thumbX = toDisplayCaptchaX(payload.thumbX ?? 0)
412
+ behaviorCaptchaData.thumbY = toDisplayCaptchaY(payload.thumbY ?? 0)
413
+ behaviorCaptchaData.thumbWidth = toDisplayCaptchaX(behaviorCaptchaSource.thumbWidth)
414
+ behaviorCaptchaData.thumbHeight = toDisplayCaptchaY(behaviorCaptchaSource.thumbHeight)
415
+ behaviorCaptchaData.thumbSize = toDisplayRotateSize(behaviorCaptchaSource.thumbSize)
416
+ behaviorCaptchaData.angle = 0
417
+ }
418
+ // 根据验证码图片原始比例更新展示宽度。
419
+ const handleCaptchaImageLoad = (event: Event) => {
420
+ const { width, height } = (event as unknown as CaptchaImageLoadDetail).detail
421
+ if (!width || !height) {
422
+ return
423
+ }
424
+ // 验证码固定展示高度,宽度按图片比例自适应,避免算术验证码横向内容被裁剪。
425
+ const imageWidth = Math.round((60 * width) / height)
426
+ captchaImageWidth.value = `${Math.min(Math.max(imageWidth, defaultCaptchaImageWidth), 300)}rpx`
427
+ }
428
+ // 传统表单登录。
429
+ const form = ref<LoginRequest>({
430
+ tenant_code: '',
431
+ user_name: '',
432
+ password: undefined,
433
+ captcha_id: '',
434
+ captcha_code: '',
435
+ })
436
+ const passwordValue = ref('')
437
+ // 校验账号密码与协议勾选状态。
438
+ const validateLoginForm = async () => {
439
+ if (!(await ensureLoginSettings())) {
440
+ return false
441
+ }
442
+ if (showTenantCode.value && !form.value.tenant_code) {
443
+ await uni.showToast({
444
+ icon: 'none',
445
+ title: '请输入租户编号',
446
+ })
447
+ return false
448
+ }
449
+ if (!form.value.user_name) {
450
+ await uni.showToast({
451
+ icon: 'none',
452
+ title: '请输入用户名或手机号',
453
+ })
454
+ return false
455
+ }
456
+ if (!passwordValue.value) {
457
+ await uni.showToast({
458
+ icon: 'none',
459
+ title: '请输入密码',
460
+ })
461
+ return false
462
+ }
463
+ if (!isBehaviorCaptcha.value && !form.value.captcha_code) {
464
+ await uni.showToast({
465
+ icon: 'none',
466
+ title: '请输入验证码',
467
+ })
468
+ return false
469
+ }
470
+ return checkedAgreePrivacy()
471
+ }
472
+ // 预校验行为验证码并返回可用于登录的一次性令牌。
473
+ const verifyCaptchaToken = async (captchaCode: string) => {
474
+ const result = await defLoginService.VerifyCaptcha({
475
+ captcha_id: form.value.captcha_id,
476
+ captcha_code: captchaCode,
477
+ })
478
+ return result.captcha_token
479
+ }
480
+ // 执行真正的账号登录流程。
481
+ const submitLogin = async (captchaCode: string) => {
482
+ const password = await encryptPassword(passwordValue.value, PASSWORD_CRYPTO_SCENE.LOGIN)
483
+ return userStore.login({
484
+ ...form.value,
485
+ tenant_code: showTenantCode.value ? form.value.tenant_code : '0000',
486
+ password,
487
+ captcha_code: captchaCode,
488
+ })
489
+ }
490
+ // 打开行为验证码弹窗。
491
+ const openBehaviorCaptcha = async () => {
492
+ behaviorDialogVisible.value = true
493
+ behaviorLoading.value = true
494
+ try {
495
+ await refreshCaptcha()
496
+ } finally {
497
+ behaviorLoading.value = false
498
+ }
499
+ }
500
+ // 关闭行为验证码弹窗。
501
+ const closeBehaviorCaptcha = () => {
502
+ behaviorDialogVisible.value = false
503
+ }
504
+ // 验证行为验证码并继续登录。
505
+ const verifyBehaviorCaptcha = async (captchaCode: string, reset: () => void) => {
506
+ if (behaviorLoading.value) {
507
+ return
508
+ }
509
+ behaviorLoading.value = true
510
+ try {
511
+ const captchaToken = await verifyCaptchaToken(captchaCode)
512
+ behaviorDialogVisible.value = false
513
+ await submitLogin(captchaToken)
514
+ await loginSuccess()
515
+ } catch {
516
+ reset()
517
+ await refreshCaptcha()
518
+ } finally {
519
+ behaviorLoading.value = false
520
+ }
521
+ }
522
+ // 行为验证码确认回调。
523
+ const onBehaviorConfirm = (
524
+ value: CaptchaPoint | ClickCaptchaPoint[] | number,
525
+ reset: () => void,
526
+ ) => {
527
+ if (currentCaptchaType.value === 'click') {
528
+ const dots = value as ClickCaptchaPoint[]
529
+ void verifyBehaviorCaptcha(
530
+ JSON.stringify(
531
+ dots.map((dot) => ({ x: toOriginalCaptchaX(dot.x), y: toOriginalCaptchaY(dot.y) })),
532
+ ),
533
+ reset,
534
+ )
535
+ return
536
+ }
537
+ if (currentCaptchaType.value === 'slide') {
538
+ const point = value as CaptchaPoint
539
+ void verifyBehaviorCaptcha(String(toOriginalCaptchaX(point.x)), reset)
540
+ return
541
+ }
542
+ void verifyBehaviorCaptcha(String(Math.round(value as number)), reset)
543
+ }
544
+ // 刷新行为验证码。
545
+ const onBehaviorRefresh = () => {
546
+ void refreshCaptcha()
547
+ }
548
+ // 表单提交
549
+ const onSubmit = async () => {
550
+ const valid = await validateLoginForm()
551
+ if (!valid) {
552
+ return
553
+ }
554
+ if (isBehaviorCaptcha.value) {
555
+ await openBehaviorCaptcha()
556
+ return
557
+ }
558
+ try {
559
+ await submitLogin(form.value.captcha_code)
560
+ await loginSuccess()
561
+ } catch {
562
+ form.value.captcha_code = ''
563
+ await refreshCaptcha()
564
+ }
565
+ }
566
+ // #endif
567
+ const loginSuccess = async () => {
568
+ await userStore.getUserProfile()
569
+ // 成功提示
570
+ await uni.showToast({ icon: 'success', title: '登录成功' })
571
+ setTimeout(() => {
572
+ const lastRoute = uni.getStorageSync('lastRoute') || homeTabPage
573
+ if (lastRoute.startsWith(homeTabPage)) {
574
+ uni.setStorageSync('SwitchTabIndex', true)
575
+ }
576
+ uni.removeStorageSync('lastRoute')
577
+
578
+ uni.reLaunch({ url: lastRoute })
579
+ }, 500)
580
+ }
581
+
582
+ // 请先阅读并勾选协议
583
+ const checkedAgreePrivacy = async () => {
584
+ if (!(await ensureLoginSettings())) {
585
+ return false
586
+ }
587
+
588
+ if (!settingStore.getData('serviceProtocol') || !settingStore.getData('privacyProtocol')) {
589
+ await uni.showToast({ icon: 'none', title: '服务条款和隐私协议未配置' })
590
+ return false
591
+ }
592
+
593
+ if (isAgreePrivacy.value) {
594
+ return true
595
+ }
596
+
597
+ triggerAgreePrivacyShake()
598
+
599
+ return new Promise<boolean>((resolve) => {
600
+ uni.showModal({
601
+ title: '提示',
602
+ content: '请先阅读并勾选协议内容,点击确定后将自动勾选并继续登录',
603
+ confirmText: '确定',
604
+ cancelText: '取消',
605
+ success: ({ confirm }) => {
606
+ if (confirm) {
607
+ isAgreePrivacy.value = true
608
+ resolve(true)
609
+ return
610
+ }
611
+ resolve(false)
612
+ },
613
+ fail: () => resolve(false),
614
+ })
615
+ })
616
+ }
617
+
618
+ /** 加载登录所需的移动端配置。 */
619
+ const loadLoginSettings = () => {
620
+ if (loginSettingsReady.value) {
621
+ return Promise.resolve(true)
622
+ }
623
+ if (loginSettingsPromise) {
624
+ return loginSettingsPromise
625
+ }
626
+
627
+ loginSettingsPromise = (async () => {
628
+ try {
629
+ await settingStore.loadData()
630
+ // #ifdef H5
631
+ const captchaType = configuredCaptchaType.value
632
+ if (!captchaType) {
633
+ throw new Error('登录验证码类型未配置')
634
+ }
635
+ currentCaptchaType.value = captchaType
636
+ await loadPageCaptcha()
637
+ // #endif
638
+ // #ifdef MP-WEIXIN
639
+ await refreshMiniCaptcha()
640
+ // #endif
641
+ loginSettingsReady.value = true
642
+ return true
643
+ } catch (error) {
644
+ await uni.showToast({
645
+ icon: 'none',
646
+ title: error instanceof Error ? error.message : '移动端配置加载失败',
647
+ })
648
+ loginSettingsPromise = undefined
649
+ return false
650
+ }
651
+ })()
652
+
653
+ return loginSettingsPromise
654
+ }
655
+
656
+ /** 确保登录前已完成移动端配置加载。 */
657
+ const ensureLoginSettings = () => loadLoginSettings()
658
+
659
+ // 获取 code 登录凭证
660
+ onLoad(() => {
661
+ void loadLoginSettings()
662
+ })
663
+ </script>
664
+
665
+ <template>
666
+ <view class="login-page">
667
+ <view class="login-hero">
668
+ <view class="login-logo-shell">
669
+ <image :src="configuredAppLogo" />
670
+ </view>
671
+ <view class="login-hero-copy">
672
+ <text class="login-title">{{ configuredMainTitle }}</text>
673
+ <text class="login-subtitle">{{ configuredSubTitle }}</text>
674
+ </view>
675
+ </view>
676
+ <view class="login-panel">
677
+ <view class="login-form">
678
+ <!-- 网页端表单登录 -->
679
+ <!-- #ifdef H5 -->
680
+ <input
681
+ v-if="showTenantCode"
682
+ v-model="form.tenant_code"
683
+ class="login-input"
684
+ type="text"
685
+ confirm-type="next"
686
+ placeholder="请输入租户编号"
687
+ />
688
+ <input
689
+ v-model="form.user_name"
690
+ class="login-input"
691
+ type="text"
692
+ confirm-type="next"
693
+ placeholder="请输入用户名/手机号码"
694
+ @confirm="onSubmit"
695
+ />
696
+ <input
697
+ v-model="passwordValue"
698
+ class="login-input"
699
+ type="text"
700
+ password
701
+ confirm-type="done"
702
+ placeholder="请输入密码"
703
+ @confirm="onSubmit"
704
+ />
705
+ <view v-if="!isBehaviorCaptcha" class="captcha-row">
706
+ <input
707
+ v-model="form.captcha_code"
708
+ class="login-input captcha-input"
709
+ type="text"
710
+ confirm-type="done"
711
+ placeholder="请输入验证码"
712
+ @confirm="onSubmit"
713
+ />
714
+ <view class="captcha-divider"></view>
715
+ <view class="captcha-trigger" :style="{ width: captchaImageWidth }" @tap="getCaptcha">
716
+ <image
717
+ class="captcha-image"
718
+ :style="{ width: captchaImageWidth }"
719
+ :src="captcha_base64"
720
+ mode="aspectFit"
721
+ @load="handleCaptchaImageLoad"
722
+ />
723
+ </view>
724
+ </view>
725
+ <button @tap="onSubmit" class="login-button login-button-primary">登录</button>
726
+ <view v-if="behaviorDialogVisible" class="login-behavior-mask">
727
+ <view class="login-behavior-panel">
728
+ <view v-if="behaviorLoading" class="login-behavior-loading">加载中...</view>
729
+ <GoCaptchaUni
730
+ :type="currentCaptchaType"
731
+ :data="behaviorCaptchaData"
732
+ :config="behaviorCaptchaConfig"
733
+ :theme="behaviorCaptchaTheme"
734
+ @event-confirm="onBehaviorConfirm"
735
+ @event-refresh="onBehaviorRefresh"
736
+ @event-close="closeBehaviorCaptcha"
737
+ />
738
+ </view>
739
+ </view>
740
+ <!-- #endif -->
741
+
742
+ <!-- 小程序端授权登录 -->
743
+ <!-- #ifdef MP-WEIXIN -->
744
+ <button
745
+ v-if="!miniBinding"
746
+ class="login-button login-button-primary"
747
+ :loading="loading"
748
+ @tap="wxLogin"
749
+ >
750
+ <text class="icon icon-phone"></text>
751
+ 微信一键登录
752
+ </button>
753
+ <view v-else class="login-form">
754
+ <view class="login-bind-tip">该微信尚未绑定,请登录已有账号完成绑定</view>
755
+ <input
756
+ v-if="showTenantCode"
757
+ v-model="miniForm.tenant_code"
758
+ class="login-input"
759
+ type="text"
760
+ placeholder="请输入租户编号"
761
+ />
762
+ <input
763
+ v-model="miniForm.user_name"
764
+ class="login-input"
765
+ type="text"
766
+ placeholder="请输入用户名/手机号码"
767
+ />
768
+ <input
769
+ v-model="miniPasswordValue"
770
+ class="login-input"
771
+ type="text"
772
+ password
773
+ placeholder="请输入密码"
774
+ />
775
+ <view class="captcha-row">
776
+ <input
777
+ v-model="miniForm.captcha_code"
778
+ class="login-input captcha-input"
779
+ type="text"
780
+ placeholder="请输入验证码"
781
+ />
782
+ <view class="captcha-divider"></view>
783
+ <view class="captcha-trigger" @tap="refreshMiniCaptcha">
784
+ <image class="captcha-image" :src="miniCaptchaImage" mode="aspectFit" />
785
+ </view>
786
+ </view>
787
+ <button
788
+ class="login-button login-button-primary"
789
+ :loading="loading"
790
+ @tap="bindMiniAccount"
791
+ >
792
+ 登录并绑定微信
793
+ </button>
794
+ </view>
795
+ <!-- #endif -->
796
+ </view>
797
+ <view class="login-tips" :class="{ animate__shakeY: isAgreePrivacyShakeY }">
798
+ <view class="login-agreement" @tap="toggleAgreePrivacy">
799
+ <view class="login-agree-icon" :class="{ checked: isAgreePrivacy }"></view>
800
+ <text class="login-agree-desc">我已阅读并同意</text>
801
+ <text class="login-agree-link" @tap.stop="onOpenServiceProtocol">《服务条款》</text>
802
+ <text class="login-agree-separator">和</text>
803
+ <text class="login-agree-link" @tap.stop="onOpenPrivacyContract">《隐私协议》</text>
804
+ </view>
805
+ </view>
806
+ </view>
807
+ </view>
808
+ </template>
809
+
810
+ <style lang="scss">
811
+ .login-page {
812
+ display: flex;
813
+ flex-direction: column;
814
+ justify-content: center;
815
+ min-height: 100vh;
816
+ padding: 48rpx 40rpx 56rpx;
817
+ background: linear-gradient(180deg, #f4fbf8 0%, #ffffff 45%, #ffffff 100%);
818
+ box-sizing: border-box;
819
+ }
820
+
821
+ .login-hero {
822
+ margin-bottom: 40rpx;
823
+ text-align: center;
824
+
825
+ .login-logo-shell {
826
+ display: inline-flex;
827
+ align-items: center;
828
+ justify-content: center;
829
+ width: 180rpx;
830
+ height: 180rpx;
831
+ margin-bottom: 24rpx;
832
+ border-radius: 44rpx;
833
+ background: rgba(255, 255, 255, 0.92);
834
+ box-shadow: 0 18rpx 44rpx rgba(40, 187, 156, 0.12);
835
+ }
836
+
837
+ image {
838
+ width: 120rpx;
839
+ height: 120rpx;
840
+ }
841
+
842
+ .login-hero-copy {
843
+ display: flex;
844
+ flex-direction: column;
845
+ }
846
+
847
+ .login-title {
848
+ font-size: 44rpx;
849
+ font-weight: 600;
850
+ color: #1f2937;
851
+ }
852
+
853
+ .login-subtitle {
854
+ margin-top: 12rpx;
855
+ color: #6b7280;
856
+ font-size: 26rpx;
857
+ }
858
+ }
859
+
860
+ .login-panel {
861
+ padding: 36rpx 28rpx 30rpx;
862
+ border-radius: 36rpx;
863
+ background: rgba(255, 255, 255, 0.96);
864
+ box-shadow: 0 24rpx 60rpx rgba(15, 23, 42, 0.08);
865
+ }
866
+
867
+ .login-form {
868
+ display: flex;
869
+ flex-direction: column;
870
+
871
+ .login-bind-tip {
872
+ margin-bottom: 20rpx;
873
+ color: #6b7280;
874
+ font-size: 24rpx;
875
+ line-height: 1.5;
876
+ text-align: center;
877
+ }
878
+
879
+ .login-input {
880
+ width: 100%;
881
+ height: 88rpx;
882
+ font-size: 28rpx;
883
+ color: #1f2937;
884
+ border-radius: 24rpx;
885
+ border: 1px solid #e5e7eb;
886
+ background: #f9fbfa;
887
+ padding: 0 30rpx;
888
+ margin-bottom: 20rpx;
889
+ box-sizing: border-box;
890
+ }
891
+
892
+ .login-button {
893
+ display: flex;
894
+ align-items: center;
895
+ justify-content: center;
896
+ width: 100%;
897
+ height: 88rpx;
898
+ margin-top: 12rpx;
899
+ font-size: 30rpx;
900
+ font-weight: 500;
901
+ border-radius: 24rpx;
902
+ color: #fff;
903
+ box-shadow: 0 18rpx 36rpx rgba(40, 187, 156, 0.24);
904
+
905
+ &::after {
906
+ border: 0;
907
+ }
908
+
909
+ .icon {
910
+ font-size: 40rpx;
911
+ margin-right: 10rpx;
912
+ }
913
+
914
+ .icon-phone::before {
915
+ content: '☎';
916
+ }
917
+ }
918
+
919
+ .login-button-primary {
920
+ background: linear-gradient(135deg, #34c8aa 0%, #28bb9c 100%);
921
+ }
922
+
923
+ .captcha-row {
924
+ display: flex;
925
+ align-items: center;
926
+ width: 100%;
927
+ height: 88rpx;
928
+ margin-bottom: 20rpx;
929
+ padding: 0 12rpx 0 30rpx;
930
+ border: 1px solid #e5e7eb;
931
+ border-radius: 24rpx;
932
+ background: #f9fbfa;
933
+ box-sizing: border-box;
934
+
935
+ .captcha-input {
936
+ flex: 1;
937
+ height: 100%;
938
+ margin-bottom: 0;
939
+ padding: 0;
940
+ border: 0;
941
+ background: transparent;
942
+ }
943
+
944
+ .captcha-divider {
945
+ width: 1rpx;
946
+ height: 44rpx;
947
+ background: #e7ebf0;
948
+ }
949
+
950
+ .captcha-trigger {
951
+ display: flex;
952
+ align-items: center;
953
+ justify-content: center;
954
+ flex: 0 0 auto;
955
+ height: 72rpx;
956
+ margin-left: 14rpx;
957
+ border-radius: 18rpx;
958
+ background: #fff;
959
+ box-sizing: border-box;
960
+ }
961
+
962
+ .captcha-image {
963
+ width: 170rpx;
964
+ flex-shrink: 0;
965
+ height: 60rpx;
966
+ }
967
+ }
968
+ }
969
+
970
+ .login-behavior-mask {
971
+ position: fixed;
972
+ z-index: 99;
973
+ top: 0;
974
+ right: 0;
975
+ bottom: 0;
976
+ left: 0;
977
+ display: flex;
978
+ align-items: center;
979
+ justify-content: center;
980
+ padding: 24rpx;
981
+ background: rgba(15, 23, 42, 0.38);
982
+ box-sizing: border-box;
983
+ }
984
+
985
+ .login-behavior-panel {
986
+ position: relative;
987
+ width: auto;
988
+ max-width: 100%;
989
+ padding: 24rpx;
990
+ border-radius: 28rpx;
991
+ background: #fff;
992
+ box-shadow: 0 24rpx 70rpx rgba(15, 23, 42, 0.18);
993
+ box-sizing: border-box;
994
+ }
995
+
996
+ .login-behavior-loading {
997
+ position: absolute;
998
+ z-index: 2;
999
+ top: 24rpx;
1000
+ right: 24rpx;
1001
+ left: 24rpx;
1002
+ height: 44rpx;
1003
+ font-size: 24rpx;
1004
+ line-height: 44rpx;
1005
+ text-align: center;
1006
+ color: #28bb9c;
1007
+ background: rgba(255, 255, 255, 0.9);
1008
+ border-radius: 999rpx;
1009
+ }
1010
+
1011
+ .login-behavior-panel .go-captcha .gc-header {
1012
+ height: auto;
1013
+ min-height: 40rpx;
1014
+ margin-bottom: 12rpx;
1015
+ }
1016
+
1017
+ .login-behavior-panel .go-captcha .gc-header .gc-text {
1018
+ font-size: 24rpx;
1019
+ font-weight: 500;
1020
+ line-height: 34rpx;
1021
+ white-space: nowrap;
1022
+ }
1023
+
1024
+ .login-behavior-panel .go-captcha .gc-body {
1025
+ margin-top: 0;
1026
+ border-radius: 18rpx;
1027
+ }
1028
+
1029
+ .login-behavior-panel .go-captcha .gc-footer {
1030
+ padding-top: 22rpx;
1031
+ }
1032
+
1033
+ .login-behavior-panel .go-captcha .gc-drag-slide-bar {
1034
+ height: 56rpx;
1035
+ }
1036
+
1037
+ .login-behavior-panel .go-captcha .gc-drag-line {
1038
+ height: 18rpx;
1039
+ background: #dbe7e3;
1040
+ border-radius: 999rpx;
1041
+ }
1042
+
1043
+ .login-behavior-panel .go-captcha .gc-drag-block {
1044
+ width: 92rpx;
1045
+ height: 56rpx;
1046
+ margin-top: -28rpx;
1047
+ background: linear-gradient(135deg, #36d6b4 0%, #20aa8f 100%);
1048
+ border-radius: 999rpx;
1049
+ box-shadow: 0 14rpx 28rpx rgba(40, 187, 156, 0.28);
1050
+ color: #fff;
1051
+ fill: #fff;
1052
+ }
1053
+
1054
+ .login-behavior-panel .go-captcha .gc-drag-block.disabled {
1055
+ background: #9adfce;
1056
+ box-shadow: none;
1057
+ }
1058
+
1059
+ .login-behavior-panel .go-captcha .gc-drag-block .gc-icon {
1060
+ color: #fff;
1061
+ font-size: 42rpx;
1062
+ }
1063
+
1064
+ .login-behavior-panel .go-captcha .gc-rotate-picture .gc-round {
1065
+ border-width: 8rpx;
1066
+ box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.78);
1067
+ }
1068
+
1069
+ .login-behavior-panel .go-captcha .gc-rotate-thumb-block {
1070
+ overflow: hidden;
1071
+ border: 4rpx solid rgba(255, 255, 255, 0.92);
1072
+ border-radius: 50%;
1073
+ box-shadow:
1074
+ 0 14rpx 30rpx rgba(15, 23, 42, 0.2),
1075
+ 0 0 0 2rpx rgba(15, 23, 42, 0.08);
1076
+ }
1077
+
1078
+ .login-behavior-panel .go-captcha .gc-rotate-thumb-block .gc-img {
1079
+ border-radius: 50%;
1080
+ }
1081
+
1082
+ .login-behavior-panel .go-captcha .gc-dots .gc-dot {
1083
+ font-size: 20rpx;
1084
+ font-weight: 600;
1085
+ border-width: 3rpx;
1086
+ box-shadow: 0 6rpx 14rpx rgba(15, 23, 42, 0.18);
1087
+ }
1088
+
1089
+ .login-tips {
1090
+ margin-top: 26rpx;
1091
+ font-size: 22rpx;
1092
+ color: #999;
1093
+ line-height: 1.6;
1094
+
1095
+ .login-agreement {
1096
+ display: flex;
1097
+ align-items: center;
1098
+ justify-content: center;
1099
+ gap: 6rpx;
1100
+ white-space: nowrap;
1101
+ }
1102
+
1103
+ .login-agree-separator {
1104
+ white-space: nowrap;
1105
+ }
1106
+
1107
+ .login-agree-icon {
1108
+ width: 26rpx;
1109
+ height: 26rpx;
1110
+ margin-right: 2rpx;
1111
+ border: 2rpx solid #d1d5db;
1112
+ border-radius: 50%;
1113
+ background-color: #fff;
1114
+ box-sizing: border-box;
1115
+ }
1116
+
1117
+ .login-agree-icon.checked {
1118
+ border-color: #28bb9c;
1119
+ background-color: #28bb9c;
1120
+ box-shadow: inset 0 0 0 6rpx #fff;
1121
+ }
1122
+
1123
+ .login-agree-link {
1124
+ white-space: nowrap;
1125
+ color: #28bb9c;
1126
+ }
1127
+
1128
+ .login-agree-desc {
1129
+ color: #9ca3af;
1130
+ white-space: nowrap;
1131
+ }
1132
+ }
1133
+ </style>