@liujitcn/kratos-taro-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 (77) hide show
  1. package/README.md +36 -0
  2. package/dist/build.mjs +38 -0
  3. package/dist/runner.mjs +321 -0
  4. package/package.json +51 -0
  5. package/src/api/base/config.ts +20 -0
  6. package/src/api/base/file.ts +57 -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 +32 -0
  14. package/src/build.ts +24 -0
  15. package/src/components/BootstrapStatus.scss +28 -0
  16. package/src/components/BootstrapStatus.tsx +25 -0
  17. package/src/components/KratosPageFrame.scss +41 -0
  18. package/src/components/KratosPageFrame.tsx +51 -0
  19. package/src/components/KratosTabBar.scss +34 -0
  20. package/src/components/KratosTabBar.tsx +45 -0
  21. package/src/index.ts +36 -0
  22. package/src/module.ts +63 -0
  23. package/src/navigation-pattern.d.mts +2 -0
  24. package/src/navigation-pattern.mjs +29 -0
  25. package/src/navigation-tree.d.mts +12 -0
  26. package/src/navigation-tree.mjs +45 -0
  27. package/src/navigation.ts +336 -0
  28. package/src/pages.ts +21 -0
  29. package/src/rpc/base/v1/config.ts +34 -0
  30. package/src/rpc/base/v1/file.ts +66 -0
  31. package/src/rpc/base/v1/login.ts +128 -0
  32. package/src/rpc/base/v1/mcp.ts +20 -0
  33. package/src/rpc/base/v1/oauth.ts +199 -0
  34. package/src/rpc/base/v1/sse.ts +22 -0
  35. package/src/rpc/common/v1/common.ts +71 -0
  36. package/src/rpc/common/v1/enum.ts +71 -0
  37. package/src/rpc/common/v1/types.ts +77 -0
  38. package/src/rpc/google/protobuf/empty.ts +19 -0
  39. package/src/rpc/google/protobuf/wrappers.ts +124 -0
  40. package/src/rpc/system/app/v1/auth.ts +54 -0
  41. package/src/rpc/system/app/v1/base_area.ts +24 -0
  42. package/src/rpc/system/app/v1/base_dict.ts +37 -0
  43. package/src/rpc/system/app/v1/base_menu.ts +61 -0
  44. package/src/rpc/system/common/v1/enum.ts +80 -0
  45. package/src/runner.ts +390 -0
  46. package/src/static/images/avatar.png +0 -0
  47. package/src/static/images/center_bg.png +0 -0
  48. package/src/static/images/logo_icon.png +0 -0
  49. package/src/static/images/navigator_bg.png +0 -0
  50. package/src/static/tabs/home_default.png +0 -0
  51. package/src/static/tabs/home_selected.png +0 -0
  52. package/src/static/tabs/user_default.png +0 -0
  53. package/src/static/tabs/user_selected.png +0 -0
  54. package/src/stores/index.ts +2 -0
  55. package/src/stores/setting.ts +43 -0
  56. package/src/stores/user.ts +152 -0
  57. package/src/styles/base.scss +45 -0
  58. package/src/types/assets.d.ts +9 -0
  59. package/src/utils/auth.ts +55 -0
  60. package/src/utils/file.ts +48 -0
  61. package/src/utils/http.ts +262 -0
  62. package/src/utils/index.ts +35 -0
  63. package/src/utils/navigation.ts +72 -0
  64. package/src/utils/passwordCrypto.ts +257 -0
  65. package/src/views/pages/index/index.scss +137 -0
  66. package/src/views/pages/index/index.tsx +74 -0
  67. package/src/views/pages/login/components/BehaviorCaptcha.h5.tsx +46 -0
  68. package/src/views/pages/login/components/BehaviorCaptcha.tsx +6 -0
  69. package/src/views/pages/login/components/BehaviorCaptcha.weapp.tsx +6 -0
  70. package/src/views/pages/login/components/types.ts +32 -0
  71. package/src/views/pages/login/login.scss +310 -0
  72. package/src/views/pages/login/login.tsx +493 -0
  73. package/src/views/pages/login/protocal.scss +84 -0
  74. package/src/views/pages/login/protocal.tsx +73 -0
  75. package/src/views/pages/status/index.tsx +42 -0
  76. package/src/views/pages/webview/webview.scss +43 -0
  77. package/src/views/pages/webview/webview.tsx +41 -0
@@ -0,0 +1,493 @@
1
+ import { Button, Image, Input, Text, View } from '@tarojs/components'
2
+ import Taro, { useLoad } from '@tarojs/taro'
3
+ import { useMemo, useRef, useState } from 'react'
4
+ import { defLoginService } from '../../../api/base/login'
5
+ import { resolveBundledAsset } from '../../../module'
6
+ import { navigateAppRoute } from '../../../navigation'
7
+ import type { LoginRequest } from '../../../rpc/base/v1/login'
8
+ import { useSettingStore, useUserStore } from '../../../stores'
9
+ import { consumeLoginRedirectUrl, homeTabPage } from '../../../utils/navigation'
10
+ import { encryptPassword, PASSWORD_CRYPTO_SCENE } from '../../../utils/passwordCrypto'
11
+ import BehaviorCaptcha from './components/BehaviorCaptcha'
12
+ import type {
13
+ BehaviorCaptchaData,
14
+ BehaviorCaptchaPoint,
15
+ } from './components/types'
16
+ import './login.scss'
17
+
18
+ const behaviorCaptchaTypes = new Set(['slide', 'click', 'rotate'])
19
+ const wechatMiniProvider = 'wechatmini'
20
+ const defaultLogo = resolveBundledAsset('static/images/logo_icon.png')
21
+
22
+ type BehaviorPayload = BehaviorCaptchaData & {
23
+ type?: string
24
+ width?: number
25
+ height?: number
26
+ }
27
+
28
+ const emptyLoginForm = (): LoginRequest => ({
29
+ tenant_code: '',
30
+ user_name: '',
31
+ password: undefined,
32
+ captcha_id: '',
33
+ captcha_code: '',
34
+ })
35
+
36
+ function isWechatUnboundError(error: unknown): boolean {
37
+ if (!error || typeof error !== 'object') return false
38
+ const response = error as {
39
+ data?: {
40
+ reason?: string | number
41
+ message?: string
42
+ binding_required?: boolean
43
+ error?: { reason?: string | number; message?: string }
44
+ }
45
+ }
46
+ const reason = response.data?.reason ?? response.data?.error?.reason
47
+ const message = response.data?.message ?? response.data?.error?.message
48
+ return (
49
+ response.data?.binding_required === true ||
50
+ String(reason || '') === 'UNAUTHENTICATED' ||
51
+ message === '微信账号未绑定,请先绑定已有账号'
52
+ )
53
+ }
54
+
55
+ async function resolveMiniCaptchaImage(payload: string, captchaId: string): Promise<string> {
56
+ if (!payload.startsWith('data:image/')) return payload
57
+ const commaIndex = payload.indexOf(',')
58
+ if (commaIndex < 0 || !Taro.env.USER_DATA_PATH) return payload
59
+ const filePath = `${Taro.env.USER_DATA_PATH}/kratos-login-captcha-${captchaId}.png`
60
+ await new Promise<void>((resolve, reject) => {
61
+ Taro.getFileSystemManager().writeFile({
62
+ filePath,
63
+ data: payload.slice(commaIndex + 1),
64
+ encoding: 'base64',
65
+ success: () => resolve(),
66
+ fail: () => reject(new Error('验证码图片写入失败')),
67
+ })
68
+ })
69
+ return filePath
70
+ }
71
+
72
+ /** 账号和微信授权登录页。 */
73
+ export default function LoginPage() {
74
+ const settings = useSettingStore((state) => state.data)
75
+ const loadSettings = useSettingStore((state) => state.loadData)
76
+ const userStore = useUserStore.getState()
77
+ const [agreed, setAgreed] = useState(false)
78
+ const [shake, setShake] = useState(false)
79
+ const [loading, setLoading] = useState(false)
80
+ const [settingsReady, setSettingsReady] = useState(false)
81
+ const settingsPromise = useRef<Promise<boolean>>()
82
+ const [form, setForm] = useState<LoginRequest>(emptyLoginForm)
83
+ const [password, setPassword] = useState('')
84
+ const [captchaImage, setCaptchaImage] = useState('')
85
+ const [captchaType, setCaptchaType] = useState('')
86
+ const [behaviorVisible, setBehaviorVisible] = useState(false)
87
+ const [behaviorLoading, setBehaviorLoading] = useState(false)
88
+ const [behaviorData, setBehaviorData] = useState<BehaviorCaptchaData>({ image: '', thumb: '' })
89
+ const [behaviorSource, setBehaviorSource] = useState({ width: 300, height: 220 })
90
+ const [miniBinding, setMiniBinding] = useState(false)
91
+ const [miniCaptchaImage, setMiniCaptchaImage] = useState('')
92
+ const [miniForm, setMiniForm] = useState(emptyLoginForm)
93
+ const [miniPassword, setMiniPassword] = useState('')
94
+
95
+ const mainTitle = settings?.get('mainTitle') || '应用框架示例'
96
+ const subTitle = settings?.get('subTitle') || '欢迎使用本应用'
97
+ const appLogo = settings?.get('appLogo') || defaultLogo
98
+ const showTenantCode = settings?.get('showTenantCode') !== 'false'
99
+ const configuredCaptchaType = settings?.get('captchaType') || ''
100
+ const isBehaviorCaptcha = behaviorCaptchaTypes.has(captchaType)
101
+ const behaviorWidth = Math.round(
102
+ Math.min(320, Math.max(280, (Taro.getSystemInfoSync().windowWidth || 375) * 0.86)),
103
+ )
104
+ const behaviorHeight = Math.round((behaviorSource.height * behaviorWidth) / behaviorSource.width)
105
+ const behaviorConfig = useMemo(() => {
106
+ if (captchaType === 'rotate') {
107
+ const size = Math.round(Math.min(300, behaviorWidth * 0.94))
108
+ return {
109
+ width: behaviorWidth,
110
+ height: size,
111
+ size,
112
+ showTheme: false,
113
+ verticalPadding: 0,
114
+ horizontalPadding: 0,
115
+ iconSize: 20,
116
+ title: '拖动滑块,将内圈图片转正',
117
+ }
118
+ }
119
+ return {
120
+ width: behaviorWidth,
121
+ height: behaviorHeight,
122
+ thumbWidth: behaviorData.thumbWidth ?? 60,
123
+ thumbHeight: behaviorData.thumbHeight ?? 60,
124
+ showTheme: false,
125
+ verticalPadding: 0,
126
+ horizontalPadding: 0,
127
+ buttonText: '确认',
128
+ iconSize: 20,
129
+ dotSize: 20,
130
+ title: captchaType === 'click' ? '请按顺序点击文字' : '请拖动滑块完成拼图',
131
+ }
132
+ }, [
133
+ behaviorData.thumbHeight,
134
+ behaviorData.thumbWidth,
135
+ behaviorHeight,
136
+ behaviorWidth,
137
+ captchaType,
138
+ ]) as unknown as Record<string, string | number | boolean>
139
+
140
+ const updateForm = (key: keyof LoginRequest, value: string) => {
141
+ setForm((current) => ({ ...current, [key]: value }))
142
+ }
143
+ const updateMiniForm = (key: keyof LoginRequest, value: string) => {
144
+ setMiniForm((current) => ({ ...current, [key]: value }))
145
+ }
146
+
147
+ const refreshCaptcha = async (requestedType = captchaType || configuredCaptchaType) => {
148
+ if (!requestedType) throw new Error('登录验证码类型未配置')
149
+ const data = await defLoginService.Captcha({ type: requestedType })
150
+ if (!data.type) throw new Error('验证码接口未返回类型')
151
+ setCaptchaType(data.type)
152
+ setForm((current) => ({ ...current, captcha_id: data.captcha_id, captcha_code: '' }))
153
+ if (!behaviorCaptchaTypes.has(data.type)) {
154
+ setBehaviorVisible(false)
155
+ setCaptchaImage(data.captcha_base64)
156
+ return true
157
+ }
158
+ const payload = JSON.parse(data.captcha_base64 || '{}') as BehaviorPayload
159
+ const source = {
160
+ width: payload.width || 300,
161
+ height: payload.height || (data.type === 'rotate' ? 300 : 220),
162
+ }
163
+ const displayHeight = Math.round((source.height * behaviorWidth) / source.width)
164
+ const scaleX = behaviorWidth / source.width
165
+ const scaleY = displayHeight / source.height
166
+ setBehaviorSource(source)
167
+ setCaptchaImage('')
168
+ setBehaviorData({
169
+ image: payload.image || '',
170
+ thumb: payload.thumb || '',
171
+ thumbX: Math.round((payload.thumbX ?? 0) * scaleX),
172
+ thumbY: Math.round((payload.thumbY ?? 0) * scaleY),
173
+ thumbWidth: Math.round((payload.thumbWidth ?? (data.type === 'click' ? 180 : 60)) * scaleX),
174
+ thumbHeight: Math.round((payload.thumbHeight ?? (data.type === 'click' ? 48 : 60)) * scaleY),
175
+ thumbSize: Math.round((payload.thumbSize ?? 150) * (behaviorWidth / source.width)),
176
+ angle: 0,
177
+ })
178
+ return true
179
+ }
180
+
181
+ const refreshMiniCaptcha = async () => {
182
+ try {
183
+ const captcha = await defLoginService.Captcha({ type: 'digit' })
184
+ setMiniCaptchaImage(
185
+ await resolveMiniCaptchaImage(captcha.captcha_base64, captcha.captcha_id),
186
+ )
187
+ setMiniForm((current) => ({
188
+ ...current,
189
+ captcha_id: captcha.captcha_id,
190
+ captcha_code: '',
191
+ }))
192
+ } catch {
193
+ await Taro.showToast({ icon: 'none', title: '验证码加载失败' })
194
+ }
195
+ }
196
+
197
+ const loadLoginSettings = (): Promise<boolean> => {
198
+ if (settingsReady) return Promise.resolve(true)
199
+ if (settingsPromise.current) return settingsPromise.current
200
+ settingsPromise.current = (async () => {
201
+ try {
202
+ await loadSettings()
203
+ const nextCaptchaType = useSettingStore.getState().getData('captchaType')
204
+ if (process.env.TARO_ENV === 'h5') {
205
+ if (!nextCaptchaType) throw new Error('登录验证码类型未配置')
206
+ setCaptchaType(nextCaptchaType)
207
+ if (!behaviorCaptchaTypes.has(nextCaptchaType)) await refreshCaptcha(nextCaptchaType)
208
+ } else {
209
+ await refreshMiniCaptcha()
210
+ }
211
+ setSettingsReady(true)
212
+ return true
213
+ } catch (error) {
214
+ await Taro.showToast({
215
+ icon: 'none',
216
+ title: error instanceof Error ? error.message : '移动端配置加载失败',
217
+ })
218
+ settingsPromise.current = undefined
219
+ return false
220
+ }
221
+ })()
222
+ return settingsPromise.current
223
+ }
224
+
225
+ useLoad(() => {
226
+ void loadLoginSettings()
227
+ })
228
+
229
+ const checkedAgreePrivacy = async (): Promise<boolean> => {
230
+ if (!(await loadLoginSettings())) return false
231
+ const currentSettings = useSettingStore.getState()
232
+ if (!currentSettings.getData('serviceProtocol') || !currentSettings.getData('privacyProtocol')) {
233
+ await Taro.showToast({ icon: 'none', title: '服务条款和隐私协议未配置' })
234
+ return false
235
+ }
236
+ if (agreed) return true
237
+ setShake(true)
238
+ setTimeout(() => setShake(false), 500)
239
+ const result = await Taro.showModal({
240
+ title: '提示',
241
+ content: '请先阅读并勾选协议内容,点击确定后将自动勾选并继续登录',
242
+ confirmText: '确定',
243
+ cancelText: '取消',
244
+ })
245
+ if (result.confirm) setAgreed(true)
246
+ return result.confirm
247
+ }
248
+
249
+ const loginSuccess = async () => {
250
+ await userStore.getUserProfile()
251
+ await Taro.showToast({ icon: 'success', title: '登录成功' })
252
+ setTimeout(() => {
253
+ const lastRoute = consumeLoginRedirectUrl() || homeTabPage
254
+ if (lastRoute.startsWith(homeTabPage)) Taro.setStorageSync('SwitchTabIndex', true)
255
+ void Taro.reLaunch({ url: lastRoute })
256
+ }, 500)
257
+ }
258
+
259
+ const validateLoginForm = async () => {
260
+ if (!(await loadLoginSettings())) return false
261
+ const validations: Array<[boolean, string]> = [
262
+ [!showTenantCode || Boolean(form.tenant_code), '请输入租户编号'],
263
+ [Boolean(form.user_name), '请输入用户名或手机号'],
264
+ [Boolean(password), '请输入密码'],
265
+ [isBehaviorCaptcha || Boolean(form.captcha_code), '请输入验证码'],
266
+ ]
267
+ const failed = validations.find(([valid]) => !valid)
268
+ if (failed) {
269
+ await Taro.showToast({ icon: 'none', title: failed[1] })
270
+ return false
271
+ }
272
+ return checkedAgreePrivacy()
273
+ }
274
+
275
+ const submitLogin = async (captchaCode: string) => {
276
+ const encrypted = await encryptPassword(password, PASSWORD_CRYPTO_SCENE.LOGIN)
277
+ await userStore.login({
278
+ ...form,
279
+ tenant_code: showTenantCode ? form.tenant_code : '0000',
280
+ password: encrypted,
281
+ captcha_code: captchaCode,
282
+ })
283
+ }
284
+
285
+ const onSubmit = async () => {
286
+ if (!(await validateLoginForm()) || loading) return
287
+ if (isBehaviorCaptcha) {
288
+ setBehaviorVisible(true)
289
+ setBehaviorLoading(true)
290
+ try {
291
+ await refreshCaptcha()
292
+ } finally {
293
+ setBehaviorLoading(false)
294
+ }
295
+ return
296
+ }
297
+ setLoading(true)
298
+ try {
299
+ await submitLogin(form.captcha_code)
300
+ await loginSuccess()
301
+ } catch (error) {
302
+ await refreshCaptcha().catch(() => undefined)
303
+ if (error instanceof Error) {
304
+ await Taro.showToast({ icon: 'none', title: error.message || '登录失败,请重试' })
305
+ }
306
+ } finally {
307
+ setLoading(false)
308
+ }
309
+ }
310
+
311
+ const verifyBehaviorCaptcha = async (
312
+ value: BehaviorCaptchaPoint | BehaviorCaptchaPoint[] | number,
313
+ reset: () => void,
314
+ ) => {
315
+ if (behaviorLoading) return
316
+ setBehaviorLoading(true)
317
+ try {
318
+ let captchaCode = String(value)
319
+ if (captchaType === 'click') {
320
+ captchaCode = JSON.stringify(
321
+ (value as BehaviorCaptchaPoint[]).map((dot) => ({
322
+ x: Math.round(dot.x * (behaviorSource.width / behaviorWidth)),
323
+ y: Math.round(dot.y * (behaviorSource.height / behaviorHeight)),
324
+ })),
325
+ )
326
+ } else if (captchaType === 'slide') {
327
+ captchaCode = String(
328
+ Math.round((value as BehaviorCaptchaPoint).x * (behaviorSource.width / behaviorWidth)),
329
+ )
330
+ } else {
331
+ captchaCode = String(Math.round(value as number))
332
+ }
333
+ const verified = await defLoginService.VerifyCaptcha({
334
+ captcha_id: form.captcha_id,
335
+ captcha_code: captchaCode,
336
+ })
337
+ setBehaviorVisible(false)
338
+ await submitLogin(verified.captcha_token)
339
+ await loginSuccess()
340
+ } catch {
341
+ reset()
342
+ await refreshCaptcha().catch(() => undefined)
343
+ } finally {
344
+ setBehaviorLoading(false)
345
+ }
346
+ }
347
+
348
+ const wxLogin = async () => {
349
+ if (!(await checkedAgreePrivacy()) || loading) return
350
+ setLoading(true)
351
+ try {
352
+ const code = (await Taro.login()).code
353
+ const session = await userStore.createOauthSession({ provider: wechatMiniProvider, code })
354
+ if (session.binding_required) {
355
+ setMiniBinding(true)
356
+ await refreshMiniCaptcha()
357
+ return
358
+ }
359
+ await loginSuccess()
360
+ } catch (error) {
361
+ if (isWechatUnboundError(error)) {
362
+ setMiniBinding(true)
363
+ await refreshMiniCaptcha()
364
+ } else {
365
+ await Taro.showToast({ icon: 'none', title: '微信登录失败,请稍后重试' })
366
+ }
367
+ } finally {
368
+ setLoading(false)
369
+ }
370
+ }
371
+
372
+ const bindMiniAccount = async () => {
373
+ if (loading || !(await checkedAgreePrivacy())) return
374
+ const failed = [
375
+ [showTenantCode && !miniForm.tenant_code, '请输入租户编号'],
376
+ [!miniForm.user_name || !miniPassword, '请输入用户名和密码'],
377
+ [!miniForm.captcha_id || !miniForm.captcha_code, '请输入验证码'],
378
+ ].find(([invalid]) => invalid)
379
+ if (failed) {
380
+ await Taro.showToast({ icon: 'none', title: String(failed[1]) })
381
+ return
382
+ }
383
+ setLoading(true)
384
+ try {
385
+ const encrypted = await encryptPassword(miniPassword, PASSWORD_CRYPTO_SCENE.LOGIN)
386
+ const code = (await Taro.login()).code
387
+ await userStore.bindOauthSession({
388
+ provider: wechatMiniProvider,
389
+ code,
390
+ tenant_code: showTenantCode ? miniForm.tenant_code : '0000',
391
+ user_name: miniForm.user_name,
392
+ password: encrypted,
393
+ captcha_code: miniForm.captcha_code,
394
+ captcha_id: miniForm.captcha_id,
395
+ })
396
+ await loginSuccess()
397
+ } catch (error) {
398
+ await refreshMiniCaptcha()
399
+ if (error instanceof Error) await Taro.showToast({ icon: 'none', title: error.message })
400
+ } finally {
401
+ setLoading(false)
402
+ }
403
+ }
404
+
405
+ const agreement = (
406
+ <View className={`login-tips${shake ? ' login-tips--shake' : ''}`}>
407
+ <View className='login-agreement' onClick={() => setAgreed((value) => !value)}>
408
+ <View className={`login-agree-icon${agreed ? ' checked' : ''}`} />
409
+ <Text className='login-agree-desc'>我已阅读并同意</Text>
410
+ <Text
411
+ className='login-agree-link'
412
+ onClick={(event) => {
413
+ event.stopPropagation()
414
+ navigateAppRoute('app/protocol/service')
415
+ }}
416
+ >
417
+ 《服务条款》
418
+ </Text>
419
+ <Text className='login-agree-separator'>和</Text>
420
+ <Text
421
+ className='login-agree-link'
422
+ onClick={(event) => {
423
+ event.stopPropagation()
424
+ navigateAppRoute('app/protocol/privacy')
425
+ }}
426
+ >
427
+ 《隐私协议》
428
+ </Text>
429
+ </View>
430
+ </View>
431
+ )
432
+
433
+ return (
434
+ <View className='login-page'>
435
+ <View className='login-hero'>
436
+ <View className='login-logo-shell'>
437
+ <Image className='login-logo' src={appLogo} />
438
+ </View>
439
+ <View className='login-hero-copy'>
440
+ <Text className='login-title'>{mainTitle}</Text>
441
+ <Text className='login-subtitle'>{subTitle}</Text>
442
+ </View>
443
+ </View>
444
+ <View className='login-panel'>
445
+ {process.env.TARO_ENV === 'h5' ? (
446
+ <View className='login-form'>
447
+ {showTenantCode ? (
448
+ <Input className='login-input' placeholder='请输入租户编号' value={form.tenant_code} onInput={(event) => updateForm('tenant_code', event.detail.value)} />
449
+ ) : null}
450
+ <Input className='login-input' placeholder='请输入用户名/手机号码' value={form.user_name} onInput={(event) => updateForm('user_name', event.detail.value)} />
451
+ <Input className='login-input' password placeholder='请输入密码' value={password} onInput={(event) => setPassword(event.detail.value)} onConfirm={() => void onSubmit()} />
452
+ {!isBehaviorCaptcha ? (
453
+ <View className='captcha-row'>
454
+ <Input className='login-input captcha-input' placeholder='请输入验证码' value={form.captcha_code} onInput={(event) => updateForm('captcha_code', event.detail.value)} onConfirm={() => void onSubmit()} />
455
+ <View className='captcha-divider' />
456
+ <View className='captcha-trigger' onClick={() => void refreshCaptcha()}>
457
+ <Image className='captcha-image' src={captchaImage} mode='aspectFit' />
458
+ </View>
459
+ </View>
460
+ ) : null}
461
+ <Button className='login-button login-button-primary' loading={loading} onClick={() => void onSubmit()}>登录</Button>
462
+ {behaviorVisible ? (
463
+ <View className='login-behavior-mask'>
464
+ <View className='login-behavior-panel'>
465
+ {behaviorLoading ? <View className='login-behavior-loading'>加载中...</View> : null}
466
+ <BehaviorCaptcha type={captchaType} data={behaviorData} config={behaviorConfig} onConfirm={(value, reset) => void verifyBehaviorCaptcha(value, reset)} onRefresh={() => void refreshCaptcha()} onClose={() => setBehaviorVisible(false)} />
467
+ </View>
468
+ </View>
469
+ ) : null}
470
+ </View>
471
+ ) : !miniBinding ? (
472
+ <Button className='login-button login-button-primary' loading={loading} onClick={() => void wxLogin()}>
473
+ <Text className='login-phone-icon'>☎</Text>微信一键登录
474
+ </Button>
475
+ ) : (
476
+ <View className='login-form'>
477
+ <View className='login-bind-tip'>该微信尚未绑定,请登录已有账号完成绑定</View>
478
+ {showTenantCode ? <Input className='login-input' placeholder='请输入租户编号' value={miniForm.tenant_code} onInput={(event) => updateMiniForm('tenant_code', event.detail.value)} /> : null}
479
+ <Input className='login-input' placeholder='请输入用户名/手机号码' value={miniForm.user_name} onInput={(event) => updateMiniForm('user_name', event.detail.value)} />
480
+ <Input className='login-input' password placeholder='请输入密码' value={miniPassword} onInput={(event) => setMiniPassword(event.detail.value)} />
481
+ <View className='captcha-row'>
482
+ <Input className='login-input captcha-input' placeholder='请输入验证码' value={miniForm.captcha_code} onInput={(event) => updateMiniForm('captcha_code', event.detail.value)} />
483
+ <View className='captcha-divider' />
484
+ <View className='captcha-trigger' onClick={() => void refreshMiniCaptcha()}><Image className='captcha-image' src={miniCaptchaImage} mode='aspectFit' /></View>
485
+ </View>
486
+ <Button className='login-button login-button-primary' loading={loading} onClick={() => void bindMiniAccount()}>登录并绑定微信</Button>
487
+ </View>
488
+ )}
489
+ {agreement}
490
+ </View>
491
+ </View>
492
+ )
493
+ }
@@ -0,0 +1,84 @@
1
+ .protocol-page {
2
+ min-height: 100vh;
3
+ padding: 24px;
4
+ box-sizing: border-box;
5
+ background-color: #f5f7f6;
6
+ }
7
+
8
+ .protocol-card {
9
+ overflow: hidden;
10
+ padding: 40px 32px 56px;
11
+ border: 1px solid #edf1ef;
12
+ border-radius: 16px;
13
+ background-color: #fff;
14
+ box-shadow: 0 8px 24px rgba(33, 54, 48, 0.05);
15
+ }
16
+
17
+ .protocol-content {
18
+ display: block;
19
+ color: #53635d;
20
+ font-size: 28px;
21
+ line-height: 1.85;
22
+ word-break: break-word;
23
+
24
+ .protocol-title {
25
+ margin: 0 0 28px;
26
+ color: #1f2d29;
27
+ font-size: 42px;
28
+ font-weight: 700;
29
+ line-height: 1.35;
30
+ }
31
+
32
+ .protocol-section-title {
33
+ margin: 38px 0 14px;
34
+ padding-left: 16px;
35
+ border-left: 6px solid #27ba9b;
36
+ color: #263a34;
37
+ font-size: 31px;
38
+ font-weight: 600;
39
+ line-height: 1.45;
40
+ }
41
+
42
+ .protocol-paragraph {
43
+ margin: 0 0 22px;
44
+ color: #53635d;
45
+ font-size: 28px;
46
+ line-height: 1.85;
47
+ }
48
+
49
+ .protocol-list {
50
+ margin: 0 0 22px;
51
+ padding-left: 42px;
52
+ }
53
+
54
+ .protocol-list-item {
55
+ margin-bottom: 10px;
56
+ padding-left: 6px;
57
+ line-height: 1.8;
58
+ }
59
+
60
+ .protocol-emphasis {
61
+ color: #263a34;
62
+ font-weight: 600;
63
+ }
64
+
65
+ .protocol-link {
66
+ color: #16806d;
67
+ text-decoration: underline;
68
+ }
69
+
70
+ .protocol-image {
71
+ display: block;
72
+ max-width: 100%;
73
+ height: auto;
74
+ margin: 24px auto;
75
+ }
76
+
77
+ .protocol-quote {
78
+ margin: 24px 0;
79
+ padding: 18px 22px;
80
+ border-left: 6px solid #b8e8dc;
81
+ color: #6c7b75;
82
+ background-color: #f3fbf8;
83
+ }
84
+ }
@@ -0,0 +1,73 @@
1
+ import { RichText, View } from '@tarojs/components'
2
+ import Taro, { useLoad } from '@tarojs/taro'
3
+ import { useState } from 'react'
4
+ import { useSettingStore } from '../../../stores'
5
+ import './protocal.scss'
6
+
7
+ const protocolClassMap: Record<string, string> = {
8
+ h2: 'protocol-title',
9
+ h3: 'protocol-section-title',
10
+ h4: 'protocol-section-title',
11
+ p: 'protocol-paragraph',
12
+ ul: 'protocol-list',
13
+ ol: 'protocol-list',
14
+ li: 'protocol-list-item',
15
+ strong: 'protocol-emphasis',
16
+ b: 'protocol-emphasis',
17
+ a: 'protocol-link',
18
+ img: 'protocol-image',
19
+ blockquote: 'protocol-quote',
20
+ }
21
+
22
+ function decorateProtocolContent(html: string): string {
23
+ return html.replace(
24
+ /<(h2|h3|h4|p|ul|ol|li|strong|b|a|img|blockquote)(\s[^>]*)?>/gi,
25
+ (match, tagName: string, attrs = '') => {
26
+ const className = protocolClassMap[tagName.toLowerCase()]
27
+ const classAttribute = attrs.match(/\bclass\s*=\s*(["'])(.*?)\1/i)
28
+ if (classAttribute) {
29
+ const nextAttrs = attrs.replace(
30
+ classAttribute[0],
31
+ `class="${classAttribute[2]} ${className}"`,
32
+ )
33
+ return `<${tagName}${nextAttrs}>`
34
+ }
35
+ return `<${tagName} class="${className}"${attrs}>`
36
+ },
37
+ )
38
+ }
39
+
40
+ /** 服务条款与隐私协议页。 */
41
+ export default function ProtocolPage() {
42
+ const [content, setContent] = useState('')
43
+
44
+ useLoad((query) => {
45
+ const isPrivacy = query?.type === 'privacy'
46
+ const title = isPrivacy ? '隐私协议' : '服务条款'
47
+ const key = isPrivacy ? 'privacyProtocol' : 'serviceProtocol'
48
+ const settingStore = useSettingStore.getState()
49
+ void settingStore
50
+ .loadData()
51
+ .then(() => {
52
+ const protocol = settingStore.getData(key)
53
+ if (!protocol) throw new Error(`${title}未配置,暂时无法查看`)
54
+ void Taro.setNavigationBarTitle({ title })
55
+ setContent(decorateProtocolContent(protocol))
56
+ })
57
+ .catch(async (error: unknown) => {
58
+ await Taro.showToast({
59
+ icon: 'none',
60
+ title: error instanceof Error ? error.message : '移动端配置加载失败',
61
+ })
62
+ setTimeout(() => void Taro.navigateBack(), 300)
63
+ })
64
+ })
65
+
66
+ return (
67
+ <View className='protocol-page'>
68
+ <View className='protocol-card'>
69
+ <RichText className='protocol-content' nodes={content} />
70
+ </View>
71
+ </View>
72
+ )
73
+ }
@@ -0,0 +1,42 @@
1
+ import { useLoad } from '@tarojs/taro'
2
+ import { useState } from 'react'
3
+ import BootstrapStatus from '../../../components/BootstrapStatus'
4
+ import {
5
+ initializeAppNavigation,
6
+ launchAppStatus,
7
+ navigateAppRoute,
8
+ } from '../../../navigation'
9
+ import type { BootstrapViewKey } from '../../../module'
10
+
11
+ const stateTitles: Record<BootstrapViewKey, string> = {
12
+ BOOTSTRAP_LOADING: '正在加载',
13
+ FORBIDDEN: '暂无访问权限',
14
+ NOT_FOUND: '页面不存在',
15
+ OFFLINE: '网络不可用',
16
+ CONFIG_ERROR: '导航配置无效',
17
+ PAGE_UNAVAILABLE: '页面暂不可用',
18
+ }
19
+
20
+ /** 启动与错误状态页。 */
21
+ export default function StatusPage() {
22
+ const [state, setState] = useState<BootstrapViewKey>('BOOTSTRAP_LOADING')
23
+ const [detail, setDetail] = useState('')
24
+
25
+ useLoad((options) => {
26
+ const nextState = (options?.state as BootstrapViewKey | undefined) ?? 'BOOTSTRAP_LOADING'
27
+ setState(nextState)
28
+ setDetail(options?.detail ? decodeURIComponent(options.detail) : '')
29
+ if (options?.bootstrap !== '1') return
30
+ void initializeAppNavigation()
31
+ .then(() => {
32
+ navigateAppRoute(options?.route ? decodeURIComponent(options.route) : 'app/home', {
33
+ replace: true,
34
+ })
35
+ })
36
+ .catch((error: unknown) => {
37
+ launchAppStatus('CONFIG_ERROR', error instanceof Error ? error.message : String(error))
38
+ })
39
+ })
40
+
41
+ return <BootstrapStatus title={stateTitles[state]} detail={detail} />
42
+ }