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,822 @@
1
+ <script setup lang="ts">
2
+ defineOptions({ name: 'SaMenuPage' })
3
+
4
+ import { computed, nextTick, onActivated, onMounted, reactive, ref } from 'vue'
5
+ import type { TableInstance } from 'element-plus'
6
+ import { ElMessage, ElMessageBox } from 'element-plus'
7
+ import { Plus, Refresh, Folder, Menu as MenuIcon, Key } from '@element-plus/icons-vue'
8
+ import { get, post, put, del } from '@/utils/request'
9
+ import AppPage from '@/components/AppPage.vue'
10
+ import { resolveMenuIcon, resolveMenuIconOptions } from '@/constants/menuIcons'
11
+
12
+ interface MenuNode {
13
+ id: number
14
+ parentId: number
15
+ menuName: string
16
+ menuType: string
17
+ orderNum: number
18
+ path?: string
19
+ component?: string
20
+ perms?: string
21
+ icon?: string
22
+ status: number
23
+ isVisible: boolean
24
+ children?: MenuNode[]
25
+ }
26
+
27
+ interface MenuForm {
28
+ id?: number
29
+ parentId: number
30
+ menuName: string
31
+ menuType: string
32
+ orderNum: number
33
+ path: string
34
+ component: string
35
+ query: string
36
+ isFrame: boolean
37
+ isCache: boolean
38
+ isVisible: boolean
39
+ status: number
40
+ perms: string
41
+ icon: string
42
+ remark: string
43
+ }
44
+
45
+ const MENU_TYPES = [
46
+ { value: 'M', label: '目录', tip: '侧栏分组' },
47
+ { value: 'C', label: '菜单', tip: '可路由页面' },
48
+ { value: 'F', label: '按钮', tip: '接口权限' },
49
+ ]
50
+
51
+ const TYPE_META: Record<string, { label: string; tone: string; icon: typeof Folder }> = {
52
+ M: { label: '目录', tone: 'dir', icon: Folder },
53
+ C: { label: '菜单', tone: 'menu', icon: MenuIcon },
54
+ F: { label: '按钮', tone: 'btn', icon: Key },
55
+ }
56
+
57
+ const loading = ref(false)
58
+ const saving = ref(false)
59
+ const tree = ref<MenuNode[]>([])
60
+ const keyword = ref('')
61
+ const dialogVisible = ref(false)
62
+ /** 默认折叠,避免一次展开上百个按钮节点导致卡顿 */
63
+ const expandAll = ref(false)
64
+ /** 默认不展示按钮权限行,显著减少表格 DOM */
65
+ const showButtons = ref(false)
66
+ const tableRef = ref<TableInstance>()
67
+
68
+ const form = reactive<MenuForm>({
69
+ id: undefined,
70
+ parentId: 0,
71
+ menuName: '',
72
+ menuType: 'C',
73
+ orderNum: 0,
74
+ path: '',
75
+ component: '',
76
+ query: '',
77
+ isFrame: false,
78
+ isCache: true,
79
+ isVisible: true,
80
+ status: 0,
81
+ perms: '',
82
+ icon: '',
83
+ remark: '',
84
+ })
85
+
86
+ /**
87
+ * 去掉按钮节点(保留目录/菜单树结构)。
88
+ */
89
+ function stripButtons(nodes: MenuNode[]): MenuNode[] {
90
+ const out: MenuNode[] = []
91
+ for (const n of nodes) {
92
+ if (n.menuType === 'F') continue
93
+ const kids = n.children?.length ? stripButtons(n.children) : []
94
+ out.push(kids.length ? { ...n, children: kids } : { ...n, children: undefined })
95
+ }
96
+ return out
97
+ }
98
+
99
+ /**
100
+ * 按关键字过滤树(命中自身或子节点则保留)。
101
+ */
102
+ function filterTree(nodes: MenuNode[], q: string): MenuNode[] {
103
+ const out: MenuNode[] = []
104
+ for (const n of nodes) {
105
+ const kids = n.children?.length ? filterTree(n.children, q) : []
106
+ const hit =
107
+ n.menuName?.toLowerCase().includes(q) ||
108
+ n.path?.toLowerCase().includes(q) ||
109
+ n.perms?.toLowerCase().includes(q) ||
110
+ n.component?.toLowerCase().includes(q)
111
+ if (hit || kids.length) {
112
+ out.push({ ...n, children: kids.length ? kids : undefined })
113
+ }
114
+ }
115
+ return out
116
+ }
117
+
118
+ const displayTree = computed(() => {
119
+ let nodes = tree.value
120
+ if (!showButtons.value) nodes = stripButtons(nodes)
121
+ const q = keyword.value.trim().toLowerCase()
122
+ if (q) nodes = filterTree(nodes, q)
123
+ return nodes
124
+ })
125
+
126
+ const parentOptions = computed(() => {
127
+ const opts: { id: number; label: string }[] = [{ id: 0, label: '顶级菜单' }]
128
+ const walk = (nodes: MenuNode[], prefix = '') => {
129
+ for (const n of nodes) {
130
+ if (n.menuType === 'F') continue
131
+ if (form.id && n.id === form.id) continue
132
+ opts.push({ id: n.id, label: `${prefix}${n.menuName}` })
133
+ if (n.children?.length) walk(n.children, `${prefix}${n.menuName} / `)
134
+ }
135
+ }
136
+ walk(tree.value)
137
+ return opts
138
+ })
139
+
140
+ const stats = computed(() => {
141
+ let m = 0
142
+ let c = 0
143
+ let f = 0
144
+ const walk = (nodes: MenuNode[]) => {
145
+ for (const n of nodes) {
146
+ if (n.menuType === 'M') m++
147
+ else if (n.menuType === 'C') c++
148
+ else if (n.menuType === 'F') f++
149
+ if (n.children?.length) walk(n.children)
150
+ }
151
+ }
152
+ walk(tree.value)
153
+ return { m, c, f, total: m + c + f }
154
+ })
155
+
156
+ /** 图标下拉选项(含当前值兜底);仅弹窗打开时计算 */
157
+ const iconOptions = computed(() =>
158
+ dialogVisible.value ? resolveMenuIconOptions(form.icon) : [],
159
+ )
160
+
161
+ function typeMeta(t: string) {
162
+ return TYPE_META[t] || TYPE_META.C
163
+ }
164
+
165
+ /**
166
+ * 遍历树节点。
167
+ */
168
+ function walkRows(nodes: MenuNode[], fn: (n: MenuNode) => void) {
169
+ for (const n of nodes) {
170
+ fn(n)
171
+ if (n.children?.length) walkRows(n.children, fn)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * 按 expandAll 同步展开状态(不销毁表格)。
177
+ */
178
+ function applyExpandState() {
179
+ nextTick(() => {
180
+ const table = tableRef.value
181
+ if (!table) return
182
+ walkRows(displayTree.value, (row) => {
183
+ if (row.children?.length) {
184
+ table.toggleRowExpansion(row, expandAll.value)
185
+ }
186
+ })
187
+ })
188
+ }
189
+
190
+ function toggleExpandAll() {
191
+ expandAll.value = !expandAll.value
192
+ applyExpandState()
193
+ }
194
+
195
+ async function load() {
196
+ if (loading.value) return
197
+ loading.value = true
198
+ try {
199
+ tree.value = (await get<MenuNode[]>('/menus/tree')) || []
200
+ // 加载后保持当前展开策略(默认折叠)
201
+ if (expandAll.value) applyExpandState()
202
+ } finally {
203
+ loading.value = false
204
+ }
205
+ }
206
+
207
+ let loadedOnce = false
208
+
209
+ onMounted(() => {
210
+ loadedOnce = true
211
+ load()
212
+ })
213
+
214
+ /** keep-alive 再次激活时不重复拉树,避免请求风暴 */
215
+ onActivated(() => {
216
+ if (!loadedOnce) {
217
+ loadedOnce = true
218
+ load()
219
+ }
220
+ })
221
+
222
+ function resetForm(parentId = 0, menuType = 'C') {
223
+ Object.assign(form, {
224
+ id: undefined,
225
+ parentId,
226
+ menuName: '',
227
+ menuType,
228
+ orderNum: 0,
229
+ path: '',
230
+ component: '',
231
+ query: '',
232
+ isFrame: false,
233
+ isCache: true,
234
+ isVisible: true,
235
+ status: 0,
236
+ perms: '',
237
+ icon: '',
238
+ remark: '',
239
+ })
240
+ }
241
+
242
+ function openCreate(parentId = 0, menuType = 'M') {
243
+ const type = parentId === 0 ? 'M' : menuType
244
+ resetForm(parentId, type)
245
+ dialogVisible.value = true
246
+ }
247
+
248
+ async function openEdit(row: MenuNode) {
249
+ const detail = await get<MenuForm>(`/menus/${row.id}`)
250
+ Object.assign(form, {
251
+ id: detail.id,
252
+ parentId: detail.parentId ?? 0,
253
+ menuName: detail.menuName || '',
254
+ menuType: detail.menuType || 'C',
255
+ orderNum: detail.orderNum ?? 0,
256
+ path: detail.path || '',
257
+ component: detail.component || '',
258
+ query: detail.query || '',
259
+ isFrame: !!detail.isFrame,
260
+ isCache: detail.isCache !== false,
261
+ isVisible: detail.isVisible !== false,
262
+ status: detail.status ?? 0,
263
+ perms: detail.perms || '',
264
+ icon: detail.icon || '',
265
+ remark: detail.remark || '',
266
+ })
267
+ dialogVisible.value = true
268
+ }
269
+
270
+ async function save() {
271
+ if (!form.menuName.trim()) {
272
+ ElMessage.warning('请填写菜单名称')
273
+ return
274
+ }
275
+ if (!form.menuType) {
276
+ ElMessage.warning('请选择菜单类型')
277
+ return
278
+ }
279
+ saving.value = true
280
+ try {
281
+ const payload = {
282
+ ...form,
283
+ menuName: form.menuName.trim(),
284
+ path: form.path.trim() || null,
285
+ component: form.component.trim() || null,
286
+ query: form.query.trim() || null,
287
+ perms: form.perms.trim() || null,
288
+ icon: form.icon.trim() || null,
289
+ remark: form.remark.trim() || null,
290
+ }
291
+ if (form.id) await put('/menus', payload)
292
+ else await post('/menus', payload)
293
+ ElMessage.success('保存成功')
294
+ dialogVisible.value = false
295
+ load()
296
+ } finally {
297
+ saving.value = false
298
+ }
299
+ }
300
+
301
+ async function remove(row: MenuNode) {
302
+ if (row.children?.length) {
303
+ ElMessage.warning('请先删除子菜单')
304
+ return
305
+ }
306
+ // 隐藏按钮视图时,目录/菜单下可能仍有按钮子节点
307
+ if (!showButtons.value && row.menuType !== 'F') {
308
+ const hasBtnChild = (nodes?: MenuNode[]): boolean => {
309
+ if (!nodes?.length) return false
310
+ return nodes.some((n) => n.menuType === 'F' || hasBtnChild(n.children))
311
+ }
312
+ const full = findNode(tree.value, row.id)
313
+ if (full && hasBtnChild(full.children)) {
314
+ ElMessage.warning('请先删除子菜单(含按钮权限),或开启「显示按钮」后操作')
315
+ return
316
+ }
317
+ }
318
+ await ElMessageBox.confirm(`确认删除「${row.menuName}」?`, '提示', { type: 'warning' })
319
+ await del('/menus', { ids: [row.id] })
320
+ ElMessage.success('已删除')
321
+ load()
322
+ }
323
+
324
+ function findNode(nodes: MenuNode[], id: number): MenuNode | null {
325
+ for (const n of nodes) {
326
+ if (n.id === id) return n
327
+ if (n.children?.length) {
328
+ const hit = findNode(n.children, id)
329
+ if (hit) return hit
330
+ }
331
+ }
332
+ return null
333
+ }
334
+ </script>
335
+
336
+ <template>
337
+ <AppPage title="菜单管理" description="维护目录、页面菜单与按钮权限,支持树形展开与图标预览">
338
+ <div class="menu-stats">
339
+ <div class="menu-stats__item">
340
+ <span class="menu-stats__num">{{ stats.total }}</span>
341
+ <span class="menu-stats__label">全部节点</span>
342
+ </div>
343
+ <div class="menu-stats__item menu-stats__item--dir">
344
+ <span class="menu-stats__num">{{ stats.m }}</span>
345
+ <span class="menu-stats__label">目录</span>
346
+ </div>
347
+ <div class="menu-stats__item menu-stats__item--menu">
348
+ <span class="menu-stats__num">{{ stats.c }}</span>
349
+ <span class="menu-stats__label">菜单</span>
350
+ </div>
351
+ <div class="menu-stats__item menu-stats__item--btn">
352
+ <span class="menu-stats__num">{{ stats.f }}</span>
353
+ <span class="menu-stats__label">按钮</span>
354
+ </div>
355
+ </div>
356
+
357
+ <div class="toolbar">
358
+ <el-input
359
+ v-model="keyword"
360
+ clearable
361
+ placeholder="搜索名称 / 路由 / 权限标识"
362
+ style="width: 260px"
363
+ />
364
+ <el-button type="primary" :icon="Refresh" @click="load">刷新</el-button>
365
+ <el-button @click="toggleExpandAll">{{ expandAll ? '全部折叠' : '全部展开' }}</el-button>
366
+ <el-checkbox v-model="showButtons" border>显示按钮</el-checkbox>
367
+ <div class="toolbar__spacer" />
368
+ <el-button type="success" :icon="Plus" v-permission="'system:menu:add'" @click="openCreate(0, 'M')">
369
+ 新增目录
370
+ </el-button>
371
+ </div>
372
+
373
+ <div class="menu-table-wrap" v-loading="loading">
374
+ <el-table
375
+ ref="tableRef"
376
+ :data="displayTree"
377
+ row-key="id"
378
+ border
379
+ stripe
380
+ :tree-props="{ children: 'children' }"
381
+ class="menu-table"
382
+ >
383
+ <el-table-column label="菜单名称" min-width="220" show-overflow-tooltip>
384
+ <template #default="{ row }">
385
+ <div class="name-cell">
386
+ <span class="name-cell__icon" :class="`is-${typeMeta(row.menuType).tone}`">
387
+ <el-icon :size="14">
388
+ <component :is="row.icon ? resolveMenuIcon(row.icon) : typeMeta(row.menuType).icon" />
389
+ </el-icon>
390
+ </span>
391
+ <strong>{{ row.menuName }}</strong>
392
+ </div>
393
+ </template>
394
+ </el-table-column>
395
+
396
+ <el-table-column label="类型" width="88" align="center">
397
+ <template #default="{ row }">
398
+ <span class="type-pill" :class="`is-${typeMeta(row.menuType).tone}`">
399
+ {{ typeMeta(row.menuType).label }}
400
+ </span>
401
+ </template>
402
+ </el-table-column>
403
+
404
+ <el-table-column prop="icon" label="图标" width="72" align="center">
405
+ <template #default="{ row }">
406
+ <el-icon v-if="row.icon" :size="16" class="icon-only">
407
+ <component :is="resolveMenuIcon(row.icon)" />
408
+ </el-icon>
409
+ <span v-else class="muted">—</span>
410
+ </template>
411
+ </el-table-column>
412
+
413
+ <el-table-column prop="orderNum" label="排序" width="72" align="center" />
414
+
415
+ <el-table-column prop="path" label="路由" min-width="120" show-overflow-tooltip>
416
+ <template #default="{ row }">
417
+ <code v-if="row.path" class="mono">{{ row.path }}</code>
418
+ <span v-else class="muted">—</span>
419
+ </template>
420
+ </el-table-column>
421
+
422
+ <el-table-column prop="component" label="组件" min-width="140" show-overflow-tooltip>
423
+ <template #default="{ row }">
424
+ <code v-if="row.component" class="mono">{{ row.component }}</code>
425
+ <span v-else class="muted">—</span>
426
+ </template>
427
+ </el-table-column>
428
+
429
+ <el-table-column prop="perms" label="权限标识" min-width="150" show-overflow-tooltip>
430
+ <template #default="{ row }">
431
+ <code v-if="row.perms" class="mono mono--perm">{{ row.perms }}</code>
432
+ <span v-else class="muted">—</span>
433
+ </template>
434
+ </el-table-column>
435
+
436
+ <el-table-column label="可见" width="72" align="center">
437
+ <template #default="{ row }">
438
+ <el-tag size="small" :type="row.isVisible ? 'success' : 'info'" effect="light">
439
+ {{ row.isVisible ? '是' : '否' }}
440
+ </el-tag>
441
+ </template>
442
+ </el-table-column>
443
+
444
+ <el-table-column label="状态" width="80" align="center">
445
+ <template #default="{ row }">
446
+ <el-tag size="small" :type="row.status === 0 ? 'success' : 'danger'">
447
+ {{ row.status === 0 ? '正常' : '停用' }}
448
+ </el-tag>
449
+ </template>
450
+ </el-table-column>
451
+
452
+ <el-table-column label="操作" width="260" fixed="right" align="center">
453
+ <template #default="{ row }">
454
+ <div class="row-actions">
455
+ <el-button
456
+ v-if="row.menuType !== 'F'"
457
+ size="small"
458
+ type="primary"
459
+ v-permission="'system:menu:add'"
460
+ @click="openCreate(row.id, row.menuType === 'M' ? 'C' : 'F')"
461
+ >
462
+ 新增
463
+ </el-button>
464
+ <el-button
465
+ size="small"
466
+ type="primary"
467
+ v-permission="'system:menu:edit'"
468
+ @click="openEdit(row)"
469
+ >
470
+ 编辑
471
+ </el-button>
472
+ <el-button
473
+ size="small"
474
+ type="danger"
475
+ v-permission="'system:menu:remove'"
476
+ @click="remove(row)"
477
+ >
478
+ 删除
479
+ </el-button>
480
+ </div>
481
+ </template>
482
+ </el-table-column>
483
+ </el-table>
484
+ </div>
485
+
486
+ <el-dialog
487
+ v-model="dialogVisible"
488
+ :title="form.id ? '编辑菜单' : '新增菜单'"
489
+ width="640px"
490
+ class="menu-dialog"
491
+ append-to-body
492
+ align-center
493
+ destroy-on-close
494
+ >
495
+ <el-form label-width="96px" class="menu-form">
496
+ <el-form-item label="上级菜单">
497
+ <el-select v-model="form.parentId" filterable style="width: 100%">
498
+ <el-option v-for="o in parentOptions" :key="o.id" :label="o.label" :value="o.id" />
499
+ </el-select>
500
+ </el-form-item>
501
+
502
+ <el-form-item label="菜单类型" required>
503
+ <div class="type-cards">
504
+ <button
505
+ v-for="t in MENU_TYPES"
506
+ :key="t.value"
507
+ type="button"
508
+ class="type-card"
509
+ :class="{ 'is-active': form.menuType === t.value }"
510
+ @click="form.menuType = t.value"
511
+ >
512
+ <strong>{{ t.label }}</strong>
513
+ <small>{{ t.tip }}</small>
514
+ </button>
515
+ </div>
516
+ </el-form-item>
517
+
518
+ <el-row :gutter="0">
519
+ <el-col :span="12" class="menu-form__col-l">
520
+ <el-form-item label="菜单名称" required>
521
+ <el-input v-model="form.menuName" maxlength="64" placeholder="显示名称" />
522
+ </el-form-item>
523
+ </el-col>
524
+ <el-col :span="12" class="menu-form__col-r">
525
+ <el-form-item label="显示排序">
526
+ <el-input-number v-model="form.orderNum" :min="0" controls-position="right" style="width: 100%" />
527
+ </el-form-item>
528
+ </el-col>
529
+ </el-row>
530
+
531
+ <el-form-item v-if="form.menuType !== 'F'" label="图标">
532
+ <el-select
533
+ v-model="form.icon"
534
+ clearable
535
+ filterable
536
+ placeholder="请选择图标"
537
+ style="width: 100%"
538
+ popper-class="menu-icon-select-popper"
539
+ >
540
+ <template #prefix>
541
+ <el-icon v-if="form.icon"><component :is="resolveMenuIcon(form.icon)" /></el-icon>
542
+ </template>
543
+ <el-option v-for="name in iconOptions" :key="name" :label="name" :value="name">
544
+ <span class="icon-option">
545
+ <el-icon :size="16"><component :is="resolveMenuIcon(name)" /></el-icon>
546
+ <span>{{ name }}</span>
547
+ </span>
548
+ </el-option>
549
+ </el-select>
550
+ </el-form-item>
551
+
552
+ <el-row v-if="form.menuType !== 'F'" :gutter="0">
553
+ <el-col :span="12" class="menu-form__col-l">
554
+ <el-form-item label="路由地址">
555
+ <el-input v-model="form.path" placeholder="如 system/user" />
556
+ </el-form-item>
557
+ </el-col>
558
+ <el-col :span="12" class="menu-form__col-r">
559
+ <el-form-item label="组件路径">
560
+ <el-input v-model="form.component" placeholder="如 system/user/index" />
561
+ </el-form-item>
562
+ </el-col>
563
+ </el-row>
564
+
565
+ <el-form-item v-if="form.menuType === 'C'" label="路由参数">
566
+ <el-input v-model="form.query" placeholder="可选,如 id=1" />
567
+ </el-form-item>
568
+
569
+ <el-form-item v-if="form.menuType !== 'M'" label="权限标识">
570
+ <el-input v-model="form.perms" placeholder="如 system:user:list" />
571
+ </el-form-item>
572
+
573
+ <el-form-item label="状态">
574
+ <el-radio-group v-model="form.status">
575
+ <el-radio :value="0">正常</el-radio>
576
+ <el-radio :value="1">停用</el-radio>
577
+ </el-radio-group>
578
+ </el-form-item>
579
+
580
+ <el-form-item v-if="form.menuType !== 'F'" label="显示">
581
+ <el-switch v-model="form.isVisible" active-text="显示" inactive-text="隐藏" />
582
+ </el-form-item>
583
+
584
+ <el-form-item v-if="form.menuType === 'C'" label="缓存">
585
+ <el-switch v-model="form.isCache" active-text="缓存" inactive-text="不缓存" />
586
+ </el-form-item>
587
+
588
+ <el-form-item v-if="form.menuType === 'C'" label="外链">
589
+ <el-switch v-model="form.isFrame" active-text="是" inactive-text="否" />
590
+ </el-form-item>
591
+
592
+ <el-form-item label="备注">
593
+ <el-input v-model="form.remark" type="textarea" :rows="2" maxlength="200" />
594
+ </el-form-item>
595
+ </el-form>
596
+
597
+ <template #footer>
598
+ <el-button @click="dialogVisible = false">取消</el-button>
599
+ <el-button type="primary" :loading="saving" @click="save">保存</el-button>
600
+ </template>
601
+ </el-dialog>
602
+ </AppPage>
603
+ </template>
604
+
605
+ <style scoped lang="scss">
606
+ .menu-stats {
607
+ display: grid;
608
+ grid-template-columns: repeat(4, minmax(0, 1fr));
609
+ gap: 12px;
610
+ margin-bottom: 14px;
611
+ }
612
+
613
+ .menu-stats__item {
614
+ display: flex;
615
+ flex-direction: column;
616
+ gap: 4px;
617
+ padding: 14px 16px;
618
+ border-radius: 14px;
619
+ border: 1px solid var(--sa-border);
620
+ background: var(--sa-panel);
621
+ box-shadow: var(--sa-shadow);
622
+ }
623
+
624
+ .menu-stats__num {
625
+ font-size: 22px;
626
+ font-weight: 800;
627
+ letter-spacing: -0.03em;
628
+ color: var(--sa-ink);
629
+ font-variant-numeric: tabular-nums;
630
+ }
631
+
632
+ .menu-stats__label {
633
+ font-size: 12px;
634
+ color: var(--sa-ink-muted);
635
+ }
636
+
637
+ .menu-stats__item--dir .menu-stats__num { color: #0369a1; }
638
+ .menu-stats__item--menu .menu-stats__num { color: var(--sa-accent); }
639
+ .menu-stats__item--btn .menu-stats__num { color: #b45309; }
640
+
641
+ .toolbar {
642
+ display: flex;
643
+ flex-wrap: wrap;
644
+ gap: 10px;
645
+ align-items: center;
646
+ margin-bottom: 14px;
647
+ padding: 12px 14px;
648
+ background: var(--sa-toolbar-bg);
649
+ border: 1px solid var(--sa-border);
650
+ border-radius: var(--sa-radius-sm);
651
+ }
652
+
653
+ .toolbar__spacer {
654
+ flex: 1;
655
+ }
656
+
657
+ .menu-table-wrap {
658
+ border: 1px solid var(--sa-border);
659
+ border-radius: 14px;
660
+ overflow: hidden;
661
+ background: var(--sa-panel);
662
+ box-shadow: var(--sa-shadow);
663
+ }
664
+
665
+ .menu-table {
666
+ width: 100%;
667
+ }
668
+
669
+ .name-cell {
670
+ display: inline-flex;
671
+ align-items: center;
672
+ gap: 8px;
673
+ min-width: 0;
674
+ }
675
+
676
+ .name-cell__icon {
677
+ width: 26px;
678
+ height: 26px;
679
+ border-radius: 8px;
680
+ display: grid;
681
+ place-items: center;
682
+ flex-shrink: 0;
683
+ }
684
+
685
+ .name-cell__icon.is-dir {
686
+ background: #e0f2fe;
687
+ color: #0369a1;
688
+ }
689
+ .name-cell__icon.is-menu {
690
+ background: var(--sa-accent-soft);
691
+ color: var(--sa-accent);
692
+ }
693
+ .name-cell__icon.is-btn {
694
+ background: #fef3c7;
695
+ color: #b45309;
696
+ }
697
+
698
+ .name-cell strong {
699
+ font-size: 13px;
700
+ font-weight: 650;
701
+ }
702
+
703
+ .type-pill {
704
+ display: inline-flex;
705
+ align-items: center;
706
+ justify-content: center;
707
+ min-width: 44px;
708
+ padding: 2px 8px;
709
+ border-radius: 999px;
710
+ font-size: 12px;
711
+ font-weight: 600;
712
+ }
713
+ .type-pill.is-dir {
714
+ background: #e0f2fe;
715
+ color: #0369a1;
716
+ }
717
+ .type-pill.is-menu {
718
+ background: var(--sa-accent-soft);
719
+ color: var(--sa-accent);
720
+ }
721
+ .type-pill.is-btn {
722
+ background: #fef3c7;
723
+ color: #b45309;
724
+ }
725
+
726
+ .icon-only {
727
+ color: var(--sa-ink-secondary);
728
+ }
729
+
730
+ .mono {
731
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
732
+ font-size: 12px;
733
+ color: var(--sa-ink-secondary);
734
+ background: var(--sa-toolbar-bg);
735
+ padding: 1px 6px;
736
+ border-radius: 6px;
737
+ }
738
+ .mono--perm {
739
+ color: var(--sa-accent);
740
+ }
741
+
742
+ .muted {
743
+ color: var(--sa-ink-muted);
744
+ }
745
+
746
+ .type-cards {
747
+ display: grid;
748
+ grid-template-columns: repeat(3, minmax(0, 1fr));
749
+ gap: 8px;
750
+ width: 100%;
751
+ }
752
+
753
+ .type-card {
754
+ text-align: left;
755
+ border: 1px solid var(--sa-border);
756
+ background: var(--sa-toolbar-bg);
757
+ border-radius: 12px;
758
+ padding: 10px 12px;
759
+ cursor: pointer;
760
+ transition: border-color 0.2s, background 0.2s, transform 0.2s;
761
+ strong {
762
+ display: block;
763
+ font-size: 13px;
764
+ }
765
+ small {
766
+ display: block;
767
+ margin-top: 2px;
768
+ font-size: 11px;
769
+ color: var(--sa-ink-muted);
770
+ }
771
+ &:hover {
772
+ transform: translateY(-1px);
773
+ border-color: color-mix(in srgb, var(--sa-accent) 40%, var(--sa-border));
774
+ }
775
+ &.is-active {
776
+ border-color: var(--sa-accent);
777
+ background: var(--sa-accent-soft);
778
+ strong {
779
+ color: var(--sa-accent);
780
+ }
781
+ }
782
+ }
783
+
784
+ .menu-form__col-l {
785
+ padding-right: 8px;
786
+ }
787
+ .menu-form__col-r {
788
+ padding-left: 8px;
789
+ }
790
+
791
+ .icon-option {
792
+ display: flex;
793
+ align-items: center;
794
+ gap: 10px;
795
+ }
796
+
797
+ @media (max-width: 900px) {
798
+ .menu-stats {
799
+ grid-template-columns: repeat(2, minmax(0, 1fr));
800
+ }
801
+ }
802
+ </style>
803
+
804
+ <style>
805
+ .menu-dialog.el-dialog {
806
+ width: min(640px, calc(100vw - 32px)) !important;
807
+ max-height: 88vh;
808
+ overflow: hidden;
809
+ display: flex;
810
+ flex-direction: column;
811
+ }
812
+ .menu-dialog .el-dialog__body {
813
+ overflow-x: hidden;
814
+ overflow-y: auto;
815
+ max-height: calc(88vh - 140px);
816
+ }
817
+ .menu-icon-select-popper .el-select-dropdown__item {
818
+ padding: 0 12px;
819
+ height: 36px;
820
+ line-height: 36px;
821
+ }
822
+ </style>