create-simpleadmin-ui 2.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 (55) hide show
  1. package/README.md +53 -0
  2. package/bin/create-simpleadmin-ui.mjs +2 -0
  3. package/package.json +35 -0
  4. package/src/index.mjs +557 -0
  5. package/src/sync-template.mjs +64 -0
  6. package/template/.env.development +4 -0
  7. package/template/.env.production.example +4 -0
  8. package/template/.vscode/extensions.json +3 -0
  9. package/template/README.md +26 -0
  10. package/template/index.html +19 -0
  11. package/template/package.json +30 -0
  12. package/template/public/vite.svg +1 -0
  13. package/template/src/App.vue +3 -0
  14. package/template/src/components/AppBreadcrumb.vue +132 -0
  15. package/template/src/components/AppPage.vue +23 -0
  16. package/template/src/components/ChangePasswordDialog.vue +97 -0
  17. package/template/src/components/ProfileEditDialog.vue +142 -0
  18. package/template/src/components/RichTextEditor.vue +121 -0
  19. package/template/src/components/SidebarMenuItem.vue +56 -0
  20. package/template/src/components/TagsView.vue +172 -0
  21. package/template/src/constants/menuIcons.ts +205 -0
  22. package/template/src/directives/permission.ts +13 -0
  23. package/template/src/env.d.ts +22 -0
  24. package/template/src/layouts/MainLayout.vue +410 -0
  25. package/template/src/main.ts +27 -0
  26. package/template/src/router/index.ts +198 -0
  27. package/template/src/stores/tagsView.ts +161 -0
  28. package/template/src/stores/theme.ts +68 -0
  29. package/template/src/stores/user.ts +191 -0
  30. package/template/src/styles/global.css +314 -0
  31. package/template/src/utils/datetime.ts +34 -0
  32. package/template/src/utils/request.ts +324 -0
  33. package/template/src/utils/requestSign.ts +162 -0
  34. package/template/src/views/error/NotFound.vue +49 -0
  35. package/template/src/views/home/HomeView.vue +1177 -0
  36. package/template/src/views/login/LoginView.vue +576 -0
  37. package/template/src/views/monitor/loginlog/index.vue +366 -0
  38. package/template/src/views/monitor/online/index.vue +298 -0
  39. package/template/src/views/monitor/operlog/index.vue +492 -0
  40. package/template/src/views/system/config/index.vue +407 -0
  41. package/template/src/views/system/dept/index.vue +517 -0
  42. package/template/src/views/system/dict/index.vue +747 -0
  43. package/template/src/views/system/file/index.vue +1031 -0
  44. package/template/src/views/system/menu/index.vue +822 -0
  45. package/template/src/views/system/message/index.vue +422 -0
  46. package/template/src/views/system/notice/index.vue +578 -0
  47. package/template/src/views/system/openApp/index.vue +596 -0
  48. package/template/src/views/system/post/index.vue +495 -0
  49. package/template/src/views/system/role/index.vue +546 -0
  50. package/template/src/views/system/user/index.vue +373 -0
  51. package/template/src/vite-env.d.ts +1 -0
  52. package/template/tsconfig.app.json +30 -0
  53. package/template/tsconfig.json +7 -0
  54. package/template/tsconfig.node.json +24 -0
  55. package/template/vite.config.ts +22 -0
@@ -0,0 +1,314 @@
1
+ :root,
2
+ html[data-theme='teal'] {
3
+ --sa-font: 'Plus Jakarta Sans', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
4
+ --sa-font-display: 'Plus Jakarta Sans', 'Noto Sans SC', sans-serif;
5
+ --sa-accent: #0f766e;
6
+ --sa-accent-hover: #0d9488;
7
+ --sa-accent-bright: #5eead4;
8
+ --sa-accent-soft: rgba(15, 118, 110, 0.12);
9
+ --sa-ink: #0f172a;
10
+ --sa-ink-secondary: #475569;
11
+ --sa-ink-muted: #94a3b8;
12
+ --sa-surface: #f1f5f9;
13
+ --sa-panel: #ffffff;
14
+ --sa-border: #e2e8f0;
15
+ --sa-header-bg: rgba(255, 255, 255, 0.82);
16
+ --sa-wash-top: #f8fafc;
17
+ --sa-toolbar-bg: #f8fafc;
18
+ --sa-table-header: #f8fafc;
19
+ --sa-table-hover: #f0fdfa;
20
+ --sa-sidebar: #0b1220;
21
+ --sa-sidebar-elevated: #111827;
22
+ --sa-sidebar-text: #94a3b8;
23
+ --sa-sidebar-active: #f8fafc;
24
+ --sa-radius: 14px;
25
+ --sa-radius-sm: 10px;
26
+ --sa-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.04);
27
+ --sa-header-h: 60px;
28
+ --sa-aside-w: 240px;
29
+ --sa-aside-collapsed: 72px;
30
+
31
+ --el-color-primary: #0f766e;
32
+ --el-color-primary-light-3: #2dd4bf;
33
+ --el-color-primary-light-5: #5eead4;
34
+ --el-color-primary-light-7: #99f6e4;
35
+ --el-color-primary-light-8: #ccfbf1;
36
+ --el-color-primary-light-9: #f0fdfa;
37
+ --el-color-primary-dark-2: #115e59;
38
+ --el-border-radius-base: 10px;
39
+ --el-font-family: var(--sa-font);
40
+ }
41
+
42
+ html[data-theme='ocean'] {
43
+ --sa-accent: #0369a1;
44
+ --sa-accent-hover: #0284c7;
45
+ --sa-accent-bright: #7dd3fc;
46
+ --sa-accent-soft: rgba(3, 105, 161, 0.12);
47
+ --sa-table-hover: #f0f9ff;
48
+ --el-color-primary: #0369a1;
49
+ --el-color-primary-light-3: #38bdf8;
50
+ --el-color-primary-light-5: #7dd3fc;
51
+ --el-color-primary-light-7: #bae6fd;
52
+ --el-color-primary-light-8: #e0f2fe;
53
+ --el-color-primary-light-9: #f0f9ff;
54
+ --el-color-primary-dark-2: #075985;
55
+ }
56
+
57
+ html[data-theme='forest'] {
58
+ --sa-accent: #15803d;
59
+ --sa-accent-hover: #16a34a;
60
+ --sa-accent-bright: #86efac;
61
+ --sa-accent-soft: rgba(21, 128, 61, 0.12);
62
+ --sa-table-hover: #f0fdf4;
63
+ --el-color-primary: #15803d;
64
+ --el-color-primary-light-3: #4ade80;
65
+ --el-color-primary-light-5: #86efac;
66
+ --el-color-primary-light-7: #bbf7d0;
67
+ --el-color-primary-light-8: #dcfce7;
68
+ --el-color-primary-light-9: #f0fdf4;
69
+ --el-color-primary-dark-2: #166534;
70
+ }
71
+
72
+ html[data-theme='amber'] {
73
+ --sa-accent: #b45309;
74
+ --sa-accent-hover: #d97706;
75
+ --sa-accent-bright: #fcd34d;
76
+ --sa-accent-soft: rgba(180, 83, 9, 0.12);
77
+ --sa-table-hover: #fffbeb;
78
+ --el-color-primary: #b45309;
79
+ --el-color-primary-light-3: #f59e0b;
80
+ --el-color-primary-light-5: #fcd34d;
81
+ --el-color-primary-light-7: #fde68a;
82
+ --el-color-primary-light-8: #fef3c7;
83
+ --el-color-primary-light-9: #fffbeb;
84
+ --el-color-primary-dark-2: #92400e;
85
+ }
86
+
87
+ html[data-theme='slate'] {
88
+ --sa-accent: #475569;
89
+ --sa-accent-hover: #64748b;
90
+ --sa-accent-bright: #cbd5e1;
91
+ --sa-accent-soft: rgba(71, 85, 105, 0.14);
92
+ --sa-table-hover: #f8fafc;
93
+ --el-color-primary: #475569;
94
+ --el-color-primary-light-3: #94a3b8;
95
+ --el-color-primary-light-5: #cbd5e1;
96
+ --el-color-primary-light-7: #e2e8f0;
97
+ --el-color-primary-light-8: #f1f5f9;
98
+ --el-color-primary-light-9: #f8fafc;
99
+ --el-color-primary-dark-2: #334155;
100
+ }
101
+
102
+ html[data-theme='dark'] {
103
+ --sa-accent: #2dd4bf;
104
+ --sa-accent-hover: #5eead4;
105
+ --sa-accent-bright: #99f6e4;
106
+ --sa-accent-soft: rgba(45, 212, 191, 0.16);
107
+ --sa-ink: #e2e8f0;
108
+ --sa-ink-secondary: #94a3b8;
109
+ --sa-ink-muted: #64748b;
110
+ --sa-surface: #0b1220;
111
+ --sa-panel: #111827;
112
+ --sa-border: #1f2937;
113
+ --sa-header-bg: rgba(17, 24, 39, 0.88);
114
+ --sa-wash-top: #0f172a;
115
+ --sa-toolbar-bg: #0f172a;
116
+ --sa-table-header: #0f172a;
117
+ --sa-table-hover: rgba(45, 212, 191, 0.08);
118
+ --sa-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.28);
119
+ --el-color-primary: #2dd4bf;
120
+ --el-color-primary-light-3: #5eead4;
121
+ --el-color-primary-light-5: #99f6e4;
122
+ --el-color-primary-light-7: #134e4a;
123
+ --el-color-primary-light-8: #115e59;
124
+ --el-color-primary-light-9: #0f3d3a;
125
+ --el-color-primary-dark-2: #14b8a6;
126
+ --el-bg-color: #111827;
127
+ --el-bg-color-overlay: #111827;
128
+ --el-text-color-primary: #e2e8f0;
129
+ --el-text-color-regular: #cbd5e1;
130
+ --el-border-color: #1f2937;
131
+ --el-fill-color-blank: #111827;
132
+ --el-mask-color: rgba(0, 0, 0, 0.6);
133
+ }
134
+
135
+ *,
136
+ *::before,
137
+ *::after {
138
+ box-sizing: border-box;
139
+ }
140
+
141
+ html,
142
+ body,
143
+ #app {
144
+ margin: 0;
145
+ padding: 0;
146
+ height: 100%;
147
+ font-family: var(--sa-font);
148
+ color: var(--sa-ink);
149
+ background: var(--sa-surface);
150
+ -webkit-font-smoothing: antialiased;
151
+ }
152
+
153
+ a {
154
+ color: inherit;
155
+ text-decoration: none;
156
+ }
157
+
158
+ .sa-page {
159
+ display: flex;
160
+ flex-direction: column;
161
+ gap: 16px;
162
+ animation: sa-fade-up 0.35s ease both;
163
+ }
164
+
165
+ .sa-page__head {
166
+ display: flex;
167
+ align-items: flex-end;
168
+ justify-content: space-between;
169
+ gap: 16px;
170
+ flex-wrap: wrap;
171
+ }
172
+
173
+ .sa-page__title {
174
+ margin: 0;
175
+ font-family: var(--sa-font-display);
176
+ font-size: 22px;
177
+ font-weight: 700;
178
+ letter-spacing: -0.02em;
179
+ color: var(--sa-ink);
180
+ }
181
+
182
+ .sa-page__desc {
183
+ margin: 6px 0 0;
184
+ font-size: 13px;
185
+ color: var(--sa-ink-secondary);
186
+ }
187
+
188
+ .sa-panel {
189
+ background: var(--sa-panel);
190
+ border: 1px solid var(--sa-border);
191
+ border-radius: var(--sa-radius);
192
+ box-shadow: var(--sa-shadow);
193
+ padding: 18px 20px 16px;
194
+ overflow: visible;
195
+ }
196
+
197
+ .sa-panel .el-card {
198
+ border: none;
199
+ box-shadow: none;
200
+ background: transparent;
201
+ }
202
+
203
+ .sa-panel .el-card__body {
204
+ padding: 0;
205
+ }
206
+
207
+ .toolbar {
208
+ display: flex;
209
+ flex-wrap: wrap;
210
+ gap: 10px;
211
+ align-items: center;
212
+ margin-bottom: 16px;
213
+ padding: 12px 14px;
214
+ background: var(--sa-toolbar-bg);
215
+ border: 1px solid var(--sa-border);
216
+ border-radius: var(--sa-radius-sm);
217
+ }
218
+
219
+ .pager {
220
+ margin-top: 16px;
221
+ display: flex;
222
+ justify-content: flex-end;
223
+ }
224
+
225
+ /* 列表时间列:固定格式、禁止换行 */
226
+ .datetime-cell,
227
+ .el-table .is-datetime .cell {
228
+ white-space: nowrap !important;
229
+ word-break: keep-all;
230
+ font-variant-numeric: tabular-nums;
231
+ font-family: var(--sa-font);
232
+ }
233
+
234
+ .el-table {
235
+ --el-table-header-bg-color: var(--sa-table-header);
236
+ --el-table-row-hover-bg-color: var(--sa-table-hover);
237
+ --el-table-bg-color: var(--sa-panel);
238
+ --el-table-tr-bg-color: var(--sa-panel);
239
+ --el-table-text-color: var(--sa-ink);
240
+ --el-table-border-color: var(--sa-border);
241
+ border-radius: var(--sa-radius-sm);
242
+ overflow: hidden;
243
+ }
244
+
245
+ .el-table th.el-table__cell {
246
+ font-weight: 600;
247
+ color: var(--sa-ink-secondary);
248
+ font-size: 13px;
249
+ }
250
+
251
+ .el-dialog {
252
+ border-radius: 16px !important;
253
+ overflow: visible;
254
+ background: var(--sa-panel) !important;
255
+ }
256
+
257
+ .el-overlay-dialog {
258
+ overflow: auto !important;
259
+ display: flex !important;
260
+ align-items: center;
261
+ justify-content: center;
262
+ }
263
+
264
+ .el-dialog__footer {
265
+ padding: 12px 20px 18px;
266
+ border-top: 1px solid var(--sa-border);
267
+ background: var(--sa-panel);
268
+ }
269
+
270
+ .el-dialog__body {
271
+ padding-top: 12px;
272
+ padding-bottom: 8px;
273
+ }
274
+
275
+ .el-dialog__title {
276
+ color: var(--sa-ink);
277
+ }
278
+
279
+ .el-button {
280
+ font-weight: 600;
281
+ }
282
+
283
+ /* 表格操作列:实体小按钮横向排列 */
284
+ .row-actions {
285
+ display: inline-flex;
286
+ flex-wrap: wrap;
287
+ align-items: center;
288
+ justify-content: center;
289
+ gap: 6px;
290
+ }
291
+
292
+ .row-actions .el-button + .el-button {
293
+ margin-left: 0;
294
+ }
295
+
296
+ @keyframes sa-fade-up {
297
+ from {
298
+ opacity: 0;
299
+ transform: translateY(8px);
300
+ }
301
+ to {
302
+ opacity: 1;
303
+ transform: translateY(0);
304
+ }
305
+ }
306
+
307
+ @keyframes sa-fade-in {
308
+ from {
309
+ opacity: 0;
310
+ }
311
+ to {
312
+ opacity: 1;
313
+ }
314
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 将接口时间格式化为 yyyy-MM-dd HH:mm:ss(单行展示)。
3
+ * @param value ISO 字符串、Date 或时间戳
4
+ */
5
+ export function formatDateTime(value?: string | number | Date | null): string {
6
+ if (value === null || value === undefined || value === '') {
7
+ return '-'
8
+ }
9
+
10
+ const date = value instanceof Date ? value : new Date(value)
11
+ if (Number.isNaN(date.getTime())) {
12
+ return String(value)
13
+ }
14
+
15
+ const pad = (n: number) => String(n).padStart(2, '0')
16
+ const y = date.getFullYear()
17
+ const m = pad(date.getMonth() + 1)
18
+ const d = pad(date.getDate())
19
+ const hh = pad(date.getHours())
20
+ const mm = pad(date.getMinutes())
21
+ const ss = pad(date.getSeconds())
22
+ return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
23
+ }
24
+
25
+ /** 常见时间字段名,用于批量识别 */
26
+ export const DATE_TIME_PROPS = new Set([
27
+ 'createTime',
28
+ 'updateTime',
29
+ 'loginTime',
30
+ 'operTime',
31
+ 'readTime',
32
+ 'deleteTime',
33
+ 'pwdUpdateTime',
34
+ ])
@@ -0,0 +1,324 @@
1
+ import axios, {
2
+ AxiosHeaders,
3
+ type AxiosInstance,
4
+ type AxiosRequestConfig,
5
+ type AxiosResponse,
6
+ type InternalAxiosRequestConfig,
7
+ } from 'axios'
8
+ import { ElMessage } from 'element-plus'
9
+ import { useUserStore } from '@/stores/user'
10
+ import router from '@/router'
11
+ import {
12
+ buildCanonicalString,
13
+ buildSignQuery,
14
+ createNonce,
15
+ hmacSha256Hex,
16
+ isRequestSignFailureMessage,
17
+ resolveApiPath,
18
+ sha256Hex,
19
+ EMPTY_BODY_SHA256,
20
+ } from '@/utils/requestSign'
21
+
22
+ /** 统一业务响应 */
23
+ export interface ApiResult<T = unknown> {
24
+ code: number
25
+ message: string
26
+ data: T
27
+ traceId?: string
28
+ }
29
+
30
+ /** 扩展请求配置:静默失败时不弹 Toast;可附带幂等键 */
31
+ export interface SaRequestConfig extends AxiosRequestConfig {
32
+ /** 为 true 时业务/网络失败不弹出全局错误提示 */
33
+ silent?: boolean
34
+ /** 幂等键;会写入请求头 Idempotency-Key(所有方法) */
35
+ idempotencyKey?: string
36
+ /** 为 true 时 401 不触发 refresh(用于 /auth/refresh 自身) */
37
+ skipAuthRefresh?: boolean
38
+ }
39
+
40
+ type SaInternalConfig = InternalAxiosRequestConfig & {
41
+ silent?: boolean
42
+ idempotencyKey?: string
43
+ skipAuthRefresh?: boolean
44
+ /** 已因 401 重试过,避免死循环 */
45
+ _retriedAfterRefresh?: boolean
46
+ }
47
+
48
+ const SIGN_APP_ID = import.meta.env.VITE_SIGN_APP_ID || 'simple-admin-web'
49
+ const SIGN_APP_SECRET =
50
+ import.meta.env.VITE_SIGN_APP_SECRET || 'SimpleAdmin_Dev_Sign_Secret_Change_Me_32'
51
+
52
+ /** 并发 401 共用一次刷新 */
53
+ let refreshFlight: Promise<boolean> | null = null
54
+
55
+ /**
56
+ * 生成幂等键;同一业务操作重试时请复用同一字符串,勿每次重新生成。
57
+ * @param prefix 业务前缀
58
+ */
59
+ export function createIdempotencyKey(prefix = 'sa'): string {
60
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
61
+ return `${prefix}-${crypto.randomUUID()}`
62
+ }
63
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
64
+ }
65
+
66
+ /**
67
+ * 由 method + url + body 生成稳定幂等键(仅写请求)。
68
+ */
69
+ function buildStableIdempotencyKey(method: string, url: string, payload: string): string {
70
+ return `auto-${simpleHash(`${method}|${url}|${payload}`)}`
71
+ }
72
+
73
+ /** 简易字符串哈希(非加密,仅用于幂等键)。 */
74
+ function simpleHash(input: string): string {
75
+ let h = 2166136261
76
+ for (let i = 0; i < input.length; i++) {
77
+ h ^= input.charCodeAt(i)
78
+ h = Math.imul(h, 16777619)
79
+ }
80
+ return (h >>> 0).toString(16).padStart(8, '0')
81
+ }
82
+
83
+ function shouldSkipAuthExempt(url: string): boolean {
84
+ const u = url.toLowerCase()
85
+ // 登录须签名;仅短信、图形验证码与登录选项免签(与后端 RequestSign:ExemptPathPrefixes 一致)
86
+ return (
87
+ u.includes('/auth/sms-code') ||
88
+ u.includes('/auth/captcha') ||
89
+ u.includes('/auth/login-options')
90
+ )
91
+ }
92
+
93
+ function isAuthRefreshUrl(url: string): boolean {
94
+ return url.toLowerCase().includes('/auth/refresh')
95
+ }
96
+
97
+ function isWriteMethod(method: string): boolean {
98
+ return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE'
99
+ }
100
+
101
+ /**
102
+ * 规范化 JSON body,保证哈希字节与实际上送一致。
103
+ */
104
+ function normalizeJsonBody(config: InternalAxiosRequestConfig, headers: AxiosHeaders): string {
105
+ const data = config.data
106
+ if (data == null || data === '') return ''
107
+ if (typeof FormData !== 'undefined' && data instanceof FormData) return ''
108
+ if (typeof Blob !== 'undefined' && data instanceof Blob) return ''
109
+ if (typeof data === 'string') return data
110
+
111
+ const bodyStr = JSON.stringify(data)
112
+ config.data = bodyStr
113
+ headers.set('Content-Type', 'application/json')
114
+ return bodyStr
115
+ }
116
+
117
+ /**
118
+ * 一次性写入幂等头 + 签名头,减少 AxiosHeaders 重建。
119
+ */
120
+ async function applySecurityHeaders(config: InternalAxiosRequestConfig) {
121
+ const url = String(config.url || '')
122
+ if (shouldSkipAuthExempt(url)) return
123
+
124
+ const headers = AxiosHeaders.from(config.headers ?? {})
125
+ const method = (config.method || 'get').toUpperCase()
126
+ const isMultipart =
127
+ typeof FormData !== 'undefined' && config.data instanceof FormData
128
+ const bodyText = isMultipart ? '' : normalizeJsonBody(config, headers)
129
+ const write = isWriteMethod(method)
130
+
131
+ // 读请求:随机幂等键即可(后端不做回放);写请求:稳定键利于去重
132
+ const explicit = (config as SaInternalConfig).idempotencyKey
133
+ let idempotencyKey = String(headers.get('Idempotency-Key') || '')
134
+ if (explicit) {
135
+ idempotencyKey = String(explicit)
136
+ } else if (!idempotencyKey) {
137
+ idempotencyKey = write
138
+ ? buildStableIdempotencyKey(method, url, bodyText || JSON.stringify(config.params ?? ''))
139
+ : createIdempotencyKey('get')
140
+ }
141
+ headers.set('Idempotency-Key', idempotencyKey)
142
+
143
+ const path = resolveApiPath(config.baseURL ?? http.defaults.baseURL, url)
144
+ const query = buildSignQuery(config.params)
145
+ // GET 等空 body 跳过 digest
146
+ const bodySha256 =
147
+ isMultipart || !bodyText ? EMPTY_BODY_SHA256 : await sha256Hex(bodyText)
148
+
149
+ const timestamp = Math.floor(Date.now() / 1000).toString()
150
+ const nonce = createNonce()
151
+ const canonical = buildCanonicalString({
152
+ method,
153
+ path,
154
+ query,
155
+ timestamp,
156
+ nonce,
157
+ bodySha256,
158
+ idempotencyKey,
159
+ })
160
+ const sign = await hmacSha256Hex(SIGN_APP_SECRET, canonical)
161
+
162
+ headers.set('X-App-Id', SIGN_APP_ID)
163
+ headers.set('X-Timestamp', timestamp)
164
+ headers.set('X-Nonce', nonce)
165
+ headers.set('X-Sign', sign)
166
+ config.headers = headers
167
+ }
168
+
169
+ const http: AxiosInstance = axios.create({
170
+ baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
171
+ timeout: 30000,
172
+ })
173
+
174
+ function isSilent(config?: SaInternalConfig | SaRequestConfig) {
175
+ return Boolean(config && (config as SaInternalConfig).silent)
176
+ }
177
+
178
+ /**
179
+ * 单飞刷新 access;成功返回 true。
180
+ */
181
+ function runRefreshOnce(): Promise<boolean> {
182
+ if (!refreshFlight) {
183
+ refreshFlight = useUserStore()
184
+ .refreshSession()
185
+ .then(() => true)
186
+ .catch(() => false)
187
+ .finally(() => {
188
+ refreshFlight = null
189
+ })
190
+ }
191
+ return refreshFlight
192
+ }
193
+
194
+ function redirectToLogin(silent: boolean) {
195
+ useUserStore().logoutLocal()
196
+ router.push('/login')
197
+ if (!silent) {
198
+ ElMessage.error('登录已失效,请重新登录')
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 业务/HTTP 401 时尝试刷新并重试;失败则跳登录。
204
+ */
205
+ async function handleUnauthorized(
206
+ config: SaInternalConfig | undefined,
207
+ silent: boolean,
208
+ ): Promise<AxiosResponse<ApiResult> | null> {
209
+ if (!config) {
210
+ redirectToLogin(silent)
211
+ return null
212
+ }
213
+
214
+ const url = String(config.url || '')
215
+ if (
216
+ config.skipAuthRefresh ||
217
+ config._retriedAfterRefresh ||
218
+ isAuthRefreshUrl(url) ||
219
+ !useUserStore().refreshToken
220
+ ) {
221
+ redirectToLogin(silent)
222
+ return null
223
+ }
224
+
225
+ const ok = await runRefreshOnce()
226
+ if (!ok) {
227
+ redirectToLogin(silent)
228
+ return null
229
+ }
230
+
231
+ config._retriedAfterRefresh = true
232
+ const headers = AxiosHeaders.from(config.headers ?? {})
233
+ headers.set('Authorization', `Bearer ${useUserStore().token}`)
234
+ // 清旧签名,由请求拦截器重签
235
+ headers.delete('X-App-Id')
236
+ headers.delete('X-Timestamp')
237
+ headers.delete('X-Nonce')
238
+ headers.delete('X-Sign')
239
+ config.headers = headers
240
+ return http.request(config)
241
+ }
242
+
243
+ http.interceptors.request.use(async (config) => {
244
+ const store = useUserStore()
245
+ const headers = AxiosHeaders.from(config.headers ?? {})
246
+ if (store.token) {
247
+ headers.set('Authorization', `Bearer ${store.token}`)
248
+ }
249
+ config.headers = headers
250
+
251
+ await applySecurityHeaders(config)
252
+ return config
253
+ })
254
+
255
+ http.interceptors.response.use(
256
+ async (response: AxiosResponse<ApiResult>) => {
257
+ const body = response.data
258
+ const cfg = response.config as SaInternalConfig
259
+ const silent = isSilent(cfg)
260
+ if (body && typeof body.code === 'number' && body.code !== 0) {
261
+ if (body.code === 401 && !isRequestSignFailureMessage(body.message)) {
262
+ const retried = await handleUnauthorized(cfg, silent)
263
+ if (retried) return retried
264
+ return Promise.reject(body)
265
+ }
266
+ if (!silent) {
267
+ ElMessage.error(body.message || '请求失败')
268
+ }
269
+ return Promise.reject(body)
270
+ }
271
+ return response
272
+ },
273
+ async (error) => {
274
+ const status = error.response?.status
275
+ const cfg = error.config as SaInternalConfig | undefined
276
+ const silent = isSilent(cfg)
277
+ const msg = error.response?.data?.message as string | undefined
278
+ if (status === 401 && !isRequestSignFailureMessage(msg)) {
279
+ const retried = await handleUnauthorized(cfg, silent)
280
+ if (retried) return retried
281
+ return Promise.reject(error)
282
+ }
283
+ if (!silent) {
284
+ ElMessage.error(msg || error.message || '网络异常')
285
+ }
286
+ return Promise.reject(error)
287
+ },
288
+ )
289
+
290
+ export async function get<T>(url: string, params?: object): Promise<T> {
291
+ const res = await http.get<ApiResult<T>>(url, { params })
292
+ return res.data.data
293
+ }
294
+
295
+ /**
296
+ * 静默 GET:失败不弹全局错误提示(用于首页概览等可选数据)。
297
+ * @param url 相对路径
298
+ * @param params 查询参数
299
+ */
300
+ export async function getSilent<T>(url: string, params?: object): Promise<T | null> {
301
+ try {
302
+ const res = await http.get<ApiResult<T>>(url, { params, silent: true } as SaRequestConfig)
303
+ return res.data.data
304
+ } catch {
305
+ return null
306
+ }
307
+ }
308
+
309
+ export async function post<T>(url: string, data?: object, config?: SaRequestConfig): Promise<T> {
310
+ const res = await http.post<ApiResult<T>>(url, data, config)
311
+ return res.data.data
312
+ }
313
+
314
+ export async function put<T>(url: string, data?: object, config?: SaRequestConfig): Promise<T> {
315
+ const res = await http.put<ApiResult<T>>(url, data, config)
316
+ return res.data.data
317
+ }
318
+
319
+ export async function del<T>(url: string, data?: object, config?: SaRequestConfig): Promise<T> {
320
+ const res = await http.delete<ApiResult<T>>(url, { ...config, data })
321
+ return res.data.data
322
+ }
323
+
324
+ export default http