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,1031 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
3
+ import { ElMessage, ElMessageBox, type UploadRequestOptions } from 'element-plus'
4
+ import {
5
+ Refresh,
6
+ Search,
7
+ Upload,
8
+ Download,
9
+ View,
10
+ ZoomIn,
11
+ ZoomOut,
12
+ RefreshRight,
13
+ Picture,
14
+ Document,
15
+ Notebook,
16
+ VideoCamera,
17
+ Headset,
18
+ Box,
19
+ Files,
20
+ Reading,
21
+ Grid,
22
+ Postcard,
23
+ } from '@element-plus/icons-vue'
24
+ import http, { get, del } from '@/utils/request'
25
+ import AppPage from '@/components/AppPage.vue'
26
+ import { formatDateTime } from '@/utils/datetime'
27
+ import { marked } from 'marked'
28
+
29
+ interface FileItem {
30
+ id: number
31
+ fileName: string
32
+ originalName: string
33
+ contentType?: string
34
+ extension: string
35
+ size: number
36
+ storagePath: string
37
+ provider: string
38
+ createTime: string
39
+ }
40
+
41
+ type PreviewKind = 'image' | 'pdf' | 'video' | 'audio' | 'text' | 'markdown' | 'unsupported'
42
+
43
+ const loading = ref(false)
44
+ const uploading = ref(false)
45
+ const previewLoading = ref(false)
46
+ const total = ref(0)
47
+ const list = ref<FileItem[]>([])
48
+ const previewVisible = ref(false)
49
+
50
+ const query = reactive({ pageIndex: 1, pageSize: 10, keyword: '' })
51
+
52
+ const preview = reactive({
53
+ name: '',
54
+ kind: 'unsupported' as PreviewKind,
55
+ url: '' as string,
56
+ text: '',
57
+ size: 0,
58
+ contentType: '',
59
+ row: null as FileItem | null,
60
+ })
61
+
62
+ /** 预览缩放:1 = 100% */
63
+ const zoom = ref(1)
64
+ const ZOOM_MIN = 0.25
65
+ const ZOOM_MAX = 4
66
+ const ZOOM_STEP = 0.25
67
+
68
+ /** 图片拖拽平移 */
69
+ const pan = reactive({ x: 0, y: 0 })
70
+ const dragging = ref(false)
71
+ const dragStart = reactive({ x: 0, y: 0, panX: 0, panY: 0 })
72
+
73
+ const pageSizeSum = computed(() => list.value.reduce((s, f) => s + (f.size || 0), 0))
74
+
75
+ const canZoom = computed(() =>
76
+ ['image', 'pdf'].includes(preview.kind) && !previewLoading.value,
77
+ )
78
+
79
+ const zoomPercent = computed(() => `${Math.round(zoom.value * 100)}%`)
80
+
81
+ const zoomTransform = computed(() => `translate(${pan.x}px, ${pan.y}px) scale(${zoom.value})`)
82
+
83
+ const TEXT_MAX_BYTES = 2 * 1024 * 1024
84
+
85
+ function clampZoom(v: number) {
86
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(v * 100) / 100))
87
+ }
88
+
89
+ function resetZoom() {
90
+ zoom.value = 1
91
+ pan.x = 0
92
+ pan.y = 0
93
+ }
94
+
95
+ function zoomIn() {
96
+ zoom.value = clampZoom(zoom.value + ZOOM_STEP)
97
+ }
98
+
99
+ function zoomOut() {
100
+ zoom.value = clampZoom(zoom.value - ZOOM_STEP)
101
+ }
102
+
103
+ function onPreviewWheel(e: WheelEvent) {
104
+ if (!canZoom.value) return
105
+ e.preventDefault()
106
+ if (e.deltaY < 0) zoomIn()
107
+ else zoomOut()
108
+ }
109
+
110
+ function onPanStart(e: MouseEvent) {
111
+ if (preview.kind !== 'image' || e.button !== 0) return
112
+ e.preventDefault()
113
+ dragging.value = true
114
+ dragStart.x = e.clientX
115
+ dragStart.y = e.clientY
116
+ dragStart.panX = pan.x
117
+ dragStart.panY = pan.y
118
+ window.addEventListener('mousemove', onPanMove)
119
+ window.addEventListener('mouseup', onPanEnd)
120
+ }
121
+
122
+ function onPanMove(e: MouseEvent) {
123
+ if (!dragging.value) return
124
+ pan.x = dragStart.panX + (e.clientX - dragStart.x)
125
+ pan.y = dragStart.panY + (e.clientY - dragStart.y)
126
+ }
127
+
128
+ function onPanEnd() {
129
+ if (!dragging.value) return
130
+ dragging.value = false
131
+ window.removeEventListener('mousemove', onPanMove)
132
+ window.removeEventListener('mouseup', onPanEnd)
133
+ }
134
+
135
+ function normalizeExt(ext: string) {
136
+ return (ext || '').replace(/^\./, '').toLowerCase()
137
+ }
138
+
139
+ function formatSize(bytes: number) {
140
+ if (!bytes || bytes < 0) return '0 B'
141
+ const units = ['B', 'KB', 'MB', 'GB']
142
+ let n = bytes
143
+ let i = 0
144
+ while (n >= 1024 && i < units.length - 1) {
145
+ n /= 1024
146
+ i++
147
+ }
148
+ return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`
149
+ }
150
+
151
+ function resolveFileMeta(ext: string) {
152
+ const e = normalizeExt(ext)
153
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(e)) {
154
+ return { tone: 'img', icon: Picture }
155
+ }
156
+ if (e === 'pdf') return { tone: 'pdf', icon: Document }
157
+ if (['doc', 'docx', 'rtf', 'odt'].includes(e)) return { tone: 'doc', icon: Notebook }
158
+ if (['xls', 'xlsx', 'csv', 'ods'].includes(e)) return { tone: 'xls', icon: Grid }
159
+ if (['ppt', 'pptx', 'odp'].includes(e)) return { tone: 'ppt', icon: Postcard }
160
+ if (['zip', 'rar', '7z', 'gz', 'tar', 'bz2'].includes(e)) return { tone: 'zip', icon: Box }
161
+ if (['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(e)) return { tone: 'video', icon: VideoCamera }
162
+ if (['mp3', 'wav', 'aac', 'm4a', 'flac'].includes(e)) return { tone: 'audio', icon: Headset }
163
+ if (['md', 'markdown'].includes(e)) return { tone: 'md', icon: Reading }
164
+ if (
165
+ ['txt', 'log', 'json', 'xml', 'html', 'htm', 'css', 'js', 'ts', 'vue', 'yml', 'yaml', 'ini', 'conf', 'sql'].includes(
166
+ e,
167
+ )
168
+ ) {
169
+ return { tone: 'text', icon: Document }
170
+ }
171
+ return { tone: 'file', icon: Files }
172
+ }
173
+
174
+ function guessMime(row: FileItem) {
175
+ const ct = (row.contentType || '').trim()
176
+ if (ct && ct !== 'application/octet-stream') return ct
177
+ const e = normalizeExt(row.extension)
178
+ const map: Record<string, string> = {
179
+ png: 'image/png',
180
+ jpg: 'image/jpeg',
181
+ jpeg: 'image/jpeg',
182
+ gif: 'image/gif',
183
+ webp: 'image/webp',
184
+ svg: 'image/svg+xml',
185
+ bmp: 'image/bmp',
186
+ ico: 'image/x-icon',
187
+ pdf: 'application/pdf',
188
+ mp4: 'video/mp4',
189
+ webm: 'video/webm',
190
+ ogg: 'video/ogg',
191
+ mp3: 'audio/mpeg',
192
+ wav: 'audio/wav',
193
+ aac: 'audio/aac',
194
+ m4a: 'audio/mp4',
195
+ txt: 'text/plain',
196
+ md: 'text/markdown',
197
+ json: 'application/json',
198
+ xml: 'application/xml',
199
+ csv: 'text/csv',
200
+ log: 'text/plain',
201
+ html: 'text/html',
202
+ htm: 'text/html',
203
+ css: 'text/css',
204
+ js: 'text/javascript',
205
+ ts: 'text/plain',
206
+ vue: 'text/plain',
207
+ yml: 'text/yaml',
208
+ yaml: 'text/yaml',
209
+ sql: 'text/plain',
210
+ }
211
+ return map[e] || 'application/octet-stream'
212
+ }
213
+
214
+ function resolvePreviewKind(row: FileItem): PreviewKind {
215
+ const e = normalizeExt(row.extension)
216
+ const ct = (row.contentType || '').toLowerCase()
217
+ if (ct.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(e)) {
218
+ return 'image'
219
+ }
220
+ if (ct === 'application/pdf' || e === 'pdf') return 'pdf'
221
+ if (ct.startsWith('video/') || ['mp4', 'webm', 'ogg'].includes(e)) return 'video'
222
+ if (ct.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'aac', 'm4a'].includes(e)) return 'audio'
223
+ if (ct === 'text/markdown' || ['md', 'markdown'].includes(e)) return 'markdown'
224
+ if (
225
+ ct.startsWith('text/') ||
226
+ ct === 'application/json' ||
227
+ ct === 'application/xml' ||
228
+ [
229
+ 'txt',
230
+ 'json',
231
+ 'xml',
232
+ 'csv',
233
+ 'log',
234
+ 'html',
235
+ 'htm',
236
+ 'css',
237
+ 'js',
238
+ 'ts',
239
+ 'vue',
240
+ 'yml',
241
+ 'yaml',
242
+ 'ini',
243
+ 'conf',
244
+ 'sql',
245
+ ].includes(e)
246
+ ) {
247
+ return 'text'
248
+ }
249
+ return 'unsupported'
250
+ }
251
+
252
+ /** 去掉 script / 行内事件,降低预览 HTML 风险 */
253
+ function sanitizeHtml(html: string) {
254
+ return (html || '')
255
+ .replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
256
+ .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
257
+ }
258
+
259
+ async function renderMarkdown(source: string) {
260
+ const html = await marked.parse(source, { gfm: true, breaks: true })
261
+ return sanitizeHtml(typeof html === 'string' ? html : String(html))
262
+ }
263
+
264
+ function canPreview(row: FileItem) {
265
+ return resolvePreviewKind(row) !== 'unsupported'
266
+ }
267
+
268
+ function revokePreviewUrl() {
269
+ if (preview.url) {
270
+ URL.revokeObjectURL(preview.url)
271
+ preview.url = ''
272
+ }
273
+ }
274
+
275
+ function resetPreview() {
276
+ revokePreviewUrl()
277
+ preview.name = ''
278
+ preview.kind = 'unsupported'
279
+ preview.text = ''
280
+ preview.size = 0
281
+ preview.contentType = ''
282
+ preview.row = null
283
+ onPanEnd()
284
+ resetZoom()
285
+ }
286
+
287
+ async function fetchBlob(row: FileItem) {
288
+ const res = await http.get(`/files/${row.id}/download`, { responseType: 'blob' })
289
+ const raw = res.data as Blob
290
+ const mime = guessMime(row)
291
+ return raw.type && raw.type !== 'application/octet-stream' ? raw : new Blob([raw], { type: mime })
292
+ }
293
+
294
+ async function openPreview(row: FileItem) {
295
+ const kind = resolvePreviewKind(row)
296
+ resetPreview()
297
+ preview.name = row.originalName || row.fileName
298
+ preview.kind = kind
299
+ preview.size = row.size
300
+ preview.contentType = row.contentType || guessMime(row)
301
+ preview.row = row
302
+ previewVisible.value = true
303
+
304
+ if (kind === 'unsupported') return
305
+
306
+ if ((kind === 'text' || kind === 'markdown') && row.size > TEXT_MAX_BYTES) {
307
+ preview.kind = 'unsupported'
308
+ preview.text = `文本过大(${formatSize(row.size)}),请下载后查看。`
309
+ return
310
+ }
311
+
312
+ previewLoading.value = true
313
+ try {
314
+ const blob = await fetchBlob(row)
315
+ if (kind === 'markdown') {
316
+ const source = await blob.text()
317
+ preview.text = await renderMarkdown(source)
318
+ } else if (kind === 'text') {
319
+ preview.text = await blob.text()
320
+ } else {
321
+ preview.url = URL.createObjectURL(blob)
322
+ }
323
+ } catch {
324
+ ElMessage.error('预览加载失败')
325
+ previewVisible.value = false
326
+ resetPreview()
327
+ } finally {
328
+ previewLoading.value = false
329
+ }
330
+ }
331
+
332
+ function onPreviewClosed() {
333
+ resetPreview()
334
+ }
335
+
336
+ async function load() {
337
+ loading.value = true
338
+ try {
339
+ const data = await get<{ total: number; items: FileItem[] }>('/files', {
340
+ pageIndex: query.pageIndex,
341
+ pageSize: query.pageSize,
342
+ keyword: query.keyword || undefined,
343
+ })
344
+ total.value = data.total
345
+ list.value = data.items || []
346
+ } finally {
347
+ loading.value = false
348
+ }
349
+ }
350
+
351
+ function search() {
352
+ query.pageIndex = 1
353
+ load()
354
+ }
355
+
356
+ function resetQuery() {
357
+ query.keyword = ''
358
+ query.pageIndex = 1
359
+ load()
360
+ }
361
+
362
+ async function customUpload(options: UploadRequestOptions) {
363
+ uploading.value = true
364
+ try {
365
+ const fd = new FormData()
366
+ fd.append('file', options.file)
367
+ await http.post('/files/upload', fd, {
368
+ headers: { 'Content-Type': 'multipart/form-data' },
369
+ })
370
+ ElMessage.success('上传成功')
371
+ options.onSuccess?.({} as never)
372
+ load()
373
+ } catch (e) {
374
+ options.onError?.(e as never)
375
+ } finally {
376
+ uploading.value = false
377
+ }
378
+ }
379
+
380
+ async function download(row: FileItem) {
381
+ try {
382
+ const blob = await fetchBlob(row)
383
+ const url = URL.createObjectURL(blob)
384
+ const a = document.createElement('a')
385
+ a.href = url
386
+ a.download = row.originalName || row.fileName || 'download'
387
+ document.body.appendChild(a)
388
+ a.click()
389
+ a.remove()
390
+ URL.revokeObjectURL(url)
391
+ } catch {
392
+ ElMessage.error('下载失败')
393
+ }
394
+ }
395
+
396
+ async function remove(row: FileItem) {
397
+ await ElMessageBox.confirm(`确认删除文件「${row.originalName}」?`, '提示', { type: 'warning' })
398
+ await del('/files', { ids: [row.id] })
399
+ ElMessage.success('已删除')
400
+ load()
401
+ }
402
+
403
+ onMounted(load)
404
+ onBeforeUnmount(() => {
405
+ onPanEnd()
406
+ revokePreviewUrl()
407
+ })
408
+ </script>
409
+
410
+ <template>
411
+ <AppPage title="文件管理" description="上传、下载、预览与文件元数据维护">
412
+ <div class="file-stats">
413
+ <div class="file-stats__item">
414
+ <span class="file-stats__num">{{ total }}</span>
415
+ <span class="file-stats__label">全部文件</span>
416
+ </div>
417
+ <div class="file-stats__item file-stats__item--hit">
418
+ <span class="file-stats__num">{{ list.length }}</span>
419
+ <span class="file-stats__label">本页条数</span>
420
+ </div>
421
+ <div class="file-stats__item file-stats__item--ok">
422
+ <span class="file-stats__num">{{ formatSize(pageSizeSum) }}</span>
423
+ <span class="file-stats__label">本页合计大小</span>
424
+ </div>
425
+ <div class="file-stats__item">
426
+ <span class="file-stats__num">{{ uploading ? '…' : '就绪' }}</span>
427
+ <span class="file-stats__label">上传状态</span>
428
+ </div>
429
+ </div>
430
+
431
+ <div class="toolbar">
432
+ <el-input
433
+ v-model="query.keyword"
434
+ clearable
435
+ placeholder="搜索文件名"
436
+ style="width: 220px"
437
+ @keyup.enter="search"
438
+ />
439
+ <el-button type="primary" :icon="Search" @click="search">查询</el-button>
440
+ <el-button :icon="Refresh" @click="resetQuery">重置</el-button>
441
+ <div class="toolbar__spacer" />
442
+ <el-upload
443
+ :show-file-list="false"
444
+ :http-request="customUpload"
445
+ v-permission="'system:file:upload'"
446
+ >
447
+ <el-button type="success" :icon="Upload" :loading="uploading">上传文件</el-button>
448
+ </el-upload>
449
+ </div>
450
+
451
+ <div class="table-wrap" v-loading="loading">
452
+ <el-table :data="list" border stripe empty-text="暂无文件">
453
+ <el-table-column label="文件名" min-width="220" show-overflow-tooltip>
454
+ <template #default="{ row }">
455
+ <div class="name-cell" :class="{ 'is-previewable': canPreview(row) }" @click="canPreview(row) && openPreview(row)">
456
+ <span class="name-cell__icon" :data-tone="resolveFileMeta(row.extension).tone">
457
+ <el-icon :size="14">
458
+ <component :is="resolveFileMeta(row.extension).icon" />
459
+ </el-icon>
460
+ </span>
461
+ <strong>{{ row.originalName }}</strong>
462
+ </div>
463
+ </template>
464
+ </el-table-column>
465
+ <el-table-column label="扩展名" width="90" align="center">
466
+ <template #default="{ row }">
467
+ <code class="mono">{{ row.extension || '—' }}</code>
468
+ </template>
469
+ </el-table-column>
470
+ <el-table-column label="大小" width="100" align="right">
471
+ <template #default="{ row }">
472
+ {{ formatSize(row.size) }}
473
+ </template>
474
+ </el-table-column>
475
+ <el-table-column prop="contentType" label="类型" min-width="140" show-overflow-tooltip>
476
+ <template #default="{ row }">
477
+ <span v-if="row.contentType" class="muted">{{ row.contentType }}</span>
478
+ <span v-else class="muted">—</span>
479
+ </template>
480
+ </el-table-column>
481
+ <el-table-column label="存储" width="88" align="center">
482
+ <template #default="{ row }">
483
+ <el-tag size="small" type="info">{{ row.provider || 'local' }}</el-tag>
484
+ </template>
485
+ </el-table-column>
486
+ <el-table-column label="上传时间" width="170" class-name="is-datetime" show-overflow-tooltip>
487
+ <template #default="{ row }">
488
+ <span class="datetime-cell">{{ formatDateTime(row.createTime) }}</span>
489
+ </template>
490
+ </el-table-column>
491
+ <el-table-column label="操作" width="280" fixed="right" align="center">
492
+ <template #default="{ row }">
493
+ <div class="row-actions">
494
+ <el-button
495
+ size="small"
496
+ type="primary"
497
+ :icon="View"
498
+ :disabled="!canPreview(row)"
499
+ v-permission="'system:file:download'"
500
+ @click="openPreview(row)"
501
+ >
502
+ 预览
503
+ </el-button>
504
+ <el-button
505
+ size="small"
506
+ type="primary"
507
+ :icon="Download"
508
+ v-permission="'system:file:download'"
509
+ @click="download(row)"
510
+ >
511
+ 下载
512
+ </el-button>
513
+ <el-button
514
+ size="small"
515
+ type="danger"
516
+ v-permission="'system:file:remove'"
517
+ @click="remove(row)"
518
+ >
519
+ 删除
520
+ </el-button>
521
+ </div>
522
+ </template>
523
+ </el-table-column>
524
+ </el-table>
525
+ </div>
526
+
527
+ <div class="pager">
528
+ <el-pagination
529
+ background
530
+ layout="total, sizes, prev, pager, next"
531
+ :total="total"
532
+ v-model:current-page="query.pageIndex"
533
+ v-model:page-size="query.pageSize"
534
+ :page-sizes="[10, 20, 50]"
535
+ @current-change="load"
536
+ @size-change="
537
+ () => {
538
+ query.pageIndex = 1
539
+ load()
540
+ }
541
+ "
542
+ />
543
+ </div>
544
+
545
+ <el-dialog
546
+ v-model="previewVisible"
547
+ :title="preview.name || '文件预览'"
548
+ width="60%"
549
+ class="file-preview-dialog"
550
+ append-to-body
551
+ align-center
552
+ destroy-on-close
553
+ @closed="onPreviewClosed"
554
+ >
555
+ <div class="preview-meta">
556
+ <el-tag size="small" type="info">{{ preview.contentType || 'unknown' }}</el-tag>
557
+ <span class="muted">{{ formatSize(preview.size) }}</span>
558
+ <div class="preview-zoom" v-if="canZoom">
559
+ <el-button-group>
560
+ <el-button :icon="ZoomOut" :disabled="zoom <= ZOOM_MIN" @click="zoomOut" />
561
+ <el-button class="preview-zoom__pct" @click="resetZoom">{{ zoomPercent }}</el-button>
562
+ <el-button :icon="ZoomIn" :disabled="zoom >= ZOOM_MAX" @click="zoomIn" />
563
+ <el-button :icon="RefreshRight" title="重置" @click="resetZoom" />
564
+ </el-button-group>
565
+ <span class="muted preview-zoom__hint">滚轮缩放{{ preview.kind === 'image' ? ',按住左键拖动' : '' }}</span>
566
+ </div>
567
+ </div>
568
+
569
+ <div
570
+ class="preview-stage"
571
+ :class="{
572
+ 'is-zoomable': canZoom,
573
+ 'is-panning': dragging,
574
+ 'is-image': preview.kind === 'image',
575
+ }"
576
+ v-loading="previewLoading"
577
+ @wheel="onPreviewWheel"
578
+ @mousedown="onPanStart"
579
+ >
580
+ <template v-if="!previewLoading">
581
+ <div
582
+ v-if="preview.kind === 'image' && preview.url"
583
+ class="preview-transform"
584
+ :style="{ transform: zoomTransform }"
585
+ >
586
+ <img class="preview-image" :src="preview.url" :alt="preview.name" draggable="false" />
587
+ </div>
588
+
589
+ <div
590
+ v-else-if="preview.kind === 'pdf' && preview.url"
591
+ class="preview-transform preview-transform--frame"
592
+ :style="{ transform: `scale(${zoom})`, transformOrigin: 'top center' }"
593
+ >
594
+ <iframe class="preview-frame" :src="preview.url" title="PDF 预览" />
595
+ </div>
596
+
597
+ <video
598
+ v-else-if="preview.kind === 'video' && preview.url"
599
+ class="preview-media"
600
+ :src="preview.url"
601
+ controls
602
+ />
603
+
604
+ <audio
605
+ v-else-if="preview.kind === 'audio' && preview.url"
606
+ class="preview-audio"
607
+ :src="preview.url"
608
+ controls
609
+ />
610
+
611
+ <div
612
+ v-else-if="preview.kind === 'markdown'"
613
+ class="preview-markdown"
614
+ v-html="preview.text"
615
+ />
616
+
617
+ <pre v-else-if="preview.kind === 'text'" class="preview-text">{{ preview.text }}</pre>
618
+
619
+ <div v-else class="preview-empty">
620
+ <p>{{ preview.text || '该文件类型暂不支持在线预览。' }}</p>
621
+ <p class="muted">可下载后使用本地应用打开(如 Office、压缩包等)。</p>
622
+ <el-button
623
+ v-if="preview.row"
624
+ type="primary"
625
+ :icon="Download"
626
+ v-permission="'system:file:download'"
627
+ @click="download(preview.row)"
628
+ >
629
+ 下载文件
630
+ </el-button>
631
+ </div>
632
+ </template>
633
+ </div>
634
+
635
+ <template #footer>
636
+ <el-button @click="previewVisible = false">关闭</el-button>
637
+ <el-button
638
+ v-if="preview.row"
639
+ type="primary"
640
+ :icon="Download"
641
+ v-permission="'system:file:download'"
642
+ @click="download(preview.row)"
643
+ >
644
+ 下载
645
+ </el-button>
646
+ </template>
647
+ </el-dialog>
648
+ </AppPage>
649
+ </template>
650
+
651
+ <style scoped lang="scss">
652
+ .file-stats {
653
+ display: grid;
654
+ grid-template-columns: repeat(4, minmax(0, 1fr));
655
+ gap: 12px;
656
+ margin-bottom: 14px;
657
+ }
658
+ .file-stats__item {
659
+ display: flex;
660
+ flex-direction: column;
661
+ gap: 4px;
662
+ padding: 14px 16px;
663
+ border-radius: 14px;
664
+ border: 1px solid var(--sa-border);
665
+ background: var(--sa-panel);
666
+ box-shadow: var(--sa-shadow);
667
+ }
668
+ .file-stats__num {
669
+ font-size: 22px;
670
+ font-weight: 800;
671
+ letter-spacing: -0.03em;
672
+ color: var(--sa-ink);
673
+ font-variant-numeric: tabular-nums;
674
+ }
675
+ .file-stats__label {
676
+ font-size: 12px;
677
+ color: var(--sa-ink-muted);
678
+ }
679
+ .file-stats__item--ok .file-stats__num {
680
+ color: var(--sa-accent);
681
+ font-size: 18px;
682
+ }
683
+ .file-stats__item--hit .file-stats__num {
684
+ color: #0369a1;
685
+ }
686
+
687
+ .toolbar {
688
+ display: flex;
689
+ flex-wrap: wrap;
690
+ gap: 10px;
691
+ align-items: center;
692
+ margin-bottom: 14px;
693
+ padding: 12px 14px;
694
+ background: var(--sa-toolbar-bg);
695
+ border: 1px solid var(--sa-border);
696
+ border-radius: var(--sa-radius-sm);
697
+ }
698
+ .toolbar__spacer {
699
+ flex: 1;
700
+ }
701
+ .table-wrap {
702
+ border: 1px solid var(--sa-border);
703
+ border-radius: 14px;
704
+ overflow: hidden;
705
+ background: var(--sa-panel);
706
+ box-shadow: var(--sa-shadow);
707
+ }
708
+ .name-cell {
709
+ display: inline-flex;
710
+ align-items: center;
711
+ gap: 8px;
712
+ }
713
+ .name-cell.is-previewable {
714
+ cursor: pointer;
715
+ }
716
+ .name-cell.is-previewable:hover strong {
717
+ color: var(--sa-accent);
718
+ }
719
+ .name-cell__icon {
720
+ width: 26px;
721
+ height: 26px;
722
+ border-radius: 8px;
723
+ display: grid;
724
+ place-items: center;
725
+ background: var(--sa-accent-soft);
726
+ color: var(--sa-accent);
727
+ }
728
+ .name-cell__icon[data-tone='img'] {
729
+ background: #ecfdf5;
730
+ color: #059669;
731
+ }
732
+ .name-cell__icon[data-tone='pdf'] {
733
+ background: #fef2f2;
734
+ color: #dc2626;
735
+ }
736
+ .name-cell__icon[data-tone='doc'] {
737
+ background: #eff6ff;
738
+ color: #2563eb;
739
+ }
740
+ .name-cell__icon[data-tone='xls'] {
741
+ background: #ecfdf5;
742
+ color: #047857;
743
+ }
744
+ .name-cell__icon[data-tone='ppt'] {
745
+ background: #fff7ed;
746
+ color: #c2410c;
747
+ }
748
+ .name-cell__icon[data-tone='zip'] {
749
+ background: #fef3c7;
750
+ color: #b45309;
751
+ }
752
+ .name-cell__icon[data-tone='video'] {
753
+ background: #f5f3ff;
754
+ color: #7c3aed;
755
+ }
756
+ .name-cell__icon[data-tone='audio'] {
757
+ background: #fdf2f8;
758
+ color: #db2777;
759
+ }
760
+ .name-cell__icon[data-tone='md'] {
761
+ background: #eef2ff;
762
+ color: #4f46e5;
763
+ }
764
+ .name-cell__icon[data-tone='text'] {
765
+ background: #f1f5f9;
766
+ color: #475569;
767
+ }
768
+ .name-cell__icon[data-tone='file'] {
769
+ background: #f8fafc;
770
+ color: #64748b;
771
+ }
772
+ .name-cell strong {
773
+ font-size: 13px;
774
+ font-weight: 650;
775
+ }
776
+ .mono {
777
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
778
+ font-size: 12px;
779
+ color: var(--sa-ink-secondary);
780
+ background: var(--sa-toolbar-bg);
781
+ padding: 1px 6px;
782
+ border-radius: 6px;
783
+ }
784
+ .muted {
785
+ color: var(--sa-ink-muted);
786
+ font-size: 12px;
787
+ }
788
+ .pager {
789
+ display: flex;
790
+ justify-content: flex-end;
791
+ margin-top: 14px;
792
+ }
793
+
794
+ .preview-meta {
795
+ display: flex;
796
+ flex-wrap: wrap;
797
+ gap: 10px;
798
+ align-items: center;
799
+ margin-bottom: 12px;
800
+ flex-shrink: 0;
801
+ }
802
+ .preview-zoom {
803
+ display: inline-flex;
804
+ flex-wrap: wrap;
805
+ gap: 10px;
806
+ align-items: center;
807
+ margin-left: auto;
808
+ }
809
+ .preview-zoom__pct {
810
+ min-width: 64px;
811
+ font-variant-numeric: tabular-nums;
812
+ }
813
+ .preview-zoom__hint {
814
+ white-space: nowrap;
815
+ }
816
+ .preview-stage {
817
+ min-height: 0;
818
+ height: 100%;
819
+ flex: 1;
820
+ max-height: none;
821
+ border: 1px solid var(--sa-border);
822
+ border-radius: 12px;
823
+ background: #0f172a0a;
824
+ overflow: auto;
825
+ display: flex;
826
+ align-items: center;
827
+ justify-content: center;
828
+ position: relative;
829
+ }
830
+ .preview-stage.is-zoomable {
831
+ overscroll-behavior: contain;
832
+ }
833
+ .preview-stage.is-image {
834
+ cursor: grab;
835
+ user-select: none;
836
+ }
837
+ .preview-stage.is-image.is-panning {
838
+ cursor: grabbing;
839
+ }
840
+ .preview-transform {
841
+ transform-origin: center center;
842
+ will-change: transform;
843
+ display: flex;
844
+ align-items: center;
845
+ justify-content: center;
846
+ }
847
+ .preview-stage:not(.is-panning) .preview-transform {
848
+ transition: transform 0.08s ease-out;
849
+ }
850
+ .preview-transform--frame {
851
+ width: 100%;
852
+ align-self: flex-start;
853
+ }
854
+ .preview-image {
855
+ max-width: 100%;
856
+ max-height: 100%;
857
+ object-fit: contain;
858
+ display: block;
859
+ pointer-events: none;
860
+ -webkit-user-drag: none;
861
+ user-select: none;
862
+ }
863
+ .preview-frame {
864
+ width: 100%;
865
+ height: calc(80vh - 180px);
866
+ min-height: 200px;
867
+ border: 0;
868
+ background: #fff;
869
+ pointer-events: auto;
870
+ }
871
+ .preview-media {
872
+ width: 100%;
873
+ max-height: calc(80vh - 180px);
874
+ background: #000;
875
+ }
876
+ .preview-audio {
877
+ width: min(100%, 480px);
878
+ margin: 48px auto;
879
+ display: block;
880
+ }
881
+ .preview-text {
882
+ width: 100%;
883
+ margin: 0;
884
+ padding: 16px 18px;
885
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
886
+ font-size: 12.5px;
887
+ line-height: 1.6;
888
+ white-space: pre-wrap;
889
+ word-break: break-word;
890
+ color: var(--sa-ink);
891
+ align-self: stretch;
892
+ }
893
+ .preview-markdown {
894
+ width: 100%;
895
+ align-self: stretch;
896
+ padding: 18px 22px;
897
+ line-height: 1.7;
898
+ color: var(--sa-ink-secondary);
899
+ overflow: auto;
900
+ }
901
+ .preview-markdown :deep(h1),
902
+ .preview-markdown :deep(h2),
903
+ .preview-markdown :deep(h3),
904
+ .preview-markdown :deep(h4) {
905
+ margin: 1em 0 0.5em;
906
+ color: var(--sa-ink);
907
+ font-weight: 700;
908
+ line-height: 1.35;
909
+ }
910
+ .preview-markdown :deep(h1) {
911
+ font-size: 1.6em;
912
+ border-bottom: 1px solid var(--sa-border);
913
+ padding-bottom: 0.3em;
914
+ }
915
+ .preview-markdown :deep(h2) {
916
+ font-size: 1.35em;
917
+ }
918
+ .preview-markdown :deep(h3) {
919
+ font-size: 1.15em;
920
+ }
921
+ .preview-markdown :deep(p) {
922
+ margin: 0 0 0.85em;
923
+ }
924
+ .preview-markdown :deep(ul),
925
+ .preview-markdown :deep(ol) {
926
+ margin: 0 0 0.85em;
927
+ padding-left: 1.5em;
928
+ }
929
+ .preview-markdown :deep(li) {
930
+ margin: 0.2em 0;
931
+ }
932
+ .preview-markdown :deep(blockquote) {
933
+ margin: 0 0 0.85em;
934
+ padding: 0.4em 0.9em;
935
+ border-left: 4px solid var(--sa-accent, #2563eb);
936
+ background: var(--sa-toolbar-bg, #f8fafc);
937
+ color: var(--sa-ink-muted);
938
+ }
939
+ .preview-markdown :deep(code) {
940
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
941
+ font-size: 0.9em;
942
+ padding: 0.12em 0.4em;
943
+ border-radius: 6px;
944
+ background: var(--sa-toolbar-bg, #f1f5f9);
945
+ }
946
+ .preview-markdown :deep(pre) {
947
+ margin: 0 0 0.85em;
948
+ padding: 12px 14px;
949
+ border-radius: 10px;
950
+ overflow: auto;
951
+ background: #0f172a;
952
+ color: #e2e8f0;
953
+ }
954
+ .preview-markdown :deep(pre code) {
955
+ padding: 0;
956
+ background: transparent;
957
+ color: inherit;
958
+ }
959
+ .preview-markdown :deep(a) {
960
+ color: var(--sa-accent, #2563eb);
961
+ }
962
+ .preview-markdown :deep(table) {
963
+ width: 100%;
964
+ border-collapse: collapse;
965
+ margin: 0 0 0.85em;
966
+ font-size: 13px;
967
+ }
968
+ .preview-markdown :deep(th),
969
+ .preview-markdown :deep(td) {
970
+ border: 1px solid var(--sa-border);
971
+ padding: 6px 10px;
972
+ text-align: left;
973
+ }
974
+ .preview-markdown :deep(th) {
975
+ background: var(--sa-toolbar-bg, #f8fafc);
976
+ }
977
+ .preview-markdown :deep(img) {
978
+ max-width: 100%;
979
+ height: auto;
980
+ }
981
+ .preview-markdown :deep(hr) {
982
+ border: 0;
983
+ border-top: 1px solid var(--sa-border);
984
+ margin: 1.2em 0;
985
+ }
986
+ .preview-empty {
987
+ text-align: center;
988
+ padding: 48px 24px;
989
+ color: var(--sa-ink-secondary);
990
+ display: flex;
991
+ flex-direction: column;
992
+ gap: 8px;
993
+ align-items: center;
994
+ }
995
+
996
+ @media (max-width: 900px) {
997
+ .file-stats {
998
+ grid-template-columns: repeat(2, minmax(0, 1fr));
999
+ }
1000
+ }
1001
+ </style>
1002
+
1003
+ <style>
1004
+ .file-preview-dialog.el-dialog {
1005
+ width: 60vw !important;
1006
+ max-width: 60vw !important;
1007
+ height: 80vh !important;
1008
+ max-height: 80vh !important;
1009
+ overflow: hidden;
1010
+ display: flex;
1011
+ flex-direction: column;
1012
+ margin: auto !important;
1013
+ }
1014
+ .el-overlay-dialog:has(.file-preview-dialog) {
1015
+ display: flex;
1016
+ align-items: center;
1017
+ justify-content: center;
1018
+ }
1019
+ .file-preview-dialog .el-dialog__body {
1020
+ overflow: hidden;
1021
+ flex: 1;
1022
+ min-height: 0;
1023
+ display: flex;
1024
+ flex-direction: column;
1025
+ padding-top: 10px;
1026
+ padding-bottom: 10px;
1027
+ }
1028
+ .file-preview-dialog .el-dialog__footer {
1029
+ flex-shrink: 0;
1030
+ }
1031
+ </style>