🐛 添加被忽略的必要文件
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -71,14 +71,12 @@ logs
|
|||||||
log
|
log
|
||||||
uploads
|
uploads
|
||||||
upload
|
upload
|
||||||
|
!/server/utils/upload/
|
||||||
|
!/server/utils/upload/**
|
||||||
|
!/web-admin/src/features/logs/
|
||||||
|
!/web-admin/src/features/logs/**
|
||||||
server/config.yaml
|
server/config.yaml
|
||||||
dist
|
dist
|
||||||
|
|
||||||
# --- Required runtime dirs (keep dir, ignore contents) ---
|
|
||||||
/server/utils/upload/**
|
|
||||||
!/server/utils/upload/.gitkeep
|
|
||||||
/web-admin/src/features/logs/**
|
|
||||||
!/web-admin/src/features/logs/.gitkeep
|
|
||||||
# ---> AI
|
# ---> AI
|
||||||
.claude
|
.claude
|
||||||
.codex
|
.codex
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
71
server/utils/upload/aliyun_oss.go
Normal file
71
server/utils/upload/aliyun_oss.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"git.echol.cn/loser/ai_wanjia/global"
|
||||||
|
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"mime/multipart"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AliyunOSS struct{}
|
||||||
|
|
||||||
|
func (*AliyunOSS) UploadFile(file *multipart.FileHeader, ctx UploadContext) (string, string, error) {
|
||||||
|
bucket, err := NewBucket()
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("function AliyunOSS.NewBucket() Failed", zap.Any("err", err.Error()))
|
||||||
|
return "", "", errors.New("function AliyunOSS.NewBucket() Failed, err:" + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
f, openError := file.Open()
|
||||||
|
if openError != nil {
|
||||||
|
global.GVA_LOG.Error("function file.Open() Failed", zap.Any("err", openError.Error()))
|
||||||
|
return "", "", errors.New("function file.Open() Failed, err:" + openError.Error())
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
subDir := ctx.SubDir()
|
||||||
|
yunFileTmpPath := global.GVA_CONFIG.AliyunOSS.BasePath + "/" + subDir + "/" + file.Filename
|
||||||
|
|
||||||
|
err = bucket.PutObject(yunFileTmpPath, f)
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("function formUploader.Put() Failed", zap.Any("err", err.Error()))
|
||||||
|
return "", "", errors.New("function formUploader.Put() Failed, err:" + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return global.GVA_CONFIG.AliyunOSS.BucketUrl + "/" + yunFileTmpPath, yunFileTmpPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AliyunOSS) DeleteFile(key string) error {
|
||||||
|
bucket, err := NewBucket()
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("function AliyunOSS.NewBucket() Failed", zap.Any("err", err.Error()))
|
||||||
|
return errors.New("function AliyunOSS.NewBucket() Failed, err:" + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除单个文件。objectName表示删除OSS文件时需要指定包含文件后缀在内的完整路径,例如abc/efg/123.jpg。
|
||||||
|
// 如需删除文件夹,请将objectName设置为对应的文件夹名称。如果文件夹非空,则需要将文件夹下的所有object删除后才能删除该文件夹。
|
||||||
|
err = bucket.DeleteObject(key)
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("function bucketManager.Delete() failed", zap.Any("err", err.Error()))
|
||||||
|
return errors.New("function bucketManager.Delete() failed, err:" + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBucket() (*oss.Bucket, error) {
|
||||||
|
// 创建OSSClient实例。
|
||||||
|
client, err := oss.New(global.GVA_CONFIG.AliyunOSS.Endpoint, global.GVA_CONFIG.AliyunOSS.AccessKeyId, global.GVA_CONFIG.AliyunOSS.AccessKeySecret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取存储空间。
|
||||||
|
bucket, err := client.Bucket(global.GVA_CONFIG.AliyunOSS.BucketName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket, nil
|
||||||
|
}
|
||||||
117
server/utils/upload/local.go
Normal file
117
server/utils/upload/local.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.echol.cn/loser/ai_wanjia/global"
|
||||||
|
"git.echol.cn/loser/ai_wanjia/utils"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
type Local struct{}
|
||||||
|
|
||||||
|
//@author: [piexlmax](https://github.com/piexlmax)
|
||||||
|
//@author: [ccfish86](https://github.com/ccfish86)
|
||||||
|
//@author: [SliverHorn](https://github.com/SliverHorn)
|
||||||
|
//@object: *Local
|
||||||
|
//@function: UploadFile
|
||||||
|
//@description: 上传文件,按来源平台和用户ID分目录存储
|
||||||
|
//@param: file *multipart.FileHeader, ctx UploadContext
|
||||||
|
//@return: string, string, error
|
||||||
|
|
||||||
|
func (*Local) UploadFile(file *multipart.FileHeader, ctx UploadContext) (string, string, error) {
|
||||||
|
// 读取文件后缀
|
||||||
|
ext := filepath.Ext(file.Filename)
|
||||||
|
// 读取文件名并加密
|
||||||
|
name := strings.TrimSuffix(file.Filename, ext)
|
||||||
|
name = utils.MD5V([]byte(name))
|
||||||
|
// 拼接新文件名
|
||||||
|
filename := name + "_" + time.Now().Format("20060102150405") + ext
|
||||||
|
|
||||||
|
// 生成按平台+用户+日期分组的子目录
|
||||||
|
subDir := ctx.SubDir()
|
||||||
|
storePath := filepath.Join(global.GVA_CONFIG.Local.StorePath, subDir)
|
||||||
|
|
||||||
|
// 尝试创建目录
|
||||||
|
mkdirErr := os.MkdirAll(storePath, os.ModePerm)
|
||||||
|
if mkdirErr != nil {
|
||||||
|
global.GVA_LOG.Error("function os.MkdirAll() failed", zap.Any("err", mkdirErr.Error()))
|
||||||
|
return "", "", errors.New("function os.MkdirAll() failed, err:" + mkdirErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼接绝对存储路径和访问路径
|
||||||
|
p := filepath.Join(storePath, filename)
|
||||||
|
// key 为相对路径(含子目录),用于后续删除时定位文件
|
||||||
|
key := subDir + "/" + filename
|
||||||
|
// 若配置了域名前缀,则返回完整 URL;否则返回相对路径
|
||||||
|
fileURL := global.GVA_CONFIG.Local.Path + "/" + key
|
||||||
|
if base := strings.TrimRight(global.GVA_CONFIG.Local.BaseURL, "/"); base != "" {
|
||||||
|
fileURL = base + "/" + global.GVA_CONFIG.Local.Path + "/" + key
|
||||||
|
}
|
||||||
|
|
||||||
|
f, openError := file.Open()
|
||||||
|
if openError != nil {
|
||||||
|
global.GVA_LOG.Error("function file.Open() failed", zap.Any("err", openError.Error()))
|
||||||
|
return "", "", errors.New("function file.Open() failed, err:" + openError.Error())
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
out, createErr := os.Create(p)
|
||||||
|
if createErr != nil {
|
||||||
|
global.GVA_LOG.Error("function os.Create() failed", zap.Any("err", createErr.Error()))
|
||||||
|
return "", "", errors.New("function os.Create() failed, err:" + createErr.Error())
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, copyErr := io.Copy(out, f)
|
||||||
|
if copyErr != nil {
|
||||||
|
global.GVA_LOG.Error("function io.Copy() failed", zap.Any("err", copyErr.Error()))
|
||||||
|
return "", "", errors.New("function io.Copy() failed, err:" + copyErr.Error())
|
||||||
|
}
|
||||||
|
return fileURL, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//@author: [piexlmax](https://github.com/piexlmax)
|
||||||
|
//@author: [ccfish86](https://github.com/ccfish86)
|
||||||
|
//@author: [SliverHorn](https://github.com/SliverHorn)
|
||||||
|
//@object: *Local
|
||||||
|
//@function: DeleteFile
|
||||||
|
//@description: 删除文件
|
||||||
|
//@param: key string
|
||||||
|
//@return: error
|
||||||
|
|
||||||
|
func (*Local) DeleteFile(key string) error {
|
||||||
|
if key == "" {
|
||||||
|
return errors.New("key不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止路径遍历:拒绝 .. 及非法字符(/ 允许,用于子目录)
|
||||||
|
if strings.Contains(key, "..") || strings.ContainsAny(key, `\*?"<>|`) {
|
||||||
|
return errors.New("非法的key")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := filepath.Join(global.GVA_CONFIG.Local.StorePath, key)
|
||||||
|
|
||||||
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||||
|
return errors.New("文件不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
err := os.Remove(p)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("文件删除失败: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
107
server/utils/upload/minio_oss.go
Normal file
107
server/utils/upload/minio_oss.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.echol.cn/loser/ai_wanjia/global"
|
||||||
|
"git.echol.cn/loser/ai_wanjia/utils"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var MinioClient *Minio // 优化性能,但是不支持动态配置
|
||||||
|
|
||||||
|
type Minio struct {
|
||||||
|
Client *minio.Client
|
||||||
|
bucket string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMinio(endpoint, accessKeyID, secretAccessKey, bucketName string, useSSL bool) (*Minio, error) {
|
||||||
|
if MinioClient != nil {
|
||||||
|
return MinioClient, nil
|
||||||
|
}
|
||||||
|
// Initialize minio client object.
|
||||||
|
minioClient, err := minio.New(endpoint, &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
|
||||||
|
Secure: useSSL, // Set to true if using https
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 尝试创建bucket
|
||||||
|
err = minioClient.MakeBucket(context.Background(), bucketName, minio.MakeBucketOptions{})
|
||||||
|
if err != nil {
|
||||||
|
// Check to see if we already own this bucket (which happens if you run this twice)
|
||||||
|
exists, errBucketExists := minioClient.BucketExists(context.Background(), bucketName)
|
||||||
|
if errBucketExists == nil && exists {
|
||||||
|
// log.Printf("We already own %s\n", bucketName)
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MinioClient = &Minio{Client: minioClient, bucket: bucketName}
|
||||||
|
return MinioClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Minio) UploadFile(file *multipart.FileHeader, ctx UploadContext) (filePathres, key string, uploadErr error) {
|
||||||
|
f, openError := file.Open()
|
||||||
|
if openError != nil {
|
||||||
|
global.GVA_LOG.Error("function file.Open() Failed", zap.Any("err", openError.Error()))
|
||||||
|
return "", "", errors.New("function file.Open() Failed, err:" + openError.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
filecontent := bytes.Buffer{}
|
||||||
|
_, err := io.Copy(&filecontent, f)
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("读取文件失败", zap.Any("err", err.Error()))
|
||||||
|
return "", "", errors.New("读取文件失败, err:" + err.Error())
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
// 对文件名进行加密存储,加时间戳保证唯一性
|
||||||
|
ext := filepath.Ext(file.Filename)
|
||||||
|
filename := utils.MD5V([]byte(strings.TrimSuffix(file.Filename, ext))) + "_" + time.Now().Format("20060102150405") + ext
|
||||||
|
|
||||||
|
// 生成按平台+用户+日期分组的子目录
|
||||||
|
subDir := ctx.SubDir()
|
||||||
|
basePath := global.GVA_CONFIG.Minio.BasePath
|
||||||
|
if basePath == "" {
|
||||||
|
basePath = "uploads"
|
||||||
|
}
|
||||||
|
key = basePath + "/" + subDir + "/" + filename
|
||||||
|
|
||||||
|
// 根据文件扩展名检测 MIME 类型
|
||||||
|
contentType := mime.TypeByExtension(ext)
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置超时10分钟
|
||||||
|
uploadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Upload the file with PutObject 大文件自动切换为分片上传
|
||||||
|
info, err := m.Client.PutObject(uploadCtx, global.GVA_CONFIG.Minio.BucketName, key, &filecontent, file.Size, minio.PutObjectOptions{ContentType: contentType})
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("上传文件到minio失败", zap.Any("err", err.Error()))
|
||||||
|
return "", "", errors.New("上传文件到minio失败, err:" + err.Error())
|
||||||
|
}
|
||||||
|
return global.GVA_CONFIG.Minio.BucketUrl + "/" + info.Key, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Minio) DeleteFile(key string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := m.Client.RemoveObject(ctx, m.bucket, key, minio.RemoveObjectOptions{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
68
server/utils/upload/obs.go
Normal file
68
server/utils/upload/obs.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime/multipart"
|
||||||
|
|
||||||
|
"git.echol.cn/loser/ai_wanjia/global"
|
||||||
|
"github.com/huaweicloud/huaweicloud-sdk-go-obs/obs"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var HuaWeiObs = new(Obs)
|
||||||
|
|
||||||
|
type Obs struct{}
|
||||||
|
|
||||||
|
func NewHuaWeiObsClient() (client *obs.ObsClient, err error) {
|
||||||
|
return obs.New(global.GVA_CONFIG.HuaWeiObs.AccessKey, global.GVA_CONFIG.HuaWeiObs.SecretKey, global.GVA_CONFIG.HuaWeiObs.Endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Obs) UploadFile(file *multipart.FileHeader, ctx UploadContext) (string, string, error) {
|
||||||
|
open, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
filename := ctx.SubDir() + "/" + file.Filename
|
||||||
|
input := &obs.PutObjectInput{
|
||||||
|
PutObjectBasicInput: obs.PutObjectBasicInput{
|
||||||
|
ObjectOperationInput: obs.ObjectOperationInput{
|
||||||
|
Bucket: global.GVA_CONFIG.HuaWeiObs.Bucket,
|
||||||
|
Key: filename,
|
||||||
|
},
|
||||||
|
HttpHeader: obs.HttpHeader{
|
||||||
|
ContentType: file.Header.Get("content-type"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Body: open,
|
||||||
|
}
|
||||||
|
|
||||||
|
var client *obs.ObsClient
|
||||||
|
client, err = NewHuaWeiObsClient()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", errors.Wrap(err, "获取华为对象存储对象失败!")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.PutObject(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", errors.Wrap(err, "文件上传失败!")
|
||||||
|
}
|
||||||
|
filepath := global.GVA_CONFIG.HuaWeiObs.Path + "/" + filename
|
||||||
|
return filepath, filename, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Obs) DeleteFile(key string) error {
|
||||||
|
client, err := NewHuaWeiObsClient()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "获取华为对象存储对象失败!")
|
||||||
|
}
|
||||||
|
input := &obs.DeleteObjectInput{
|
||||||
|
Bucket: global.GVA_CONFIG.HuaWeiObs.Bucket,
|
||||||
|
Key: key,
|
||||||
|
}
|
||||||
|
var output *obs.DeleteObjectOutput
|
||||||
|
output, err = client.DeleteObject(input)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "删除对象(%s)失败!, output: %v", key, output)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
96
server/utils/upload/qiniu.go
Normal file
96
server/utils/upload/qiniu.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.echol.cn/loser/ai_wanjia/global"
|
||||||
|
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
||||||
|
"github.com/qiniu/go-sdk/v7/storage"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Qiniu struct{}
|
||||||
|
|
||||||
|
//@author: [piexlmax](https://github.com/piexlmax)
|
||||||
|
//@author: [ccfish86](https://github.com/ccfish86)
|
||||||
|
//@author: [SliverHorn](https://github.com/SliverHorn)
|
||||||
|
//@object: *Qiniu
|
||||||
|
//@function: UploadFile
|
||||||
|
//@description: 上传文件
|
||||||
|
//@param: file *multipart.FileHeader
|
||||||
|
//@return: string, string, error
|
||||||
|
|
||||||
|
func (*Qiniu) UploadFile(file *multipart.FileHeader, ctx UploadContext) (string, string, error) {
|
||||||
|
putPolicy := storage.PutPolicy{Scope: global.GVA_CONFIG.Qiniu.Bucket}
|
||||||
|
mac := qbox.NewMac(global.GVA_CONFIG.Qiniu.AccessKey, global.GVA_CONFIG.Qiniu.SecretKey)
|
||||||
|
upToken := putPolicy.UploadToken(mac)
|
||||||
|
cfg := qiniuConfig()
|
||||||
|
formUploader := storage.NewFormUploader(cfg)
|
||||||
|
ret := storage.PutRet{}
|
||||||
|
putExtra := storage.PutExtra{Params: map[string]string{"x:name": "github logo"}}
|
||||||
|
|
||||||
|
f, openError := file.Open()
|
||||||
|
if openError != nil {
|
||||||
|
global.GVA_LOG.Error("function file.Open() failed", zap.Any("err", openError.Error()))
|
||||||
|
|
||||||
|
return "", "", errors.New("function file.Open() failed, err:" + openError.Error())
|
||||||
|
}
|
||||||
|
defer f.Close() // 创建文件 defer 关闭
|
||||||
|
fileKey := ctx.SubDir() + "/" + fmt.Sprintf("%d%s", time.Now().Unix(), file.Filename)
|
||||||
|
putErr := formUploader.Put(context.Background(), &ret, upToken, fileKey, f, file.Size, &putExtra)
|
||||||
|
if putErr != nil {
|
||||||
|
global.GVA_LOG.Error("function formUploader.Put() failed", zap.Any("err", putErr.Error()))
|
||||||
|
return "", "", errors.New("function formUploader.Put() failed, err:" + putErr.Error())
|
||||||
|
}
|
||||||
|
return global.GVA_CONFIG.Qiniu.ImgPath + "/" + ret.Key, ret.Key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//@author: [piexlmax](https://github.com/piexlmax)
|
||||||
|
//@author: [ccfish86](https://github.com/ccfish86)
|
||||||
|
//@author: [SliverHorn](https://github.com/SliverHorn)
|
||||||
|
//@object: *Qiniu
|
||||||
|
//@function: DeleteFile
|
||||||
|
//@description: 删除文件
|
||||||
|
//@param: key string
|
||||||
|
//@return: error
|
||||||
|
|
||||||
|
func (*Qiniu) DeleteFile(key string) error {
|
||||||
|
mac := qbox.NewMac(global.GVA_CONFIG.Qiniu.AccessKey, global.GVA_CONFIG.Qiniu.SecretKey)
|
||||||
|
cfg := qiniuConfig()
|
||||||
|
bucketManager := storage.NewBucketManager(mac, cfg)
|
||||||
|
if err := bucketManager.Delete(global.GVA_CONFIG.Qiniu.Bucket, key); err != nil {
|
||||||
|
global.GVA_LOG.Error("function bucketManager.Delete() failed", zap.Any("err", err.Error()))
|
||||||
|
return errors.New("function bucketManager.Delete() failed, err:" + err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//@author: [SliverHorn](https://github.com/SliverHorn)
|
||||||
|
//@object: *Qiniu
|
||||||
|
//@function: qiniuConfig
|
||||||
|
//@description: 根据配置文件进行返回七牛云的配置
|
||||||
|
//@return: *storage.Config
|
||||||
|
|
||||||
|
func qiniuConfig() *storage.Config {
|
||||||
|
cfg := storage.Config{
|
||||||
|
UseHTTPS: global.GVA_CONFIG.Qiniu.UseHTTPS,
|
||||||
|
UseCdnDomains: global.GVA_CONFIG.Qiniu.UseCdnDomains,
|
||||||
|
}
|
||||||
|
switch global.GVA_CONFIG.Qiniu.Zone { // 根据配置文件进行初始化空间对应的机房
|
||||||
|
case "ZoneHuadong":
|
||||||
|
cfg.Zone = &storage.ZoneHuadong
|
||||||
|
case "ZoneHuabei":
|
||||||
|
cfg.Zone = &storage.ZoneHuabei
|
||||||
|
case "ZoneHuanan":
|
||||||
|
cfg.Zone = &storage.ZoneHuanan
|
||||||
|
case "ZoneBeimei":
|
||||||
|
cfg.Zone = &storage.ZoneBeimei
|
||||||
|
case "ZoneXinjiapo":
|
||||||
|
cfg.Zone = &storage.ZoneXinjiapo
|
||||||
|
}
|
||||||
|
return &cfg
|
||||||
|
}
|
||||||
61
server/utils/upload/tencent_cos.go
Normal file
61
server/utils/upload/tencent_cos.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.echol.cn/loser/ai_wanjia/global"
|
||||||
|
|
||||||
|
"github.com/tencentyun/cos-go-sdk-v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TencentCOS struct{}
|
||||||
|
|
||||||
|
// UploadFile upload file to COS
|
||||||
|
func (*TencentCOS) UploadFile(file *multipart.FileHeader, ctx UploadContext) (string, string, error) {
|
||||||
|
client := NewClient()
|
||||||
|
f, openError := file.Open()
|
||||||
|
if openError != nil {
|
||||||
|
global.GVA_LOG.Error("function file.Open() failed", zap.Any("err", openError.Error()))
|
||||||
|
return "", "", errors.New("function file.Open() failed, err:" + openError.Error())
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
fileKey := ctx.SubDir() + "/" + fmt.Sprintf("%d%s", time.Now().Unix(), file.Filename)
|
||||||
|
|
||||||
|
_, err := client.Object.Put(context.Background(), global.GVA_CONFIG.TencentCOS.PathPrefix+"/"+fileKey, f, nil)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return global.GVA_CONFIG.TencentCOS.BaseURL + "/" + global.GVA_CONFIG.TencentCOS.PathPrefix + "/" + fileKey, fileKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFile delete file form COS
|
||||||
|
func (*TencentCOS) DeleteFile(key string) error {
|
||||||
|
client := NewClient()
|
||||||
|
name := global.GVA_CONFIG.TencentCOS.PathPrefix + "/" + key
|
||||||
|
_, err := client.Object.Delete(context.Background(), name)
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Error("function bucketManager.Delete() failed", zap.Any("err", err.Error()))
|
||||||
|
return errors.New("function bucketManager.Delete() failed, err:" + err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient init COS client
|
||||||
|
func NewClient() *cos.Client {
|
||||||
|
urlStr, _ := url.Parse("https://" + global.GVA_CONFIG.TencentCOS.Bucket + ".cos." + global.GVA_CONFIG.TencentCOS.Region + ".myqcloud.com")
|
||||||
|
baseURL := &cos.BaseURL{BucketURL: urlStr}
|
||||||
|
client := cos.NewClient(baseURL, &http.Client{
|
||||||
|
Transport: &cos.AuthorizationTransport{
|
||||||
|
SecretID: global.GVA_CONFIG.TencentCOS.SecretID,
|
||||||
|
SecretKey: global.GVA_CONFIG.TencentCOS.SecretKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return client
|
||||||
|
}
|
||||||
72
server/utils/upload/upload.go
Normal file
72
server/utils/upload/upload.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.echol.cn/loser/ai_wanjia/global"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 平台来源常量
|
||||||
|
const (
|
||||||
|
SourceApp = "app" // 用户端(app + web)
|
||||||
|
SourceCreator = "creator" // 创作者平台
|
||||||
|
SourceAdmin = "admin" // 管理后台
|
||||||
|
SourceSystem = "system" // 系统/通用(无来源标识时使用)
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadContext 标识文件上传的来源平台及操作用户
|
||||||
|
type UploadContext struct {
|
||||||
|
Source string // 平台来源:app / creator / admin / system
|
||||||
|
UserID uint // 上传者ID,0 表示无特定用户(系统级操作)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubDir 生成存储子目录
|
||||||
|
// 有用户ID:{source}/{userId}/{date} 例如 app/42/2024-01-15
|
||||||
|
// 无用户ID:{source}/{date} 例如 system/2024-01-15
|
||||||
|
func (ctx UploadContext) SubDir() string {
|
||||||
|
date := time.Now().Format("2006-01-02")
|
||||||
|
if ctx.Source == "" {
|
||||||
|
ctx.Source = SourceSystem
|
||||||
|
}
|
||||||
|
if ctx.UserID > 0 {
|
||||||
|
return fmt.Sprintf("%s/%d/%s", ctx.Source, ctx.UserID, date)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/%s", ctx.Source, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSS 对象存储接口
|
||||||
|
// Author [SliverHorn](https://github.com/SliverHorn)
|
||||||
|
// Author [ccfish86](https://github.com/ccfish86)
|
||||||
|
type OSS interface {
|
||||||
|
UploadFile(file *multipart.FileHeader, ctx UploadContext) (string, string, error)
|
||||||
|
DeleteFile(key string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOss OSS的实例化方法
|
||||||
|
// Author [SliverHorn](https://github.com/SliverHorn)
|
||||||
|
// Author [ccfish86](https://github.com/ccfish86)
|
||||||
|
func NewOss() OSS {
|
||||||
|
switch global.GVA_CONFIG.System.OssType {
|
||||||
|
case "local":
|
||||||
|
return &Local{}
|
||||||
|
case "qiniu":
|
||||||
|
return &Qiniu{}
|
||||||
|
case "tencent-cos":
|
||||||
|
return &TencentCOS{}
|
||||||
|
case "aliyun-oss":
|
||||||
|
return &AliyunOSS{}
|
||||||
|
case "huawei-obs":
|
||||||
|
return HuaWeiObs
|
||||||
|
case "minio":
|
||||||
|
minioClient, err := GetMinio(global.GVA_CONFIG.Minio.Endpoint, global.GVA_CONFIG.Minio.AccessKeyId, global.GVA_CONFIG.Minio.AccessKeySecret, global.GVA_CONFIG.Minio.BucketName, global.GVA_CONFIG.Minio.UseSSL)
|
||||||
|
if err != nil {
|
||||||
|
global.GVA_LOG.Warn("你配置了使用minio,但是初始化失败,请检查minio可用性或安全配置: " + err.Error())
|
||||||
|
panic("minio初始化失败") // 建议这样做,用户自己配置了minio,如果报错了还要把服务开起来,使用起来也很危险
|
||||||
|
}
|
||||||
|
return minioClient
|
||||||
|
default:
|
||||||
|
return &Local{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
222
web-admin/src/features/logs/LoginLogPage.tsx
Normal file
222
web-admin/src/features/logs/LoginLogPage.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Button, Card, Form, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { loginLogApi } from '@/lib/api'
|
||||||
|
import { formatDate } from '@/lib/date'
|
||||||
|
import type { LoginLog } from '@/types/system'
|
||||||
|
|
||||||
|
type LoginLogSearch = {
|
||||||
|
username?: string
|
||||||
|
status?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginLogPage() {
|
||||||
|
const [searchForm] = Form.useForm<LoginLogSearch>()
|
||||||
|
const [rows, setRows] = useState<LoginLog[]>([])
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
const reloadRows = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const values = searchForm.getFieldsValue()
|
||||||
|
const response = await loginLogApi.getLoginLogList({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
username: values.username,
|
||||||
|
...(typeof values.status === 'boolean' ? { status: values.status } : {}),
|
||||||
|
})
|
||||||
|
setRows(response.data.list)
|
||||||
|
setTotal(response.data.total)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, pageSize, searchForm])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadRows()
|
||||||
|
}, [reloadRows])
|
||||||
|
|
||||||
|
const refreshAfterDelete = (removedCount: number) => {
|
||||||
|
setSelectedRowKeys([])
|
||||||
|
if (rows.length <= removedCount && page > 1) {
|
||||||
|
setPage((current) => current - 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reloadRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSearch = () => {
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reloadRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRow = (record: LoginLog) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `删除登录日志 #${record.ID}`,
|
||||||
|
content: '删除后无法恢复。',
|
||||||
|
okText: '删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
await loginLogApi.deleteLoginLog(record.ID)
|
||||||
|
message.success('登录日志已删除')
|
||||||
|
refreshAfterDelete(1)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSelectedRows = () => {
|
||||||
|
if (!selectedRowKeys.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: `批量删除 ${selectedRowKeys.length} 条登录日志`,
|
||||||
|
content: '删除后无法恢复。',
|
||||||
|
okText: '删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
await loginLogApi.deleteLoginLogByIds(selectedRowKeys.map((item) => Number(item)))
|
||||||
|
message.success('登录日志已删除')
|
||||||
|
refreshAfterDelete(selectedRowKeys.length)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<LoginLog> = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'ID',
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户名',
|
||||||
|
dataIndex: 'username',
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '登录 IP',
|
||||||
|
dataIndex: 'ip',
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 110,
|
||||||
|
render: (_, record) => <Tag color={record.status ? 'green' : 'red'}>{record.status ? '成功' : '失败'}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '详情',
|
||||||
|
render: (_, record) => record.errorMessage || (record.status ? '登录成功' : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '浏览器/设备',
|
||||||
|
dataIndex: 'agent',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '登录时间',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => formatDate(record.CreatedAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 100,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button danger type="link" onClick={() => deleteRow(record)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||||
|
登录日志
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||||
|
当前页用于审计登录结果、失败原因和终端信息。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Form
|
||||||
|
form={searchForm}
|
||||||
|
layout="inline"
|
||||||
|
onFinish={runSearch}
|
||||||
|
>
|
||||||
|
<Form.Item name="username" label="用户名">
|
||||||
|
<Input allowClear placeholder="输入用户名" style={{ width: 220 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部状态"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
options={[
|
||||||
|
{ label: '成功', value: true },
|
||||||
|
{ label: '失败', value: false },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
searchForm.resetFields()
|
||||||
|
runSearch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button danger disabled={!selectedRowKeys.length} onClick={deleteSelectedRows}>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="ID"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: setSelectedRowKeys,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
292
web-admin/src/features/logs/OperationLogPage.tsx
Normal file
292
web-admin/src/features/logs/OperationLogPage.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Button, Card, Form, Input, Modal, Space, Table, Tag, Typography, message } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { operationApi } from '@/lib/api'
|
||||||
|
import { formatDate } from '@/lib/date'
|
||||||
|
import type { OperationRecord } from '@/types/system'
|
||||||
|
|
||||||
|
type OperationSearch = {
|
||||||
|
method?: string
|
||||||
|
path?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPayload(payload?: string) {
|
||||||
|
if (!payload) {
|
||||||
|
return '无内容'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(payload), null, 2)
|
||||||
|
} catch {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperationLogPage() {
|
||||||
|
const [searchForm] = Form.useForm<OperationSearch>()
|
||||||
|
const [rows, setRows] = useState<OperationRecord[]>([])
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [previewTitle, setPreviewTitle] = useState('')
|
||||||
|
const [previewContent, setPreviewContent] = useState('')
|
||||||
|
|
||||||
|
const previewOpen = useMemo(() => Boolean(previewTitle), [previewTitle])
|
||||||
|
|
||||||
|
const reloadRows = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const values = searchForm.getFieldsValue()
|
||||||
|
const response = await operationApi.getOperationList({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
method: values.method,
|
||||||
|
path: values.path,
|
||||||
|
status: values.status || undefined,
|
||||||
|
})
|
||||||
|
setRows(response.data.list)
|
||||||
|
setTotal(response.data.total)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, pageSize, searchForm])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadRows()
|
||||||
|
}, [reloadRows])
|
||||||
|
|
||||||
|
const refreshAfterDelete = (removedCount: number) => {
|
||||||
|
setSelectedRowKeys([])
|
||||||
|
if (rows.length <= removedCount && page > 1) {
|
||||||
|
setPage((current) => current - 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reloadRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPreview = (title: string, payload?: string) => {
|
||||||
|
setPreviewTitle(title)
|
||||||
|
setPreviewContent(formatPayload(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRow = (record: OperationRecord) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `删除操作记录 #${record.ID}`,
|
||||||
|
content: '删除后无法恢复。',
|
||||||
|
okText: '删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
await operationApi.deleteOperation(record.ID)
|
||||||
|
message.success('操作记录已删除')
|
||||||
|
refreshAfterDelete(1)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSearch = () => {
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reloadRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSelectedRows = () => {
|
||||||
|
if (!selectedRowKeys.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: `批量删除 ${selectedRowKeys.length} 条操作记录`,
|
||||||
|
content: '删除后无法恢复。',
|
||||||
|
okText: '删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
await operationApi.deleteOperationByIds(selectedRowKeys.map((item) => Number(item)))
|
||||||
|
message.success('操作记录已删除')
|
||||||
|
refreshAfterDelete(selectedRowKeys.length)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<OperationRecord> = [
|
||||||
|
{
|
||||||
|
title: '操作人',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => {
|
||||||
|
const userName = record.user?.userName || '系统'
|
||||||
|
const nickName = record.user?.nickName
|
||||||
|
return nickName ? `${userName} (${nickName})` : userName
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => formatDate(record.CreatedAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态码',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 110,
|
||||||
|
render: (status: number) => <Tag color={status >= 200 && status < 400 ? 'green' : 'red'}>{status}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '请求 IP',
|
||||||
|
dataIndex: 'ip',
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '请求方法',
|
||||||
|
dataIndex: 'method',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '请求路径',
|
||||||
|
dataIndex: 'path',
|
||||||
|
width: 260,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '耗时',
|
||||||
|
dataIndex: 'latency',
|
||||||
|
width: 120,
|
||||||
|
render: (value?: string) => value || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '请求体',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button type="link" disabled={!record.body} onClick={() => openPreview('请求体', record.body)}>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '响应体',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button type="link" disabled={!record.resp} onClick={() => openPreview('响应体', record.resp)}>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 100,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button danger type="link" onClick={() => deleteRow(record)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||||
|
操作历史
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||||
|
当前页用于审计接口调用链路、请求内容和响应结果。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass-panel page-panel">
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Form
|
||||||
|
form={searchForm}
|
||||||
|
layout="inline"
|
||||||
|
onFinish={runSearch}
|
||||||
|
>
|
||||||
|
<Form.Item name="method" label="请求方法">
|
||||||
|
<Input allowClear placeholder="如 GET" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="path" label="请求路径">
|
||||||
|
<Input allowClear placeholder="如 /user/getUserList" style={{ width: 280 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态码">
|
||||||
|
<Input allowClear placeholder="如 200" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
searchForm.resetFields()
|
||||||
|
runSearch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button danger disabled={!selectedRowKeys.length} onClick={deleteSelectedRows}>
|
||||||
|
批量删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="ID"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: setSelectedRowKeys,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1500 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={previewOpen}
|
||||||
|
title={previewTitle}
|
||||||
|
footer={null}
|
||||||
|
onCancel={() => {
|
||||||
|
setPreviewTitle('')
|
||||||
|
setPreviewContent('')
|
||||||
|
}}
|
||||||
|
width={880}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
maxHeight: 520,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 16,
|
||||||
|
background: 'rgba(16, 37, 66, 0.05)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewContent}
|
||||||
|
</pre>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user