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.
Files changed (89) hide show
  1. package/.editorconfig +8 -0
  2. package/.env.development +3 -0
  3. package/.env.production +3 -0
  4. package/.env.test +3 -0
  5. package/.gitattributes +1 -0
  6. package/.oxlintrc.json +10 -0
  7. package/.prettierrc.json +6 -0
  8. package/.vscode/extensions.json +9 -0
  9. package/.vscode/settings.json +13 -0
  10. package/README.md +179 -0
  11. package/auto-imports.d.ts +629 -0
  12. package/components.d.ts +21 -0
  13. package/design-qa.md +36 -0
  14. package/docs/MX_API.md +244 -0
  15. package/docs/REQUEST.md +217 -0
  16. package/docs/jsapi.js +515 -0
  17. package/docs/package-json-guide.md +132 -0
  18. package/env.d.ts +15 -0
  19. package/eslint.config.ts +26 -0
  20. package/index.html +16 -0
  21. package/package.json +83 -0
  22. package/plugins/bump-version.ts +61 -0
  23. package/postcss.config.ts +15 -0
  24. package/public/favicon.ico +0 -0
  25. package/public/images/12a73787-86a9-4891-a65f-66104746f6a8.png +0 -0
  26. package/public/images/5798d7aa-ba8b-4605-8079-58b35495ac55.png +0 -0
  27. package/public/images/73fef1e4-0fd0-4a1a-9b8b-a70a5b6acbbc.png +0 -0
  28. package/public/images/bc685b4c-0cca-4a79-924c-a8ee10e6f8eb.png +0 -0
  29. package/public/images/c3dbbd9d-be56-490e-b9f4-6ee17ebefffc.png +0 -0
  30. package/public/images/ea745a10-42aa-4f44-8d7f-3ab02cc0adcd.png +0 -0
  31. package/public/images/f5876785-b927-4347-ba19-999114240649.png +0 -0
  32. package/public/images/img.png +0 -0
  33. package/public/images/opening-reserve-estimate.png +0 -0
  34. package/public/images/position-estimate-report.png +0 -0
  35. package/src/App.vue +131 -0
  36. package/src/api/announcement.ts +405 -0
  37. package/src/api/health.ts +13 -0
  38. package/src/api/pbc-position.ts +178 -0
  39. package/src/api/rmb-position.ts +233 -0
  40. package/src/api/tam.ts +173 -0
  41. package/src/api/user.ts +92 -0
  42. package/src/auto-imports.d.ts +633 -0
  43. package/src/components/AppTitleBar.vue +376 -0
  44. package/src/components.d.ts +33 -0
  45. package/src/config/config.properties +3 -0
  46. package/src/config/env.ts +6 -0
  47. package/src/config/plugin.properties.pro +6 -0
  48. package/src/config/plugin.properties.test +6 -0
  49. package/src/core/mxApi/index.ts +451 -0
  50. package/src/core/request/index.ts +135 -0
  51. package/src/main.ts +40 -0
  52. package/src/router/index.ts +144 -0
  53. package/src/stores/app.ts +103 -0
  54. package/src/stores/counter.ts +12 -0
  55. package/src/stores/user.ts +137 -0
  56. package/src/styles/nprogress.css +22 -0
  57. package/src/styles/vant-overrides.css +42 -0
  58. package/src/types/api.d.ts +14 -0
  59. package/src/types/nprogress.d.ts +8 -0
  60. package/src/utils/auth-token.ts +241 -0
  61. package/src/utils/code-highlight.ts +165 -0
  62. package/src/utils/copy.ts +36 -0
  63. package/src/utils/query.ts +27 -0
  64. package/src/utils/request.ts +238 -0
  65. package/src/utils/rmb-forecast.ts +84 -0
  66. package/src/utils/toast.ts +61 -0
  67. package/src/views/article-detail/index.vue +289 -0
  68. package/src/views/article-edit/index.vue +600 -0
  69. package/src/views/articles/index.vue +293 -0
  70. package/src/views/clearing-detail/index.vue +26 -0
  71. package/src/views/dashboard/index.vue +18 -0
  72. package/src/views/foreign-position/index.vue +26 -0
  73. package/src/views/home/index.vue +213 -0
  74. package/src/views/login/index.vue +275 -0
  75. package/src/views/mine/index.vue +147 -0
  76. package/src/views/net-debit/index.vue +26 -0
  77. package/src/views/opening-reserve-estimate/index.vue +28 -0
  78. package/src/views/pbc-position/index.vue +357 -0
  79. package/src/views/position-estimate/index.vue +67 -0
  80. package/src/views/position-estimate-report/index.vue +28 -0
  81. package/src/views/rmb-position/index.vue +1013 -0
  82. package/src/views/rmb-position-create/index.vue +221 -0
  83. package/src/views/rmb-position-detail/index.vue +355 -0
  84. package/src/views/settings/index.vue +67 -0
  85. package/src/views/warning/index.vue +26 -0
  86. package/tsconfig.app.json +18 -0
  87. package/tsconfig.json +11 -0
  88. package/tsconfig.node.json +28 -0
  89. 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