初始化
This commit is contained in:
738
_backup_20260613/FileList.vue
Normal file
738
_backup_20260613/FileList.vue
Normal file
@@ -0,0 +1,738 @@
|
||||
<!--
|
||||
文件对象储存 — 表格列表页
|
||||
|
||||
功能:
|
||||
- 表格展示所有文件(缩略图、原文件名、储存文件名、类型、大小、上传者、时间)
|
||||
- 图片缩略图 50x50,点击弹窗预览大图
|
||||
- 操作:下载 / 复制下载链接 / 删除
|
||||
|
||||
管理后台接口(Cookie 登录态):
|
||||
- GET /zgapi/v1/admin/file/{id}/view 预览
|
||||
- GET /zgapi/v1/admin/file/{id}/download 下载
|
||||
- DELETE /zgapi/v1/admin/file/{id} 删除
|
||||
- POST /zgapi/v1/admin/file/page 分页
|
||||
|
||||
开放 API(需外部系统自行获取 token):
|
||||
{gatewayUrl}/zgapi/v1/open/file/{id}/download?token=YOUR_TOKEN
|
||||
-->
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<!-- 搜索+操作栏 -->
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header-title">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索条件</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="query" inline>
|
||||
<el-form-item label="原文件名">
|
||||
<el-input v-model="query.fileName" placeholder="请输入文件名" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上传者">
|
||||
<el-input v-model="query.uploadUserName" placeholder="请输入上传者" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="文件类型">
|
||||
<el-select v-model="query.fileType" placeholder="全部" clearable style="width:120px">
|
||||
<el-option label="头像" value="avatar" />
|
||||
<el-option label="图标" value="icon" />
|
||||
<el-option label="文档" value="doc" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="uploadVisible = true" v-permission="'system:file:upload'">
|
||||
<el-icon><Upload /></el-icon>上传文件
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 上传弹窗 -->
|
||||
<el-dialog v-model="uploadVisible" width="640px" destroy-on-close @closed="resetUpload" class="upload-dialog">
|
||||
<template #header>
|
||||
<div class="dialog-header-custom">
|
||||
<div class="header-icon-box">
|
||||
<el-icon :size="22"><UploadFilled /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<div class="header-title">上传文件</div>
|
||||
<div class="header-sub">{{ uploadQueue.length ? uploadQueue.length + ' 个文件待上传' : '支持批量选择,逐个上传' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 拖拽区域 -->
|
||||
<div
|
||||
class="drop-zone"
|
||||
:class="{ 'drag-over': dragOver, 'has-files': uploadQueue.length > 0 }"
|
||||
@click="triggerUpload"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave="dragOver = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<!-- 空状态 -->
|
||||
<div v-if="uploadQueue.length === 0" class="drop-empty">
|
||||
<div class="drop-icon-ring">
|
||||
<el-icon :size="36"><Cloudy /></el-icon>
|
||||
</div>
|
||||
<p class="drop-title">将文件拖拽到此处</p>
|
||||
<p class="drop-hint">或点击此区域选择文件(支持多选)</p>
|
||||
</div>
|
||||
|
||||
<!-- 文件队列 -->
|
||||
<div v-else class="queue-list">
|
||||
<div
|
||||
v-for="(f, i) in uploadQueue" :key="i"
|
||||
class="queue-card"
|
||||
:class="f._status"
|
||||
>
|
||||
<!-- 状态图标 -->
|
||||
<div class="q-icon" :class="f._status">
|
||||
<el-icon v-if="f._status === 'done'" :size="22"><Check /></el-icon>
|
||||
<el-icon v-else-if="f._status === 'fail'" :size="22"><Close /></el-icon>
|
||||
<div v-else-if="f._status === 'uploading'" class="spinner"></div>
|
||||
<el-icon v-else :size="22"><Document /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 文件信息 -->
|
||||
<div class="q-body">
|
||||
<div class="q-name">{{ f.file.name }}</div>
|
||||
<div class="q-meta">
|
||||
<span>{{ formatSize(f.file.size) }}</span>
|
||||
</div>
|
||||
<!-- 进度条 -->
|
||||
<div v-if="f._status === 'uploading'" class="q-progress">
|
||||
<el-progress :percentage="uploadPercent" :stroke-width="8" :show-text="true" :color="'#6366f1'" />
|
||||
<div class="q-speed">{{ uploadSpeed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作 -->
|
||||
<el-button
|
||||
v-if="f._status === 'wait'"
|
||||
class="q-remove" circle size="small" text
|
||||
@click.stop="removeQueueItem(i)"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
<el-tag v-else-if="f._status === 'done'" type="success" size="small" effect="light" round>完成</el-tag>
|
||||
<el-tag v-else-if="f._status === 'fail'" type="danger" size="small" effect="light" round>失败</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="fileInputRef" type="file" multiple style="display:none" @change="onFileSelected" />
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div class="upload-footer">
|
||||
<div class="footer-left">
|
||||
<el-select v-model="uploadForm.fileType" style="width:110px" :disabled="uploading" size="default">
|
||||
<el-option label="🖼 头像" value="avatar" />
|
||||
<el-option label="⭐ 图标" value="icon" />
|
||||
<el-option label="📄 文档" value="doc" />
|
||||
<el-option label="📦 其他" value="other" />
|
||||
</el-select>
|
||||
<el-button @click="triggerUpload" :disabled="uploading" size="default">
|
||||
<el-icon><FolderAdd /></el-icon>添加文件
|
||||
</el-button>
|
||||
<el-button v-if="uploadQueue.length > 0" @click="uploadQueue = []" :disabled="uploading" size="default" text>
|
||||
清空列表
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary" :loading="uploading" size="large"
|
||||
:disabled="uploadQueue.length === 0 || allDone"
|
||||
@click="startUpload"
|
||||
class="btn-start"
|
||||
>
|
||||
<template v-if="!uploading">
|
||||
<el-icon><Upload /></el-icon>
|
||||
开始上传({{ uploadQueue.filter(f => f._status !== 'done').length }} 个文件)
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ doneCount }}/{{ uploadQueue.length }}
|
||||
</template>
|
||||
</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-card shadow="never">
|
||||
<el-table :data="fileList" border stripe v-loading="loading" style="width:100%">
|
||||
<!-- 压缩图 -->
|
||||
<el-table-column label="压缩图" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="thumb-box" @click="preview(row)" v-if="isImage(row.mimeType)">
|
||||
<img :src="getViewUrl(row.id)" class="thumb-img" loading="lazy" />
|
||||
</div>
|
||||
<el-icon v-else :size="28" color="#94a3b8"><Document /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 原文件名 -->
|
||||
<el-table-column prop="fileName" label="原文件名" min-width="180" show-overflow-tooltip />
|
||||
|
||||
<!-- 储存文件名 -->
|
||||
<el-table-column prop="storageFileName" label="储存文件名" width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<code class="storage-name-cell">{{ row.storageFileName || '-' }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 文件类型 -->
|
||||
<el-table-column prop="fileType" label="文件类型" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="fileTypeTag(row.fileType)" size="small" effect="dark">
|
||||
{{ fileTypeLabel(row.fileType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 文件大小 -->
|
||||
<el-table-column label="文件大小" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatSize(row.fileSize) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 上传者 -->
|
||||
<el-table-column prop="uploadUserName" label="上传者" width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.uploadUserName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 上传时间 -->
|
||||
<el-table-column label="上传时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ row.createdAt ? new Date(row.createdAt).toLocaleString('zh-CN') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 操作 -->
|
||||
<el-table-column label="操作" width="220" align="center" class-name="ops-col" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="actions">
|
||||
<el-button size="small" @click="downloadFile(row)">
|
||||
<el-icon><Download /></el-icon>下载
|
||||
</el-button>
|
||||
<el-button size="small" @click="copyDownloadUrl(row)">
|
||||
<el-icon><Link /></el-icon>复制链接
|
||||
</el-button>
|
||||
<el-popconfirm title="确定删除此文件?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无文件" :image-size="100" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="fetchData"
|
||||
@size-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<el-dialog v-model="previewVisible" title="文件预览" width="720px" destroy-on-close>
|
||||
<div class="preview-body">
|
||||
<template v-if="previewItem && isImage(previewItem.mimeType)">
|
||||
<img :src="getViewUrl(previewItem.id)" style="max-width:100%;max-height:65vh;display:block;margin:0 auto" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-empty description="非图片文件,无法预览" />
|
||||
</template>
|
||||
<div class="preview-meta" v-if="previewItem">
|
||||
<div class="meta-line"><span class="meta-label">原文件名:</span>{{ previewItem.fileName }}</div>
|
||||
<div class="meta-line"><span class="meta-label">储存文件名:</span>{{ previewItem.storageFileName || '-' }}</div>
|
||||
<div class="meta-line"><span class="meta-label">类型:</span>{{ previewItem.mimeType || '-' }}</div>
|
||||
<div class="meta-line"><span class="meta-label">大小:</span>{{ formatSize(previewItem.fileSize) }}</div>
|
||||
<div class="meta-line"><span class="meta-label">上传者:</span>{{ previewItem.uploadUserName || '-' }}</div>
|
||||
<div class="meta-line"><span class="meta-label">上传时间:</span>{{ previewItem.createdAt ? new Date(previewItem.createdAt).toLocaleString('zh-CN') : '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Search, Document, Delete, Upload, UploadFilled, Close, Download, Link,
|
||||
Cloudy, FolderAdd, Check,
|
||||
} from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
|
||||
interface FileItem {
|
||||
id: string
|
||||
fileName: string
|
||||
storageFileName: string
|
||||
fileType: string
|
||||
fileSize: number
|
||||
mimeType: string
|
||||
md5: string
|
||||
uploadUserName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const fileList = ref<FileItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''
|
||||
|
||||
// 搜索条件
|
||||
const query = reactive({
|
||||
fileName: '',
|
||||
uploadUserName: '',
|
||||
fileType: '' as string,
|
||||
})
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const previewItem = ref<FileItem | null>(null)
|
||||
|
||||
// ===== 上传队列 =====
|
||||
interface QueueItem { file: File; _status: 'wait' | 'uploading' | 'done' | 'fail' }
|
||||
|
||||
const uploadStore = useUploadStore()
|
||||
const uploadVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadPercent = ref(0)
|
||||
const uploadSpeed = ref('')
|
||||
const dragOver = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const uploadQueue = ref<QueueItem[]>([])
|
||||
const uploadForm = reactive({ fileType: 'other' })
|
||||
let lastLoaded = 0
|
||||
let lastTime = 0
|
||||
|
||||
const doneCount = computed(() => uploadQueue.value.filter(f => f._status === 'done').length)
|
||||
const allDone = computed(() => uploadQueue.value.length > 0 && uploadQueue.value.every(f => f._status === 'done' || f._status === 'fail'))
|
||||
|
||||
function resetUpload() {
|
||||
if (uploading.value) return // 上传中不重置
|
||||
uploading.value = false
|
||||
uploadPercent.value = 0
|
||||
uploadSpeed.value = ''
|
||||
uploadQueue.value = []
|
||||
dragOver.value = false
|
||||
}
|
||||
|
||||
function triggerUpload() { fileInputRef.value?.click() }
|
||||
|
||||
function addFiles(files: FileList | File[]) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
uploadQueue.value.push({ file, _status: 'wait' })
|
||||
// 根据第一个文件自动判断类型
|
||||
if (uploadQueue.value.length === 1) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
if (ext && ['png','jpg','jpeg','gif','webp','svg','ico'].includes(ext)) uploadForm.fileType = 'icon'
|
||||
else if (ext && ['pdf','doc','docx','xls','xlsx','txt','md'].includes(ext)) uploadForm.fileType = 'doc'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeQueueItem(i: number) {
|
||||
uploadQueue.value.splice(i, 1)
|
||||
}
|
||||
|
||||
function onFileSelected(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (files && files.length > 0) addFiles(files)
|
||||
// 重置 input 以允许重复选同一个文件
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
dragOver.value = false
|
||||
const files = e.dataTransfer?.files
|
||||
if (files && files.length > 0) addFiles(files)
|
||||
}
|
||||
|
||||
/** 逐个上传队列中的文件(串行) */
|
||||
async function startUpload() {
|
||||
if (uploadQueue.value.length === 0) return
|
||||
uploading.value = true
|
||||
|
||||
for (let i = 0; i < uploadQueue.value.length; i++) {
|
||||
const item = uploadQueue.value[i]
|
||||
if (item._status === 'done') continue
|
||||
|
||||
item._status = 'uploading'
|
||||
uploadPercent.value = 0
|
||||
uploadSpeed.value = '准备上传...'
|
||||
lastLoaded = 0; lastTime = Date.now()
|
||||
|
||||
// 注册到全局上传面板
|
||||
const taskId = Date.now().toString(36) + i
|
||||
uploadStore.addTask({
|
||||
id: taskId, fileName: item.file.name, fileSize: item.file.size,
|
||||
fileType: uploadForm.fileType, file: item.file,
|
||||
status: 'uploading', progress: 0, speed: '', startedAt: Date.now(),
|
||||
})
|
||||
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', item.file)
|
||||
fd.append('fileType', uploadForm.fileType)
|
||||
|
||||
const axios = (await import('axios')).default
|
||||
const baseURL = import.meta.env.VITE_GATEWAY_URL || ''
|
||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1] || ''
|
||||
|
||||
const res = await axios.post(baseURL + '/zgapi/v1/admin/file/upload', fd, {
|
||||
timeout: 43200000, // 12小时
|
||||
headers: { 'Content-Type': 'multipart/form-data', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
||||
onUploadProgress: (e: any) => {
|
||||
if (e.total > 0) {
|
||||
uploadPercent.value = Math.round((e.loaded / e.total) * 100)
|
||||
const now = Date.now()
|
||||
const elapsed = (now - lastTime) / 1000
|
||||
if (elapsed >= 1) {
|
||||
const speed = (e.loaded - lastLoaded) / elapsed
|
||||
uploadSpeed.value = formatSpeed(speed)
|
||||
lastLoaded = e.loaded; lastTime = now
|
||||
}
|
||||
uploadStore.updateTask(taskId, { progress: uploadPercent.value, speed: uploadSpeed.value })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 检查业务状态码
|
||||
if (res.data?.code && res.data.code !== 200) {
|
||||
throw new Error(res.data.msg || '服务器返回错误')
|
||||
}
|
||||
|
||||
item._status = 'done'
|
||||
uploadStore.updateTask(taskId, { status: 'completed', progress: 100 })
|
||||
} catch (e: any) {
|
||||
item._status = 'fail'
|
||||
// 提取错误信息
|
||||
let errMsg = '上传失败'
|
||||
if (e.response?.data?.msg) {
|
||||
errMsg = e.response.data.msg
|
||||
} else if (e.response?.status) {
|
||||
errMsg = `服务器错误(${e.response.status})`
|
||||
} else if (e.message && e.message !== '上传失败') {
|
||||
errMsg = e.message
|
||||
}
|
||||
uploadStore.updateTask(taskId, { status: 'failed', error: errMsg })
|
||||
console.error(`[上传失败] ${item.file.name}:`, errMsg, e)
|
||||
}
|
||||
}
|
||||
|
||||
uploading.value = false
|
||||
ElMessage.success(`上传完成: ${doneCount.value}/${uploadQueue.value.length} 个文件成功`)
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec: number) {
|
||||
if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + ' B/s'
|
||||
if (bytesPerSec < 1048576) return (bytesPerSec / 1024).toFixed(1) + ' KB/s'
|
||||
return (bytesPerSec / 1048576).toFixed(1) + ' MB/s'
|
||||
}
|
||||
|
||||
// ===== 数据 =====
|
||||
function getViewUrl(id: string) { return `${gatewayUrl}/zgapi/v1/admin/file/${id}/view` }
|
||||
function getDownloadUrl(id: string) { return `${gatewayUrl}/zgapi/v1/admin/file/${id}/download` }
|
||||
function isImage(mime: string | null | undefined) { return mime ? mime.startsWith('image/') : false }
|
||||
|
||||
function formatSize(bytes: number | null) {
|
||||
if (!bytes) return '0 B'
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1048576).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function fileTypeTag(type: string) {
|
||||
const map: Record<string, string> = { avatar: 'success', icon: 'warning', doc: '', other: 'info' }
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
function fileTypeLabel(type: string) {
|
||||
const map: Record<string, string> = { avatar: '头像', icon: '图标', doc: '文档', other: '其他' }
|
||||
return map[type] || type || '其他'
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
}
|
||||
if (query.fileName) params.fileName = query.fileName
|
||||
if (query.uploadUserName) params.uploadUserName = query.uploadUserName
|
||||
if (query.fileType) params.fileType = query.fileType
|
||||
const res = await request.post('/zgapi/v1/admin/file/page', params)
|
||||
fileList.value = res.data?.records || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch { /* ignore */ } finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.fileName = ''
|
||||
query.uploadUserName = ''
|
||||
query.fileType = ''
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// ===== 预览 =====
|
||||
function preview(item: FileItem) {
|
||||
previewItem.value = item
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
// ===== 下载(fetch + blob,支持跨域) =====
|
||||
async function downloadFile(item: FileItem) {
|
||||
try {
|
||||
const res = await fetch(getDownloadUrl(item.id), { credentials: 'include' })
|
||||
if (!res.ok) { ElMessage.error('下载失败'); return }
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = item.fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 复制下载链接(自动获取一次性 token) =====
|
||||
async function copyDownloadUrl(item: FileItem) {
|
||||
try {
|
||||
// 自动获取开放 API Token
|
||||
const tokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' })
|
||||
const token = tokenRes.data?.token
|
||||
if (!token) { ElMessage.error('获取下载Token失败'); return }
|
||||
|
||||
const url = `${gatewayUrl}/zgapi/v1/open/file/${item.id}/download?token=${token}`
|
||||
await navigator.clipboard.writeText(url)
|
||||
ElMessage.success('已复制下载链接(Token 12小时有效,仅可使用一次)')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 删除 =====
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await request.delete(`/zgapi/v1/admin/file/${id}`)
|
||||
ElMessage.success('已删除')
|
||||
if (fileList.value.length === 1 && page.value > 1) page.value--
|
||||
fetchData()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 防误刷:上传中刷新/关闭页面时弹提示
|
||||
function beforeUnload(e: BeforeUnloadEvent) {
|
||||
if (uploading.value) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
window.addEventListener('beforeunload', beforeUnload)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', beforeUnload)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-wrapper { display:flex; flex-direction:column; gap:16px; }
|
||||
|
||||
/* 搜索卡片 */
|
||||
.card-header-title {
|
||||
display:flex; align-items:center; gap:6px; font-weight:600; color:#303133;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.toolbar { display:flex; justify-content:flex-start; margin-bottom:0; }
|
||||
.toolbar-left { display:flex; gap:12px; }
|
||||
.toolbar-right { display:flex; gap:8px; }
|
||||
|
||||
/* 缩略图 */
|
||||
.thumb-box {
|
||||
width:50px; height:50px; border-radius:6px; overflow:hidden;
|
||||
cursor:pointer; border:1px solid var(--border-color);
|
||||
}
|
||||
.thumb-box:hover { border-color:var(--color-primary); }
|
||||
.thumb-img {
|
||||
width:100%; height:100%; object-fit:cover; display:block;
|
||||
}
|
||||
|
||||
/* 储存文件名 */
|
||||
.storage-name-cell {
|
||||
font-family:'SF Mono','Fira Code',monospace;
|
||||
font-size:12px; color:var(--text-muted);
|
||||
background:var(--bg-page); padding:2px 6px; border-radius:4px;
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination-wrapper { display:flex; justify-content:flex-end; margin-top:20px; }
|
||||
|
||||
/* 操作 */
|
||||
.actions { display:flex; gap:4px; flex-wrap:wrap; justify-content:center; }
|
||||
|
||||
/* ===== 上传弹窗 ===== */
|
||||
.upload-dialog :deep(.el-dialog__header) { display:none; }
|
||||
.upload-dialog :deep(.el-dialog__body) { padding:0; }
|
||||
|
||||
/* 自定义标题 */
|
||||
.dialog-header-custom {
|
||||
display:flex; align-items:center; gap:14px;
|
||||
padding:20px 28px 0;
|
||||
}
|
||||
.header-icon-box {
|
||||
width:44px; height:44px; border-radius:var(--radius-lg);
|
||||
background:linear-gradient(135deg, #6366f1, #818cf8);
|
||||
display:flex; align-items:center; justify-content:center; color:#fff; flex-shrink:0;
|
||||
}
|
||||
.header-title { font-size:17px; font-weight:700; color:var(--text-primary); }
|
||||
.header-sub { font-size:12px; color:var(--text-muted); margin-top:2px; }
|
||||
|
||||
/* 拖拽区域 */
|
||||
.drop-zone {
|
||||
margin:20px 28px; padding:24px;
|
||||
border:2px dashed var(--border-color); border-radius:var(--radius-xl);
|
||||
cursor:pointer; transition:all 0.25s; position:relative;
|
||||
min-height:160px; max-height:360px; overflow-y:auto;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.drag-over {
|
||||
border-color:var(--color-primary); background:linear-gradient(135deg, rgba(99,102,241,0.03), rgba(129,140,248,0.05));
|
||||
}
|
||||
.drop-zone.drag-over { border-style:solid; box-shadow:0 0 0 4px rgba(99,102,241,0.1); }
|
||||
.drop-zone.has-files { border-style:solid; border-color:var(--border-color); }
|
||||
|
||||
/* 空状态 */
|
||||
.drop-empty { text-align:center; padding:20px 0; }
|
||||
.drop-icon-ring {
|
||||
width:72px; height:72px; border-radius:50%;
|
||||
background:linear-gradient(135deg, #eef2ff, #e0e7ff);
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
margin-bottom:16px; color:var(--color-primary);
|
||||
}
|
||||
.drop-title { font-size:16px; font-weight:600; color:var(--text-primary); margin-bottom:4px; }
|
||||
.drop-hint { font-size:13px; color:var(--text-muted); }
|
||||
|
||||
/* 文件卡片 */
|
||||
.queue-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.queue-card {
|
||||
display:flex; align-items:center; gap:14px;
|
||||
padding:14px 16px; border-radius:var(--radius-lg);
|
||||
background:#fff; border:1px solid var(--border-color);
|
||||
transition:all 0.2s;
|
||||
}
|
||||
.queue-card:hover { box-shadow:var(--shadow-sm); }
|
||||
.queue-card.done { border-color:#bbf7d0; background:#f0fdf4; }
|
||||
.queue-card.fail { border-color:#fecaca; background:#fef2f2; }
|
||||
.queue-card.uploading { border-color:#c7d2fe; background:#eef2ff; }
|
||||
|
||||
/* 状态图标 */
|
||||
.q-icon {
|
||||
width:44px; height:44px; border-radius:var(--radius-md);
|
||||
display:flex; align-items:center; justify-content:center; flex-shrink:0;
|
||||
background:var(--bg-page); color:var(--text-muted);
|
||||
}
|
||||
.q-icon.done { background:#dcfce7; color:#16a34a; }
|
||||
.q-icon.fail { background:#fecaca; color:#dc2626; }
|
||||
.q-icon.uploading { background:#e0e7ff; }
|
||||
|
||||
/* 旋转动画 */
|
||||
.spinner {
|
||||
width:20px; height:20px; border:2.5px solid #c7d2fe;
|
||||
border-top-color:var(--color-primary); border-radius:50%;
|
||||
animation:spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
|
||||
/* 文件信息 */
|
||||
.q-body { flex:1; min-width:0; }
|
||||
.q-name { font-size:14px; font-weight:500; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.q-meta { font-size:12px; color:var(--text-muted); margin-top:2px; }
|
||||
.q-progress { margin-top:8px; }
|
||||
.q-speed { font-size:11px; color:var(--text-muted); text-align:right; margin-top:2px; }
|
||||
|
||||
.q-remove { flex-shrink:0; color:var(--text-muted); }
|
||||
.q-remove:hover { color:var(--color-danger); background:#fef2f2; }
|
||||
|
||||
/* 底部栏 */
|
||||
.upload-footer {
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
padding:16px 28px 24px; border-top:1px solid var(--border-light);
|
||||
gap:12px;
|
||||
}
|
||||
.footer-left { display:flex; align-items:center; gap:8px; }
|
||||
.btn-start { min-width:180px; }
|
||||
|
||||
/* 移动端 */
|
||||
@media (max-width:768px) {
|
||||
.upload-dialog :deep(.el-dialog) { width:100vw !important; max-width:100vw !important; margin:0 !important; border-radius:0; }
|
||||
.drop-zone { margin:12px 16px; padding:16px; min-height:120px; max-height:240px; }
|
||||
.dialog-header-custom { padding:16px 16px 0; }
|
||||
.upload-footer { flex-direction:column; padding:12px 16px 20px; }
|
||||
.footer-left { width:100%; flex-wrap:wrap; }
|
||||
.btn-start { width:100%; }
|
||||
}
|
||||
|
||||
/* 预览弹窗 */
|
||||
.preview-meta { margin-top:20px; padding:16px; background:var(--bg-page); border-radius:var(--radius-md); }
|
||||
.meta-line { font-size:13px; color:var(--text-secondary); line-height:2; }
|
||||
.meta-label { color:var(--text-muted); }
|
||||
|
||||
/* 移动端 */
|
||||
@media (max-width:768px) {
|
||||
.toolbar { flex-direction:column; }
|
||||
.toolbar .el-button { width:100%; }
|
||||
|
||||
.search-card :deep(.el-form-item) { width:100%; margin-right:0; }
|
||||
.search-card :deep(.el-input),
|
||||
.search-card :deep(.el-select) { width:100% !important; }
|
||||
|
||||
.storage-name-cell { font-size:10px; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user