Files
mokeegateway/_backup_20260613/FileList.vue
2026-07-15 16:13:27 +08:00

739 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
文件对象储存 表格列表页
功能
- 表格展示所有文件缩略图原文件名储存文件名类型大小上传者时间
- 图片缩略图 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>