@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,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,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
|
+
}
|