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,546 @@
1
+ <script setup lang="ts">
2
+ import { nextTick, onMounted, reactive, ref, watch } from 'vue'
3
+ import { ElMessage, ElMessageBox, type ElTree } from 'element-plus'
4
+ import { get, post, put, del, getSilent } from '@/utils/request'
5
+ import AppPage from '@/components/AppPage.vue'
6
+ import { formatDateTime } from '@/utils/datetime'
7
+
8
+ interface RoleItem {
9
+ id: number
10
+ roleName: string
11
+ roleCode: string
12
+ orderNum: number
13
+ dataScope: number
14
+ status: number
15
+ createTime: string
16
+ }
17
+
18
+ interface MenuNode {
19
+ id: number
20
+ label: string
21
+ menuType: string
22
+ children?: MenuNode[]
23
+ }
24
+
25
+ interface DeptNode {
26
+ id: number
27
+ label: string
28
+ children?: DeptNode[]
29
+ }
30
+
31
+ interface RoleForm {
32
+ id?: number
33
+ roleName: string
34
+ roleCode: string
35
+ orderNum: number
36
+ dataScope: number
37
+ status: number
38
+ remark: string
39
+ menuIds: number[]
40
+ deptIds: number[]
41
+ }
42
+
43
+ const DATA_SCOPE_OPTIONS = [
44
+ { value: 1, label: '全部数据权限' },
45
+ { value: 2, label: '自定义数据权限' },
46
+ { value: 3, label: '本部门数据权限' },
47
+ { value: 4, label: '本部门及以下数据' },
48
+ { value: 5, label: '仅本人数据权限' },
49
+ ]
50
+
51
+ const MENU_TYPE_LABEL: Record<string, string> = {
52
+ M: '目录',
53
+ C: '菜单',
54
+ F: '按钮',
55
+ }
56
+
57
+ const loading = ref(false)
58
+ const total = ref(0)
59
+ const list = ref<RoleItem[]>([])
60
+ const query = reactive({ pageIndex: 1, pageSize: 10, keyword: '' })
61
+ const dialogVisible = ref(false)
62
+ const saving = ref(false)
63
+ const menuTree = ref<MenuNode[]>([])
64
+ const deptTree = ref<DeptNode[]>([])
65
+ const menuTreeRef = ref<InstanceType<typeof ElTree>>()
66
+ const deptTreeRef = ref<InstanceType<typeof ElTree>>()
67
+ /** 每次打开弹窗递增,强制重建树,避免勾选状态残留 */
68
+ const treeRenderKey = ref(0)
69
+ /** 待回显的菜单/部门 Id(在 @opened 时写入树) */
70
+ const pendingMenuIds = ref<number[]>([])
71
+ const pendingDeptIds = ref<number[]>([])
72
+
73
+ const form = reactive<RoleForm>({
74
+ id: undefined,
75
+ roleName: '',
76
+ roleCode: '',
77
+ orderNum: 0,
78
+ dataScope: 1,
79
+ status: 0,
80
+ remark: '',
81
+ menuIds: [],
82
+ deptIds: [],
83
+ })
84
+
85
+ function dataScopeLabel(v: number) {
86
+ return DATA_SCOPE_OPTIONS.find((x) => x.value === v)?.label || String(v)
87
+ }
88
+
89
+ function menuTypeLabel(t: string) {
90
+ return MENU_TYPE_LABEL[t] || t
91
+ }
92
+
93
+ /**
94
+ * 仅保留叶子节点 Id,供级联勾选树回显(父节点勾选会连带全选子节点)。
95
+ */
96
+ function pickLeafIds(nodes: MenuNode[] | DeptNode[], ids: number[], labelChildren = 'children'): number[] {
97
+ const wanted = new Set(ids.map((x) => Number(x)))
98
+ const leaves: number[] = []
99
+ const walk = (list: any[]) => {
100
+ for (const n of list || []) {
101
+ const kids = n[labelChildren] as any[] | undefined
102
+ if (kids?.length) {
103
+ walk(kids)
104
+ } else if (wanted.has(Number(n.id))) {
105
+ leaves.push(Number(n.id))
106
+ }
107
+ }
108
+ }
109
+ walk(nodes as any[])
110
+ return leaves
111
+ }
112
+
113
+ async function loadMenus() {
114
+ menuTree.value = (await getSilent<MenuNode[]>('/menus/options')) || []
115
+ }
116
+
117
+ async function loadDepts() {
118
+ deptTree.value = (await getSilent<DeptNode[]>('/depts/options')) || []
119
+ }
120
+
121
+ async function load() {
122
+ loading.value = true
123
+ try {
124
+ const data = await get<{ total: number; items: RoleItem[] }>('/roles', query)
125
+ total.value = data.total
126
+ list.value = data.items
127
+ } finally {
128
+ loading.value = false
129
+ }
130
+ }
131
+
132
+ function resetForm() {
133
+ Object.assign(form, {
134
+ id: undefined,
135
+ roleName: '',
136
+ roleCode: '',
137
+ orderNum: 0,
138
+ dataScope: 1,
139
+ status: 0,
140
+ remark: '',
141
+ menuIds: [],
142
+ deptIds: [],
143
+ })
144
+ }
145
+
146
+ /**
147
+ * 弹窗完全打开后再勾选,避免 destroy-on-close 导致树未挂载。
148
+ */
149
+ function onDialogOpened() {
150
+ nextTick(() => {
151
+ const menuLeaves = pickLeafIds(menuTree.value, pendingMenuIds.value)
152
+ menuTreeRef.value?.setCheckedKeys(menuLeaves, false)
153
+ applyDeptChecks()
154
+ })
155
+ }
156
+
157
+ function applyDeptChecks() {
158
+ if (form.dataScope !== 2) return
159
+ nextTick(() => {
160
+ const deptKeys = pendingDeptIds.value.map(Number)
161
+ deptTreeRef.value?.setCheckedKeys(deptKeys, false)
162
+ })
163
+ }
164
+
165
+ watch(
166
+ () => form.dataScope,
167
+ (v) => {
168
+ if (dialogVisible.value && v === 2) applyDeptChecks()
169
+ },
170
+ )
171
+
172
+ async function openCreate() {
173
+ resetForm()
174
+ if (!menuTree.value.length) await loadMenus()
175
+ if (!deptTree.value.length) await loadDepts()
176
+ pendingMenuIds.value = []
177
+ pendingDeptIds.value = []
178
+ treeRenderKey.value += 1
179
+ dialogVisible.value = true
180
+ }
181
+
182
+ async function openEdit(row: RoleItem) {
183
+ if (!menuTree.value.length) await loadMenus()
184
+ if (!deptTree.value.length) await loadDepts()
185
+ const detail = await get<RoleForm>(`/roles/${row.id}`)
186
+ Object.assign(form, {
187
+ id: detail.id,
188
+ roleName: detail.roleName || '',
189
+ roleCode: detail.roleCode || '',
190
+ orderNum: detail.orderNum ?? 0,
191
+ dataScope: Number(detail.dataScope ?? 1),
192
+ status: Number(detail.status ?? 0),
193
+ remark: detail.remark || '',
194
+ menuIds: (detail.menuIds || []).map(Number),
195
+ deptIds: (detail.deptIds || []).map(Number),
196
+ })
197
+ pendingMenuIds.value = [...form.menuIds]
198
+ pendingDeptIds.value = [...form.deptIds]
199
+ treeRenderKey.value += 1
200
+ dialogVisible.value = true
201
+ }
202
+
203
+ function collectMenuIds(): number[] {
204
+ const checked = ((menuTreeRef.value?.getCheckedKeys(false) as Array<string | number>) || []).map(Number)
205
+ const half = ((menuTreeRef.value?.getHalfCheckedKeys() as Array<string | number>) || []).map(Number)
206
+ return Array.from(new Set([...checked, ...half]))
207
+ }
208
+
209
+ function collectDeptIds(): number[] {
210
+ return ((deptTreeRef.value?.getCheckedKeys(false) as Array<string | number>) || []).map(Number)
211
+ }
212
+
213
+ async function save() {
214
+ if (!form.roleName.trim()) {
215
+ ElMessage.warning('请填写角色名称')
216
+ return
217
+ }
218
+ if (!form.roleCode.trim()) {
219
+ ElMessage.warning('请填写角色编码')
220
+ return
221
+ }
222
+ const menuIds = collectMenuIds()
223
+ const deptIds = form.dataScope === 2 ? collectDeptIds() : []
224
+ if (!menuIds.length) {
225
+ ElMessage.warning('请至少勾选一项菜单权限')
226
+ return
227
+ }
228
+ if (form.dataScope === 2 && !deptIds.length) {
229
+ ElMessage.warning('自定义数据权限请勾选部门')
230
+ return
231
+ }
232
+
233
+ const payload = {
234
+ id: form.id,
235
+ roleName: form.roleName.trim(),
236
+ roleCode: form.roleCode.trim(),
237
+ orderNum: form.orderNum,
238
+ dataScope: form.dataScope,
239
+ status: form.status,
240
+ remark: form.remark.trim() || null,
241
+ menuIds,
242
+ deptIds,
243
+ }
244
+
245
+ saving.value = true
246
+ try {
247
+ if (form.id) await put('/roles', payload)
248
+ else await post('/roles', payload)
249
+ ElMessage.success('保存成功')
250
+ dialogVisible.value = false
251
+ load()
252
+ } finally {
253
+ saving.value = false
254
+ }
255
+ }
256
+
257
+ async function remove(row: RoleItem) {
258
+ if (row.id === 1) {
259
+ ElMessage.warning('不可删除超级管理员角色')
260
+ return
261
+ }
262
+ await ElMessageBox.confirm(`确认删除角色「${row.roleName}」?`, '提示')
263
+ await del('/roles', { ids: [row.id] })
264
+ ElMessage.success('已删除')
265
+ load()
266
+ }
267
+
268
+ onMounted(async () => {
269
+ await Promise.all([load(), loadMenus(), loadDepts()])
270
+ })
271
+ </script>
272
+
273
+ <template>
274
+ <AppPage title="角色管理" description="在新增/编辑弹窗中勾选菜单权限与数据范围">
275
+ <div class="toolbar">
276
+ <el-input v-model="query.keyword" placeholder="角色名称/编码" clearable style="width: 220px" @keyup.enter="load" />
277
+ <el-button type="primary" @click="load">查询</el-button>
278
+ <el-button type="success" v-permission="'system:role:add'" @click="openCreate">新增</el-button>
279
+ </div>
280
+
281
+ <el-table :data="list" v-loading="loading" border stripe>
282
+ <el-table-column prop="roleName" label="角色名称" min-width="120" />
283
+ <el-table-column prop="roleCode" label="角色编码" min-width="120" />
284
+ <el-table-column prop="orderNum" label="排序" width="80" />
285
+ <el-table-column label="数据范围" min-width="140" show-overflow-tooltip>
286
+ <template #default="{ row }">{{ dataScopeLabel(row.dataScope) }}</template>
287
+ </el-table-column>
288
+ <el-table-column prop="status" label="状态" width="90">
289
+ <template #default="{ row }">
290
+ <el-tag :type="row.status === 0 ? 'success' : 'danger'">{{ row.status === 0 ? '正常' : '停用' }}</el-tag>
291
+ </template>
292
+ </el-table-column>
293
+ <el-table-column prop="createTime" label="创建时间" width="170" class-name="is-datetime" show-overflow-tooltip>
294
+ <template #default="{ row }">
295
+ <span class="datetime-cell">{{ formatDateTime(row.createTime) }}</span>
296
+ </template>
297
+ </el-table-column>
298
+ <el-table-column label="操作" width="180" fixed="right" align="center">
299
+ <template #default="{ row }">
300
+ <div class="row-actions">
301
+ <el-button
302
+ size="small"
303
+ type="primary"
304
+ v-permission="'system:role:edit'"
305
+ @click="openEdit(row)"
306
+ >
307
+ 编辑
308
+ </el-button>
309
+ <el-button
310
+ size="small"
311
+ type="danger"
312
+ v-permission="'system:role:remove'"
313
+ @click="remove(row)"
314
+ >
315
+ 删除
316
+ </el-button>
317
+ </div>
318
+ </template>
319
+ </el-table-column>
320
+ </el-table>
321
+
322
+ <el-pagination
323
+ class="pager"
324
+ background
325
+ layout="total, prev, pager, next"
326
+ :total="total"
327
+ v-model:current-page="query.pageIndex"
328
+ v-model:page-size="query.pageSize"
329
+ @current-change="load"
330
+ />
331
+
332
+ <el-dialog
333
+ v-model="dialogVisible"
334
+ :title="form.id ? '编辑角色 / 权限' : '新增角色 / 权限'"
335
+ width="760px"
336
+ class="role-perm-dialog"
337
+ append-to-body
338
+ align-center
339
+ destroy-on-close
340
+ @opened="onDialogOpened"
341
+ >
342
+ <el-form label-width="96px" class="role-form">
343
+ <el-row :gutter="0" class="role-form__row">
344
+ <el-col :span="12" class="role-form__col">
345
+ <el-form-item label="角色名称" required>
346
+ <el-input v-model="form.roleName" maxlength="64" />
347
+ </el-form-item>
348
+ </el-col>
349
+ <el-col :span="12" class="role-form__col">
350
+ <el-form-item label="角色编码" required>
351
+ <el-input v-model="form.roleCode" maxlength="64" :disabled="form.id === 1" />
352
+ </el-form-item>
353
+ </el-col>
354
+ <el-col :span="12" class="role-form__col">
355
+ <el-form-item label="显示排序">
356
+ <el-input-number v-model="form.orderNum" :min="0" controls-position="right" style="width: 100%" />
357
+ </el-form-item>
358
+ </el-col>
359
+ <el-col :span="12" class="role-form__col">
360
+ <el-form-item label="状态">
361
+ <el-radio-group v-model="form.status">
362
+ <el-radio :value="0">正常</el-radio>
363
+ <el-radio :value="1">停用</el-radio>
364
+ </el-radio-group>
365
+ </el-form-item>
366
+ </el-col>
367
+ <el-col :span="24">
368
+ <el-form-item label="数据范围">
369
+ <el-select v-model="form.dataScope" style="width: 100%">
370
+ <el-option
371
+ v-for="opt in DATA_SCOPE_OPTIONS"
372
+ :key="opt.value"
373
+ :label="opt.label"
374
+ :value="opt.value"
375
+ />
376
+ </el-select>
377
+ </el-form-item>
378
+ </el-col>
379
+ <el-col :span="24">
380
+ <el-form-item label="备注">
381
+ <el-input v-model="form.remark" type="textarea" :rows="2" maxlength="200" />
382
+ </el-form-item>
383
+ </el-col>
384
+ </el-row>
385
+
386
+ <el-form-item label="菜单权限" required class="perm-item">
387
+ <div class="perm-box">
388
+ <div class="perm-box__tip">勾选菜单与按钮权限;半选父级表示仅部分子权限</div>
389
+ <el-tree
390
+ :key="`menu-${treeRenderKey}`"
391
+ ref="menuTreeRef"
392
+ :data="menuTree"
393
+ show-checkbox
394
+ node-key="id"
395
+ :props="{ label: 'label', children: 'children' }"
396
+ default-expand-all
397
+ :check-on-click-node="false"
398
+ >
399
+ <template #default="{ data }">
400
+ <span class="tree-node">
401
+ <span class="tree-node__name">{{ data.label }}</span>
402
+ <el-tag size="small" effect="plain" class="tree-node__tag">
403
+ {{ menuTypeLabel(data.menuType) }}
404
+ </el-tag>
405
+ </span>
406
+ </template>
407
+ </el-tree>
408
+ </div>
409
+ </el-form-item>
410
+
411
+ <el-form-item v-if="form.dataScope === 2" label="数据权限" class="perm-item">
412
+ <div class="perm-box">
413
+ <div class="perm-box__tip">自定义数据范围时勾选可见部门(可单独勾选父/子级)</div>
414
+ <el-tree
415
+ :key="`dept-${treeRenderKey}`"
416
+ ref="deptTreeRef"
417
+ :data="deptTree"
418
+ show-checkbox
419
+ node-key="id"
420
+ check-strictly
421
+ :props="{ label: 'label', children: 'children' }"
422
+ default-expand-all
423
+ :check-on-click-node="false"
424
+ />
425
+ </div>
426
+ </el-form-item>
427
+ </el-form>
428
+
429
+ <template #footer>
430
+ <el-button @click="dialogVisible = false">取消</el-button>
431
+ <el-button type="primary" :loading="saving" @click="save">保存</el-button>
432
+ </template>
433
+ </el-dialog>
434
+ </AppPage>
435
+ </template>
436
+
437
+ <style scoped>
438
+ .role-form {
439
+ width: 100%;
440
+ max-width: 100%;
441
+ overflow-x: hidden;
442
+ }
443
+ .role-form__row {
444
+ margin-left: 0 !important;
445
+ margin-right: 0 !important;
446
+ }
447
+ .role-form__col:nth-child(odd) {
448
+ padding-right: 8px;
449
+ }
450
+ .role-form__col:nth-child(even) {
451
+ padding-left: 8px;
452
+ }
453
+ .perm-item {
454
+ align-items: flex-start;
455
+ }
456
+ .perm-item :deep(.el-form-item__content) {
457
+ display: block;
458
+ width: 100%;
459
+ max-width: 100%;
460
+ min-width: 0;
461
+ overflow: hidden;
462
+ }
463
+ .perm-box {
464
+ box-sizing: border-box;
465
+ width: 100%;
466
+ max-width: 100%;
467
+ max-height: 320px;
468
+ overflow-x: hidden;
469
+ overflow-y: auto;
470
+ padding: 10px 12px 12px;
471
+ border: 1px solid var(--sa-border);
472
+ border-radius: 10px;
473
+ background: var(--sa-toolbar-bg, #f8fafc);
474
+ }
475
+ .perm-box__tip {
476
+ margin-bottom: 8px;
477
+ font-size: 12px;
478
+ color: var(--sa-ink-muted);
479
+ }
480
+ .perm-box :deep(.el-tree) {
481
+ width: 100%;
482
+ background: transparent;
483
+ overflow: hidden;
484
+ }
485
+ .perm-box :deep(.el-tree-node) {
486
+ width: 100%;
487
+ }
488
+ .perm-box :deep(.el-tree-node__content) {
489
+ width: 100%;
490
+ box-sizing: border-box;
491
+ height: 32px;
492
+ overflow: hidden;
493
+ }
494
+ .perm-box :deep(.el-tree-node__label),
495
+ .perm-box :deep(.el-tree-node__content > .tree-node) {
496
+ flex: 1;
497
+ min-width: 0;
498
+ overflow: hidden;
499
+ }
500
+ .tree-node {
501
+ display: inline-flex;
502
+ align-items: center;
503
+ gap: 8px;
504
+ max-width: 100%;
505
+ min-width: 0;
506
+ overflow: hidden;
507
+ vertical-align: middle;
508
+ }
509
+ .tree-node__name {
510
+ font-size: 13px;
511
+ overflow: hidden;
512
+ text-overflow: ellipsis;
513
+ white-space: nowrap;
514
+ }
515
+ .tree-node__tag {
516
+ flex-shrink: 0;
517
+ height: 20px;
518
+ line-height: 18px;
519
+ }
520
+ </style>
521
+
522
+ <style>
523
+ /* 弹窗仅纵向滚动,禁止左右滚动条 */
524
+ .role-perm-dialog.el-dialog {
525
+ margin-top: 6vh !important;
526
+ width: min(760px, calc(100vw - 32px)) !important;
527
+ max-width: calc(100vw - 32px);
528
+ max-height: 88vh;
529
+ display: flex;
530
+ flex-direction: column;
531
+ overflow: hidden;
532
+ }
533
+ .role-perm-dialog .el-dialog__header,
534
+ .role-perm-dialog .el-dialog__footer {
535
+ flex-shrink: 0;
536
+ }
537
+ .role-perm-dialog .el-dialog__body {
538
+ flex: 1;
539
+ min-height: 0;
540
+ overflow-x: hidden;
541
+ overflow-y: auto;
542
+ max-height: calc(88vh - 140px);
543
+ padding: 12px 20px 8px;
544
+ box-sizing: border-box;
545
+ }
546
+ </style>