file
This commit is contained in:
@@ -59,6 +59,59 @@ export const miniApps: MockMiniApp[] = Array.from({ length: 12 }, (_, index) =>
|
||||
}
|
||||
})
|
||||
|
||||
export interface MockFile {
|
||||
id: number
|
||||
storage_key: string
|
||||
filename: string
|
||||
origin_name: string
|
||||
mime_type: string
|
||||
ext: string
|
||||
size: number
|
||||
uploader_type: 'admin' | 'ma'
|
||||
uploader_id: number
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
pdf: 'application/pdf',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
txt: 'text/plain',
|
||||
}
|
||||
|
||||
const MOCK_FILE_SAMPLES = [
|
||||
{ name: '产品截图.png', ext: 'png', size: 245_000 },
|
||||
{ name: '用户头像.jpg', ext: 'jpg', size: 88_400 },
|
||||
{ name: '操作手册.pdf', ext: 'pdf', size: 1_280_000 },
|
||||
{ name: '数据报表.xlsx', ext: 'xlsx', size: 56_200 },
|
||||
{ name: '说明文档.docx', ext: 'docx', size: 33_100 },
|
||||
{ name: '日志备份.txt', ext: 'txt', size: 12_800 },
|
||||
]
|
||||
|
||||
export const files: MockFile[] = Array.from({ length: 18 }, (_, index) => {
|
||||
const id = index + 1
|
||||
const sample = MOCK_FILE_SAMPLES[index % MOCK_FILE_SAMPLES.length]
|
||||
const rand = Math.random().toString(36).slice(2, 10)
|
||||
return {
|
||||
id,
|
||||
storage_key: `2026/10/27/${rand}.${sample.ext}`,
|
||||
filename: `${rand}.${sample.ext}`,
|
||||
origin_name: sample.name,
|
||||
mime_type: MIME_BY_EXT[sample.ext] ?? 'application/octet-stream',
|
||||
ext: sample.ext,
|
||||
size: sample.size + (id % 7) * 1024,
|
||||
uploader_type: id % 3 === 0 ? 'ma' : 'admin',
|
||||
uploader_id: (id % 3) + 1,
|
||||
url: `/files/static/2026/10/27/${rand}.${sample.ext}`,
|
||||
created_at: new Date(Date.now() - id * 3_600_000).toISOString().slice(0, 19).replace('T', ' '),
|
||||
}
|
||||
})
|
||||
|
||||
export const menuRoutes = [
|
||||
{
|
||||
path: '/system',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
files,
|
||||
menuRoutes,
|
||||
miniApps,
|
||||
type MockFile,
|
||||
users,
|
||||
USER_STATUS,
|
||||
type MockMiniApp,
|
||||
@@ -197,6 +199,66 @@ export const mockRules: MockRule[] = [
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /^\/api\/v1\/api\/admin\/files$/,
|
||||
handler: (ctx) => {
|
||||
const { keyword, uploader_type, page, size } = ctx.query
|
||||
const pageNum = toNumber(page, 1)
|
||||
const pageSize = toNumber(size, 20)
|
||||
const kw = (keyword as string) || ''
|
||||
const type = (uploader_type as string) || ''
|
||||
|
||||
const filtered = files.filter((item) => {
|
||||
if (type && item.uploader_type !== type) return false
|
||||
if (kw && !item.origin_name.includes(kw) && !item.filename.includes(kw)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const start = (pageNum - 1) * pageSize
|
||||
return { list: filtered.slice(start, start + pageSize), total: filtered.length }
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /^\/api\/v1\/api\/admin\/files\/upload$/,
|
||||
handler: () => {
|
||||
const id = files.length + 1
|
||||
const rand = Math.random().toString(36).slice(2, 10)
|
||||
const created: MockFile = {
|
||||
id,
|
||||
storage_key: `2026/10/27/${rand}.png`,
|
||||
filename: `${rand}.png`,
|
||||
origin_name: 'mock-upload.png',
|
||||
mime_type: 'image/png',
|
||||
ext: 'png',
|
||||
size: 1024,
|
||||
uploader_type: 'admin',
|
||||
uploader_id: 1,
|
||||
url: `/files/static/2026/10/27/${rand}.png`,
|
||||
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
}
|
||||
files.unshift(created)
|
||||
return {
|
||||
id,
|
||||
url: created.url,
|
||||
filename: created.filename,
|
||||
origin_name: created.origin_name,
|
||||
mime_type: created.mime_type,
|
||||
size: created.size,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
pattern: /^\/api\/v1\/api\/admin\/files\/(\d+)$/,
|
||||
handler: (ctx) => {
|
||||
const id = toNumber(ctx.params[0])
|
||||
const index = files.findIndex((item) => item.id === id)
|
||||
if (index > -1) files.splice(index, 1)
|
||||
return { id }
|
||||
},
|
||||
},
|
||||
{
|
||||
// 统计每个小程序的用户数(有真实用户的取真实值,其余返回稳定模拟值)
|
||||
method: 'GET',
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ export function mockPlugin(globalMock = false): Plugin {
|
||||
const requestUrl = new URL(req.url, 'http://localhost')
|
||||
void (async () => {
|
||||
const raw = req.method === 'GET' ? '' : await readBody(req)
|
||||
const isJsonBody = (req.headers['content-type'] ?? '').includes('application/json')
|
||||
const rule = mockRules.find(
|
||||
(item) =>
|
||||
item.method === (req.method ?? 'GET').toUpperCase() &&
|
||||
@@ -41,7 +42,7 @@ export function mockPlugin(globalMock = false): Plugin {
|
||||
url: requestUrl.pathname,
|
||||
params: matched ? matched.slice(1) : [],
|
||||
query: Object.fromEntries(requestUrl.searchParams.entries()),
|
||||
body: raw ? JSON.parse(raw) : {},
|
||||
body: raw && isJsonBody ? JSON.parse(raw) : {},
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json;charset=utf-8')
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import request from '@/utils/request'
|
||||
import type { PageResult } from '@/types/common'
|
||||
import type { FileQuery, FileUploadVO, FileVO } from '@/types/file'
|
||||
|
||||
// 叠加在 VITE_APP_BASE_API(/api/v1) 之上,经 Vite proxy 剥离 /api/v1 后命中后端 /api/admin/files
|
||||
const BASE_URL = '/api/admin/files'
|
||||
|
||||
export const FileAPI = {
|
||||
/** 分页查询文件列表 */
|
||||
query(params: FileQuery) {
|
||||
return request<PageResult<FileVO>>({ url: BASE_URL, method: 'get', params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传单个文件(multipart/form-data)。
|
||||
* 实例默认 Content-Type 为 application/json,此处显式置为 undefined,
|
||||
* 由 axios 在 toJSON 阶段剔除该头,最终交由浏览器自动补全 boundary。
|
||||
*/
|
||||
upload(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request<FileUploadVO>({
|
||||
url: `${BASE_URL}/upload`,
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': undefined },
|
||||
})
|
||||
},
|
||||
|
||||
/** 删除文件(同时清理存储内容与元数据) */
|
||||
delete(id: number) {
|
||||
return request<{ id: number }>({ url: `${BASE_URL}/${id}`, method: 'delete' })
|
||||
},
|
||||
}
|
||||
@@ -34,6 +34,20 @@ export const staticMenuRoutes: RouteVO[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/file',
|
||||
component: 'Layout',
|
||||
redirect: '/file/index',
|
||||
meta: { title: '文件管理', icon: 'Folder', hidden: false },
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
component: 'file/index',
|
||||
name: 'File',
|
||||
meta: { title: '文件管理', icon: 'Folder', hidden: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: 'Layout',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Option } from '@/types/common'
|
||||
import type { FileUploaderType } from '@/types/file'
|
||||
|
||||
/** 上传者类型枚举 */
|
||||
export const FileUploaderTypeEnum = {
|
||||
ADMIN: 'admin',
|
||||
MA: 'ma',
|
||||
} as const
|
||||
|
||||
/** 上传者类型下拉选项 */
|
||||
export const FILE_UPLOADER_TYPE_OPTIONS: Option<FileUploaderType>[] = [
|
||||
{ label: '后台', value: FileUploaderTypeEnum.ADMIN },
|
||||
{ label: '小程序', value: FileUploaderTypeEnum.MA },
|
||||
]
|
||||
|
||||
/** 上传者类型映射文案 */
|
||||
export const FILE_UPLOADER_TYPE_LABEL: Record<FileUploaderType, string> = {
|
||||
admin: '后台',
|
||||
ma: '小程序',
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
const permissionStore = usePermissionStore()
|
||||
return findFirstRoutePath(permissionStore.routes) ?? '/login'
|
||||
},
|
||||
// 占位 children,满足 vue-router 对带 component 路由的类型约束
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { RbacPageQuery } from '@/types/common'
|
||||
|
||||
/** 上传者类型:admin 后台管理员 / ma 小程序用户 */
|
||||
export type FileUploaderType = 'admin' | 'ma'
|
||||
|
||||
/** 文件列表查询参数(后端使用 page / size,见 file-api.md §3.1) */
|
||||
export interface FileQuery extends RbacPageQuery {
|
||||
/** 模糊匹配原始文件名或存储文件名 */
|
||||
keyword?: string
|
||||
/** 按上传者类型过滤,空表示不限 */
|
||||
uploader_type?: FileUploaderType
|
||||
/** 按上传者主键过滤,0 表示不限 */
|
||||
uploader_id?: number
|
||||
}
|
||||
|
||||
/** 文件实体(files 表,见 file-api.md §4.1) */
|
||||
export interface FileVO {
|
||||
id: number
|
||||
/** 存储后端内的 key,形如 2026/10/27/<rand>.<ext> */
|
||||
storage_key: string
|
||||
/** 落盘文件名(含扩展名) */
|
||||
filename: string
|
||||
/** 用户上传时的原始文件名 */
|
||||
origin_name: string
|
||||
/** 由扩展名推断的 MIME 类型 */
|
||||
mime_type: string
|
||||
/** 小写扩展名,不含点 */
|
||||
ext: string
|
||||
/** 文件字节数 */
|
||||
size: number
|
||||
/** 上传者类型:admin / ma */
|
||||
uploader_type: FileUploaderType
|
||||
/** 上传者主键 */
|
||||
uploader_id: number
|
||||
/** 可访问地址(base_url 拼接 storage_key) */
|
||||
url: string
|
||||
/** 创建时间(Y-m-d H:i:s) */
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 上传成功返回摘要字段(见 file-api.md §3.2) */
|
||||
export interface FileUploadVO {
|
||||
id: number
|
||||
url: string
|
||||
filename: string
|
||||
origin_name: string
|
||||
mime_type: string
|
||||
size: number
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { Delete, Download, Refresh, Search, Upload, View } from '@element-plus/icons-vue'
|
||||
import type { UploadRequestOptions } from 'element-plus'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { FileAPI } from '@/api/file'
|
||||
import { FILE_UPLOADER_TYPE_OPTIONS, FILE_UPLOADER_TYPE_LABEL } from '@/enums/file'
|
||||
import type { FileQuery, FileUploaderType, FileVO } from '@/types/file'
|
||||
|
||||
defineOptions({ name: 'File' })
|
||||
|
||||
/** 经 Vite proxy 拼接前缀,使本地静态访问也走后端代理(避免硬编码域名/端口) */
|
||||
const previewUrl = (url: string) => `${import.meta.env.VITE_APP_BASE_API}${url}`
|
||||
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const list = ref<FileVO[]>([])
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const queryParams = reactive<FileQuery>({
|
||||
page: 1,
|
||||
size: 20,
|
||||
keyword: undefined,
|
||||
uploader_type: undefined,
|
||||
uploader_id: undefined,
|
||||
})
|
||||
|
||||
watch([() => queryParams.page, () => queryParams.size], handleQuery)
|
||||
|
||||
async function handleQuery() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { list: rows, total: count } = await FileAPI.query(queryParams)
|
||||
list.value = rows
|
||||
total.value = count
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
Object.assign(queryParams, { page: 1, size: 20, keyword: undefined, uploader_type: undefined, uploader_id: undefined })
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
function handleSelectionChange(selection: FileVO[]) {
|
||||
selectedIds.value = selection.map((item) => item.id)
|
||||
}
|
||||
|
||||
function isImage(row: FileVO) {
|
||||
return row.mime_type.startsWith('image/')
|
||||
}
|
||||
|
||||
/** 文件大小格式化 */
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const units = ['KB', 'MB', 'GB']
|
||||
let value = bytes / 1024
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
return `${value.toFixed(2)} ${units[index]}`
|
||||
}
|
||||
|
||||
function uploaderLabel(type: FileUploaderType): string {
|
||||
return FILE_UPLOADER_TYPE_LABEL[type]
|
||||
}
|
||||
|
||||
/** 自定义上传:复用统一 request 以携带鉴权头 */
|
||||
async function handleUpload(options: UploadRequestOptions) {
|
||||
try {
|
||||
await FileAPI.upload(options.file)
|
||||
ElMessage.success('上传成功')
|
||||
handleQuery()
|
||||
} catch {
|
||||
// 失败由 request 拦截器统一提示,此处仅消费异常避免 el-upload 抛出未捕获错误
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreview(row: FileVO) {
|
||||
window.open(previewUrl(row.url), '_blank')
|
||||
}
|
||||
|
||||
function handleDownload(row: FileVO) {
|
||||
const link = document.createElement('a')
|
||||
link.href = previewUrl(row.url)
|
||||
link.download = row.origin_name || row.filename
|
||||
link.click()
|
||||
}
|
||||
|
||||
async function handleDelete(row?: FileVO) {
|
||||
const ids = row ? [row.id] : [...selectedIds.value]
|
||||
if (!ids.length) {
|
||||
ElMessage.warning('请先选择要删除的文件')
|
||||
return
|
||||
}
|
||||
|
||||
await ElMessageBox.confirm('确认删除选中的文件吗?删除后不可恢复', '提示', { type: 'warning' })
|
||||
await Promise.all(ids.map((id) => FileAPI.delete(id)))
|
||||
|
||||
// 删除末页最后一条时回退页码,避免出现空列表
|
||||
const remain = total.value - ids.length
|
||||
const maxPage = Math.max(1, Math.ceil(remain / queryParams.size))
|
||||
queryParams.page = Math.min(queryParams.page, maxPage)
|
||||
|
||||
ElMessage.success('删除成功')
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
onMounted(handleQuery)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 搜索栏 + 工具栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-form :model="queryParams" inline @submit.prevent="handleQuery">
|
||||
<el-form-item label="文件名">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="原始名 / 存储名"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上传者">
|
||||
<el-select v-model="queryParams.uploader_type" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option
|
||||
v-for="option in FILE_UPLOADER_TYPE_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<el-upload :show-file-list="false" :http-request="handleUpload">
|
||||
<el-button type="primary" :icon="Upload">上传文件</el-button>
|
||||
</el-upload>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
:disabled="!selectedIds.length"
|
||||
@click="handleDelete()"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table v-loading="loading" :data="list" border @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column label="预览" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="isImage(row as FileVO)"
|
||||
:src="previewUrl((row as FileVO).url)"
|
||||
:preview-src-list="[previewUrl((row as FileVO).url)]"
|
||||
:initial-index="0"
|
||||
fit="cover"
|
||||
class="thumb"
|
||||
/>
|
||||
<el-icon v-else class="thumb-placeholder"><View /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="origin_name" label="原文件名" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="mime_type" label="类型" width="140" show-overflow-tooltip />
|
||||
<el-table-column label="大小" width="120" align="right">
|
||||
<template #default="{ row }">{{ formatSize((row as FileVO).size) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上传者" width="140">
|
||||
<template #default="{ row }">{{ uploaderLabel((row as FileVO).uploader_type) }}({{ (row as FileVO).uploader_id }})</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="View" @click="handlePreview(row as FileVO)">预览</el-button>
|
||||
<el-button link type="primary" :icon="Download" @click="handleDownload(row as FileVO)">下载</el-button>
|
||||
<el-button link type="danger" :icon="Delete" @click="handleDelete(row as FileVO)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无数据" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.size"
|
||||
class="pagination"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.thumb-placeholder {
|
||||
font-size: 24px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -138,7 +138,7 @@
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
@change="(val: MiniAppStatus) => handleStatusChange(row as MiniAppVO, val)"
|
||||
@change="(val) => handleStatusChange(row as MiniAppVO, val as MiniAppStatus)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
Reference in New Issue
Block a user