@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.
- package/README.md +36 -0
- package/dist/build.mjs +38 -0
- package/dist/runner.mjs +321 -0
- package/package.json +51 -0
- package/src/api/base/config.ts +20 -0
- package/src/api/base/file.ts +57 -0
- package/src/api/base/login.ts +87 -0
- package/src/api/base/menu.ts +17 -0
- package/src/api/base/oauth.ts +149 -0
- package/src/api/system/auth.ts +54 -0
- package/src/api/system/base_area.ts +34 -0
- package/src/api/system/base_dict.ts +24 -0
- package/src/bootstrap.ts +32 -0
- package/src/build.ts +24 -0
- package/src/components/BootstrapStatus.scss +28 -0
- package/src/components/BootstrapStatus.tsx +25 -0
- package/src/components/KratosPageFrame.scss +41 -0
- package/src/components/KratosPageFrame.tsx +51 -0
- package/src/components/KratosTabBar.scss +34 -0
- package/src/components/KratosTabBar.tsx +45 -0
- package/src/index.ts +36 -0
- package/src/module.ts +63 -0
- package/src/navigation-pattern.d.mts +2 -0
- package/src/navigation-pattern.mjs +29 -0
- package/src/navigation-tree.d.mts +12 -0
- package/src/navigation-tree.mjs +45 -0
- package/src/navigation.ts +336 -0
- package/src/pages.ts +21 -0
- package/src/rpc/base/v1/config.ts +34 -0
- package/src/rpc/base/v1/file.ts +66 -0
- package/src/rpc/base/v1/login.ts +128 -0
- package/src/rpc/base/v1/mcp.ts +20 -0
- package/src/rpc/base/v1/oauth.ts +199 -0
- package/src/rpc/base/v1/sse.ts +22 -0
- package/src/rpc/common/v1/common.ts +71 -0
- package/src/rpc/common/v1/enum.ts +71 -0
- package/src/rpc/common/v1/types.ts +77 -0
- package/src/rpc/google/protobuf/empty.ts +19 -0
- package/src/rpc/google/protobuf/wrappers.ts +124 -0
- package/src/rpc/system/app/v1/auth.ts +54 -0
- package/src/rpc/system/app/v1/base_area.ts +24 -0
- package/src/rpc/system/app/v1/base_dict.ts +37 -0
- package/src/rpc/system/app/v1/base_menu.ts +61 -0
- package/src/rpc/system/common/v1/enum.ts +80 -0
- package/src/runner.ts +390 -0
- package/src/static/images/avatar.png +0 -0
- package/src/static/images/center_bg.png +0 -0
- package/src/static/images/logo_icon.png +0 -0
- package/src/static/images/navigator_bg.png +0 -0
- package/src/static/tabs/home_default.png +0 -0
- package/src/static/tabs/home_selected.png +0 -0
- package/src/static/tabs/user_default.png +0 -0
- package/src/static/tabs/user_selected.png +0 -0
- package/src/stores/index.ts +2 -0
- package/src/stores/setting.ts +43 -0
- package/src/stores/user.ts +152 -0
- package/src/styles/base.scss +45 -0
- package/src/types/assets.d.ts +9 -0
- package/src/utils/auth.ts +55 -0
- package/src/utils/file.ts +48 -0
- package/src/utils/http.ts +262 -0
- package/src/utils/index.ts +35 -0
- package/src/utils/navigation.ts +72 -0
- package/src/utils/passwordCrypto.ts +257 -0
- package/src/views/pages/index/index.scss +137 -0
- package/src/views/pages/index/index.tsx +74 -0
- package/src/views/pages/login/components/BehaviorCaptcha.h5.tsx +46 -0
- package/src/views/pages/login/components/BehaviorCaptcha.tsx +6 -0
- package/src/views/pages/login/components/BehaviorCaptcha.weapp.tsx +6 -0
- package/src/views/pages/login/components/types.ts +32 -0
- package/src/views/pages/login/login.scss +310 -0
- package/src/views/pages/login/login.tsx +493 -0
- package/src/views/pages/login/protocal.scss +84 -0
- package/src/views/pages/login/protocal.tsx +73 -0
- package/src/views/pages/status/index.tsx +42 -0
- package/src/views/pages/webview/webview.scss +43 -0
- 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
|
+
}
|