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,596 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, reactive, ref } from 'vue'
3
+ import { ElMessage, ElMessageBox } from 'element-plus'
4
+ import { Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
5
+ import { get, post, put, del } from '@/utils/request'
6
+ import AppPage from '@/components/AppPage.vue'
7
+ import { formatDateTime } from '@/utils/datetime'
8
+
9
+ interface OpenAppItem {
10
+ id: number
11
+ appId: string
12
+ appName: string
13
+ appSecretMasked: string
14
+ status: number
15
+ expireTime?: string | null
16
+ createTime?: string
17
+ remark?: string
18
+ }
19
+
20
+ interface OpenAppForm {
21
+ id?: number
22
+ appId: string
23
+ appName: string
24
+ appSecret: string
25
+ status: number
26
+ expireTime: string | null
27
+ remark: string
28
+ }
29
+
30
+ interface SecretReveal {
31
+ appId: string
32
+ appSecret: string
33
+ }
34
+
35
+ const loading = ref(false)
36
+ const saving = ref(false)
37
+ const total = ref(0)
38
+ const list = ref<OpenAppItem[]>([])
39
+ const dialogVisible = ref(false)
40
+ const secretVisible = ref(false)
41
+ const secretReveal = reactive<SecretReveal>({ appId: '', appSecret: '' })
42
+
43
+ const query = reactive({
44
+ pageIndex: 1,
45
+ pageSize: 10,
46
+ keyword: '',
47
+ status: undefined as number | undefined,
48
+ })
49
+
50
+ const form = reactive<OpenAppForm>({
51
+ id: undefined,
52
+ appId: '',
53
+ appName: '',
54
+ appSecret: '',
55
+ status: 0,
56
+ expireTime: null,
57
+ remark: '',
58
+ })
59
+
60
+ const stats = reactive({ total: 0, normal: 0, disabled: 0 })
61
+
62
+ async function loadStats(pageTotal?: number) {
63
+ const base = { pageIndex: 1, pageSize: 1, keyword: query.keyword || undefined }
64
+ const unfiltered = query.status === undefined || query.status === null
65
+ try {
66
+ const tasks: Promise<void>[] = []
67
+ if (unfiltered && pageTotal != null) {
68
+ stats.total = pageTotal
69
+ } else {
70
+ tasks.push(
71
+ get<{ total: number }>('/open-apps', base).then((all) => {
72
+ stats.total = all.total
73
+ }),
74
+ )
75
+ }
76
+ tasks.push(
77
+ get<{ total: number }>('/open-apps', { ...base, status: 0 }).then((r) => {
78
+ stats.normal = r.total
79
+ }),
80
+ get<{ total: number }>('/open-apps', { ...base, status: 1 }).then((r) => {
81
+ stats.disabled = r.total
82
+ }),
83
+ )
84
+ await Promise.all(tasks)
85
+ } catch {
86
+ stats.total = total.value
87
+ stats.normal = 0
88
+ stats.disabled = 0
89
+ }
90
+ }
91
+
92
+ async function load() {
93
+ loading.value = true
94
+ try {
95
+ const params: Record<string, unknown> = {
96
+ pageIndex: query.pageIndex,
97
+ pageSize: query.pageSize,
98
+ keyword: query.keyword || undefined,
99
+ }
100
+ if (query.status !== undefined && query.status !== null) params.status = query.status
101
+ const data = await get<{ total: number; items: OpenAppItem[] }>('/open-apps', params)
102
+ total.value = data.total
103
+ list.value = data.items || []
104
+ await loadStats(data.total)
105
+ } finally {
106
+ loading.value = false
107
+ }
108
+ }
109
+
110
+ function search() {
111
+ query.pageIndex = 1
112
+ load()
113
+ }
114
+
115
+ function resetQuery() {
116
+ query.keyword = ''
117
+ query.status = undefined
118
+ query.pageIndex = 1
119
+ load()
120
+ }
121
+
122
+ function resetForm() {
123
+ Object.assign(form, {
124
+ id: undefined,
125
+ appId: '',
126
+ appName: '',
127
+ appSecret: '',
128
+ status: 0,
129
+ expireTime: null,
130
+ remark: '',
131
+ })
132
+ }
133
+
134
+ function showSecretOnce(appId: string, appSecret: string) {
135
+ secretReveal.appId = appId
136
+ secretReveal.appSecret = appSecret
137
+ secretVisible.value = true
138
+ }
139
+
140
+ async function copySecret() {
141
+ try {
142
+ await navigator.clipboard.writeText(secretReveal.appSecret)
143
+ ElMessage.success('密钥已复制')
144
+ } catch {
145
+ ElMessage.warning('复制失败,请手动选中复制')
146
+ }
147
+ }
148
+
149
+ function openCreate() {
150
+ resetForm()
151
+ dialogVisible.value = true
152
+ }
153
+
154
+ async function openEdit(row: OpenAppItem) {
155
+ const detail = await get<{
156
+ id: number
157
+ appId: string
158
+ appName: string
159
+ appSecretMasked: string
160
+ status: number
161
+ expireTime?: string | null
162
+ remark?: string
163
+ }>(`/open-apps/${row.id}`)
164
+ Object.assign(form, {
165
+ id: detail.id,
166
+ appId: detail.appId || '',
167
+ appName: detail.appName || '',
168
+ appSecret: '',
169
+ status: detail.status ?? 0,
170
+ expireTime: detail.expireTime || null,
171
+ remark: detail.remark || '',
172
+ })
173
+ dialogVisible.value = true
174
+ }
175
+
176
+ async function save() {
177
+ if (!form.appId.trim()) {
178
+ ElMessage.warning('请填写 AppId')
179
+ return
180
+ }
181
+ if (!form.appName.trim()) {
182
+ ElMessage.warning('请填写应用名称')
183
+ return
184
+ }
185
+ if (!form.id && form.appSecret.trim() && form.appSecret.trim().length < 16) {
186
+ ElMessage.warning('AppSecret 长度至少 16,或留空由系统生成')
187
+ return
188
+ }
189
+ saving.value = true
190
+ try {
191
+ const payload = {
192
+ id: form.id,
193
+ appId: form.appId.trim(),
194
+ appName: form.appName.trim(),
195
+ appSecret: form.appSecret.trim() || null,
196
+ status: form.status,
197
+ expireTime: form.expireTime || null,
198
+ remark: form.remark.trim() || null,
199
+ }
200
+ if (form.id) {
201
+ await put('/open-apps', payload)
202
+ ElMessage.success('保存成功')
203
+ } else {
204
+ const created = await post<{ id: number; appId: string; appSecret: string }>('/open-apps', payload)
205
+ ElMessage.success('创建成功,请妥善保存密钥')
206
+ showSecretOnce(created.appId, created.appSecret)
207
+ }
208
+ dialogVisible.value = false
209
+ load()
210
+ } finally {
211
+ saving.value = false
212
+ }
213
+ }
214
+
215
+ async function resetSecret(row: OpenAppItem) {
216
+ await ElMessageBox.confirm(
217
+ `重置后旧密钥立即失效。确认重置「${row.appName}」的签名密钥?`,
218
+ '重置密钥',
219
+ { type: 'warning' },
220
+ )
221
+ const result = await put<{ id: number; appId: string; appSecret: string }>(
222
+ `/open-apps/${row.id}/reset-secret`,
223
+ )
224
+ ElMessage.success('密钥已重置,请妥善保存')
225
+ showSecretOnce(result.appId, result.appSecret)
226
+ load()
227
+ }
228
+
229
+ async function remove(row: OpenAppItem) {
230
+ await ElMessageBox.confirm(`确认删除开放应用「${row.appName}」?`, '提示', { type: 'warning' })
231
+ await del('/open-apps', { ids: [row.id] })
232
+ ElMessage.success('已删除')
233
+ load()
234
+ }
235
+
236
+ onMounted(load)
237
+ </script>
238
+
239
+ <template>
240
+ <AppPage title="开放应用" description="为第三方分配独立 AppId / AppSecret,用于请求 HMAC 签名">
241
+ <div class="oa-stats">
242
+ <div class="oa-stats__item">
243
+ <span class="oa-stats__num">{{ stats.total }}</span>
244
+ <span class="oa-stats__label">全部应用</span>
245
+ </div>
246
+ <div class="oa-stats__item oa-stats__item--ok">
247
+ <span class="oa-stats__num">{{ stats.normal }}</span>
248
+ <span class="oa-stats__label">正常</span>
249
+ </div>
250
+ <div class="oa-stats__item oa-stats__item--off">
251
+ <span class="oa-stats__num">{{ stats.disabled }}</span>
252
+ <span class="oa-stats__label">停用</span>
253
+ </div>
254
+ <div class="oa-stats__item oa-stats__item--hit">
255
+ <span class="oa-stats__num">{{ total }}</span>
256
+ <span class="oa-stats__label">当前筛选</span>
257
+ </div>
258
+ </div>
259
+
260
+ <div class="toolbar">
261
+ <el-input
262
+ v-model="query.keyword"
263
+ clearable
264
+ placeholder="搜索 AppId / 名称"
265
+ style="width: 220px"
266
+ @keyup.enter="search"
267
+ />
268
+ <el-select v-model="query.status" clearable placeholder="状态" style="width: 120px">
269
+ <el-option label="正常" :value="0" />
270
+ <el-option label="停用" :value="1" />
271
+ </el-select>
272
+ <el-button type="primary" :icon="Search" @click="search">查询</el-button>
273
+ <el-button :icon="Refresh" @click="resetQuery">重置</el-button>
274
+ <div class="toolbar__spacer" />
275
+ <el-button type="success" :icon="Plus" v-permission="'system:openApp:add'" @click="openCreate">
276
+ 新增应用
277
+ </el-button>
278
+ </div>
279
+
280
+ <div class="table-wrap" v-loading="loading">
281
+ <el-table :data="list" border stripe empty-text="暂无开放应用">
282
+ <el-table-column label="应用名称" min-width="160" show-overflow-tooltip>
283
+ <template #default="{ row }">
284
+ <div class="name-cell">
285
+ <span class="name-cell__icon">
286
+ <el-icon :size="14"><Key /></el-icon>
287
+ </span>
288
+ <strong>{{ row.appName }}</strong>
289
+ </div>
290
+ </template>
291
+ </el-table-column>
292
+ <el-table-column label="AppId" min-width="150" show-overflow-tooltip>
293
+ <template #default="{ row }">
294
+ <code class="mono">{{ row.appId }}</code>
295
+ </template>
296
+ </el-table-column>
297
+ <el-table-column label="密钥" min-width="140" show-overflow-tooltip>
298
+ <template #default="{ row }">
299
+ <code class="mono">{{ row.appSecretMasked }}</code>
300
+ </template>
301
+ </el-table-column>
302
+ <el-table-column label="状态" width="88" align="center">
303
+ <template #default="{ row }">
304
+ <el-tag size="small" :type="row.status === 0 ? 'success' : 'danger'">
305
+ {{ row.status === 0 ? '正常' : '停用' }}
306
+ </el-tag>
307
+ </template>
308
+ </el-table-column>
309
+ <el-table-column label="过期时间" width="170" align="center">
310
+ <template #default="{ row }">
311
+ <span v-if="row.expireTime" class="datetime-cell">{{ formatDateTime(row.expireTime) }}</span>
312
+ <span v-else class="muted">长期</span>
313
+ </template>
314
+ </el-table-column>
315
+ <el-table-column label="创建时间" width="170" align="center">
316
+ <template #default="{ row }">
317
+ <span class="datetime-cell">{{ formatDateTime(row.createTime) }}</span>
318
+ </template>
319
+ </el-table-column>
320
+ <el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip>
321
+ <template #default="{ row }">
322
+ <span v-if="row.remark">{{ row.remark }}</span>
323
+ <span v-else class="muted">—</span>
324
+ </template>
325
+ </el-table-column>
326
+ <el-table-column label="操作" width="260" fixed="right" align="center">
327
+ <template #default="{ row }">
328
+ <div class="row-actions">
329
+ <el-button
330
+ size="small"
331
+ type="primary"
332
+ v-permission="'system:openApp:edit'"
333
+ @click="openEdit(row)"
334
+ >
335
+ 编辑
336
+ </el-button>
337
+ <el-button
338
+ size="small"
339
+ type="warning"
340
+ v-permission="'system:openApp:edit'"
341
+ @click="resetSecret(row)"
342
+ >
343
+ 重置密钥
344
+ </el-button>
345
+ <el-button
346
+ size="small"
347
+ type="danger"
348
+ v-permission="'system:openApp:remove'"
349
+ @click="remove(row)"
350
+ >
351
+ 删除
352
+ </el-button>
353
+ </div>
354
+ </template>
355
+ </el-table-column>
356
+ </el-table>
357
+ </div>
358
+
359
+ <div class="pager">
360
+ <el-pagination
361
+ background
362
+ layout="total, sizes, prev, pager, next"
363
+ :total="total"
364
+ v-model:current-page="query.pageIndex"
365
+ v-model:page-size="query.pageSize"
366
+ :page-sizes="[10, 20, 50]"
367
+ @current-change="load"
368
+ @size-change="
369
+ () => {
370
+ query.pageIndex = 1
371
+ load()
372
+ }
373
+ "
374
+ />
375
+ </div>
376
+
377
+ <el-dialog
378
+ v-model="dialogVisible"
379
+ :title="form.id ? '编辑开放应用' : '新增开放应用'"
380
+ width="560px"
381
+ class="sa-dialog"
382
+ append-to-body
383
+ align-center
384
+ destroy-on-close
385
+ >
386
+ <el-form label-width="96px">
387
+ <el-form-item label="AppId" required>
388
+ <el-input
389
+ v-model="form.appId"
390
+ maxlength="64"
391
+ placeholder="对应请求头 X-App-Id"
392
+ :disabled="!!form.id"
393
+ />
394
+ </el-form-item>
395
+ <el-form-item label="应用名称" required>
396
+ <el-input v-model="form.appName" maxlength="128" placeholder="展示名称" />
397
+ </el-form-item>
398
+ <el-form-item v-if="!form.id" label="AppSecret">
399
+ <el-input
400
+ v-model="form.appSecret"
401
+ type="password"
402
+ show-password
403
+ maxlength="256"
404
+ placeholder="留空则系统自动生成(≥16)"
405
+ />
406
+ </el-form-item>
407
+ <el-form-item v-else label="密钥">
408
+ <span class="muted">列表仅显示脱敏值;如需更换请使用「重置密钥」</span>
409
+ </el-form-item>
410
+ <el-form-item label="状态">
411
+ <el-radio-group v-model="form.status">
412
+ <el-radio :value="0">正常</el-radio>
413
+ <el-radio :value="1">停用</el-radio>
414
+ </el-radio-group>
415
+ </el-form-item>
416
+ <el-form-item label="过期时间">
417
+ <el-date-picker
418
+ v-model="form.expireTime"
419
+ type="datetime"
420
+ value-format="YYYY-MM-DDTHH:mm:ss"
421
+ placeholder="空表示长期有效"
422
+ clearable
423
+ style="width: 100%"
424
+ />
425
+ </el-form-item>
426
+ <el-form-item label="备注">
427
+ <el-input v-model="form.remark" type="textarea" :rows="2" maxlength="200" />
428
+ </el-form-item>
429
+ </el-form>
430
+ <template #footer>
431
+ <el-button @click="dialogVisible = false">取消</el-button>
432
+ <el-button type="primary" :loading="saving" @click="save">保存</el-button>
433
+ </template>
434
+ </el-dialog>
435
+
436
+ <el-dialog
437
+ v-model="secretVisible"
438
+ title="请妥善保存密钥"
439
+ width="520px"
440
+ class="sa-dialog"
441
+ append-to-body
442
+ align-center
443
+ :close-on-click-modal="false"
444
+ >
445
+ <el-alert
446
+ type="warning"
447
+ :closable="false"
448
+ title="明文密钥仅展示一次,关闭后无法再次查看,请立即复制并交给对接方。"
449
+ style="margin-bottom: 14px"
450
+ />
451
+ <el-form label-width="88px">
452
+ <el-form-item label="AppId">
453
+ <code class="mono">{{ secretReveal.appId }}</code>
454
+ </el-form-item>
455
+ <el-form-item label="AppSecret">
456
+ <code class="mono secret-block">{{ secretReveal.appSecret }}</code>
457
+ </el-form-item>
458
+ </el-form>
459
+ <template #footer>
460
+ <el-button type="primary" @click="copySecret">复制密钥</el-button>
461
+ <el-button @click="secretVisible = false">已保存</el-button>
462
+ </template>
463
+ </el-dialog>
464
+ </AppPage>
465
+ </template>
466
+
467
+ <style scoped lang="scss">
468
+ .oa-stats {
469
+ display: grid;
470
+ grid-template-columns: repeat(4, minmax(0, 1fr));
471
+ gap: 12px;
472
+ margin-bottom: 14px;
473
+ }
474
+ .oa-stats__item {
475
+ display: flex;
476
+ flex-direction: column;
477
+ gap: 4px;
478
+ padding: 14px 16px;
479
+ border-radius: 14px;
480
+ border: 1px solid var(--sa-border);
481
+ background: var(--sa-panel);
482
+ box-shadow: var(--sa-shadow);
483
+ }
484
+ .oa-stats__num {
485
+ font-size: 22px;
486
+ font-weight: 800;
487
+ letter-spacing: -0.03em;
488
+ color: var(--sa-ink);
489
+ font-variant-numeric: tabular-nums;
490
+ }
491
+ .oa-stats__label {
492
+ font-size: 12px;
493
+ color: var(--sa-ink-muted);
494
+ }
495
+ .oa-stats__item--ok .oa-stats__num {
496
+ color: #15803d;
497
+ }
498
+ .oa-stats__item--off .oa-stats__num {
499
+ color: #b91c1c;
500
+ }
501
+ .oa-stats__item--hit .oa-stats__num {
502
+ color: #0369a1;
503
+ }
504
+
505
+ .toolbar {
506
+ display: flex;
507
+ flex-wrap: wrap;
508
+ gap: 10px;
509
+ align-items: center;
510
+ margin-bottom: 14px;
511
+ padding: 12px 14px;
512
+ background: var(--sa-toolbar-bg);
513
+ border: 1px solid var(--sa-border);
514
+ border-radius: var(--sa-radius-sm);
515
+ }
516
+ .toolbar__spacer {
517
+ flex: 1;
518
+ }
519
+ .table-wrap {
520
+ border: 1px solid var(--sa-border);
521
+ border-radius: 14px;
522
+ overflow: hidden;
523
+ background: var(--sa-panel);
524
+ box-shadow: var(--sa-shadow);
525
+ }
526
+ .name-cell {
527
+ display: inline-flex;
528
+ align-items: center;
529
+ gap: 8px;
530
+ }
531
+ .name-cell__icon {
532
+ width: 26px;
533
+ height: 26px;
534
+ border-radius: 8px;
535
+ display: grid;
536
+ place-items: center;
537
+ background: var(--sa-accent-soft);
538
+ color: var(--sa-accent);
539
+ }
540
+ .name-cell strong {
541
+ font-size: 13px;
542
+ font-weight: 650;
543
+ }
544
+ .mono {
545
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
546
+ font-size: 12px;
547
+ color: var(--sa-ink-secondary);
548
+ background: var(--sa-toolbar-bg);
549
+ padding: 1px 6px;
550
+ border-radius: 6px;
551
+ word-break: break-all;
552
+ }
553
+ .secret-block {
554
+ display: block;
555
+ padding: 8px 10px;
556
+ line-height: 1.5;
557
+ }
558
+ .muted {
559
+ color: var(--sa-ink-muted);
560
+ }
561
+ .datetime-cell {
562
+ white-space: nowrap;
563
+ font-variant-numeric: tabular-nums;
564
+ }
565
+ .row-actions {
566
+ display: inline-flex;
567
+ flex-wrap: wrap;
568
+ gap: 6px;
569
+ justify-content: center;
570
+ }
571
+ .pager {
572
+ display: flex;
573
+ justify-content: flex-end;
574
+ margin-top: 14px;
575
+ }
576
+ @media (max-width: 900px) {
577
+ .oa-stats {
578
+ grid-template-columns: repeat(2, minmax(0, 1fr));
579
+ }
580
+ }
581
+ </style>
582
+
583
+ <style>
584
+ .sa-dialog.el-dialog {
585
+ width: min(560px, calc(100vw - 32px)) !important;
586
+ max-height: 88vh;
587
+ overflow: hidden;
588
+ display: flex;
589
+ flex-direction: column;
590
+ }
591
+ .sa-dialog .el-dialog__body {
592
+ overflow-x: hidden;
593
+ overflow-y: auto;
594
+ max-height: calc(88vh - 140px);
595
+ }
596
+ </style>