Files
web/admin/src/views/files/FileManage.vue
whm 0800982224 feat: 分片上传断点续传、临时目录后台配置与清扫、宇恒云账号管理
- 管理端大文件分片上传与 sessionStorage 续传;Nginx 大请求体/超时
- .chunk-uploads 定期清扫;system_config 后台配置保留时长与扫描间隔
- 宇恒云 POST /register 对接与 yuheng_cloud_register_records 留痕;yuheng_cloud:manage 权限

Made-with: Cursor
2026-04-13 14:50:27 +08:00

257 lines
9.2 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.
<template>
<div class="file-manage">
<el-card>
<template #header>
<span>文件管理</span>
</template>
<el-tabs v-model="activeTab">
<el-tab-pane label="图片与图标" name="images">
<p class="tip">图片与图标统一在此管理支持可下载/不可下载功能开发中</p>
</el-tab-pane>
<el-tab-pane label="功能模块" name="module">
<div class="module-toolbar">
<el-select v-model="siteId" placeholder="选择站点" filterable style="width: 220px; margin-right: 12px" @change="onSiteChange">
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-button :disabled="!siteId" @click="showNewFolder = true">新建文件夹</el-button>
<el-upload :show-file-list="false" :disabled="!siteId" :before-upload="beforeUpload">
<el-button type="primary" :disabled="!siteId" :loading="uploading">上传文件</el-button>
</el-upload>
</div>
<el-alert v-if="!siteId" title="请先选择站点" type="info" style="margin: 12px 0" />
<template v-else>
<div class="breadcrumb-wrap">
<el-breadcrumb separator="/">
<el-breadcrumb-item @click="currentPath = ''"><a href="javascript:;">根目录</a></el-breadcrumb-item>
<el-breadcrumb-item v-for="(p, i) in pathParts" :key="i">
<a href="javascript:;" @click="currentPath = pathParts.slice(0, i + 1).join('/')">{{ p }}</a>
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="subdirs" v-if="subDirs && subDirs.length">
<span class="label">子目录</span>
<el-button v-for="d in subDirs" :key="d" link type="primary" @click="enterDir(d)">{{ d }}/</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe style="margin-top: 12px">
<el-table-column label="文件名" prop="name" min-width="180" />
<el-table-column label="存储路径" prop="file_path" min-width="200" show-overflow-tooltip />
<el-table-column label="可下载" width="80">
<template #default="{ row }">{{ row.downloadable ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ formatSize(row.size) }}</template>
</el-table-column>
<el-table-column label="上传时间" prop="created_at" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && list.length === 0 && (!subDirs || !subDirs.length)" description="当前目录为空,可上传文件或新建文件夹" />
</template>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 上传前选择是否可下载 -->
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="440px" :close-on-click-modal="false">
<p class="upload-resume-hint">8MB 将自动分片上传中断后<strong>同一文件</strong>再次选择上传可续传勿改文件名/大小</p>
<el-form label-width="112px">
<el-form-item label="当前目录">
<span>{{ currentPath || '根目录' }}</span>
</el-form-item>
<el-form-item label="保留原文件名">
<el-switch v-model="uploadPreserveFilename" />
<span class="form-hint">开启后将按原文件名保存同名文件会被覆盖</span>
</el-form-item>
<el-form-item label="允许下载">
<el-switch v-model="uploadDownloadable" />
</el-form-item>
<el-form-item v-if="uploading && uploadPercent > 0" label="进度">
<el-progress :percentage="uploadPercent" :stroke-width="16" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="doUpload">确定上传</el-button>
</template>
</el-dialog>
<!-- 新建文件夹 -->
<el-dialog v-model="showNewFolder" title="新建文件夹" width="400px">
<el-form label-width="80px">
<el-form-item label="目录名">
<el-input v-model="newFolderName" placeholder="当前目录下新建,可填多级如 a/b" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showNewFolder = false">取消</el-button>
<el-button type="primary" @click="createFolder">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSites, getSiteAssets, deleteSiteAsset, createSiteFolder } from '../../api/admin'
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
const activeTab = ref('module')
const siteId = ref('')
const sites = ref([])
const list = ref([])
const subDirs = ref([])
const loading = ref(false)
const currentPath = ref('')
const uploading = ref(false)
const uploadPercent = ref(0)
const uploadDialogVisible = ref(false)
const uploadDownloadable = ref(false)
const uploadPreserveFilename = ref(false)
const pendingFile = ref(null)
const showNewFolder = ref(false)
const newFolderName = ref('')
const pathParts = computed(() => {
const p = currentPath.value
if (!p) return []
return p.split('/').filter(Boolean)
})
const fetchSites = async () => {
try {
const res = await getSites()
sites.value = res.list || []
if (sites.value.length && !siteId.value) siteId.value = sites.value[0].id
} catch (e) {
ElMessage.error(e.message)
}
}
const fetchList = async () => {
if (!siteId.value) {
list.value = []
subDirs.value = []
return
}
loading.value = true
try {
const res = await getSiteAssets(siteId.value, currentPath.value || undefined)
list.value = res.list || []
subDirs.value = res.sub_dirs || []
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
const onSiteChange = () => {
currentPath.value = ''
fetchList()
}
const enterDir = (name) => {
currentPath.value = currentPath.value ? currentPath.value + '/' + name : name
}
watch([siteId, currentPath], fetchList)
const beforeUpload = (file) => {
pendingFile.value = file
const p = (currentPath.value || '').replace(/^\//, '')
uploadPreserveFilename.value = p.startsWith('promotion/')
uploadDownloadable.value = !uploadPreserveFilename.value
uploadDialogVisible.value = true
return false
}
const doUpload = async () => {
if (!pendingFile.value || !siteId.value) return
uploading.value = true
uploadPercent.value = 0
try {
await uploadSiteAssetWithResume(
siteId.value,
pendingFile.value,
{
folder: currentPath.value || undefined,
downloadable: uploadDownloadable.value,
preserveFilename: uploadPreserveFilename.value
},
{
onProgress: ({ percent }) => {
uploadPercent.value = percent
}
}
)
ElMessage.success('上传成功')
uploadDialogVisible.value = false
pendingFile.value = null
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '上传失败')
} finally {
uploading.value = false
uploadPercent.value = 0
}
}
const createFolder = async () => {
const name = (newFolderName.value || '').trim()
if (!name) {
ElMessage.warning('请输入目录名')
return
}
const fullPath = currentPath.value ? currentPath.value + '/' + name : name
try {
await createSiteFolder(siteId.value, fullPath)
ElMessage.success('创建成功')
showNewFolder.value = false
newFolderName.value = ''
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
}
}
const handleDelete = async (row) => {
await ElMessageBox.confirm('确定删除该文件?', '提示', { type: 'warning' })
try {
await deleteSiteAsset(siteId.value, row.id)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
}
}
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
onMounted(() => fetchSites().then(() => fetchList()))
</script>
<style scoped>
.file-manage .tip { color: #666; font-size: 14px; }
.form-hint { display: block; margin-top: 6px; font-size: 12px; color: #909399; line-height: 1.4; }
.form-hint code { font-size: 11px; }
.module-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.breadcrumb-wrap { margin-top: 12px; }
.subdirs { margin-top: 8px; font-size: 13px; color: #666; }
.subdirs .label { margin-right: 8px; }
.upload-resume-hint {
margin: 0 0 12px;
font-size: 12px;
color: #606266;
line-height: 1.5;
}
</style>