From feaf90bb7821f107dc6654351e2a0b9a8c912ddd Mon Sep 17 00:00:00 2001 From: wangrui233 Date: Tue, 27 Oct 2026 23:26:21 +0800 Subject: [PATCH] file --- mock/data.ts | 53 ++++++++ mock/index.ts | 62 ++++++++++ mock/plugin.ts | 3 +- src/api/file/index.ts | 34 +++++ src/config/static-routes.ts | 14 +++ src/enums/file.ts | 20 +++ src/router/index.ts | 2 + src/types/file.ts | 49 ++++++++ src/views/file/index.vue | 239 ++++++++++++++++++++++++++++++++++++ src/views/miniapp/index.vue | 2 +- 10 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 src/api/file/index.ts create mode 100644 src/enums/file.ts create mode 100644 src/types/file.ts create mode 100644 src/views/file/index.vue diff --git a/mock/data.ts b/mock/data.ts index ca490a8..65cb130 100644 --- a/mock/data.ts +++ b/mock/data.ts @@ -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 = { + 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', diff --git a/mock/index.ts b/mock/index.ts index c66e4fd..ce9381f 100644 --- a/mock/index.ts +++ b/mock/index.ts @@ -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', diff --git a/mock/plugin.ts b/mock/plugin.ts index e6ecd8a..ab977a6 100644 --- a/mock/plugin.ts +++ b/mock/plugin.ts @@ -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') diff --git a/src/api/file/index.ts b/src/api/file/index.ts new file mode 100644 index 0000000..120f162 --- /dev/null +++ b/src/api/file/index.ts @@ -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>({ 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({ + 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' }) + }, +} diff --git a/src/config/static-routes.ts b/src/config/static-routes.ts index 4503c4a..cd96f10 100644 --- a/src/config/static-routes.ts +++ b/src/config/static-routes.ts @@ -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', diff --git a/src/enums/file.ts b/src/enums/file.ts new file mode 100644 index 0000000..01a4ff9 --- /dev/null +++ b/src/enums/file.ts @@ -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[] = [ + { label: '后台', value: FileUploaderTypeEnum.ADMIN }, + { label: '小程序', value: FileUploaderTypeEnum.MA }, +] + +/** 上传者类型映射文案 */ +export const FILE_UPLOADER_TYPE_LABEL: Record = { + admin: '后台', + ma: '小程序', +} diff --git a/src/router/index.ts b/src/router/index.ts index 4322c04..183b490 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -32,6 +32,8 @@ export const constantRoutes: RouteRecordRaw[] = [ const permissionStore = usePermissionStore() return findFirstRoutePath(permissionStore.routes) ?? '/login' }, + // 占位 children,满足 vue-router 对带 component 路由的类型约束 + children: [], }, ] diff --git a/src/types/file.ts b/src/types/file.ts new file mode 100644 index 0000000..d4d071e --- /dev/null +++ b/src/types/file.ts @@ -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/. */ + 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 +} diff --git a/src/views/file/index.vue b/src/views/file/index.vue new file mode 100644 index 0000000..cfb4ee3 --- /dev/null +++ b/src/views/file/index.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/src/views/miniapp/index.vue b/src/views/miniapp/index.vue index 0f5ca44..6373ddb 100644 --- a/src/views/miniapp/index.vue +++ b/src/views/miniapp/index.vue @@ -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)" />