64 KiB
File Storage Module Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add a file storage module with configurable backends (local/OSS/MinIO), backend CRUD API, and frontend file management page with preview.
Architecture: Strategy Pattern for storage abstraction — a Storage interface with three implementations swappable via YAML config. File metadata stored in MySQL via GORM. Frontend uses existing shadcn/ui component patterns with drag-and-drop upload and inline preview (image/video/PDF).
Tech Stack: Go (go-zero, GORM), Aliyun OSS SDK, MinIO SDK, React 19, TypeScript, Tailwind CSS, lucide-react icons.
Task 1: Add Go dependencies (OSS + MinIO SDKs)
Files:
- Modify:
backend/go.mod - Modify:
backend/go.sum
Step 1: Install dependencies
cd D:\APPS\base\backend
go get github.com/aliyun/aliyun-oss-go-sdk/oss@latest
go get github.com/minio/minio-go/v7@latest
go get github.com/google/uuid@latest
Step 2: Verify installation
Run: cd D:\APPS\base\backend && go mod tidy
Expected: No errors. go.mod should list the three new dependencies.
Step 3: Commit
cd D:\APPS\base\backend
git add go.mod go.sum
git commit -m "chore: add aliyun-oss, minio, uuid dependencies"
Task 2: Config — add StorageConfig
Files:
- Modify:
backend/internal/config/config.go - Modify:
backend/etc/base-api.yaml
Step 1: Add StorageConfig struct to config.go
Open backend/internal/config/config.go. The current content is:
package config
import "github.com/zeromicro/go-zero/rest"
type Config struct {
rest.RestConf
MySQL struct {
DSN string
}
Casdoor struct {
Endpoint string
ClientId string
ClientSecret string
Organization string
Application string
RedirectUrl string
FrontendUrl string
}
}
Add a Storage field and its sub-types. Replace the entire file with:
package config
import "github.com/zeromicro/go-zero/rest"
type Config struct {
rest.RestConf
MySQL struct {
DSN string
}
Casdoor struct {
Endpoint string
ClientId string
ClientSecret string
Organization string
Application string
RedirectUrl string
FrontendUrl string
}
Storage StorageConfig
}
type StorageConfig struct {
Type string `json:",default=local"` // local, oss, minio
MaxSize int64 `json:",default=104857600"` // 100MB
Local LocalStorageConfig
OSS OSSStorageConfig
MinIO MinIOStorageConfig
}
type LocalStorageConfig struct {
RootDir string `json:",default=./uploads"`
}
type OSSStorageConfig struct {
Endpoint string
AccessKeyId string
AccessKeySecret string
Bucket string
}
type MinIOStorageConfig struct {
Endpoint string
AccessKeyId string
AccessKeySecret string
Bucket string
UseSSL bool
}
Step 2: Add Storage section to YAML config
Append to the end of backend/etc/base-api.yaml:
# 文件存储配置
Storage:
Type: "local"
MaxSize: 104857600
Local:
RootDir: "./uploads"
OSS:
Endpoint: ""
AccessKeyId: ""
AccessKeySecret: ""
Bucket: ""
MinIO:
Endpoint: ""
AccessKeyId: ""
AccessKeySecret: ""
Bucket: ""
UseSSL: false
Step 3: Verify it compiles
Run: cd D:\APPS\base\backend && go build ./...
Expected: Build succeeds.
Step 4: Commit
cd D:\APPS\base\backend
git add internal/config/config.go etc/base-api.yaml
git commit -m "feat: add storage config for local/oss/minio"
Task 3: Storage interface + Local implementation
Files:
- Create:
backend/internal/storage/storage.go - Create:
backend/internal/storage/local.go
Step 1: Create the Storage interface and factory function
Create backend/internal/storage/storage.go:
package storage
import (
"context"
"fmt"
"io"
"github.com/youruser/base/internal/config"
)
// Storage defines the interface for file storage backends.
type Storage interface {
// Upload stores a file with the given key.
Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error
// Delete removes a file by key.
Delete(ctx context.Context, key string) error
// GetURL returns a URL for accessing the file.
GetURL(ctx context.Context, key string) (string, error)
// Exists checks whether a file exists.
Exists(ctx context.Context, key string) (bool, error)
}
// NewStorage creates the appropriate storage backend from config.
func NewStorage(cfg config.StorageConfig) (Storage, error) {
switch cfg.Type {
case "local":
return NewLocalStorage(cfg.Local)
case "oss":
return NewOSSStorage(cfg.OSS)
case "minio":
return NewMinIOStorage(cfg.MinIO)
default:
return nil, fmt.Errorf("unsupported storage type: %s", cfg.Type)
}
}
Step 2: Create the Local storage implementation
Create backend/internal/storage/local.go:
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"github.com/youruser/base/internal/config"
)
type LocalStorage struct {
rootDir string
}
func NewLocalStorage(cfg config.LocalStorageConfig) (*LocalStorage, error) {
rootDir := cfg.RootDir
if rootDir == "" {
rootDir = "./uploads"
}
// Ensure root directory exists
if err := os.MkdirAll(rootDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create upload dir: %w", err)
}
return &LocalStorage{rootDir: rootDir}, nil
}
func (s *LocalStorage) Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error {
fullPath := filepath.Join(s.rootDir, key)
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
file, err := os.Create(fullPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
if _, err := io.Copy(file, reader); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
func (s *LocalStorage) Delete(ctx context.Context, key string) error {
fullPath := filepath.Join(s.rootDir, key)
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete file: %w", err)
}
return nil
}
func (s *LocalStorage) GetURL(ctx context.Context, key string) (string, error) {
// For local storage, the URL is served via the backend proxy endpoint.
// The actual URL construction happens in the logic layer using the file ID.
// Here we return a relative path marker.
return "local://" + key, nil
}
func (s *LocalStorage) Exists(ctx context.Context, key string) (bool, error) {
fullPath := filepath.Join(s.rootDir, key)
_, err := os.Stat(fullPath)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
// GetFilePath returns the full filesystem path for a key (used by download handler).
func (s *LocalStorage) GetFilePath(key string) string {
return filepath.Join(s.rootDir, key)
}
Step 3: Verify it compiles
Run: cd D:\APPS\base\backend && go build ./internal/storage/...
Expected: Build succeeds (OSS/MinIO stubs not yet needed because NewStorage factory references them but we haven't called it yet — this will fail). Let's create stubs instead.
Actually, the factory references NewOSSStorage and NewMinIOStorage, so we need at least stubs. Continue to Task 4 and Task 5 before verifying compile.
Step 4: Commit
cd D:\APPS\base\backend
git add internal/storage/storage.go internal/storage/local.go
git commit -m "feat: add Storage interface and local filesystem implementation"
Task 4: OSS storage implementation
Files:
- Create:
backend/internal/storage/oss.go
Step 1: Create the Aliyun OSS implementation
Create backend/internal/storage/oss.go:
package storage
import (
"context"
"fmt"
"io"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/youruser/base/internal/config"
)
type OSSStorage struct {
bucket *oss.Bucket
}
func NewOSSStorage(cfg config.OSSStorageConfig) (*OSSStorage, error) {
if cfg.Endpoint == "" || cfg.AccessKeyId == "" || cfg.AccessKeySecret == "" || cfg.Bucket == "" {
return nil, fmt.Errorf("OSS config incomplete: endpoint, accessKeyId, accessKeySecret, bucket are required")
}
client, err := oss.New(cfg.Endpoint, cfg.AccessKeyId, cfg.AccessKeySecret)
if err != nil {
return nil, fmt.Errorf("failed to create OSS client: %w", err)
}
bucket, err := client.Bucket(cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("failed to get OSS bucket: %w", err)
}
return &OSSStorage{bucket: bucket}, nil
}
func (s *OSSStorage) Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error {
options := []oss.Option{
oss.ContentType(contentType),
}
if err := s.bucket.PutObject(key, reader, options...); err != nil {
return fmt.Errorf("failed to upload to OSS: %w", err)
}
return nil
}
func (s *OSSStorage) Delete(ctx context.Context, key string) error {
if err := s.bucket.DeleteObject(key); err != nil {
return fmt.Errorf("failed to delete from OSS: %w", err)
}
return nil
}
func (s *OSSStorage) GetURL(ctx context.Context, key string) (string, error) {
// Generate a signed URL valid for 1 hour
url, err := s.bucket.SignURL(key, oss.HTTPGet, 3600)
if err != nil {
return "", fmt.Errorf("failed to generate OSS signed URL: %w", err)
}
return url, nil
}
func (s *OSSStorage) Exists(ctx context.Context, key string) (bool, error) {
exists, err := s.bucket.IsObjectExist(key)
if err != nil {
return false, fmt.Errorf("failed to check OSS object: %w", err)
}
return exists, nil
}
Step 2: Commit
cd D:\APPS\base\backend
git add internal/storage/oss.go
git commit -m "feat: add Aliyun OSS storage implementation"
Task 5: MinIO storage implementation
Files:
- Create:
backend/internal/storage/minio.go
Step 1: Create the MinIO implementation
Create backend/internal/storage/minio.go:
package storage
import (
"context"
"fmt"
"io"
"net/url"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/youruser/base/internal/config"
)
type MinIOStorage struct {
client *minio.Client
bucket string
}
func NewMinIOStorage(cfg config.MinIOStorageConfig) (*MinIOStorage, error) {
if cfg.Endpoint == "" || cfg.AccessKeyId == "" || cfg.AccessKeySecret == "" || cfg.Bucket == "" {
return nil, fmt.Errorf("MinIO config incomplete: endpoint, accessKeyId, accessKeySecret, bucket are required")
}
client, err := minio.New(cfg.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKeyId, cfg.AccessKeySecret, ""),
Secure: cfg.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("failed to create MinIO client: %w", err)
}
// Ensure bucket exists
ctx := context.Background()
exists, err := client.BucketExists(ctx, cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("failed to check MinIO bucket: %w", err)
}
if !exists {
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{}); err != nil {
return nil, fmt.Errorf("failed to create MinIO bucket: %w", err)
}
}
return &MinIOStorage{client: client, bucket: cfg.Bucket}, nil
}
func (s *MinIOStorage) Upload(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error {
opts := minio.PutObjectOptions{
ContentType: contentType,
}
if _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, opts); err != nil {
return fmt.Errorf("failed to upload to MinIO: %w", err)
}
return nil
}
func (s *MinIOStorage) Delete(ctx context.Context, key string) error {
if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil {
return fmt.Errorf("failed to delete from MinIO: %w", err)
}
return nil
}
func (s *MinIOStorage) GetURL(ctx context.Context, key string) (string, error) {
// Generate a presigned URL valid for 1 hour
reqParams := make(url.Values)
presignedURL, err := s.client.PresignedGetObject(ctx, s.bucket, key, time.Hour, reqParams)
if err != nil {
return "", fmt.Errorf("failed to generate MinIO presigned URL: %w", err)
}
return presignedURL.String(), nil
}
func (s *MinIOStorage) Exists(ctx context.Context, key string) (bool, error) {
_, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{})
if err != nil {
errResponse := minio.ToErrorResponse(err)
if errResponse.Code == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("failed to check MinIO object: %w", err)
}
return true, nil
}
Step 2: Verify all storage code compiles
Run: cd D:\APPS\base\backend && go build ./internal/storage/...
Expected: Build succeeds.
Step 3: Commit
cd D:\APPS\base\backend
git add internal/storage/minio.go
git commit -m "feat: add MinIO storage implementation"
Task 6: File data model (entity + model)
Files:
- Create:
backend/model/file_entity.go - Create:
backend/model/file_model.go
Step 1: Create the File entity
Create backend/model/file_entity.go:
package model
import "time"
// File 文件模型
type File struct {
Id int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Name string `gorm:"column:name;type:varchar(255);not null" json:"name"`
Key string `gorm:"column:key;type:varchar(500);uniqueIndex" json:"key"`
Size int64 `gorm:"column:size;not null" json:"size"`
MimeType string `gorm:"column:mime_type;type:varchar(100)" json:"mimeType"`
Category string `gorm:"column:category;type:varchar(50);index;default:'default'" json:"category"`
IsPublic bool `gorm:"column:is_public;default:false" json:"isPublic"`
UserId int64 `gorm:"column:user_id;index" json:"userId"`
StorageType string `gorm:"column:storage_type;type:varchar(20)" json:"storageType"`
Status int `gorm:"column:status;default:1" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"`
}
// TableName 指定表名
func (File) TableName() string {
return "file"
}
Step 2: Create the File model (data access methods)
Create backend/model/file_model.go:
package model
import (
"context"
"errors"
"gorm.io/gorm"
)
// FileInsert 插入文件记录
func FileInsert(ctx context.Context, db *gorm.DB, file *File) (int64, error) {
result := db.WithContext(ctx).Create(file)
if result.Error != nil {
return 0, result.Error
}
return file.Id, nil
}
// FileFindOne 根据 ID 查询文件
func FileFindOne(ctx context.Context, db *gorm.DB, id int64) (*File, error) {
var file File
result := db.WithContext(ctx).Where("status = 1").First(&file, id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &file, nil
}
// FileFindList 查询文件列表(分页+筛选)
// userRole: 调用者角色,admin/super_admin 可看全部,其他只能看自己的+公开的
func FileFindList(ctx context.Context, db *gorm.DB, page, pageSize int64, keyword, category, mimeType string, userId int64, userRole string) ([]File, int64, error) {
var files []File
var total int64
query := db.WithContext(ctx).Model(&File{}).Where("status = 1")
// 权限过滤:非 admin/super_admin 只能看自己的文件 + 公开文件
if userRole != RoleAdmin && userRole != RoleSuperAdmin {
query = query.Where("user_id = ? OR is_public = ?", userId, true)
}
// 关键字搜索(文件名)
if keyword != "" {
query = query.Where("name LIKE ?", "%"+keyword+"%")
}
// 分类筛选
if category != "" {
query = query.Where("category = ?", category)
}
// MIME 类型筛选(前缀匹配,如 "image" 匹配 "image/png")
if mimeType != "" {
query = query.Where("mime_type LIKE ?", mimeType+"%")
}
// 统计总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询,按创建时间倒序
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
err := query.Order("created_at DESC").Offset(int(offset)).Limit(int(pageSize)).Find(&files).Error
if err != nil {
return nil, 0, err
}
return files, total, nil
}
// FileUpdate 更新文件记录
func FileUpdate(ctx context.Context, db *gorm.DB, file *File) error {
result := db.WithContext(ctx).Save(file)
return result.Error
}
// FileDelete 软删除文件(设置 status=0)
func FileDelete(ctx context.Context, db *gorm.DB, id int64) error {
result := db.WithContext(ctx).Model(&File{}).Where("id = ?", id).Update("status", 0)
return result.Error
}
Step 3: Verify it compiles
Run: cd D:\APPS\base\backend && go build ./model/...
Expected: Build succeeds.
Step 4: Commit
cd D:\APPS\base\backend
git add model/file_entity.go model/file_model.go
git commit -m "feat: add File entity and data access model"
Task 7: API definition + goctl code generation
Files:
- Create:
backend/api/file.api - Modify:
backend/base.api(add import) - Regenerate:
backend/internal/types/types.go - Regenerate:
backend/internal/handler/routes.go
Step 1: Create file.api
Create backend/api/file.api:
syntax = "v1"
// ========== 文件管理类型定义 ==========
type (
// 文件信息
FileInfo {
Id int64 `json:"id"`
Name string `json:"name"`
Key string `json:"key"`
Size int64 `json:"size"`
MimeType string `json:"mimeType"`
Category string `json:"category"`
IsPublic bool `json:"isPublic"`
UserId int64 `json:"userId"`
StorageType string `json:"storageType"`
Url string `json:"url"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// 文件列表请求
FileListRequest {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Keyword string `form:"keyword,optional"`
Category string `form:"category,optional"`
MimeType string `form:"mimeType,optional"`
}
// 文件列表响应
FileListResponse {
Total int64 `json:"total"`
List []FileInfo `json:"list"`
}
// 获取文件请求
GetFileRequest {
Id int64 `path:"id"`
}
// 更新文件请求
UpdateFileRequest {
Id int64 `path:"id"`
Name string `json:"name,optional"`
Category string `json:"category,optional"`
IsPublic *bool `json:"isPublic,optional"`
}
// 删除文件请求
DeleteFileRequest {
Id int64 `path:"id"`
}
// 文件 URL 响应
FileUrlResponse {
Url string `json:"url"`
}
)
Step 2: Add import to base.api
In backend/base.api, add after import "api/dashboard.api":
import "api/file.api"
And add the file server block at the end of base.api (before the final empty line), after the dashboard block:
@server (
prefix: /api/v1
group: file
middleware: Cors,Log,Auth,Authz
)
service base-api {
@doc "上传文件"
@handler uploadFile
post /file/upload returns (FileInfo)
@doc "获取文件列表"
@handler getFileList
get /files (FileListRequest) returns (FileListResponse)
@doc "获取文件详情"
@handler getFile
get /file/:id (GetFileRequest) returns (FileInfo)
@doc "获取文件访问URL"
@handler getFileUrl
get /file/:id/url (GetFileRequest) returns (FileUrlResponse)
@doc "更新文件信息"
@handler updateFile
put /file/:id (UpdateFileRequest) returns (FileInfo)
@doc "删除文件"
@handler deleteFile
delete /file/:id (DeleteFileRequest) returns (Response)
}
Step 3: Run goctl code generation
Run: cd D:\APPS\base\backend && goctl api go -api base.api -dir .
Expected: Generates/updates:
internal/types/types.go— new file types addedinternal/handler/routes.go— new file routes addedinternal/handler/file/— 6 new handler stubs createdinternal/logic/file/— 6 new logic stubs created
Step 4: Verify it compiles
Run: cd D:\APPS\base\backend && go build ./...
Expected: Build succeeds (logic stubs return nil).
Step 5: Commit
cd D:\APPS\base\backend
git add api/file.api base.api internal/types/types.go internal/handler/ internal/logic/file/
git commit -m "feat: add file API definition and generate handlers/logic stubs"
Task 8: ServiceContext — integrate Storage + File migration + Casbin policies
Files:
- Modify:
backend/internal/svc/servicecontext.go
Step 1: Add Storage to ServiceContext and update initialization
In servicecontext.go, make these changes:
- Add import for
"github.com/youruser/base/internal/storage" - Add
Storage storage.Storagefield toServiceContextstruct - Add
&model.File{}toAutoMigratecall - Initialize storage with
storage.NewStorage(c.Storage) - Add file policies to
seedCasbinPolicies
The ServiceContext struct becomes:
type ServiceContext struct {
Config config.Config
Cors rest.Middleware
Log rest.Middleware
Auth rest.Middleware
Authz rest.Middleware
DB *gorm.DB
Enforcer *casbin.Enforcer
Storage storage.Storage
}
In NewServiceContext, after the DB connection and before Casbin init, add:
// 自动迁移表
err = db.AutoMigrate(&model.User{}, &model.Profile{}, &model.File{})
After seedCasbinPolicies(enforcer), add storage initialization:
// 初始化存储
store, err := storage.NewStorage(c.Storage)
if err != nil {
panic("Failed to initialize storage: " + err.Error())
}
log.Printf("[Storage] Initialized with type: %s", c.Storage.Type)
And set Storage: store in the return struct.
Add file policies to seedCasbinPolicies:
// user: 文件管理
{"user", "/api/v1/file/upload", "POST"},
{"user", "/api/v1/files", "GET"},
{"user", "/api/v1/file/:id", "GET"},
{"user", "/api/v1/file/:id/url", "GET"},
{"user", "/api/v1/file/:id", "PUT"},
// super_admin: 文件删除
{"super_admin", "/api/v1/file/:id", "DELETE"},
Step 2: Verify it compiles
Run: cd D:\APPS\base\backend && go build ./...
Expected: Build succeeds.
Step 3: Commit
cd D:\APPS\base\backend
git add internal/svc/servicecontext.go
git commit -m "feat: integrate Storage into ServiceContext, add File migration and Casbin policies"
Task 9: Upload file logic (custom handler — multipart)
Files:
- Modify:
backend/internal/handler/file/uploadfilehandler.go - Modify:
backend/internal/logic/file/uploadfilelogic.go
Step 1: Rewrite the upload handler for multipart form
The goctl-generated handler uses httpx.Parse for JSON, but upload needs multipart/form-data. Replace the generated uploadfilehandler.go with:
package file
import (
"net/http"
"github.com/youruser/base/internal/logic/file"
"github.com/youruser/base/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UploadFileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse multipart form with max size from config
maxSize := svcCtx.Config.Storage.MaxSize
if maxSize <= 0 {
maxSize = 100 << 20 // 100MB default
}
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := file.NewUploadFileLogic(r.Context(), svcCtx)
resp, err := l.UploadFile(r)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
Step 2: Implement the upload logic
Replace the generated uploadfilelogic.go with:
package file
import (
"context"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type UploadFileLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUploadFileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadFileLogic {
return &UploadFileLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UploadFileLogic) UploadFile(r *http.Request) (resp *types.FileInfo, err error) {
// Get user info from context
userId, _ := l.ctx.Value("userId").(int64)
if userId == 0 {
// Try json.Number format
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok {
userId, _ = num.Int64()
}
}
// Get the uploaded file
file, header, err := r.FormFile("file")
if err != nil {
return nil, fmt.Errorf("获取上传文件失败: %w", err)
}
defer file.Close()
// Get optional form fields
category := r.FormValue("category")
if category == "" {
category = "default"
}
isPublicStr := r.FormValue("isPublic")
isPublic := isPublicStr == "true" || isPublicStr == "1"
// Generate storage key: {category}/{YYYY-MM}/{uuid}{ext}
ext := strings.ToLower(filepath.Ext(header.Filename))
now := time.Now()
key := fmt.Sprintf("%s/%s/%s%s", category, now.Format("2006-01"), uuid.New().String(), ext)
// Detect MIME type
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
// Upload to storage backend
if err := l.svcCtx.Storage.Upload(l.ctx, key, file, header.Size, contentType); err != nil {
return nil, fmt.Errorf("上传文件失败: %w", err)
}
// Save file record to database
fileRecord := &model.File{
Name: header.Filename,
Key: key,
Size: header.Size,
MimeType: contentType,
Category: category,
IsPublic: isPublic,
UserId: userId,
StorageType: l.svcCtx.Config.Storage.Type,
Status: 1,
}
id, err := model.FileInsert(l.ctx, l.svcCtx.DB, fileRecord)
if err != nil {
// Try to clean up the uploaded file
l.svcCtx.Storage.Delete(l.ctx, key)
return nil, fmt.Errorf("保存文件记录失败: %w", err)
}
// Get URL
url, _ := l.svcCtx.Storage.GetURL(l.ctx, key)
// For local storage, use the API endpoint
if l.svcCtx.Config.Storage.Type == "local" {
url = fmt.Sprintf("/api/v1/file/%d/url", id)
}
l.Infof("文件上传成功,fileId=%d, name=%s, size=%d", id, header.Filename, header.Size)
return &types.FileInfo{
Id: id,
Name: header.Filename,
Key: key,
Size: header.Size,
MimeType: contentType,
Category: category,
IsPublic: isPublic,
UserId: userId,
StorageType: l.svcCtx.Config.Storage.Type,
Url: url,
CreatedAt: fileRecord.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: fileRecord.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}
Step 3: Verify it compiles
Run: cd D:\APPS\base\backend && go build ./...
Expected: Build succeeds.
Step 4: Commit
cd D:\APPS\base\backend
git add internal/handler/file/uploadfilehandler.go internal/logic/file/uploadfilelogic.go
git commit -m "feat: implement file upload handler and logic (multipart)"
Task 10: Implement remaining file logic (list, get, getUrl, update, delete)
Files:
- Modify:
backend/internal/logic/file/getfilelistlogic.go - Modify:
backend/internal/logic/file/getfilelogic.go - Modify:
backend/internal/logic/file/getfileurllogic.go - Modify:
backend/internal/logic/file/updatefilelogic.go - Modify:
backend/internal/logic/file/deletefilelogic.go
Step 1: Implement GetFileList logic
Replace the body of GetFileList method in getfilelistlogic.go:
func (l *GetFileListLogic) GetFileList(req *types.FileListRequest) (resp *types.FileListResponse, err error) {
userId, _ := l.ctx.Value("userId").(int64)
if userId == 0 {
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok {
userId, _ = num.Int64()
}
}
userRole, _ := l.ctx.Value("role").(string)
files, total, err := model.FileFindList(l.ctx, l.svcCtx.DB,
int64(req.Page), int64(req.PageSize),
req.Keyword, req.Category, req.MimeType,
userId, userRole)
if err != nil {
return nil, fmt.Errorf("查询文件列表失败: %v", err)
}
list := make([]types.FileInfo, 0, len(files))
for _, f := range files {
url, _ := l.svcCtx.Storage.GetURL(l.ctx, f.Key)
if f.StorageType == "local" {
url = fmt.Sprintf("/api/v1/file/%d/url", f.Id)
}
list = append(list, types.FileInfo{
Id: f.Id,
Name: f.Name,
Key: f.Key,
Size: f.Size,
MimeType: f.MimeType,
Category: f.Category,
IsPublic: f.IsPublic,
UserId: f.UserId,
StorageType: f.StorageType,
Url: url,
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: f.UpdatedAt.Format("2006-01-02 15:04:05"),
})
}
return &types.FileListResponse{
Total: total,
List: list,
}, nil
}
Imports needed: "fmt", "github.com/youruser/base/model".
Step 2: Implement GetFile logic
Replace the body of GetFile method in getfilelogic.go:
func (l *GetFileLogic) GetFile(req *types.GetFileRequest) (resp *types.FileInfo, err error) {
f, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
if err == model.ErrNotFound {
return nil, fmt.Errorf("文件不存在")
}
return nil, fmt.Errorf("查询文件失败: %v", err)
}
// Permission check: non-admin can only see own files + public files
userId, _ := l.ctx.Value("userId").(int64)
if userId == 0 {
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok {
userId, _ = num.Int64()
}
}
userRole, _ := l.ctx.Value("role").(string)
if userRole != model.RoleAdmin && userRole != model.RoleSuperAdmin {
if f.UserId != userId && !f.IsPublic {
return nil, fmt.Errorf("无权访问该文件")
}
}
url, _ := l.svcCtx.Storage.GetURL(l.ctx, f.Key)
if f.StorageType == "local" {
url = fmt.Sprintf("/api/v1/file/%d/url", f.Id)
}
return &types.FileInfo{
Id: f.Id,
Name: f.Name,
Key: f.Key,
Size: f.Size,
MimeType: f.MimeType,
Category: f.Category,
IsPublic: f.IsPublic,
UserId: f.UserId,
StorageType: f.StorageType,
Url: url,
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: f.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}
Step 3: Implement GetFileUrl logic
Replace the body of GetFileUrl method in getfileurllogic.go:
func (l *GetFileUrlLogic) GetFileUrl(req *types.GetFileRequest) (resp *types.FileUrlResponse, err error) {
f, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
if err == model.ErrNotFound {
return nil, fmt.Errorf("文件不存在")
}
return nil, fmt.Errorf("查询文件失败: %v", err)
}
// Permission check
userId, _ := l.ctx.Value("userId").(int64)
if userId == 0 {
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok {
userId, _ = num.Int64()
}
}
userRole, _ := l.ctx.Value("role").(string)
if userRole != model.RoleAdmin && userRole != model.RoleSuperAdmin {
if f.UserId != userId && !f.IsPublic {
return nil, fmt.Errorf("无权访问该文件")
}
}
url, err := l.svcCtx.Storage.GetURL(l.ctx, f.Key)
if err != nil {
return nil, fmt.Errorf("获取文件URL失败: %v", err)
}
// For local storage, serve the file directly via redirect or inline
if f.StorageType == "local" {
// Return a direct download URL pattern; the handler will serve the file
url = "local://" + f.Key
}
return &types.FileUrlResponse{
Url: url,
}, nil
}
Note: For local storage, the handler layer (getfileurlhandler.go) should be modified to serve the file directly instead of returning JSON. We'll handle this in the next step.
Step 4: Implement UpdateFile logic
Replace the body of UpdateFile method in updatefilelogic.go:
func (l *UpdateFileLogic) UpdateFile(req *types.UpdateFileRequest) (resp *types.FileInfo, err error) {
f, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
if err == model.ErrNotFound {
return nil, fmt.Errorf("文件不存在")
}
return nil, fmt.Errorf("查询文件失败: %v", err)
}
// Permission check: user can only edit own files
userId, _ := l.ctx.Value("userId").(int64)
if userId == 0 {
if num, ok := l.ctx.Value("userId").(interface{ Int64() (int64, error) }); ok {
userId, _ = num.Int64()
}
}
userRole, _ := l.ctx.Value("role").(string)
if userRole != model.RoleAdmin && userRole != model.RoleSuperAdmin {
if f.UserId != userId {
return nil, fmt.Errorf("无权编辑该文件")
}
}
// Apply updates
if req.Name != "" {
f.Name = req.Name
}
if req.Category != "" {
f.Category = req.Category
}
if req.IsPublic != nil {
f.IsPublic = *req.IsPublic
}
if err := model.FileUpdate(l.ctx, l.svcCtx.DB, f); err != nil {
return nil, fmt.Errorf("更新文件失败: %v", err)
}
url, _ := l.svcCtx.Storage.GetURL(l.ctx, f.Key)
if f.StorageType == "local" {
url = fmt.Sprintf("/api/v1/file/%d/url", f.Id)
}
return &types.FileInfo{
Id: f.Id,
Name: f.Name,
Key: f.Key,
Size: f.Size,
MimeType: f.MimeType,
Category: f.Category,
IsPublic: f.IsPublic,
UserId: f.UserId,
StorageType: f.StorageType,
Url: url,
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: f.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}
Step 5: Implement DeleteFile logic
Replace the body of DeleteFile method in deletefilelogic.go:
func (l *DeleteFileLogic) DeleteFile(req *types.DeleteFileRequest) (resp *types.Response, err error) {
f, err := model.FileFindOne(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
if err == model.ErrNotFound {
return nil, fmt.Errorf("文件不存在")
}
return nil, fmt.Errorf("查询文件失败: %v", err)
}
// Soft delete from database
if err := model.FileDelete(l.ctx, l.svcCtx.DB, req.Id); err != nil {
return nil, fmt.Errorf("删除文件失败: %v", err)
}
// Delete from storage backend
if delErr := l.svcCtx.Storage.Delete(l.ctx, f.Key); delErr != nil {
l.Errorf("删除存储文件失败: %v (file id=%d, key=%s)", delErr, f.Id, f.Key)
// Don't return error — DB record is already soft-deleted
}
l.Infof("文件删除成功,fileId=%d, name=%s", f.Id, f.Name)
return &types.Response{
Code: 200,
Message: "删除成功",
Success: true,
}, nil
}
Step 6: Modify GetFileUrl handler to serve local files
Replace getfileurlhandler.go to handle local file serving:
package file
import (
"net/http"
"strings"
"github.com/youruser/base/internal/logic/file"
"github.com/youruser/base/internal/storage"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetFileUrlHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GetFileRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := file.NewGetFileUrlLogic(r.Context(), svcCtx)
resp, err := l.GetFileUrl(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
// For local storage, serve the file directly
if strings.HasPrefix(resp.Url, "local://") {
key := strings.TrimPrefix(resp.Url, "local://")
if localStorage, ok := svcCtx.Storage.(*storage.LocalStorage); ok {
filePath := localStorage.GetFilePath(key)
http.ServeFile(w, r, filePath)
return
}
}
// For OSS/MinIO, return the signed URL as JSON
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
Step 7: Verify it compiles
Run: cd D:\APPS\base\backend && go build ./...
Expected: Build succeeds.
Step 8: Commit
cd D:\APPS\base\backend
git add internal/logic/file/ internal/handler/file/
git commit -m "feat: implement file CRUD logic (list, get, getUrl, update, delete)"
Task 11: Backend integration test with curl
Prerequisites: Backend running via air on port 8888.
Step 1: Start backend if not running
Run: cd D:\APPS\base\backend && air
Step 2: Login as admin to get token
curl -s -X POST http://localhost:8888/api/v1/login -H "Content-Type: application/json" -d '{"account":"admin","password":"admin123"}' | jq .token -r
Save the token as TOKEN.
Step 3: Upload a test file
curl -s -X POST http://localhost:8888/api/v1/file/upload -H "Authorization: Bearer $TOKEN" -F "file=@D:\APPS\base\backend\etc\base-api.yaml" -F "category=test" -F "isPublic=true" | jq .
Expected: Returns FileInfo with id, name, key, mimeType, etc.
Step 4: List files
curl -s "http://localhost:8888/api/v1/files?page=1&pageSize=10" -H "Authorization: Bearer $TOKEN" | jq .
Expected: Returns list with the uploaded file.
Step 5: Get file detail
curl -s "http://localhost:8888/api/v1/file/1" -H "Authorization: Bearer $TOKEN" | jq .
Step 6: Get file URL
curl -s "http://localhost:8888/api/v1/file/1/url" -H "Authorization: Bearer $TOKEN"
Expected: For local storage, serves the file content directly.
Step 7: Update file
curl -s -X PUT "http://localhost:8888/api/v1/file/1" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"name":"renamed.yaml","category":"docs"}' | jq .
Step 8: Delete file
curl -s -X DELETE "http://localhost:8888/api/v1/file/1" -H "Authorization: Bearer $TOKEN" | jq .
Expected: Returns {"code":200,"message":"删除成功","success":true}.
Task 12: Frontend types — add File types
Files:
- Modify:
frontend/react-shadcn/pc/src/types/index.ts
Step 1: Add file-related types
Append to the end of frontend/react-shadcn/pc/src/types/index.ts:
// File Types
export interface FileInfo {
id: number
name: string
key: string
size: number
mimeType: string
category: string
isPublic: boolean
userId: number
storageType: string
url: string
createdAt: string
updatedAt: string
}
export interface FileListRequest {
page?: number
pageSize?: number
keyword?: string
category?: string
mimeType?: string
}
export interface FileListResponse {
code: number
message: string
success: boolean
data: {
list: FileInfo[]
total: number
}
}
export interface UpdateFileRequest {
name?: string
category?: string
isPublic?: boolean
}
Step 2: Commit
cd D:\APPS\base\frontend\react-shadcn\pc
git add src/types/index.ts
git commit -m "feat: add FileInfo and file-related types"
Task 13: Frontend API client — add file methods
Files:
- Modify:
frontend/react-shadcn/pc/src/services/api.ts
Step 1: Add import for new types
In api.ts, update the import to include the new types:
import type {
// ... existing imports ...
FileInfo,
FileListRequest,
FileListResponse,
UpdateFileRequest,
} from '@/types'
Step 2: Add file API methods to ApiClient class
Add these methods inside the ApiClient class, before the healthCheck method:
// File Management
async uploadFile(file: File, category?: string, isPublic?: boolean): Promise<ApiResponse<FileInfo>> {
const url = `${API_BASE_URL}/file/upload`
const formData = new FormData()
formData.append('file', file)
if (category) formData.append('category', category)
if (isPublic !== undefined) formData.append('isPublic', isPublic ? 'true' : 'false')
const headers: Record<string, string> = {}
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`
}
// Note: Do NOT set Content-Type header — browser sets it with boundary for FormData
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || 'Upload failed')
}
// Wrap raw response into standard format if needed
if ('id' in data) {
return { code: 200, message: 'success', success: true, data }
}
return data
}
async getFiles(params: FileListRequest = {}): Promise<FileListResponse> {
const queryParams = new URLSearchParams()
if (params.page) queryParams.append('page', params.page.toString())
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
if (params.keyword) queryParams.append('keyword', params.keyword)
if (params.category) queryParams.append('category', params.category)
if (params.mimeType) queryParams.append('mimeType', params.mimeType)
const rawData = await this.request<{ total: number; list: FileInfo[] }>(`/files?${queryParams}`)
if ('success' in rawData) {
return rawData as FileListResponse
}
return {
code: 200,
message: 'success',
success: true,
data: { list: rawData.list || [], total: rawData.total || 0 },
}
}
async getFile(id: number): Promise<ApiResponse<FileInfo>> {
const rawData = await this.request<FileInfo>(`/file/${id}`)
if ('success' in rawData) return rawData as ApiResponse<FileInfo>
return { code: 200, message: 'success', success: true, data: rawData }
}
async getFileUrl(id: number): Promise<string> {
// For local storage, the URL endpoint serves the file directly
// For OSS/MinIO, it returns a JSON with the signed URL
const url = `${API_BASE_URL}/file/${id}/url`
const headers: Record<string, string> = {}
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`
}
const response = await fetch(url, { headers })
const contentType = response.headers.get('Content-Type') || ''
if (contentType.includes('application/json')) {
const data = await response.json()
return data.url
}
// For local storage, the endpoint serves the file — return the URL itself
return url
}
async updateFile(id: number, data: UpdateFileRequest): Promise<ApiResponse<FileInfo>> {
const rawData = await this.request<FileInfo>(`/file/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
if ('success' in rawData) return rawData as ApiResponse<FileInfo>
return { code: 200, message: 'success', success: true, data: rawData }
}
async deleteFile(id: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/file/${id}`, {
method: 'DELETE',
})
}
Step 3: Commit
cd D:\APPS\base\frontend\react-shadcn\pc
git add src/services/api.ts
git commit -m "feat: add file CRUD methods to API client"
Task 14: Frontend — File Management Page
Files:
- Create:
frontend/react-shadcn/pc/src/pages/FileManagementPage.tsx
Step 1: Create the file management page
Create frontend/react-shadcn/pc/src/pages/FileManagementPage.tsx. This page should follow the same patterns as UserManagementPage.tsx:
import { useState, useEffect, useRef, useCallback } from 'react'
import { Upload, Search, Eye, Edit2, Trash2, Download, X, File as FileIcon, Image, Film, FileText } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'
import { Modal } from '@/components/ui/Modal'
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from '@/components/ui/Table'
import type { FileInfo, UpdateFileRequest } from '@/types'
import { apiClient } from '@/services/api'
import { useAuth } from '@/contexts/AuthContext'
const CATEGORY_OPTIONS = [
{ value: '', label: '全部分类' },
{ value: 'default', label: '默认' },
{ value: 'avatar', label: '头像' },
{ value: 'document', label: '文档' },
{ value: 'media', label: '媒体' },
]
const MIME_FILTER_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
{ value: 'application/pdf', label: 'PDF' },
]
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) return <Image className="h-4 w-4 text-green-400" />
if (mimeType.startsWith('video/')) return <Film className="h-4 w-4 text-purple-400" />
if (mimeType === 'application/pdf') return <FileText className="h-4 w-4 text-red-400" />
return <FileIcon className="h-4 w-4 text-gray-400" />
}
export function FileManagementPage() {
const { user: currentUser } = useAuth()
const [files, setFiles] = useState<FileInfo[]>([])
const [total, setTotal] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [mimeFilter, setMimeFilter] = useState('')
const [page, setPage] = useState(1)
const [error, setError] = useState<string | null>(null)
// Upload state
const [isUploading, setIsUploading] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [uploadCategory, setUploadCategory] = useState('default')
const [uploadIsPublic, setUploadIsPublic] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Edit state
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingFile, setEditingFile] = useState<FileInfo | null>(null)
const [editForm, setEditForm] = useState<UpdateFileRequest>({})
// Preview state
const [previewFile, setPreviewFile] = useState<FileInfo | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
// Delete state
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [fileToDelete, setFileToDelete] = useState<FileInfo | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'super_admin'
const isSuperAdmin = currentUser?.role === 'super_admin'
const fetchFiles = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const response = await apiClient.getFiles({
page,
pageSize: 20,
keyword: searchQuery,
category: categoryFilter,
mimeType: mimeFilter,
})
if (response.success && response.data) {
setFiles(response.data.list)
setTotal(response.data.total)
} else {
setFiles([])
setTotal(0)
}
} catch (err) {
console.error('Failed to fetch files:', err)
setError('获取文件列表失败')
setFiles([])
} finally {
setIsLoading(false)
}
}, [page, searchQuery, categoryFilter, mimeFilter])
useEffect(() => {
fetchFiles()
}, [fetchFiles])
// Upload handlers
const handleUpload = async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return
setIsUploading(true)
try {
for (const file of Array.from(fileList)) {
await apiClient.uploadFile(file, uploadCategory, uploadIsPublic)
}
await fetchFiles()
} catch (err) {
console.error('Upload failed:', err)
alert('上传失败')
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
handleUpload(e.dataTransfer.files)
}
// Preview handler
const handlePreview = async (file: FileInfo) => {
setPreviewFile(file)
try {
const url = await apiClient.getFileUrl(file.id)
setPreviewUrl(url)
} catch {
setPreviewUrl(null)
}
}
// Edit handlers
const openEditModal = (file: FileInfo) => {
setEditingFile(file)
setEditForm({ name: file.name, category: file.category, isPublic: file.isPublic })
setEditModalOpen(true)
}
const handleUpdate = async () => {
if (!editingFile) return
try {
await apiClient.updateFile(editingFile.id, editForm)
setEditModalOpen(false)
await fetchFiles()
} catch {
alert('更新失败')
}
}
// Delete handlers
const handleDelete = async () => {
if (!fileToDelete) return
try {
setIsDeleting(true)
await apiClient.deleteFile(fileToDelete.id)
setDeleteConfirmOpen(false)
setFileToDelete(null)
await fetchFiles()
} catch {
alert('删除失败')
} finally {
setIsDeleting(false)
}
}
return (
<div className="space-y-6 animate-fade-in">
{/* Upload Area */}
<Card>
<CardContent className="p-6">
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging ? 'border-sky-500 bg-sky-500/10' : 'border-gray-700 hover:border-gray-500'
}`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<Upload className="h-8 w-8 mx-auto mb-3 text-gray-500" />
<p className="text-gray-400 mb-3">
{isUploading ? '上传中...' : '拖拽文件到此处,或点击选择文件'}
</p>
<div className="flex items-center justify-center gap-4 mb-3">
<select
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white"
value={uploadCategory}
onChange={(e) => setUploadCategory(e.target.value)}
>
{CATEGORY_OPTIONS.filter(o => o.value).map(opt => (
<option key={opt.value} value={opt.value} className="bg-gray-800">{opt.label}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-gray-400">
<input
type="checkbox"
checked={uploadIsPublic}
onChange={(e) => setUploadIsPublic(e.target.checked)}
className="rounded"
/>
公开
</label>
</div>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
<Upload className="h-4 w-4" />
{isUploading ? '上传中...' : '选择文件'}
</Button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleUpload(e.target.files)}
/>
</div>
</CardContent>
</Card>
{/* Filters */}
<Card>
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="flex-1 w-full sm:max-w-md">
<Input
placeholder="搜索文件名..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setPage(1) }}
leftIcon={<Search className="h-4 w-4" />}
/>
</div>
<select
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white"
value={categoryFilter}
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1) }}
>
{CATEGORY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value} className="bg-gray-800">{opt.label}</option>
))}
</select>
<select
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white"
value={mimeFilter}
onChange={(e) => { setMimeFilter(e.target.value); setPage(1) }}
>
{MIME_FILTER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value} className="bg-gray-800">{opt.label}</option>
))}
</select>
</div>
</CardContent>
</Card>
{/* Error */}
{error && (
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center">
<span>{error}</span>
<button onClick={fetchFiles} className="underline hover:text-red-300">重试</button>
</div>
)}
{/* File Table */}
<Card>
<CardHeader>
<CardTitle>文件列表 ({total})</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>文件名</TableHead>
<TableHead>分类</TableHead>
<TableHead>大小</TableHead>
<TableHead>类型</TableHead>
<TableHead>公开</TableHead>
<TableHead>上传时间</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow><TableCell>加载中...</TableCell></TableRow>
) : files.length === 0 ? (
<TableRow><TableCell>暂无文件</TableCell></TableRow>
) : (
files.map((file) => (
<TableRow key={file.id}>
<TableCell>
<div className="flex items-center gap-2">
{getFileIcon(file.mimeType)}
<span className="font-medium text-white truncate max-w-[200px]">{file.name}</span>
</div>
</TableCell>
<TableCell>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/20 text-blue-400 border border-blue-500/30">
{file.category}
</span>
</TableCell>
<TableCell className="text-gray-400">{formatFileSize(file.size)}</TableCell>
<TableCell className="text-gray-400 text-xs">{file.mimeType}</TableCell>
<TableCell>
<span className={`text-xs ${file.isPublic ? 'text-green-400' : 'text-gray-500'}`}>
{file.isPublic ? '公开' : '私有'}
</span>
</TableCell>
<TableCell className="text-gray-400">
{new Date(file.createdAt).toLocaleDateString('zh-CN')}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handlePreview(file)} title="预览">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEditModal(file)} title="编辑">
<Edit2 className="h-4 w-4" />
</Button>
{(isSuperAdmin || isAdmin) && (
<Button
variant="ghost"
size="sm"
onClick={() => { setFileToDelete(file); setDeleteConfirmOpen(true) }}
className="text-red-400 hover:text-red-300"
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{total > 20 && (
<div className="flex justify-center gap-2 mt-4">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
>
上一页
</Button>
<span className="px-3 py-1.5 text-sm text-gray-400">
第 {page} 页 / 共 {Math.ceil(total / 20)} 页
</span>
<Button
variant="outline"
size="sm"
disabled={page >= Math.ceil(total / 20)}
onClick={() => setPage(p => p + 1)}
>
下一页
</Button>
</div>
)}
</CardContent>
</Card>
{/* Preview Modal */}
<Modal
isOpen={!!previewFile}
onClose={() => { setPreviewFile(null); setPreviewUrl(null) }}
title={previewFile?.name || '文件预览'}
size="lg"
>
<div className="py-4">
{previewFile && previewUrl && (
<>
{previewFile.mimeType.startsWith('image/') && (
<img src={previewUrl} alt={previewFile.name} className="max-w-full max-h-[60vh] mx-auto rounded" />
)}
{previewFile.mimeType.startsWith('video/') && (
<video src={previewUrl} controls className="max-w-full max-h-[60vh] mx-auto rounded">
您的浏览器不支持视频播放
</video>
)}
{previewFile.mimeType === 'application/pdf' && (
<iframe src={previewUrl} className="w-full h-[60vh] rounded" title={previewFile.name} />
)}
{!previewFile.mimeType.startsWith('image/') &&
!previewFile.mimeType.startsWith('video/') &&
previewFile.mimeType !== 'application/pdf' && (
<div className="text-center py-8">
<FileIcon className="h-16 w-16 mx-auto mb-4 text-gray-500" />
<p className="text-gray-400 mb-2">{previewFile.name}</p>
<p className="text-sm text-gray-500 mb-4">{formatFileSize(previewFile.size)} · {previewFile.mimeType}</p>
<a
href={previewUrl}
download={previewFile.name}
className="inline-flex items-center gap-2 px-4 py-2 bg-sky-500 text-white rounded-lg hover:bg-sky-600 transition"
>
<Download className="h-4 w-4" />
下载文件
</a>
</div>
)}
</>
)}
{previewFile && !previewUrl && (
<p className="text-center text-gray-500 py-8">加载预览中...</p>
)}
</div>
</Modal>
{/* Edit Modal */}
<Modal
isOpen={editModalOpen}
onClose={() => setEditModalOpen(false)}
title="编辑文件信息"
size="md"
footer={
<>
<Button variant="outline" onClick={() => setEditModalOpen(false)}>取消</Button>
<Button onClick={handleUpdate}>保存</Button>
</>
}
>
<div className="space-y-4">
<Input
label="文件名"
value={editForm.name || ''}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">分类</label>
<select
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white"
value={editForm.category || ''}
onChange={(e) => setEditForm({ ...editForm, category: e.target.value })}
>
{CATEGORY_OPTIONS.filter(o => o.value).map(opt => (
<option key={opt.value} value={opt.value} className="bg-gray-800">{opt.label}</option>
))}
</select>
</div>
<label className="flex items-center gap-2 text-sm text-gray-300">
<input
type="checkbox"
checked={editForm.isPublic || false}
onChange={(e) => setEditForm({ ...editForm, isPublic: e.target.checked })}
className="rounded"
/>
公开文件
</label>
</div>
</Modal>
{/* Delete Confirm Modal */}
<Modal
isOpen={deleteConfirmOpen}
onClose={() => { setDeleteConfirmOpen(false); setFileToDelete(null) }}
title="确认删除"
size="sm"
footer={
<>
<Button variant="outline" onClick={() => { setDeleteConfirmOpen(false); setFileToDelete(null) }} disabled={isDeleting}>取消</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? '删除中...' : '确认删除'}
</Button>
</>
}
>
<div className="py-4">
<p className="text-gray-300">
确定要删除文件 <span className="font-medium text-white">{fileToDelete?.name}</span> 吗?
</p>
<p className="text-sm text-gray-500 mt-2">文件将从存储中永久删除,此操作不可恢复。</p>
</div>
</Modal>
</div>
)
}
Step 2: Commit
cd D:\APPS\base\frontend\react-shadcn\pc
git add src/pages/FileManagementPage.tsx
git commit -m "feat: add FileManagementPage with upload, preview, CRUD"
Task 15: Frontend — routing + sidebar integration
Files:
- Modify:
frontend/react-shadcn/pc/src/App.tsx - Modify:
frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx
Step 1: Add route in App.tsx
In App.tsx, add the import:
import { FileManagementPage } from './pages/FileManagementPage'
Add the route inside the protected MainLayout routes, after users:
<Route path="files" element={<FileManagementPage />} />
Step 2: Add sidebar navigation item
In Sidebar.tsx, add the import for the icon:
import { LayoutDashboard, Users, LogOut, Settings, FolderOpen } from 'lucide-react'
Add to the navItems array, between Users and Settings:
{ path: '/files', icon: FolderOpen, label: '文件管理' },
Step 3: Verify frontend compiles
Run: cd D:\APPS\base\frontend\react-shadcn\pc && npm run build
Expected: Build succeeds.
Step 4: Commit
cd D:\APPS\base\frontend\react-shadcn\pc
git add src/App.tsx src/components/layout/Sidebar.tsx
git commit -m "feat: add /files route and sidebar navigation for file management"
Task 16: E2E verification with Playwright
Prerequisites: Backend running on :8888, frontend running on :5173.
Step 1: Login as admin
Navigate to http://localhost:5173/login, log in with admin / admin123.
Step 2: Verify sidebar shows file management
Check sidebar has a "文件管理" link.
Step 3: Navigate to /files
Click "文件管理" in sidebar. Verify the page loads with upload area and empty file table.
Step 4: Upload a file
Click "选择文件" and upload a test image. Verify it appears in the table with correct name, category, size, and type.
Step 5: Preview the file
Click the eye icon on the uploaded file. Verify the preview modal shows the image.
Step 6: Edit the file
Click the edit icon. Change the name and category. Save. Verify the changes appear in the table.
Step 7: Delete the file
Click the trash icon. Confirm deletion. Verify the file is removed from the table.
Summary
| Task | Description | Files |
|---|---|---|
| 1 | Add Go dependencies | go.mod, go.sum |
| 2 | Config — StorageConfig | config.go, base-api.yaml |
| 3 | Storage interface + Local impl | storage/storage.go, storage/local.go |
| 4 | OSS implementation | storage/oss.go |
| 5 | MinIO implementation | storage/minio.go |
| 6 | File data model | model/file_entity.go, model/file_model.go |
| 7 | API definition + goctl gen | api/file.api, base.api, types.go, routes.go |
| 8 | ServiceContext integration | svc/servicecontext.go |
| 9 | Upload handler + logic | handler/file/upload*, logic/file/upload* |
| 10 | Remaining file logic (5 endpoints) | logic/file/.go, handler/file/getfileurl |
| 11 | Backend curl integration test | — |
| 12 | Frontend types | types/index.ts |
| 13 | Frontend API client | services/api.ts |
| 14 | File Management Page | pages/FileManagementPage.tsx |
| 15 | Routing + sidebar | App.tsx, Sidebar.tsx |
| 16 | E2E verification | — |