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,578 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, reactive, ref } from 'vue'
3
+ import { ElMessage, ElMessageBox } from 'element-plus'
4
+ import { Plus, Refresh, Search, Notification, View } from '@element-plus/icons-vue'
5
+ import { get, post, put, del } from '@/utils/request'
6
+ import AppPage from '@/components/AppPage.vue'
7
+ import RichTextEditor from '@/components/RichTextEditor.vue'
8
+ import { formatDateTime } from '@/utils/datetime'
9
+
10
+ interface NoticeItem {
11
+ id: number
12
+ noticeTitle: string
13
+ noticeType: number
14
+ status: number
15
+ createTime: string
16
+ }
17
+
18
+ interface NoticeForm {
19
+ id?: number
20
+ noticeTitle: string
21
+ noticeContent: string
22
+ noticeType: number
23
+ status: number
24
+ remark: string
25
+ }
26
+
27
+ const loading = ref(false)
28
+ const saving = ref(false)
29
+ const total = ref(0)
30
+ const list = ref<NoticeItem[]>([])
31
+ const dialogVisible = ref(false)
32
+ const previewVisible = ref(false)
33
+ const preview = reactive({
34
+ title: '',
35
+ noticeType: 1,
36
+ status: 0,
37
+ createTime: '',
38
+ content: '',
39
+ })
40
+
41
+ const query = reactive({
42
+ pageIndex: 1,
43
+ pageSize: 10,
44
+ keyword: '',
45
+ noticeType: undefined as number | undefined,
46
+ status: undefined as number | undefined,
47
+ })
48
+
49
+ const form = reactive<NoticeForm>({
50
+ id: undefined,
51
+ noticeTitle: '',
52
+ noticeContent: '',
53
+ noticeType: 1,
54
+ status: 0,
55
+ remark: '',
56
+ })
57
+
58
+ const stats = reactive({ total: 0, notice: 0, announce: 0, closed: 0 })
59
+
60
+ function typeLabel(t: number) {
61
+ return t === 2 ? '公告' : '通知'
62
+ }
63
+
64
+ /** 去掉 script,降低管理员内容误写入脚本的风险 */
65
+ function sanitizeHtml(html: string) {
66
+ return (html || '')
67
+ .replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
68
+ .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
69
+ }
70
+
71
+ function isEmptyHtml(html: string) {
72
+ const text = (html || '')
73
+ .replace(/<[^>]+>/g, '')
74
+ .replace(/&nbsp;/gi, ' ')
75
+ .trim()
76
+ return !text
77
+ }
78
+
79
+ async function loadStats(pageTotal?: number) {
80
+ const base = { pageIndex: 1, pageSize: 1, keyword: query.keyword || undefined }
81
+ const unfiltered =
82
+ (query.noticeType === undefined || query.noticeType === null) &&
83
+ (query.status === undefined || query.status === null)
84
+ try {
85
+ const tasks: Promise<void>[] = []
86
+ if (unfiltered && pageTotal != null) {
87
+ stats.total = pageTotal
88
+ } else {
89
+ tasks.push(
90
+ get<{ total: number }>('/notices', base).then((all) => {
91
+ stats.total = all.total
92
+ }),
93
+ )
94
+ }
95
+ tasks.push(
96
+ get<{ total: number }>('/notices', { ...base, noticeType: 1 }).then((r) => {
97
+ stats.notice = r.total
98
+ }),
99
+ get<{ total: number }>('/notices', { ...base, noticeType: 2 }).then((r) => {
100
+ stats.announce = r.total
101
+ }),
102
+ get<{ total: number }>('/notices', { ...base, status: 1 }).then((r) => {
103
+ stats.closed = r.total
104
+ }),
105
+ )
106
+ await Promise.all(tasks)
107
+ } catch {
108
+ stats.total = total.value
109
+ }
110
+ }
111
+
112
+ async function load() {
113
+ loading.value = true
114
+ try {
115
+ const params: Record<string, unknown> = {
116
+ pageIndex: query.pageIndex,
117
+ pageSize: query.pageSize,
118
+ keyword: query.keyword || undefined,
119
+ }
120
+ if (query.noticeType !== undefined && query.noticeType !== null) params.noticeType = query.noticeType
121
+ if (query.status !== undefined && query.status !== null) params.status = query.status
122
+ const data = await get<{ total: number; items: NoticeItem[] }>('/notices', params)
123
+ total.value = data.total
124
+ list.value = data.items || []
125
+ await loadStats(data.total)
126
+ } finally {
127
+ loading.value = false
128
+ }
129
+ }
130
+
131
+ function search() {
132
+ query.pageIndex = 1
133
+ load()
134
+ }
135
+
136
+ function resetQuery() {
137
+ query.keyword = ''
138
+ query.noticeType = undefined
139
+ query.status = undefined
140
+ query.pageIndex = 1
141
+ load()
142
+ }
143
+
144
+ function openCreate() {
145
+ Object.assign(form, {
146
+ id: undefined,
147
+ noticeTitle: '',
148
+ noticeContent: '',
149
+ noticeType: 1,
150
+ status: 0,
151
+ remark: '',
152
+ })
153
+ dialogVisible.value = true
154
+ }
155
+
156
+ async function openEdit(row: NoticeItem) {
157
+ const detail = await get<NoticeForm>(`/notices/${row.id}`)
158
+ Object.assign(form, {
159
+ id: detail.id,
160
+ noticeTitle: detail.noticeTitle || '',
161
+ noticeContent: detail.noticeContent || '',
162
+ noticeType: detail.noticeType ?? 1,
163
+ status: detail.status ?? 0,
164
+ remark: detail.remark || '',
165
+ })
166
+ dialogVisible.value = true
167
+ }
168
+
169
+ async function openPreview(row: NoticeItem) {
170
+ const detail = await get<NoticeForm>(`/notices/${row.id}`)
171
+ preview.title = detail.noticeTitle || row.noticeTitle
172
+ preview.noticeType = detail.noticeType ?? row.noticeType
173
+ preview.status = detail.status ?? row.status
174
+ preview.createTime = row.createTime
175
+ preview.content = sanitizeHtml(detail.noticeContent || '')
176
+ previewVisible.value = true
177
+ }
178
+
179
+ async function save() {
180
+ if (!form.noticeTitle.trim()) {
181
+ ElMessage.warning('请填写公告标题')
182
+ return
183
+ }
184
+ if (isEmptyHtml(form.noticeContent)) {
185
+ ElMessage.warning('请填写公告内容')
186
+ return
187
+ }
188
+ saving.value = true
189
+ try {
190
+ const payload = {
191
+ id: form.id,
192
+ noticeTitle: form.noticeTitle.trim(),
193
+ noticeContent: form.noticeContent,
194
+ noticeType: form.noticeType,
195
+ status: form.status,
196
+ remark: form.remark.trim() || null,
197
+ }
198
+ if (form.id) await put('/notices', payload)
199
+ else await post('/notices', payload)
200
+ ElMessage.success('保存成功')
201
+ dialogVisible.value = false
202
+ load()
203
+ } finally {
204
+ saving.value = false
205
+ }
206
+ }
207
+
208
+ async function remove(row: NoticeItem) {
209
+ await ElMessageBox.confirm(`确认删除「${row.noticeTitle}」?`, '提示', { type: 'warning' })
210
+ await del('/notices', { ids: [row.id] })
211
+ ElMessage.success('已删除')
212
+ load()
213
+ }
214
+
215
+ onMounted(load)
216
+ </script>
217
+
218
+ <template>
219
+ <AppPage title="通知公告" description="发布与管理站内通知、公告">
220
+ <div class="nt-stats">
221
+ <div class="nt-stats__item">
222
+ <span class="nt-stats__num">{{ stats.total }}</span>
223
+ <span class="nt-stats__label">全部</span>
224
+ </div>
225
+ <div class="nt-stats__item nt-stats__item--info">
226
+ <span class="nt-stats__num">{{ stats.notice }}</span>
227
+ <span class="nt-stats__label">通知</span>
228
+ </div>
229
+ <div class="nt-stats__item nt-stats__item--ok">
230
+ <span class="nt-stats__num">{{ stats.announce }}</span>
231
+ <span class="nt-stats__label">公告</span>
232
+ </div>
233
+ <div class="nt-stats__item nt-stats__item--off">
234
+ <span class="nt-stats__num">{{ stats.closed }}</span>
235
+ <span class="nt-stats__label">已关闭</span>
236
+ </div>
237
+ </div>
238
+
239
+ <div class="toolbar">
240
+ <el-input
241
+ v-model="query.keyword"
242
+ clearable
243
+ placeholder="搜索标题"
244
+ style="width: 200px"
245
+ @keyup.enter="search"
246
+ />
247
+ <el-select v-model="query.noticeType" clearable placeholder="类型" style="width: 110px" @change="search">
248
+ <el-option label="通知" :value="1" />
249
+ <el-option label="公告" :value="2" />
250
+ </el-select>
251
+ <el-select v-model="query.status" clearable placeholder="状态" style="width: 110px" @change="search">
252
+ <el-option label="正常" :value="0" />
253
+ <el-option label="关闭" :value="1" />
254
+ </el-select>
255
+ <el-button type="primary" :icon="Search" @click="search">查询</el-button>
256
+ <el-button :icon="Refresh" @click="resetQuery">重置</el-button>
257
+ <div class="toolbar__spacer" />
258
+ <el-button type="success" :icon="Plus" v-permission="'system:notice:add'" @click="openCreate">
259
+ 发布公告
260
+ </el-button>
261
+ </div>
262
+
263
+ <div class="table-wrap" v-loading="loading">
264
+ <el-table :data="list" border stripe empty-text="暂无公告">
265
+ <el-table-column label="标题" min-width="220" show-overflow-tooltip>
266
+ <template #default="{ row }">
267
+ <div class="name-cell">
268
+ <span class="name-cell__icon">
269
+ <el-icon :size="14"><Notification /></el-icon>
270
+ </span>
271
+ <strong>{{ row.noticeTitle }}</strong>
272
+ </div>
273
+ </template>
274
+ </el-table-column>
275
+ <el-table-column label="类型" width="88" align="center">
276
+ <template #default="{ row }">
277
+ <el-tag size="small" :type="row.noticeType === 2 ? 'warning' : 'primary'">
278
+ {{ typeLabel(row.noticeType) }}
279
+ </el-tag>
280
+ </template>
281
+ </el-table-column>
282
+ <el-table-column label="状态" width="88" align="center">
283
+ <template #default="{ row }">
284
+ <el-tag size="small" :type="row.status === 0 ? 'success' : 'info'">
285
+ {{ row.status === 0 ? '正常' : '关闭' }}
286
+ </el-tag>
287
+ </template>
288
+ </el-table-column>
289
+ <el-table-column label="创建时间" width="170" class-name="is-datetime" show-overflow-tooltip>
290
+ <template #default="{ row }">
291
+ <span class="datetime-cell">{{ formatDateTime(row.createTime) }}</span>
292
+ </template>
293
+ </el-table-column>
294
+ <el-table-column label="操作" width="280" fixed="right" align="center">
295
+ <template #default="{ row }">
296
+ <div class="row-actions">
297
+ <el-button size="small" type="primary" :icon="View" @click="openPreview(row)">预览</el-button>
298
+ <el-button
299
+ size="small"
300
+ type="primary"
301
+ v-permission="'system:notice:edit'"
302
+ @click="openEdit(row)"
303
+ >
304
+ 编辑
305
+ </el-button>
306
+ <el-button
307
+ size="small"
308
+ type="danger"
309
+ v-permission="'system:notice:remove'"
310
+ @click="remove(row)"
311
+ >
312
+ 删除
313
+ </el-button>
314
+ </div>
315
+ </template>
316
+ </el-table-column>
317
+ </el-table>
318
+ </div>
319
+
320
+ <div class="pager">
321
+ <el-pagination
322
+ background
323
+ layout="total, sizes, prev, pager, next"
324
+ :total="total"
325
+ v-model:current-page="query.pageIndex"
326
+ v-model:page-size="query.pageSize"
327
+ :page-sizes="[10, 20, 50]"
328
+ @current-change="load"
329
+ @size-change="
330
+ () => {
331
+ query.pageIndex = 1
332
+ load()
333
+ }
334
+ "
335
+ />
336
+ </div>
337
+
338
+ <el-dialog
339
+ v-model="dialogVisible"
340
+ :title="form.id ? '编辑公告' : '发布公告'"
341
+ width="860px"
342
+ class="notice-dialog"
343
+ append-to-body
344
+ align-center
345
+ destroy-on-close
346
+ >
347
+ <el-form label-width="96px">
348
+ <el-form-item label="公告标题" required>
349
+ <el-input v-model="form.noticeTitle" maxlength="128" placeholder="标题" />
350
+ </el-form-item>
351
+ <el-row>
352
+ <el-col :span="12" class="col-l">
353
+ <el-form-item label="公告类型">
354
+ <el-radio-group v-model="form.noticeType">
355
+ <el-radio :value="1">通知</el-radio>
356
+ <el-radio :value="2">公告</el-radio>
357
+ </el-radio-group>
358
+ </el-form-item>
359
+ </el-col>
360
+ <el-col :span="12" class="col-r">
361
+ <el-form-item label="状态">
362
+ <el-radio-group v-model="form.status">
363
+ <el-radio :value="0">正常</el-radio>
364
+ <el-radio :value="1">关闭</el-radio>
365
+ </el-radio-group>
366
+ </el-form-item>
367
+ </el-col>
368
+ </el-row>
369
+ <el-form-item label="公告内容" required>
370
+ <RichTextEditor
371
+ v-if="dialogVisible"
372
+ v-model="form.noticeContent"
373
+ placeholder="支持标题、列表、链接、图片等富文本"
374
+ :height="360"
375
+ />
376
+ </el-form-item>
377
+ <el-form-item label="备注">
378
+ <el-input v-model="form.remark" type="textarea" :rows="2" maxlength="200" />
379
+ </el-form-item>
380
+ </el-form>
381
+ <template #footer>
382
+ <el-button @click="dialogVisible = false">取消</el-button>
383
+ <el-button type="primary" :loading="saving" @click="save">保存</el-button>
384
+ </template>
385
+ </el-dialog>
386
+
387
+ <el-dialog
388
+ v-model="previewVisible"
389
+ title="公告预览"
390
+ width="720px"
391
+ class="notice-preview-dialog"
392
+ append-to-body
393
+ align-center
394
+ destroy-on-close
395
+ >
396
+ <div class="preview-head">
397
+ <h3>{{ preview.title }}</h3>
398
+ <div class="preview-meta">
399
+ <el-tag size="small" :type="preview.noticeType === 2 ? 'warning' : 'primary'">
400
+ {{ typeLabel(preview.noticeType) }}
401
+ </el-tag>
402
+ <el-tag size="small" :type="preview.status === 0 ? 'success' : 'info'">
403
+ {{ preview.status === 0 ? '正常' : '关闭' }}
404
+ </el-tag>
405
+ <span class="muted">{{ formatDateTime(preview.createTime) }}</span>
406
+ </div>
407
+ </div>
408
+ <div class="preview-body rich-html" v-html="preview.content" />
409
+ </el-dialog>
410
+ </AppPage>
411
+ </template>
412
+
413
+ <style scoped lang="scss">
414
+ .nt-stats {
415
+ display: grid;
416
+ grid-template-columns: repeat(4, minmax(0, 1fr));
417
+ gap: 12px;
418
+ margin-bottom: 14px;
419
+ }
420
+ .nt-stats__item {
421
+ display: flex;
422
+ flex-direction: column;
423
+ gap: 4px;
424
+ padding: 14px 16px;
425
+ border-radius: 14px;
426
+ border: 1px solid var(--sa-border);
427
+ background: var(--sa-panel);
428
+ box-shadow: var(--sa-shadow);
429
+ }
430
+ .nt-stats__num {
431
+ font-size: 22px;
432
+ font-weight: 800;
433
+ letter-spacing: -0.03em;
434
+ color: var(--sa-ink);
435
+ font-variant-numeric: tabular-nums;
436
+ }
437
+ .nt-stats__label {
438
+ font-size: 12px;
439
+ color: var(--sa-ink-muted);
440
+ }
441
+ .nt-stats__item--info .nt-stats__num {
442
+ color: #0369a1;
443
+ }
444
+ .nt-stats__item--ok .nt-stats__num {
445
+ color: var(--sa-accent);
446
+ }
447
+ .nt-stats__item--off .nt-stats__num {
448
+ color: #64748b;
449
+ }
450
+
451
+ .toolbar {
452
+ display: flex;
453
+ flex-wrap: wrap;
454
+ gap: 10px;
455
+ align-items: center;
456
+ margin-bottom: 14px;
457
+ padding: 12px 14px;
458
+ background: var(--sa-toolbar-bg);
459
+ border: 1px solid var(--sa-border);
460
+ border-radius: var(--sa-radius-sm);
461
+ }
462
+ .toolbar__spacer {
463
+ flex: 1;
464
+ }
465
+ .table-wrap {
466
+ border: 1px solid var(--sa-border);
467
+ border-radius: 14px;
468
+ overflow: hidden;
469
+ background: var(--sa-panel);
470
+ box-shadow: var(--sa-shadow);
471
+ }
472
+ .name-cell {
473
+ display: inline-flex;
474
+ align-items: center;
475
+ gap: 8px;
476
+ }
477
+ .name-cell__icon {
478
+ width: 26px;
479
+ height: 26px;
480
+ border-radius: 8px;
481
+ display: grid;
482
+ place-items: center;
483
+ background: var(--sa-accent-soft);
484
+ color: var(--sa-accent);
485
+ }
486
+ .name-cell strong {
487
+ font-size: 13px;
488
+ font-weight: 650;
489
+ }
490
+ .pager {
491
+ display: flex;
492
+ justify-content: flex-end;
493
+ margin-top: 14px;
494
+ }
495
+ .col-l {
496
+ padding-right: 8px;
497
+ }
498
+ .col-r {
499
+ padding-left: 8px;
500
+ }
501
+ .preview-head h3 {
502
+ margin: 0 0 10px;
503
+ font-size: 18px;
504
+ font-weight: 700;
505
+ color: var(--sa-ink);
506
+ }
507
+ .preview-meta {
508
+ display: flex;
509
+ flex-wrap: wrap;
510
+ gap: 8px;
511
+ align-items: center;
512
+ margin-bottom: 16px;
513
+ }
514
+ .muted {
515
+ color: var(--sa-ink-muted);
516
+ font-size: 12px;
517
+ }
518
+ .preview-body {
519
+ min-height: 160px;
520
+ padding: 16px 18px;
521
+ border-radius: 12px;
522
+ border: 1px solid var(--sa-border);
523
+ background: var(--sa-toolbar-bg);
524
+ line-height: 1.7;
525
+ color: var(--sa-ink-secondary);
526
+ overflow-x: auto;
527
+ }
528
+ .rich-html :deep(img) {
529
+ max-width: 100%;
530
+ height: auto;
531
+ }
532
+ .rich-html :deep(p) {
533
+ margin: 0 0 0.75em;
534
+ }
535
+ .rich-html :deep(ul),
536
+ .rich-html :deep(ol) {
537
+ padding-left: 1.4em;
538
+ margin: 0 0 0.75em;
539
+ }
540
+ .rich-html :deep(h1),
541
+ .rich-html :deep(h2),
542
+ .rich-html :deep(h3) {
543
+ margin: 0.6em 0 0.4em;
544
+ color: var(--sa-ink);
545
+ }
546
+ @media (max-width: 900px) {
547
+ .nt-stats {
548
+ grid-template-columns: repeat(2, minmax(0, 1fr));
549
+ }
550
+ }
551
+ </style>
552
+
553
+ <style>
554
+ .notice-dialog.el-dialog {
555
+ width: min(860px, calc(100vw - 32px)) !important;
556
+ max-height: 92vh;
557
+ overflow: hidden;
558
+ display: flex;
559
+ flex-direction: column;
560
+ }
561
+ .notice-dialog .el-dialog__body {
562
+ overflow-x: hidden;
563
+ overflow-y: auto;
564
+ max-height: calc(92vh - 140px);
565
+ }
566
+ .notice-preview-dialog.el-dialog {
567
+ width: min(720px, calc(100vw - 32px)) !important;
568
+ max-height: 90vh;
569
+ overflow: hidden;
570
+ display: flex;
571
+ flex-direction: column;
572
+ }
573
+ .notice-preview-dialog .el-dialog__body {
574
+ overflow-x: hidden;
575
+ overflow-y: auto;
576
+ max-height: calc(90vh - 120px);
577
+ }
578
+ </style>