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,410 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { useUserStore } from '@/stores/user'
5
+ import { THEME_OPTIONS, useThemeStore, type ThemeId } from '@/stores/theme'
6
+ import { useTagsViewStore } from '@/stores/tagsView'
7
+ import { resetDynamicRoutes } from '@/router'
8
+ import ChangePasswordDialog from '@/components/ChangePasswordDialog.vue'
9
+ import ProfileEditDialog from '@/components/ProfileEditDialog.vue'
10
+ import SidebarMenuItem from '@/components/SidebarMenuItem.vue'
11
+ import AppBreadcrumb from '@/components/AppBreadcrumb.vue'
12
+ import TagsView from '@/components/TagsView.vue'
13
+ import {
14
+ Fold,
15
+ Expand,
16
+ SwitchButton,
17
+ Bell,
18
+ Brush,
19
+ Lock,
20
+ ArrowDown,
21
+ User,
22
+ } from '@element-plus/icons-vue'
23
+
24
+ const collapsed = ref(false)
25
+ const pwdVisible = ref(false)
26
+ const profileVisible = ref(false)
27
+ const route = useRoute()
28
+ const router = useRouter()
29
+ const userStore = useUserStore()
30
+ const themeStore = useThemeStore()
31
+ const tagsStore = useTagsViewStore()
32
+
33
+ const active = computed(() => route.path)
34
+ const sidebarMenus = computed(() => (userStore.menus || []).filter((m) => !m.meta?.hidden))
35
+
36
+ function onThemeSelect(id: ThemeId) {
37
+ themeStore.setTheme(id)
38
+ }
39
+
40
+ async function onLogout() {
41
+ await userStore.logout()
42
+ tagsStore.reset()
43
+ resetDynamicRoutes()
44
+ router.push('/login')
45
+ }
46
+ </script>
47
+
48
+ <template>
49
+ <div class="layout" :class="{ 'is-collapsed': collapsed }">
50
+ <aside class="aside">
51
+ <div class="brand">
52
+ <span class="brand__mark">S</span>
53
+ <div v-show="!collapsed" class="brand__text">
54
+ <strong>SimpleAdmin</strong>
55
+ <small>Control Center</small>
56
+ </div>
57
+ </div>
58
+
59
+ <el-scrollbar class="aside__scroll">
60
+ <el-menu
61
+ :default-active="active"
62
+ :collapse="collapsed"
63
+ :collapse-transition="false"
64
+ router
65
+ class="aside__menu"
66
+ >
67
+ <SidebarMenuItem v-for="item in sidebarMenus" :key="item.path + item.name" :item="item" />
68
+ </el-menu>
69
+ </el-scrollbar>
70
+ </aside>
71
+
72
+ <section class="main">
73
+ <header class="header">
74
+ <button class="icon-btn" type="button" @click="collapsed = !collapsed">
75
+ <el-icon :size="18">
76
+ <Fold v-if="!collapsed" />
77
+ <Expand v-else />
78
+ </el-icon>
79
+ </button>
80
+ <div class="header__crumb">
81
+ <span class="header__eyebrow">Workspace</span>
82
+ <AppBreadcrumb />
83
+ </div>
84
+ <div class="spacer" />
85
+
86
+ <el-dropdown trigger="click" @command="onThemeSelect">
87
+ <button class="icon-btn" type="button" title="切换主题">
88
+ <el-icon :size="18"><Brush /></el-icon>
89
+ </button>
90
+ <template #dropdown>
91
+ <el-dropdown-menu class="theme-menu">
92
+ <el-dropdown-item
93
+ v-for="t in THEME_OPTIONS"
94
+ :key="t.id"
95
+ :command="t.id"
96
+ :class="{ 'is-active-theme': themeStore.themeId === t.id }"
97
+ >
98
+ <span class="theme-swatch" :style="{ background: t.swatch }" />
99
+ <span>{{ t.label }}</span>
100
+ <span v-if="themeStore.themeId === t.id" class="theme-check">当前</span>
101
+ </el-dropdown-item>
102
+ </el-dropdown-menu>
103
+ </template>
104
+ </el-dropdown>
105
+
106
+ <button class="icon-btn" type="button" title="消息" @click="router.push('/system/message')">
107
+ <el-icon :size="18"><Bell /></el-icon>
108
+ </button>
109
+
110
+ <el-dropdown trigger="click">
111
+ <div class="user-chip user-chip--btn">
112
+ <span class="user-chip__avatar">
113
+ {{ (userStore.userInfo?.nickName || userStore.userInfo?.userName || 'A').slice(0, 1) }}
114
+ </span>
115
+ <div class="user-chip__meta">
116
+ <strong>{{ userStore.userInfo?.nickName || userStore.userInfo?.userName }}</strong>
117
+ <small>{{ userStore.userInfo?.roles?.[0] || 'user' }}</small>
118
+ </div>
119
+ <el-icon class="user-chip__caret"><ArrowDown /></el-icon>
120
+ </div>
121
+ <template #dropdown>
122
+ <el-dropdown-menu>
123
+ <el-dropdown-item :icon="User" @click="profileVisible = true">修改个人信息</el-dropdown-item>
124
+ <el-dropdown-item :icon="Lock" @click="pwdVisible = true">修改密码</el-dropdown-item>
125
+ <el-dropdown-item divided :icon="SwitchButton" @click="onLogout">退出登录</el-dropdown-item>
126
+ </el-dropdown-menu>
127
+ </template>
128
+ </el-dropdown>
129
+ </header>
130
+
131
+ <TagsView />
132
+
133
+ <main class="content">
134
+ <div class="content__wash" />
135
+ <div class="content__inner">
136
+ <router-view v-slot="{ Component }">
137
+ <keep-alive :include="tagsStore.cachedNames">
138
+ <component :is="Component" />
139
+ </keep-alive>
140
+ </router-view>
141
+ </div>
142
+ </main>
143
+ </section>
144
+
145
+ <ChangePasswordDialog v-model="pwdVisible" />
146
+ <ProfileEditDialog v-model="profileVisible" />
147
+ </div>
148
+ </template>
149
+
150
+ <style scoped lang="scss">
151
+ .layout {
152
+ display: flex;
153
+ min-height: 100vh;
154
+ background: var(--sa-surface);
155
+ }
156
+
157
+ .aside {
158
+ width: var(--sa-aside-w);
159
+ flex-shrink: 0;
160
+ background:
161
+ radial-gradient(circle at 20% 0%, color-mix(in srgb, var(--sa-accent) 18%, transparent), transparent 42%),
162
+ linear-gradient(180deg, #0f172a 0%, var(--sa-sidebar) 100%);
163
+ color: var(--sa-sidebar-text);
164
+ transition: width 0.28s ease;
165
+ display: flex;
166
+ flex-direction: column;
167
+ border-right: 1px solid rgba(148, 163, 184, 0.08);
168
+ }
169
+
170
+ .layout.is-collapsed .aside {
171
+ width: var(--sa-aside-collapsed);
172
+ }
173
+
174
+ .brand {
175
+ height: var(--sa-header-h);
176
+ display: flex;
177
+ align-items: center;
178
+ gap: 12px;
179
+ padding: 0 18px;
180
+ border-bottom: 1px solid rgba(148, 163, 184, 0.1);
181
+ }
182
+
183
+ .brand__mark {
184
+ width: 34px;
185
+ height: 34px;
186
+ border-radius: 11px;
187
+ display: grid;
188
+ place-items: center;
189
+ font-weight: 800;
190
+ color: #042f2e;
191
+ background: linear-gradient(135deg, var(--sa-accent-bright), var(--sa-accent));
192
+ flex-shrink: 0;
193
+ }
194
+
195
+ .brand__text {
196
+ display: flex;
197
+ flex-direction: column;
198
+ line-height: 1.15;
199
+ strong {
200
+ color: #f8fafc;
201
+ font-size: 15px;
202
+ font-weight: 700;
203
+ letter-spacing: -0.02em;
204
+ }
205
+ small {
206
+ color: #64748b;
207
+ font-size: 11px;
208
+ margin-top: 2px;
209
+ }
210
+ }
211
+
212
+ .aside__scroll {
213
+ flex: 1;
214
+ padding: 12px 10px 20px;
215
+ }
216
+
217
+ .aside__menu {
218
+ border-right: none !important;
219
+ background: transparent !important;
220
+ --el-menu-bg-color: transparent;
221
+ --el-menu-hover-bg-color: rgba(148, 163, 184, 0.1);
222
+ --el-menu-text-color: #94a3b8;
223
+ --el-menu-active-color: #f8fafc;
224
+ --el-menu-item-height: 44px;
225
+ }
226
+
227
+ :deep(.aside__menu .el-menu-item),
228
+ :deep(.aside__menu .el-sub-menu__title) {
229
+ border-radius: 10px;
230
+ margin: 2px 0;
231
+ }
232
+
233
+ :deep(.aside__menu .el-menu-item.is-active) {
234
+ background: linear-gradient(90deg, color-mix(in srgb, var(--sa-accent) 28%, transparent), color-mix(in srgb, var(--sa-accent) 6%, transparent));
235
+ color: #f8fafc;
236
+ font-weight: 600;
237
+ }
238
+
239
+ .main {
240
+ flex: 1;
241
+ min-width: 0;
242
+ display: flex;
243
+ flex-direction: column;
244
+ }
245
+
246
+ .header {
247
+ height: var(--sa-header-h);
248
+ display: flex;
249
+ align-items: center;
250
+ gap: 12px;
251
+ padding: 0 20px;
252
+ background: var(--sa-header-bg);
253
+ backdrop-filter: blur(12px);
254
+ border-bottom: 1px solid var(--sa-border);
255
+ position: sticky;
256
+ top: 0;
257
+ z-index: 20;
258
+ }
259
+
260
+ .header__crumb {
261
+ display: flex;
262
+ flex-direction: column;
263
+ line-height: 1.15;
264
+ min-width: 0;
265
+ max-width: min(560px, 48vw);
266
+ }
267
+
268
+ .header__eyebrow {
269
+ font-size: 11px;
270
+ letter-spacing: 0.08em;
271
+ text-transform: uppercase;
272
+ color: var(--sa-ink-muted);
273
+ margin-bottom: 2px;
274
+ }
275
+
276
+ .icon-btn {
277
+ width: 36px;
278
+ height: 36px;
279
+ border: 1px solid var(--sa-border);
280
+ border-radius: 10px;
281
+ background: var(--sa-panel);
282
+ display: grid;
283
+ place-items: center;
284
+ cursor: pointer;
285
+ color: var(--sa-ink-secondary);
286
+ transition: border-color 0.2s, color 0.2s, transform 0.2s;
287
+ flex-shrink: 0;
288
+ &:hover {
289
+ border-color: var(--sa-accent);
290
+ color: var(--sa-accent);
291
+ transform: translateY(-1px);
292
+ }
293
+ }
294
+
295
+ .spacer {
296
+ flex: 1;
297
+ }
298
+
299
+ .user-chip {
300
+ display: flex;
301
+ align-items: center;
302
+ gap: 10px;
303
+ padding: 4px 10px 4px 4px;
304
+ border: 1px solid var(--sa-border);
305
+ border-radius: 999px;
306
+ background: var(--sa-panel);
307
+ }
308
+
309
+ .user-chip--btn {
310
+ cursor: pointer;
311
+ outline: none;
312
+ transition: border-color 0.2s;
313
+ &:hover {
314
+ border-color: var(--sa-accent);
315
+ }
316
+ }
317
+
318
+ .user-chip__avatar {
319
+ width: 32px;
320
+ height: 32px;
321
+ border-radius: 50%;
322
+ display: grid;
323
+ place-items: center;
324
+ font-weight: 700;
325
+ font-size: 13px;
326
+ color: #fff;
327
+ background: linear-gradient(135deg, var(--sa-accent-bright), var(--sa-accent));
328
+ }
329
+
330
+ .user-chip__meta {
331
+ display: none;
332
+ flex-direction: column;
333
+ line-height: 1.1;
334
+ padding-right: 2px;
335
+ strong {
336
+ font-size: 13px;
337
+ color: var(--sa-ink);
338
+ }
339
+ small {
340
+ font-size: 11px;
341
+ color: var(--sa-ink-muted);
342
+ margin-top: 2px;
343
+ }
344
+ @media (min-width: 900px) {
345
+ display: flex;
346
+ }
347
+ }
348
+
349
+ .user-chip__caret {
350
+ color: var(--sa-ink-muted);
351
+ font-size: 12px;
352
+ }
353
+
354
+ .content {
355
+ position: relative;
356
+ flex: 1;
357
+ overflow: auto;
358
+ }
359
+
360
+ .content__wash {
361
+ pointer-events: none;
362
+ position: absolute;
363
+ inset: 0;
364
+ background:
365
+ radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--sa-accent) 14%, transparent), transparent 28%),
366
+ radial-gradient(circle at 0% 100%, color-mix(in srgb, var(--sa-accent-hover) 10%, transparent), transparent 30%),
367
+ linear-gradient(180deg, var(--sa-wash-top) 0%, var(--sa-surface) 48%);
368
+ }
369
+
370
+ .content__inner {
371
+ position: relative;
372
+ padding: 20px 22px 28px;
373
+ min-height: calc(100vh - var(--sa-header-h) - 40px);
374
+ }
375
+
376
+ .sa-page-enter-active,
377
+ .sa-page-leave-active {
378
+ transition: opacity 0.22s ease;
379
+ }
380
+ .sa-page-enter-from,
381
+ .sa-page-leave-to {
382
+ opacity: 0;
383
+ }
384
+ </style>
385
+
386
+ <style lang="scss">
387
+ .theme-menu .el-dropdown-menu__item {
388
+ display: flex;
389
+ align-items: center;
390
+ gap: 10px;
391
+ min-width: 148px;
392
+ }
393
+ .theme-menu .el-dropdown-menu__item.is-active-theme {
394
+ color: var(--sa-accent);
395
+ font-weight: 600;
396
+ }
397
+ .theme-swatch {
398
+ width: 14px;
399
+ height: 14px;
400
+ border-radius: 50%;
401
+ border: 1px solid rgba(15, 23, 42, 0.12);
402
+ flex-shrink: 0;
403
+ }
404
+ .theme-check {
405
+ margin-left: auto;
406
+ font-size: 11px;
407
+ color: var(--sa-ink-muted);
408
+ font-weight: 500;
409
+ }
410
+ </style>
@@ -0,0 +1,27 @@
1
+ import { createApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import ElementPlus from 'element-plus'
4
+ import zhCn from 'element-plus/es/locale/lang/zh-cn'
5
+ import 'element-plus/dist/index.css'
6
+ import '@/styles/global.css'
7
+ import App from './App.vue'
8
+ import router from './router'
9
+ import { permission } from '@/directives/permission'
10
+ import { useThemeStore } from '@/stores/theme'
11
+ import { useTagsViewStore } from '@/stores/tagsView'
12
+
13
+ const app = createApp(App)
14
+ const pinia = createPinia()
15
+ app.use(pinia)
16
+ useThemeStore(pinia).init()
17
+ app.use(router)
18
+ app.use(ElementPlus, { locale: zhCn })
19
+ app.directive('permission', permission)
20
+
21
+ router.afterEach((to) => {
22
+ if (to.path === '/login') {
23
+ useTagsViewStore().reset()
24
+ }
25
+ })
26
+
27
+ app.mount('#app')
@@ -0,0 +1,198 @@
1
+ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
2
+ import { useUserStore } from '@/stores/user'
3
+ import { get } from '@/utils/request'
4
+
5
+ const Layout = () => import('@/layouts/MainLayout.vue')
6
+
7
+ /** 静态路由(不含 catch-all,避免抢先匹配动态路由) */
8
+ export const constantRoutes: RouteRecordRaw[] = [
9
+ {
10
+ path: '/login',
11
+ name: 'Login',
12
+ component: () => import('@/views/login/LoginView.vue'),
13
+ meta: { title: '登录', public: true },
14
+ },
15
+ {
16
+ path: '/',
17
+ name: 'Root',
18
+ component: Layout,
19
+ redirect: '/index',
20
+ children: [
21
+ {
22
+ path: 'index',
23
+ name: 'Home',
24
+ component: () => import('@/views/home/HomeView.vue'),
25
+ meta: { title: '首页', icon: 'HomeFilled', affix: true },
26
+ },
27
+ ],
28
+ },
29
+ ]
30
+
31
+ const viewModules = import.meta.glob('../views/**/*.vue')
32
+
33
+ /** 后端 Component 到本地视图的兼容映射 */
34
+ const componentAlias: Record<string, string> = {
35
+ 'system/index/index': 'home/HomeView',
36
+ }
37
+
38
+ function resolveView(component?: string | null, routeName?: string) {
39
+ if (!component) return undefined
40
+ const mapped = componentAlias[component] || component
41
+ const candidates = [
42
+ `../views/${mapped}.vue`,
43
+ `../views/${mapped}/index.vue`,
44
+ `../views/${component}.vue`,
45
+ `../views/${component}/index.vue`,
46
+ ]
47
+ for (const key of candidates) {
48
+ const loader = viewModules[key]
49
+ if (!loader) continue
50
+ if (!routeName) return loader
51
+ // 为 keep-alive 写入与路由 name 一致的组件名
52
+ return () =>
53
+ (loader as () => Promise<{ default: { name?: string } }> )().then((mod) => {
54
+ if (mod?.default && routeName) {
55
+ mod.default.name = routeName
56
+ }
57
+ return mod
58
+ })
59
+ }
60
+ return undefined
61
+ }
62
+
63
+ export interface RouterVo {
64
+ name: string
65
+ path: string
66
+ component?: string | null
67
+ meta: { title: string; icon?: string; hidden?: boolean }
68
+ children?: RouterVo[]
69
+ }
70
+
71
+ /**
72
+ * 将后端路由转为挂在 Root(Layout) 下的子路由。
73
+ */
74
+ function flattenToLayoutChildren(nodes: RouterVo[], parentAbs = ''): RouteRecordRaw[] {
75
+ const result: RouteRecordRaw[] = []
76
+ for (const node of nodes) {
77
+ const segment = (node.path || '').replace(/^\//, '')
78
+ const abs = [parentAbs, segment].filter(Boolean).join('/')
79
+ const children = node.children?.length
80
+ ? flattenToLayoutChildren(node.children, abs)
81
+ : []
82
+
83
+ // 目录:只展开子节点
84
+ if (!node.component && children.length) {
85
+ result.push(...children)
86
+ continue
87
+ }
88
+
89
+ // 跳过与静态首页冲突的节点
90
+ if (abs === 'index' || abs === '') {
91
+ continue
92
+ }
93
+
94
+ const routeName = node.name || abs.replace(/\//g, '-')
95
+ const comp = resolveView(node.component, routeName)
96
+ result.push({
97
+ path: abs,
98
+ name: routeName,
99
+ component: comp || (() => import('@/views/error/NotFound.vue')),
100
+ meta: { ...(node.meta || {}), title: node.meta?.title || node.name },
101
+ })
102
+ }
103
+ return result
104
+ }
105
+
106
+ const router = createRouter({
107
+ history: createWebHistory(),
108
+ routes: constantRoutes,
109
+ })
110
+
111
+ let dynamicAdded = false
112
+
113
+ export async function setupDynamicRoutes() {
114
+ if (dynamicAdded) return
115
+ const routers = await get<RouterVo[]>('/auth/routers')
116
+ useUserStore().setMenus(routers || [])
117
+ const children = flattenToLayoutChildren(routers || [])
118
+ children.forEach((r) => router.addRoute('Root', r))
119
+
120
+ // catch-all 最后注册
121
+ if (!router.hasRoute('NotFound')) {
122
+ router.addRoute({
123
+ path: '/:pathMatch(.*)*',
124
+ name: 'NotFound',
125
+ component: () => import('@/views/error/NotFound.vue'),
126
+ meta: { title: '未找到' },
127
+ })
128
+ }
129
+
130
+ dynamicAdded = true
131
+ useUserStore().routersLoaded = true
132
+ }
133
+
134
+ export function resetDynamicRoutes() {
135
+ dynamicAdded = false
136
+ useUserStore().routersLoaded = false
137
+ useUserStore().setMenus([])
138
+ // 移除动态子路由与 NotFound,保留 Login / Root / Home
139
+ router.getRoutes().forEach((r) => {
140
+ if (r.name && r.name !== 'Login' && r.name !== 'Root' && r.name !== 'Home') {
141
+ router.removeRoute(r.name)
142
+ }
143
+ })
144
+ }
145
+
146
+ router.beforeEach(async (to, _from, next) => {
147
+ const store = useUserStore()
148
+
149
+ if (to.path === '/login') {
150
+ if (store.token) {
151
+ next('/index')
152
+ return
153
+ }
154
+ next()
155
+ return
156
+ }
157
+
158
+ if (!store.token) {
159
+ next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
160
+ return
161
+ }
162
+
163
+ if (!store.userInfo) {
164
+ try {
165
+ await store.fetchInfo()
166
+ } catch {
167
+ store.logoutLocal()
168
+ resetDynamicRoutes()
169
+ next('/login')
170
+ return
171
+ }
172
+ }
173
+
174
+ if (!dynamicAdded) {
175
+ try {
176
+ await setupDynamicRoutes()
177
+ next({ path: to.fullPath, replace: true })
178
+ return
179
+ } catch (err) {
180
+ console.error('加载动态路由失败', err)
181
+ // 仍放行静态首页,避免卡在登录页
182
+ dynamicAdded = true
183
+ if (!router.hasRoute('NotFound')) {
184
+ router.addRoute({
185
+ path: '/:pathMatch(.*)*',
186
+ name: 'NotFound',
187
+ component: () => import('@/views/error/NotFound.vue'),
188
+ })
189
+ }
190
+ next({ path: to.path === '/' ? '/index' : to.fullPath, replace: true })
191
+ return
192
+ }
193
+ }
194
+
195
+ next()
196
+ })
197
+
198
+ export default router