mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 03:15:04 +08:00
Compare commits
2 Commits
3bd0fde82f
...
11a3204c18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a3204c18 | ||
|
|
5276112e48 |
@@ -1,12 +1,66 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CopyrightClaimToResponse 将版权申述实体转换为响应对象
|
||||
// CopyrightClaimToResponseWithResources 将版权申述实体和关联资源转换为响应对象
|
||||
func CopyrightClaimToResponseWithResources(claim *entity.CopyrightClaim, resources []*entity.Resource) *dto.CopyrightClaimResponse {
|
||||
if claim == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 转换关联的资源信息
|
||||
var resourceInfos []dto.ResourceInfo
|
||||
for _, resource := range resources {
|
||||
categoryName := ""
|
||||
if resource.Category.ID != 0 {
|
||||
categoryName = resource.Category.Name
|
||||
}
|
||||
|
||||
panName := ""
|
||||
if resource.Pan.ID != 0 {
|
||||
panName = resource.Pan.Name
|
||||
}
|
||||
|
||||
resourceInfo := dto.ResourceInfo{
|
||||
ID: resource.ID,
|
||||
Title: resource.Title,
|
||||
Description: resource.Description,
|
||||
URL: resource.URL,
|
||||
SaveURL: resource.SaveURL,
|
||||
FileSize: resource.FileSize,
|
||||
Category: categoryName,
|
||||
PanName: panName,
|
||||
ViewCount: resource.ViewCount,
|
||||
IsValid: resource.IsValid,
|
||||
CreatedAt: resource.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
resourceInfos = append(resourceInfos, resourceInfo)
|
||||
}
|
||||
|
||||
return &dto.CopyrightClaimResponse{
|
||||
ID: claim.ID,
|
||||
ResourceKey: claim.ResourceKey,
|
||||
Identity: claim.Identity,
|
||||
ProofType: claim.ProofType,
|
||||
Reason: claim.Reason,
|
||||
ContactInfo: claim.ContactInfo,
|
||||
ClaimantName: claim.ClaimantName,
|
||||
ProofFiles: claim.ProofFiles,
|
||||
UserAgent: claim.UserAgent,
|
||||
IPAddress: claim.IPAddress,
|
||||
Status: claim.Status,
|
||||
Note: claim.Note,
|
||||
CreatedAt: claim.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: claim.UpdatedAt.Format(time.RFC3339),
|
||||
Resources: resourceInfos,
|
||||
}
|
||||
}
|
||||
|
||||
// CopyrightClaimToResponse 将版权申述实体转换为响应对象(不包含资源详情)
|
||||
func CopyrightClaimToResponse(claim *entity.CopyrightClaim) *dto.CopyrightClaimResponse {
|
||||
if claim == nil {
|
||||
return nil
|
||||
@@ -27,6 +81,7 @@ func CopyrightClaimToResponse(claim *entity.CopyrightClaim) *dto.CopyrightClaimR
|
||||
Note: claim.Note,
|
||||
CreatedAt: claim.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: claim.UpdatedAt.Format(time.RFC3339),
|
||||
Resources: []dto.ResourceInfo{}, // 空的资源列表
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,20 +21,21 @@ type CopyrightClaimUpdateRequest struct {
|
||||
|
||||
// CopyrightClaimResponse 版权申述响应
|
||||
type CopyrightClaimResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
Identity string `json:"identity"`
|
||||
ProofType string `json:"proof_type"`
|
||||
Reason string `json:"reason"`
|
||||
ContactInfo string `json:"contact_info"`
|
||||
ClaimantName string `json:"claimant_name"`
|
||||
ProofFiles string `json:"proof_files"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Status string `json:"status"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID uint `json:"id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
Identity string `json:"identity"`
|
||||
ProofType string `json:"proof_type"`
|
||||
Reason string `json:"reason"`
|
||||
ContactInfo string `json:"contact_info"`
|
||||
ClaimantName string `json:"claimant_name"`
|
||||
ProofFiles string `json:"proof_files"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Status string `json:"status"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Resources []ResourceInfo `json:"resources"`
|
||||
}
|
||||
|
||||
// CopyrightClaimListRequest 版权申述列表请求
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
@@ -15,12 +16,14 @@ import (
|
||||
|
||||
type CopyrightClaimHandler struct {
|
||||
copyrightClaimRepo repo.CopyrightClaimRepository
|
||||
resourceRepo repo.ResourceRepository
|
||||
validate *validator.Validate
|
||||
}
|
||||
|
||||
func NewCopyrightClaimHandler(copyrightClaimRepo repo.CopyrightClaimRepository) *CopyrightClaimHandler {
|
||||
func NewCopyrightClaimHandler(copyrightClaimRepo repo.CopyrightClaimRepository, resourceRepo repo.ResourceRepository) *CopyrightClaimHandler {
|
||||
return &CopyrightClaimHandler{
|
||||
copyrightClaimRepo: copyrightClaimRepo,
|
||||
resourceRepo: resourceRepo,
|
||||
validate: validator.New(),
|
||||
}
|
||||
}
|
||||
@@ -144,7 +147,38 @@ func (h *CopyrightClaimHandler) ListCopyrightClaims(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
PageResponse(c, converter.CopyrightClaimsToResponse(claims), total, req.Page, req.PageSize)
|
||||
// 转换为包含资源信息的响应
|
||||
var responses []*dto.CopyrightClaimResponse
|
||||
for _, claim := range claims {
|
||||
// 查询关联的资源信息
|
||||
resources, err := h.getResourcesByResourceKey(claim.ResourceKey)
|
||||
if err != nil {
|
||||
// 如果查询资源失败,使用空资源列表
|
||||
responses = append(responses, converter.CopyrightClaimToResponse(claim))
|
||||
} else {
|
||||
// 使用包含资源详情的转换函数
|
||||
responses = append(responses, converter.CopyrightClaimToResponseWithResources(claim, resources))
|
||||
}
|
||||
}
|
||||
|
||||
PageResponse(c, responses, total, req.Page, req.PageSize)
|
||||
}
|
||||
|
||||
// getResourcesByResourceKey 根据资源key获取关联的资源列表
|
||||
func (h *CopyrightClaimHandler) getResourcesByResourceKey(resourceKey string) ([]*entity.Resource, error) {
|
||||
// 从资源仓库获取与key关联的所有资源
|
||||
resources, err := h.resourceRepo.FindByResourceKey(resourceKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 将 []entity.Resource 转换为 []*entity.Resource
|
||||
var resourcePointers []*entity.Resource
|
||||
for i := range resources {
|
||||
resourcePointers = append(resourcePointers, &resources[i])
|
||||
}
|
||||
|
||||
return resourcePointers, nil
|
||||
}
|
||||
|
||||
// UpdateCopyrightClaim 更新版权申述状态
|
||||
@@ -263,16 +297,16 @@ func (h *CopyrightClaimHandler) GetCopyrightClaimByResource(c *gin.Context) {
|
||||
}
|
||||
|
||||
// RegisterCopyrightClaimRoutes 注册版权申述相关路由
|
||||
func RegisterCopyrightClaimRoutes(router *gin.RouterGroup, copyrightClaimRepo repo.CopyrightClaimRepository) {
|
||||
handler := NewCopyrightClaimHandler(copyrightClaimRepo)
|
||||
func RegisterCopyrightClaimRoutes(router *gin.RouterGroup, copyrightClaimRepo repo.CopyrightClaimRepository, resourceRepo repo.ResourceRepository) {
|
||||
handler := NewCopyrightClaimHandler(copyrightClaimRepo, resourceRepo)
|
||||
|
||||
claims := router.Group("/copyright-claims")
|
||||
{
|
||||
claims.POST("", handler.CreateCopyrightClaim) // 创建版权申述
|
||||
claims.GET("/:id", handler.GetCopyrightClaim) // 获取版权申述详情
|
||||
claims.GET("", handler.ListCopyrightClaims) // 获取版权申述列表
|
||||
claims.PUT("/:id", handler.UpdateCopyrightClaim) // 更新版权申述状态
|
||||
claims.DELETE("/:id", handler.DeleteCopyrightClaim) // 删除版权申述
|
||||
claims.GET("", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.ListCopyrightClaims) // 获取版权申述列表
|
||||
claims.PUT("/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.UpdateCopyrightClaim) // 更新版权申述状态
|
||||
claims.DELETE("/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.DeleteCopyrightClaim) // 删除版权申述
|
||||
claims.GET("/resource/:resource_key", handler.GetCopyrightClaimByResource) // 获取资源版权申述列表
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
pan "github.com/ctwj/urldb/common"
|
||||
panutils "github.com/ctwj/urldb/common"
|
||||
commonutils "github.com/ctwj/urldb/common/utils"
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
@@ -1212,6 +1213,282 @@ func GetRelatedResources(c *gin.Context) {
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
// CheckResourceValidity 检查资源链接有效性
|
||||
func CheckResourceValidity(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的资源ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 查询资源信息
|
||||
resource, err := repoManager.ResourceRepository.FindByID(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("开始检测资源有效性 - ID: %d, URL: %s", resource.ID, resource.URL)
|
||||
|
||||
// 检查缓存
|
||||
cacheKey := fmt.Sprintf("resource_validity_%d", resource.ID)
|
||||
cacheManager := utils.GetResourceValidityCache()
|
||||
ttl := 5 * time.Minute // 5分钟缓存
|
||||
|
||||
if cachedData, found := cacheManager.Get(cacheKey, ttl); found {
|
||||
if result, ok := cachedData.(gin.H); ok {
|
||||
utils.Info("使用资源有效性缓存 - ID: %d", resource.ID)
|
||||
result["cached"] = true
|
||||
SuccessResponse(c, result)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 执行检测:只使用深度检测实现
|
||||
isValid, detectionMethod, err := performAdvancedValidityCheck(resource)
|
||||
|
||||
if err != nil {
|
||||
utils.Error("深度检测资源链接失败 - ID: %d, Error: %v", resource.ID, err)
|
||||
|
||||
// 深度检测失败,但不标记为无效(用户可自行验证)
|
||||
result := gin.H{
|
||||
"resource_id": resource.ID,
|
||||
"url": resource.URL,
|
||||
"is_valid": resource.IsValid, // 保持原始状态
|
||||
"last_checked": time.Now().Format(time.RFC3339),
|
||||
"error": err.Error(),
|
||||
"detection_method": detectionMethod,
|
||||
"cached": false,
|
||||
"note": "当前网盘暂不支持自动检测,建议用户自行验证",
|
||||
}
|
||||
cacheManager.Set(cacheKey, result)
|
||||
SuccessResponse(c, result)
|
||||
return
|
||||
}
|
||||
|
||||
// 只有明确检测出无效的资源才更新数据库状态
|
||||
// 如果检测成功且结果与数据库状态不同,则更新
|
||||
if detectionMethod == "quark_deep" && isValid != resource.IsValid {
|
||||
resource.IsValid = isValid
|
||||
updateErr := repoManager.ResourceRepository.Update(resource)
|
||||
if updateErr != nil {
|
||||
utils.Error("更新资源有效性状态失败 - ID: %d, Error: %v", resource.ID, updateErr)
|
||||
} else {
|
||||
utils.Info("更新资源有效性状态 - ID: %d, Status: %v, Method: %s", resource.ID, isValid, detectionMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建检测结果
|
||||
result := gin.H{
|
||||
"resource_id": resource.ID,
|
||||
"url": resource.URL,
|
||||
"is_valid": isValid,
|
||||
"last_checked": time.Now().Format(time.RFC3339),
|
||||
"detection_method": detectionMethod,
|
||||
"cached": false,
|
||||
}
|
||||
|
||||
// 缓存检测结果
|
||||
cacheManager.Set(cacheKey, result)
|
||||
|
||||
utils.Info("资源有效性检测完成 - ID: %d, Valid: %v, Method: %s", resource.ID, isValid, detectionMethod)
|
||||
SuccessResponse(c, result)
|
||||
}
|
||||
|
||||
// performAdvancedValidityCheck 执行深度检测(只使用具体网盘服务)
|
||||
func performAdvancedValidityCheck(resource *entity.Resource) (bool, string, error) {
|
||||
// 提取分享ID和服务类型
|
||||
shareID, serviceType := panutils.ExtractShareId(resource.URL)
|
||||
if serviceType == panutils.NotFound {
|
||||
return false, "unsupported", fmt.Errorf("不支持的网盘服务: %s", resource.URL)
|
||||
}
|
||||
|
||||
utils.Info("开始深度检测 - Service: %s, ShareID: %s", serviceType.String(), shareID)
|
||||
|
||||
// 根据服务类型选择检测策略
|
||||
switch serviceType {
|
||||
case panutils.Quark:
|
||||
return performQuarkValidityCheck(resource, shareID)
|
||||
case panutils.Alipan:
|
||||
return performAlipanValidityCheck(resource, shareID)
|
||||
case panutils.BaiduPan, panutils.UC, panutils.Xunlei, panutils.Tianyi, panutils.Pan123, panutils.Pan115:
|
||||
// 这些网盘暂未实现深度检测,返回不支持提示
|
||||
return false, "unsupported", fmt.Errorf("当前网盘类型 %s 暂不支持深度检测,请等待后续更新", serviceType.String())
|
||||
default:
|
||||
return false, "unsupported", fmt.Errorf("未知的网盘服务类型: %s", serviceType.String())
|
||||
}
|
||||
}
|
||||
|
||||
// performQuarkValidityCheck 夸克网盘深度检测
|
||||
func performQuarkValidityCheck(resource *entity.Resource, shareID string) (bool, string, error) {
|
||||
// 获取夸克网盘账号
|
||||
panID, err := getQuarkPanID()
|
||||
if err != nil {
|
||||
return false, "quark_failed", fmt.Errorf("获取夸克平台ID失败: %v", err)
|
||||
}
|
||||
|
||||
accounts, err := repoManager.CksRepository.FindByPanID(panID)
|
||||
if err != nil {
|
||||
return false, "quark_failed", fmt.Errorf("获取夸克网盘账号失败: %v", err)
|
||||
}
|
||||
|
||||
if len(accounts) == 0 {
|
||||
return false, "quark_failed", fmt.Errorf("没有可用的夸克网盘账号")
|
||||
}
|
||||
|
||||
// 选择第一个有效账号
|
||||
var selectedAccount *entity.Cks
|
||||
for _, account := range accounts {
|
||||
if account.IsValid {
|
||||
selectedAccount = &account
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selectedAccount == nil {
|
||||
return false, "quark_failed", fmt.Errorf("没有有效的夸克网盘账号")
|
||||
}
|
||||
|
||||
// 创建网盘服务配置
|
||||
config := &pan.PanConfig{
|
||||
URL: resource.URL,
|
||||
Code: "",
|
||||
IsType: 1, // 只获取基本信息,不转存
|
||||
ExpiredType: 1,
|
||||
AdFid: "",
|
||||
Stoken: "",
|
||||
Cookie: selectedAccount.Ck,
|
||||
}
|
||||
|
||||
// 创建夸克网盘服务
|
||||
factory := pan.NewPanFactory()
|
||||
panService, err := factory.CreatePanService(resource.URL, config)
|
||||
if err != nil {
|
||||
return false, "quark_failed", fmt.Errorf("创建夸克网盘服务失败: %v", err)
|
||||
}
|
||||
|
||||
// 执行深度检测(Transfer方法)
|
||||
utils.Info("执行夸克网盘深度检测 - ShareID: %s", shareID)
|
||||
result, err := panService.Transfer(shareID)
|
||||
if err != nil {
|
||||
return false, "quark_failed", fmt.Errorf("夸克网盘检测失败: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
return false, "quark_failed", fmt.Errorf("夸克网盘链接无效: %s", result.Message)
|
||||
}
|
||||
|
||||
utils.Info("夸克网盘深度检测成功 - ShareID: %s", shareID)
|
||||
return true, "quark_deep", nil
|
||||
}
|
||||
|
||||
// performAlipanValidityCheck 阿里云盘深度检测
|
||||
func performAlipanValidityCheck(resource *entity.Resource, shareID string) (bool, string, error) {
|
||||
// 阿里云盘深度检测暂未实现
|
||||
utils.Info("阿里云盘暂不支持深度检测 - ShareID: %s", shareID)
|
||||
return false, "unsupported", fmt.Errorf("阿里云盘暂不支持深度检测,请等待后续更新")
|
||||
}
|
||||
|
||||
|
||||
// BatchCheckResourceValidity 批量检查资源链接有效性
|
||||
func BatchCheckResourceValidity(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
ErrorResponse(c, "ID列表不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) > 20 {
|
||||
ErrorResponse(c, "单次最多检测20个资源", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("开始批量检测资源有效性 - Count: %d", len(req.IDs))
|
||||
|
||||
cacheManager := utils.GetResourceValidityCache()
|
||||
ttl := 5 * time.Minute
|
||||
results := make([]gin.H, 0, len(req.IDs))
|
||||
|
||||
for _, id := range req.IDs {
|
||||
// 查询资源信息
|
||||
resource, err := repoManager.ResourceRepository.FindByID(id)
|
||||
if err != nil {
|
||||
results = append(results, gin.H{
|
||||
"resource_id": id,
|
||||
"is_valid": false,
|
||||
"error": "资源不存在",
|
||||
"cached": false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
cacheKey := fmt.Sprintf("resource_validity_%d", id)
|
||||
if cachedData, found := cacheManager.Get(cacheKey, ttl); found {
|
||||
if result, ok := cachedData.(gin.H); ok {
|
||||
result["cached"] = true
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 执行深度检测
|
||||
isValid, detectionMethod, err := performAdvancedValidityCheck(resource)
|
||||
|
||||
if err != nil {
|
||||
// 深度检测失败,但不标记为无效(用户可自行验证)
|
||||
result := gin.H{
|
||||
"resource_id": id,
|
||||
"url": resource.URL,
|
||||
"is_valid": resource.IsValid, // 保持原始状态
|
||||
"last_checked": time.Now().Format(time.RFC3339),
|
||||
"error": err.Error(),
|
||||
"detection_method": detectionMethod,
|
||||
"cached": false,
|
||||
"note": "当前网盘暂不支持自动检测,建议用户自行验证",
|
||||
}
|
||||
cacheManager.Set(cacheKey, result)
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// 只有明确检测出无效的资源才更新数据库状态
|
||||
if detectionMethod == "quark_deep" && isValid != resource.IsValid {
|
||||
resource.IsValid = isValid
|
||||
updateErr := repoManager.ResourceRepository.Update(resource)
|
||||
if updateErr != nil {
|
||||
utils.Error("更新资源有效性状态失败 - ID: %d, Error: %v", id, updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
result := gin.H{
|
||||
"resource_id": id,
|
||||
"url": resource.URL,
|
||||
"is_valid": isValid,
|
||||
"last_checked": time.Now().Format(time.RFC3339),
|
||||
"detection_method": detectionMethod,
|
||||
"cached": false,
|
||||
}
|
||||
|
||||
cacheManager.Set(cacheKey, result)
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
utils.Info("批量检测资源有效性完成 - Count: %d", len(results))
|
||||
SuccessResponse(c, gin.H{
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
// getQuarkPanID 获取夸克网盘ID
|
||||
func getQuarkPanID() (uint, error) {
|
||||
// 通过FindAll方法查找所有平台,然后过滤出quark平台
|
||||
|
||||
4
main.go
4
main.go
@@ -213,7 +213,7 @@ func main() {
|
||||
|
||||
// 创建举报和版权申述处理器
|
||||
reportHandler := handlers.NewReportHandler(repoManager.ReportRepository, repoManager.ResourceRepository)
|
||||
copyrightClaimHandler := handlers.NewCopyrightClaimHandler(repoManager.CopyrightClaimRepository)
|
||||
copyrightClaimHandler := handlers.NewCopyrightClaimHandler(repoManager.CopyrightClaimRepository, repoManager.ResourceRepository)
|
||||
|
||||
// API路由
|
||||
api := r.Group("/api")
|
||||
@@ -247,6 +247,8 @@ func main() {
|
||||
api.GET("/resources/related", handlers.GetRelatedResources)
|
||||
api.POST("/resources/:id/view", handlers.IncrementResourceViewCount)
|
||||
api.GET("/resources/:id/link", handlers.GetResourceLink)
|
||||
api.GET("/resources/:id/validity", handlers.CheckResourceValidity)
|
||||
api.POST("/resources/validity/batch", handlers.BatchCheckResourceValidity)
|
||||
api.DELETE("/resources/batch", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchDeleteResources)
|
||||
|
||||
// 分类管理
|
||||
|
||||
@@ -143,6 +143,9 @@ var (
|
||||
|
||||
// 标签缓存
|
||||
TagsCache = NewCacheManager()
|
||||
|
||||
// 资源有效性检测缓存
|
||||
ResourceValidityCache = NewCacheManager()
|
||||
)
|
||||
|
||||
// GetHotResourcesCache 获取热门资源缓存管理器
|
||||
@@ -170,6 +173,11 @@ func GetTagsCache() *CacheManager {
|
||||
return TagsCache
|
||||
}
|
||||
|
||||
// GetResourceValidityCache 获取资源有效性检测缓存管理器
|
||||
func GetResourceValidityCache() *CacheManager {
|
||||
return ResourceValidityCache
|
||||
}
|
||||
|
||||
// ClearAllCaches 清空所有全局缓存
|
||||
func ClearAllCaches() {
|
||||
HotResourcesCache.Clear()
|
||||
@@ -177,6 +185,7 @@ func ClearAllCaches() {
|
||||
SystemConfigCache.Clear()
|
||||
CategoriesCache.Clear()
|
||||
TagsCache.Clear()
|
||||
ResourceValidityCache.Clear()
|
||||
}
|
||||
|
||||
// CleanAllExpiredCaches 清理所有过期缓存
|
||||
@@ -187,6 +196,7 @@ func CleanAllExpiredCaches(ttl time.Duration) {
|
||||
totalCleaned += SystemConfigCache.CleanExpired(ttl)
|
||||
totalCleaned += CategoriesCache.CleanExpired(ttl)
|
||||
totalCleaned += TagsCache.CleanExpired(ttl)
|
||||
totalCleaned += ResourceValidityCache.CleanExpired(ttl)
|
||||
|
||||
if totalCleaned > 0 {
|
||||
Info("清理过期缓存完成,共清理 %d 个缓存项", totalCleaned)
|
||||
|
||||
@@ -63,6 +63,10 @@ export const useResourceApi = () => {
|
||||
const getResourceLink = (id: number) => useApiFetch(`/resources/${id}/link`).then(parseApiResponse)
|
||||
// 新增:获取相关资源
|
||||
const getRelatedResources = (params?: any) => useApiFetch('/resources/related', { params }).then(parseApiResponse)
|
||||
// 新增:检查资源有效性
|
||||
const checkResourceValidity = (id: number) => useApiFetch(`/resources/${id}/validity`).then(parseApiResponse)
|
||||
// 新增:批量检查资源有效性
|
||||
const batchCheckResourceValidity = (ids: number[]) => useApiFetch('/resources/validity/batch', { method: 'POST', body: { ids } }).then(parseApiResponse)
|
||||
// 新增:提交举报
|
||||
const submitReport = (data: any) => useApiFetch('/reports', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
// 新增:提交版权申述
|
||||
@@ -82,7 +86,7 @@ export const useResourceApi = () => {
|
||||
const deleteCopyrightClaim = (id: number) => useApiFetch(`/copyright-claims/${id}`, { method: 'DELETE' }).then(parseApiResponse)
|
||||
|
||||
return {
|
||||
getResources, getHotResources, getResource, getResourcesByKey, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink, getRelatedResources,
|
||||
getResources, getHotResources, getResource, getResourcesByKey, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink, getRelatedResources, checkResourceValidity, batchCheckResourceValidity,
|
||||
submitReport, submitCopyrightClaim,
|
||||
getReports, getReport, updateReport, deleteReport, getReportsRaw,
|
||||
getCopyrightClaims, getCopyrightClaim, updateCopyrightClaim, deleteCopyrightClaim
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
:loading="loading"
|
||||
:scroll-x="1200"
|
||||
:scroll-x="1020"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -143,7 +143,20 @@
|
||||
</div>
|
||||
<div v-if="selectedClaim.proof_files">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">证明文件</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100 break-all">{{ selectedClaim.proof_files }}</p>
|
||||
<div class="mt-1 space-y-2">
|
||||
<div
|
||||
v-for="(file, index) in getProofFiles(selectedClaim.proof_files)"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||
@click="downloadFile(file)"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fas fa-file-download text-blue-500"></i>
|
||||
<span class="text-sm text-gray-900 dark:text-gray-100">{{ getFileName(file) }}</span>
|
||||
</div>
|
||||
<i class="fas fa-download text-gray-400 hover:text-blue-500 transition-colors"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">提交时间</h3>
|
||||
@@ -204,102 +217,180 @@ const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
width: 60,
|
||||
render: (row: any) => {
|
||||
return h('span', { class: 'font-medium' }, row.id)
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
h('div', { class: 'font-medium text-sm' }, row.id),
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-400',
|
||||
title: `IP: ${row.ip_address || '未知'}`
|
||||
}, row.ip_address ? `IP: ${row.ip_address.slice(0, 8)}...` : 'IP:未知')
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '资源Key',
|
||||
title: '资源',
|
||||
key: 'resource_key',
|
||||
width: 200,
|
||||
render: (row: any) => {
|
||||
const resourceInfo = getResourceInfo(row);
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
// 第一行:标题(单行,省略号)
|
||||
h('div', {
|
||||
class: 'font-medium text-sm truncate max-w-[200px]',
|
||||
style: { maxWidth: '200px' },
|
||||
title: resourceInfo.title // 鼠标hover显示完整标题
|
||||
}, resourceInfo.title),
|
||||
// 第二行:详情(单行,省略号)
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]',
|
||||
style: { maxWidth: '200px' },
|
||||
title: resourceInfo.description // 鼠标hover显示完整描述
|
||||
}, resourceInfo.description),
|
||||
// 第三行:分类图片和链接数
|
||||
h('div', { class: 'flex items-center gap-1' }, [
|
||||
h('i', {
|
||||
class: `fas fa-${getCategoryIcon(resourceInfo.category)} text-blue-500 text-xs`,
|
||||
// 鼠标hover显示第一个资源的链接地址
|
||||
title: resourceInfo.resources.length > 0 ? `链接地址: ${resourceInfo.resources[0].save_url || resourceInfo.resources[0].url}` : `资源链接地址: ${row.resource_key}`
|
||||
}),
|
||||
h('span', { class: 'text-xs text-gray-400' }, `链接数: ${resourceInfo.resources.length}`)
|
||||
])
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '申述人信息',
|
||||
key: 'claimant_info',
|
||||
width: 180,
|
||||
render: (row: any) => {
|
||||
return h('n-tag', {
|
||||
type: 'info',
|
||||
size: 'small',
|
||||
class: 'truncate max-w-xs'
|
||||
}, { default: () => row.resource_key })
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
// 第一行:姓名和身份
|
||||
h('div', { class: 'font-medium text-sm' }, [
|
||||
h('i', { class: 'fas fa-user text-green-500 mr-1 text-xs' }),
|
||||
row.claimant_name || '未知'
|
||||
]),
|
||||
h('div', {
|
||||
class: 'text-xs text-blue-600 dark:text-blue-400 truncate max-w-[180px]',
|
||||
title: getIdentityLabel(row.identity)
|
||||
}, getIdentityLabel(row.identity)),
|
||||
// 第二行:联系方式
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[180px]',
|
||||
title: row.contact_info
|
||||
}, [
|
||||
h('i', { class: 'fas fa-phone text-purple-500 mr-1' }),
|
||||
row.contact_info || '未提供'
|
||||
]),
|
||||
// 第三行:证明类型
|
||||
h('div', {
|
||||
class: 'text-xs text-orange-600 dark:text-orange-400 truncate max-w-[180px]',
|
||||
title: getProofTypeLabel(row.proof_type)
|
||||
}, [
|
||||
h('i', { class: 'fas fa-certificate text-orange-500 mr-1' }),
|
||||
getProofTypeLabel(row.proof_type)
|
||||
])
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '申述人身份',
|
||||
key: 'identity',
|
||||
width: 120,
|
||||
title: '申述详情',
|
||||
key: 'claim_details',
|
||||
width: 280,
|
||||
render: (row: any) => {
|
||||
return h('span', null, getIdentityLabel(row.identity))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '证明类型',
|
||||
key: 'proof_type',
|
||||
width: 140,
|
||||
render: (row: any) => {
|
||||
return h('span', null, getProofTypeLabel(row.proof_type))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '申述人姓名',
|
||||
key: 'claimant_name',
|
||||
width: 120,
|
||||
render: (row: any) => {
|
||||
return h('span', null, row.claimant_name)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '联系方式',
|
||||
key: 'contact_info',
|
||||
width: 150,
|
||||
render: (row: any) => {
|
||||
return h('span', null, row.contact_info)
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
// 第一行:申述理由和提交时间
|
||||
h('div', { class: 'space-y-1' }, [
|
||||
h('div', { class: 'text-xs text-gray-500 dark:text-gray-400' }, '申述理由:'),
|
||||
h('div', {
|
||||
class: 'text-sm text-gray-700 dark:text-gray-300 line-clamp-2 max-h-10',
|
||||
title: row.reason
|
||||
}, row.reason || '无'),
|
||||
h('div', { class: 'text-xs text-gray-400' }, [
|
||||
h('i', { class: 'fas fa-clock mr-1' }),
|
||||
`提交时间: ${formatDateTime(row.created_at)}`
|
||||
])
|
||||
]),
|
||||
// 第二行:证明文件
|
||||
row.proof_files ?
|
||||
h('div', { class: 'space-y-1' }, [
|
||||
h('div', { class: 'text-xs text-gray-500 dark:text-gray-400' }, '证明文件:'),
|
||||
...getProofFiles(row.proof_files).slice(0, 2).map((file, index) =>
|
||||
h('div', {
|
||||
class: 'text-xs text-blue-600 dark:text-blue-400 truncate max-w-[280px] cursor-pointer hover:text-blue-500 hover:underline',
|
||||
title: `点击下载: ${file}`,
|
||||
onClick: () => downloadFile(file)
|
||||
}, [
|
||||
h('i', { class: 'fas fa-download text-blue-500 mr-1' }),
|
||||
getFileName(file)
|
||||
])
|
||||
),
|
||||
getProofFiles(row.proof_files).length > 2 ?
|
||||
h('div', { class: 'text-xs text-gray-400' }, `还有 ${getProofFiles(row.proof_files).length - 2} 个文件...`) : null
|
||||
]) :
|
||||
h('div', { class: 'text-xs text-gray-400' }, '无证明文件'),
|
||||
// 第三行:处理备注(如果有)
|
||||
row.note ?
|
||||
h('div', { class: 'space-y-1' }, [
|
||||
h('div', { class: 'text-xs text-gray-500 dark:text-gray-400' }, '处理备注:'),
|
||||
h('div', {
|
||||
class: 'text-xs text-yellow-600 dark:text-yellow-400 truncate max-w-[280px]',
|
||||
title: row.note
|
||||
}, [
|
||||
h('i', { class: 'fas fa-sticky-note text-yellow-500 mr-1' }),
|
||||
row.note.length > 30 ? `${row.note.slice(0, 30)}...` : row.note
|
||||
])
|
||||
]) : null
|
||||
].filter(Boolean))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
width: 100,
|
||||
render: (row: any) => {
|
||||
const type = getStatusType(row.status)
|
||||
return h('n-tag', {
|
||||
type: type,
|
||||
size: 'small',
|
||||
bordered: false
|
||||
}, { default: () => getStatusLabel(row.status) })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (row: any) => {
|
||||
return h('span', null, formatDateTime(row.created_at))
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
h('n-tag', {
|
||||
type: type,
|
||||
size: 'small',
|
||||
bordered: false
|
||||
}, { default: () => getStatusLabel(row.status) }),
|
||||
// 显示处理时间(如果已处理)
|
||||
(row.status !== 'pending' && row.updated_at) ?
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-400',
|
||||
title: `处理时间: ${formatDateTime(row.updated_at)}`
|
||||
}, `更新: ${new Date(row.updated_at).toLocaleDateString()}`) : null
|
||||
].filter(Boolean))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
width: 160,
|
||||
render: (row: any) => {
|
||||
const buttons = [
|
||||
h('button', {
|
||||
class: 'px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 rounded transition-colors mr-1',
|
||||
class: 'px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 rounded transition-colors mb-1 w-full',
|
||||
onClick: () => viewClaim(row)
|
||||
}, [
|
||||
h('i', { class: 'fas fa-eye mr-1 text-xs' }),
|
||||
'查看'
|
||||
'查看详情'
|
||||
])
|
||||
]
|
||||
|
||||
if (row.status === 'pending') {
|
||||
buttons.push(
|
||||
h('button', {
|
||||
class: 'px-2 py-1 text-xs bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/20 dark:text-green-400 rounded transition-colors mr-1',
|
||||
class: 'px-2 py-1 text-xs bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/20 dark:text-green-400 rounded transition-colors mb-1 w-full',
|
||||
onClick: () => updateClaimStatus(row, 'approved')
|
||||
}, [
|
||||
h('i', { class: 'fas fa-check mr-1 text-xs' }),
|
||||
'批准'
|
||||
]),
|
||||
h('button', {
|
||||
class: 'px-2 py-1 text-xs bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded transition-colors',
|
||||
class: 'px-2 py-1 text-xs bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded transition-colors w-full',
|
||||
onClick: () => updateClaimStatus(row, 'rejected')
|
||||
}, [
|
||||
h('i', { class: 'fas fa-times mr-1 text-xs' }),
|
||||
@@ -308,7 +399,7 @@ const columns = [
|
||||
)
|
||||
}
|
||||
|
||||
return h('div', { class: 'flex items-center gap-1' }, buttons)
|
||||
return h('div', { class: 'flex flex-col gap-1' }, buttons)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -338,8 +429,18 @@ const fetchClaims = async () => {
|
||||
if (filters.value.resourceKey) params.resource_key = filters.value.resourceKey
|
||||
|
||||
const response = await resourceApi.getCopyrightClaims(params)
|
||||
claims.value = response.items || []
|
||||
pagination.value.total = response.total || 0
|
||||
console.log(response)
|
||||
|
||||
// 检查响应格式并处理
|
||||
if (response && response.data && response.data.list !== undefined) {
|
||||
// 如果后端返回了分页格式,使用正确的字段
|
||||
claims.value = response.data.list || []
|
||||
pagination.value.total = response.data.total || 0
|
||||
} else {
|
||||
// 如果是其他格式,尝试直接使用响应
|
||||
claims.value = response || []
|
||||
pagination.value.total = response.length || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取版权申述列表失败:', error)
|
||||
// 显示错误提示
|
||||
@@ -503,6 +604,220 @@ const formatDateTime = (dateString: string) => {
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取分类图标
|
||||
const getCategoryIcon = (category: string) => {
|
||||
if (!category) return 'folder';
|
||||
|
||||
// 根据分类名称返回对应的图标
|
||||
const categoryMap: Record<string, string> = {
|
||||
'文档': 'file-alt',
|
||||
'文档资料': 'file-alt',
|
||||
'压缩包': 'file-archive',
|
||||
'图片': 'images',
|
||||
'视频': 'film',
|
||||
'音乐': 'music',
|
||||
'电子书': 'book',
|
||||
'软件': 'cogs',
|
||||
'应用': 'mobile-alt',
|
||||
'游戏': 'gamepad',
|
||||
'资料': 'folder',
|
||||
'其他': 'file',
|
||||
'folder': 'folder',
|
||||
'file': 'file'
|
||||
};
|
||||
|
||||
return categoryMap[category] || 'folder';
|
||||
}
|
||||
|
||||
// 获取资源信息显示
|
||||
const getResourceInfo = (row: any) => {
|
||||
// 从后端返回的资源列表中获取信息
|
||||
const resources = row.resources || [];
|
||||
|
||||
if (resources.length > 0) {
|
||||
// 如果有多个资源,可以选择第一个或合并信息
|
||||
const resource = resources[0];
|
||||
return {
|
||||
title: resource.title || `资源: ${row.resource_key}`,
|
||||
description: resource.description || `资源详情: ${row.resource_key}`,
|
||||
category: resource.category || 'folder',
|
||||
resources: resources // 返回所有资源用于显示链接数量等
|
||||
}
|
||||
} else {
|
||||
// 如果没有关联资源,使用默认值
|
||||
return {
|
||||
title: `资源: ${row.resource_key}`,
|
||||
description: `资源详情: ${row.resource_key}`,
|
||||
category: 'folder',
|
||||
resources: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析证明文件字符串
|
||||
const getProofFiles = (proofFiles: string) => {
|
||||
if (!proofFiles) return []
|
||||
|
||||
console.log('原始证明文件数据:', proofFiles)
|
||||
|
||||
try {
|
||||
// 尝试解析为JSON格式
|
||||
const parsed = JSON.parse(proofFiles)
|
||||
console.log('JSON解析结果:', parsed)
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
// 处理对象数组格式:[{id: "xxx", name: "文件名.pdf", status: "pending"}]
|
||||
const fileObjects = parsed.filter(item => item && typeof item === 'object')
|
||||
if (fileObjects.length > 0) {
|
||||
// 返回原始对象,包含完整信息
|
||||
console.log('解析出文件对象数组:', fileObjects)
|
||||
return fileObjects
|
||||
}
|
||||
|
||||
// 如果不是对象数组,尝试作为字符串数组处理
|
||||
const files = parsed.filter(file => file && typeof file === 'string' && file.trim()).map(file => file.trim())
|
||||
if (files.length > 0) {
|
||||
console.log('解析出的文件字符串数组:', files)
|
||||
return files
|
||||
}
|
||||
} else if (typeof parsed === 'object' && parsed.url) {
|
||||
console.log('解析出的单个文件:', parsed.url)
|
||||
return [parsed.url]
|
||||
} else if (typeof parsed === 'object' && parsed.files) {
|
||||
// 处理 {files: ["url1", "url2"]} 格式
|
||||
if (Array.isArray(parsed.files)) {
|
||||
const files = parsed.files.filter(file => file && file.trim()).map(file => file.trim())
|
||||
console.log('解析出的files数组:', files)
|
||||
return files
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('JSON解析失败,尝试分隔符解析:', e.message)
|
||||
// 如果不是JSON格式,按分隔符解析
|
||||
// 假设文件URL以逗号、分号或换行符分隔
|
||||
const files = proofFiles.split(/[,;\n\r]+/).filter(file => file.trim()).map(file => file.trim())
|
||||
console.log('分隔符解析结果:', files)
|
||||
return files
|
||||
}
|
||||
|
||||
console.log('未解析出任何文件')
|
||||
return []
|
||||
}
|
||||
|
||||
// 获取文件名
|
||||
const getFileName = (fileInfo: any) => {
|
||||
if (!fileInfo) return '未知文件'
|
||||
|
||||
// 如果是对象,优先使用name字段
|
||||
if (typeof fileInfo === 'object') {
|
||||
return fileInfo.name || fileInfo.id || '未知文件'
|
||||
}
|
||||
|
||||
// 如果是字符串,从URL中提取文件名
|
||||
const fileName = fileInfo.split('/').pop() || fileInfo.split('\\').pop() || fileInfo
|
||||
|
||||
// 如果URL太长,截断显示
|
||||
return fileName.length > 50 ? fileName.substring(0, 47) + '...' : fileName
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = async (fileInfo: any) => {
|
||||
console.log('尝试下载文件:', fileInfo)
|
||||
|
||||
if (!fileInfo) {
|
||||
console.error('文件信息为空')
|
||||
if (process.client) {
|
||||
notification.warning({
|
||||
content: '文件信息无效',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let downloadUrl = ''
|
||||
let fileName = ''
|
||||
|
||||
// 处理文件对象格式:{id: "xxx", name: "文件名.pdf", status: "pending"}
|
||||
if (typeof fileInfo === 'object' && fileInfo.id) {
|
||||
fileName = fileInfo.name || fileInfo.id
|
||||
// 构建下载API URL,假设有 /api/files/{id} 端点
|
||||
downloadUrl = `/api/files/${fileInfo.id}`
|
||||
console.log('文件对象下载:', { id: fileInfo.id, name: fileName, url: downloadUrl })
|
||||
}
|
||||
// 处理字符串格式(直接是URL)
|
||||
else if (typeof fileInfo === 'string') {
|
||||
downloadUrl = fileInfo
|
||||
fileName = getFileName(fileInfo)
|
||||
|
||||
// 检查是否是文件名(不包含http://或https://或/开头)
|
||||
if (!fileInfo.match(/^https?:\/\//) && !fileInfo.startsWith('/')) {
|
||||
console.log('检测到纯文件名,需要通过API下载:', fileName)
|
||||
|
||||
if (process.client) {
|
||||
notification.info({
|
||||
content: `文件 "${fileName}" 需要通过API下载,功能开发中...`,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 处理相对路径URL
|
||||
if (fileInfo.startsWith('/uploads/')) {
|
||||
downloadUrl = `${window.location.origin}${fileInfo}`
|
||||
console.log('处理本地文件URL:', downloadUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (!downloadUrl) {
|
||||
console.error('无法确定下载URL')
|
||||
if (process.client) {
|
||||
notification.warning({
|
||||
content: '无法确定下载地址',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.target = '_blank' // 在新标签页打开,避免跨域问题
|
||||
|
||||
// 设置下载文件名
|
||||
link.download = fileName.includes('.') ? fileName : fileName + '.file'
|
||||
|
||||
console.log('下载参数:', {
|
||||
originalInfo: fileInfo,
|
||||
downloadUrl: downloadUrl,
|
||||
fileName: fileName
|
||||
})
|
||||
|
||||
// 添加到页面并触发点击
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
if (process.client) {
|
||||
notification.success({
|
||||
content: `开始下载: ${fileName}`,
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载文件失败:', error)
|
||||
if (process.client) {
|
||||
notification.error({
|
||||
content: `下载失败: ${error.message}`,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
onMounted(() => {
|
||||
fetchClaims()
|
||||
|
||||
@@ -128,10 +128,19 @@
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<i class="fas fa-cloud-download-alt text-blue-500"></i>
|
||||
网盘资源 ({{ resourcesData?.resources?.length || 0 }})
|
||||
</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<i class="fas fa-cloud-download-alt text-blue-500"></i>
|
||||
网盘资源 ({{ resourcesData?.resources?.length || 0 }})
|
||||
</h3>
|
||||
|
||||
<!-- 检测状态总览 -->
|
||||
<div v-if="resourcesData?.resources?.length > 0" class="flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium" :class="detectionStatus.text + ' bg-opacity-10 ' + detectionStatus.text.replace('text', 'bg')">
|
||||
<i :class="detectionStatus.icon"></i>
|
||||
<span>{{ detectionStatus.label }}</span>
|
||||
<span v-if="detectionStatus.detectedCount > 0" class="ml-1 opacity-75">({{ detectionStatus.detectedCount }}已检测)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分享按钮 -->
|
||||
<ShareButtons
|
||||
@@ -175,16 +184,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<!-- 右侧:检测状态和操作按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 检测状态标签(放在最前面) -->
|
||||
<div v-if="detectionMethods[resource.id]" class="flex items-center gap-1">
|
||||
<!-- 检测方法标识 -->
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded text-xs font-medium"
|
||||
:class="getDetectionMethodClass(detectionMethods[resource.id])"
|
||||
:title="getDetectionMethodTitle(detectionMethods[resource.id], resource)"
|
||||
>
|
||||
{{ getDetectionMethodLabel(detectionMethods[resource.id]) }}
|
||||
</span>
|
||||
|
||||
<!-- 不支持检测的三角感叹号提示 -->
|
||||
<span v-if="detectionMethods[resource.id] === 'unsupported'" class="text-amber-600 dark:text-amber-400" title="当前网盘暂不支持自动检测,建议您点击链接自行验证">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 检测中状态 -->
|
||||
<div v-if="isDetecting && !detectionResults[resource.id]" class="flex items-center gap-2 px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div v-if="isDetecting && !detectionResults[resource.id]" class="flex items-center gap-2 px-3 py-2 text-sm text-blue-600 dark:text-blue-400">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
<span>检测中</span>
|
||||
<span>检测中... ({{ Object.keys(detectionResults).length }}/{{ resourcesData?.resources?.length }})</span>
|
||||
</div>
|
||||
|
||||
<!-- 检测完成后的按钮 -->
|
||||
<template v-else>
|
||||
<!-- 重新检测按钮 -->
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-green-200 dark:border-green-400/30 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors flex items-center gap-1 disabled:opacity-50"
|
||||
:disabled="isDetecting"
|
||||
@click="smartDetectResourceValidity(true)"
|
||||
title="重新检测链接有效性"
|
||||
>
|
||||
<i class="fas" :class="isDetecting ? 'fa-spinner fa-spin' : 'fa-sync-alt'"></i>
|
||||
<span class="hidden sm:inline">{{ isDetecting ? '检测中' : '重测' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 获取链接按钮 -->
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border border-blue-200 dark:border-blue-400/30 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
:disabled="resource.forbidden || loadingStates[resource.id]"
|
||||
@@ -194,6 +232,7 @@
|
||||
{{ resource.forbidden ? '受限' : (loadingStates[resource.id] ? '获取中' : '获取链接') }}
|
||||
</button>
|
||||
|
||||
<!-- 复制转存链接按钮 -->
|
||||
<button
|
||||
v-if="resource.save_url && !resource.forbidden"
|
||||
class="p-2 text-sm rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-600 transition-colors"
|
||||
@@ -453,6 +492,9 @@ const selectedResource = ref<any>(null)
|
||||
const loadingStates = ref<Record<number, boolean>>({})
|
||||
const isDetecting = ref(false)
|
||||
const detectionResults = ref<Record<number, boolean>>({})
|
||||
const detectionErrors = ref<Record<number, string>>({})
|
||||
const detectionMethods = ref<Record<number, string>>({})
|
||||
const detectionNotes = ref<Record<number, string>>({})
|
||||
const relatedResources = ref<any[]>([])
|
||||
const relatedResourcesLoading = ref(true)
|
||||
const hotResources = ref<any[]>([])
|
||||
@@ -496,23 +538,16 @@ const mainResource = computed(() => {
|
||||
|
||||
// 生成完整的资源URL
|
||||
const getResourceUrl = computed(() => {
|
||||
const config = useRuntimeConfig()
|
||||
const key = mainResource.value?.key
|
||||
if (!key) return ''
|
||||
|
||||
// 优先使用配置中的站点URL,如果未设置则使用当前页面的origin
|
||||
let siteUrl = config.public.siteUrl
|
||||
if (!siteUrl || siteUrl === 'https://yourdomain.com') {
|
||||
// 在客户端使用当前页面的origin
|
||||
if (typeof window !== 'undefined') {
|
||||
siteUrl = window.location.origin
|
||||
} else {
|
||||
// 在服务端渲染时,使用默认值(这应该在部署时被环境变量覆盖)
|
||||
siteUrl = process.env.NUXT_PUBLIC_SITE_URL || 'https://yourdomain.com'
|
||||
}
|
||||
// 在客户端直接使用当前页面的origin
|
||||
if (typeof window !== 'undefined') {
|
||||
return `${window.location.origin}/r/${key}`
|
||||
}
|
||||
|
||||
return `${siteUrl}/r/${key}`
|
||||
// 在服务端渲染时返回相对路径(客户端会自动补全)
|
||||
return `/r/${key}`
|
||||
})
|
||||
|
||||
// 服务端相关资源处理(去重)
|
||||
@@ -555,7 +590,8 @@ const detectionStatus = computed(() => {
|
||||
return {
|
||||
icon: 'fas fa-spinner fa-spin text-blue-600',
|
||||
text: 'text-blue-600',
|
||||
label: '检测中'
|
||||
label: '检测中',
|
||||
detectedCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,30 +600,49 @@ const detectionStatus = computed(() => {
|
||||
return {
|
||||
icon: 'fas fa-question-circle text-gray-400',
|
||||
text: 'text-gray-400',
|
||||
label: '未知状态'
|
||||
label: '未知状态',
|
||||
detectedCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
const validCount = resources.filter(r => detectionResults.value[r.id] !== false && (detectionResults.value[r.id] || r.is_valid)).length
|
||||
const totalCount = resources.length
|
||||
// 统计有效和已检测的资源(只统计真正有检测结果的)
|
||||
const detectedResources = resources.filter(r => detectionResults.value[r.id] !== undefined)
|
||||
const validCount = detectedResources.filter(r => detectionResults.value[r.id] === true).length
|
||||
|
||||
const totalCount = detectedResources.length
|
||||
const undetectedCount = resources.length - detectedResources.length
|
||||
|
||||
// 如果没有检测任何资源,显示未检测状态
|
||||
if (detectedResources.length === 0) {
|
||||
return {
|
||||
icon: 'fas fa-question-circle text-gray-400',
|
||||
text: 'text-gray-400',
|
||||
label: '未检测',
|
||||
detectedCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 基于已检测的资源显示状态
|
||||
if (validCount === totalCount) {
|
||||
return {
|
||||
icon: 'fas fa-check-circle text-green-600',
|
||||
text: 'text-green-600',
|
||||
label: '全部有效'
|
||||
label: '全部有效',
|
||||
detectedCount: detectedResources.length
|
||||
}
|
||||
} else if (validCount === 0) {
|
||||
return {
|
||||
icon: 'fas fa-times-circle text-red-600',
|
||||
text: 'text-red-600',
|
||||
label: '全部无效'
|
||||
label: '全部无效',
|
||||
detectedCount: detectedResources.length
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
icon: 'fas fa-exclamation-triangle text-orange-600',
|
||||
text: 'text-orange-600',
|
||||
label: `${validCount}/${totalCount} 有效`
|
||||
label: `${validCount}/${totalCount} 有效`,
|
||||
detectedCount: detectedResources.length
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -643,28 +698,77 @@ const detectResourceValidity = async () => {
|
||||
|
||||
isDetecting.value = true
|
||||
detectionResults.value = {} // 重置检测结果
|
||||
detectionErrors.value = {} // 重置错误信息
|
||||
detectionMethods.value = {} // 重置检测方法
|
||||
detectionNotes.value = {} // 重置检测提示
|
||||
|
||||
try {
|
||||
// 逐个检测每个资源
|
||||
for (const resource of resourcesData.value.resources) {
|
||||
try {
|
||||
// 这里可以添加实际的检测逻辑
|
||||
// const result = await resourceApi.checkResourceValidity(resource.id)
|
||||
// detectionResults.value[resource.id] = result.isValid
|
||||
// 提取所有资源ID
|
||||
const resourceIds = resourcesData.value.resources.map(r => r.id)
|
||||
|
||||
// 暂时使用现有的is_valid字段,但添加随机延迟模拟真实检测
|
||||
await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 700))
|
||||
detectionResults.value[resource.id] = resource.is_valid
|
||||
} catch (error) {
|
||||
console.error(`检测资源 ${resource.id} 失败:`, error)
|
||||
detectionResults.value[resource.id] = false
|
||||
}
|
||||
// 批量检测所有资源
|
||||
const response = await resourceApi.batchCheckResourceValidity(resourceIds) as any
|
||||
|
||||
// 处理检测结果
|
||||
if (response && response.results) {
|
||||
response.results.forEach((result: any) => {
|
||||
// 只有真正进行了检测的资源才设置检测结果
|
||||
if (result.detection_method !== 'unsupported') {
|
||||
detectionResults.value[result.resource_id] = result.is_valid
|
||||
}
|
||||
detectionMethods.value[result.resource_id] = result.detection_method || 'unknown'
|
||||
|
||||
// 保存错误信息
|
||||
if (result.error) {
|
||||
detectionErrors.value[result.resource_id] = result.error
|
||||
}
|
||||
|
||||
// 保存检测提示
|
||||
if (result.note) {
|
||||
detectionNotes.value[result.resource_id] = result.note
|
||||
}
|
||||
|
||||
// 显示缓存状态
|
||||
if (result.cached) {
|
||||
console.log(`资源 ${result.resource_id} 使用缓存检测结果`)
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isDetecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 智能检测:避免频繁重复检测
|
||||
const smartDetectResourceValidity = async (force = false) => {
|
||||
if (!resourcesData.value?.resources) return
|
||||
|
||||
// 如果正在检测,不重复执行
|
||||
if (isDetecting.value) {
|
||||
console.log('检测正在进行中,跳过重复请求')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果不是强制检测且已有检测结果,跳过
|
||||
if (!force && Object.keys(detectionResults.value).length > 0) {
|
||||
const lastDetectionTime = lastDetectionTimestamp.value
|
||||
const now = Date.now()
|
||||
const timeSinceLastDetection = now - lastDetectionTime
|
||||
|
||||
// 5分钟内不重复检测
|
||||
if (timeSinceLastDetection < 5 * 60 * 1000) {
|
||||
console.log('距离上次检测时间不足5分钟,跳过重复检测')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await detectResourceValidity()
|
||||
lastDetectionTimestamp.value = Date.now()
|
||||
}
|
||||
|
||||
// 添加最后检测时间戳
|
||||
const lastDetectionTimestamp = ref(0)
|
||||
|
||||
// 切换链接显示
|
||||
const toggleLink = async (resource: any) => {
|
||||
if (resource.forbidden) {
|
||||
@@ -833,15 +937,39 @@ const navigateToResource = (key: string) => {
|
||||
navigateTo(`/r/${key}`)
|
||||
}
|
||||
|
||||
// 页面加载完成后开始检测
|
||||
onMounted(() => {
|
||||
// 开始检测资源有效性
|
||||
nextTick(() => {
|
||||
if (resourcesData.value?.resources) {
|
||||
detectResourceValidity()
|
||||
}
|
||||
})
|
||||
// 检测方法相关函数
|
||||
const getDetectionMethodLabel = (method: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
'quark_deep': '深度检测',
|
||||
'unsupported': '未检测',
|
||||
'unknown': '未知方法',
|
||||
'error': '检测错误'
|
||||
}
|
||||
return labels[method] || '未知'
|
||||
}
|
||||
|
||||
const getDetectionMethodClass = (method: string) => {
|
||||
const classes: Record<string, string> = {
|
||||
'quark_deep': 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-100',
|
||||
'unsupported': 'bg-gray-100 text-gray-600 dark:bg-gray-500/20 dark:text-gray-300',
|
||||
'unknown': 'bg-gray-100 text-gray-700 dark:bg-gray-500/20 dark:text-gray-100',
|
||||
'error': 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-100'
|
||||
}
|
||||
return classes[method] || 'bg-gray-100 text-gray-700 dark:bg-gray-500/20 dark:text-gray-100'
|
||||
}
|
||||
|
||||
const getDetectionMethodTitle = (method: string, resource: any) => {
|
||||
const titles: Record<string, string> = {
|
||||
'quark_deep': '使用深度检测(通过实际网盘操作验证)',
|
||||
'unsupported': `${resource.pan?.remark || '未知网盘'} 暂不支持深度检测`,
|
||||
'unknown': '检测方法未知',
|
||||
'error': '检测过程中发生错误'
|
||||
}
|
||||
return titles[method] || '未知检测方法'
|
||||
}
|
||||
|
||||
// 页面加载完成后
|
||||
onMounted(() => {
|
||||
// 获取相关资源
|
||||
nextTick(() => {
|
||||
fetchRelatedResources()
|
||||
@@ -891,8 +1019,13 @@ const updatePageSeo = () => {
|
||||
})
|
||||
|
||||
// 设置更详细的HTML元数据
|
||||
const baseUrl = config.public.siteUrl || 'https://yourdomain.com'
|
||||
const canonicalUrl = `${baseUrl}/r/${resourceKey.value}`
|
||||
let canonicalUrl
|
||||
if (typeof window !== 'undefined') {
|
||||
canonicalUrl = `${window.location.origin}/r/${resourceKey.value}`
|
||||
} else {
|
||||
// 在服务端渲染时使用相对路径
|
||||
canonicalUrl = `/r/${resourceKey.value}`
|
||||
}
|
||||
|
||||
// 生成动态OG图片URL(使用新的key参数格式)
|
||||
const ogImageUrl = generateOgImageUrl(resourceKey.value, '', 'blue')
|
||||
|
||||
Reference in New Issue
Block a user