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,1177 @@
1
+ <script setup lang="ts">
2
+ defineOptions({ name: 'Home' })
3
+
4
+ import { computed, onMounted, onUnmounted, ref } from 'vue'
5
+ import { useRouter } from 'vue-router'
6
+ import { useUserStore, type SidebarRouter } from '@/stores/user'
7
+ import { getSilent } from '@/utils/request'
8
+ import { formatDateTime } from '@/utils/datetime'
9
+ import { resolveMenuIconName } from '@/constants/menuIcons'
10
+ import {
11
+ UserFilled,
12
+ Avatar,
13
+ Connection,
14
+ Notification,
15
+ ArrowRight,
16
+ Key,
17
+ Document,
18
+ Menu,
19
+ } from '@element-plus/icons-vue'
20
+
21
+ interface Paged<T> {
22
+ total: number
23
+ items: T[]
24
+ }
25
+
26
+ interface NoticeItem {
27
+ id: number
28
+ noticeTitle: string
29
+ noticeType: number
30
+ status: number
31
+ createTime: string
32
+ }
33
+
34
+ interface LoginLogItem {
35
+ id: number
36
+ userName?: string
37
+ ipAddress?: string
38
+ status: number
39
+ msg?: string
40
+ loginTime?: string
41
+ }
42
+
43
+ interface OperLogItem {
44
+ id: number
45
+ title?: string
46
+ operName?: string
47
+ status: number
48
+ operTime?: string
49
+ costTime: number
50
+ }
51
+
52
+ interface ProfileBrief {
53
+ userName: string
54
+ nickName: string
55
+ realName?: string
56
+ email?: string
57
+ phone?: string
58
+ }
59
+
60
+ interface FeedPanel {
61
+ key: string
62
+ title: string
63
+ desc: string
64
+ moreLabel: string
65
+ path: string
66
+ kind: 'notice' | 'login' | 'oper'
67
+ }
68
+
69
+ interface ShortcutItem {
70
+ title: string
71
+ desc: string
72
+ path: string
73
+ icon: string
74
+ tone: string
75
+ }
76
+
77
+ const store = useUserStore()
78
+ const router = useRouter()
79
+
80
+ const now = ref(new Date())
81
+ let timer: ReturnType<typeof setInterval> | undefined
82
+
83
+ const loading = ref(true)
84
+ const stats = ref({
85
+ users: null as number | null,
86
+ roles: null as number | null,
87
+ online: null as number | null,
88
+ notices: null as number | null,
89
+ })
90
+ const notices = ref<NoticeItem[]>([])
91
+ const loginLogs = ref<LoginLogItem[]>([])
92
+ const operLogs = ref<OperLogItem[]>([])
93
+ const profile = ref<ProfileBrief | null>(null)
94
+
95
+ /** 首页列表默认条数 */
96
+ const HOME_FEED_SIZE = 5
97
+
98
+ const canNotice = computed(() => store.hasPermission('system:notice:list'))
99
+ const canLoginLog = computed(() => store.hasPermission('monitor:loginlog:list'))
100
+ const canOperLog = computed(() => store.hasPermission('monitor:operlog:list'))
101
+ const canUser = computed(() => store.hasPermission('system:user:list'))
102
+ const canRole = computed(() => store.hasPermission('system:role:list'))
103
+ const canOnline = computed(() => store.hasPermission('monitor:online:list'))
104
+
105
+ const greeting = computed(() => {
106
+ const h = now.value.getHours()
107
+ if (h < 6) return '夜深了'
108
+ if (h < 11) return '早上好'
109
+ if (h < 14) return '中午好'
110
+ if (h < 18) return '下午好'
111
+ return '晚上好'
112
+ })
113
+
114
+ const clockLabel = computed(() => {
115
+ const d = now.value
116
+ const pad = (n: number) => String(n).padStart(2, '0')
117
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
118
+ })
119
+
120
+ const displayName = computed(
121
+ () => store.userInfo?.nickName || store.userInfo?.userName || '管理员',
122
+ )
123
+ const roleText = computed(() => store.userInfo?.roles?.join(' · ') || '-')
124
+ const permLabel = computed(() => {
125
+ const roles = store.userInfo?.roles || []
126
+ const perms = store.userInfo?.permissions || []
127
+ if (roles.includes('super_admin') || perms.includes('*')) return '全部'
128
+ return String(perms.length)
129
+ })
130
+
131
+ /** 上次登录展示文案。 */
132
+ const lastLoginLabel = computed(() => {
133
+ const t = store.userInfo?.lastLoginTime
134
+ if (!t) return '首次登录'
135
+ const time = formatDateTime(t)
136
+ const ip = store.userInfo?.lastLoginIp
137
+ return ip ? `${time}(${ip})` : time
138
+ })
139
+
140
+ const weekLabel = computed(() => {
141
+ const week = ['日', '一', '二', '三', '四', '五', '六'][now.value.getDay()]
142
+ return `星期${week}`
143
+ })
144
+
145
+ const dateShort = computed(() => {
146
+ const d = now.value
147
+ const pad = (n: number) => String(n).padStart(2, '0')
148
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
149
+ })
150
+
151
+ const menuLeafCount = computed(() => flattenMenuLeaves(store.menus || []).length)
152
+
153
+ /** 有权限的管理 KPI;不足时用个人指标填满,避免空白。 */
154
+ const kpiCards = computed(() => {
155
+ const admin: Array<{
156
+ key: string
157
+ label: string
158
+ value: number | null
159
+ icon: unknown
160
+ tone: string
161
+ path: string
162
+ hint: string
163
+ clickable: boolean
164
+ }> = []
165
+
166
+ if (canUser.value) {
167
+ admin.push({
168
+ key: 'users',
169
+ label: '用户总数',
170
+ value: stats.value.users,
171
+ icon: UserFilled,
172
+ tone: 'teal',
173
+ path: '/system/user',
174
+ hint: '账号与权限主体',
175
+ clickable: true,
176
+ })
177
+ }
178
+ if (canRole.value) {
179
+ admin.push({
180
+ key: 'roles',
181
+ label: '角色数量',
182
+ value: stats.value.roles,
183
+ icon: Avatar,
184
+ tone: 'sky',
185
+ path: '/system/role',
186
+ hint: 'RBAC 角色集合',
187
+ clickable: true,
188
+ })
189
+ }
190
+ if (canOnline.value) {
191
+ admin.push({
192
+ key: 'online',
193
+ label: '在线会话',
194
+ value: stats.value.online,
195
+ icon: Connection,
196
+ tone: 'amber',
197
+ path: '/monitor/online',
198
+ hint: '当前有效登录',
199
+ clickable: true,
200
+ })
201
+ }
202
+ if (canNotice.value) {
203
+ admin.push({
204
+ key: 'notices',
205
+ label: '公告条数',
206
+ value: stats.value.notices,
207
+ icon: Notification,
208
+ tone: 'rose',
209
+ path: '/system/notice',
210
+ hint: '系统通知公告',
211
+ clickable: true,
212
+ })
213
+ }
214
+
215
+ if (admin.length >= 2) return admin
216
+
217
+ const personal = [
218
+ {
219
+ key: 'my-roles',
220
+ label: '我的角色',
221
+ value: store.userInfo?.roles?.length ?? 0,
222
+ icon: Key,
223
+ tone: 'teal',
224
+ path: '',
225
+ hint: roleText.value,
226
+ clickable: false,
227
+ },
228
+ {
229
+ key: 'my-perms',
230
+ label: '权限项',
231
+ value:
232
+ store.userInfo?.roles?.includes('super_admin') || store.userInfo?.permissions?.includes('*')
233
+ ? null
234
+ : store.userInfo?.permissions?.length ?? 0,
235
+ icon: Document,
236
+ tone: 'sky',
237
+ path: '',
238
+ hint: permLabel.value === '全部' ? '超级管理员权限' : '当前授权数量',
239
+ clickable: false,
240
+ },
241
+ {
242
+ key: 'my-menus',
243
+ label: '可访问菜单',
244
+ value: menuLeafCount.value,
245
+ icon: Menu,
246
+ tone: 'amber',
247
+ path: '',
248
+ hint: '侧栏可见功能入口',
249
+ clickable: false,
250
+ },
251
+ ]
252
+
253
+ // 合并:已有管理卡片优先,再用个人卡片补到最多 4 个
254
+ const merged = [...admin]
255
+ for (const p of personal) {
256
+ if (merged.length >= 4) break
257
+ if (!merged.some((x) => x.key === p.key)) merged.push(p)
258
+ }
259
+ return merged
260
+ })
261
+
262
+ const kpiColsClass = computed(() => {
263
+ const n = Math.min(4, Math.max(1, kpiCards.value.length))
264
+ return `kpi--cols-${n}`
265
+ })
266
+
267
+ const feedPanels = computed(() => {
268
+ const list: FeedPanel[] = []
269
+ if (canNotice.value) {
270
+ list.push({
271
+ key: 'notice',
272
+ title: '最新公告',
273
+ desc: '系统通知与公告动态',
274
+ moreLabel: '查看全部',
275
+ path: '/system/notice',
276
+ kind: 'notice',
277
+ })
278
+ }
279
+ if (canLoginLog.value) {
280
+ list.push({
281
+ key: 'login',
282
+ title: '最近登录',
283
+ desc: '账号登录审计摘要',
284
+ moreLabel: '查看全部',
285
+ path: '/monitor/loginlog',
286
+ kind: 'login',
287
+ })
288
+ }
289
+ if (canOperLog.value) {
290
+ list.push({
291
+ key: 'oper',
292
+ title: '近期操作',
293
+ desc: '审计操作摘要',
294
+ moreLabel: '明细',
295
+ path: '/monitor/operlog',
296
+ kind: 'oper',
297
+ })
298
+ }
299
+ return list
300
+ })
301
+
302
+ const feedColsClass = computed(() => {
303
+ const n = feedPanels.value.length
304
+ if (n <= 1) return 'split--cols-1'
305
+ if (n === 2) return 'split--cols-2'
306
+ return 'split--cols-3'
307
+ })
308
+
309
+ const tones = ['teal', 'sky', 'amber', 'rose', 'indigo', 'slate']
310
+
311
+ /** 快捷入口:优先取当前账号真实可访问菜单,避免无权限入口占位。 */
312
+ const shortcuts = computed((): ShortcutItem[] => {
313
+ const leaves = flattenMenuLeaves(store.menus || []).slice(0, 12)
314
+ if (leaves.length) {
315
+ return leaves.map((m, i) => ({
316
+ title: m.title,
317
+ desc: '已授权功能',
318
+ path: m.path,
319
+ icon: resolveMenuIconName(m.icon, 'Document'),
320
+ tone: tones[i % tones.length],
321
+ }))
322
+ }
323
+ return [
324
+ {
325
+ title: '首页',
326
+ desc: '返回工作台',
327
+ path: '/index',
328
+ icon: 'HomeFilled',
329
+ tone: 'teal',
330
+ },
331
+ ]
332
+ })
333
+
334
+ const showWorkspace = computed(() => feedPanels.value.length < 2)
335
+
336
+ function joinPath(parent: string | undefined, path: string): string {
337
+ if (!path) return parent || '/'
338
+ if (path.startsWith('/')) return path
339
+ const base = (parent || '').replace(/\/$/, '')
340
+ if (!base || base === '/') return `/${path}`
341
+ return `${base}/${path}`.replace(/\/{2,}/g, '/')
342
+ }
343
+
344
+ function flattenMenuLeaves(
345
+ nodes: SidebarRouter[],
346
+ parentPath = '',
347
+ ): Array<{ title: string; path: string; icon?: string }> {
348
+ const out: Array<{ title: string; path: string; icon?: string }> = []
349
+ for (const n of nodes) {
350
+ if (n.meta?.hidden) continue
351
+ const full = joinPath(parentPath, n.path)
352
+ const kids = (n.children || []).filter((c) => !c.meta?.hidden)
353
+ const isDir = kids.length > 0 && !n.component
354
+ if (isDir) {
355
+ out.push(...flattenMenuLeaves(kids, full))
356
+ } else if (full && full !== '/index' && full !== '/') {
357
+ out.push({
358
+ title: n.meta?.title || n.name,
359
+ path: full,
360
+ icon: n.meta?.icon,
361
+ })
362
+ }
363
+ }
364
+ return out
365
+ }
366
+
367
+ function noticeTypeLabel(type: number) {
368
+ return type === 2 ? '公告' : '通知'
369
+ }
370
+
371
+ function formatStat(value: number | null, fallback = '—') {
372
+ if (value === null) return fallback
373
+ return value.toLocaleString()
374
+ }
375
+
376
+ function formatKpiValue(item: { key: string; value: number | null }) {
377
+ if (item.key === 'my-perms' && permLabel.value === '全部') return '全部'
378
+ return formatStat(item.value)
379
+ }
380
+
381
+ async function safePagedTotal(url: string): Promise<number | null> {
382
+ const data = await getSilent<Paged<unknown>>(url, { pageIndex: 1, pageSize: 1 })
383
+ return data?.total ?? null
384
+ }
385
+
386
+ async function loadDashboard() {
387
+ loading.value = true
388
+ try {
389
+ const tasks: Promise<void>[] = []
390
+
391
+ // 路由守卫已拉取过 userInfo 则跳过,避免重复 /auth/info
392
+ if (!store.userInfo) {
393
+ tasks.push(
394
+ (async () => {
395
+ try {
396
+ await store.fetchInfo()
397
+ } catch {
398
+ /* 会话信息失败不阻断首页 */
399
+ }
400
+ })(),
401
+ )
402
+ }
403
+
404
+ tasks.push(
405
+ (async () => {
406
+ const data = await getSilent<ProfileBrief>('/auth/profile')
407
+ if (data) profile.value = data
408
+ })(),
409
+ )
410
+
411
+ if (canUser.value) {
412
+ tasks.push(
413
+ (async () => {
414
+ stats.value.users = await safePagedTotal('/users')
415
+ })(),
416
+ )
417
+ }
418
+ if (canRole.value) {
419
+ tasks.push(
420
+ (async () => {
421
+ stats.value.roles = await safePagedTotal('/roles')
422
+ })(),
423
+ )
424
+ }
425
+ if (canNotice.value) {
426
+ tasks.push(
427
+ (async () => {
428
+ // 一次请求同时拿 total 与 feed,避免 /notices 打两次
429
+ const data = await getSilent<Paged<NoticeItem>>('/notices', {
430
+ pageIndex: 1,
431
+ pageSize: HOME_FEED_SIZE,
432
+ })
433
+ stats.value.notices = data?.total ?? null
434
+ notices.value = data?.items || []
435
+ })(),
436
+ )
437
+ }
438
+ if (canOnline.value) {
439
+ tasks.push(
440
+ (async () => {
441
+ const count = await getSilent<number>('/online-users/count')
442
+ stats.value.online = count ?? null
443
+ })(),
444
+ )
445
+ }
446
+ if (canLoginLog.value) {
447
+ tasks.push(
448
+ (async () => {
449
+ const data = await getSilent<Paged<LoginLogItem>>('/login-logs', {
450
+ pageIndex: 1,
451
+ pageSize: HOME_FEED_SIZE,
452
+ })
453
+ loginLogs.value = data?.items || []
454
+ })(),
455
+ )
456
+ }
457
+ if (canOperLog.value) {
458
+ tasks.push(
459
+ (async () => {
460
+ const data = await getSilent<Paged<OperLogItem>>('/oper-logs', {
461
+ pageIndex: 1,
462
+ pageSize: HOME_FEED_SIZE,
463
+ })
464
+ operLogs.value = data?.items || []
465
+ })(),
466
+ )
467
+ }
468
+
469
+ await Promise.all(tasks)
470
+ } finally {
471
+ loading.value = false
472
+ }
473
+ }
474
+
475
+ function onKpiClick(item: { clickable: boolean; path: string }) {
476
+ if (item.clickable && item.path) router.push(item.path)
477
+ }
478
+
479
+ onMounted(() => {
480
+ timer = setInterval(() => {
481
+ now.value = new Date()
482
+ }, 1000)
483
+ loadDashboard()
484
+ })
485
+
486
+ onUnmounted(() => {
487
+ if (timer) clearInterval(timer)
488
+ })
489
+ </script>
490
+
491
+ <template>
492
+ <div class="home sa-page" v-loading="loading">
493
+ <section class="hero">
494
+ <div class="hero__glow" aria-hidden="true" />
495
+ <div class="hero__main">
496
+ <div class="hero__text">
497
+ <p class="hero__eyebrow">SimpleAdmin</p>
498
+ <h1>{{ greeting }},{{ displayName }}</h1>
499
+ <p class="hero__lead">
500
+ {{
501
+ feedPanels.length
502
+ ? '欢迎回到控制台,快速查看运行概况与常用入口。'
503
+ : '欢迎回来。下方为你的工作台与已授权功能入口。'
504
+ }}
505
+ </p>
506
+ <div class="hero__chips">
507
+ <span class="chip"><el-icon><Key /></el-icon> {{ roleText }}</span>
508
+ <span class="chip"><el-icon><Document /></el-icon> 权限 {{ permLabel }}</span>
509
+ <span class="chip chip--login" title="不含本次登录">上次登录 {{ lastLoginLabel }}</span>
510
+ </div>
511
+ </div>
512
+ <div class="hero__time" aria-label="当前时间">
513
+ <span class="hero__clock">{{ clockLabel }}</span>
514
+ <span class="hero__date">{{ dateShort }} · {{ weekLabel }}</span>
515
+ </div>
516
+ </div>
517
+ </section>
518
+
519
+ <section v-if="kpiCards.length" class="kpi" :class="kpiColsClass">
520
+ <button
521
+ v-for="(item, idx) in kpiCards"
522
+ :key="item.key"
523
+ type="button"
524
+ class="kpi__card"
525
+ :class="[`kpi__card--${item.tone}`, { 'is-static': !item.clickable }]"
526
+ :style="{ animationDelay: `${0.04 * idx}s` }"
527
+ @click="onKpiClick(item)"
528
+ >
529
+ <span class="kpi__icon">
530
+ <el-icon :size="22"><component :is="item.icon" /></el-icon>
531
+ </span>
532
+ <div class="kpi__meta">
533
+ <span class="kpi__label">{{ item.label }}</span>
534
+ <strong class="kpi__value">{{ formatKpiValue(item) }}</strong>
535
+ <small>{{ item.hint }}</small>
536
+ </div>
537
+ <el-icon v-if="item.clickable" class="kpi__arrow"><ArrowRight /></el-icon>
538
+ </button>
539
+ </section>
540
+
541
+ <section v-if="showWorkspace" class="panel workspace">
542
+ <header class="panel__head">
543
+ <div>
544
+ <h2>我的工作台</h2>
545
+ <p>账号资料与当前授权概览</p>
546
+ </div>
547
+ </header>
548
+ <div class="workspace__body">
549
+ <div class="workspace__avatar">
550
+ {{ (displayName || '?').slice(0, 1) }}
551
+ </div>
552
+ <div class="workspace__meta">
553
+ <strong>{{ displayName }}</strong>
554
+ <p>
555
+ 账号 {{ profile?.userName || store.userInfo?.userName || '—' }}
556
+ <template v-if="profile?.phone"> · {{ profile.phone }}</template>
557
+ <template v-else-if="profile?.email"> · {{ profile.email }}</template>
558
+ </p>
559
+ <div class="workspace__tags">
560
+ <span class="tag">角色 {{ roleText }}</span>
561
+ <span class="tag">权限 {{ permLabel }}</span>
562
+ <span class="tag">菜单 {{ menuLeafCount }}</span>
563
+ </div>
564
+ </div>
565
+ <p class="workspace__tip">
566
+ {{
567
+ shortcuts.length
568
+ ? '请从下方快捷入口进入已授权功能;如需更多权限,请联系管理员。'
569
+ : '当前账号暂无业务菜单,请联系管理员分配角色权限。'
570
+ }}
571
+ </p>
572
+ </div>
573
+ </section>
574
+
575
+ <section v-if="feedPanels.length" class="split" :class="feedColsClass">
576
+ <div v-for="panel in feedPanels" :key="panel.key" class="panel">
577
+ <header class="panel__head">
578
+ <div>
579
+ <h2>{{ panel.title }}</h2>
580
+ <p>{{ panel.desc }}</p>
581
+ </div>
582
+ <el-button text type="primary" @click="router.push(panel.path)">
583
+ {{ panel.moreLabel }}
584
+ </el-button>
585
+ </header>
586
+
587
+ <template v-if="panel.kind === 'notice'">
588
+ <ul v-if="notices.length" class="feed">
589
+ <li v-for="n in notices" :key="n.id" class="feed__item">
590
+ <span class="feed__badge" :class="n.noticeType === 2 ? 'is-announce' : 'is-notice'">
591
+ {{ noticeTypeLabel(n.noticeType) }}
592
+ </span>
593
+ <div class="feed__body">
594
+ <strong>{{ n.noticeTitle }}</strong>
595
+ <small>{{ formatDateTime(n.createTime) }}</small>
596
+ </div>
597
+ </li>
598
+ </ul>
599
+ <el-empty v-else description="暂无公告" :image-size="64" />
600
+ </template>
601
+
602
+ <template v-else-if="panel.kind === 'login'">
603
+ <ul v-if="loginLogs.length" class="feed">
604
+ <li v-for="log in loginLogs" :key="log.id" class="feed__item">
605
+ <span class="feed__dot" :class="log.status === 0 ? 'ok' : 'fail'" />
606
+ <div class="feed__body">
607
+ <strong>{{ log.userName || '未知账号' }}</strong>
608
+ <small>
609
+ {{ log.ipAddress || '-' }} ·
610
+ {{ log.msg || (log.status === 0 ? '登录成功' : '登录失败') }} ·
611
+ {{ formatDateTime(log.loginTime) }}
612
+ </small>
613
+ </div>
614
+ </li>
615
+ </ul>
616
+ <el-empty v-else description="暂无登录日志" :image-size="64" />
617
+ </template>
618
+
619
+ <template v-else>
620
+ <ul v-if="operLogs.length" class="ops">
621
+ <li v-for="op in operLogs" :key="op.id">
622
+ <div>
623
+ <strong>{{ op.title || '操作' }}</strong>
624
+ <small>{{ op.operName || '-' }} · {{ formatDateTime(op.operTime) }}</small>
625
+ </div>
626
+ <span class="ops__cost">{{ op.costTime }}ms</span>
627
+ </li>
628
+ </ul>
629
+ <el-empty v-else description="暂无操作日志" :image-size="64" />
630
+ </template>
631
+ </div>
632
+ </section>
633
+
634
+ <section class="panel">
635
+ <header class="panel__head">
636
+ <div>
637
+ <h2>快捷入口</h2>
638
+ <p>{{ feedPanels.length ? '常用系统与监控模块' : '你的已授权功能' }}</p>
639
+ </div>
640
+ </header>
641
+ <div v-if="shortcuts.length" class="grid" :class="{ 'grid--dense': shortcuts.length <= 4 }">
642
+ <button
643
+ v-for="(item, idx) in shortcuts"
644
+ :key="item.path + item.title"
645
+ type="button"
646
+ class="tile"
647
+ :class="`tile--${item.tone}`"
648
+ :style="{ animationDelay: `${0.03 * idx}s` }"
649
+ @click="router.push(item.path)"
650
+ >
651
+ <span class="tile__icon">
652
+ <el-icon :size="18"><component :is="item.icon" /></el-icon>
653
+ </span>
654
+ <div>
655
+ <strong>{{ item.title }}</strong>
656
+ <p>{{ item.desc }}</p>
657
+ </div>
658
+ </button>
659
+ </div>
660
+ <el-empty v-else description="暂无可用功能入口" :image-size="72" />
661
+ </section>
662
+ </div>
663
+ </template>
664
+
665
+ <style scoped lang="scss">
666
+ .home {
667
+ gap: 18px;
668
+ }
669
+
670
+ .hero {
671
+ position: relative;
672
+ overflow: hidden;
673
+ padding: 24px 28px;
674
+ border-radius: 22px;
675
+ color: #ecfeff;
676
+ background:
677
+ linear-gradient(125deg, #0f3d3a 0%, #0f766e 48%, #155e75 100%);
678
+ box-shadow: var(--sa-shadow);
679
+ animation: sa-fade-up 0.35s ease both;
680
+ }
681
+
682
+ .hero__glow {
683
+ position: absolute;
684
+ inset: auto -10% -40% 40%;
685
+ height: 180%;
686
+ background: radial-gradient(circle, rgba(45, 212, 191, 0.28), transparent 55%);
687
+ pointer-events: none;
688
+ animation: pulse-glow 6s ease-in-out infinite;
689
+ }
690
+
691
+ .hero__main {
692
+ position: relative;
693
+ z-index: 1;
694
+ display: flex;
695
+ align-items: center;
696
+ justify-content: space-between;
697
+ gap: 28px;
698
+ min-height: 112px;
699
+ }
700
+
701
+ .hero__text {
702
+ min-width: 0;
703
+ flex: 1;
704
+ }
705
+
706
+ .hero__eyebrow {
707
+ margin: 0 0 8px;
708
+ font-size: 11px;
709
+ letter-spacing: 0.16em;
710
+ text-transform: uppercase;
711
+ opacity: 0.78;
712
+ }
713
+
714
+ .hero h1 {
715
+ margin: 0;
716
+ font-size: clamp(24px, 2.6vw, 32px);
717
+ font-weight: 800;
718
+ letter-spacing: -0.03em;
719
+ line-height: 1.2;
720
+ }
721
+
722
+ .hero__lead {
723
+ margin: 8px 0 0;
724
+ max-width: 42ch;
725
+ color: rgba(236, 254, 255, 0.84);
726
+ font-size: 13px;
727
+ line-height: 1.55;
728
+ }
729
+
730
+ .hero__chips {
731
+ display: flex;
732
+ flex-wrap: wrap;
733
+ gap: 8px;
734
+ margin-top: 14px;
735
+ }
736
+
737
+ .chip {
738
+ display: inline-flex;
739
+ align-items: center;
740
+ gap: 6px;
741
+ padding: 5px 11px;
742
+ border-radius: 999px;
743
+ font-size: 12px;
744
+ background: rgba(255, 255, 255, 0.1);
745
+ border: 1px solid rgba(255, 255, 255, 0.14);
746
+ }
747
+
748
+ .chip--login {
749
+ max-width: min(100%, 360px);
750
+ overflow: hidden;
751
+ text-overflow: ellipsis;
752
+ white-space: nowrap;
753
+ }
754
+
755
+ .hero__time {
756
+ flex-shrink: 0;
757
+ display: flex;
758
+ flex-direction: column;
759
+ align-items: flex-end;
760
+ justify-content: center;
761
+ gap: 6px;
762
+ padding: 14px 18px;
763
+ border-radius: 16px;
764
+ background: rgba(255, 255, 255, 0.1);
765
+ border: 1px solid rgba(255, 255, 255, 0.14);
766
+ backdrop-filter: blur(8px);
767
+ min-width: 168px;
768
+ }
769
+
770
+ .hero__clock {
771
+ font-size: clamp(28px, 3vw, 36px);
772
+ font-weight: 780;
773
+ letter-spacing: 0.04em;
774
+ font-variant-numeric: tabular-nums;
775
+ line-height: 1;
776
+ }
777
+
778
+ .hero__date {
779
+ font-size: 12px;
780
+ color: rgba(236, 254, 255, 0.78);
781
+ letter-spacing: 0.02em;
782
+ }
783
+
784
+ .kpi {
785
+ display: grid;
786
+ gap: 14px;
787
+ }
788
+ .kpi--cols-1 { grid-template-columns: minmax(0, 1fr); }
789
+ .kpi--cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
790
+ .kpi--cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
791
+ .kpi--cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
792
+
793
+ .kpi__card {
794
+ position: relative;
795
+ display: flex;
796
+ align-items: center;
797
+ gap: 14px;
798
+ text-align: left;
799
+ padding: 16px 16px 16px 14px;
800
+ border-radius: 16px;
801
+ border: 1px solid var(--sa-border);
802
+ background: #fff;
803
+ box-shadow: var(--sa-shadow);
804
+ cursor: pointer;
805
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
806
+ animation: sa-fade-up 0.4s ease both;
807
+ &:hover {
808
+ transform: translateY(-3px);
809
+ border-color: rgba(15, 118, 110, 0.28);
810
+ box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08);
811
+ }
812
+ &.is-static {
813
+ cursor: default;
814
+ &:hover {
815
+ transform: none;
816
+ border-color: var(--sa-border);
817
+ box-shadow: var(--sa-shadow);
818
+ }
819
+ }
820
+ }
821
+
822
+ .kpi__icon {
823
+ width: 44px;
824
+ height: 44px;
825
+ border-radius: 13px;
826
+ display: grid;
827
+ place-items: center;
828
+ flex-shrink: 0;
829
+ }
830
+
831
+ .kpi__meta {
832
+ min-width: 0;
833
+ flex: 1;
834
+ display: flex;
835
+ flex-direction: column;
836
+ gap: 2px;
837
+ }
838
+
839
+ .kpi__label {
840
+ font-size: 12px;
841
+ color: var(--sa-ink-muted);
842
+ }
843
+
844
+ .kpi__value {
845
+ font-size: 24px;
846
+ font-weight: 800;
847
+ letter-spacing: -0.03em;
848
+ font-variant-numeric: tabular-nums;
849
+ color: var(--sa-ink);
850
+ }
851
+
852
+ .kpi__meta small {
853
+ font-size: 12px;
854
+ color: var(--sa-ink-secondary);
855
+ overflow: hidden;
856
+ text-overflow: ellipsis;
857
+ white-space: nowrap;
858
+ }
859
+
860
+ .kpi__arrow {
861
+ color: var(--sa-ink-muted);
862
+ opacity: 0;
863
+ transform: translateX(-4px);
864
+ transition: opacity 0.2s ease, transform 0.2s ease;
865
+ }
866
+ .kpi__card:hover:not(.is-static) .kpi__arrow {
867
+ opacity: 1;
868
+ transform: translateX(0);
869
+ }
870
+
871
+ .kpi__card--teal .kpi__icon { background: #ccfbf1; color: #0f766e; }
872
+ .kpi__card--sky .kpi__icon { background: #e0f2fe; color: #0369a1; }
873
+ .kpi__card--amber .kpi__icon { background: #fef3c7; color: #b45309; }
874
+ .kpi__card--rose .kpi__icon { background: #ffe4e6; color: #be123c; }
875
+
876
+ .workspace__body {
877
+ display: grid;
878
+ grid-template-columns: auto 1fr;
879
+ gap: 10px 16px;
880
+ align-items: center;
881
+ }
882
+
883
+ .workspace__avatar {
884
+ grid-row: 1 / span 2;
885
+ width: 56px;
886
+ height: 56px;
887
+ border-radius: 16px;
888
+ display: grid;
889
+ place-items: center;
890
+ font-size: 22px;
891
+ font-weight: 800;
892
+ color: #fff;
893
+ background: linear-gradient(135deg, #0f766e, #0e7490);
894
+ }
895
+
896
+ .workspace__meta {
897
+ min-width: 0;
898
+ strong {
899
+ display: block;
900
+ font-size: 16px;
901
+ font-weight: 740;
902
+ }
903
+ p {
904
+ margin: 4px 0 0;
905
+ font-size: 13px;
906
+ color: var(--sa-ink-secondary);
907
+ }
908
+ }
909
+
910
+ .workspace__tags {
911
+ display: flex;
912
+ flex-wrap: wrap;
913
+ gap: 8px;
914
+ margin-top: 10px;
915
+ }
916
+
917
+ .tag {
918
+ padding: 4px 10px;
919
+ border-radius: 999px;
920
+ font-size: 12px;
921
+ background: #f0fdfa;
922
+ color: #0f766e;
923
+ border: 1px solid rgba(15, 118, 110, 0.16);
924
+ }
925
+
926
+ .workspace__tip {
927
+ grid-column: 1 / -1;
928
+ margin: 4px 0 0;
929
+ padding: 10px 12px;
930
+ border-radius: 12px;
931
+ background: #f8fafc;
932
+ font-size: 13px;
933
+ color: var(--sa-ink-secondary);
934
+ line-height: 1.5;
935
+ }
936
+
937
+ .split {
938
+ display: grid;
939
+ gap: 14px;
940
+ }
941
+ .split--cols-1 { grid-template-columns: minmax(0, 1fr); }
942
+ .split--cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
943
+ .split--cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
944
+
945
+ .panel {
946
+ background: #fff;
947
+ border: 1px solid var(--sa-border);
948
+ border-radius: 18px;
949
+ box-shadow: var(--sa-shadow);
950
+ padding: 16px 18px 14px;
951
+ animation: sa-fade-up 0.45s ease both;
952
+ }
953
+
954
+ .panel__head {
955
+ display: flex;
956
+ align-items: flex-start;
957
+ justify-content: space-between;
958
+ gap: 12px;
959
+ margin-bottom: 12px;
960
+ h2 {
961
+ margin: 0;
962
+ font-size: 16px;
963
+ font-weight: 740;
964
+ }
965
+ p {
966
+ margin: 4px 0 0;
967
+ font-size: 12px;
968
+ color: var(--sa-ink-muted);
969
+ }
970
+ }
971
+
972
+ .feed {
973
+ list-style: none;
974
+ margin: 0;
975
+ padding: 0;
976
+ display: grid;
977
+ gap: 8px;
978
+ }
979
+
980
+ .feed__item {
981
+ display: flex;
982
+ align-items: flex-start;
983
+ gap: 10px;
984
+ padding: 10px;
985
+ border-radius: 12px;
986
+ background: #f8fafc;
987
+ border: 1px solid transparent;
988
+ transition: border-color 0.2s ease, background 0.2s ease;
989
+ &:hover {
990
+ background: #f0fdfa;
991
+ border-color: rgba(15, 118, 110, 0.18);
992
+ }
993
+ }
994
+
995
+ .feed__badge {
996
+ flex-shrink: 0;
997
+ margin-top: 2px;
998
+ padding: 2px 8px;
999
+ border-radius: 999px;
1000
+ font-size: 11px;
1001
+ font-weight: 600;
1002
+ }
1003
+ .feed__badge.is-notice {
1004
+ background: #e0f2fe;
1005
+ color: #0369a1;
1006
+ }
1007
+ .feed__badge.is-announce {
1008
+ background: #fef3c7;
1009
+ color: #b45309;
1010
+ }
1011
+
1012
+ .feed__dot {
1013
+ width: 8px;
1014
+ height: 8px;
1015
+ margin-top: 7px;
1016
+ border-radius: 50%;
1017
+ flex-shrink: 0;
1018
+ }
1019
+ .feed__dot.ok { background: #0d9488; box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.18); }
1020
+ .feed__dot.fail { background: #e11d48; box-shadow: 0 0 0 3px rgba(225, 29, 72, 0.15); }
1021
+
1022
+ .feed__body {
1023
+ min-width: 0;
1024
+ display: flex;
1025
+ flex-direction: column;
1026
+ gap: 3px;
1027
+ strong {
1028
+ font-size: 13px;
1029
+ font-weight: 650;
1030
+ color: var(--sa-ink);
1031
+ overflow: hidden;
1032
+ text-overflow: ellipsis;
1033
+ white-space: nowrap;
1034
+ }
1035
+ small {
1036
+ font-size: 12px;
1037
+ color: var(--sa-ink-muted);
1038
+ line-height: 1.4;
1039
+ }
1040
+ }
1041
+
1042
+ .grid {
1043
+ display: grid;
1044
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1045
+ gap: 10px;
1046
+ }
1047
+ .grid--dense {
1048
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
1049
+ }
1050
+
1051
+ .tile {
1052
+ display: flex;
1053
+ align-items: flex-start;
1054
+ gap: 10px;
1055
+ text-align: left;
1056
+ border: 1px solid var(--sa-border);
1057
+ background: #f8fafc;
1058
+ border-radius: 14px;
1059
+ padding: 12px;
1060
+ cursor: pointer;
1061
+ transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
1062
+ animation: sa-fade-up 0.4s ease both;
1063
+ strong {
1064
+ display: block;
1065
+ font-size: 13px;
1066
+ font-weight: 700;
1067
+ }
1068
+ p {
1069
+ margin: 3px 0 0;
1070
+ font-size: 12px;
1071
+ color: var(--sa-ink-secondary);
1072
+ line-height: 1.4;
1073
+ }
1074
+ &:hover {
1075
+ transform: translateY(-2px);
1076
+ background: #fff;
1077
+ border-color: rgba(15, 118, 110, 0.3);
1078
+ }
1079
+ }
1080
+
1081
+ .tile__icon {
1082
+ width: 34px;
1083
+ height: 34px;
1084
+ border-radius: 10px;
1085
+ display: grid;
1086
+ place-items: center;
1087
+ flex-shrink: 0;
1088
+ }
1089
+
1090
+ .tile--teal .tile__icon { background: #ccfbf1; color: #0f766e; }
1091
+ .tile--sky .tile__icon { background: #e0f2fe; color: #0369a1; }
1092
+ .tile--amber .tile__icon { background: #fef3c7; color: #b45309; }
1093
+ .tile--rose .tile__icon { background: #ffe4e6; color: #be123c; }
1094
+ .tile--indigo .tile__icon { background: #e0e7ff; color: #4338ca; }
1095
+ .tile--slate .tile__icon { background: #e2e8f0; color: #334155; }
1096
+
1097
+ .ops {
1098
+ list-style: none;
1099
+ margin: 0;
1100
+ padding: 0;
1101
+ display: grid;
1102
+ gap: 8px;
1103
+ }
1104
+
1105
+ .ops li {
1106
+ display: flex;
1107
+ align-items: center;
1108
+ justify-content: space-between;
1109
+ gap: 10px;
1110
+ padding: 8px 6px;
1111
+ border-bottom: 1px dashed var(--sa-border);
1112
+ &:last-child {
1113
+ border-bottom: 0;
1114
+ }
1115
+ strong {
1116
+ display: block;
1117
+ font-size: 13px;
1118
+ }
1119
+ small {
1120
+ font-size: 12px;
1121
+ color: var(--sa-ink-muted);
1122
+ }
1123
+ }
1124
+
1125
+ .ops__cost {
1126
+ font-size: 12px;
1127
+ color: var(--sa-accent);
1128
+ font-variant-numeric: tabular-nums;
1129
+ font-weight: 650;
1130
+ }
1131
+
1132
+ @keyframes pulse-glow {
1133
+ 0%,
1134
+ 100% { opacity: 0.7; transform: scale(1); }
1135
+ 50% { opacity: 1; transform: scale(1.05); }
1136
+ }
1137
+
1138
+ @media (max-width: 1200px) {
1139
+ .kpi--cols-3,
1140
+ .kpi--cols-4 {
1141
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1142
+ }
1143
+ .split--cols-3 {
1144
+ grid-template-columns: 1fr;
1145
+ }
1146
+ .grid {
1147
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1148
+ }
1149
+ }
1150
+
1151
+ @media (max-width: 800px) {
1152
+ .hero__main {
1153
+ flex-direction: column;
1154
+ align-items: stretch;
1155
+ gap: 16px;
1156
+ }
1157
+ .hero__time {
1158
+ align-items: flex-start;
1159
+ min-width: 0;
1160
+ }
1161
+ .split--cols-2,
1162
+ .split--cols-3 {
1163
+ grid-template-columns: 1fr;
1164
+ }
1165
+ .kpi--cols-2,
1166
+ .kpi--cols-3,
1167
+ .kpi--cols-4 {
1168
+ grid-template-columns: 1fr;
1169
+ }
1170
+ .grid {
1171
+ grid-template-columns: 1fr;
1172
+ }
1173
+ .workspace__body {
1174
+ grid-template-columns: auto 1fr;
1175
+ }
1176
+ }
1177
+ </style>