@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,152 @@
1
+ import Taro from '@tarojs/taro'
2
+ import { create } from 'zustand'
3
+ import { defLoginService } from '../api/base/login'
4
+ import { defOauthService } from '../api/base/oauth'
5
+ import { defAuthService } from '../api/system/auth'
6
+ import type { LoginRequest, LoginResponse } from '../rpc/base/v1/login'
7
+ import type {
8
+ BindOauthSessionRequest,
9
+ CreateOauthSessionRequest,
10
+ CreateOauthSessionResponse,
11
+ } from '../rpc/base/v1/oauth'
12
+ import type { UserProfileForm } from '../rpc/system/app/v1/auth'
13
+ import {
14
+ clearToken,
15
+ getRefreshToken,
16
+ hasValidToken,
17
+ setRefreshToken,
18
+ setToken,
19
+ setTokenExpiresIn,
20
+ } from '../utils/auth'
21
+ import { AUTH_SILENT_LOGOUT_EVENT } from '../utils/http'
22
+
23
+ const USER_STORAGE_KEY = 'user'
24
+
25
+ /** 用户状态扩展生命周期处理器。 */
26
+ export interface UserStoreExtension {
27
+ /** 登录令牌保存完成后执行。 */
28
+ onLogin?: () => void | Promise<void>
29
+ /** 用户主动登出并清理本地状态后执行。 */
30
+ onLogout?: () => void | Promise<void>
31
+ /** 令牌失效并静默清理本地状态后执行。 */
32
+ onSilentLogout?: () => void | Promise<void>
33
+ }
34
+
35
+ /** 用户状态及操作。 */
36
+ export interface UserStoreState {
37
+ userInfo?: UserProfileForm
38
+ hydrated: boolean
39
+ hydrate: () => void
40
+ isAuthenticated: () => boolean
41
+ applyLoginToken: (data: LoginResponse | CreateOauthSessionResponse) => Promise<void>
42
+ login: (request: LoginRequest) => Promise<void>
43
+ createOauthSession: (
44
+ request: CreateOauthSessionRequest,
45
+ ) => Promise<CreateOauthSessionResponse>
46
+ bindOauthSession: (request: BindOauthSessionRequest) => Promise<void>
47
+ getUserProfile: () => Promise<UserProfileForm>
48
+ logout: () => Promise<void>
49
+ refreshToken: () => Promise<void>
50
+ clearUserData: () => Promise<void>
51
+ silentLogout: () => void
52
+ ensureAuthenticated: () => boolean
53
+ }
54
+
55
+ const userStoreExtensions = new Set<UserStoreExtension>()
56
+ let eventBridgeStarted = false
57
+
58
+ /** 注册用户 Store 的业务扩展,返回注销函数。 */
59
+ export function registerUserStoreExtension(extension: UserStoreExtension): () => void {
60
+ userStoreExtensions.add(extension)
61
+ return () => userStoreExtensions.delete(extension)
62
+ }
63
+
64
+ async function runUserStoreExtensions(event: keyof UserStoreExtension): Promise<void> {
65
+ for (const extension of userStoreExtensions) {
66
+ try {
67
+ await extension[event]?.()
68
+ } catch (error) {
69
+ console.warn(`user store ${event} extension failed`, error)
70
+ }
71
+ }
72
+ }
73
+
74
+ function persistUserInfo(userInfo?: UserProfileForm): void {
75
+ if (userInfo) Taro.setStorageSync(USER_STORAGE_KEY, { userInfo })
76
+ else Taro.removeStorageSync(USER_STORAGE_KEY)
77
+ }
78
+
79
+ /** 用户 Zustand Store。 */
80
+ export const useUserStore = create<UserStoreState>((set, get) => ({
81
+ userInfo: undefined,
82
+ hydrated: false,
83
+ hydrate() {
84
+ const cached = Taro.getStorageSync<{ userInfo?: UserProfileForm }>(USER_STORAGE_KEY)
85
+ set({ userInfo: cached?.userInfo, hydrated: true })
86
+ },
87
+ isAuthenticated() {
88
+ return Boolean(get().userInfo && hasValidToken())
89
+ },
90
+ async applyLoginToken(data) {
91
+ setToken(`${data.token_type} ${data.access_token}`)
92
+ setRefreshToken(data.refresh_token)
93
+ setTokenExpiresIn(data.expires_in)
94
+ await runUserStoreExtensions('onLogin')
95
+ },
96
+ async login(request) {
97
+ await get().applyLoginToken(await defLoginService.Login(request))
98
+ },
99
+ async createOauthSession(request) {
100
+ const response = await defOauthService.CreateOauthSession(request)
101
+ if (!response.binding_required) await get().applyLoginToken(response)
102
+ return response
103
+ },
104
+ async bindOauthSession(request) {
105
+ await get().applyLoginToken(await defOauthService.BindOauthSession(request))
106
+ },
107
+ async getUserProfile() {
108
+ const profile = await defAuthService.GetUserProfile({})
109
+ if (!profile) throw new Error('Verification failed, please Login again.')
110
+ set({ userInfo: profile })
111
+ persistUserInfo(profile)
112
+ return profile
113
+ },
114
+ async logout() {
115
+ await defLoginService.Logout({})
116
+ await get().clearUserData()
117
+ },
118
+ async refreshToken() {
119
+ const response = await defLoginService.RefreshToken({ refresh_token: getRefreshToken() })
120
+ setToken(`${response.token_type} ${response.access_token}`)
121
+ setRefreshToken(response.refresh_token)
122
+ setTokenExpiresIn(response.expires_in)
123
+ },
124
+ async clearUserData() {
125
+ clearToken()
126
+ persistUserInfo()
127
+ set({ userInfo: undefined })
128
+ await runUserStoreExtensions('onLogout')
129
+ },
130
+ silentLogout() {
131
+ clearToken()
132
+ persistUserInfo()
133
+ set({ userInfo: undefined })
134
+ void runUserStoreExtensions('onSilentLogout')
135
+ },
136
+ ensureAuthenticated() {
137
+ if (get().isAuthenticated()) return true
138
+ get().silentLogout()
139
+ return false
140
+ },
141
+ }))
142
+
143
+ /** 启动请求层与用户 Store 之间的静默登出事件桥。 */
144
+ export function startUserStoreEventBridge(): void {
145
+ if (eventBridgeStarted) return
146
+ eventBridgeStarted = true
147
+ Taro.eventCenter.on(AUTH_SILENT_LOGOUT_EVENT, () => {
148
+ persistUserInfo()
149
+ useUserStore.setState({ userInfo: undefined })
150
+ void runUserStoreExtensions('onSilentLogout')
151
+ })
152
+ }
@@ -0,0 +1,45 @@
1
+ page,
2
+ body {
3
+ min-height: 100%;
4
+ color: #333;
5
+ font-size: 16PX;
6
+ line-height: normal;
7
+ background: #f7f7f8;
8
+ font-family:
9
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
10
+ 'Microsoft YaHei', sans-serif;
11
+ }
12
+
13
+ view,
14
+ scroll-view,
15
+ swiper,
16
+ button,
17
+ input,
18
+ textarea,
19
+ label,
20
+ navigator,
21
+ image {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ button::after {
26
+ border: 0;
27
+ }
28
+
29
+ // Taro H5 的默认确认色与 uni-app H5 不同,统一为基线平台色。
30
+ .taro-model__confirm {
31
+ color: #007aff !important;
32
+ }
33
+
34
+ // H5 路由页和 ScrollView 同时滚动,隐藏滚动条避免重复占用内容宽度。
35
+ .taro_page,
36
+ .taro-scroll-view__scroll-y {
37
+ scrollbar-width: none;
38
+ }
39
+
40
+ .taro_page::-webkit-scrollbar,
41
+ .taro-scroll-view__scroll-y::-webkit-scrollbar {
42
+ display: none;
43
+ width: 0;
44
+ height: 0;
45
+ }
@@ -0,0 +1,9 @@
1
+ declare module '*.png' {
2
+ const source: string
3
+ export default source
4
+ }
5
+
6
+ declare module '*.jpg' {
7
+ const source: string
8
+ export default source
9
+ }
@@ -0,0 +1,55 @@
1
+ import Taro from '@tarojs/taro'
2
+
3
+ const ACCESS_TOKEN_KEY = 'access_token'
4
+ const ACCESS_TOKEN_EXPIRES_IN = 'expiresIn'
5
+ const REFRESH_TOKEN_KEY = 'refresh_token'
6
+ const TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000
7
+
8
+ /** 读取访问令牌。 */
9
+ export function getToken(): string {
10
+ return Taro.getStorageSync<string>(ACCESS_TOKEN_KEY) || ''
11
+ }
12
+
13
+ /** 保存访问令牌。 */
14
+ export function setToken(token: string): void {
15
+ Taro.setStorageSync(ACCESS_TOKEN_KEY, token)
16
+ }
17
+
18
+ /** 读取访问令牌过期时间戳。 */
19
+ export function getTokenExpiresIn(): number {
20
+ return Number(Taro.getStorageSync<string>(ACCESS_TOKEN_EXPIRES_IN) || '')
21
+ }
22
+
23
+ /** 按后端返回的秒数保存访问令牌过期时间。 */
24
+ export function setTokenExpiresIn(expiresIn: number): void {
25
+ Taro.setStorageSync(ACCESS_TOKEN_EXPIRES_IN, String(Date.now() + expiresIn * 1000))
26
+ }
27
+
28
+ /** 读取刷新令牌。 */
29
+ export function getRefreshToken(): string {
30
+ return Taro.getStorageSync<string>(REFRESH_TOKEN_KEY) || ''
31
+ }
32
+
33
+ /** 保存刷新令牌。 */
34
+ export function setRefreshToken(token: string): void {
35
+ Taro.setStorageSync(REFRESH_TOKEN_KEY, token)
36
+ }
37
+
38
+ /** 清理本地认证令牌。 */
39
+ export function clearToken(): void {
40
+ Taro.removeStorageSync(ACCESS_TOKEN_KEY)
41
+ Taro.removeStorageSync(REFRESH_TOKEN_KEY)
42
+ Taro.removeStorageSync(ACCESS_TOKEN_EXPIRES_IN)
43
+ }
44
+
45
+ /** 判断本地访问令牌是否仍在有效期内。 */
46
+ export function hasValidToken(): boolean {
47
+ const expiresIn = getTokenExpiresIn()
48
+ return Boolean(getToken() && expiresIn && expiresIn > Date.now())
49
+ }
50
+
51
+ /** 判断访问令牌是否即将过期,供请求层提前刷新。 */
52
+ export function shouldRefreshToken(): boolean {
53
+ const expiresIn = getTokenExpiresIn()
54
+ return Boolean(getToken() && expiresIn && expiresIn - Date.now() <= TOKEN_REFRESH_THRESHOLD)
55
+ }
@@ -0,0 +1,48 @@
1
+ import Taro from '@tarojs/taro'
2
+ import type { FileInfo, MultiUploadFileResponse } from '../rpc/base/v1/file'
3
+ import { formatSrc } from './index'
4
+ import { getRequestAccessToken, requestBaseURL } from './http'
5
+
6
+ /** 上传单个文件。 */
7
+ export async function uploadFile(fileType: string, filePath: string): Promise<FileInfo> {
8
+ const token = await getRequestAccessToken()
9
+ const response = await Taro.uploadFile({
10
+ url: `${requestBaseURL}/v1/base/file`,
11
+ name: 'file',
12
+ filePath,
13
+ formData: { fileType },
14
+ header: token ? { Authorization: token, 'source-client': 'miniapp' } : {},
15
+ })
16
+ if (response.statusCode !== 200) throw new Error('上传失败')
17
+ return JSON.parse(response.data) as FileInfo
18
+ }
19
+
20
+ /** 并发上传文件列表。 */
21
+ export async function uploadFileList(fileType: string, filePaths: string[]): Promise<FileInfo[]> {
22
+ const results = await Promise.allSettled(filePaths.map((filePath) => uploadFile(fileType, filePath)))
23
+ return results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
24
+ }
25
+
26
+ /** 使用微信小程序 files 参数上传多个文件。 */
27
+ export async function multiUploadFile(
28
+ fileType: string,
29
+ files: Array<{ path: string; name?: string }> = [],
30
+ ): Promise<FileInfo[]> {
31
+ const uploaded = await uploadFileList(
32
+ fileType,
33
+ files.map((file) => file.path),
34
+ )
35
+ const response: MultiUploadFileResponse = { files: uploaded }
36
+ return response.files
37
+ }
38
+
39
+ /** 从 URL 构建文件元信息。 */
40
+ export function getFileInfo(url: string): FileInfo {
41
+ const fullName = url.split(/[\\/]/).pop() || ''
42
+ if (!fullName) return { name: '', extname: '', url }
43
+ const dotIndex = fullName.lastIndexOf('.')
44
+ if (dotIndex > 0) {
45
+ return { name: fullName, extname: fullName.slice(dotIndex), url: formatSrc(url) }
46
+ }
47
+ return { name: fullName, extname: '', url }
48
+ }
@@ -0,0 +1,262 @@
1
+ import Taro from '@tarojs/taro'
2
+ import {
3
+ clearToken,
4
+ getRefreshToken,
5
+ getToken,
6
+ hasValidToken,
7
+ setRefreshToken,
8
+ setToken,
9
+ setTokenExpiresIn,
10
+ shouldRefreshToken,
11
+ } from './auth'
12
+ import { saveCurrentRoute } from './navigation'
13
+
14
+ const apiBasePath = process.env.KRATOS_TARO_API_BASE || '/api'
15
+ const apiTargetUrl = process.env.KRATOS_TARO_API_URL || ''
16
+ const normalizedApiBasePath = apiBasePath.startsWith('/') ? apiBasePath : `/${apiBasePath}`
17
+ const requestOrigin = process.env.TARO_ENV === 'h5' ? '' : apiTargetUrl.replace(/\/$/, '')
18
+ /** 请求基础地址。 */
19
+ export const requestBaseURL = `${requestOrigin}${normalizedApiBasePath}`
20
+
21
+ const SESSION_URL = '/v1/base/session'
22
+ const REFRESH_TOKEN_URL = '/v1/base/token'
23
+ const CAPTCHA_URL = '/v1/base/captcha'
24
+ const CONFIG_URL = '/v1/base/config'
25
+ const PASSWORD_PUBLIC_KEY_URL = '/v1/base/password-public-key'
26
+ const NO_AUTH_URL_SET = new Set([
27
+ SESSION_URL,
28
+ REFRESH_TOKEN_URL,
29
+ CAPTCHA_URL,
30
+ CONFIG_URL,
31
+ PASSWORD_PUBLIC_KEY_URL,
32
+ '/auth',
33
+ '/login/captcha',
34
+ '/auth/token',
35
+ ])
36
+ const AUTH_EXPIRED_EXCLUDED_URL_SET = new Set([
37
+ SESSION_URL,
38
+ CAPTCHA_URL,
39
+ CONFIG_URL,
40
+ PASSWORD_PUBLIC_KEY_URL,
41
+ '/auth',
42
+ '/login/captcha',
43
+ ])
44
+ /** 认证状态被静默清理的事件名。 */
45
+ export const AUTH_SILENT_LOGOUT_EVENT = 'auth:silent-logout'
46
+
47
+ /** 接口认证模式。 */
48
+ export type AuthMode = 'none' | 'optional' | 'required'
49
+ /** Taro 请求参数扩展。 */
50
+ export type HttpRequestOptions = Taro.request.Option & { authMode?: AuthMode }
51
+
52
+ type ErrorData = {
53
+ code?: string | number
54
+ message?: string
55
+ reason?: string | number
56
+ }
57
+
58
+ const authErrorCodeSet = new Set(['401', '403'])
59
+ const authErrorReasonSet = new Set(['UNAUTHENTICATED', 'PERMISSION_DENIED'])
60
+ let refreshTokenPromise: Promise<void> | null = null
61
+ let isPromptingRelogin = false
62
+
63
+ function isAuthErrorResponse(data: unknown): boolean {
64
+ if (!data || typeof data !== 'object') return false
65
+ const response = data as ErrorData
66
+ const code = response.code === undefined ? '' : String(response.code)
67
+ const reason = response.reason === undefined ? '' : String(response.reason)
68
+ return authErrorCodeSet.has(code) || authErrorCodeSet.has(reason) || authErrorReasonSet.has(reason)
69
+ }
70
+
71
+ function resolveAuthMode(options: HttpRequestOptions, url: string): AuthMode {
72
+ if (options.authMode) return options.authMode
73
+ if (options.header?.Authorization === 'no-auth' || NO_AUTH_URL_SET.has(url)) return 'none'
74
+ return 'required'
75
+ }
76
+
77
+ function resolveRequestUrl(url: string): string {
78
+ return /^https?:\/\//.test(url) ? url : `${requestBaseURL}${url.startsWith('/') ? url : `/${url}`}`
79
+ }
80
+
81
+ /** 发送经过认证、刷新令牌和统一错误处理的 Taro 请求。 */
82
+ export async function http<T>(options: HttpRequestOptions): Promise<T> {
83
+ return sendRequest<T>(options, false)
84
+ }
85
+
86
+ async function sendRequest<T>(
87
+ options: HttpRequestOptions,
88
+ retriedAsAnonymous: boolean,
89
+ ): Promise<T> {
90
+ const requestUrl = String(options.url)
91
+ const authMode = resolveAuthMode(options, requestUrl)
92
+ let accessToken = ''
93
+ try {
94
+ accessToken = await getAccessTokenByMode(
95
+ retriedAsAnonymous && authMode === 'optional' ? 'none' : authMode,
96
+ )
97
+ } catch (error) {
98
+ if (authMode === 'optional' && !retriedAsAnonymous) {
99
+ silentClearAuthData()
100
+ return sendRequest<T>(options, true)
101
+ }
102
+ throw error
103
+ }
104
+
105
+ try {
106
+ const response = await Taro.request({
107
+ ...options,
108
+ url: resolveRequestUrl(requestUrl),
109
+ timeout: options.timeout || 10000,
110
+ header: {
111
+ ...options.header,
112
+ 'source-client': 'miniapp',
113
+ ...(accessToken ? { Authorization: accessToken } : {}),
114
+ },
115
+ })
116
+ const responseData = response.data as ErrorData
117
+ if (response.statusCode >= 200 && response.statusCode < 300 && !isAuthErrorResponse(responseData)) {
118
+ return response.data as T
119
+ }
120
+ if (
121
+ response.statusCode === 401 ||
122
+ response.statusCode === 403 ||
123
+ isAuthErrorResponse(responseData)
124
+ ) {
125
+ if (authMode === 'optional' && !retriedAsAnonymous) {
126
+ silentClearAuthData()
127
+ return sendRequest<T>(options, true)
128
+ }
129
+ handleAuthExpiredByMode(authMode, requestUrl, responseData)
130
+ throw response
131
+ }
132
+ await Taro.showToast({ icon: 'none', title: responseData.message || '请求错误' })
133
+ throw response
134
+ } catch (error) {
135
+ if (
136
+ typeof error === 'object' &&
137
+ error &&
138
+ ('statusCode' in error || String((error as { errMsg?: string }).errMsg).includes('abort'))
139
+ ) {
140
+ throw error
141
+ }
142
+ await Taro.showToast({ icon: 'none', title: '网络错误,换个网络试试' })
143
+ throw error
144
+ }
145
+ }
146
+
147
+ /** 获取请求可用访问令牌,供流式请求复用。 */
148
+ export async function getRequestAccessToken(authMode: AuthMode = 'required'): Promise<string> {
149
+ return getAccessTokenByMode(authMode)
150
+ }
151
+
152
+ /** 触发登录失效处理,供流式请求复用。 */
153
+ export function handleAuthExpired(authMode: AuthMode = 'required'): void {
154
+ if (authMode === 'required') void promptRelogin()
155
+ else silentClearAuthData()
156
+ }
157
+
158
+ async function getAccessTokenByMode(authMode: AuthMode): Promise<string> {
159
+ if (authMode === 'none') return ''
160
+ if (!getToken()) {
161
+ if (authMode === 'required') {
162
+ await promptRelogin()
163
+ throw new Error('auth required')
164
+ }
165
+ return ''
166
+ }
167
+ if (shouldRefreshToken()) await handleTokenRefresh(authMode)
168
+ if (hasValidToken()) return getToken()
169
+ if (authMode === 'required') {
170
+ await promptRelogin()
171
+ throw new Error('auth expired')
172
+ }
173
+ silentClearAuthData()
174
+ return ''
175
+ }
176
+
177
+ function handleTokenRefresh(authMode: AuthMode): Promise<void> {
178
+ if (refreshTokenPromise) return refreshTokenPromise
179
+ refreshTokenPromise = refreshAccessToken()
180
+ .catch(async (error) => {
181
+ if (authMode === 'required') await promptRelogin()
182
+ else silentClearAuthData()
183
+ throw error
184
+ })
185
+ .finally(() => {
186
+ refreshTokenPromise = null
187
+ })
188
+ return refreshTokenPromise
189
+ }
190
+
191
+ async function refreshAccessToken(): Promise<void> {
192
+ const refreshToken = getRefreshToken()
193
+ if (!refreshToken) throw new Error('refresh token missing')
194
+ const response = await Taro.request({
195
+ url: resolveRequestUrl(REFRESH_TOKEN_URL),
196
+ method: 'POST',
197
+ data: { refresh_token: refreshToken },
198
+ header: { 'source-client': 'miniapp' },
199
+ })
200
+ const data = response.data as ErrorData & {
201
+ token_type?: string
202
+ access_token?: string
203
+ refresh_token?: string
204
+ expires_in?: number
205
+ }
206
+ if (
207
+ response.statusCode < 200 ||
208
+ response.statusCode >= 300 ||
209
+ isAuthErrorResponse(data) ||
210
+ !data.token_type ||
211
+ !data.access_token ||
212
+ !data.refresh_token ||
213
+ !data.expires_in
214
+ ) {
215
+ throw response
216
+ }
217
+ setToken(`${data.token_type} ${data.access_token}`)
218
+ setRefreshToken(data.refresh_token)
219
+ setTokenExpiresIn(data.expires_in)
220
+ }
221
+
222
+ async function promptRelogin(): Promise<void> {
223
+ if (isPromptingRelogin) return
224
+ isPromptingRelogin = true
225
+ try {
226
+ const modal = await Taro.showModal({
227
+ title: '提示',
228
+ content: '当前页面已失效,请重新登录',
229
+ showCancel: false,
230
+ confirmText: '重新登录',
231
+ })
232
+ if (!modal.confirm) return
233
+ await new Promise((resolve) => setTimeout(resolve, 80))
234
+ clearToken()
235
+ Taro.removeStorageSync('user')
236
+ saveCurrentRoute()
237
+ await Taro.reLaunch({ url: '/pages/login/login' })
238
+ } finally {
239
+ isPromptingRelogin = false
240
+ }
241
+ }
242
+
243
+ function handleAuthExpiredByMode(
244
+ authMode: AuthMode,
245
+ url: string,
246
+ responseData: ErrorData,
247
+ ): void {
248
+ if (authMode === 'required' && !AUTH_EXPIRED_EXCLUDED_URL_SET.has(url)) {
249
+ void promptRelogin()
250
+ return
251
+ }
252
+ silentClearAuthData()
253
+ if (authMode !== 'optional') {
254
+ void Taro.showToast({ icon: 'none', title: responseData.message || '请求错误' })
255
+ }
256
+ }
257
+
258
+ function silentClearAuthData(): void {
259
+ clearToken()
260
+ Taro.removeStorageSync('user')
261
+ Taro.eventCenter.trigger(AUTH_SILENT_LOGOUT_EVENT)
262
+ }
@@ -0,0 +1,35 @@
1
+ const configuredStaticUrl = process.env.KRATOS_TARO_STATIC_URL || ''
2
+
3
+ /** 日期格式化函数。 */
4
+ export function formatDate(date: Date, format = 'YYYY-MM-DD HH:mm:ss'): string {
5
+ const year = String(date.getFullYear())
6
+ const month = String(date.getMonth() + 1).padStart(2, '0')
7
+ const day = String(date.getDate()).padStart(2, '0')
8
+ const hours = String(date.getHours()).padStart(2, '0')
9
+ const minutes = String(date.getMinutes()).padStart(2, '0')
10
+ const seconds = String(date.getSeconds()).padStart(2, '0')
11
+ return format
12
+ .replace('YYYY', year)
13
+ .replace('MM', month)
14
+ .replace('DD', day)
15
+ .replace('HH', hours)
16
+ .replace('mm', minutes)
17
+ .replace('ss', seconds)
18
+ }
19
+
20
+ /** 将分单位金额格式化为元。 */
21
+ export function formatPrice(price: number): string {
22
+ return price ? (price / 100).toFixed(2) : '0.00'
23
+ }
24
+
25
+ /** 格式化后端静态资源地址。 */
26
+ export function formatSrc(src: string): string {
27
+ if (!src || /^https?:\/\//.test(src)) return src
28
+ const browserOrigin =
29
+ typeof window !== 'undefined' && window.location?.origin ? window.location.origin : ''
30
+ const staticOrigin = browserOrigin || configuredStaticUrl.replace(/\/$/, '')
31
+ if (!staticOrigin) return src
32
+ return src.startsWith('/')
33
+ ? `${staticOrigin}${src}`
34
+ : `${staticOrigin}/${src.replace(/^\/+/, '')}`
35
+ }
@@ -0,0 +1,72 @@
1
+ import Taro, { getCurrentPages } from '@tarojs/taro'
2
+
3
+ const LAST_ROUTE_KEY = 'lastRoute'
4
+ const LOGIN_PAGE = '/pages/login/login'
5
+ /** 首页 tab 页面路径。 */
6
+ export const homeTabPage = '/pages/index/index'
7
+
8
+ type RouteQueryValue = string | number | boolean | null | undefined
9
+
10
+ function buildRouteUrl(path: string, query: Record<string, RouteQueryValue> = {}): string {
11
+ const queryString = Object.entries(query)
12
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
13
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
14
+ .join('&')
15
+ return queryString ? `${path}?${queryString}` : path
16
+ }
17
+
18
+ function getCurrentRouteUrl(): string {
19
+ const pages = getCurrentPages()
20
+ const currentPage = pages[pages.length - 1] as
21
+ | {
22
+ route?: string
23
+ options?: Record<string, RouteQueryValue>
24
+ $taroParams?: Record<string, RouteQueryValue>
25
+ }
26
+ | undefined
27
+ if (!currentPage?.route) return ''
28
+ return buildRouteUrl(
29
+ `/${currentPage.route}`,
30
+ currentPage.options ?? currentPage.$taroParams ?? {},
31
+ )
32
+ }
33
+
34
+ /** 保存当前页面路由,登录成功后恢复到用户原来的页面。 */
35
+ export function saveCurrentRoute(): void {
36
+ const currentRoute = getCurrentRouteUrl()
37
+ if (!currentRoute || currentRoute.startsWith(LOGIN_PAGE)) {
38
+ Taro.removeStorageSync(LAST_ROUTE_KEY)
39
+ return
40
+ }
41
+ Taro.setStorageSync(LAST_ROUTE_KEY, currentRoute)
42
+ }
43
+
44
+ /** 保存指定页面路由,供登录成功后继续原访问目标。 */
45
+ export function saveLoginRedirectUrl(url: string): void {
46
+ const normalizedUrl = url.startsWith('/') ? url : `/${url}`
47
+ if (!normalizedUrl.startsWith(LOGIN_PAGE)) Taro.setStorageSync(LAST_ROUTE_KEY, normalizedUrl)
48
+ }
49
+
50
+ /** 读取并清理登录成功后的回跳地址。 */
51
+ export function consumeLoginRedirectUrl(): string {
52
+ const url = Taro.getStorageSync<string>(LAST_ROUTE_KEY) || ''
53
+ Taro.removeStorageSync(LAST_ROUTE_KEY)
54
+ return url
55
+ }
56
+
57
+ /** 跳转到登录页,并在跳转前记录当前页面。 */
58
+ export function navigateToLogin(redirectUrl?: string): void {
59
+ if (typeof redirectUrl === 'string' && redirectUrl) saveLoginRedirectUrl(redirectUrl)
60
+ else saveCurrentRoute()
61
+ void Taro.navigateTo({
62
+ url: LOGIN_PAGE,
63
+ fail: () => {
64
+ void Taro.reLaunch({ url: LOGIN_PAGE })
65
+ },
66
+ })
67
+ }
68
+
69
+ /** 返回首页。 */
70
+ export function switchTabToHome(): Promise<TaroGeneral.CallbackResult> {
71
+ return Taro.reLaunch({ url: homeTabPage })
72
+ }