初始化

This commit is contained in:
zg
2026-07-15 16:13:27 +08:00
parent 6d6f559793
commit c4023c1623
276 changed files with 42622 additions and 0 deletions

View 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>