minitest2.0 0.0.0
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/.editorconfig +8 -0
- package/.env.development +3 -0
- package/.env.production +3 -0
- package/.env.test +3 -0
- package/.gitattributes +1 -0
- package/.oxlintrc.json +10 -0
- package/.prettierrc.json +6 -0
- package/.vscode/extensions.json +9 -0
- package/.vscode/settings.json +13 -0
- package/README.md +179 -0
- package/auto-imports.d.ts +629 -0
- package/components.d.ts +21 -0
- package/design-qa.md +36 -0
- package/docs/MX_API.md +244 -0
- package/docs/REQUEST.md +217 -0
- package/docs/jsapi.js +515 -0
- package/docs/package-json-guide.md +132 -0
- package/env.d.ts +15 -0
- package/eslint.config.ts +26 -0
- package/index.html +16 -0
- package/package.json +83 -0
- package/plugins/bump-version.ts +61 -0
- package/postcss.config.ts +15 -0
- package/public/favicon.ico +0 -0
- package/public/images/12a73787-86a9-4891-a65f-66104746f6a8.png +0 -0
- package/public/images/5798d7aa-ba8b-4605-8079-58b35495ac55.png +0 -0
- package/public/images/73fef1e4-0fd0-4a1a-9b8b-a70a5b6acbbc.png +0 -0
- package/public/images/bc685b4c-0cca-4a79-924c-a8ee10e6f8eb.png +0 -0
- package/public/images/c3dbbd9d-be56-490e-b9f4-6ee17ebefffc.png +0 -0
- package/public/images/ea745a10-42aa-4f44-8d7f-3ab02cc0adcd.png +0 -0
- package/public/images/f5876785-b927-4347-ba19-999114240649.png +0 -0
- package/public/images/img.png +0 -0
- package/public/images/opening-reserve-estimate.png +0 -0
- package/public/images/position-estimate-report.png +0 -0
- package/src/App.vue +131 -0
- package/src/api/announcement.ts +405 -0
- package/src/api/health.ts +13 -0
- package/src/api/pbc-position.ts +178 -0
- package/src/api/rmb-position.ts +233 -0
- package/src/api/tam.ts +173 -0
- package/src/api/user.ts +92 -0
- package/src/auto-imports.d.ts +633 -0
- package/src/components/AppTitleBar.vue +376 -0
- package/src/components.d.ts +33 -0
- package/src/config/config.properties +3 -0
- package/src/config/env.ts +6 -0
- package/src/config/plugin.properties.pro +6 -0
- package/src/config/plugin.properties.test +6 -0
- package/src/core/mxApi/index.ts +451 -0
- package/src/core/request/index.ts +135 -0
- package/src/main.ts +40 -0
- package/src/router/index.ts +144 -0
- package/src/stores/app.ts +103 -0
- package/src/stores/counter.ts +12 -0
- package/src/stores/user.ts +137 -0
- package/src/styles/nprogress.css +22 -0
- package/src/styles/vant-overrides.css +42 -0
- package/src/types/api.d.ts +14 -0
- package/src/types/nprogress.d.ts +8 -0
- package/src/utils/auth-token.ts +241 -0
- package/src/utils/code-highlight.ts +165 -0
- package/src/utils/copy.ts +36 -0
- package/src/utils/query.ts +27 -0
- package/src/utils/request.ts +238 -0
- package/src/utils/rmb-forecast.ts +84 -0
- package/src/utils/toast.ts +61 -0
- package/src/views/article-detail/index.vue +289 -0
- package/src/views/article-edit/index.vue +600 -0
- package/src/views/articles/index.vue +293 -0
- package/src/views/clearing-detail/index.vue +26 -0
- package/src/views/dashboard/index.vue +18 -0
- package/src/views/foreign-position/index.vue +26 -0
- package/src/views/home/index.vue +213 -0
- package/src/views/login/index.vue +275 -0
- package/src/views/mine/index.vue +147 -0
- package/src/views/net-debit/index.vue +26 -0
- package/src/views/opening-reserve-estimate/index.vue +28 -0
- package/src/views/pbc-position/index.vue +357 -0
- package/src/views/position-estimate/index.vue +67 -0
- package/src/views/position-estimate-report/index.vue +28 -0
- package/src/views/rmb-position/index.vue +1013 -0
- package/src/views/rmb-position-create/index.vue +221 -0
- package/src/views/rmb-position-detail/index.vue +355 -0
- package/src/views/settings/index.vue +67 -0
- package/src/views/warning/index.vue +26 -0
- package/tsconfig.app.json +18 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +28 -0
- package/vite.config.ts +100 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { useUserStore } from '@/stores/user'
|
|
2
|
+
|
|
3
|
+
export const REFRESH_TOKEN_URL = '/uaa/oauth/mobile/refresh_token'
|
|
4
|
+
|
|
5
|
+
export type AuthPrincipal = {
|
|
6
|
+
branchId?: string
|
|
7
|
+
currentOrgId?: string
|
|
8
|
+
ip?: string
|
|
9
|
+
loginIp?: string
|
|
10
|
+
orgId?: string
|
|
11
|
+
orgIds?: string[]
|
|
12
|
+
reqIpAddr?: string
|
|
13
|
+
username?: string
|
|
14
|
+
userId?: string
|
|
15
|
+
userRealName?: string
|
|
16
|
+
organizationName?: string
|
|
17
|
+
orgName?: string
|
|
18
|
+
org_name?: string
|
|
19
|
+
currentOrgName?: string
|
|
20
|
+
current_org_name?: string
|
|
21
|
+
institutionName?: string
|
|
22
|
+
institution_name?: string
|
|
23
|
+
branchName?: string
|
|
24
|
+
branch_name?: string
|
|
25
|
+
deptName?: string
|
|
26
|
+
dept_name?: string
|
|
27
|
+
departmentName?: string
|
|
28
|
+
department_name?: string
|
|
29
|
+
unitName?: string
|
|
30
|
+
unit_name?: string
|
|
31
|
+
agencyName?: string
|
|
32
|
+
agency_name?: string
|
|
33
|
+
bankName?: string
|
|
34
|
+
bank_name?: string
|
|
35
|
+
organization?: unknown
|
|
36
|
+
org?: unknown
|
|
37
|
+
institution?: unknown
|
|
38
|
+
branch?: unknown
|
|
39
|
+
dept?: unknown
|
|
40
|
+
department?: unknown
|
|
41
|
+
unit?: unknown
|
|
42
|
+
agency?: unknown
|
|
43
|
+
bank?: unknown
|
|
44
|
+
[key: string]: unknown
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type AuthTokenPayload = {
|
|
48
|
+
token?: string
|
|
49
|
+
access_token?: string
|
|
50
|
+
refresh_token?: string
|
|
51
|
+
refreshToken?: string
|
|
52
|
+
token_type?: string
|
|
53
|
+
expires_in?: number
|
|
54
|
+
scope?: string
|
|
55
|
+
principal?: AuthPrincipal
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type AuthTokenResponse = AuthTokenPayload & {
|
|
59
|
+
data?: AuthTokenPayload
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let refreshTokenPromise: Promise<string> | null = null
|
|
63
|
+
|
|
64
|
+
export function getAccessTokenFromResponse(response: AuthTokenResponse) {
|
|
65
|
+
return (
|
|
66
|
+
response.data?.token || response.data?.access_token || response.token || response.access_token
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getRefreshTokenFromResponse(response: AuthTokenResponse) {
|
|
71
|
+
return (
|
|
72
|
+
response.data?.refresh_token ||
|
|
73
|
+
response.data?.refreshToken ||
|
|
74
|
+
response.refresh_token ||
|
|
75
|
+
response.refreshToken
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getStoredAccessToken() {
|
|
80
|
+
return useUserStore().token
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getStoredRefreshToken() {
|
|
84
|
+
return useUserStore().refreshToken
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getAuthHeaders(headers?: Record<string, string>, skipAuth = false) {
|
|
88
|
+
const token = getStoredAccessToken()
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
Accept: 'application/json',
|
|
93
|
+
...(!skipAuth && token ? { Authorization: `Bearer ${token}` } : {}),
|
|
94
|
+
...headers,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function saveAuthTokens(response: AuthTokenResponse) {
|
|
99
|
+
const payload = getAuthPayloadFromResponse(response)
|
|
100
|
+
const payloadForStorage = sanitizeAuthPayload(payload)
|
|
101
|
+
const token = getAccessTokenFromResponse(response)
|
|
102
|
+
const refreshToken = getRefreshTokenFromResponse(response)
|
|
103
|
+
const principal = payload.principal
|
|
104
|
+
|
|
105
|
+
if (!token) {
|
|
106
|
+
throw new Error('接口未返回业务 token')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const userStore = useUserStore()
|
|
110
|
+
userStore.setAuthTokens(token, refreshToken, payloadForStorage)
|
|
111
|
+
userStore.setUserInfo({
|
|
112
|
+
username: principal?.username,
|
|
113
|
+
realName: principal?.userRealName,
|
|
114
|
+
organizationName: getOrganizationNameFromPrincipal(principal),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return token
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getAuthPayloadFromResponse(response: AuthTokenResponse) {
|
|
121
|
+
// 登录接口可能直接返回 token,也可能包一层 data;这里统一取真正的认证载荷。
|
|
122
|
+
if (isAuthPayload(response.data)) {
|
|
123
|
+
return response.data
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return response
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isAuthPayload(payload?: AuthTokenPayload): payload is AuthTokenPayload {
|
|
130
|
+
return Boolean(
|
|
131
|
+
payload &&
|
|
132
|
+
(payload.token ||
|
|
133
|
+
payload.access_token ||
|
|
134
|
+
payload.refresh_token ||
|
|
135
|
+
payload.refreshToken ||
|
|
136
|
+
payload.principal),
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sanitizeAuthPayload(payload: AuthTokenPayload): AuthTokenPayload {
|
|
141
|
+
if (!payload.principal) return payload
|
|
142
|
+
|
|
143
|
+
// 后端 principal 里会带 password 哈希,本地只保存业务需要的用户信息。
|
|
144
|
+
const { password: _password, ...principal } = payload.principal
|
|
145
|
+
return {
|
|
146
|
+
...payload,
|
|
147
|
+
principal,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getOrganizationNameFromPrincipal(principal?: AuthPrincipal) {
|
|
152
|
+
if (!principal) return undefined
|
|
153
|
+
|
|
154
|
+
return firstText(
|
|
155
|
+
principal.organizationName,
|
|
156
|
+
principal.orgName,
|
|
157
|
+
principal.org_name,
|
|
158
|
+
principal.currentOrgName,
|
|
159
|
+
principal.current_org_name,
|
|
160
|
+
principal.institutionName,
|
|
161
|
+
principal.institution_name,
|
|
162
|
+
principal.branchName,
|
|
163
|
+
principal.branch_name,
|
|
164
|
+
principal.deptName,
|
|
165
|
+
principal.dept_name,
|
|
166
|
+
principal.departmentName,
|
|
167
|
+
principal.department_name,
|
|
168
|
+
principal.unitName,
|
|
169
|
+
principal.unit_name,
|
|
170
|
+
principal.agencyName,
|
|
171
|
+
principal.agency_name,
|
|
172
|
+
principal.bankName,
|
|
173
|
+
principal.bank_name,
|
|
174
|
+
principal.organization,
|
|
175
|
+
principal.org,
|
|
176
|
+
principal.institution,
|
|
177
|
+
principal.branch,
|
|
178
|
+
principal.dept,
|
|
179
|
+
principal.department,
|
|
180
|
+
principal.unit,
|
|
181
|
+
principal.agency,
|
|
182
|
+
principal.bank,
|
|
183
|
+
getNameFromRecord(principal.organization),
|
|
184
|
+
getNameFromRecord(principal.org),
|
|
185
|
+
getNameFromRecord(principal.institution),
|
|
186
|
+
getNameFromRecord(principal.branch),
|
|
187
|
+
getNameFromRecord(principal.dept),
|
|
188
|
+
getNameFromRecord(principal.department),
|
|
189
|
+
getNameFromRecord(principal.unit),
|
|
190
|
+
getNameFromRecord(principal.agency),
|
|
191
|
+
getNameFromRecord(principal.bank),
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getNameFromRecord(value: unknown) {
|
|
196
|
+
if (!isRecord(value)) return undefined
|
|
197
|
+
|
|
198
|
+
return firstText(
|
|
199
|
+
value.name,
|
|
200
|
+
value.organizationName,
|
|
201
|
+
value.orgName,
|
|
202
|
+
value.institutionName,
|
|
203
|
+
value.branchName,
|
|
204
|
+
value.deptName,
|
|
205
|
+
value.departmentName,
|
|
206
|
+
value.unitName,
|
|
207
|
+
value.agencyName,
|
|
208
|
+
value.bankName,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function firstText(...values: unknown[]) {
|
|
213
|
+
for (const value of values) {
|
|
214
|
+
if (typeof value !== 'string') continue
|
|
215
|
+
|
|
216
|
+
const text = value.trim()
|
|
217
|
+
if (text) return text
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return undefined
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
224
|
+
return typeof value === 'object' && value !== null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function clearAuthTokens() {
|
|
228
|
+
useUserStore().clearToken()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function refreshAccessToken(requestRefreshToken: () => Promise<AuthTokenResponse>) {
|
|
232
|
+
if (!refreshTokenPromise) {
|
|
233
|
+
refreshTokenPromise = requestRefreshToken()
|
|
234
|
+
.then(saveAuthTokens)
|
|
235
|
+
.finally(() => {
|
|
236
|
+
refreshTokenPromise = null
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return refreshTokenPromise
|
|
241
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const KEYWORDS = new Set([
|
|
2
|
+
'abstract',
|
|
3
|
+
'async',
|
|
4
|
+
'await',
|
|
5
|
+
'boolean',
|
|
6
|
+
'break',
|
|
7
|
+
'case',
|
|
8
|
+
'catch',
|
|
9
|
+
'class',
|
|
10
|
+
'const',
|
|
11
|
+
'continue',
|
|
12
|
+
'default',
|
|
13
|
+
'delete',
|
|
14
|
+
'do',
|
|
15
|
+
'else',
|
|
16
|
+
'enum',
|
|
17
|
+
'export',
|
|
18
|
+
'extends',
|
|
19
|
+
'false',
|
|
20
|
+
'finally',
|
|
21
|
+
'for',
|
|
22
|
+
'from',
|
|
23
|
+
'function',
|
|
24
|
+
'if',
|
|
25
|
+
'import',
|
|
26
|
+
'in',
|
|
27
|
+
'interface',
|
|
28
|
+
'let',
|
|
29
|
+
'new',
|
|
30
|
+
'null',
|
|
31
|
+
'number',
|
|
32
|
+
'private',
|
|
33
|
+
'protected',
|
|
34
|
+
'public',
|
|
35
|
+
'return',
|
|
36
|
+
'static',
|
|
37
|
+
'string',
|
|
38
|
+
'super',
|
|
39
|
+
'switch',
|
|
40
|
+
'this',
|
|
41
|
+
'throw',
|
|
42
|
+
'true',
|
|
43
|
+
'try',
|
|
44
|
+
'type',
|
|
45
|
+
'typeof',
|
|
46
|
+
'undefined',
|
|
47
|
+
'var',
|
|
48
|
+
'void',
|
|
49
|
+
'while',
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
const TOKEN_PATTERN =
|
|
53
|
+
/(\/\*[\s\S]*?\*\/|\/\/[^\n]*|#[^\n]*|<!--[\s\S]*?-->|(["'`])(?:\\[\s\S]|(?!\2)[^\\])*\2|<\/?[A-Za-z][^>\n]*?>|\b\d+(?:\.\d+)?\b|\b[A-Za-z_$][\w$]*\b)/g
|
|
54
|
+
|
|
55
|
+
export function highlightArticleHtml(html: string) {
|
|
56
|
+
if (!html.trim()) {
|
|
57
|
+
return ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const doc = new DOMParser().parseFromString(`<article>${html}</article>`, 'text/html')
|
|
61
|
+
const root = doc.body.firstElementChild
|
|
62
|
+
|
|
63
|
+
if (!root) {
|
|
64
|
+
return html
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
root.querySelectorAll('pre code').forEach((code) => {
|
|
68
|
+
const rawCode = code.textContent || ''
|
|
69
|
+
const language = getLanguage(code)
|
|
70
|
+
|
|
71
|
+
code.innerHTML = highlightCode(rawCode)
|
|
72
|
+
code.classList.add('code-highlighted')
|
|
73
|
+
|
|
74
|
+
if (language) {
|
|
75
|
+
code.setAttribute('data-language', language)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return root.innerHTML
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function highlightCode(code: string) {
|
|
83
|
+
let html = ''
|
|
84
|
+
let lastIndex = 0
|
|
85
|
+
|
|
86
|
+
code.replace(TOKEN_PATTERN, (...args) => {
|
|
87
|
+
const token = args[0]
|
|
88
|
+
const offset = args[args.length - 2] as number
|
|
89
|
+
|
|
90
|
+
html += escapeHtml(code.slice(lastIndex, offset))
|
|
91
|
+
html += renderToken(token)
|
|
92
|
+
lastIndex = offset + token.length
|
|
93
|
+
|
|
94
|
+
return token
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
html += escapeHtml(code.slice(lastIndex))
|
|
98
|
+
|
|
99
|
+
return html
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderToken(token: string) {
|
|
103
|
+
if (isComment(token)) {
|
|
104
|
+
return wrapToken(token, 'hl-comment')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (isString(token)) {
|
|
108
|
+
return wrapToken(token, 'hl-string')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isTag(token)) {
|
|
112
|
+
return wrapToken(token, 'hl-tag')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (isNumber(token)) {
|
|
116
|
+
return wrapToken(token, 'hl-number')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (KEYWORDS.has(token)) {
|
|
120
|
+
return wrapToken(token, 'hl-keyword')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return escapeHtml(token)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function wrapToken(token: string, className: string) {
|
|
127
|
+
return `<span class="${className}">${escapeHtml(token)}</span>`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getLanguage(code: Element) {
|
|
131
|
+
const languageAttr = code.getAttribute('data-language')
|
|
132
|
+
const languageClass = Array.from(code.classList).find((className) =>
|
|
133
|
+
className.startsWith('language-'),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return languageAttr || languageClass?.replace(/^language-/, '') || ''
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isComment(token: string) {
|
|
140
|
+
return (
|
|
141
|
+
token.startsWith('//') ||
|
|
142
|
+
token.startsWith('/*') ||
|
|
143
|
+
token.startsWith('#') ||
|
|
144
|
+
token.startsWith('<!--')
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isString(token: string) {
|
|
149
|
+
return /^["'`]/.test(token)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isTag(token: string) {
|
|
153
|
+
return /^<\/?[A-Za-z]/.test(token)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isNumber(token: string) {
|
|
157
|
+
return /^\d/.test(token)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function escapeHtml(value: string) {
|
|
161
|
+
const div = document.createElement('div')
|
|
162
|
+
div.textContent = value
|
|
163
|
+
|
|
164
|
+
return div.innerHTML
|
|
165
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export async function copyText(text: string) {
|
|
2
|
+
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
|
3
|
+
try {
|
|
4
|
+
await navigator.clipboard.writeText(text)
|
|
5
|
+
return
|
|
6
|
+
} catch {
|
|
7
|
+
// HTTP 部署或宿主 WebView 里经常不可用,继续走兼容方案。
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
fallbackCopyText(text)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fallbackCopyText(text: string) {
|
|
15
|
+
const textarea = document.createElement('textarea')
|
|
16
|
+
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
17
|
+
|
|
18
|
+
textarea.value = text
|
|
19
|
+
textarea.setAttribute('readonly', 'true')
|
|
20
|
+
textarea.style.position = 'fixed'
|
|
21
|
+
textarea.style.left = '-9999px'
|
|
22
|
+
textarea.style.top = '0'
|
|
23
|
+
textarea.style.opacity = '0'
|
|
24
|
+
document.body.appendChild(textarea)
|
|
25
|
+
textarea.select()
|
|
26
|
+
textarea.setSelectionRange(0, textarea.value.length)
|
|
27
|
+
|
|
28
|
+
const copied = document.execCommand('copy')
|
|
29
|
+
|
|
30
|
+
document.body.removeChild(textarea)
|
|
31
|
+
previousFocus?.focus()
|
|
32
|
+
|
|
33
|
+
if (!copied) {
|
|
34
|
+
throw new Error('复制失败')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 将 query 对象拼接到 URL 上
|
|
3
|
+
*
|
|
4
|
+
* 自动过滤 undefined/null 值,支持数组参数。
|
|
5
|
+
*/
|
|
6
|
+
export function appendQuery(url: string, query?: Record<string, unknown>): string {
|
|
7
|
+
if (!query) return url
|
|
8
|
+
|
|
9
|
+
const searchParams = new URLSearchParams()
|
|
10
|
+
Object.entries(query).forEach(([key, value]) => {
|
|
11
|
+
if (value === undefined || value === null) return
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
value.forEach((item) => {
|
|
14
|
+
if (item !== undefined && item !== null) {
|
|
15
|
+
searchParams.append(key, String(item))
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
searchParams.append(key, String(value))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const queryString = searchParams.toString()
|
|
24
|
+
if (!queryString) return url
|
|
25
|
+
|
|
26
|
+
return `${url}${url.includes('?') ? '&' : '?'}${queryString}`
|
|
27
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import axios from 'axios'
|
|
2
|
+
import type {
|
|
3
|
+
AxiosError,
|
|
4
|
+
AxiosInstance,
|
|
5
|
+
AxiosRequestConfig,
|
|
6
|
+
AxiosResponse,
|
|
7
|
+
InternalAxiosRequestConfig,
|
|
8
|
+
} from 'axios'
|
|
9
|
+
import NProgress from 'nprogress'
|
|
10
|
+
import 'nprogress/nprogress.css'
|
|
11
|
+
import '@/styles/nprogress.css'
|
|
12
|
+
import { apiConfig } from '@/config/env'
|
|
13
|
+
import type { ApiResponse } from '@/types/api'
|
|
14
|
+
import router from '@/router'
|
|
15
|
+
import {
|
|
16
|
+
clearAuthTokens,
|
|
17
|
+
getStoredAccessToken,
|
|
18
|
+
getStoredRefreshToken,
|
|
19
|
+
REFRESH_TOKEN_URL,
|
|
20
|
+
refreshAccessToken,
|
|
21
|
+
type AuthTokenResponse,
|
|
22
|
+
} from '@/utils/auth-token'
|
|
23
|
+
import { showAppToast } from '@/utils/toast'
|
|
24
|
+
|
|
25
|
+
// 关闭 NProgress 右侧旋转小圆圈,保持进度条简洁
|
|
26
|
+
NProgress.configure({ showSpinner: false })
|
|
27
|
+
|
|
28
|
+
interface AuthRequestConfig extends InternalAxiosRequestConfig {
|
|
29
|
+
skipAuth?: boolean
|
|
30
|
+
_retry?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface AuthAxiosRequestConfig<T = unknown> extends AxiosRequestConfig<T> {
|
|
34
|
+
skipAuth?: boolean
|
|
35
|
+
_retry?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 创建 axios 实例,baseURL 和 timeout 从环境变量配置中读取
|
|
39
|
+
const service: AxiosInstance = axios.create({
|
|
40
|
+
baseURL: apiConfig.baseURL, // 接口基础地址,由 .env 文件中的 VITE_API_BASE_URL 决定
|
|
41
|
+
timeout: apiConfig.timeout, // 请求超时时间(毫秒),默认 10000
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 请求拦截器
|
|
46
|
+
* 在每个请求发出前执行:
|
|
47
|
+
* 1. 启动顶部进度条
|
|
48
|
+
* 2. 自动携带 JWT token 到请求头
|
|
49
|
+
*/
|
|
50
|
+
service.interceptors.request.use(
|
|
51
|
+
(config: AuthRequestConfig) => {
|
|
52
|
+
// 开始加载进度条
|
|
53
|
+
NProgress.start()
|
|
54
|
+
|
|
55
|
+
// 从 Pinia 读取 token,存在则添加到请求头
|
|
56
|
+
const token = getStoredAccessToken()
|
|
57
|
+
if (!config.skipAuth && token) {
|
|
58
|
+
config.headers.Authorization = `Bearer ${token}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return config
|
|
62
|
+
},
|
|
63
|
+
// 请求配置出错时(如网络断开),结束进度条并抛出错误
|
|
64
|
+
(error) => {
|
|
65
|
+
NProgress.done()
|
|
66
|
+
return Promise.reject(error)
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 响应拦截器
|
|
72
|
+
* 在收到响应后执行:
|
|
73
|
+
* 1. 结束进度条
|
|
74
|
+
* 2. 判断后端业务 code,非 0 视为业务失败
|
|
75
|
+
* 3. HTTP 状态码异常时给出中文提示
|
|
76
|
+
* 4. 401 统一清除 token 并跳转登录页
|
|
77
|
+
*/
|
|
78
|
+
service.interceptors.response.use(
|
|
79
|
+
// ——— 成功响应(HTTP 2xx)———
|
|
80
|
+
(response: AxiosResponse<ApiResponse>) => {
|
|
81
|
+
NProgress.done()
|
|
82
|
+
|
|
83
|
+
// 普通业务接口返回 { code, message, data };OAuth 登录接口直接返回 token 结构。
|
|
84
|
+
const res = response.data
|
|
85
|
+
|
|
86
|
+
// 只有响应体明确包含 code 字段时,才按统一业务格式判断,兼容 0 和 "0000" 两种成功码。
|
|
87
|
+
if (typeof res.code !== 'undefined' && !isSuccessCode(res.code)) {
|
|
88
|
+
showAppToast(res.message || '请求失败')
|
|
89
|
+
|
|
90
|
+
// 将业务错误抛出,调用方可通过 catch 捕获
|
|
91
|
+
return Promise.reject(new Error(res.message || '请求失败'))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 业务成功,直接返回 data 字段,调用方无需再解包 response.data.data
|
|
95
|
+
return res as unknown as AxiosResponse
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// ——— 失败响应(HTTP 非 2xx 或网络错误)———
|
|
99
|
+
async (error: AxiosError) => {
|
|
100
|
+
NProgress.done()
|
|
101
|
+
|
|
102
|
+
const originalConfig = error.config as AuthAxiosRequestConfig | undefined
|
|
103
|
+
|
|
104
|
+
if (error.response?.status === 401 && originalConfig && shouldRefreshToken(originalConfig)) {
|
|
105
|
+
try {
|
|
106
|
+
const token = await refreshAccessToken(requestRefreshTokenByAxios)
|
|
107
|
+
|
|
108
|
+
originalConfig._retry = true
|
|
109
|
+
originalConfig.headers = {
|
|
110
|
+
...originalConfig.headers,
|
|
111
|
+
Authorization: `Bearer ${token}`,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return service.request(originalConfig)
|
|
115
|
+
} catch (refreshError) {
|
|
116
|
+
clearAuthTokens()
|
|
117
|
+
router.push('/login')
|
|
118
|
+
showAppToast('登录已过期,请重新登录')
|
|
119
|
+
return Promise.reject(refreshError)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// HTTP 状态码 → 中文提示映射表
|
|
124
|
+
const statusCodeMap: Record<number, string> = {
|
|
125
|
+
400: '请求参数错误',
|
|
126
|
+
401: '未授权,请重新登录',
|
|
127
|
+
403: '拒绝访问',
|
|
128
|
+
404: '请求资源不存在',
|
|
129
|
+
500: '服务器内部错误',
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 优先取映射表中的提示,没有则显示状态码;无 response 说明是网络异常
|
|
133
|
+
const status = error.response?.status as number
|
|
134
|
+
const message = error.response ? statusCodeMap[status] || `连接错误 ${status}` : '网络连接异常'
|
|
135
|
+
|
|
136
|
+
showAppToast(message)
|
|
137
|
+
|
|
138
|
+
// 401 未授权:清除 token 并跳转登录页
|
|
139
|
+
if (error.response?.status === 401) {
|
|
140
|
+
clearAuthTokens()
|
|
141
|
+
router.push('/login')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return Promise.reject(error)
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
function shouldRefreshToken(config: AuthAxiosRequestConfig) {
|
|
149
|
+
return !config.skipAuth && !config._retry && Boolean(getStoredRefreshToken())
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isSuccessCode(code: ApiResponse['code']) {
|
|
153
|
+
return code === 0 || code === '0' || code === '0000'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function requestRefreshTokenByAxios() {
|
|
157
|
+
const refreshToken = getStoredRefreshToken()
|
|
158
|
+
|
|
159
|
+
if (!refreshToken) {
|
|
160
|
+
throw new Error('本地不存在 refresh token')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const response = await axios.request<ApiResponse<AuthTokenResponse> & AuthTokenResponse>({
|
|
164
|
+
baseURL: apiConfig.baseURL,
|
|
165
|
+
timeout: apiConfig.timeout,
|
|
166
|
+
url: REFRESH_TOKEN_URL,
|
|
167
|
+
method: 'POST',
|
|
168
|
+
data: {
|
|
169
|
+
refresh_token:refreshToken,
|
|
170
|
+
},
|
|
171
|
+
headers: {
|
|
172
|
+
'Content-Type': 'application/json',
|
|
173
|
+
Accept: 'application/json',
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const result = response.data
|
|
178
|
+
|
|
179
|
+
if (typeof result.code === 'number' && result.code !== 0) {
|
|
180
|
+
throw new Error(result.message || '刷新 token 失败')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return result.data || result
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 请求配置接口
|
|
188
|
+
* @property url - 请求地址(必填)
|
|
189
|
+
* @property method - 请求方法,默认 GET
|
|
190
|
+
* @property data - 请求体(POST/PUT/PATCH 时使用)
|
|
191
|
+
* @property params - URL 查询参数(拼接到 ?key=value)
|
|
192
|
+
*/
|
|
193
|
+
export interface RequestConfig<T = unknown> {
|
|
194
|
+
url: string
|
|
195
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
196
|
+
data?: T
|
|
197
|
+
params?: Record<string, unknown>
|
|
198
|
+
headers?: Record<string, string>
|
|
199
|
+
auth?: { username: string; password: string } // HTTP Basic Auth
|
|
200
|
+
skipAuth?: boolean
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 通用请求函数
|
|
205
|
+
* @typeParam R - 响应 data 的类型(后端返回的 data 字段类型)
|
|
206
|
+
* @typeParam T - 请求参数的类型(data 的类型)
|
|
207
|
+
* @param config - 请求配置对象
|
|
208
|
+
* @returns Promise,resolve 后直接拿到 ApiResponse<R>
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* // GET 请求
|
|
212
|
+
* request<UserInfo>({ url: '/user/1' })
|
|
213
|
+
*
|
|
214
|
+
* // POST 请求
|
|
215
|
+
* request<{ token: string }, LoginParams>({
|
|
216
|
+
* url: '/login',
|
|
217
|
+
* method: 'POST',
|
|
218
|
+
* data: { username: 'admin', password: '123' }
|
|
219
|
+
* })
|
|
220
|
+
*/
|
|
221
|
+
export function request<R = unknown, T = unknown>(
|
|
222
|
+
config: RequestConfig<T>,
|
|
223
|
+
): Promise<ApiResponse<R>> {
|
|
224
|
+
const axiosConfig: AuthAxiosRequestConfig<T> = {
|
|
225
|
+
url: config.url,
|
|
226
|
+
method: config.method || 'GET',
|
|
227
|
+
data: config.data,
|
|
228
|
+
params: config.params,
|
|
229
|
+
headers: config.headers,
|
|
230
|
+
auth: config.auth,
|
|
231
|
+
skipAuth: config.skipAuth,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return service.request<ApiResponse<R>>(axiosConfig) as unknown as Promise<ApiResponse<R>>
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 同时导出 axios 实例,供需要直接使用 axios 原生方法的场景(如文件上传)
|
|
238
|
+
export default service
|