@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,67 @@
1
+ // 访问 token 缓存的 key
2
+ const ACCESS_TOKEN_KEY = 'access_token'
3
+ // 访问 token 缓存的 key的有效期
4
+ const ACCESS_TOKEN_EXPIRES_IN = 'expiresIn'
5
+ // 刷新 token 缓存的 key
6
+ const REFRESH_TOKEN_KEY = 'refresh_token'
7
+
8
+ const TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000
9
+
10
+ function getToken(): string {
11
+ return uni.getStorageSync(ACCESS_TOKEN_KEY) || ''
12
+ }
13
+
14
+ function setToken(token: string) {
15
+ uni.setStorageSync(ACCESS_TOKEN_KEY, token)
16
+ }
17
+
18
+ function getTokenExpiresIn(): number {
19
+ const s = uni.getStorageSync(ACCESS_TOKEN_EXPIRES_IN) || ''
20
+ return Number(s)
21
+ }
22
+
23
+ function setTokenExpiresIn(expires_in: number) {
24
+ // 获取当前时间(毫秒)
25
+ const d = new Date().getTime() + expires_in * 1000
26
+ uni.setStorageSync(ACCESS_TOKEN_EXPIRES_IN, String(d))
27
+ }
28
+
29
+ function getRefreshToken(): string {
30
+ return uni.getStorageSync(REFRESH_TOKEN_KEY) || ''
31
+ }
32
+
33
+ function setRefreshToken(token: string) {
34
+ uni.setStorageSync(REFRESH_TOKEN_KEY, token)
35
+ }
36
+
37
+ function clearToken() {
38
+ uni.removeStorageSync(ACCESS_TOKEN_KEY)
39
+ uni.removeStorageSync(REFRESH_TOKEN_KEY)
40
+ uni.removeStorageSync(ACCESS_TOKEN_EXPIRES_IN)
41
+ }
42
+
43
+ /** 判断本地访问令牌是否仍在有效期内。 */
44
+ function hasValidToken(): boolean {
45
+ const token = getToken()
46
+ const expiresIn = getTokenExpiresIn()
47
+ return Boolean(token && expiresIn && expiresIn > new Date().getTime())
48
+ }
49
+
50
+ /** 判断访问令牌是否即将过期,供请求层提前刷新。 */
51
+ function shouldRefreshToken(): boolean {
52
+ const expiresIn = getTokenExpiresIn()
53
+ const remain = expiresIn - new Date().getTime()
54
+ return Boolean(getToken() && expiresIn && remain <= TOKEN_REFRESH_THRESHOLD)
55
+ }
56
+
57
+ export {
58
+ getToken,
59
+ setToken,
60
+ clearToken,
61
+ getRefreshToken,
62
+ setRefreshToken,
63
+ setTokenExpiresIn,
64
+ getTokenExpiresIn,
65
+ hasValidToken,
66
+ shouldRefreshToken,
67
+ }
@@ -0,0 +1,95 @@
1
+ import type { FileInfo, MultiUploadFileResponse } from '../rpc/base/v1/file'
2
+ import { formatSrc } from './index'
3
+
4
+ // 文件上传-兼容小程序端、H5端、App端
5
+ export const uploadFile = async (fileType: string, filePath: string): Promise<FileInfo> => {
6
+ const res = await uni.uploadFile({
7
+ url: '/v1/base/file',
8
+ name: 'file',
9
+ filePath: filePath,
10
+ formData: {
11
+ fileType: fileType,
12
+ },
13
+ })
14
+ if (res.statusCode === 200) {
15
+ return JSON.parse(res.data) as FileInfo
16
+ } else {
17
+ throw new Error('上传失败')
18
+ }
19
+ }
20
+
21
+ // 文件上传-兼容小程序端、H5端、App端
22
+ export const uploadFileList = async (
23
+ fileType: string,
24
+ filePaths: string[],
25
+ ): Promise<FileInfo[]> => {
26
+ const fileInfoArr: FileInfo[] = []
27
+ // 使用 Promise.all 等待所有上传完成
28
+ await Promise.all(
29
+ filePaths.map(async (filePath) => {
30
+ try {
31
+ const res = await uni.uploadFile({
32
+ url: '/v1/base/file',
33
+ name: 'file',
34
+ filePath: filePath,
35
+ formData: {
36
+ fileType: fileType,
37
+ },
38
+ })
39
+ if (res.statusCode === 200) {
40
+ const data = JSON.parse(res.data) as FileInfo
41
+ fileInfoArr.push(data)
42
+ } else {
43
+ console.error('上传失败,状态码:', res.statusCode)
44
+ }
45
+ } catch (error) {
46
+ console.error('上传异常:', error)
47
+ }
48
+ }),
49
+ )
50
+ return fileInfoArr
51
+ }
52
+
53
+ // 多文件上传-兼容小程序端、H5端、App端
54
+ export const multiUploadFile = async (fileType: string, files: any): Promise<FileInfo[]> => {
55
+ const res = await uni.uploadFile({
56
+ url: '/v1/base/file/multi',
57
+ name: 'file',
58
+ filePath: '',
59
+ files: files,
60
+ formData: {
61
+ fileType: fileType,
62
+ },
63
+ })
64
+ if (res.statusCode === 200) {
65
+ const data = JSON.parse(res.data) as MultiUploadFileResponse
66
+ return data.files
67
+ } else {
68
+ await uni.showToast({ icon: 'error', title: '上传失败' })
69
+ return []
70
+ }
71
+ }
72
+
73
+ export const getFileInfo = (url: string): FileInfo => {
74
+ // 处理路径分隔符(兼容Windows和Unix系统)
75
+ const parts = url.split(/[\\/]/)
76
+ // 获取文件名部分(包含扩展名)
77
+ const fullName = parts.pop() || ''
78
+
79
+ if (!fullName) return { name: '', extname: '', url: url } // 空文件名处理
80
+
81
+ // 查找最后一个点号的位置
82
+ const dotIndex = fullName.lastIndexOf('.')
83
+
84
+ // 排除隐藏文件(如.gitignore)和无扩展名情况
85
+ if (dotIndex > 0) {
86
+ return {
87
+ name: fullName,
88
+ extname: fullName.slice(dotIndex),
89
+ url: formatSrc(url),
90
+ }
91
+ }
92
+
93
+ // 无合法扩展名时返回完整文件名
94
+ return { name: fullName, extname: '', url: url }
95
+ }
@@ -0,0 +1,430 @@
1
+ /**
2
+ * 添加拦截器:
3
+ * 拦截 request 请求
4
+ * 拦截 uploadFile 文件上传
5
+ *
6
+ */
7
+
8
+ import {
9
+ clearToken,
10
+ getRefreshToken,
11
+ getToken,
12
+ hasValidToken,
13
+ setRefreshToken,
14
+ setToken,
15
+ setTokenExpiresIn,
16
+ shouldRefreshToken,
17
+ } from './auth'
18
+ import { saveCurrentRoute } from './navigation'
19
+
20
+ const apiBasePath = import.meta.env.VITE_APP_BASE_API || '/api'
21
+ const apiTargetUrl = import.meta.env.VITE_APP_API_URL || ''
22
+ const normalizedApiBasePath = apiBasePath.startsWith('/') ? apiBasePath : `/${apiBasePath}`
23
+
24
+ /**
25
+ * H5 开发环境优先走同源代理,避免浏览器直接请求后端产生跨域。
26
+ * 其它平台继续使用显式配置的后端地址。
27
+ */
28
+ const requestOrigin =
29
+ typeof window !== 'undefined' && window.location?.protocol?.startsWith('http')
30
+ ? ''
31
+ : apiTargetUrl.replace(/\/$/, '')
32
+ const baseURL = `${requestOrigin}${normalizedApiBasePath}`
33
+ export const requestBaseURL = baseURL
34
+ const SESSION_URL = '/v1/base/session'
35
+ const REFRESH_TOKEN_URL = '/v1/base/token'
36
+ const CAPTCHA_URL = '/v1/base/captcha'
37
+ const CONFIG_URL = '/v1/base/config'
38
+ const PASSWORD_PUBLIC_KEY_URL = '/v1/base/password-public-key'
39
+ const LEGACY_AUTH_URL = '/auth'
40
+ const LEGACY_REFRESH_TOKEN_URL = `${LEGACY_AUTH_URL}/token`
41
+ const LEGACY_CAPTCHA_URL = '/login/captcha'
42
+ // 认证公共接口不携带旧 token,同时保留旧路径兼容迁移期间的灰度访问。
43
+ const NO_AUTH_URL_SET = new Set([
44
+ SESSION_URL,
45
+ REFRESH_TOKEN_URL,
46
+ CAPTCHA_URL,
47
+ CONFIG_URL,
48
+ PASSWORD_PUBLIC_KEY_URL,
49
+ LEGACY_AUTH_URL,
50
+ LEGACY_CAPTCHA_URL,
51
+ LEGACY_REFRESH_TOKEN_URL,
52
+ ])
53
+ const AUTH_EXPIRED_EXCLUDED_URL_SET = new Set([
54
+ SESSION_URL,
55
+ CAPTCHA_URL,
56
+ CONFIG_URL,
57
+ PASSWORD_PUBLIC_KEY_URL,
58
+ LEGACY_AUTH_URL,
59
+ LEGACY_CAPTCHA_URL,
60
+ ])
61
+
62
+ const AUTH_SILENT_LOGOUT_EVENT = 'auth:silent-logout'
63
+
64
+ export type AuthMode = 'none' | 'optional' | 'required'
65
+
66
+ type HttpRequestOptions = UniApp.RequestOptions & {
67
+ /** none: 游客接口;optional: 可选登录;required: 必须登录。 */
68
+ authMode?: AuthMode
69
+ }
70
+
71
+ // 添加拦截器
72
+ const httpInterceptor = {
73
+ // 拦截前触发
74
+ invoke(options: UniApp.RequestOptions) {
75
+ const authMode = (options as HttpRequestOptions).authMode
76
+ // 1. 相对接口地址只拼接一次基础路径,兼容 HMR 等场景下拦截器重复注册。
77
+ if (
78
+ !options.url.startsWith('http') &&
79
+ options.url !== baseURL &&
80
+ !options.url.startsWith(`${baseURL}/`)
81
+ ) {
82
+ options.url = baseURL + options.url
83
+ }
84
+ // 2. 请求超时,普通请求默认 10s;流式等长连接可自行传入更长超时。
85
+ options.timeout = options.timeout || 10000
86
+ // 3. 添加小程序端请求头标识
87
+ options.header = {
88
+ ...options.header,
89
+ 'source-client': 'miniapp',
90
+ }
91
+ // 4. 添加 token 请求头标识
92
+ const accessToken = getToken()
93
+ if (authMode !== 'none' && options.header.Authorization !== 'no-auth' && accessToken) {
94
+ options.header.Authorization = accessToken
95
+ } else {
96
+ delete options.header.Authorization
97
+ }
98
+ },
99
+ }
100
+ uni.addInterceptor('request', httpInterceptor)
101
+ uni.addInterceptor('uploadFile', httpInterceptor)
102
+
103
+ /**
104
+ * 请求函数
105
+ * @param UniApp.RequestOptions
106
+ * @returns Promise
107
+ * 1. 返回 Promise 对象
108
+ * 2. 获取数据成功
109
+ * 2.1 提取核心数据 res.data
110
+ * 2.2 添加类型,支持泛型
111
+ * 3. 获取数据失败
112
+ * 3.1 401/403错误 -> 清理用户信息,跳转到登录页
113
+ * 3.2 其他错误 -> 根据后端错误信息轻提示
114
+ * 3.3 网络错误 -> 提示用户换网络
115
+ */
116
+ type Data = {
117
+ code?: string | number
118
+ message?: string
119
+ reason?: string | number
120
+ }
121
+
122
+ // 新错误模型下,认证与鉴权只保留两类顶层原因。
123
+ const authErrorCodeSet = new Set(['401', '403'])
124
+ const authErrorReasonSet = new Set(['UNAUTHENTICATED', 'PERMISSION_DENIED'])
125
+
126
+ function isAuthErrorResponse(data: unknown) {
127
+ if (!data || typeof data !== 'object') {
128
+ return false
129
+ }
130
+
131
+ const response = data as Data
132
+ const code = response.code !== undefined ? String(response.code) : ''
133
+ const reason = response.reason !== undefined ? String(response.reason) : ''
134
+
135
+ return (
136
+ authErrorCodeSet.has(code) || authErrorCodeSet.has(reason) || authErrorReasonSet.has(reason)
137
+ )
138
+ }
139
+
140
+ // 当前请求属于公共认证接口时,不应自动附带旧登录态。
141
+ function isNoAuthRequest(url: string) {
142
+ return NO_AUTH_URL_SET.has(url)
143
+ }
144
+
145
+ // 登录与验证码接口失败时,只提示本次请求失败,不触发重新登录流程。
146
+ function shouldSkipAuthExpiredPrompt(url: string) {
147
+ return AUTH_EXPIRED_EXCLUDED_URL_SET.has(url)
148
+ }
149
+
150
+ function resolveAuthMode(options: HttpRequestOptions, url: string): AuthMode {
151
+ if (options.authMode) {
152
+ return options.authMode
153
+ }
154
+ if (options.header?.Authorization === 'no-auth' || isNoAuthRequest(url)) {
155
+ return 'none'
156
+ }
157
+ return 'required'
158
+ }
159
+
160
+ // 2.2 添加类型,支持泛型
161
+ export const http = <T>(options: HttpRequestOptions) => {
162
+ // 1. 返回 Promise 对象
163
+ return new Promise<T>((resolve, reject) => {
164
+ const sendRequest = async (retriedAsAnonymous = false) => {
165
+ try {
166
+ const requestOptions = { ...options, header: { ...options.header } }
167
+ const requestUrl = String(requestOptions.url)
168
+ const authMode = resolveAuthMode(requestOptions, requestUrl)
169
+ let accessToken = ''
170
+ try {
171
+ accessToken = await getAccessTokenByMode(
172
+ retriedAsAnonymous && authMode === 'optional' ? 'none' : authMode,
173
+ )
174
+ } catch (error) {
175
+ if (authMode === 'optional' && !retriedAsAnonymous) {
176
+ silentClearAuthData()
177
+ void sendRequest(true)
178
+ return
179
+ }
180
+ throw error
181
+ }
182
+ if (accessToken) {
183
+ requestOptions.header = {
184
+ ...requestOptions.header,
185
+ Authorization: accessToken,
186
+ }
187
+ } else {
188
+ delete requestOptions.header.Authorization
189
+ }
190
+ uni.request({
191
+ ...requestOptions,
192
+ // 响应成功
193
+ success(res) {
194
+ const responseData = res.data as Data
195
+ // 状态码 2xx, axios 就是这样设计的
196
+ if (res.statusCode >= 200 && res.statusCode < 300) {
197
+ if (isAuthErrorResponse(responseData)) {
198
+ if (authMode === 'optional' && !retriedAsAnonymous) {
199
+ silentClearAuthData()
200
+ void sendRequest(true)
201
+ return
202
+ }
203
+ handleAuthExpiredByMode(authMode, requestUrl, responseData)
204
+ reject(res)
205
+ return
206
+ }
207
+ // 2.1 提取核心数据 res.data
208
+ resolve(res.data as T)
209
+ } else if (res.statusCode === 401 || res.statusCode === 403) {
210
+ // 401/403 错误 -> 清理用户信息,跳转到登录页
211
+ if (authMode === 'optional' && !retriedAsAnonymous) {
212
+ silentClearAuthData()
213
+ void sendRequest(true)
214
+ return
215
+ }
216
+ handleAuthExpiredByMode(authMode, requestUrl, responseData)
217
+ reject(res)
218
+ } else {
219
+ // 其他错误 -> 根据后端错误信息轻提示
220
+ void uni.showToast({
221
+ icon: 'none',
222
+ title: responseData.message || '请求错误',
223
+ })
224
+ reject(res)
225
+ }
226
+ },
227
+ // 响应失败
228
+ fail(err) {
229
+ void uni.showToast({
230
+ icon: 'none',
231
+ title: '网络错误,换个网络试试',
232
+ })
233
+ reject(err)
234
+ },
235
+ })
236
+ } catch (error) {
237
+ reject(error)
238
+ }
239
+ }
240
+
241
+ void sendRequest()
242
+ })
243
+ }
244
+
245
+ // 刷新 Token 的锁
246
+ let isRefreshing = false
247
+ let refreshTokenPromise: Promise<void> | null = null
248
+ let isPromptingRelogin = false
249
+
250
+ /** 获取请求可用访问令牌,供 direct stream 等非 uni.request 场景复用。 */
251
+ export async function getRequestAccessToken(authMode: AuthMode = 'required') {
252
+ return getAccessTokenByMode(authMode)
253
+ }
254
+
255
+ /** 触发登录失效处理,供 direct stream 等非 uni.request 场景复用。 */
256
+ export function handleAuthExpired(authMode: AuthMode = 'required') {
257
+ if (authMode === 'required') {
258
+ void promptRelogin()
259
+ return
260
+ }
261
+ silentClearAuthData()
262
+ }
263
+
264
+ // 刷新 Token 处理
265
+ function handleTokenRefresh(authMode: AuthMode) {
266
+ if (refreshTokenPromise) {
267
+ return refreshTokenPromise
268
+ }
269
+ isRefreshing = true
270
+ refreshTokenPromise = refreshAccessToken()
271
+ .catch(async (error) => {
272
+ if (authMode === 'required') {
273
+ await promptRelogin()
274
+ } else {
275
+ silentClearAuthData()
276
+ }
277
+ throw error
278
+ })
279
+ .finally(() => {
280
+ isRefreshing = false
281
+ refreshTokenPromise = null
282
+ })
283
+ return refreshTokenPromise
284
+ }
285
+
286
+ async function getAccessTokenByMode(authMode: AuthMode) {
287
+ if (authMode === 'none') {
288
+ return ''
289
+ }
290
+
291
+ if (!getToken()) {
292
+ if (authMode === 'required') {
293
+ await promptRelogin()
294
+ throw new Error('auth required')
295
+ }
296
+ return ''
297
+ }
298
+
299
+ if (shouldRefreshToken()) {
300
+ await handleTokenRefresh(authMode)
301
+ }
302
+
303
+ if (hasValidToken()) {
304
+ return getToken()
305
+ }
306
+
307
+ if (authMode === 'required') {
308
+ await promptRelogin()
309
+ throw new Error('auth expired')
310
+ }
311
+
312
+ silentClearAuthData()
313
+ return ''
314
+ }
315
+
316
+ async function refreshAccessToken() {
317
+ const refreshToken = getRefreshToken()
318
+ if (!refreshToken) {
319
+ throw new Error('refresh token missing')
320
+ }
321
+
322
+ const response = await new Promise<UniApp.RequestSuccessCallbackResult>((resolve, reject) => {
323
+ uni.request({
324
+ url: REFRESH_TOKEN_URL,
325
+ method: 'POST',
326
+ data: { refresh_token: refreshToken },
327
+ header: {
328
+ 'source-client': 'miniapp',
329
+ },
330
+ success: resolve,
331
+ fail: reject,
332
+ })
333
+ })
334
+
335
+ const responseData = response.data as
336
+ | Data
337
+ | {
338
+ token_type?: string
339
+ access_token?: string
340
+ refresh_token?: string
341
+ expires_in?: number
342
+ }
343
+
344
+ if (
345
+ response.statusCode < 200 ||
346
+ response.statusCode >= 300 ||
347
+ isAuthErrorResponse(responseData)
348
+ ) {
349
+ throw response
350
+ }
351
+
352
+ const {
353
+ token_type,
354
+ access_token,
355
+ refresh_token: nextRefreshToken,
356
+ expires_in,
357
+ } = responseData as {
358
+ token_type?: string
359
+ access_token?: string
360
+ refresh_token?: string
361
+ expires_in?: number
362
+ }
363
+
364
+ if (!token_type || !access_token || !nextRefreshToken || !expires_in) {
365
+ throw new Error('refresh token response invalid')
366
+ }
367
+
368
+ setToken(`${token_type} ${access_token}`)
369
+ setRefreshToken(nextRefreshToken)
370
+ setTokenExpiresIn(expires_in)
371
+ }
372
+
373
+ async function promptRelogin() {
374
+ if (isPromptingRelogin) {
375
+ return
376
+ }
377
+ isPromptingRelogin = true
378
+ try {
379
+ const modalRes = await uni.showModal({
380
+ title: '提示',
381
+ content: '当前页面已失效,请重新登录',
382
+ showCancel: false,
383
+ confirmText: '重新登录',
384
+ })
385
+
386
+ if (!modalRes.confirm) {
387
+ return
388
+ }
389
+
390
+ // 小程序端确认弹窗关闭到页面跳转之间留一个极短缓冲,避免按钮点击后路由不生效。
391
+ await new Promise((resolve) => setTimeout(resolve, 80))
392
+ await clearUserData()
393
+ } finally {
394
+ isPromptingRelogin = false
395
+ }
396
+ }
397
+
398
+ function handleAuthExpiredByMode(authMode: AuthMode, url: string, responseData: Data) {
399
+ if (authMode === 'required' && !shouldSkipAuthExpiredPrompt(url)) {
400
+ void promptRelogin()
401
+ return
402
+ }
403
+
404
+ silentClearAuthData()
405
+ if (authMode !== 'optional') {
406
+ void uni.showToast({
407
+ icon: 'none',
408
+ title: responseData.message || '请求错误',
409
+ })
410
+ }
411
+ }
412
+
413
+ function silentClearAuthData() {
414
+ clearToken()
415
+ uni.removeStorageSync('user')
416
+ uni.$emit(AUTH_SILENT_LOGOUT_EVENT)
417
+ }
418
+
419
+ async function clearUserData() {
420
+ clearToken()
421
+ uni.removeStorageSync('user')
422
+ saveCurrentRoute()
423
+ // token 失效时直接重启页面栈,避免小程序历史页残留。
424
+ uni.reLaunch({
425
+ url: '/pages/login/login',
426
+ fail: () => {
427
+ uni.redirectTo({ url: '/pages/login/login' })
428
+ },
429
+ })
430
+ }
@@ -0,0 +1,79 @@
1
+ const baseURL = import.meta.env.VITE_APP_STATIC_URL
2
+
3
+ /**
4
+ * 解析静态资源访问前缀
5
+ * @returns 静态资源访问前缀
6
+ */
7
+ const resolveStaticOrigin = () => {
8
+ // H5 开发环境优先走当前站点,由 Vite 代理转发静态资源。
9
+ if (typeof window !== 'undefined' && window.location?.protocol?.startsWith('http')) {
10
+ return window.location.origin
11
+ }
12
+ if (baseURL) {
13
+ return baseURL.replace(/\/$/, '')
14
+ }
15
+ if (typeof window !== 'undefined' && window.location?.origin) {
16
+ return window.location.origin
17
+ }
18
+ return ''
19
+ }
20
+ /**
21
+ * 日期格式化函数
22
+ * @param date 日期对象
23
+ * @param format 日期格式,默认为 YYYY-MM-DD HH:mm:ss
24
+ */
25
+ export const formatDate = (date: Date, format = 'YYYY-MM-DD HH:mm:ss') => {
26
+ // 获取年月日时分秒,通过 padStart 补 0
27
+ const year = String(date.getFullYear())
28
+ const month = String(date.getMonth() + 1).padStart(2, '0')
29
+ const day = String(date.getDate()).padStart(2, '0')
30
+ const hours = String(date.getHours()).padStart(2, '0')
31
+ const minutes = String(date.getMinutes()).padStart(2, '0')
32
+ const seconds = String(date.getSeconds()).padStart(2, '0')
33
+
34
+ // 返回格式化后的结果
35
+ return format
36
+ .replace('YYYY', year)
37
+ .replace('MM', month)
38
+ .replace('DD', day)
39
+ .replace('HH', hours)
40
+ .replace('mm', minutes)
41
+ .replace('ss', seconds)
42
+ }
43
+
44
+ /**
45
+ * 金额格式化函数
46
+ * @param price 金额
47
+ */
48
+ export const formatPrice = (price: number) => {
49
+ if (!price) {
50
+ return '0.00'
51
+ }
52
+ return (price / 100).toFixed(2)
53
+ }
54
+
55
+ /**
56
+ * 图片地址格式化函数
57
+ * @param src 图片地址
58
+ */
59
+ export const formatSrc = (src: string) => {
60
+ if (!src) {
61
+ return src
62
+ }
63
+ // 已经是绝对地址时直接返回,避免重复拼接。
64
+ if (/^https?:\/\//.test(src)) {
65
+ return src
66
+ }
67
+
68
+ const staticOrigin = resolveStaticOrigin()
69
+ if (!staticOrigin) {
70
+ return src
71
+ }
72
+
73
+ // 以 / 开头的后端静态资源路径需要固定挂到站点根路径,不能受 /app base 影响。
74
+ if (src.startsWith('/')) {
75
+ return `${staticOrigin}${src}`
76
+ }
77
+
78
+ return `${staticOrigin}/${src.replace(/^\/+/, '')}`
79
+ }