feat(m8): P1 MinIO 对象存储与文件管理
- 迁移 000023 gateway.file_objects(personal/system 归属隔离 + 部分索引) - internal/platform/storage:minio-go 适配(端点 scheme 剥离、流式 PutObject/Open/Delete) - internal/workbench/files.go:FileService(sha256 校验、PutObject-then-insert 回滚、delete 先删行再删对象) - admin /api/v1/admin/files + portal /api/v1/portal/files 处理器(流式上传下载、Content-Disposition) - RBAC file:read/file:manage;菜单加文件管理 + 门户文件仓库 - compose 增 minio 服务(S3_* anchor、不暴露端口);nginx client_max_body_size 32m→256m - 管理端文件管理页 + 门户个人文件仓;集成测试 TestFileObjectLifecycle 连真 MinIO 通过 - healthz object_storage:true;README/PRODUCTION/进展文档同步 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import axios from 'axios'
|
||||
import request from '@/utils/http'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
export interface FileObject {
|
||||
id: string
|
||||
original_name: string
|
||||
content_type: string
|
||||
size_bytes: number
|
||||
content_sha256: string
|
||||
scope: string
|
||||
owner_user_id?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const fetchFiles = () => request.get<FileObject[]>({ url: '/api/v1/admin/files' })
|
||||
export const uploadFile = (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post<FileObject>({ url: '/api/v1/admin/files', data: form, timeout: 120000 })
|
||||
}
|
||||
export const deleteFile = (id: string) => request.del({ url: `/api/v1/admin/files/${id}` })
|
||||
|
||||
// 文件体以 blob 流式下载,绕开统一响应拦截器(其期望 JSON envelope)。
|
||||
export async function downloadFile(id: string, filename: string) {
|
||||
const { accessToken } = useUserStore()
|
||||
const token = accessToken.startsWith('Bearer ') ? accessToken : `Bearer ${accessToken}`
|
||||
const res = await axios.get(`/api/v1/admin/files/${id}/download`, {
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
headers: { Authorization: token },
|
||||
responseType: 'blob'
|
||||
})
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex justify-between">
|
||||
<div><h2 class="text-xl font-semibold">文件管理</h2><p class="text-g-500 mt-1 text-sm">平台级文件存放于自托管对象存储(MinIO),上传/下载全部经网关代理</p></div>
|
||||
<ElUpload :show-file-list="false" :http-request="upload">
|
||||
<ElButton type="primary" :loading="uploading">上传文件</ElButton>
|
||||
</ElUpload>
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="files">
|
||||
<ElTableColumn prop="original_name" label="文件名" min-width="200" show-overflow-tooltip/>
|
||||
<ElTableColumn label="大小" width="120"><template #default="{row}">{{ formatBytes(row.size_bytes) }}</template></ElTableColumn>
|
||||
<ElTableColumn prop="content_type" label="类型" width="180" show-overflow-tooltip/>
|
||||
<ElTableColumn label="SHA-256" width="110"><template #default="{row}">{{ row.content_sha256.slice(0, 10) }}…</template></ElTableColumn>
|
||||
<ElTableColumn prop="created_at" label="上传时间" width="190"/>
|
||||
<ElTableColumn label="操作" width="160" fixed="right"><template #default="{row}">
|
||||
<ElButton link type="primary" @click="download(row)">下载</ElButton>
|
||||
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||
</template></ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { FileObject, deleteFile, downloadFile, fetchFiles, uploadFile } from '@/api/files'
|
||||
const loading = ref(false), uploading = ref(false)
|
||||
const files = ref<FileObject[]>([])
|
||||
function formatBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { files.value = await fetchFiles() } finally { loading.value = false }
|
||||
}
|
||||
async function upload({ file }: { file: File }) {
|
||||
uploading.value = true
|
||||
try {
|
||||
await uploadFile(file)
|
||||
ElMessage.success('上传成功')
|
||||
await load()
|
||||
} finally { uploading.value = false }
|
||||
}
|
||||
function download(row: FileObject) {
|
||||
downloadFile(row.id, row.original_name)
|
||||
}
|
||||
async function remove(row: FileObject) {
|
||||
await ElMessageBox.confirm(`确认删除文件「${row.original_name}」?`, '删除确认', { type: 'warning' })
|
||||
await deleteFile(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -1,4 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import request from '@/utils/http'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
export interface Application { id:string;code:string;name:string;description:string;published_version?:number }
|
||||
export interface Knowledge { id:string;name:string;description:string;document_count:number;chunk_count:number }
|
||||
@@ -21,6 +23,31 @@ export const fetchStats=(days:number)=>request.get<Stats>({url:'/api/v1/portal/s
|
||||
export const fetchLogs=(days:number,limit=50)=>request.get<{items:AuditEvent[]}>({url:'/api/v1/portal/logs',params:{days,limit}})
|
||||
export const changePassword=(params:{old_password:string;new_password:string})=>request.post({url:'/api/v1/portal/password',params})
|
||||
|
||||
// --- 个人文件仓库(M8 对象存储)---
|
||||
export interface FileObject { id:string;original_name:string;content_type:string;size_bytes:number;content_sha256:string;scope:string;created_at:string;updated_at:string }
|
||||
export const fetchMyFiles=()=>request.get<FileObject[]>({url:'/api/v1/portal/files'})
|
||||
export const uploadMyFile=(file:File)=>{
|
||||
const form=new FormData()
|
||||
form.append('file',file)
|
||||
return request.post<FileObject>({url:'/api/v1/portal/files',data:form,timeout:120000})
|
||||
}
|
||||
export const deleteMyFile=(id:string)=>request.del({url:`/api/v1/portal/files/${id}`})
|
||||
export async function downloadMyFile(id:string,filename:string){
|
||||
const { accessToken } = useUserStore()
|
||||
const token = accessToken.startsWith('Bearer ')?accessToken:`Bearer ${accessToken}`
|
||||
const res = await axios.get(`/api/v1/portal/files/${id}/download`,{
|
||||
baseURL:import.meta.env.VITE_API_URL,
|
||||
headers:{Authorization:token},
|
||||
responseType:'blob'
|
||||
})
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
|
||||
// --- 资源市场 ---
|
||||
export interface MarketItem { type:string;code:string;name:string;description:string;category_id?:string;category_name:string;tags:string[];department_ids:string[];updated_at:string }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex justify-between">
|
||||
<div><h2 class="text-xl font-semibold">我的文件仓库</h2><p class="text-g-500 mt-1 text-sm">个人文件存储于平台对象存储,仅自己可见,可通过 API 下载引用</p></div>
|
||||
<ElUpload :show-file-list="false" :http-request="upload">
|
||||
<ElButton type="primary" :loading="uploading">上传文件</ElButton>
|
||||
</ElUpload>
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="files">
|
||||
<ElTableColumn prop="original_name" label="文件名" min-width="200" show-overflow-tooltip/>
|
||||
<ElTableColumn label="大小" width="120"><template #default="{row}">{{ formatBytes(row.size_bytes) }}</template></ElTableColumn>
|
||||
<ElTableColumn prop="content_type" label="类型" width="180" show-overflow-tooltip/>
|
||||
<ElTableColumn prop="created_at" label="上传时间" width="190"/>
|
||||
<ElTableColumn label="操作" width="160" fixed="right"><template #default="{row}">
|
||||
<ElButton link type="primary" @click="download(row)">下载</ElButton>
|
||||
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||
</template></ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { FileObject, deleteMyFile, downloadMyFile, fetchMyFiles, uploadMyFile } from '@/api/portal'
|
||||
const loading = ref(false), uploading = ref(false)
|
||||
const files = ref<FileObject[]>([])
|
||||
function formatBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { files.value = await fetchMyFiles() } finally { loading.value = false }
|
||||
}
|
||||
async function upload({ file }: { file: File }) {
|
||||
uploading.value = true
|
||||
try {
|
||||
await uploadMyFile(file)
|
||||
ElMessage.success('上传成功')
|
||||
await load()
|
||||
} finally { uploading.value = false }
|
||||
}
|
||||
function download(row: FileObject) {
|
||||
downloadMyFile(row.id, row.original_name)
|
||||
}
|
||||
async function remove(row: FileObject) {
|
||||
await ElMessageBox.confirm(`确认删除文件「${row.original_name}」?`, '删除确认', { type: 'warning' })
|
||||
await deleteMyFile(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user