diff --git a/.gitignore b/.gitignore index 52ba83f..6287e9e 100644 --- a/.gitignore +++ b/.gitignore @@ -71,14 +71,12 @@ logs log uploads upload +!/server/utils/upload/ +!/server/utils/upload/** +!/web-admin/src/features/logs/ +!/web-admin/src/features/logs/** server/config.yaml 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 .claude .codex diff --git a/server/utils/upload/.gitkeep b/server/utils/upload/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/server/utils/upload/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/utils/upload/aliyun_oss.go b/server/utils/upload/aliyun_oss.go new file mode 100644 index 0000000..f8218b0 --- /dev/null +++ b/server/utils/upload/aliyun_oss.go @@ -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 +} diff --git a/server/utils/upload/local.go b/server/utils/upload/local.go new file mode 100644 index 0000000..fea4645 --- /dev/null +++ b/server/utils/upload/local.go @@ -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 +} diff --git a/server/utils/upload/minio_oss.go b/server/utils/upload/minio_oss.go new file mode 100644 index 0000000..fdfbca8 --- /dev/null +++ b/server/utils/upload/minio_oss.go @@ -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 +} diff --git a/server/utils/upload/obs.go b/server/utils/upload/obs.go new file mode 100644 index 0000000..fc170c9 --- /dev/null +++ b/server/utils/upload/obs.go @@ -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 +} diff --git a/server/utils/upload/qiniu.go b/server/utils/upload/qiniu.go new file mode 100644 index 0000000..e945df4 --- /dev/null +++ b/server/utils/upload/qiniu.go @@ -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 +} diff --git a/server/utils/upload/tencent_cos.go b/server/utils/upload/tencent_cos.go new file mode 100644 index 0000000..5fd1e53 --- /dev/null +++ b/server/utils/upload/tencent_cos.go @@ -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 +} diff --git a/server/utils/upload/upload.go b/server/utils/upload/upload.go new file mode 100644 index 0000000..b8951d6 --- /dev/null +++ b/server/utils/upload/upload.go @@ -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{} + } +} diff --git a/web-admin/src/features/logs/.gitkeep b/web-admin/src/features/logs/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/web-admin/src/features/logs/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web-admin/src/features/logs/LoginLogPage.tsx b/web-admin/src/features/logs/LoginLogPage.tsx new file mode 100644 index 0000000..d55c003 --- /dev/null +++ b/web-admin/src/features/logs/LoginLogPage.tsx @@ -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() + const [rows, setRows] = useState([]) + const [selectedRowKeys, setSelectedRowKeys] = useState([]) + 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 = [ + { + title: 'ID', + dataIndex: 'ID', + width: 80, + }, + { + title: '用户名', + dataIndex: 'username', + width: 160, + }, + { + title: '登录 IP', + dataIndex: 'ip', + width: 160, + }, + { + title: '状态', + width: 110, + render: (_, record) => {record.status ? '成功' : '失败'}, + }, + { + 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) => ( + + ), + }, + ] + + return ( +
+ +
+
+ + 登录日志 + + + 当前页用于审计登录结果、失败原因和终端信息。 + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + { + setPage(nextPage) + setPageSize(nextPageSize) + }, + }} + /> + + + + { + setPreviewTitle('') + setPreviewContent('') + }} + width={880} + > +
+          {previewContent}
+        
+
+ + ) +}