update: 添加logo的配置

This commit is contained in:
ctwj
2025-08-18 02:30:15 +08:00
parent acb462c6d5
commit 949a328ee3
13 changed files with 678 additions and 25 deletions

View File

@@ -129,6 +129,8 @@ func (f *PanFactory) CreatePanService(url string, config *PanConfig) (PanService
return NewBaiduPanService(config), nil
case UC:
return NewUCService(config), nil
case Xunlei:
return NewXunleiPanService(config), nil
default:
return nil, fmt.Errorf("不支持的服务类型: %s", url)
}
@@ -145,8 +147,8 @@ func (f *PanFactory) CreatePanServiceByType(serviceType ServiceType, config *Pan
return NewBaiduPanService(config), nil
case UC:
return NewUCService(config), nil
// case Xunlei:
// return NewXunleiService(config), nil
case Xunlei:
return NewXunleiPanService(config), nil
// case Tianyi:
// return NewTianyiService(config), nil
default:
@@ -178,6 +180,12 @@ func (f *PanFactory) GetUCService(config *PanConfig) PanService {
return service
}
// GetXunleiService 获取迅雷网盘服务单例
func (f *PanFactory) GetXunleiService(config *PanConfig) PanService {
service := NewXunleiPanService(config)
return service
}
// ExtractServiceType 从URL中提取服务类型
func ExtractServiceType(url string) ServiceType {
url = strings.ToLower(url)

View File

@@ -13,8 +13,10 @@ import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
@@ -31,10 +33,6 @@ var (
// 配置化 API Host
func (x *XunleiPanService) apiHost() string {
// if x.config != nil && x.config.ApiHost != "" {
// return x.config.ApiHost
// }
// 推荐用官方: https://api-pan.xunlei.com
return "https://api-pan.xunlei.com"
}
@@ -43,7 +41,6 @@ func (x *XunleiPanService) setCommonHeader(req *http.Request) {
for k, v := range x.headers {
req.Header.Set(k, v)
}
// 可扩展: Authorization, x-device-id, x-client-id, x-captcha-token
}
func NewXunleiPanService(config *PanConfig) *XunleiPanService {
@@ -61,6 +58,11 @@ func NewXunleiPanService(config *PanConfig) *XunleiPanService {
return xunleiInstance
}
// GetXunleiInstance 获取迅雷网盘服务单例实例
func GetXunleiInstance() *XunleiPanService {
return NewXunleiPanService(nil)
}
func (x *XunleiPanService) UpdateConfig(config *PanConfig) {
if config == nil {
return
@@ -73,6 +75,249 @@ func (x *XunleiPanService) UpdateConfig(config *PanConfig) {
}
}
// GetServiceType 获取服务类型
func (x *XunleiPanService) GetServiceType() ServiceType {
return Xunlei
}
// Transfer 转存分享链接 - 实现 PanService 接口
func (x *XunleiPanService) Transfer(shareID string) (*TransferResult, error) {
// 读取配置(线程安全)
x.configMutex.RLock()
config := x.config
x.configMutex.RUnlock()
log.Printf("开始处理迅雷分享: %s", shareID)
// 检查是否为检验模式
if config.IsType == 1 {
// 检验模式:直接获取分享信息
shareInfo, err := x.getShareInfo(shareID)
if err != nil {
return ErrorResult(fmt.Sprintf("获取分享信息失败: %v", err)), nil
}
return SuccessResult("检验成功", map[string]interface{}{
"title": shareInfo.Title,
"shareUrl": config.URL,
}), nil
}
// 转存模式:实现完整的转存流程
// 1. 获取分享详情
shareDetail, err := x.GetShareFolder(shareID, "", "")
if err != nil {
return ErrorResult(fmt.Sprintf("获取分享详情失败: %v", err)), nil
}
// 2. 提取文件ID列表
fileIDs := make([]string, 0)
for _, file := range shareDetail.Data.Files {
fileIDs = append(fileIDs, file.FileID)
}
if len(fileIDs) == 0 {
return ErrorResult("分享中没有可转存的文件"), nil
}
// 3. 转存文件
restoreResult, err := x.Restore(shareID, "", fileIDs)
if err != nil {
return ErrorResult(fmt.Sprintf("转存失败: %v", err)), nil
}
// 4. 等待转存完成
taskID := restoreResult.Data.TaskID
_, err = x.waitForTask(taskID)
if err != nil {
return ErrorResult(fmt.Sprintf("等待转存完成失败: %v", err)), nil
}
// 5. 创建新的分享
shareResult, err := x.FileBatchShare(fileIDs, false, 0) // 永久分享
if err != nil {
return ErrorResult(fmt.Sprintf("创建分享失败: %v", err)), nil
}
// 6. 返回结果
return SuccessResult("转存成功", map[string]interface{}{
"shareUrl": shareResult.Data.ShareURL,
"title": fmt.Sprintf("迅雷分享_%s", shareID),
"fid": strings.Join(fileIDs, ","),
}), nil
}
// waitForTask 等待任务完成
func (x *XunleiPanService) waitForTask(taskID string) (*XLTaskResult, error) {
maxRetries := 50
retryDelay := 2 * time.Second
for retryIndex := 0; retryIndex < maxRetries; retryIndex++ {
result, err := x.getTaskStatus(taskID, retryIndex)
if err != nil {
return nil, err
}
if result.Status == 2 { // 任务完成
return result, nil
}
time.Sleep(retryDelay)
}
return nil, fmt.Errorf("任务超时")
}
// getTaskStatus 获取任务状态
func (x *XunleiPanService) getTaskStatus(taskID string, retryIndex int) (*XLTaskResult, error) {
apiURL := x.apiHost() + "/drive/v1/task"
params := url.Values{}
params.Set("task_id", taskID)
params.Set("retry_index", fmt.Sprintf("%d", retryIndex))
apiURL = apiURL + "?" + params.Encode()
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, err
}
x.setCommonHeader(req)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
result, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
}
var data XLTaskResult
if err := json.Unmarshal(result, &data); err != nil {
return nil, err
}
return &data, nil
}
// getShareInfo 获取分享信息(用于检验模式)
func (x *XunleiPanService) getShareInfo(shareID string) (*XLShareInfo, error) {
// 使用现有的 GetShareFolder 方法获取分享信息
shareDetail, err := x.GetShareFolder(shareID, "", "")
if err != nil {
return nil, err
}
// 构造分享信息
shareInfo := &XLShareInfo{
ShareID: shareID,
Title: fmt.Sprintf("迅雷分享_%s", shareID),
Files: make([]XLFileInfo, 0),
}
// 处理文件信息
for _, file := range shareDetail.Data.Files {
shareInfo.Files = append(shareInfo.Files, XLFileInfo{
FileID: file.FileID,
Name: file.Name,
})
}
return shareInfo, nil
}
// GetFiles 获取文件列表 - 实现 PanService 接口
func (x *XunleiPanService) GetFiles(pdirFid string) (*TransferResult, error) {
log.Printf("开始获取迅雷网盘文件列表目录ID: %s", pdirFid)
// 使用现有的 GetShareList 方法获取文件列表
shareList, err := x.GetShareList("")
if err != nil {
return ErrorResult(fmt.Sprintf("获取文件列表失败: %v", err)), nil
}
// 转换为通用格式
fileList := make([]interface{}, 0)
for _, share := range shareList.Data.List {
fileList = append(fileList, map[string]interface{}{
"share_id": share.ShareID,
"title": share.Title,
})
}
return SuccessResult("获取成功", fileList), nil
}
// DeleteFiles 删除文件 - 实现 PanService 接口
func (x *XunleiPanService) DeleteFiles(fileList []string) (*TransferResult, error) {
log.Printf("开始删除迅雷网盘文件,文件数量: %d", len(fileList))
// 使用现有的 ShareBatchDelete 方法删除分享
result, err := x.ShareBatchDelete(fileList)
if err != nil {
return ErrorResult(fmt.Sprintf("删除文件失败: %v", err)), nil
}
if result.Code != 0 {
return ErrorResult(fmt.Sprintf("删除文件失败: %s", result.Msg)), nil
}
return SuccessResult("删除成功", nil), nil
}
// GetUserInfo 获取用户信息 - 实现 PanService 接口
func (x *XunleiPanService) GetUserInfo(cookie string) (*UserInfo, error) {
log.Printf("开始获取迅雷网盘用户信息")
// 临时设置cookie
originalCookie := x.GetHeader("Cookie")
x.SetHeader("Cookie", cookie)
defer x.SetHeader("Cookie", originalCookie) // 恢复原始cookie
// 获取用户信息
apiURL := x.apiHost() + "/drive/v1/user/info"
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
}
x.setCommonHeader(req)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("获取用户信息失败: %v", err)
}
defer resp.Body.Close()
result, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
}
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Username string `json:"username"`
VIPStatus bool `json:"vip_status"`
UsedSpace int64 `json:"used_space"`
TotalSpace int64 `json:"total_space"`
} `json:"data"`
}
if err := json.Unmarshal(result, &response); err != nil {
return nil, fmt.Errorf("解析用户信息失败: %v", err)
}
if response.Code != 0 {
return nil, fmt.Errorf("获取用户信息失败: %s", response.Msg)
}
return &UserInfo{
Username: response.Data.Username,
VIPStatus: response.Data.VIPStatus,
UsedSpace: response.Data.UsedSpace,
TotalSpace: response.Data.TotalSpace,
ServiceType: "xunlei",
}, nil
}
// GetShareList 严格对齐 GET + queryxunleix实现
func (x *XunleiPanService) GetShareList(pageToken string) (*XLShareListResp, error) {
api := x.apiHost() + "/drive/v1/share/list"
@@ -82,9 +327,9 @@ func (x *XunleiPanService) GetShareList(pageToken string) (*XLShareListResp, err
if pageToken != "" {
params.Set("page_token", pageToken)
}
url := api + "?" + params.Encode()
apiURL := api + "?" + params.Encode()
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, err
}
@@ -108,14 +353,14 @@ func (x *XunleiPanService) GetShareList(pageToken string) (*XLShareListResp, err
// FileBatchShare 创建分享POST, body
func (x *XunleiPanService) FileBatchShare(ids []string, needPassword bool, expirationDays int) (*XLBatchShareResp, error) {
url := x.apiHost() + "/drive/v1/share/batch"
apiURL := x.apiHost() + "/drive/v1/share/batch"
body := map[string]interface{}{
"file_ids": ids,
"need_password": needPassword,
"expiration_days": expirationDays,
}
bs, _ := json.Marshal(body)
req, err := http.NewRequest("POST", url, bytes.NewReader(bs))
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
if err != nil {
return nil, err
}
@@ -139,12 +384,12 @@ func (x *XunleiPanService) FileBatchShare(ids []string, needPassword bool, expir
// ShareBatchDelete 取消分享POST, body
func (x *XunleiPanService) ShareBatchDelete(ids []string) (*XLCommonResp, error) {
url := x.apiHost() + "/drive/v1/share/batch/delete"
apiURL := x.apiHost() + "/drive/v1/share/batch/delete"
body := map[string]interface{}{
"share_ids": ids,
}
bs, _ := json.Marshal(body)
req, err := http.NewRequest("POST", url, bytes.NewReader(bs))
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
if err != nil {
return nil, err
}
@@ -168,7 +413,7 @@ func (x *XunleiPanService) ShareBatchDelete(ids []string) (*XLCommonResp, error)
// GetShareFolder 获取分享内容POST, body
func (x *XunleiPanService) GetShareFolder(shareID, passCodeToken, parentID string) (*XLShareFolderResp, error) {
url := x.apiHost() + "/drive/v1/share/detail"
apiURL := x.apiHost() + "/drive/v1/share/detail"
body := map[string]interface{}{
"share_id": shareID,
"pass_code_token": passCodeToken,
@@ -178,7 +423,7 @@ func (x *XunleiPanService) GetShareFolder(shareID, passCodeToken, parentID strin
"order": "6",
}
bs, _ := json.Marshal(body)
req, err := http.NewRequest("POST", url, bytes.NewReader(bs))
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
if err != nil {
return nil, err
}
@@ -202,7 +447,7 @@ func (x *XunleiPanService) GetShareFolder(shareID, passCodeToken, parentID strin
// Restore 转存POST, body
func (x *XunleiPanService) Restore(shareID, passCodeToken string, fileIDs []string) (*XLRestoreResp, error) {
url := x.apiHost() + "/drive/v1/share/restore"
apiURL := x.apiHost() + "/drive/v1/share/restore"
body := map[string]interface{}{
"share_id": shareID,
"pass_code_token": passCodeToken,
@@ -212,7 +457,7 @@ func (x *XunleiPanService) Restore(shareID, passCodeToken string, fileIDs []stri
"parent_id": "",
}
bs, _ := json.Marshal(body)
req, err := http.NewRequest("POST", url, bytes.NewReader(bs))
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
if err != nil {
return nil, err
}
@@ -277,3 +522,23 @@ type XLRestoreResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// 新增辅助结构体
type XLShareInfo struct {
ShareID string `json:"share_id"`
Title string `json:"title"`
Files []XLFileInfo `json:"files"`
}
type XLFileInfo struct {
FileID string `json:"file_id"`
Name string `json:"name"`
}
type XLTaskResult struct {
Status int `json:"status"`
TaskID string `json:"task_id"`
Data struct {
ShareID string `json:"share_id"`
} `json:"data"`
}

View File

@@ -18,6 +18,7 @@ func FileToResponse(file *entity.File) dto.FileResponse {
FileSize: file.FileSize,
FileType: file.FileType,
MimeType: file.MimeType,
FileHash: file.FileHash,
AccessURL: file.AccessURL,
UserID: file.UserID,
Status: file.Status,

View File

@@ -30,6 +30,8 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
response.Author = config.Value
case entity.ConfigKeyCopyright:
response.Copyright = config.Value
case entity.ConfigKeySiteLogo:
response.SiteLogo = config.Value
case entity.ConfigKeyAutoProcessReadyResources:
if val, err := strconv.ParseBool(config.Value); err == nil {
response.AutoProcessReadyResources = val
@@ -103,6 +105,7 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyKeywords, Value: req.Keywords, Type: entity.ConfigTypeString})
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAuthor, Value: req.Author, Type: entity.ConfigTypeString})
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyCopyright, Value: req.Copyright, Type: entity.ConfigTypeString})
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteLogo, Value: req.SiteLogo, Type: entity.ConfigTypeString})
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyApiToken, Value: req.ApiToken, Type: entity.ConfigTypeString})
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyForbiddenWords, Value: req.ForbiddenWords, Type: entity.ConfigTypeString})
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAdKeywords, Value: req.AdKeywords, Type: entity.ConfigTypeString})
@@ -140,6 +143,7 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
"site_logo": "",
entity.ConfigResponseFieldAutoProcessReadyResources: false,
entity.ConfigResponseFieldAutoProcessInterval: 30,
entity.ConfigResponseFieldAutoTransferEnabled: false,
@@ -167,6 +171,8 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
response[entity.ConfigResponseFieldAuthor] = config.Value
case entity.ConfigKeyCopyright:
response[entity.ConfigResponseFieldCopyright] = config.Value
case entity.ConfigKeySiteLogo:
response["site_logo"] = config.Value
case entity.ConfigKeyAutoProcessReadyResources:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoProcessReadyResources] = val
@@ -231,6 +237,7 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
Keywords: entity.ConfigDefaultKeywords,
Author: entity.ConfigDefaultAuthor,
Copyright: entity.ConfigDefaultCopyright,
SiteLogo: "",
AutoProcessReadyResources: false,
AutoProcessInterval: 30,
AutoTransferEnabled: false,

View File

@@ -3,6 +3,7 @@ package dto
// FileUploadRequest 文件上传请求
type FileUploadRequest struct {
IsPublic bool `json:"is_public" form:"is_public"` // 是否公开
FileHash string `json:"file_hash" form:"file_hash"` // 文件哈希值
}
// FileResponse 文件响应
@@ -18,6 +19,7 @@ type FileResponse struct {
FileSize int64 `json:"file_size"`
FileType string `json:"file_type"`
MimeType string `json:"mime_type"`
FileHash string `json:"file_hash"`
// 访问信息
AccessURL string `json:"access_url"`
@@ -55,6 +57,7 @@ type FileUploadResponse struct {
File FileResponse `json:"file"`
Message string `json:"message"`
Success bool `json:"success"`
IsDuplicate bool `json:"is_duplicate"` // 是否为重复文件
}
// FileDeleteRequest 文件删除请求

View File

@@ -8,6 +8,7 @@ type SystemConfigRequest struct {
Keywords string `json:"keywords"`
Author string `json:"author"`
Copyright string `json:"copyright"`
SiteLogo string `json:"site_logo"`
// 自动处理配置组
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
@@ -48,6 +49,7 @@ type SystemConfigResponse struct {
Keywords string `json:"keywords"`
Author string `json:"author"`
Copyright string `json:"copyright"`
SiteLogo string `json:"site_logo"`
// 自动处理配置组
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源

View File

@@ -17,6 +17,7 @@ type File struct {
FileSize int64 `json:"file_size" gorm:"not null;comment:文件大小(字节)"`
FileType string `json:"file_type" gorm:"size:100;not null;comment:文件类型"`
MimeType string `json:"mime_type" gorm:"size:100;comment:MIME类型"`
FileHash string `json:"file_hash" gorm:"size:64;uniqueIndex;comment:文件哈希值"`
// 访问信息
AccessURL string `json:"access_url" gorm:"size:500;comment:访问URL"`

View File

@@ -8,6 +8,7 @@ const (
ConfigKeyKeywords = "keywords"
ConfigKeyAuthor = "author"
ConfigKeyCopyright = "copyright"
ConfigKeySiteLogo = "site_logo"
// 自动处理配置组
ConfigKeyAutoProcessReadyResources = "auto_process_ready_resources"

View File

@@ -9,6 +9,7 @@ import (
type FileRepository interface {
BaseRepository[entity.File]
FindByFileName(fileName string) (*entity.File, error)
FindByHash(fileHash string) (*entity.File, error)
FindByUserID(userID uint, page, pageSize int) ([]entity.File, int64, error)
FindPublicFiles(page, pageSize int) ([]entity.File, int64, error)
SearchFiles(search string, fileType, status string, userID uint, page, pageSize int) ([]entity.File, int64, error)
@@ -142,3 +143,13 @@ func (r *FileRepositoryImpl) UpdateFileStatus(id uint, status string) error {
func (r *FileRepositoryImpl) UpdateFilePublic(id uint, isPublic bool) error {
return r.db.Model(&entity.File{}).Where("id = ?", id).Update("is_public", isPublic).Error
}
// FindByHash 根据文件哈希查找文件
func (r *FileRepositoryImpl) FindByHash(fileHash string) (*entity.File, error) {
var file entity.File
err := r.db.Where("file_hash = ? AND is_deleted = ?", fileHash, false).First(&file).Error
if err != nil {
return nil, err
}
return &file, nil
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
"net/http"
@@ -52,6 +53,28 @@ func (h *FileHandler) UploadFile(c *gin.Context) {
return
}
// 获取文件哈希值
fileHash := c.PostForm("file_hash")
// 如果提供了文件哈希,先检查是否已存在
if fileHash != "" {
existingFile, err := h.fileRepo.FindByHash(fileHash)
if err == nil && existingFile != nil {
// 文件已存在,直接返回已存在的文件信息
utils.Info("文件已存在,跳过上传 - Hash: %s, 文件名: %s", fileHash, existingFile.OriginalName)
response := dto.FileUploadResponse{
File: converter.FileToResponse(existingFile),
Message: "文件已存在,极速上传成功",
Success: true,
IsDuplicate: true,
}
SuccessResponse(c, response)
return
}
}
// 获取上传目录配置(从环境变量或使用默认值)
uploadDir := os.Getenv("UPLOAD_DIR")
if uploadDir == "" {
@@ -127,6 +150,33 @@ func (h *FileHandler) UploadFile(c *gin.Context) {
return
}
// 计算文件哈希值(如果前端没有提供)
if fileHash == "" {
fileHash, err = h.calculateFileHash(filePath)
if err != nil {
ErrorResponse(c, "计算文件哈希失败", http.StatusInternalServerError)
return
}
}
// 再次检查文件是否已存在(使用计算出的哈希)
existingFile, err := h.fileRepo.FindByHash(fileHash)
if err == nil && existingFile != nil {
// 文件已存在,删除刚上传的文件,返回已存在的文件信息
os.Remove(filePath)
utils.Info("文件已存在,跳过上传 - Hash: %s, 文件名: %s", fileHash, existingFile.OriginalName)
response := dto.FileUploadResponse{
File: converter.FileToResponse(existingFile),
Message: "文件已存在,极速上传成功",
Success: true,
IsDuplicate: true,
}
SuccessResponse(c, response)
return
}
// 获取文件类型
fileType := h.getFileType(header.Filename)
mimeType := header.Header.Get("Content-Type")
@@ -150,6 +200,7 @@ func (h *FileHandler) UploadFile(c *gin.Context) {
FileSize: header.Size,
FileType: fileType,
MimeType: mimeType,
FileHash: fileHash,
AccessURL: accessURL,
UserID: currentUser.ID,
Status: entity.FileStatusActive,
@@ -380,3 +431,18 @@ func (h *FileHandler) getFileType(filename string) string {
return "image" // 默认返回image因为只支持图片格式
}
// calculateFileHash 计算文件哈希值
func (h *FileHandler) calculateFileHash(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}

View File

@@ -0,0 +1 @@
<template>

View File

@@ -19,7 +19,7 @@
点击或者拖动文件到该区域来上传
</n-text>
<n-p depth="3" style="margin: 8px 0 0 0">
请不要上传敏感数据比如你的银行卡号和密码信用卡号有效期和安全码
支持极速上传相同文件将直接返回已上传的文件信息
</n-p>
</n-upload-dragger>
</n-upload>
@@ -39,6 +39,7 @@ interface FileItem {
file_size: number
file_type: string
mime_type: string
file_hash: string
access_url: string
user_id: number
user: string
@@ -72,7 +73,28 @@ const acceptTypes = ref('image/*')
const uploadedFiles = ref<Map<string, boolean>>(new Map()) // 文件哈希 -> 是否已上传
const uploadingFiles = ref<Set<string>>(new Set()) // 正在上传的文件哈希
// 生成文件哈希值(基于文件名、大小和修改时间)
// 计算文件SHA256哈希值
const calculateFileHash = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const arrayBuffer = e.target?.result as ArrayBuffer
crypto.subtle.digest('SHA-256', arrayBuffer).then(hashBuffer => {
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
resolve(hashHex)
}).catch(reject)
} catch (error) {
reject(error)
}
}
reader.onerror = reject
reader.readAsArrayBuffer(file)
})
}
// 生成文件哈希值(基于文件名、大小和修改时间,用于前端去重)
const generateFileHash = (file: File): string => {
return `${file.name}_${file.size}_${file.lastModified}`
}
@@ -133,10 +155,15 @@ const customRequest = async (options: any) => {
console.log('开始上传文件:', file.name, file.file)
try {
// 计算文件哈希值
const fileHash = await calculateFileHash(file.file)
console.log('文件哈希值:', fileHash)
// 创建FormData
const formData = new FormData()
formData.append('file', file.file)
formData.append('is_public', isPublic.value.toString())
formData.append('file_hash', fileHash)
// 调用统一的API接口
const response = await fileApi.uploadFile(formData)
@@ -146,6 +173,13 @@ const customRequest = async (options: any) => {
// 标记文件为已上传
markFileAsUploaded(file.file)
// 检查是否为重复文件
if (response.data && response.data.is_duplicate) {
message.success(`${file.name} 极速上传成功(文件已存在)`)
} else {
message.success(`${file.name} 上传成功`)
}
if (onSuccess) {
onSuccess(response)
}
@@ -159,7 +193,6 @@ const customRequest = async (options: any) => {
}
}
// 默认文件列表从props传入
const defaultFileList = ref<UploadFileInfo[]>([])
@@ -212,7 +245,6 @@ const handleUploadFinish = (data: { file: Required<UploadFileInfo> }) => {
const { file } = data
if (file.status === 'finished') {
message.success(`${file.name} 上传成功`)
// 确保文件被标记为已上传
if (file.file) {
markFileAsUploaded(file.file)

View File

@@ -70,6 +70,40 @@
/>
</div>
<!-- 网站Logo -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">网站Logo</label>
<span class="text-xs text-gray-500 dark:text-gray-400">选择网站Logo图片建议使用正方形图片</span>
</div>
<div class="flex items-center space-x-4">
<div v-if="configForm.site_logo" class="flex-shrink-0">
<n-image
:src="configForm.site_logo"
alt="网站Logo"
width="80"
height="80"
object-fit="cover"
class="rounded-lg border"
/>
</div>
<div class="flex-1">
<n-button type="primary" @click="openLogoSelector">
<template #icon>
<i class="fas fa-image"></i>
</template>
{{ configForm.site_logo ? '更换Logo' : '选择Logo' }}
</n-button>
<n-button v-if="configForm.site_logo" @click="clearLogo" class="ml-2">
<template #icon>
<i class="fas fa-times"></i>
</template>
清除
</n-button>
</div>
</div>
</div>
<!-- 版权信息 -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
@@ -143,6 +177,101 @@
</n-tab-pane>
</n-tabs>
</n-card>
<!-- Logo选择模态框 -->
<n-modal v-model:show="showLogoSelector" preset="card" title="选择Logo图片" style="width: 90vw; max-width: 1200px; max-height: 80vh;">
<div class="space-y-4">
<!-- 搜索 -->
<div class="flex gap-4">
<n-input
v-model:value="searchKeyword"
placeholder="搜索文件名..."
@keyup.enter="handleSearch"
class="flex-1"
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<n-button type="primary" @click="handleSearch">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
<!-- 文件列表 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<n-spin size="large" />
</div>
<div v-else-if="fileList.length === 0" class="text-center py-8">
<i class="fas fa-file-upload text-4xl text-gray-400 mb-4"></i>
<p class="text-gray-500">暂无图片文件</p>
</div>
<div v-else class="file-grid">
<div
v-for="file in fileList"
:key="file.id"
class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors"
:class="{ 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-300 dark:border-blue-600': selectedFileId === file.id }"
@click="selectFile(file)"
>
<div class="image-preview">
<n-image
:src="file.access_url"
:alt="file.original_name"
:lazy="true"
:intersection-observer-options="{
root: null,
rootMargin: '50px',
threshold: 0.1
}"
object-fit="cover"
class="preview-image rounded"
/>
<div class="image-info mt-2">
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ file.original_name }}
</div>
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
{{ formatFileSize(file.file_size) }}
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-count="Math.ceil(pagination.total / pagination.pageSize)"
:page-sizes="pagination.pageSizes"
show-size-picker
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<template #footer>
<n-space justify="end">
<n-button @click="showLogoSelector = false">取消</n-button>
<n-button
type="primary"
@click="confirmSelection"
:disabled="!selectedFileId"
>
确认选择
</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
@@ -158,12 +287,28 @@ const formRef = ref()
const saving = ref(false)
const activeTab = ref('basic')
// Logo选择器相关数据
const showLogoSelector = ref(false)
const loading = ref(false)
const fileList = ref<any[]>([])
const selectedFileId = ref<number | null>(null)
const searchKeyword = ref('')
// 分页
const pagination = ref({
page: 1,
pageSize: 20,
total: 0,
pageSizes: [10, 20, 50, 100]
})
// 配置表单数据
const configForm = ref<{
site_title: string
site_description: string
keywords: string
copyright: string
site_logo: string
maintenance_mode: boolean
enable_register: boolean
forbidden_words: string
@@ -174,6 +319,7 @@ const configForm = ref<{
site_description: '',
keywords: '',
copyright: '',
site_logo: '',
maintenance_mode: false,
enable_register: false, // 新增:开启注册开关
forbidden_words: '',
@@ -210,6 +356,7 @@ const fetchConfig = async () => {
site_description: response.site_description || '',
keywords: response.keywords || '',
copyright: response.copyright || '',
site_logo: response.site_logo || '',
maintenance_mode: response.maintenance_mode || false,
enable_register: response.enable_register || false, // 新增:获取开启注册开关
forbidden_words: response.forbidden_words || '',
@@ -240,6 +387,7 @@ const saveConfig = async () => {
site_description: configForm.value.site_description,
keywords: configForm.value.keywords,
copyright: configForm.value.copyright,
site_logo: configForm.value.site_logo,
maintenance_mode: configForm.value.maintenance_mode,
enable_register: configForm.value.enable_register, // 新增:保存开启注册开关
forbidden_words: configForm.value.forbidden_words,
@@ -267,6 +415,84 @@ const saveConfig = async () => {
}
}
// Logo选择器方法
const openLogoSelector = () => {
showLogoSelector.value = true
loadFileList()
}
const clearLogo = () => {
configForm.value.site_logo = ''
}
const loadFileList = async () => {
try {
loading.value = true
const { useFileApi } = await import('~/composables/useFileApi')
const fileApi = useFileApi()
const response = await fileApi.getFileList({
page: pagination.value.page,
pageSize: pagination.value.pageSize,
search: searchKeyword.value,
fileType: 'image', // 只获取图片文件
status: 'active' // 只获取正常状态的文件
}) as any
if (response && response.data) {
fileList.value = response.data.files || []
pagination.value.total = response.data.total || 0
console.log('获取到的图片文件:', fileList.value) // 调试信息
}
} catch (error) {
console.error('获取文件列表失败:', error)
notification.error({
content: '获取文件列表失败',
duration: 3000
})
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.value.page = 1
loadFileList()
}
const handlePageChange = (page: number) => {
pagination.value.page = page
loadFileList()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
loadFileList()
}
const selectFile = (file: any) => {
selectedFileId.value = file.id
}
const confirmSelection = () => {
if (selectedFileId.value) {
const file = fileList.value.find(f => f.id === selectedFileId.value)
if (file) {
configForm.value.site_logo = file.access_url
showLogoSelector.value = false
selectedFileId.value = null
}
}
}
const formatFileSize = (size: number) => {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + ' MB'
return (size / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
}
// 页面加载时获取配置
onMounted(() => {
fetchConfig()
@@ -277,4 +503,33 @@ onMounted(() => {
<style scoped>
/* 自定义样式 */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
max-height: 400px;
overflow-y: auto;
}
.file-item {
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
}
.file-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.preview-image {
width: 100%;
height: 120px;
object-fit: cover;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 1rem;
}
</style>