mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 03:15:04 +08:00
Compare commits
18 Commits
b99a97c0a9
...
8708e869a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8708e869a4 | ||
|
|
6c84b8d7b7 | ||
|
|
fc4cf8ecfb | ||
|
|
447512d809 | ||
|
|
64e7169140 | ||
|
|
99d2c7f20f | ||
|
|
9e6b5a58c4 | ||
|
|
040e6bc6bf | ||
|
|
3370f75d5e | ||
|
|
11a3204c18 | ||
|
|
5276112e48 | ||
|
|
3bd0fde82f | ||
|
|
61e5cbf80d | ||
|
|
57f7bab443 | ||
|
|
242e12c29c | ||
|
|
f9a1043431 | ||
|
|
5dc431ab24 | ||
|
|
c50282bec8 |
@@ -1,3 +1,6 @@
|
||||
### v1.3.4
|
||||
1. 添加详情页
|
||||
|
||||
### v1.3.3
|
||||
1. 公众号自动回复
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ func InitDB() error {
|
||||
&entity.APIAccessLog{},
|
||||
&entity.APIAccessLogStats{},
|
||||
&entity.APIAccessLogSummary{},
|
||||
&entity.Report{},
|
||||
&entity.CopyrightClaim{},
|
||||
)
|
||||
if err != nil {
|
||||
utils.Fatal("数据库迁移失败: %v", err)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
|
||||
response := dto.ResourceResponse{
|
||||
ID: resource.ID,
|
||||
Key: resource.Key,
|
||||
Title: resource.Title,
|
||||
Description: resource.Description,
|
||||
URL: resource.URL,
|
||||
@@ -36,6 +37,18 @@ func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
|
||||
response.CategoryName = resource.Category.Name
|
||||
}
|
||||
|
||||
// 设置平台信息
|
||||
if resource.Pan.ID != 0 {
|
||||
panResponse := dto.PanResponse{
|
||||
ID: resource.Pan.ID,
|
||||
Name: resource.Pan.Name,
|
||||
Key: resource.Pan.Key,
|
||||
Icon: resource.Pan.Icon,
|
||||
Remark: resource.Pan.Remark,
|
||||
}
|
||||
response.Pan = &panResponse
|
||||
}
|
||||
|
||||
// 转换标签
|
||||
response.Tags = make([]dto.TagResponse, len(resource.Tags))
|
||||
for i, tag := range resource.Tags {
|
||||
|
||||
95
db/converter/copyright_claim_converter.go
Normal file
95
db/converter/copyright_claim_converter.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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: []dto.ResourceInfo{}, // 空的资源列表
|
||||
}
|
||||
}
|
||||
|
||||
// CopyrightClaimsToResponse 将版权申述实体列表转换为响应对象列表
|
||||
func CopyrightClaimsToResponse(claims []*entity.CopyrightClaim) []*dto.CopyrightClaimResponse {
|
||||
var responses []*dto.CopyrightClaimResponse
|
||||
for _, claim := range claims {
|
||||
responses = append(responses, CopyrightClaimToResponse(claim))
|
||||
}
|
||||
return responses
|
||||
}
|
||||
89
db/converter/report_converter.go
Normal file
89
db/converter/report_converter.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// ReportToResponseWithResources 将举报实体和关联资源转换为响应对象
|
||||
func ReportToResponseWithResources(report *entity.Report, resources []*entity.Resource) *dto.ReportResponse {
|
||||
if report == 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.ReportResponse{
|
||||
ID: report.ID,
|
||||
ResourceKey: report.ResourceKey,
|
||||
Reason: report.Reason,
|
||||
Description: report.Description,
|
||||
Contact: report.Contact,
|
||||
UserAgent: report.UserAgent,
|
||||
IPAddress: report.IPAddress,
|
||||
Status: report.Status,
|
||||
Note: report.Note,
|
||||
CreatedAt: report.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: report.UpdatedAt.Format(time.RFC3339),
|
||||
Resources: resourceInfos,
|
||||
}
|
||||
}
|
||||
|
||||
// ReportToResponse 将举报实体转换为响应对象(不包含资源详情)
|
||||
func ReportToResponse(report *entity.Report) *dto.ReportResponse {
|
||||
if report == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &dto.ReportResponse{
|
||||
ID: report.ID,
|
||||
ResourceKey: report.ResourceKey,
|
||||
Reason: report.Reason,
|
||||
Description: report.Description,
|
||||
Contact: report.Contact,
|
||||
UserAgent: report.UserAgent,
|
||||
IPAddress: report.IPAddress,
|
||||
Status: report.Status,
|
||||
Note: report.Note,
|
||||
CreatedAt: report.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: report.UpdatedAt.Format(time.RFC3339),
|
||||
Resources: []dto.ResourceInfo{}, // 空的资源列表
|
||||
}
|
||||
}
|
||||
|
||||
// ReportsToResponse 将举报实体列表转换为响应对象列表
|
||||
func ReportsToResponse(reports []*entity.Report) []*dto.ReportResponse {
|
||||
var responses []*dto.ReportResponse
|
||||
for _, report := range reports {
|
||||
responses = append(responses, ReportToResponse(report))
|
||||
}
|
||||
return responses
|
||||
}
|
||||
@@ -280,39 +280,27 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
|
||||
return configs
|
||||
}
|
||||
|
||||
// SystemConfigToPublicResponse 返回不含 api_token 的系统配置响应
|
||||
// SystemConfigToPublicResponse 返回不含敏感配置的系统配置响应
|
||||
func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]interface{} {
|
||||
response := map[string]interface{}{
|
||||
entity.ConfigResponseFieldID: 0,
|
||||
entity.ConfigResponseFieldCreatedAt: utils.GetCurrentTimeString(),
|
||||
entity.ConfigResponseFieldUpdatedAt: utils.GetCurrentTimeString(),
|
||||
entity.ConfigResponseFieldSiteTitle: entity.ConfigDefaultSiteTitle,
|
||||
entity.ConfigResponseFieldSiteDescription: entity.ConfigDefaultSiteDescription,
|
||||
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
|
||||
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
|
||||
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
|
||||
"site_logo": "",
|
||||
entity.ConfigResponseFieldAutoProcessReadyResources: false,
|
||||
entity.ConfigResponseFieldAutoProcessInterval: 30,
|
||||
entity.ConfigResponseFieldAutoTransferEnabled: false,
|
||||
entity.ConfigResponseFieldAutoTransferLimitDays: 0,
|
||||
entity.ConfigResponseFieldAutoTransferMinSpace: 100,
|
||||
entity.ConfigResponseFieldAutoFetchHotDramaEnabled: false,
|
||||
entity.ConfigResponseFieldForbiddenWords: "",
|
||||
entity.ConfigResponseFieldAdKeywords: "",
|
||||
entity.ConfigResponseFieldAutoInsertAd: "",
|
||||
entity.ConfigResponseFieldPageSize: 100,
|
||||
entity.ConfigResponseFieldMaintenanceMode: false,
|
||||
entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能
|
||||
entity.ConfigResponseFieldThirdPartyStatsCode: "",
|
||||
entity.ConfigResponseFieldMeilisearchEnabled: false,
|
||||
entity.ConfigResponseFieldMeilisearchHost: "localhost",
|
||||
entity.ConfigResponseFieldMeilisearchPort: "7700",
|
||||
entity.ConfigResponseFieldMeilisearchMasterKey: "",
|
||||
entity.ConfigResponseFieldMeilisearchIndexName: "resources",
|
||||
entity.ConfigResponseFieldID: 0,
|
||||
entity.ConfigResponseFieldCreatedAt: utils.GetCurrentTimeString(),
|
||||
entity.ConfigResponseFieldUpdatedAt: utils.GetCurrentTimeString(),
|
||||
entity.ConfigResponseFieldSiteTitle: entity.ConfigDefaultSiteTitle,
|
||||
entity.ConfigResponseFieldSiteDescription: entity.ConfigDefaultSiteDescription,
|
||||
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
|
||||
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
|
||||
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
|
||||
"site_logo": "",
|
||||
entity.ConfigResponseFieldAdKeywords: "",
|
||||
entity.ConfigResponseFieldAutoInsertAd: "",
|
||||
entity.ConfigResponseFieldPageSize: 100,
|
||||
entity.ConfigResponseFieldMaintenanceMode: false,
|
||||
entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能
|
||||
entity.ConfigResponseFieldThirdPartyStatsCode: "",
|
||||
}
|
||||
|
||||
// 将键值对转换为map
|
||||
// 将键值对转换为map,过滤掉敏感配置
|
||||
for _, config := range configs {
|
||||
switch config.Key {
|
||||
case entity.ConfigKeySiteTitle:
|
||||
@@ -327,32 +315,6 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
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
|
||||
}
|
||||
case entity.ConfigKeyAutoProcessInterval:
|
||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoProcessInterval] = val
|
||||
}
|
||||
case entity.ConfigKeyAutoTransferEnabled:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoTransferEnabled] = val
|
||||
}
|
||||
case entity.ConfigKeyAutoTransferLimitDays:
|
||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoTransferLimitDays] = val
|
||||
}
|
||||
case entity.ConfigKeyAutoTransferMinSpace:
|
||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoTransferMinSpace] = val
|
||||
}
|
||||
case entity.ConfigKeyAutoFetchHotDramaEnabled:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoFetchHotDramaEnabled] = val
|
||||
}
|
||||
case entity.ConfigKeyForbiddenWords:
|
||||
response[entity.ConfigResponseFieldForbiddenWords] = config.Value
|
||||
case entity.ConfigKeyAdKeywords:
|
||||
response[entity.ConfigResponseFieldAdKeywords] = config.Value
|
||||
case entity.ConfigKeyAutoInsertAd:
|
||||
@@ -371,18 +333,6 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
}
|
||||
case entity.ConfigKeyThirdPartyStatsCode:
|
||||
response[entity.ConfigResponseFieldThirdPartyStatsCode] = config.Value
|
||||
case entity.ConfigKeyMeilisearchEnabled:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldMeilisearchEnabled] = val
|
||||
}
|
||||
case entity.ConfigKeyMeilisearchHost:
|
||||
response[entity.ConfigResponseFieldMeilisearchHost] = config.Value
|
||||
case entity.ConfigKeyMeilisearchPort:
|
||||
response[entity.ConfigResponseFieldMeilisearchPort] = config.Value
|
||||
case entity.ConfigKeyMeilisearchMasterKey:
|
||||
response[entity.ConfigResponseFieldMeilisearchMasterKey] = config.Value
|
||||
case entity.ConfigKeyMeilisearchIndexName:
|
||||
response[entity.ConfigResponseFieldMeilisearchIndexName] = config.Value
|
||||
case entity.ConfigKeyEnableAnnouncements:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response["enable_announcements"] = val
|
||||
@@ -403,6 +353,27 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
response["telegram_qr_image"] = config.Value
|
||||
case entity.ConfigKeyQrCodeStyle:
|
||||
response["qr_code_style"] = config.Value
|
||||
case entity.ConfigKeyAutoProcessReadyResources:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response["auto_process_ready_resources"] = val
|
||||
}
|
||||
case entity.ConfigKeyAutoTransferEnabled:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response["auto_transfer_enabled"] = val
|
||||
}
|
||||
// 跳过不需要返回给公众的配置
|
||||
case entity.ConfigKeyAutoProcessInterval:
|
||||
case entity.ConfigKeyAutoTransferLimitDays:
|
||||
case entity.ConfigKeyAutoTransferMinSpace:
|
||||
case entity.ConfigKeyAutoFetchHotDramaEnabled:
|
||||
case entity.ConfigKeyMeilisearchEnabled:
|
||||
case entity.ConfigKeyMeilisearchHost:
|
||||
case entity.ConfigKeyMeilisearchPort:
|
||||
case entity.ConfigKeyMeilisearchMasterKey:
|
||||
case entity.ConfigKeyMeilisearchIndexName:
|
||||
case entity.ConfigKeyForbiddenWords:
|
||||
// 这些配置不返回给公众
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
db/dto/copyright_claim.go
Normal file
46
db/dto/copyright_claim.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package dto
|
||||
|
||||
// CopyrightClaimCreateRequest 版权申述创建请求
|
||||
type CopyrightClaimCreateRequest struct {
|
||||
ResourceKey string `json:"resource_key" validate:"required,max=255"`
|
||||
Identity string `json:"identity" validate:"required,max=50"`
|
||||
ProofType string `json:"proof_type" validate:"required,max=50"`
|
||||
Reason string `json:"reason" validate:"required,max=2000"`
|
||||
ContactInfo string `json:"contact_info" validate:"required,max=255"`
|
||||
ClaimantName string `json:"claimant_name" validate:"required,max=100"`
|
||||
ProofFiles string `json:"proof_files" validate:"omitempty,max=2000"`
|
||||
UserAgent string `json:"user_agent" validate:"omitempty,max=1000"`
|
||||
IPAddress string `json:"ip_address" validate:"omitempty,max=45"`
|
||||
}
|
||||
|
||||
// CopyrightClaimUpdateRequest 版权申述更新请求
|
||||
type CopyrightClaimUpdateRequest struct {
|
||||
Status string `json:"status" validate:"required,oneof=pending approved rejected"`
|
||||
Note string `json:"note" validate:"omitempty,max=1000"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Resources []ResourceInfo `json:"resources"`
|
||||
}
|
||||
|
||||
// CopyrightClaimListRequest 版权申述列表请求
|
||||
type CopyrightClaimListRequest struct {
|
||||
Page int `query:"page" validate:"omitempty,min=1"`
|
||||
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100"`
|
||||
Status string `query:"status" validate:"omitempty,oneof=pending approved rejected"`
|
||||
}
|
||||
55
db/dto/report.go
Normal file
55
db/dto/report.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package dto
|
||||
|
||||
// ReportCreateRequest 举报创建请求
|
||||
type ReportCreateRequest struct {
|
||||
ResourceKey string `json:"resource_key" validate:"required,max=255"`
|
||||
Reason string `json:"reason" validate:"required,max=100"`
|
||||
Description string `json:"description" validate:"required,max=1000"`
|
||||
Contact string `json:"contact" validate:"omitempty,max=255"`
|
||||
UserAgent string `json:"user_agent" validate:"omitempty,max=1000"`
|
||||
IPAddress string `json:"ip_address" validate:"omitempty,max=45"`
|
||||
}
|
||||
|
||||
// ReportUpdateRequest 举报更新请求
|
||||
type ReportUpdateRequest struct {
|
||||
Status string `json:"status" validate:"required,oneof=pending approved rejected"`
|
||||
Note string `json:"note" validate:"omitempty,max=1000"`
|
||||
}
|
||||
|
||||
// ResourceInfo 资源信息
|
||||
type ResourceInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
SaveURL string `json:"save_url"`
|
||||
FileSize string `json:"file_size"`
|
||||
Category string `json:"category"`
|
||||
PanName string `json:"pan_name"`
|
||||
ViewCount int `json:"view_count"`
|
||||
IsValid bool `json:"is_valid"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ReportResponse 举报响应
|
||||
type ReportResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
Reason string `json:"reason"`
|
||||
Description string `json:"description"`
|
||||
Contact string `json:"contact"`
|
||||
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"` // 关联的资源列表
|
||||
}
|
||||
|
||||
// ReportListRequest 举报列表请求
|
||||
type ReportListRequest struct {
|
||||
Page int `query:"page" validate:"omitempty,min=1"`
|
||||
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100"`
|
||||
Status string `query:"status" validate:"omitempty,oneof=pending approved rejected"`
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type SearchResponse struct {
|
||||
// ResourceResponse 资源响应
|
||||
type ResourceResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
@@ -32,6 +33,7 @@ type ResourceResponse struct {
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
SyncedToMeilisearch bool `json:"synced_to_meilisearch"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
Pan *PanResponse `json:"pan,omitempty"` // 平台信息
|
||||
// 高亮字段
|
||||
TitleHighlight string `json:"title_highlight,omitempty"`
|
||||
DescriptionHighlight string `json:"description_highlight,omitempty"`
|
||||
|
||||
32
db/entity/copyright_claim.go
Normal file
32
db/entity/copyright_claim.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CopyrightClaim 版权申述实体
|
||||
type CopyrightClaim struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ResourceKey string `gorm:"type:varchar(255);not null;index" json:"resource_key"` // 资源唯一标识
|
||||
Identity string `gorm:"type:varchar(50);not null" json:"identity"` // 申述人身份
|
||||
ProofType string `gorm:"type:varchar(50);not null" json:"proof_type"` // 证明类型
|
||||
Reason string `gorm:"type:text;not null" json:"reason"` // 申述理由
|
||||
ContactInfo string `gorm:"type:varchar(255);not null" json:"contact_info"` // 联系信息
|
||||
ClaimantName string `gorm:"type:varchar(100);not null" json:"claimant_name"` // 申述人姓名
|
||||
ProofFiles string `gorm:"type:text" json:"proof_files"` // 证明文件(JSON格式)
|
||||
UserAgent string `gorm:"type:text" json:"user_agent"` // 用户代理
|
||||
IPAddress string `gorm:"type:varchar(45)" json:"ip_address"` // IP地址
|
||||
Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // 处理状态: pending, approved, rejected
|
||||
ProcessedAt *time.Time `json:"processed_at"` // 处理时间
|
||||
ProcessedBy *uint `json:"processed_by"` // 处理人ID
|
||||
Note string `gorm:"type:text" json:"note"` // 处理备注
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (CopyrightClaim) TableName() string {
|
||||
return "copyright_claims"
|
||||
}
|
||||
29
db/entity/report.go
Normal file
29
db/entity/report.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Report 举报实体
|
||||
type Report struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ResourceKey string `gorm:"type:varchar(255);not null;index" json:"resource_key"` // 资源唯一标识
|
||||
Reason string `gorm:"type:varchar(100);not null" json:"reason"` // 举报原因
|
||||
Description string `gorm:"type:text" json:"description"` // 详细描述
|
||||
Contact string `gorm:"type:varchar(255)" json:"contact"` // 联系方式
|
||||
UserAgent string `gorm:"type:text" json:"user_agent"` // 用户代理
|
||||
IPAddress string `gorm:"type:varchar(45)" json:"ip_address"` // IP地址
|
||||
Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // 处理状态: pending, approved, rejected
|
||||
ProcessedAt *time.Time `json:"processed_at"` // 处理时间
|
||||
ProcessedBy *uint `json:"processed_by"` // 处理人ID
|
||||
Note string `gorm:"type:text" json:"note"` // 处理备注
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (Report) TableName() string {
|
||||
return "reports"
|
||||
}
|
||||
@@ -74,6 +74,14 @@ const (
|
||||
ConfigKeyWechatSearchImage = "wechat_search_image"
|
||||
ConfigKeyTelegramQrImage = "telegram_qr_image"
|
||||
ConfigKeyQrCodeStyle = "qr_code_style"
|
||||
|
||||
// Sitemap配置
|
||||
ConfigKeySitemapConfig = "sitemap_config"
|
||||
ConfigKeySitemapLastGenerateTime = "sitemap_last_generate_time"
|
||||
ConfigKeySitemapAutoGenerateEnabled = "sitemap_auto_generate_enabled"
|
||||
|
||||
// 网站URL配置
|
||||
ConfigKeyWebsiteURL = "website_url"
|
||||
)
|
||||
|
||||
// ConfigType 配置类型常量
|
||||
|
||||
@@ -12,6 +12,7 @@ type BaseRepository[T any] interface {
|
||||
Update(entity *T) error
|
||||
Delete(id uint) error
|
||||
FindWithPagination(page, limit int) ([]T, int64, error)
|
||||
GetDB() *gorm.DB
|
||||
}
|
||||
|
||||
// BaseRepositoryImpl 基础Repository实现
|
||||
|
||||
87
db/repo/copyright_claim_repository.go
Normal file
87
db/repo/copyright_claim_repository.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// CopyrightClaimRepository 版权申述Repository接口
|
||||
type CopyrightClaimRepository interface {
|
||||
BaseRepository[entity.CopyrightClaim]
|
||||
GetByResourceKey(resourceKey string) ([]*entity.CopyrightClaim, error)
|
||||
List(status string, page, pageSize int) ([]*entity.CopyrightClaim, int64, error)
|
||||
UpdateStatus(id uint, status string, processedBy *uint, note string) error
|
||||
// 兼容原有方法名
|
||||
GetByID(id uint) (*entity.CopyrightClaim, error)
|
||||
}
|
||||
|
||||
// CopyrightClaimRepositoryImpl 版权申述Repository实现
|
||||
type CopyrightClaimRepositoryImpl struct {
|
||||
BaseRepositoryImpl[entity.CopyrightClaim]
|
||||
}
|
||||
|
||||
// NewCopyrightClaimRepository 创建版权申述Repository
|
||||
func NewCopyrightClaimRepository(db *gorm.DB) CopyrightClaimRepository {
|
||||
return &CopyrightClaimRepositoryImpl{
|
||||
BaseRepositoryImpl: BaseRepositoryImpl[entity.CopyrightClaim]{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建版权申述
|
||||
func (r *CopyrightClaimRepositoryImpl) Create(claim *entity.CopyrightClaim) error {
|
||||
return r.GetDB().Create(claim).Error
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取版权申述
|
||||
func (r *CopyrightClaimRepositoryImpl) GetByID(id uint) (*entity.CopyrightClaim, error) {
|
||||
var claim entity.CopyrightClaim
|
||||
err := r.GetDB().Where("id = ?", id).First(&claim).Error
|
||||
return &claim, err
|
||||
}
|
||||
|
||||
// GetByResourceKey 获取某个资源的所有版权申述
|
||||
func (r *CopyrightClaimRepositoryImpl) GetByResourceKey(resourceKey string) ([]*entity.CopyrightClaim, error) {
|
||||
var claims []*entity.CopyrightClaim
|
||||
err := r.GetDB().Where("resource_key = ?", resourceKey).Find(&claims).Error
|
||||
return claims, err
|
||||
}
|
||||
|
||||
// List 获取版权申述列表
|
||||
func (r *CopyrightClaimRepositoryImpl) List(status string, page, pageSize int) ([]*entity.CopyrightClaim, int64, error) {
|
||||
var claims []*entity.CopyrightClaim
|
||||
var total int64
|
||||
|
||||
query := r.GetDB().Model(&entity.CopyrightClaim{})
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
query.Count(&total)
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&claims).Error
|
||||
return claims, total, err
|
||||
}
|
||||
|
||||
// Update 更新版权申述
|
||||
func (r *CopyrightClaimRepositoryImpl) Update(claim *entity.CopyrightClaim) error {
|
||||
return r.GetDB().Save(claim).Error
|
||||
}
|
||||
|
||||
// UpdateStatus 更新版权申述状态
|
||||
func (r *CopyrightClaimRepositoryImpl) UpdateStatus(id uint, status string, processedBy *uint, note string) error {
|
||||
return r.GetDB().Model(&entity.CopyrightClaim{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"processed_at": gorm.Expr("NOW()"),
|
||||
"processed_by": processedBy,
|
||||
"note": note,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// Delete 删除版权申述
|
||||
func (r *CopyrightClaimRepositoryImpl) Delete(id uint) error {
|
||||
return r.GetDB().Delete(&entity.CopyrightClaim{}, id).Error
|
||||
}
|
||||
@@ -22,6 +22,8 @@ type RepositoryManager struct {
|
||||
FileRepository FileRepository
|
||||
TelegramChannelRepository TelegramChannelRepository
|
||||
APIAccessLogRepository APIAccessLogRepository
|
||||
ReportRepository ReportRepository
|
||||
CopyrightClaimRepository CopyrightClaimRepository
|
||||
}
|
||||
|
||||
// NewRepositoryManager 创建Repository管理器
|
||||
@@ -43,5 +45,7 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager {
|
||||
FileRepository: NewFileRepository(db),
|
||||
TelegramChannelRepository: NewTelegramChannelRepository(db),
|
||||
APIAccessLogRepository: NewAPIAccessLogRepository(db),
|
||||
ReportRepository: NewReportRepository(db),
|
||||
CopyrightClaimRepository: NewCopyrightClaimRepository(db),
|
||||
}
|
||||
}
|
||||
|
||||
87
db/repo/report_repository.go
Normal file
87
db/repo/report_repository.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// ReportRepository 举报Repository接口
|
||||
type ReportRepository interface {
|
||||
BaseRepository[entity.Report]
|
||||
GetByResourceKey(resourceKey string) ([]*entity.Report, error)
|
||||
List(status string, page, pageSize int) ([]*entity.Report, int64, error)
|
||||
UpdateStatus(id uint, status string, processedBy *uint, note string) error
|
||||
// 兼容原有方法名
|
||||
GetByID(id uint) (*entity.Report, error)
|
||||
}
|
||||
|
||||
// ReportRepositoryImpl 举报Repository实现
|
||||
type ReportRepositoryImpl struct {
|
||||
BaseRepositoryImpl[entity.Report]
|
||||
}
|
||||
|
||||
// NewReportRepository 创建举报Repository
|
||||
func NewReportRepository(db *gorm.DB) ReportRepository {
|
||||
return &ReportRepositoryImpl{
|
||||
BaseRepositoryImpl: BaseRepositoryImpl[entity.Report]{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建举报
|
||||
func (r *ReportRepositoryImpl) Create(report *entity.Report) error {
|
||||
return r.GetDB().Create(report).Error
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取举报
|
||||
func (r *ReportRepositoryImpl) GetByID(id uint) (*entity.Report, error) {
|
||||
var report entity.Report
|
||||
err := r.GetDB().Where("id = ?", id).First(&report).Error
|
||||
return &report, err
|
||||
}
|
||||
|
||||
// GetByResourceKey 获取某个资源的所有举报
|
||||
func (r *ReportRepositoryImpl) GetByResourceKey(resourceKey string) ([]*entity.Report, error) {
|
||||
var reports []*entity.Report
|
||||
err := r.GetDB().Where("resource_key = ?", resourceKey).Find(&reports).Error
|
||||
return reports, err
|
||||
}
|
||||
|
||||
// List 获取举报列表
|
||||
func (r *ReportRepositoryImpl) List(status string, page, pageSize int) ([]*entity.Report, int64, error) {
|
||||
var reports []*entity.Report
|
||||
var total int64
|
||||
|
||||
query := r.GetDB().Model(&entity.Report{})
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
query.Count(&total)
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&reports).Error
|
||||
return reports, total, err
|
||||
}
|
||||
|
||||
// Update 更新举报
|
||||
func (r *ReportRepositoryImpl) Update(report *entity.Report) error {
|
||||
return r.GetDB().Save(report).Error
|
||||
}
|
||||
|
||||
// UpdateStatus 更新举报状态
|
||||
func (r *ReportRepositoryImpl) UpdateStatus(id uint, status string, processedBy *uint, note string) error {
|
||||
return r.GetDB().Model(&entity.Report{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"processed_at": gorm.Expr("NOW()"),
|
||||
"processed_by": processedBy,
|
||||
"note": note,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// Delete 删除举报
|
||||
func (r *ReportRepositoryImpl) Delete(id uint) error {
|
||||
return r.GetDB().Delete(&entity.Report{}, id).Error
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
@@ -46,6 +48,9 @@ type ResourceRepository interface {
|
||||
GetRandomResourceWithFilters(categoryFilter, tagFilter string, isPushSavedInfo bool) (*entity.Resource, error)
|
||||
DeleteRelatedResources(ckID uint) (int64, error)
|
||||
CountResourcesByCkID(ckID uint) (int64, error)
|
||||
FindByResourceKey(key string) ([]entity.Resource, error)
|
||||
FindByKey(key string) ([]entity.Resource, error)
|
||||
GetHotResources(limit int) ([]entity.Resource, error)
|
||||
}
|
||||
|
||||
// ResourceRepositoryImpl Resource的Repository实现
|
||||
@@ -242,6 +247,23 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
|
||||
Where("resource_tags.tag_id = ?", tagEntity.ID)
|
||||
}
|
||||
}
|
||||
case "tag_ids": // 添加tag_ids参数支持(标签ID列表)
|
||||
if tagIdsStr, ok := value.(string); ok && tagIdsStr != "" {
|
||||
// 将逗号分隔的标签ID字符串转换为整数ID数组
|
||||
tagIdStrs := strings.Split(tagIdsStr, ",")
|
||||
var tagIds []uint
|
||||
for _, idStr := range tagIdStrs {
|
||||
idStr = strings.TrimSpace(idStr) // 去除空格
|
||||
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
|
||||
tagIds = append(tagIds, uint(id))
|
||||
}
|
||||
}
|
||||
if len(tagIds) > 0 {
|
||||
// 通过中间表查找包含任一标签的资源
|
||||
db = db.Joins("JOIN resource_tags ON resources.id = resource_tags.resource_id").
|
||||
Where("resource_tags.tag_id IN ?", tagIds)
|
||||
}
|
||||
}
|
||||
case "pan_id": // 添加pan_id参数支持
|
||||
if panID, ok := value.(uint); ok {
|
||||
db = db.Where("pan_id = ?", panID)
|
||||
@@ -335,12 +357,37 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
|
||||
// 计算偏移量
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 获取分页数据,按更新时间倒序
|
||||
// 处理排序参数
|
||||
orderBy := "updated_at"
|
||||
orderDir := "DESC"
|
||||
|
||||
if orderByVal, ok := params["order_by"].(string); ok && orderByVal != "" {
|
||||
// 验证排序字段,防止SQL注入
|
||||
validOrderByFields := map[string]bool{
|
||||
"created_at": true,
|
||||
"updated_at": true,
|
||||
"view_count": true,
|
||||
"title": true,
|
||||
"id": true,
|
||||
}
|
||||
if validOrderByFields[orderByVal] {
|
||||
orderBy = orderByVal
|
||||
}
|
||||
}
|
||||
|
||||
if orderDirVal, ok := params["order_dir"].(string); ok && orderDirVal != "" {
|
||||
// 验证排序方向
|
||||
if orderDirVal == "ASC" || orderDirVal == "DESC" {
|
||||
orderDir = orderDirVal
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分页数据,应用排序
|
||||
queryStart := utils.GetCurrentTime()
|
||||
err := db.Order("updated_at DESC").Offset(offset).Limit(pageSize).Find(&resources).Error
|
||||
err := db.Order(fmt.Sprintf("%s %s", orderBy, orderDir)).Offset(offset).Limit(pageSize).Find(&resources).Error
|
||||
queryDuration := time.Since(queryStart)
|
||||
totalDuration := time.Since(startTime)
|
||||
utils.Debug("SearchWithFilters完成: 总数=%d, 当前页数据量=%d, 查询耗时=%v, 总耗时=%v", total, len(resources), queryDuration, totalDuration)
|
||||
utils.Debug("SearchWithFilters完成: 总数=%d, 当前页数据量=%d, 排序=%s %s, 查询耗时=%v, 总耗时=%v", total, len(resources), orderBy, orderDir, queryDuration, totalDuration)
|
||||
return resources, total, err
|
||||
}
|
||||
|
||||
@@ -692,3 +739,63 @@ func (r *ResourceRepositoryImpl) CountResourcesByCkID(ckID uint) (int64, error)
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// FindByKey 根据Key查找资源(同一组资源)
|
||||
func (r *ResourceRepositoryImpl) FindByKey(key string) ([]entity.Resource, error) {
|
||||
var resources []entity.Resource
|
||||
err := r.db.Where("key = ?", key).
|
||||
Preload("Category").
|
||||
Preload("Pan").
|
||||
Preload("Tags").
|
||||
Order("pan_id ASC").
|
||||
Find(&resources).Error
|
||||
return resources, err
|
||||
}
|
||||
|
||||
// GetHotResources 获取热门资源(按查看次数排序,去重,限制数量)
|
||||
func (r *ResourceRepositoryImpl) GetHotResources(limit int) ([]entity.Resource, error) {
|
||||
var resources []entity.Resource
|
||||
|
||||
// 按key分组,获取每个key中查看次数最高的资源,然后按查看次数排序
|
||||
err := r.db.Table("resources").
|
||||
Select(`
|
||||
resources.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY key ORDER BY view_count DESC) as rn
|
||||
`).
|
||||
Where("is_public = ? AND view_count > 0", true).
|
||||
Preload("Category").
|
||||
Preload("Pan").
|
||||
Preload("Tags").
|
||||
Order("view_count DESC").
|
||||
Limit(limit * 2). // 获取更多数据以确保去重后有足够的结果
|
||||
Find(&resources).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 按key去重,保留每个key的第一个(即查看次数最高的)
|
||||
seenKeys := make(map[string]bool)
|
||||
var hotResources []entity.Resource
|
||||
for _, resource := range resources {
|
||||
if !seenKeys[resource.Key] {
|
||||
seenKeys[resource.Key] = true
|
||||
hotResources = append(hotResources, resource)
|
||||
if len(hotResources) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hotResources, nil
|
||||
}
|
||||
|
||||
// FindByResourceKey 根据资源Key查找资源
|
||||
func (r *ResourceRepositoryImpl) FindByResourceKey(key string) ([]entity.Resource, error) {
|
||||
var resources []entity.Resource
|
||||
err := r.GetDB().Where("key = ?", key).Find(&resources).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
BIN
font/SourceHanSansSC-Bold.otf
Normal file
BIN
font/SourceHanSansSC-Bold.otf
Normal file
Binary file not shown.
BIN
font/SourceHanSansSC-Regular.otf
Normal file
BIN
font/SourceHanSansSC-Regular.otf
Normal file
Binary file not shown.
2
go.mod
2
go.mod
@@ -48,7 +48,7 @@ require (
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
|
||||
312
handlers/copyright_claim_handler.go
Normal file
312
handlers/copyright_claim_handler.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CopyrightClaimHandler struct {
|
||||
copyrightClaimRepo repo.CopyrightClaimRepository
|
||||
resourceRepo repo.ResourceRepository
|
||||
validate *validator.Validate
|
||||
}
|
||||
|
||||
func NewCopyrightClaimHandler(copyrightClaimRepo repo.CopyrightClaimRepository, resourceRepo repo.ResourceRepository) *CopyrightClaimHandler {
|
||||
return &CopyrightClaimHandler{
|
||||
copyrightClaimRepo: copyrightClaimRepo,
|
||||
resourceRepo: resourceRepo,
|
||||
validate: validator.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCopyrightClaim 创建版权申述
|
||||
// @Summary 创建版权申述
|
||||
// @Description 提交资源版权申述
|
||||
// @Tags CopyrightClaim
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.CopyrightClaimCreateRequest true "版权申述信息"
|
||||
// @Success 200 {object} Response{data=dto.CopyrightClaimResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims [post]
|
||||
func (h *CopyrightClaimHandler) CreateCopyrightClaim(c *gin.Context) {
|
||||
var req dto.CopyrightClaimCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建版权申述实体
|
||||
claim := &entity.CopyrightClaim{
|
||||
ResourceKey: req.ResourceKey,
|
||||
Identity: req.Identity,
|
||||
ProofType: req.ProofType,
|
||||
Reason: req.Reason,
|
||||
ContactInfo: req.ContactInfo,
|
||||
ClaimantName: req.ClaimantName,
|
||||
ProofFiles: req.ProofFiles,
|
||||
UserAgent: req.UserAgent,
|
||||
IPAddress: req.IPAddress,
|
||||
Status: "pending", // 默认为待处理
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
if err := h.copyrightClaimRepo.Create(claim); err != nil {
|
||||
ErrorResponse(c, "创建版权申述失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := converter.CopyrightClaimToResponse(claim)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// GetCopyrightClaim 获取版权申述详情
|
||||
// @Summary 获取版权申述详情
|
||||
// @Description 根据ID获取版权申述详情
|
||||
// @Tags CopyrightClaim
|
||||
// @Produce json
|
||||
// @Param id path int true "版权申述ID"
|
||||
// @Success 200 {object} Response{data=dto.CopyrightClaimResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 404 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims/{id} [get]
|
||||
func (h *CopyrightClaimHandler) GetCopyrightClaim(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
claim, err := h.copyrightClaimRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ErrorResponse(c, "版权申述不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ErrorResponse(c, "获取版权申述失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, converter.CopyrightClaimToResponse(claim))
|
||||
}
|
||||
|
||||
// ListCopyrightClaims 获取版权申述列表
|
||||
// @Summary 获取版权申述列表
|
||||
// @Description 获取版权申述列表(支持分页和状态筛选)
|
||||
// @Tags CopyrightClaim
|
||||
// @Produce json
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(10)
|
||||
// @Param status query string false "处理状态"
|
||||
// @Success 200 {object} Response{data=object{items=[]dto.CopyrightClaimResponse,total=int}}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims [get]
|
||||
func (h *CopyrightClaimHandler) ListCopyrightClaims(c *gin.Context) {
|
||||
var req dto.CopyrightClaimListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if req.Page == 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize == 0 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
claims, total, err := h.copyrightClaimRepo.List(req.Status, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取版权申述列表失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为包含资源信息的响应
|
||||
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 更新版权申述状态
|
||||
// @Summary 更新版权申述状态
|
||||
// @Description 更新版权申述处理状态
|
||||
// @Tags CopyrightClaim
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "版权申述ID"
|
||||
// @Param request body dto.CopyrightClaimUpdateRequest true "更新信息"
|
||||
// @Success 200 {object} Response{data=dto.CopyrightClaimResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 404 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims/{id} [put]
|
||||
func (h *CopyrightClaimHandler) UpdateCopyrightClaim(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CopyrightClaimUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前版权申述
|
||||
_, err = h.copyrightClaimRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ErrorResponse(c, "版权申述不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ErrorResponse(c, "获取版权申述失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
processedBy := uint(0) // 从上下文获取当前用户ID,如果存在的话
|
||||
if currentUser := c.GetUint("user_id"); currentUser > 0 {
|
||||
processedBy = currentUser
|
||||
}
|
||||
|
||||
if err := h.copyrightClaimRepo.UpdateStatus(uint(id), req.Status, &processedBy, req.Note); err != nil {
|
||||
ErrorResponse(c, "更新版权申述状态失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取更新后的版权申述信息
|
||||
updatedClaim, err := h.copyrightClaimRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取更新后版权申述信息失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, converter.CopyrightClaimToResponse(updatedClaim))
|
||||
}
|
||||
|
||||
// DeleteCopyrightClaim 删除版权申述
|
||||
// @Summary 删除版权申述
|
||||
// @Description 删除版权申述记录
|
||||
// @Tags CopyrightClaim
|
||||
// @Produce json
|
||||
// @Param id path int true "版权申述ID"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims/{id} [delete]
|
||||
func (h *CopyrightClaimHandler) DeleteCopyrightClaim(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.copyrightClaimRepo.Delete(uint(id)); err != nil {
|
||||
ErrorResponse(c, "删除版权申述失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, nil)
|
||||
}
|
||||
|
||||
// GetCopyrightClaimByResource 获取某个资源的版权申述列表
|
||||
// @Summary 获取资源版权申述列表
|
||||
// @Description 获取某个资源的所有版权申述记录
|
||||
// @Tags CopyrightClaim
|
||||
// @Produce json
|
||||
// @Param resource_key path string true "资源Key"
|
||||
// @Success 200 {object} Response{data=[]dto.CopyrightClaimResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims/resource/{resource_key} [get]
|
||||
func (h *CopyrightClaimHandler) GetCopyrightClaimByResource(c *gin.Context) {
|
||||
resourceKey := c.Param("resource_key")
|
||||
if resourceKey == "" {
|
||||
ErrorResponse(c, "资源Key不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.copyrightClaimRepo.GetByResourceKey(resourceKey)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取资源版权申述失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, converter.CopyrightClaimsToResponse(claims))
|
||||
}
|
||||
|
||||
// RegisterCopyrightClaimRoutes 注册版权申述相关路由
|
||||
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("", 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) // 获取资源版权申述列表
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,21 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"github.com/ctwj/urldb/db"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/fogleman/gg"
|
||||
"image/color"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
)
|
||||
|
||||
// OGImageHandler 处理OG图片生成请求
|
||||
@@ -20,17 +27,69 @@ func NewOGImageHandler() *OGImageHandler {
|
||||
return &OGImageHandler{}
|
||||
}
|
||||
|
||||
// Resource 简化的资源结构体
|
||||
type Resource struct {
|
||||
Title string
|
||||
Description string
|
||||
Cover string
|
||||
Key string
|
||||
}
|
||||
|
||||
// getResourceByKey 通过key获取资源信息
|
||||
func (h *OGImageHandler) getResourceByKey(key string) (*Resource, error) {
|
||||
// 这里简化处理,实际应该从数据库查询
|
||||
// 为了演示,我们先返回一个模拟的资源
|
||||
// 在实际应用中,您需要连接数据库并查询
|
||||
|
||||
// 模拟数据库查询 - 实际应用中请替换为真实的数据库查询
|
||||
dbInstance := db.DB
|
||||
if dbInstance == nil {
|
||||
return nil, fmt.Errorf("数据库连接失败")
|
||||
}
|
||||
|
||||
var resource entity.Resource
|
||||
result := dbInstance.Where("key = ?", key).First(&resource)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &Resource{
|
||||
Title: resource.Title,
|
||||
Description: resource.Description,
|
||||
Cover: resource.Cover,
|
||||
Key: resource.Key,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateOGImage 生成OG图片
|
||||
func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
|
||||
// 获取请求参数
|
||||
key := strings.TrimSpace(c.Query("key"))
|
||||
title := strings.TrimSpace(c.Query("title"))
|
||||
description := strings.TrimSpace(c.Query("description"))
|
||||
siteName := strings.TrimSpace(c.Query("site_name"))
|
||||
theme := strings.TrimSpace(c.Query("theme"))
|
||||
coverUrl := strings.TrimSpace(c.Query("cover"))
|
||||
|
||||
width, _ := strconv.Atoi(c.Query("width"))
|
||||
height, _ := strconv.Atoi(c.Query("height"))
|
||||
|
||||
// 如果提供了key,从数据库获取资源信息
|
||||
if key != "" {
|
||||
resource, err := h.getResourceByKey(key)
|
||||
if err == nil && resource != nil {
|
||||
if title == "" {
|
||||
title = resource.Title
|
||||
}
|
||||
if description == "" {
|
||||
description = resource.Description
|
||||
}
|
||||
if coverUrl == "" && resource.Cover != "" {
|
||||
coverUrl = resource.Cover
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if title == "" {
|
||||
title = "老九网盘资源数据库"
|
||||
@@ -45,8 +104,16 @@ func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
|
||||
height = 630
|
||||
}
|
||||
|
||||
// 获取当前请求的域名
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
domain := scheme + "://" + host
|
||||
|
||||
// 生成图片
|
||||
imageBuffer, err := createOGImage(title, description, siteName, theme, width, height)
|
||||
imageBuffer, err := createOGImage(title, description, siteName, theme, width, height, coverUrl, key, domain)
|
||||
if err != nil {
|
||||
utils.Error("生成OG图片失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -62,13 +129,16 @@ func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
|
||||
}
|
||||
|
||||
// createOGImage 创建OG图片
|
||||
func createOGImage(title, description, siteName, theme string, width, height int) (*bytes.Buffer, error) {
|
||||
func createOGImage(title, description, siteName, theme string, width, height int, coverUrl, key, domain string) (*bytes.Buffer, error) {
|
||||
dc := gg.NewContext(width, height)
|
||||
|
||||
// 设置圆角裁剪区域
|
||||
cornerRadius := 20.0
|
||||
dc.DrawRoundedRectangle(0, 0, float64(width), float64(height), cornerRadius)
|
||||
|
||||
// 设置背景色
|
||||
bgColor := getBackgroundColor(theme)
|
||||
dc.SetColor(bgColor)
|
||||
dc.DrawRectangle(0, 0, float64(width), float64(height))
|
||||
dc.Fill()
|
||||
|
||||
// 绘制渐变效果
|
||||
@@ -76,62 +146,151 @@ func createOGImage(title, description, siteName, theme string, width, height int
|
||||
gradient.AddColorStop(0, getGradientStartColor(theme))
|
||||
gradient.AddColorStop(1, getGradientEndColor(theme))
|
||||
dc.SetFillStyle(gradient)
|
||||
dc.DrawRectangle(0, 0, float64(width), float64(height))
|
||||
dc.Fill()
|
||||
|
||||
// 设置站点标识
|
||||
dc.SetHexColor("#ffffff")
|
||||
// 尝试加载字体,如果失败则使用默认字体
|
||||
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Regular.ttc", 24); err != nil {
|
||||
// 使用默认字体设置
|
||||
// 定义布局区域
|
||||
imageAreaWidth := width / 3 // 左侧1/3用于图片
|
||||
textAreaWidth := width * 2 / 3 // 右侧2/3用于文案
|
||||
textAreaX := imageAreaWidth // 文案区域起始X坐标
|
||||
|
||||
// 统一的字体加载函数,确保中文显示正常
|
||||
loadChineseFont := func(fontSize float64) bool {
|
||||
// 优先使用项目字体
|
||||
if err := dc.LoadFontFace("font/SourceHanSansSC-Regular.otf", fontSize); err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Windows系统常见字体,按优先级顺序尝试
|
||||
commonFonts := []string{
|
||||
"C:/Windows/Fonts/msyh.ttc", // 微软雅黑
|
||||
"C:/Windows/Fonts/simhei.ttf", // 黑体
|
||||
"C:/Windows/Fonts/simsun.ttc", // 宋体
|
||||
}
|
||||
|
||||
for _, fontPath := range commonFonts {
|
||||
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都失败了,尝试使用粗体版本
|
||||
boldFonts := []string{
|
||||
"C:/Windows/Fonts/msyhbd.ttc", // 微软雅黑粗体
|
||||
"C:/Windows/Fonts/simhei.ttf", // 黑体
|
||||
}
|
||||
|
||||
for _, fontPath := range boldFonts {
|
||||
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
dc.DrawStringAnchored(siteName, 60, 50, 0, 0.5)
|
||||
// 加载基础字体(24px)
|
||||
fontLoaded := loadChineseFont(24)
|
||||
dc.SetHexColor("#ffffff")
|
||||
|
||||
// 绘制封面图片(如果存在)
|
||||
if coverUrl != "" {
|
||||
if err := drawCoverImageInLeftArea(dc, coverUrl, width, height, imageAreaWidth); err != nil {
|
||||
utils.Error("绘制封面图片失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置站点标识
|
||||
dc.DrawStringAnchored(siteName, float64(textAreaX)+60, 50, 0, 0.5)
|
||||
|
||||
// 绘制标题
|
||||
dc.SetHexColor("#ffffff")
|
||||
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Bold.ttc", 48); err != nil {
|
||||
// 使用默认字体设置
|
||||
}
|
||||
|
||||
// 文字居中处理
|
||||
titleWidth, _ := dc.MeasureString(title)
|
||||
if titleWidth > float64(width-120) {
|
||||
// 如果标题过长,尝试加载较小字体
|
||||
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Bold.ttc", 42); err != nil {
|
||||
// 使用默认字体设置
|
||||
}
|
||||
titleWidth, _ = dc.MeasureString(title)
|
||||
if titleWidth > float64(width-120) {
|
||||
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Bold.ttc", 36); err != nil {
|
||||
// 使用默认字体设置
|
||||
// 标题在右侧区域显示,考虑文案宽度限制
|
||||
maxTitleWidth := float64(textAreaWidth - 120) // 右侧区域减去左右边距
|
||||
|
||||
// 动态调整字体大小以适应文案区域,使用统一的字体加载逻辑
|
||||
fontSize := 48.0
|
||||
titleFontLoaded := false
|
||||
for fontSize > 24 { // 最小字体24
|
||||
// 优先使用项目粗体字体
|
||||
if err := dc.LoadFontFace("font/SourceHanSansSC-Bold.otf", fontSize); err == nil {
|
||||
titleWidth, _ := dc.MeasureString(title)
|
||||
if titleWidth <= maxTitleWidth {
|
||||
titleFontLoaded = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// 尝试系统粗体字体
|
||||
boldFonts := []string{
|
||||
"C:/Windows/Fonts/msyhbd.ttc", // 微软雅黑粗体
|
||||
"C:/Windows/Fonts/simhei.ttf", // 黑体
|
||||
}
|
||||
for _, fontPath := range boldFonts {
|
||||
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
|
||||
titleWidth, _ := dc.MeasureString(title)
|
||||
if titleWidth <= maxTitleWidth {
|
||||
titleFontLoaded = true
|
||||
break
|
||||
}
|
||||
break // 找到可用字体就跳出内层循环
|
||||
}
|
||||
}
|
||||
if titleFontLoaded {
|
||||
break
|
||||
}
|
||||
}
|
||||
fontSize -= 4
|
||||
}
|
||||
|
||||
dc.DrawStringAnchored(title, float64(width)/2, float64(height)/2-30, 0.5, 0.5)
|
||||
// 如果粗体字体都失败了,使用常规字体
|
||||
if !titleFontLoaded {
|
||||
loadChineseFont(36) // 使用稍大的常规字体
|
||||
}
|
||||
|
||||
// 标题左对齐显示在右侧区域
|
||||
titleX := float64(textAreaX) + 60
|
||||
titleY := float64(height)/2 - 80
|
||||
dc.DrawString(title, titleX, titleY)
|
||||
|
||||
// 绘制描述
|
||||
if description != "" {
|
||||
dc.SetHexColor("#e5e7eb")
|
||||
// 尝试加载较小字体
|
||||
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Regular.ttc", 28); err != nil {
|
||||
// 使用默认字体设置
|
||||
}
|
||||
// 使用统一的字体加载逻辑
|
||||
loadChineseFont(28)
|
||||
|
||||
// 自动换行处理
|
||||
wrappedDesc := wrapText(dc, description, float64(width-120))
|
||||
startY := float64(height)/2 + 40
|
||||
// 自动换行处理,适配右侧区域宽度
|
||||
wrappedDesc := wrapText(dc, description, float64(textAreaWidth-120))
|
||||
descY := titleY + 60 // 标题下方
|
||||
|
||||
for i, line := range wrappedDesc {
|
||||
y := startY + float64(i)*35
|
||||
dc.DrawStringAnchored(line, float64(width)/2, y, 0.5, 0.5)
|
||||
y := descY + float64(i)*30 // 行高30像素
|
||||
dc.DrawString(line, titleX, y)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加装饰性元素
|
||||
drawDecorativeElements(dc, width, height, theme)
|
||||
|
||||
// 绘制底部URL访问地址
|
||||
if key != "" && domain != "" {
|
||||
resourceURL := domain + "/r/" + key
|
||||
dc.SetHexColor("#d1d5db") // 浅灰色
|
||||
|
||||
// 使用统一的字体加载逻辑
|
||||
loadChineseFont(20)
|
||||
|
||||
// URL位置:底部居中,距离底部边缘40像素,给更多空间
|
||||
urlY := float64(height) - 40
|
||||
|
||||
dc.DrawStringAnchored(resourceURL, float64(width)/2, urlY, 0.5, 0.5)
|
||||
}
|
||||
|
||||
// 添加调试信息(仅在开发环境)
|
||||
if title == "DEBUG" {
|
||||
dc.SetHexColor("#ff0000")
|
||||
dc.DrawString("Font loaded: "+strconv.FormatBool(fontLoaded), 50, float64(height)-80)
|
||||
}
|
||||
|
||||
// 生成图片
|
||||
buf := &bytes.Buffer{}
|
||||
err := dc.EncodePNG(buf)
|
||||
@@ -240,4 +399,167 @@ func drawDecorativeElements(dc *gg.Context, width, height int, theme string) {
|
||||
// 绘制底部装饰线
|
||||
dc.DrawLine(60, float64(height-80), float64(width-60), float64(height-80))
|
||||
dc.Stroke()
|
||||
}
|
||||
|
||||
// drawCoverImageInLeftArea 在左侧1/3区域绘制封面图片
|
||||
func drawCoverImageInLeftArea(dc *gg.Context, coverUrl string, width, height int, imageAreaWidth int) error {
|
||||
// 下载封面图片
|
||||
resp, err := http.Get(coverUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取图片数据
|
||||
imgData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解码图片
|
||||
img, _, err := image.Decode(bytes.NewReader(imgData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取图片尺寸和宽高比
|
||||
bounds := img.Bounds()
|
||||
imgWidth := bounds.Dx()
|
||||
imgHeight := bounds.Dy()
|
||||
aspectRatio := float64(imgWidth) / float64(imgHeight)
|
||||
|
||||
// 计算图片区域的可显示尺寸,留出边距
|
||||
padding := 40
|
||||
maxImageWidth := imageAreaWidth - padding*2
|
||||
maxImageHeight := height - padding*2
|
||||
|
||||
var scaledImg image.Image
|
||||
var drawWidth, drawHeight, drawX, drawY int
|
||||
|
||||
// 判断是竖图还是横图,采用不同的缩放策略
|
||||
if aspectRatio < 1.0 {
|
||||
// 竖图:充满整个左侧区域(去掉边距)
|
||||
drawHeight = height - padding*2 // 留上下边距
|
||||
drawWidth = int(float64(drawHeight) * aspectRatio)
|
||||
|
||||
// 如果宽度超出左侧区域,则以宽度为准充满整个区域宽度
|
||||
if drawWidth > imageAreaWidth - padding*2 {
|
||||
drawWidth = imageAreaWidth - padding*2
|
||||
drawHeight = int(float64(drawWidth) / aspectRatio)
|
||||
}
|
||||
|
||||
// 缩放图片
|
||||
scaledImg = scaleImage(img, drawWidth, drawHeight)
|
||||
|
||||
// 垂直居中,水平居左
|
||||
drawX = padding
|
||||
drawY = (height - drawHeight) / 2
|
||||
} else {
|
||||
// 横图:优先占满宽度
|
||||
drawWidth = maxImageWidth
|
||||
drawHeight = int(float64(drawWidth) / aspectRatio)
|
||||
|
||||
// 如果高度超出限制,则以高度为准
|
||||
if drawHeight > maxImageHeight {
|
||||
drawHeight = maxImageHeight
|
||||
drawWidth = int(float64(drawHeight) * aspectRatio)
|
||||
}
|
||||
|
||||
// 缩放图片
|
||||
scaledImg = scaleImage(img, drawWidth, drawHeight)
|
||||
|
||||
// 水平居中,垂直居中
|
||||
drawX = (imageAreaWidth - drawWidth) / 2
|
||||
drawY = (height - drawHeight) / 2
|
||||
}
|
||||
|
||||
// 绘制图片
|
||||
dc.DrawImage(scaledImg, drawX, drawY)
|
||||
|
||||
// 添加半透明遮罩效果,让文字更清晰(仅在有图片时添加)
|
||||
maskColor := color.RGBA{0, 0, 0, 80} // 半透明黑色,透明度稍低
|
||||
dc.SetColor(maskColor)
|
||||
dc.DrawRectangle(float64(drawX), float64(drawY), float64(drawWidth), float64(drawHeight))
|
||||
dc.Fill()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// scaleImage 图片缩放函数
|
||||
func scaleImage(img image.Image, width, height int) image.Image {
|
||||
// 使用 gg 库的 Scale 变换来实现缩放
|
||||
srcWidth := img.Bounds().Dx()
|
||||
srcHeight := img.Bounds().Dy()
|
||||
|
||||
// 创建目标尺寸的画布
|
||||
dc := gg.NewContext(width, height)
|
||||
|
||||
// 计算缩放比例
|
||||
scaleX := float64(width) / float64(srcWidth)
|
||||
scaleY := float64(height) / float64(srcHeight)
|
||||
|
||||
// 应用缩放变换并绘制图片
|
||||
dc.Scale(scaleX, scaleY)
|
||||
dc.DrawImage(img, 0, 0)
|
||||
|
||||
return dc.Image()
|
||||
}
|
||||
|
||||
// drawCoverImage 绘制封面图片(保留原函数作为备用)
|
||||
func drawCoverImage(dc *gg.Context, coverUrl string, width, height int) error {
|
||||
// 下载封面图片
|
||||
resp, err := http.Get(coverUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取图片数据
|
||||
imgData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解码图片
|
||||
img, _, err := image.Decode(bytes.NewReader(imgData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 计算封面图片的位置和大小,放置在左侧
|
||||
coverWidth := 200 // 封面图宽度
|
||||
coverHeight := 280 // 封面图高度
|
||||
coverX := 50
|
||||
coverY := (height - coverHeight) / 2
|
||||
|
||||
// 绘制封面图片(按比例缩放)
|
||||
bounds := img.Bounds()
|
||||
imgWidth := bounds.Dx()
|
||||
imgHeight := bounds.Dy()
|
||||
|
||||
// 计算缩放比例,保持宽高比
|
||||
scaleX := float64(coverWidth) / float64(imgWidth)
|
||||
scaleY := float64(coverHeight) / float64(imgHeight)
|
||||
scale := scaleX
|
||||
if scaleY < scaleX {
|
||||
scale = scaleY
|
||||
}
|
||||
|
||||
// 计算缩放后的尺寸
|
||||
newWidth := int(float64(imgWidth) * scale)
|
||||
newHeight := int(float64(imgHeight) * scale)
|
||||
|
||||
// 居中绘制
|
||||
offsetX := coverX + (coverWidth-newWidth)/2
|
||||
offsetY := coverY + (coverHeight-newHeight)/2
|
||||
|
||||
dc.DrawImage(img, offsetX, offsetY)
|
||||
|
||||
// 添加半透明遮罩效果,让文字更清晰
|
||||
maskColor := color.RGBA{0, 0, 0, 120} // 半透明黑色
|
||||
dc.SetColor(maskColor)
|
||||
dc.DrawRectangle(float64(coverX), float64(coverY), float64(coverWidth), float64(coverHeight))
|
||||
dc.Fill()
|
||||
|
||||
return nil
|
||||
}
|
||||
310
handlers/report_handler.go
Normal file
310
handlers/report_handler.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ReportHandler struct {
|
||||
reportRepo repo.ReportRepository
|
||||
resourceRepo repo.ResourceRepository
|
||||
validate *validator.Validate
|
||||
}
|
||||
|
||||
func NewReportHandler(reportRepo repo.ReportRepository, resourceRepo repo.ResourceRepository) *ReportHandler {
|
||||
return &ReportHandler{
|
||||
reportRepo: reportRepo,
|
||||
resourceRepo: resourceRepo,
|
||||
validate: validator.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateReport 创建举报
|
||||
// @Summary 创建举报
|
||||
// @Description 提交资源举报
|
||||
// @Tags Report
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.ReportCreateRequest true "举报信息"
|
||||
// @Success 200 {object} Response{data=dto.ReportResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports [post]
|
||||
func (h *ReportHandler) CreateReport(c *gin.Context) {
|
||||
var req dto.ReportCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建举报实体
|
||||
report := &entity.Report{
|
||||
ResourceKey: req.ResourceKey,
|
||||
Reason: req.Reason,
|
||||
Description: req.Description,
|
||||
Contact: req.Contact,
|
||||
UserAgent: req.UserAgent,
|
||||
IPAddress: req.IPAddress,
|
||||
Status: "pending", // 默认为待处理
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
if err := h.reportRepo.Create(report); err != nil {
|
||||
ErrorResponse(c, "创建举报失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := converter.ReportToResponse(report)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// GetReport 获取举报详情
|
||||
// @Summary 获取举报详情
|
||||
// @Description 根据ID获取举报详情
|
||||
// @Tags Report
|
||||
// @Produce json
|
||||
// @Param id path int true "举报ID"
|
||||
// @Success 200 {object} Response{data=dto.ReportResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 404 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports/{id} [get]
|
||||
func (h *ReportHandler) GetReport(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
report, err := h.reportRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ErrorResponse(c, "举报不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ErrorResponse(c, "获取举报失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := converter.ReportToResponse(report)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// ListReports 获取举报列表
|
||||
// @Summary 获取举报列表
|
||||
// @Description 获取举报列表(支持分页和状态筛选)
|
||||
// @Tags Report
|
||||
// @Produce json
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(10)
|
||||
// @Param status query string false "处理状态"
|
||||
// @Success 200 {object} Response{data=object{items=[]dto.ReportResponse,total=int}}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports [get]
|
||||
func (h *ReportHandler) ListReports(c *gin.Context) {
|
||||
var req dto.ReportListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if req.Page == 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize == 0 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reports, total, err := h.reportRepo.List(req.Status, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取举报列表失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取每个举报关联的资源
|
||||
var reportResponses []*dto.ReportResponse
|
||||
for _, report := range reports {
|
||||
// 通过资源key查找关联的资源
|
||||
resources, err := h.getResourcesByResourceKey(report.ResourceKey)
|
||||
if err != nil {
|
||||
// 如果获取资源失败,仍然返回基本的举报信息
|
||||
reportResponses = append(reportResponses, converter.ReportToResponse(report))
|
||||
} else {
|
||||
// 使用包含资源详情的转换函数
|
||||
response := converter.ReportToResponseWithResources(report, resources)
|
||||
reportResponses = append(reportResponses, response)
|
||||
}
|
||||
}
|
||||
|
||||
PageResponse(c, reportResponses, total, req.Page, req.PageSize)
|
||||
}
|
||||
|
||||
// getResourcesByResourceKey 根据资源key获取关联的资源列表
|
||||
func (h *ReportHandler) 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
|
||||
}
|
||||
|
||||
// UpdateReport 更新举报状态
|
||||
// @Summary 更新举报状态
|
||||
// @Description 更新举报处理状态
|
||||
// @Tags Report
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "举报ID"
|
||||
// @Param request body dto.ReportUpdateRequest true "更新信息"
|
||||
// @Success 200 {object} Response{data=dto.ReportResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 404 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports/{id} [put]
|
||||
func (h *ReportHandler) UpdateReport(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.ReportUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前举报
|
||||
_, err = h.reportRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ErrorResponse(c, "举报不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ErrorResponse(c, "获取举报失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
processedBy := uint(0) // 从上下文获取当前用户ID,如果存在的话
|
||||
if currentUser := c.GetUint("user_id"); currentUser > 0 {
|
||||
processedBy = currentUser
|
||||
}
|
||||
|
||||
if err := h.reportRepo.UpdateStatus(uint(id), req.Status, &processedBy, req.Note); err != nil {
|
||||
ErrorResponse(c, "更新举报状态失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取更新后的举报信息
|
||||
updatedReport, err := h.reportRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取更新后举报信息失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, converter.ReportToResponse(updatedReport))
|
||||
}
|
||||
|
||||
// DeleteReport 删除举报
|
||||
// @Summary 删除举报
|
||||
// @Description 删除举报记录
|
||||
// @Tags Report
|
||||
// @Produce json
|
||||
// @Param id path int true "举报ID"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports/{id} [delete]
|
||||
func (h *ReportHandler) DeleteReport(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.reportRepo.Delete(uint(id)); err != nil {
|
||||
ErrorResponse(c, "删除举报失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, nil)
|
||||
}
|
||||
|
||||
// GetReportByResource 获取某个资源的举报列表
|
||||
// @Summary 获取资源举报列表
|
||||
// @Description 获取某个资源的所有举报记录
|
||||
// @Tags Report
|
||||
// @Produce json
|
||||
// @Param resource_key path string true "资源Key"
|
||||
// @Success 200 {object} Response{data=[]dto.ReportResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports/resource/{resource_key} [get]
|
||||
func (h *ReportHandler) GetReportByResource(c *gin.Context) {
|
||||
resourceKey := c.Param("resource_key")
|
||||
if resourceKey == "" {
|
||||
ErrorResponse(c, "资源Key不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reports, err := h.reportRepo.GetByResourceKey(resourceKey)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取资源举报失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, converter.ReportsToResponse(reports))
|
||||
}
|
||||
|
||||
// RegisterReportRoutes 注册举报相关路由
|
||||
func RegisterReportRoutes(router *gin.RouterGroup, reportRepo repo.ReportRepository, resourceRepo repo.ResourceRepository) {
|
||||
handler := NewReportHandler(reportRepo, resourceRepo)
|
||||
|
||||
reports := router.Group("/reports")
|
||||
{
|
||||
reports.POST("", handler.CreateReport) // 创建举报
|
||||
reports.GET("/:id", handler.GetReport) // 获取举报详情
|
||||
reports.GET("", handler.ListReports) // 获取举报列表
|
||||
reports.PUT("/:id", handler.UpdateReport) // 更新举报状态
|
||||
reports.DELETE("/:id", handler.DeleteReport) // 删除举报
|
||||
reports.GET("/resource/:resource_key", handler.GetReportByResource) // 获取资源举报列表
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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"
|
||||
@@ -162,6 +165,7 @@ func GetResources(c *gin.Context) {
|
||||
|
||||
resourceResponse := gin.H{
|
||||
"id": processedResource.ID,
|
||||
"key": processedResource.Key, // 添加key字段
|
||||
"title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
|
||||
"url": processedResource.URL,
|
||||
"description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
|
||||
@@ -223,6 +227,51 @@ func GetResourceByID(c *gin.Context) {
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// GetResourcesByKey 根据Key获取资源组
|
||||
func GetResourcesByKey(c *gin.Context) {
|
||||
key := c.Param("key")
|
||||
if key == "" {
|
||||
ErrorResponse(c, "Key参数不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resources, err := repoManager.ResourceRepository.FindByKey(key)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if len(resources) == 0 {
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式并处理违禁词
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{}
|
||||
}
|
||||
|
||||
var responses []dto.ResourceResponse
|
||||
for _, resource := range resources {
|
||||
response := converter.ToResourceResponse(&resource)
|
||||
// 检查违禁词
|
||||
forbiddenInfo := utils.CheckResourceForbiddenWords(response.Title, response.Description, cleanWords)
|
||||
response.HasForbiddenWords = forbiddenInfo.HasForbiddenWords
|
||||
response.ForbiddenWords = forbiddenInfo.ForbiddenWords
|
||||
responses = append(responses, response)
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"resources": responses,
|
||||
"total": len(responses),
|
||||
"key": key,
|
||||
})
|
||||
}
|
||||
|
||||
// CheckResourceExists 检查资源是否存在(测试FindExists函数)
|
||||
func CheckResourceExists(c *gin.Context) {
|
||||
url := c.Query("url")
|
||||
@@ -855,6 +904,591 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
|
||||
}
|
||||
}
|
||||
|
||||
// GetHotResources 获取热门资源
|
||||
func GetHotResources(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
|
||||
utils.Info("获取热门资源请求 - limit: %d", limit)
|
||||
|
||||
// 限制最大请求数量
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
// 使用公共缓存机制
|
||||
cacheKey := fmt.Sprintf("hot_resources_%d", limit)
|
||||
ttl := time.Hour // 1小时缓存
|
||||
cacheManager := utils.GetHotResourcesCache()
|
||||
|
||||
// 尝试从缓存获取
|
||||
if cachedData, found := cacheManager.Get(cacheKey, ttl); found {
|
||||
utils.Info("使用热门资源缓存 - key: %s", cacheKey)
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("ETag", fmt.Sprintf("hot-resources-%d", len(cachedData.([]gin.H))))
|
||||
|
||||
// 转换为正确的类型
|
||||
if data, ok := cachedData.([]gin.H); ok {
|
||||
SuccessResponse(c, gin.H{
|
||||
"data": data,
|
||||
"total": len(data),
|
||||
"limit": limit,
|
||||
"cached": true,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存未命中,从数据库获取
|
||||
resources, err := repoManager.ResourceRepository.GetHotResources(limit)
|
||||
if err != nil {
|
||||
utils.Error("获取热门资源失败: %v", err)
|
||||
ErrorResponse(c, "获取热门资源失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取违禁词配置
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{}
|
||||
}
|
||||
|
||||
// 处理违禁词并转换为响应格式
|
||||
var resourceResponses []gin.H
|
||||
for _, resource := range resources {
|
||||
// 检查违禁词
|
||||
forbiddenInfo := utils.CheckResourceForbiddenWords(resource.Title, resource.Description, cleanWords)
|
||||
|
||||
resourceResponse := gin.H{
|
||||
"id": resource.ID,
|
||||
"key": resource.Key,
|
||||
"title": forbiddenInfo.ProcessedTitle,
|
||||
"url": resource.URL,
|
||||
"description": forbiddenInfo.ProcessedDesc,
|
||||
"pan_id": resource.PanID,
|
||||
"view_count": resource.ViewCount,
|
||||
"created_at": resource.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"updated_at": resource.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
"cover": resource.Cover,
|
||||
"author": resource.Author,
|
||||
"file_size": resource.FileSize,
|
||||
}
|
||||
|
||||
// 添加违禁词标记
|
||||
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
|
||||
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
|
||||
|
||||
// 添加标签信息
|
||||
var tagResponses []gin.H
|
||||
if len(resource.Tags) > 0 {
|
||||
for _, tag := range resource.Tags {
|
||||
tagResponse := gin.H{
|
||||
"id": tag.ID,
|
||||
"name": tag.Name,
|
||||
"description": tag.Description,
|
||||
}
|
||||
tagResponses = append(tagResponses, tagResponse)
|
||||
}
|
||||
}
|
||||
resourceResponse["tags"] = tagResponses
|
||||
|
||||
resourceResponses = append(resourceResponses, resourceResponse)
|
||||
}
|
||||
|
||||
// 存储到缓存
|
||||
cacheManager.Set(cacheKey, resourceResponses)
|
||||
utils.Info("热门资源已缓存 - key: %s, count: %d", cacheKey, len(resourceResponses))
|
||||
|
||||
// 设置缓存头
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("ETag", fmt.Sprintf("hot-resources-%d", len(resourceResponses)))
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"data": resourceResponses,
|
||||
"total": len(resourceResponses),
|
||||
"limit": limit,
|
||||
"cached": false,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRelatedResources 获取相关资源
|
||||
func GetRelatedResources(c *gin.Context) {
|
||||
// 获取查询参数
|
||||
key := c.Query("key") // 当前资源的key
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "8"))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
|
||||
utils.Info("获取相关资源请求 - key: %s, limit: %d", key, limit)
|
||||
|
||||
if key == "" {
|
||||
ErrorResponse(c, "缺少资源key参数", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 首先通过key获取当前资源信息
|
||||
currentResources, err := repoManager.ResourceRepository.FindByKey(key)
|
||||
if err != nil {
|
||||
utils.Error("获取当前资源失败: %v", err)
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if len(currentResources) == 0 {
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
currentResource := ¤tResources[0] // 取第一个资源作为当前资源
|
||||
|
||||
var resources []entity.Resource
|
||||
var total int64
|
||||
|
||||
// 获取当前资源的标签ID列表
|
||||
var tagIDsList []string
|
||||
if currentResource.Tags != nil {
|
||||
for _, tag := range currentResource.Tags {
|
||||
tagIDsList = append(tagIDsList, strconv.Itoa(int(tag.ID)))
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("当前资源标签: %v", tagIDsList)
|
||||
|
||||
// 1. 优先使用Meilisearch进行标签搜索
|
||||
if meilisearchManager != nil && meilisearchManager.IsEnabled() && len(tagIDsList) > 0 {
|
||||
service := meilisearchManager.GetService()
|
||||
if service != nil {
|
||||
// 使用标签进行搜索
|
||||
filters := make(map[string]interface{})
|
||||
filters["tag_ids"] = tagIDsList
|
||||
|
||||
// 使用当前资源的标题作为搜索关键词,提高相关性
|
||||
searchQuery := currentResource.Title
|
||||
if searchQuery == "" {
|
||||
searchQuery = strings.Join(tagIDsList, " ") // 如果没有标题,使用标签作为搜索词
|
||||
}
|
||||
|
||||
docs, docTotal, err := service.Search(searchQuery, filters, page, limit)
|
||||
if err == nil && len(docs) > 0 {
|
||||
// 转换为Resource实体
|
||||
for _, doc := range docs {
|
||||
// 排除当前资源
|
||||
if doc.Key == key {
|
||||
continue
|
||||
}
|
||||
resource := entity.Resource{
|
||||
ID: doc.ID,
|
||||
Title: doc.Title,
|
||||
Description: doc.Description,
|
||||
URL: doc.URL,
|
||||
SaveURL: doc.SaveURL,
|
||||
FileSize: doc.FileSize,
|
||||
Key: doc.Key,
|
||||
PanID: doc.PanID,
|
||||
ViewCount: 0, // Meilisearch文档中没有ViewCount字段,设为默认值
|
||||
CreatedAt: doc.CreatedAt,
|
||||
UpdatedAt: doc.UpdatedAt,
|
||||
Cover: doc.Cover,
|
||||
Author: doc.Author,
|
||||
}
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
total = docTotal
|
||||
utils.Info("Meilisearch搜索到 %d 个相关资源", len(resources))
|
||||
} else {
|
||||
utils.Error("Meilisearch搜索失败,回退到标签搜索: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果Meilisearch未启用、搜索失败或没有结果,使用数据库标签搜索
|
||||
if len(resources) == 0 {
|
||||
params := map[string]interface{}{
|
||||
"page": page,
|
||||
"page_size": limit,
|
||||
"is_public": true,
|
||||
"order_by": "updated_at",
|
||||
"order_dir": "desc",
|
||||
}
|
||||
|
||||
// 使用当前资源的标签进行搜索
|
||||
if len(tagIDsList) > 0 {
|
||||
params["tag_ids"] = strings.Join(tagIDsList, ",")
|
||||
} else {
|
||||
// 如果没有标签,使用当前资源的分类作为搜索条件
|
||||
if currentResource.CategoryID != nil && *currentResource.CategoryID > 0 {
|
||||
params["category_id"] = *currentResource.CategoryID
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
|
||||
if err != nil {
|
||||
utils.Error("搜索相关资源失败: %v", err)
|
||||
ErrorResponse(c, "搜索相关资源失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 排除当前资源
|
||||
var filteredResources []entity.Resource
|
||||
for _, resource := range resources {
|
||||
if resource.Key != key {
|
||||
filteredResources = append(filteredResources, resource)
|
||||
}
|
||||
}
|
||||
resources = filteredResources
|
||||
total = int64(len(filteredResources))
|
||||
}
|
||||
|
||||
utils.Info("标签搜索到 %d 个相关资源", len(resources))
|
||||
|
||||
// 获取违禁词配置
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{}
|
||||
}
|
||||
|
||||
// 处理违禁词并转换为响应格式
|
||||
var resourceResponses []gin.H
|
||||
for _, resource := range resources {
|
||||
// 检查违禁词
|
||||
forbiddenInfo := utils.CheckResourceForbiddenWords(resource.Title, resource.Description, cleanWords)
|
||||
|
||||
resourceResponse := gin.H{
|
||||
"id": resource.ID,
|
||||
"key": resource.Key,
|
||||
"title": forbiddenInfo.ProcessedTitle,
|
||||
"url": resource.URL,
|
||||
"description": forbiddenInfo.ProcessedDesc,
|
||||
"pan_id": resource.PanID,
|
||||
"view_count": resource.ViewCount,
|
||||
"created_at": resource.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"updated_at": resource.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
"cover": resource.Cover,
|
||||
"author": resource.Author,
|
||||
"file_size": resource.FileSize,
|
||||
}
|
||||
|
||||
// 添加违禁词标记
|
||||
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
|
||||
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
|
||||
|
||||
// 添加标签信息
|
||||
var tagResponses []gin.H
|
||||
if len(resource.Tags) > 0 {
|
||||
for _, tag := range resource.Tags {
|
||||
tagResponse := gin.H{
|
||||
"id": tag.ID,
|
||||
"name": tag.Name,
|
||||
"description": tag.Description,
|
||||
}
|
||||
tagResponses = append(tagResponses, tagResponse)
|
||||
}
|
||||
}
|
||||
resourceResponse["tags"] = tagResponses
|
||||
|
||||
resourceResponses = append(resourceResponses, resourceResponse)
|
||||
}
|
||||
|
||||
// 构建响应数据
|
||||
responseData := gin.H{
|
||||
"data": resourceResponses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": limit,
|
||||
"source": "database",
|
||||
}
|
||||
|
||||
if meilisearchManager != nil && meilisearchManager.IsEnabled() && len(tagIDsList) > 0 {
|
||||
responseData["source"] = "meilisearch"
|
||||
}
|
||||
|
||||
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平台
|
||||
|
||||
411
handlers/sitemap_handler.go
Normal file
411
handlers/sitemap_handler.go
Normal file
@@ -0,0 +1,411 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/scheduler"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
resourceRepo repo.ResourceRepository
|
||||
systemConfigRepo repo.SystemConfigRepository
|
||||
hotDramaRepo repo.HotDramaRepository
|
||||
readyResourceRepo repo.ReadyResourceRepository
|
||||
panRepo repo.PanRepository
|
||||
cksRepo repo.CksRepository
|
||||
tagRepo repo.TagRepository
|
||||
categoryRepo repo.CategoryRepository
|
||||
)
|
||||
|
||||
// SetSitemapDependencies 注册Sitemap处理器依赖
|
||||
func SetSitemapDependencies(
|
||||
resourceRepository repo.ResourceRepository,
|
||||
systemConfigRepository repo.SystemConfigRepository,
|
||||
hotDramaRepository repo.HotDramaRepository,
|
||||
readyResourceRepository repo.ReadyResourceRepository,
|
||||
panRepository repo.PanRepository,
|
||||
cksRepository repo.CksRepository,
|
||||
tagRepository repo.TagRepository,
|
||||
categoryRepository repo.CategoryRepository,
|
||||
) {
|
||||
resourceRepo = resourceRepository
|
||||
systemConfigRepo = systemConfigRepository
|
||||
hotDramaRepo = hotDramaRepository
|
||||
readyResourceRepo = readyResourceRepository
|
||||
panRepo = panRepository
|
||||
cksRepo = cksRepository
|
||||
tagRepo = tagRepository
|
||||
categoryRepo = categoryRepository
|
||||
}
|
||||
|
||||
|
||||
const SITEMAP_MAX_URLS = 50000 // 每个sitemap最多5万个URL
|
||||
|
||||
// SitemapIndex sitemap索引结构
|
||||
type SitemapIndex struct {
|
||||
XMLName xml.Name `xml:"sitemapindex"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
Sitemaps []Sitemap `xml:"sitemap"`
|
||||
}
|
||||
|
||||
// Sitemap 单个sitemap信息
|
||||
type Sitemap struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod"`
|
||||
}
|
||||
|
||||
// UrlSet sitemap内容
|
||||
type UrlSet struct {
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
URLs []Url `xml:"url"`
|
||||
}
|
||||
|
||||
// Url 单个URL信息
|
||||
type Url struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod"`
|
||||
ChangeFreq string `xml:"changefreq"`
|
||||
Priority float64 `xml:"priority"`
|
||||
}
|
||||
|
||||
// SitemapConfig sitemap配置
|
||||
type SitemapConfig struct {
|
||||
AutoGenerate bool `json:"auto_generate"`
|
||||
LastGenerate time.Time `json:"last_generate"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
}
|
||||
|
||||
// GetSitemapConfig 获取sitemap配置
|
||||
func GetSitemapConfig(c *gin.Context) {
|
||||
// 从全局调度器获取配置
|
||||
enabled, err := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
).GetSitemapConfig()
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
// 如果获取失败,尝试从配置表中获取
|
||||
configStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapAutoGenerateEnabled)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
ErrorResponse(c, "获取配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
enabled = configStr == "1" || configStr == "true"
|
||||
}
|
||||
|
||||
// 获取最后生成时间(从配置中获取)
|
||||
configStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapLastGenerateTime)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
// 如果获取失败,只返回启用状态
|
||||
config := SitemapConfig{
|
||||
AutoGenerate: enabled,
|
||||
LastGenerate: time.Time{}, // 空时间
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
SuccessResponse(c, config)
|
||||
return
|
||||
}
|
||||
|
||||
var lastGenerateTime time.Time
|
||||
if configStr != "" {
|
||||
lastGenerateTime, _ = time.Parse("2006-01-02 15:04:05", configStr)
|
||||
}
|
||||
|
||||
config := SitemapConfig{
|
||||
AutoGenerate: enabled,
|
||||
LastGenerate: lastGenerateTime,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
|
||||
SuccessResponse(c, config)
|
||||
}
|
||||
|
||||
// UpdateSitemapConfig 更新sitemap配置
|
||||
func UpdateSitemapConfig(c *gin.Context) {
|
||||
var config SitemapConfig
|
||||
if err := c.ShouldBindJSON(&config); err != nil {
|
||||
ErrorResponse(c, "参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新调度器配置
|
||||
if err := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
).UpdateSitemapConfig(config.AutoGenerate); err != nil {
|
||||
ErrorResponse(c, "更新调度器配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 保存自动生成功能状态
|
||||
autoGenerateStr := "0"
|
||||
if config.AutoGenerate {
|
||||
autoGenerateStr = "1"
|
||||
}
|
||||
autoGenerateConfig := entity.SystemConfig{
|
||||
Key: entity.ConfigKeySitemapAutoGenerateEnabled,
|
||||
Value: autoGenerateStr,
|
||||
Type: "bool",
|
||||
}
|
||||
|
||||
// 保存最后生成时间
|
||||
lastGenerateStr := config.LastGenerate.Format("2006-01-02 15:04:05")
|
||||
lastGenerateConfig := entity.SystemConfig{
|
||||
Key: entity.ConfigKeySitemapLastGenerateTime,
|
||||
Value: lastGenerateStr,
|
||||
Type: "string",
|
||||
}
|
||||
|
||||
configs := []entity.SystemConfig{autoGenerateConfig, lastGenerateConfig}
|
||||
if err := systemConfigRepo.UpsertConfigs(configs); err != nil {
|
||||
ErrorResponse(c, "保存配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据配置启动或停止调度器
|
||||
if config.AutoGenerate {
|
||||
scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
).StartSitemapScheduler()
|
||||
} else {
|
||||
scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
).StopSitemapScheduler()
|
||||
}
|
||||
|
||||
SuccessResponse(c, config)
|
||||
}
|
||||
|
||||
// GenerateSitemap 手动生成sitemap
|
||||
func GenerateSitemap(c *gin.Context) {
|
||||
// 获取资源总数
|
||||
var total int64
|
||||
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
|
||||
ErrorResponse(c, "获取资源总数失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
|
||||
|
||||
// 获取全局调度器并立即执行sitemap生成
|
||||
globalScheduler := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
)
|
||||
|
||||
// 手动触发sitemap生成
|
||||
globalScheduler.TriggerSitemapGeneration()
|
||||
|
||||
// 记录最后生成时间为当前时间
|
||||
lastGenerateStr := time.Now().Format("2006-01-02 15:04:05")
|
||||
lastGenerateConfig := entity.SystemConfig{
|
||||
Key: entity.ConfigKeySitemapLastGenerateTime,
|
||||
Value: lastGenerateStr,
|
||||
Type: "string",
|
||||
}
|
||||
|
||||
if err := systemConfigRepo.UpsertConfigs([]entity.SystemConfig{lastGenerateConfig}); err != nil {
|
||||
ErrorResponse(c, "更新最后生成时间失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"total_resources": total,
|
||||
"total_pages": totalPages,
|
||||
"status": "started",
|
||||
"message": fmt.Sprintf("开始生成 %d 个sitemap文件", totalPages),
|
||||
}
|
||||
|
||||
SuccessResponse(c, result)
|
||||
}
|
||||
|
||||
// GetSitemapStatus 获取sitemap生成状态
|
||||
func GetSitemapStatus(c *gin.Context) {
|
||||
// 获取资源总数
|
||||
var total int64
|
||||
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
|
||||
ErrorResponse(c, "获取资源总数失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算需要生成的sitemap文件数量
|
||||
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
|
||||
|
||||
// 获取最后生成时间
|
||||
lastGenerateStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapLastGenerateTime)
|
||||
if err != nil {
|
||||
// 如果没有记录,使用当前时间
|
||||
lastGenerateStr = time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
lastGenerate, err := time.Parse("2006-01-02 15:04:05", lastGenerateStr)
|
||||
if err != nil {
|
||||
lastGenerate = time.Now()
|
||||
}
|
||||
|
||||
// 检查调度器是否运行
|
||||
isRunning := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
).IsSitemapSchedulerRunning()
|
||||
|
||||
// 获取自动生成功能状态
|
||||
autoGenerateEnabled, err := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
).GetSitemapConfig()
|
||||
if err != nil {
|
||||
// 如果调度器获取失败,从配置中获取
|
||||
configStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapAutoGenerateEnabled)
|
||||
if err != nil {
|
||||
autoGenerateEnabled = false
|
||||
} else {
|
||||
autoGenerateEnabled = configStr == "1" || configStr == "true"
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"total_resources": total,
|
||||
"total_pages": totalPages,
|
||||
"last_generate": lastGenerate.Format("2006-01-02 15:04:05"),
|
||||
"status": "ready",
|
||||
"is_running": isRunning,
|
||||
"auto_generate": autoGenerateEnabled,
|
||||
}
|
||||
|
||||
SuccessResponse(c, result)
|
||||
}
|
||||
|
||||
// SitemapIndexHandler sitemap索引文件处理器
|
||||
func SitemapIndexHandler(c *gin.Context) {
|
||||
// 获取资源总数
|
||||
var total int64
|
||||
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取资源总数失败"})
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
|
||||
|
||||
// 构建主机URL
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if host == "" {
|
||||
host = "localhost:8080" // 默认值
|
||||
}
|
||||
baseURL := fmt.Sprintf("%s://%s", scheme, host)
|
||||
|
||||
// 创建sitemap列表 - 现在文件保存在data/sitemap目录,通过/file/sitemap/路径访问
|
||||
var sitemaps []Sitemap
|
||||
for i := 0; i < totalPages; i++ {
|
||||
sitemapURL := fmt.Sprintf("%s/file/sitemap/sitemap-%d.xml", baseURL, i)
|
||||
sitemaps = append(sitemaps, Sitemap{
|
||||
Loc: sitemapURL,
|
||||
LastMod: time.Now().Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
|
||||
sitemapIndex := SitemapIndex{
|
||||
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
Sitemaps: sitemaps,
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/xml")
|
||||
c.XML(http.StatusOK, sitemapIndex)
|
||||
}
|
||||
|
||||
// SitemapPageHandler sitemap页面处理器
|
||||
func SitemapPageHandler(c *gin.Context) {
|
||||
pageStr := c.Param("page")
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面参数"})
|
||||
return
|
||||
}
|
||||
|
||||
offset := page * SITEMAP_MAX_URLS
|
||||
limit := SITEMAP_MAX_URLS
|
||||
|
||||
var resources []entity.Resource
|
||||
if err := resourceRepo.GetDB().Offset(offset).Limit(limit).Find(&resources).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取资源数据失败"})
|
||||
return
|
||||
}
|
||||
|
||||
var urls []Url
|
||||
for _, resource := range resources {
|
||||
lastMod := resource.UpdatedAt
|
||||
if resource.CreatedAt.After(lastMod) {
|
||||
lastMod = resource.CreatedAt
|
||||
}
|
||||
|
||||
urls = append(urls, Url{
|
||||
Loc: fmt.Sprintf("/r/%s", resource.Key),
|
||||
LastMod: lastMod.Format("2006-01-01"), // 只保留日期部分
|
||||
ChangeFreq: "weekly",
|
||||
Priority: 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
urlSet := UrlSet{
|
||||
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
URLs: urls,
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/xml")
|
||||
c.XML(http.StatusOK, urlSet)
|
||||
}
|
||||
|
||||
// 手动生成完整sitemap文件
|
||||
func GenerateFullSitemap(c *gin.Context) {
|
||||
// 获取资源总数
|
||||
var total int64
|
||||
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
|
||||
ErrorResponse(c, "获取资源总数失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取全局调度器并立即执行sitemap生成
|
||||
globalScheduler := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
)
|
||||
|
||||
// 手动触发sitemap生成
|
||||
globalScheduler.TriggerSitemapGeneration()
|
||||
|
||||
// 记录最后生成时间为当前时间
|
||||
lastGenerateStr := time.Now().Format("2006-01-02 15:04:05")
|
||||
lastGenerateConfig := entity.SystemConfig{
|
||||
Key: entity.ConfigKeySitemapLastGenerateTime,
|
||||
Value: lastGenerateStr,
|
||||
Type: "string",
|
||||
}
|
||||
|
||||
if err := systemConfigRepo.UpsertConfigs([]entity.SystemConfig{lastGenerateConfig}); err != nil {
|
||||
ErrorResponse(c, "更新最后生成时间失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"message": "Sitemap生成任务已启动",
|
||||
"total_resources": total,
|
||||
"status": "processing",
|
||||
"estimated_time": fmt.Sprintf("%d秒", total/1000), // 估算时间
|
||||
}
|
||||
|
||||
SuccessResponse(c, result)
|
||||
}
|
||||
@@ -511,6 +511,27 @@ func (h *TelegramHandler) GetTelegramLogStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ManualPushToChannel 手动推送到频道
|
||||
func (h *TelegramHandler) ManualPushToChannel(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
channelID, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的频道ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.telegramBotService.ManualPushToChannel(uint(channelID))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "手动推送失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "手动推送请求已提交",
|
||||
})
|
||||
}
|
||||
|
||||
// ClearTelegramLogs 清理旧的Telegram日志
|
||||
func (h *TelegramHandler) ClearTelegramLogs(c *gin.Context) {
|
||||
daysStr := c.DefaultQuery("days", "30")
|
||||
|
||||
81
main.go
81
main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -159,6 +160,18 @@ func main() {
|
||||
// 将Repository管理器注入到services中
|
||||
services.SetRepositoryManager(repoManager)
|
||||
|
||||
// 设置Sitemap处理器依赖
|
||||
handlers.SetSitemapDependencies(
|
||||
repoManager.ResourceRepository,
|
||||
repoManager.SystemConfigRepository,
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.PanRepository,
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
)
|
||||
|
||||
// 设置Meilisearch管理器到handlers中
|
||||
handlers.SetMeilisearchManager(meilisearchManager)
|
||||
|
||||
@@ -184,6 +197,7 @@ func main() {
|
||||
autoFetchHotDrama, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoFetchHotDramaEnabled)
|
||||
autoProcessReadyResources, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
|
||||
autoTransferEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
|
||||
autoSitemapEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeySitemapAutoGenerateEnabled)
|
||||
|
||||
globalScheduler.UpdateSchedulerStatusWithAutoTransfer(
|
||||
autoFetchHotDrama,
|
||||
@@ -191,6 +205,14 @@ func main() {
|
||||
autoTransferEnabled,
|
||||
)
|
||||
|
||||
// 根据系统配置启动Sitemap调度器
|
||||
if autoSitemapEnabled {
|
||||
globalScheduler.StartSitemapScheduler()
|
||||
utils.Info("系统配置启用Sitemap自动生成功能,启动定时任务")
|
||||
} else {
|
||||
utils.Info("系统配置禁用Sitemap自动生成功能")
|
||||
}
|
||||
|
||||
utils.Info("调度器初始化完成")
|
||||
|
||||
// 设置公开API中间件的Repository管理器
|
||||
@@ -211,6 +233,10 @@ func main() {
|
||||
// 创建OG图片处理器
|
||||
ogImageHandler := handlers.NewOGImageHandler()
|
||||
|
||||
// 创建举报和版权申述处理器
|
||||
reportHandler := handlers.NewReportHandler(repoManager.ReportRepository, repoManager.ResourceRepository)
|
||||
copyrightClaimHandler := handlers.NewCopyrightClaimHandler(repoManager.CopyrightClaimRepository, repoManager.ResourceRepository)
|
||||
|
||||
// API路由
|
||||
api := r.Group("/api")
|
||||
{
|
||||
@@ -233,13 +259,18 @@ func main() {
|
||||
|
||||
// 资源管理
|
||||
api.GET("/resources", handlers.GetResources)
|
||||
api.GET("/resources/hot", handlers.GetHotResources)
|
||||
api.POST("/resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.CreateResource)
|
||||
api.PUT("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateResource)
|
||||
api.DELETE("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteResource)
|
||||
api.GET("/resources/:id", handlers.GetResourceByID)
|
||||
api.GET("/resources/key/:key", handlers.GetResourcesByKey)
|
||||
api.GET("/resources/check-exists", handlers.CheckResourceExists)
|
||||
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)
|
||||
|
||||
// 分类管理
|
||||
@@ -426,6 +457,7 @@ func main() {
|
||||
api.GET("/telegram/logs/stats", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.GetTelegramLogStats)
|
||||
api.POST("/telegram/logs/clear", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.ClearTelegramLogs)
|
||||
api.POST("/telegram/webhook", telegramHandler.HandleWebhook)
|
||||
api.POST("/telegram/manual-push/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.ManualPushToChannel)
|
||||
|
||||
// 微信公众号相关路由
|
||||
wechatHandler := handlers.NewWechatHandler(
|
||||
@@ -440,6 +472,55 @@ func main() {
|
||||
|
||||
// OG图片生成路由
|
||||
api.GET("/og-image", ogImageHandler.GenerateOGImage)
|
||||
|
||||
// 举报和版权申述路由
|
||||
api.POST("/reports", reportHandler.CreateReport)
|
||||
api.GET("/reports/:id", reportHandler.GetReport)
|
||||
api.GET("/reports", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.ListReports)
|
||||
api.PUT("/reports/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.UpdateReport)
|
||||
api.DELETE("/reports/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.DeleteReport)
|
||||
api.GET("/reports/resource/:resource_key", reportHandler.GetReportByResource)
|
||||
|
||||
api.POST("/copyright-claims", copyrightClaimHandler.CreateCopyrightClaim)
|
||||
api.GET("/copyright-claims/:id", copyrightClaimHandler.GetCopyrightClaim)
|
||||
api.GET("/copyright-claims", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.ListCopyrightClaims)
|
||||
api.PUT("/copyright-claims/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.UpdateCopyrightClaim)
|
||||
api.DELETE("/copyright-claims/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.DeleteCopyrightClaim)
|
||||
api.GET("/copyright-claims/resource/:resource_key", copyrightClaimHandler.GetCopyrightClaimByResource)
|
||||
|
||||
// Sitemap静态文件服务(优先于API路由)
|
||||
// 提供生成的sitemap.xml索引文件
|
||||
r.StaticFile("/sitemap.xml", "./data/sitemap/sitemap.xml")
|
||||
// 提供生成的sitemap分页文件,使用通配符路由
|
||||
r.GET("/sitemap-:page", func(c *gin.Context) {
|
||||
page := c.Param("page")
|
||||
if !strings.HasSuffix(page, ".xml") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
c.File("./data/sitemap/sitemap-" + page)
|
||||
})
|
||||
|
||||
// Sitemap静态文件API路由(API兼容)
|
||||
api.GET("/sitemap.xml", func(c *gin.Context) {
|
||||
c.File("./data/sitemap/sitemap.xml")
|
||||
})
|
||||
// 提供生成的sitemap分页文件,使用API路径
|
||||
api.GET("/sitemap-:page", func(c *gin.Context) {
|
||||
page := c.Param("page")
|
||||
if !strings.HasSuffix(page, ".xml") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
c.File("./data/sitemap/sitemap-" + page)
|
||||
})
|
||||
|
||||
// Sitemap管理API(通过管理员接口进行管理)
|
||||
api.GET("/sitemap/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSitemapConfig)
|
||||
api.POST("/sitemap/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateSitemapConfig)
|
||||
api.POST("/sitemap/generate", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GenerateSitemap)
|
||||
api.GET("/sitemap/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSitemapStatus)
|
||||
api.POST("/sitemap/full-generate", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GenerateFullSitemap)
|
||||
}
|
||||
|
||||
// 设置监控系统
|
||||
|
||||
@@ -56,22 +56,18 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// logRequest 记录请求日志 - 优化后仅记录异常和关键请求
|
||||
// logRequest 记录请求日志 - 恢复正常请求日志记录
|
||||
func logRequest(r *http.Request, rw *responseWriter, duration time.Duration, requestBody []byte) {
|
||||
// 获取客户端IP
|
||||
clientIP := getClientIP(r)
|
||||
|
||||
// 判断是否需要记录日志的条件
|
||||
shouldLog := rw.statusCode >= 400 || // 错误状态码
|
||||
// 判断是否需要详细记录日志的条件
|
||||
shouldDetailLog := rw.statusCode >= 400 || // 错误状态码
|
||||
duration > 5*time.Second || // 耗时过长
|
||||
shouldLogPath(r.URL.Path) || // 关键路径
|
||||
isAdminPath(r.URL.Path) // 管理员路径
|
||||
|
||||
if !shouldLog {
|
||||
return // 正常请求不记录日志,减少日志噪音
|
||||
}
|
||||
|
||||
// 简化的日志格式,移除User-Agent以减少噪音
|
||||
// 所有API请求都记录基本信息,但详细日志只记录重要请求
|
||||
if rw.statusCode >= 400 {
|
||||
// 错误请求记录详细信息
|
||||
utils.Error("HTTP异常 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
|
||||
@@ -85,10 +81,14 @@ func logRequest(r *http.Request, rw *responseWriter, duration time.Duration, req
|
||||
// 慢请求警告
|
||||
utils.Warn("HTTP慢请求 - %s %s - IP: %s - 耗时: %v",
|
||||
r.Method, r.URL.Path, clientIP, duration)
|
||||
} else {
|
||||
} else if shouldDetailLog {
|
||||
// 关键路径的正常请求
|
||||
utils.Info("HTTP关键请求 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
|
||||
r.Method, r.URL.Path, clientIP, rw.statusCode, duration)
|
||||
} else {
|
||||
// 普通API请求记录简化日志 - 使用Info级别确保能被看到
|
||||
// utils.Info("HTTP请求 - %s %s - 状态码: %d - 耗时: %v",
|
||||
// r.Method, r.URL.Path, rw.statusCode, duration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,13 @@ func shouldLogPath(path string) bool {
|
||||
"/api/admin/config",
|
||||
"/api/admin/users",
|
||||
"/telegram/webhook",
|
||||
"/api/resources",
|
||||
"/api/version",
|
||||
"/api/cks",
|
||||
"/api/pans",
|
||||
"/api/categories",
|
||||
"/api/tags",
|
||||
"/api/tasks",
|
||||
}
|
||||
|
||||
for _, keyPath := range keyPaths {
|
||||
@@ -113,7 +120,7 @@ func shouldLogPath(path string) bool {
|
||||
// isAdminPath 判断是否为管理员路径
|
||||
func isAdminPath(path string) bool {
|
||||
return strings.HasPrefix(path, "/api/admin/") ||
|
||||
strings.HasPrefix(path, "/admin/")
|
||||
strings.HasPrefix(path, "/admin/")
|
||||
}
|
||||
|
||||
// getClientIP 获取客户端真实IP地址
|
||||
|
||||
@@ -101,6 +101,23 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /sitemap.xml {
|
||||
proxy_pass http://backend/api/sitemap.xml;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 其他 sitemap 分页文件
|
||||
location ~ ^/sitemap-\d+\.xml$ {
|
||||
proxy_pass http://backend/api$request_uri;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 默认路由 - 所有其他请求转发到前端
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
|
||||
96
scheduler/cache_cleaner.go
Normal file
96
scheduler/cache_cleaner.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// CacheCleaner 缓存清理调度器
|
||||
type CacheCleaner struct {
|
||||
baseScheduler *BaseScheduler
|
||||
running bool
|
||||
ticker *time.Ticker
|
||||
stopChan chan bool
|
||||
}
|
||||
|
||||
// NewCacheCleaner 创建缓存清理调度器
|
||||
func NewCacheCleaner(baseScheduler *BaseScheduler) *CacheCleaner {
|
||||
return &CacheCleaner{
|
||||
baseScheduler: baseScheduler,
|
||||
running: false,
|
||||
ticker: time.NewTicker(time.Hour), // 每小时执行一次
|
||||
stopChan: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动缓存清理任务
|
||||
func (cc *CacheCleaner) Start() {
|
||||
if cc.running {
|
||||
utils.Warn("缓存清理任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
cc.running = true
|
||||
utils.Info("启动缓存清理任务")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-cc.ticker.C:
|
||||
cc.cleanCache()
|
||||
case <-cc.stopChan:
|
||||
cc.running = false
|
||||
utils.Info("缓存清理任务已停止")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop 停止缓存清理任务
|
||||
func (cc *CacheCleaner) Stop() {
|
||||
if !cc.running {
|
||||
return
|
||||
}
|
||||
|
||||
close(cc.stopChan)
|
||||
cc.ticker.Stop()
|
||||
}
|
||||
|
||||
// cleanCache 执行缓存清理
|
||||
func (cc *CacheCleaner) cleanCache() {
|
||||
utils.Debug("开始清理过期缓存")
|
||||
|
||||
// 清理过期缓存(1小时TTL)
|
||||
utils.CleanAllExpiredCaches(time.Hour)
|
||||
utils.Debug("定期清理过期缓存完成")
|
||||
|
||||
// 可以在这里添加其他缓存清理逻辑,比如:
|
||||
// - 清理特定模式的缓存
|
||||
// - 记录缓存统计信息
|
||||
cc.logCacheStats()
|
||||
}
|
||||
|
||||
// logCacheStats 记录缓存统计信息
|
||||
func (cc *CacheCleaner) logCacheStats() {
|
||||
hotCacheSize := utils.GetHotResourcesCache().Size()
|
||||
relatedCacheSize := utils.GetRelatedResourcesCache().Size()
|
||||
systemConfigSize := utils.GetSystemConfigCache().Size()
|
||||
categoriesSize := utils.GetCategoriesCache().Size()
|
||||
tagsSize := utils.GetTagsCache().Size()
|
||||
|
||||
totalSize := hotCacheSize + relatedCacheSize + systemConfigSize + categoriesSize + tagsSize
|
||||
|
||||
utils.Debug("缓存统计 - 热门资源: %d, 相关资源: %d, 系统配置: %d, 分类: %d, 标签: %d, 总计: %d",
|
||||
hotCacheSize, relatedCacheSize, systemConfigSize, categoriesSize, tagsSize, totalSize)
|
||||
|
||||
// 如果缓存过多,可以记录警告
|
||||
if totalSize > 1000 {
|
||||
utils.Warn("缓存项数量过多: %d,建议检查缓存策略", totalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning 检查是否正在运行
|
||||
func (cc *CacheCleaner) IsRunning() bool {
|
||||
return cc.running
|
||||
}
|
||||
@@ -148,3 +148,56 @@ func (gs *GlobalScheduler) UpdateSchedulerStatusWithAutoTransfer(autoFetchHotDra
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// StartSitemapScheduler 启动Sitemap调度任务
|
||||
func (gs *GlobalScheduler) StartSitemapScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.manager.IsSitemapRunning() {
|
||||
utils.Debug("Sitemap定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StartSitemapScheduler()
|
||||
utils.Debug("全局调度器已启动Sitemap定时任务")
|
||||
}
|
||||
|
||||
// StopSitemapScheduler 停止Sitemap调度任务
|
||||
func (gs *GlobalScheduler) StopSitemapScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.manager.IsSitemapRunning() {
|
||||
utils.Debug("Sitemap定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StopSitemapScheduler()
|
||||
utils.Debug("全局调度器已停止Sitemap定时任务")
|
||||
}
|
||||
|
||||
// IsSitemapSchedulerRunning 检查Sitemap定时任务是否在运行
|
||||
func (gs *GlobalScheduler) IsSitemapSchedulerRunning() bool {
|
||||
gs.mutex.RLock()
|
||||
defer gs.mutex.RUnlock()
|
||||
return gs.manager.IsSitemapRunning()
|
||||
}
|
||||
|
||||
// UpdateSitemapConfig 更新Sitemap配置
|
||||
func (gs *GlobalScheduler) UpdateSitemapConfig(enabled bool) error {
|
||||
return gs.manager.UpdateSitemapConfig(enabled)
|
||||
}
|
||||
|
||||
// GetSitemapConfig 获取Sitemap配置
|
||||
func (gs *GlobalScheduler) GetSitemapConfig() (bool, error) {
|
||||
return gs.manager.GetSitemapConfig()
|
||||
}
|
||||
|
||||
// TriggerSitemapGeneration 手动触发sitemap生成
|
||||
func (gs *GlobalScheduler) TriggerSitemapGeneration() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
gs.manager.TriggerSitemapGeneration()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type Manager struct {
|
||||
baseScheduler *BaseScheduler
|
||||
hotDramaScheduler *HotDramaScheduler
|
||||
readyResourceScheduler *ReadyResourceScheduler
|
||||
sitemapScheduler *SitemapScheduler
|
||||
}
|
||||
|
||||
// NewManager 创建调度器管理器
|
||||
@@ -38,11 +39,13 @@ func NewManager(
|
||||
// 创建各个具体的调度器
|
||||
hotDramaScheduler := NewHotDramaScheduler(baseScheduler)
|
||||
readyResourceScheduler := NewReadyResourceScheduler(baseScheduler)
|
||||
sitemapScheduler := NewSitemapScheduler(baseScheduler)
|
||||
|
||||
return &Manager{
|
||||
baseScheduler: baseScheduler,
|
||||
hotDramaScheduler: hotDramaScheduler,
|
||||
readyResourceScheduler: readyResourceScheduler,
|
||||
sitemapScheduler: sitemapScheduler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +110,41 @@ func (m *Manager) GetHotDramaNames() ([]string, error) {
|
||||
return m.hotDramaScheduler.GetHotDramaNames()
|
||||
}
|
||||
|
||||
// StartSitemapScheduler 启动Sitemap调度任务
|
||||
func (m *Manager) StartSitemapScheduler() {
|
||||
m.sitemapScheduler.Start()
|
||||
}
|
||||
|
||||
// StopSitemapScheduler 停止Sitemap调度任务
|
||||
func (m *Manager) StopSitemapScheduler() {
|
||||
m.sitemapScheduler.Stop()
|
||||
}
|
||||
|
||||
// IsSitemapRunning 检查Sitemap调度任务是否在运行
|
||||
func (m *Manager) IsSitemapRunning() bool {
|
||||
return m.sitemapScheduler.IsRunning()
|
||||
}
|
||||
|
||||
// GetSitemapConfig 获取Sitemap配置
|
||||
func (m *Manager) GetSitemapConfig() (bool, error) {
|
||||
return m.sitemapScheduler.GetSitemapConfig()
|
||||
}
|
||||
|
||||
// UpdateSitemapConfig 更新Sitemap配置
|
||||
func (m *Manager) UpdateSitemapConfig(enabled bool) error {
|
||||
return m.sitemapScheduler.UpdateSitemapConfig(enabled)
|
||||
}
|
||||
|
||||
// TriggerSitemapGeneration 手动触发sitemap生成
|
||||
func (m *Manager) TriggerSitemapGeneration() {
|
||||
go m.sitemapScheduler.generateSitemap()
|
||||
}
|
||||
|
||||
// GetStatus 获取所有调度任务的状态
|
||||
func (m *Manager) GetStatus() map[string]bool {
|
||||
return map[string]bool{
|
||||
"hot_drama": m.IsHotDramaRunning(),
|
||||
"ready_resource": m.IsReadyResourceRunning(),
|
||||
"sitemap": m.IsSitemapRunning(),
|
||||
}
|
||||
}
|
||||
|
||||
308
scheduler/sitemap.go
Normal file
308
scheduler/sitemap.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
SITEMAP_MAX_URLS = 50000 // 每个sitemap最多5万个URL
|
||||
SITEMAP_DIR = "./data/sitemap" // sitemap文件目录
|
||||
)
|
||||
|
||||
// SitemapScheduler Sitemap调度器
|
||||
type SitemapScheduler struct {
|
||||
*BaseScheduler
|
||||
sitemapConfig entity.SystemConfig
|
||||
stopChan chan bool
|
||||
isRunning bool
|
||||
}
|
||||
|
||||
// NewSitemapScheduler 创建Sitemap调度器
|
||||
func NewSitemapScheduler(baseScheduler *BaseScheduler) *SitemapScheduler {
|
||||
return &SitemapScheduler{
|
||||
BaseScheduler: baseScheduler,
|
||||
stopChan: make(chan bool),
|
||||
isRunning: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动Sitemap调度任务
|
||||
func (s *SitemapScheduler) Start() {
|
||||
if s.IsRunning() {
|
||||
utils.Debug("Sitemap定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
s.SetRunning(true)
|
||||
utils.Info("开始启动Sitemap定时任务")
|
||||
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop 停止Sitemap调度任务
|
||||
func (s *SitemapScheduler) Stop() {
|
||||
if !s.IsRunning() {
|
||||
utils.Debug("Sitemap定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("正在停止Sitemap定时任务")
|
||||
s.stopChan <- true
|
||||
s.SetRunning(false)
|
||||
}
|
||||
|
||||
// IsRunning 检查Sitemap调度任务是否在运行
|
||||
func (s *SitemapScheduler) IsRunning() bool {
|
||||
return s.isRunning
|
||||
}
|
||||
|
||||
// SetRunning 设置运行状态
|
||||
func (s *SitemapScheduler) SetRunning(running bool) {
|
||||
s.isRunning = running
|
||||
}
|
||||
|
||||
// GetStopChan 获取停止通道
|
||||
func (s *SitemapScheduler) GetStopChan() chan bool {
|
||||
return s.stopChan
|
||||
}
|
||||
|
||||
// run 执行调度任务的主循环
|
||||
func (s *SitemapScheduler) run() {
|
||||
utils.Info("Sitemap定时任务开始运行")
|
||||
|
||||
// 立即执行一次
|
||||
s.generateSitemap()
|
||||
|
||||
// 定时执行
|
||||
ticker := time.NewTicker(24 * time.Hour) // 每24小时执行一次
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
utils.Info("定时执行Sitemap生成任务")
|
||||
s.generateSitemap()
|
||||
case <-s.stopChan:
|
||||
utils.Info("收到停止信号,Sitemap调度任务退出")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateSitemap 生成sitemap
|
||||
func (s *SitemapScheduler) generateSitemap() {
|
||||
utils.Info("开始生成Sitemap...")
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// 获取资源总数
|
||||
var total int64
|
||||
if err := s.BaseScheduler.resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
|
||||
utils.Error("获取资源总数失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("需要处理的资源总数: %d", total)
|
||||
|
||||
if total == 0 {
|
||||
utils.Info("没有资源需要生成Sitemap")
|
||||
return
|
||||
}
|
||||
|
||||
// 计算需要多少个sitemap文件
|
||||
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
|
||||
utils.Info("需要生成 %d 个sitemap文件", totalPages)
|
||||
|
||||
// 确保目录存在
|
||||
if err := os.MkdirAll(SITEMAP_DIR, 0755); err != nil {
|
||||
utils.Error("创建sitemap目录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 生成每个sitemap文件
|
||||
for page := 0; page < totalPages; page++ {
|
||||
if s.SleepWithStopCheck(100 * time.Millisecond) { // 避免过于频繁的检查
|
||||
utils.Info("在生成sitemap过程中收到停止信号,退出生成")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("正在生成第 %d 个sitemap文件", page+1)
|
||||
|
||||
if err := s.generateSitemapPage(page); err != nil {
|
||||
utils.Error("生成第 %d 个sitemap文件失败: %v", page, err)
|
||||
} else {
|
||||
utils.Info("成功生成第 %d 个sitemap文件", page+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成sitemap索引文件
|
||||
if err := s.generateSitemapIndex(totalPages); err != nil {
|
||||
utils.Error("生成sitemap索引文件失败: %v", err)
|
||||
} else {
|
||||
utils.Info("成功生成sitemap索引文件")
|
||||
}
|
||||
|
||||
// 尝试获取网站基础URL
|
||||
baseURL, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.ConfigKeyWebsiteURL)
|
||||
if err != nil || baseURL == "" {
|
||||
baseURL = "https://yoursite.com" // 默认值
|
||||
}
|
||||
|
||||
utils.Info("Sitemap生成完成,耗时: %v", time.Since(startTime))
|
||||
utils.Info("Sitemap地址: %s/sitemap.xml", baseURL)
|
||||
}
|
||||
|
||||
// generateSitemapPage 生成单个sitemap页面
|
||||
func (s *SitemapScheduler) generateSitemapPage(page int) error {
|
||||
offset := page * SITEMAP_MAX_URLS
|
||||
limit := SITEMAP_MAX_URLS
|
||||
|
||||
var resources []entity.Resource
|
||||
if err := s.BaseScheduler.resourceRepo.GetDB().Offset(offset).Limit(limit).Find(&resources).Error; err != nil {
|
||||
return fmt.Errorf("获取资源数据失败: %w", err)
|
||||
}
|
||||
|
||||
var urls []Url
|
||||
for _, resource := range resources {
|
||||
lastMod := resource.UpdatedAt
|
||||
if resource.CreatedAt.After(lastMod) {
|
||||
lastMod = resource.CreatedAt
|
||||
}
|
||||
|
||||
urls = append(urls, Url{
|
||||
Loc: fmt.Sprintf("/r/%s", resource.Key),
|
||||
LastMod: lastMod.Format("2006-01-02"), // 只保留日期部分
|
||||
ChangeFreq: "weekly",
|
||||
Priority: 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
urlSet := UrlSet{
|
||||
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
URLs: urls,
|
||||
}
|
||||
|
||||
filename := filepath.Join(SITEMAP_DIR, fmt.Sprintf("sitemap-%d.xml", page))
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
file.WriteString(xml.Header)
|
||||
encoder := xml.NewEncoder(file)
|
||||
encoder.Indent("", " ")
|
||||
if err := encoder.Encode(urlSet); err != nil {
|
||||
return fmt.Errorf("写入XML失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSitemapIndex 生成sitemap索引文件
|
||||
func (s *SitemapScheduler) generateSitemapIndex(totalPages int) error {
|
||||
// 构建主机URL - 这里使用默认URL,实际应用中应从配置获取
|
||||
baseURL, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.ConfigKeyWebsiteURL)
|
||||
if err != nil || baseURL == "" {
|
||||
baseURL = "https://yoursite.com" // 默认值
|
||||
}
|
||||
|
||||
// 移除URL末尾的斜杠
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
var sitemaps []Sitemap
|
||||
for i := 0; i < totalPages; i++ {
|
||||
sitemapURL := fmt.Sprintf("%s/sitemap-%d.xml", baseURL, i)
|
||||
sitemaps = append(sitemaps, Sitemap{
|
||||
Loc: sitemapURL,
|
||||
LastMod: time.Now().Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
|
||||
sitemapIndex := SitemapIndex{
|
||||
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
Sitemaps: sitemaps,
|
||||
}
|
||||
|
||||
filename := filepath.Join(SITEMAP_DIR, "sitemap.xml")
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建索引文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
file.WriteString(xml.Header)
|
||||
encoder := xml.NewEncoder(file)
|
||||
encoder.Indent("", " ")
|
||||
if err := encoder.Encode(sitemapIndex); err != nil {
|
||||
return fmt.Errorf("写入索引XML失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSitemapConfig 获取Sitemap配置
|
||||
func (s *SitemapScheduler) GetSitemapConfig() (bool, error) {
|
||||
configStr, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapConfig)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 解析配置字符串,这里简化处理
|
||||
return configStr == "1" || configStr == "true", nil
|
||||
}
|
||||
|
||||
// UpdateSitemapConfig 更新Sitemap配置
|
||||
func (s *SitemapScheduler) UpdateSitemapConfig(enabled bool) error {
|
||||
configStr := "0"
|
||||
if enabled {
|
||||
configStr = "1"
|
||||
}
|
||||
|
||||
config := entity.SystemConfig{
|
||||
Key: entity.ConfigKeySitemapConfig,
|
||||
Value: configStr,
|
||||
Type: "bool",
|
||||
}
|
||||
|
||||
// 由于repository没有直接的SetConfig方法,我们使用UpsertConfigs
|
||||
configs := []entity.SystemConfig{config}
|
||||
return s.BaseScheduler.systemConfigRepo.UpsertConfigs(configs)
|
||||
}
|
||||
|
||||
// UrlSet sitemap内容
|
||||
type UrlSet struct {
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
URLs []Url `xml:"url"`
|
||||
}
|
||||
|
||||
// Url 单个URL信息
|
||||
type Url struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod"`
|
||||
ChangeFreq string `xml:"changefreq"`
|
||||
Priority float64 `xml:"priority"`
|
||||
}
|
||||
|
||||
// SitemapIndex sitemap索引结构
|
||||
type SitemapIndex struct {
|
||||
XMLName xml.Name `xml:"sitemapindex"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
Sitemaps []Sitemap `xml:"sitemap"`
|
||||
}
|
||||
|
||||
// Sitemap 单个sitemap信息
|
||||
type Sitemap struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod"`
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type TelegramBotService interface {
|
||||
IsChannelRegistered(chatID int64) bool
|
||||
HandleWebhookUpdate(c interface{})
|
||||
CleanupDuplicateChannels() error
|
||||
ManualPushToChannel(channelID uint) error
|
||||
}
|
||||
|
||||
type TelegramBotServiceImpl struct {
|
||||
@@ -1072,6 +1073,10 @@ func (s *TelegramBotServiceImpl) findResourcesForChannel(channel entity.Telegram
|
||||
func (s *TelegramBotServiceImpl) findLatestResources(channel entity.TelegramChannel, excludeResourceIDs []uint) []interface{} {
|
||||
params := s.buildFilterParams(channel)
|
||||
|
||||
// 添加按创建时间倒序的排序参数,确保获取最新资源
|
||||
params["order_by"] = "created_at"
|
||||
params["order_dir"] = "DESC"
|
||||
|
||||
// 在数据库查询中排除已推送的资源
|
||||
if len(excludeResourceIDs) > 0 {
|
||||
params["exclude_ids"] = excludeResourceIDs
|
||||
@@ -1330,15 +1335,23 @@ func (s *TelegramBotServiceImpl) SendMessage(chatID int64, text string, img stri
|
||||
} else {
|
||||
// 如果 img 以 http 开头,则为图片URL,否则为文件remote_id
|
||||
if strings.HasPrefix(img, "http") {
|
||||
// 发送图片URL
|
||||
photoMsg := tgbotapi.NewPhoto(chatID, tgbotapi.FileURL(img))
|
||||
photoMsg.Caption = text
|
||||
photoMsg.ParseMode = "HTML"
|
||||
_, err := s.bot.Send(photoMsg)
|
||||
if err != nil {
|
||||
utils.Error("[TELEGRAM:MESSAGE:ERROR] 发送图片消息失败: %v", err)
|
||||
// 发送图片URL前先验证URL是否可访问并返回有效的图片格式
|
||||
if s.isValidImageURL(img) {
|
||||
photoMsg := tgbotapi.NewPhoto(chatID, tgbotapi.FileURL(img))
|
||||
photoMsg.Caption = text
|
||||
photoMsg.ParseMode = "HTML"
|
||||
_, err := s.bot.Send(photoMsg)
|
||||
if err != nil {
|
||||
utils.Error("[TELEGRAM:MESSAGE:ERROR] 发送图片消息失败: %v", err)
|
||||
// 如果URL方式失败,尝试将URL作为普通文本发送
|
||||
return s.sendTextMessage(chatID, text)
|
||||
}
|
||||
return err
|
||||
} else {
|
||||
utils.Warn("[TELEGRAM:MESSAGE:WARNING] 图片URL无效,仅发送文本消息: %s", img)
|
||||
// URL无效时只发送文本消息
|
||||
return s.sendTextMessage(chatID, text)
|
||||
}
|
||||
return err
|
||||
} else {
|
||||
// imgUrl := s.GetImgUrl(img)
|
||||
//todo 判断 imgUrl 是否可用
|
||||
@@ -1349,12 +1362,85 @@ func (s *TelegramBotServiceImpl) SendMessage(chatID int64, text string, img stri
|
||||
_, err := s.bot.Send(photoMsg)
|
||||
if err != nil {
|
||||
utils.Error("[TELEGRAM:MESSAGE:ERROR] 发送图片消息失败: %v", err)
|
||||
// 如果文件ID方式失败,尝试将URL作为普通文本发送
|
||||
return s.sendTextMessage(chatID, text)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isValidImageURL 验证图片URL是否有效
|
||||
func (s *TelegramBotServiceImpl) isValidImageURL(imageURL string) bool {
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// 如果配置了代理,设置代理
|
||||
if s.config.ProxyEnabled && s.config.ProxyHost != "" {
|
||||
var proxyClient *http.Client
|
||||
if s.config.ProxyType == "socks5" {
|
||||
auth := &proxy.Auth{}
|
||||
if s.config.ProxyUsername != "" {
|
||||
auth.User = s.config.ProxyUsername
|
||||
auth.Password = s.config.ProxyPassword
|
||||
}
|
||||
dialer, proxyErr := proxy.SOCKS5("tcp", fmt.Sprintf("%s:%d", s.config.ProxyHost, s.config.ProxyPort), auth, proxy.Direct)
|
||||
if proxyErr != nil {
|
||||
utils.Warn("[TELEGRAM:IMAGE] 代理配置错误: %v", proxyErr)
|
||||
return false
|
||||
}
|
||||
proxyClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Dial: dialer.Dial,
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
} else {
|
||||
proxyURL := &url.URL{
|
||||
Scheme: s.config.ProxyType,
|
||||
Host: fmt.Sprintf("%s:%d", s.config.ProxyHost, s.config.ProxyPort),
|
||||
}
|
||||
if s.config.ProxyUsername != "" {
|
||||
proxyURL.User = url.UserPassword(s.config.ProxyUsername, s.config.ProxyPassword)
|
||||
}
|
||||
proxyClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
client = proxyClient
|
||||
}
|
||||
|
||||
resp, err := client.Head(imageURL)
|
||||
if err != nil {
|
||||
utils.Warn("[TELEGRAM:IMAGE] 检查图片URL失败: %v, URL: %s", err, imageURL)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查Content-Type是否为图片格式
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
isImage := strings.HasPrefix(contentType, "image/")
|
||||
if !isImage {
|
||||
utils.Warn("[TELEGRAM:IMAGE] URL不是图片格式: %s, Content-Type: %s", imageURL, contentType)
|
||||
}
|
||||
return isImage
|
||||
}
|
||||
|
||||
// sendTextMessage 仅发送文本消息的辅助方法
|
||||
func (s *TelegramBotServiceImpl) sendTextMessage(chatID int64, text string) error {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = "HTML"
|
||||
_, err := s.bot.Send(msg)
|
||||
if err != nil {
|
||||
utils.Error("[TELEGRAM:MESSAGE:ERROR] 发送文本消息失败: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteMessage 删除消息
|
||||
func (s *TelegramBotServiceImpl) DeleteMessage(chatID int64, messageID int) error {
|
||||
if s.bot == nil {
|
||||
@@ -2017,3 +2103,25 @@ func (s *TelegramBotServiceImpl) isChannelInPushTimeRange(channel entity.Telegra
|
||||
return currentTime >= startTime || currentTime <= endTime
|
||||
}
|
||||
}
|
||||
|
||||
// ManualPushToChannel 手动推送内容到指定频道
|
||||
func (s *TelegramBotServiceImpl) ManualPushToChannel(channelID uint) error {
|
||||
// 获取指定频道信息
|
||||
channel, err := s.channelRepo.FindByID(channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("找不到指定的频道: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("[TELEGRAM:MANUAL_PUSH] 开始手动推送到频道: %s (ID: %d)", channel.ChatName, channel.ChatID)
|
||||
|
||||
// 检查频道是否启用推送
|
||||
if !channel.PushEnabled {
|
||||
return fmt.Errorf("频道 %s 未启用推送功能", channel.ChatName)
|
||||
}
|
||||
|
||||
// 推送内容到频道,使用频道配置的策略
|
||||
s.pushToChannel(*channel)
|
||||
|
||||
utils.Info("[TELEGRAM:MANUAL_PUSH] 手动推送请求已提交: %s (ID: %d)", channel.ChatName, channel.ChatID)
|
||||
return nil
|
||||
}
|
||||
|
||||
204
utils/cache.go
Normal file
204
utils/cache.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheData 缓存数据结构
|
||||
type CacheData struct {
|
||||
Data interface{}
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CacheManager 通用缓存管理器
|
||||
type CacheManager struct {
|
||||
cache map[string]*CacheData
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewCacheManager 创建缓存管理器
|
||||
func NewCacheManager() *CacheManager {
|
||||
return &CacheManager{
|
||||
cache: make(map[string]*CacheData),
|
||||
}
|
||||
}
|
||||
|
||||
// Set 设置缓存
|
||||
func (cm *CacheManager) Set(key string, data interface{}) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
cm.cache[key] = &CacheData{
|
||||
Data: data,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Get 获取缓存
|
||||
func (cm *CacheManager) Get(key string, ttl time.Duration) (interface{}, bool) {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
|
||||
if cachedData, exists := cm.cache[key]; exists {
|
||||
if time.Since(cachedData.UpdatedAt) < ttl {
|
||||
return cachedData.Data, true
|
||||
}
|
||||
// 缓存过期,删除
|
||||
delete(cm.cache, key)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetWithTTL 获取缓存并返回剩余TTL
|
||||
func (cm *CacheManager) GetWithTTL(key string, ttl time.Duration) (interface{}, bool, time.Duration) {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
|
||||
if cachedData, exists := cm.cache[key]; exists {
|
||||
elapsed := time.Since(cachedData.UpdatedAt)
|
||||
if elapsed < ttl {
|
||||
return cachedData.Data, true, ttl - elapsed
|
||||
}
|
||||
// 缓存过期,删除
|
||||
delete(cm.cache, key)
|
||||
}
|
||||
return nil, false, 0
|
||||
}
|
||||
|
||||
// Delete 删除缓存
|
||||
func (cm *CacheManager) Delete(key string) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
delete(cm.cache, key)
|
||||
}
|
||||
|
||||
// DeletePattern 删除匹配模式的缓存
|
||||
func (cm *CacheManager) DeletePattern(pattern string) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
for key := range cm.cache {
|
||||
// 简单的字符串匹配,可以根据需要扩展为正则表达式
|
||||
if len(pattern) > 0 && (key == pattern || (len(key) >= len(pattern) && key[:len(pattern)] == pattern)) {
|
||||
delete(cm.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear 清空所有缓存
|
||||
func (cm *CacheManager) Clear() {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
cm.cache = make(map[string]*CacheData)
|
||||
}
|
||||
|
||||
// Size 获取缓存项数量
|
||||
func (cm *CacheManager) Size() int {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
return len(cm.cache)
|
||||
}
|
||||
|
||||
// CleanExpired 清理过期缓存
|
||||
func (cm *CacheManager) CleanExpired(ttl time.Duration) int {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
cleaned := 0
|
||||
now := time.Now()
|
||||
for key, cachedData := range cm.cache {
|
||||
if now.Sub(cachedData.UpdatedAt) >= ttl {
|
||||
delete(cm.cache, key)
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// GetKeys 获取所有缓存键
|
||||
func (cm *CacheManager) GetKeys() []string {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
|
||||
keys := make([]string, 0, len(cm.cache))
|
||||
for key := range cm.cache {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// 全局缓存管理器实例
|
||||
var (
|
||||
// 热门资源缓存
|
||||
HotResourcesCache = NewCacheManager()
|
||||
|
||||
// 相关资源缓存
|
||||
RelatedResourcesCache = NewCacheManager()
|
||||
|
||||
// 系统配置缓存
|
||||
SystemConfigCache = NewCacheManager()
|
||||
|
||||
// 分类缓存
|
||||
CategoriesCache = NewCacheManager()
|
||||
|
||||
// 标签缓存
|
||||
TagsCache = NewCacheManager()
|
||||
|
||||
// 资源有效性检测缓存
|
||||
ResourceValidityCache = NewCacheManager()
|
||||
)
|
||||
|
||||
// GetHotResourcesCache 获取热门资源缓存管理器
|
||||
func GetHotResourcesCache() *CacheManager {
|
||||
return HotResourcesCache
|
||||
}
|
||||
|
||||
// GetRelatedResourcesCache 获取相关资源缓存管理器
|
||||
func GetRelatedResourcesCache() *CacheManager {
|
||||
return RelatedResourcesCache
|
||||
}
|
||||
|
||||
// GetSystemConfigCache 获取系统配置缓存管理器
|
||||
func GetSystemConfigCache() *CacheManager {
|
||||
return SystemConfigCache
|
||||
}
|
||||
|
||||
// GetCategoriesCache 获取分类缓存管理器
|
||||
func GetCategoriesCache() *CacheManager {
|
||||
return CategoriesCache
|
||||
}
|
||||
|
||||
// GetTagsCache 获取标签缓存管理器
|
||||
func GetTagsCache() *CacheManager {
|
||||
return TagsCache
|
||||
}
|
||||
|
||||
// GetResourceValidityCache 获取资源有效性检测缓存管理器
|
||||
func GetResourceValidityCache() *CacheManager {
|
||||
return ResourceValidityCache
|
||||
}
|
||||
|
||||
// ClearAllCaches 清空所有全局缓存
|
||||
func ClearAllCaches() {
|
||||
HotResourcesCache.Clear()
|
||||
RelatedResourcesCache.Clear()
|
||||
SystemConfigCache.Clear()
|
||||
CategoriesCache.Clear()
|
||||
TagsCache.Clear()
|
||||
ResourceValidityCache.Clear()
|
||||
}
|
||||
|
||||
// CleanAllExpiredCaches 清理所有过期缓存
|
||||
func CleanAllExpiredCaches(ttl time.Duration) {
|
||||
totalCleaned := 0
|
||||
totalCleaned += HotResourcesCache.CleanExpired(ttl)
|
||||
totalCleaned += RelatedResourcesCache.CleanExpired(ttl)
|
||||
totalCleaned += SystemConfigCache.CleanExpired(ttl)
|
||||
totalCleaned += CategoriesCache.CleanExpired(ttl)
|
||||
totalCleaned += TagsCache.CleanExpired(ttl)
|
||||
totalCleaned += ResourceValidityCache.CleanExpired(ttl)
|
||||
|
||||
if totalCleaned > 0 {
|
||||
Info("清理过期缓存完成,共清理 %d 个缓存项", totalCleaned)
|
||||
}
|
||||
}
|
||||
@@ -28,23 +28,40 @@ func GetTelegramLogs(startTime *time.Time, endTime *time.Time, limit int) ([]Tel
|
||||
return []TelegramLogEntry{}, nil
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(logDir, "app_*.log"))
|
||||
// 查找所有日志文件,包括当前的app.log和历史日志文件
|
||||
allFiles, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查找日志文件失败: %v", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
if len(allFiles) == 0 {
|
||||
return []TelegramLogEntry{}, nil
|
||||
}
|
||||
|
||||
// 按时间排序,最近的在前面
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(files)))
|
||||
// 将app.log放在最前面,其他文件按时间排序
|
||||
var files []string
|
||||
var otherFiles []string
|
||||
|
||||
for _, file := range allFiles {
|
||||
if filepath.Base(file) == "app.log" {
|
||||
files = append(files, file) // 当前日志文件优先
|
||||
} else {
|
||||
otherFiles = append(otherFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他文件按时间排序,最近的在前面
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(otherFiles)))
|
||||
files = append(files, otherFiles...)
|
||||
|
||||
// files现在已经是app.log优先,然后是其他文件按时间倒序排列
|
||||
|
||||
var allEntries []TelegramLogEntry
|
||||
|
||||
// 编译Telegram相关的正则表达式
|
||||
telegramRegex := regexp.MustCompile(`(?i)(\[TELEGRAM.*?\])`)
|
||||
messageRegex := regexp.MustCompile(`\[(\w+)\]\s+(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[.*?\]\s+(.*)`)
|
||||
// 修正正则表达式以匹配实际的日志格式: 2025/01/20 14:30:15 [INFO] [file:line] [TELEGRAM] message
|
||||
messageRegex := regexp.MustCompile(`(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[(\w+)\]\s+\[.*?:\d+\]\s+\[TELEGRAM.*?\]\s+(.*)`)
|
||||
|
||||
for _, file := range files {
|
||||
entries, err := parseTelegramLogsFromFile(file, telegramRegex, messageRegex, startTime, endTime)
|
||||
@@ -119,18 +136,23 @@ func parseTelegramLogsFromFile(filePath string, telegramRegex, messageRegex *reg
|
||||
|
||||
// parseLogLine 解析单行日志
|
||||
func parseLogLine(line string, messageRegex *regexp.Regexp) (TelegramLogEntry, error) {
|
||||
// 匹配日志格式: [LEVEL] 2006/01/02 15:04:05 [file:line] message
|
||||
// 匹配日志格式: 2006/01/02 15:04:05 [LEVEL] [file:line] [TELEGRAM] message
|
||||
matches := messageRegex.FindStringSubmatch(line)
|
||||
if len(matches) < 4 {
|
||||
return TelegramLogEntry{}, fmt.Errorf("无法解析日志行: %s", line)
|
||||
}
|
||||
|
||||
level := matches[1]
|
||||
timeStr := matches[2]
|
||||
timeStr := matches[1]
|
||||
level := matches[2]
|
||||
message := matches[3]
|
||||
|
||||
// 解析时间
|
||||
timestamp, err := time.Parse("2006/01/02 15:04:05", timeStr)
|
||||
// 解析时间(使用本地时区)
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return TelegramLogEntry{}, fmt.Errorf("加载时区失败: %v", err)
|
||||
}
|
||||
|
||||
timestamp, err := time.ParseInLocation("2006/01/02 15:04:05", timeStr, location)
|
||||
if err != nil {
|
||||
return TelegramLogEntry{}, fmt.Errorf("时间解析失败: %v", err)
|
||||
}
|
||||
@@ -203,7 +225,7 @@ func ClearOldTelegramLogs(daysToKeep int) error {
|
||||
return nil // 日志目录不存在,无需清理
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(logDir, "app_*.log"))
|
||||
files, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找日志文件失败: %v", err)
|
||||
}
|
||||
|
||||
293
web/components/CopyrightModal.vue
Normal file
293
web/components/CopyrightModal.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="visible"
|
||||
@update:show="handleClose"
|
||||
:mask-closable="true"
|
||||
preset="card"
|
||||
title="版权申述"
|
||||
class="max-w-lg w-full"
|
||||
:style="{ maxWidth: '95vw' }"
|
||||
>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-400/30 rounded-lg p-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mt-0.5"></i>
|
||||
<div class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p class="font-medium mb-1">版权申述说明:</p>
|
||||
<ul class="space-y-1 text-xs">
|
||||
<li>• 请确保您是版权所有者或授权代表</li>
|
||||
<li>• 提供真实准确的版权证明材料</li>
|
||||
<li>• 虚假申述可能承担法律责任</li>
|
||||
<li>• 我们会在收到申述后及时处理</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-placement="top"
|
||||
require-mark-placement="right-hanging"
|
||||
>
|
||||
<n-form-item label="申述人身份" path="identity">
|
||||
<n-select
|
||||
v-model:value="formData.identity"
|
||||
:options="identityOptions"
|
||||
placeholder="请选择您的身份"
|
||||
:loading="loading"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="权利证明" path="proof_type">
|
||||
<n-select
|
||||
v-model:value="formData.proof_type"
|
||||
:options="proofOptions"
|
||||
placeholder="请选择权利证明类型"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="版权证明文件" path="proof_files">
|
||||
<n-upload
|
||||
v-model:file-list="formData.proof_files"
|
||||
:max="5"
|
||||
:default-upload="false"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<n-upload-dragger>
|
||||
<div class="text-center">
|
||||
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
点击或拖拽上传版权证明文件
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
支持 PDF、JPG、PNG 格式,最多5个文件
|
||||
</p>
|
||||
</div>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="申述理由" path="reason">
|
||||
<n-input
|
||||
v-model:value="formData.reason"
|
||||
type="textarea"
|
||||
placeholder="请详细说明版权申述理由,包括具体的侵权情况..."
|
||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||
maxlength="1000"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="联系信息" path="contact_info">
|
||||
<n-input
|
||||
v-model:value="formData.contact_info"
|
||||
placeholder="请提供有效的联系方式(邮箱/电话),以便我们与您联系"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="申述人姓名" path="claimant_name">
|
||||
<n-input
|
||||
v-model:value="formData.claimant_name"
|
||||
placeholder="请填写申述人真实姓名或公司名称"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item>
|
||||
<n-checkbox v-model:checked="formData.agreement">
|
||||
我确认以上信息真实有效,并承担相应的法律责任
|
||||
</n-checkbox>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div class="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 rounded p-3">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
请谨慎提交版权申述,虚假申述可能承担法律责任
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<n-button @click="handleClose" :disabled="submitting">
|
||||
取消
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!formData.agreement"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交申述
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useResourceApi } from '~/composables/useApi'
|
||||
|
||||
const resourceApi = useResourceApi()
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
resourceKey: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'submitted'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
identity: '',
|
||||
proof_type: '',
|
||||
proof_files: [],
|
||||
reason: '',
|
||||
contact_info: '',
|
||||
claimant_name: '',
|
||||
agreement: false
|
||||
})
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 身份选项
|
||||
const identityOptions = [
|
||||
{ label: '版权所有者', value: 'copyright_owner' },
|
||||
{ label: '授权代表', value: 'authorized_agent' },
|
||||
{ label: '律师事务所', value: 'law_firm' },
|
||||
{ label: '其他', value: 'other' }
|
||||
]
|
||||
|
||||
// 证明类型选项
|
||||
const proofOptions = [
|
||||
{ label: '版权登记证书', value: 'copyright_certificate' },
|
||||
{ label: '作品首发证明', value: 'first_publish_proof' },
|
||||
{ label: '授权委托书', value: 'authorization_letter' },
|
||||
{ label: '身份证明文件', value: 'identity_document' },
|
||||
{ label: '其他证明材料', value: 'other_proof' }
|
||||
]
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
identity: {
|
||||
required: true,
|
||||
message: '请选择申述人身份',
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
proof_type: {
|
||||
required: true,
|
||||
message: '请选择权利证明类型',
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
reason: {
|
||||
required: true,
|
||||
message: '请详细说明申述理由',
|
||||
trigger: 'blur'
|
||||
},
|
||||
contact_info: {
|
||||
required: true,
|
||||
message: '请提供联系信息',
|
||||
trigger: 'blur'
|
||||
},
|
||||
claimant_name: {
|
||||
required: true,
|
||||
message: '请填写申述人姓名',
|
||||
trigger: 'blur'
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件变化
|
||||
const handleFileChange = (options: any) => {
|
||||
console.log('文件变化:', options)
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const handleClose = () => {
|
||||
if (!submitting.value) {
|
||||
emit('close')
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
identity: '',
|
||||
proof_type: '',
|
||||
proof_files: [],
|
||||
reason: '',
|
||||
contact_info: '',
|
||||
claimant_name: '',
|
||||
agreement: false
|
||||
}
|
||||
formRef.value?.restoreValidation()
|
||||
}
|
||||
|
||||
// 提交申述
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (!formData.value.agreement) {
|
||||
message.warning('请确认申述信息真实有效并承担相应法律责任')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
|
||||
// 构建证明文件数组(从文件列表转换为字符串)
|
||||
const proofFilesArray = formData.value.proof_files.map((file: any) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
status: file.status,
|
||||
percentage: file.percentage
|
||||
}))
|
||||
|
||||
// 调用实际的版权申述API
|
||||
const copyrightData = {
|
||||
resource_key: props.resourceKey,
|
||||
identity: formData.value.identity,
|
||||
proof_type: formData.value.proof_type,
|
||||
reason: formData.value.reason,
|
||||
contact_info: formData.value.contact_info,
|
||||
claimant_name: formData.value.claimant_name,
|
||||
proof_files: JSON.stringify(proofFilesArray), // 将文件信息转换为JSON字符串
|
||||
user_agent: navigator.userAgent,
|
||||
ip_address: '' // 服务端获取IP
|
||||
}
|
||||
|
||||
const result = await resourceApi.submitCopyrightClaim(copyrightData)
|
||||
console.log('版权申述提交结果:', result)
|
||||
|
||||
message.success('版权申述提交成功,我们会在24小时内处理并回复')
|
||||
emit('submitted') // 发送提交事件
|
||||
} catch (error: any) {
|
||||
console.error('提交版权申述失败:', error)
|
||||
let errorMessage = '提交失败,请重试'
|
||||
if (error && typeof error === 'object' && error.data) {
|
||||
errorMessage = error.data.message || errorMessage
|
||||
} else if (error && typeof error === 'object' && error.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
186
web/components/ReportModal.vue
Normal file
186
web/components/ReportModal.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="visible"
|
||||
@update:show="handleClose"
|
||||
:mask-closable="true"
|
||||
preset="card"
|
||||
title="举报资源失效"
|
||||
class="max-w-md w-full"
|
||||
:style="{ maxWidth: '90vw' }"
|
||||
>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="text-gray-600 dark:text-gray-300 text-sm">
|
||||
请选择举报原因,我们会尽快核实处理:
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-placement="top"
|
||||
require-mark-placement="right-hanging"
|
||||
>
|
||||
<n-form-item label="举报原因" path="reason">
|
||||
<n-select
|
||||
v-model:value="formData.reason"
|
||||
:options="reasonOptions"
|
||||
placeholder="请选择举报原因"
|
||||
:loading="loading"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="详细描述" path="description">
|
||||
<n-input
|
||||
v-model:value="formData.description"
|
||||
type="textarea"
|
||||
placeholder="请详细描述问题,帮助我们更好地处理..."
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="联系方式(选填)" path="contact">
|
||||
<n-input
|
||||
v-model:value="formData.contact"
|
||||
placeholder="邮箱或手机号,便于我们反馈处理结果"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
我们承诺保护您的隐私,举报信息仅用于核实处理
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<n-button @click="handleClose" :disabled="submitting">
|
||||
取消
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交举报
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useResourceApi } from '~/composables/useApi'
|
||||
|
||||
const resourceApi = useResourceApi()
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
resourceKey: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'submitted'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
reason: '',
|
||||
description: '',
|
||||
contact: ''
|
||||
})
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 举报原因选项
|
||||
const reasonOptions = [
|
||||
{ label: '链接已失效', value: 'link_invalid' },
|
||||
{ label: '资源无法下载', value: 'download_failed' },
|
||||
{ label: '资源内容不符', value: 'content_mismatch' },
|
||||
{ label: '包含恶意软件', value: 'malicious' },
|
||||
{ label: '版权问题', value: 'copyright' },
|
||||
{ label: '其他问题', value: 'other' }
|
||||
]
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
reason: {
|
||||
required: true,
|
||||
message: '请选择举报原因',
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
description: {
|
||||
required: true,
|
||||
message: '请详细描述问题',
|
||||
trigger: 'blur'
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const handleClose = () => {
|
||||
if (!submitting.value) {
|
||||
emit('close')
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
reason: '',
|
||||
description: '',
|
||||
contact: ''
|
||||
}
|
||||
formRef.value?.restoreValidation()
|
||||
}
|
||||
|
||||
// 提交举报
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
submitting.value = true
|
||||
|
||||
// 调用实际的举报API
|
||||
const reportData = {
|
||||
resource_key: props.resourceKey,
|
||||
reason: formData.value.reason,
|
||||
description: formData.value.description,
|
||||
contact: formData.value.contact,
|
||||
user_agent: navigator.userAgent,
|
||||
ip_address: '' // 服务端获取IP
|
||||
}
|
||||
|
||||
const result = await resourceApi.submitReport(reportData)
|
||||
console.log('举报提交结果:', result)
|
||||
|
||||
message.success('举报提交成功,我们会尽快核实处理')
|
||||
emit('submitted') // 发送提交事件
|
||||
} catch (error: any) {
|
||||
console.error('提交举报失败:', error)
|
||||
let errorMessage = '提交失败,请重试'
|
||||
if (error && typeof error === 'object' && error.data) {
|
||||
errorMessage = error.data.message || errorMessage
|
||||
} else if (error && typeof error === 'object' && error.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
46
web/components/SearchButton.vue
Normal file
46
web/components/SearchButton.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<!-- 搜索按钮组件 -->
|
||||
<div class="search-button-container">
|
||||
<!-- 搜索按钮 -->
|
||||
<n-button
|
||||
size="tiny"
|
||||
type="tertiary"
|
||||
round
|
||||
ghost
|
||||
class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white"
|
||||
@click="openSearch"
|
||||
>
|
||||
<i class="fas fa-search text-xs"></i>
|
||||
<span class="ml-1 hidden sm:inline">搜索</span>
|
||||
</n-button>
|
||||
|
||||
<!-- 完整的搜索弹窗组件 -->
|
||||
<SearchModal ref="searchModalRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import SearchModal from './SearchModal.vue'
|
||||
|
||||
// 搜索弹窗的引用
|
||||
const searchModalRef = ref()
|
||||
|
||||
// 打开搜索弹窗
|
||||
const openSearch = () => {
|
||||
searchModalRef.value?.show()
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
openSearch,
|
||||
closeSearch: () => searchModalRef.value?.hide(),
|
||||
toggleSearch: () => searchModalRef.value?.toggle()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-button-container {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
423
web/components/SearchModal.vue
Normal file
423
web/components/SearchModal.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<!-- 自定义背景遮罩 -->
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
|
||||
@click="handleBackdropClick"
|
||||
>
|
||||
<!-- 背景模糊遮罩 -->
|
||||
<div class="absolute inset-0 bg-black/20 backdrop-blur-sm"></div>
|
||||
|
||||
<!-- 搜索弹窗 -->
|
||||
<div
|
||||
class="relative w-full max-w-2xl mx-4 transform transition-all duration-200 ease-out"
|
||||
:class="visible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 搜索输入区域 -->
|
||||
<div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<!-- 顶部装饰条 -->
|
||||
<div class="h-1 bg-gradient-to-r from-green-500 via-emerald-500 to-teal-500"></div>
|
||||
|
||||
<!-- 搜索输入框 -->
|
||||
<div class="relative px-6 py-5">
|
||||
<div class="relative flex items-center">
|
||||
<!-- 搜索图标 -->
|
||||
<div class="absolute left-4 flex items-center pointer-events-none">
|
||||
<div class="w-5 h-5 rounded-full bg-gradient-to-r from-green-500 to-emerald-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索资源..."
|
||||
class="w-full pl-12 pr-32 py-4 bg-transparent border-0 text-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-0"
|
||||
@keyup.enter="handleSearch"
|
||||
@input="handleInputChange"
|
||||
@keydown.escape="handleClose"
|
||||
>
|
||||
|
||||
<!-- 搜索按钮 -->
|
||||
<div class="absolute right-2 flex items-center gap-2">
|
||||
<button
|
||||
v-if="searchQuery.trim()"
|
||||
type="button"
|
||||
@click="clearSearch"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleSearch"
|
||||
:disabled="!searchQuery.trim()"
|
||||
:loading="searching"
|
||||
class="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-500 text-white text-sm font-medium rounded-lg hover:from-green-600 hover:to-emerald-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<span v-if="!searching" class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
搜索
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
搜索中
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索建议下拉 -->
|
||||
<div v-if="showSuggestions && suggestions.length > 0" class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
<div class="px-6 py-3">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">搜索建议</div>
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="index"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 text-left rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors group"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<svg class="w-4 h-4 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ suggestion }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">点击搜索 "{{ suggestion }}"</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<div v-if="searchHistory.length > 0" class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">最近搜索</span>
|
||||
</div>
|
||||
<button
|
||||
@click="clearHistory"
|
||||
class="text-xs text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="(item, index) in searchHistory.slice(0, 8)"
|
||||
:key="index"
|
||||
@click="selectHistory(item)"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 group"
|
||||
>
|
||||
<svg class="w-3 h-3 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索提示 -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 bg-gradient-to-r from-green-50 via-emerald-50 to-teal-50 dark:from-green-900/20 dark:via-emerald-900/20 dark:to-teal-900/20">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-8 h-8 rounded-full bg-white dark:bg-gray-800 shadow-sm flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">搜索技巧</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
<span>支持多关键词搜索,用空格分隔</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
|
||||
<span>按 <kbd class="px-1.5 py-0.5 text-xs bg-white dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600 font-mono">Ctrl+K</kbd> 快速打开</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>
|
||||
<span>按 <kbd class="px-1.5 py-0.5 text-xs bg-white dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600 font-mono">Esc</kbd> 关闭弹窗</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
<span>搜索历史自动保存,方便下次使用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
// 组件状态 - 完全内部管理
|
||||
const visible = ref(false)
|
||||
const searchInput = ref<any>(null)
|
||||
const searchQuery = ref('')
|
||||
const searching = ref(false)
|
||||
const showSuggestions = ref(false)
|
||||
const searchHistory = ref<string[]>([])
|
||||
|
||||
// 路由器
|
||||
const router = useRouter()
|
||||
|
||||
// 计算属性
|
||||
const suggestions = computed(() => {
|
||||
if (!searchQuery.value.trim()) return []
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
|
||||
return searchHistory.value
|
||||
.filter(item => item.toLowerCase().includes(query))
|
||||
.filter(item => item.toLowerCase() !== query)
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
// 初始化搜索历史
|
||||
const initSearchHistory = () => {
|
||||
if (process.client && typeof localStorage !== 'undefined') {
|
||||
const history = localStorage.getItem('searchHistory')
|
||||
if (history) {
|
||||
try {
|
||||
searchHistory.value = JSON.parse(history)
|
||||
} catch (e) {
|
||||
searchHistory.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存搜索历史
|
||||
const saveSearchHistory = () => {
|
||||
if (process.client && typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = () => {
|
||||
showSuggestions.value = searchQuery.value.trim().length > 0
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
const query = searchQuery.value.trim()
|
||||
if (!query) return
|
||||
|
||||
searching.value = true
|
||||
|
||||
// 添加到搜索历史
|
||||
if (!searchHistory.value.includes(query)) {
|
||||
searchHistory.value.unshift(query)
|
||||
if (searchHistory.value.length > 10) {
|
||||
searchHistory.value = searchHistory.value.slice(0, 10)
|
||||
}
|
||||
saveSearchHistory()
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
visible.value = false
|
||||
|
||||
// 跳转到搜索页面
|
||||
nextTick(() => {
|
||||
router.push(`/?search=${encodeURIComponent(query)}`)
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
searching.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 清空搜索
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
showSuggestions.value = false
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
// 选择搜索建议
|
||||
const selectSuggestion = (suggestion: string) => {
|
||||
searchQuery.value = suggestion
|
||||
showSuggestions.value = false
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
// 选择历史记录
|
||||
const selectHistory = (item: string) => {
|
||||
searchQuery.value = item
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 清空历史
|
||||
const clearHistory = () => {
|
||||
searchHistory.value = []
|
||||
saveSearchHistory()
|
||||
}
|
||||
|
||||
// 处理背景点击
|
||||
const handleBackdropClick = () => {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
searchQuery.value = ''
|
||||
showSuggestions.value = false
|
||||
}
|
||||
|
||||
// 监听弹窗显示状态
|
||||
watch(visible, (newValue) => {
|
||||
if (newValue && process.client) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
initSearchHistory()
|
||||
})
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
searchQuery.value = ''
|
||||
showSuggestions.value = false
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
// 键盘事件监听
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
if (!visible.value) {
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && visible.value) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时添加键盘事件监听器
|
||||
onMounted(() => {
|
||||
if (process.client && typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理事件监听器
|
||||
onUnmounted(() => {
|
||||
if (process.client && typeof document !== 'undefined') {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
show: () => { visible.value = true },
|
||||
hide: () => { handleClose() },
|
||||
toggle: () => { visible.value = !visible.value }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义动画 */
|
||||
.transform {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
|
||||
/* 深色模式滚动条 */
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: rgba(75, 85, 99, 0.3);
|
||||
}
|
||||
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
/* 键盘快捷键样式 */
|
||||
kbd {
|
||||
box-shadow: 0 1px 0 1px rgba(0, 0, 0, 0.1), 0 1px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 按钮悬停效果 */
|
||||
button {
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* 输入框聚焦效果 */
|
||||
input:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 渐变动画 */
|
||||
@keyframes gradient {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-gradient-to-r {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient 3s ease infinite;
|
||||
}
|
||||
</style>
|
||||
204
web/components/SystemConfigCacheInfo.vue
Normal file
204
web/components/SystemConfigCacheInfo.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div v-if="showCacheInfo && isClient" class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 max-w-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">系统配置缓存状态</h3>
|
||||
<button
|
||||
@click="showCacheInfo = false"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-xs">
|
||||
<!-- 初始化状态 -->
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">初始化状态:</span>
|
||||
<span :class="status.initialized ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
|
||||
{{ status.initialized ? '已初始化' : '未初始化' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">加载状态:</span>
|
||||
<span :class="status.isLoading ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'">
|
||||
{{ status.isLoading ? '加载中...' : '空闲' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 缓存状态 -->
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">缓存状态:</span>
|
||||
<span :class="status.isCacheValid ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'">
|
||||
{{ status.isCacheValid ? '有效' : '无效/过期' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 缓存剩余时间 -->
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">缓存剩余:</span>
|
||||
<span class="text-gray-900 dark:text-gray-100">
|
||||
{{ formatTime(status.cacheTimeRemaining) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 最后获取时间 -->
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">最后更新:</span>
|
||||
<span class="text-gray-900 dark:text-gray-100">
|
||||
{{ formatLastFetch(status.lastFetchTime) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="status.error" class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">错误:</span>
|
||||
<span class="text-red-600 dark:text-red-400">
|
||||
{{ status.error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
@click="refreshCache"
|
||||
:disabled="status.isLoading"
|
||||
class="flex-1 px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1"></i>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
@click="clearCache"
|
||||
class="flex-1 px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 浮动按钮(仅在开发环境和客户端显示) -->
|
||||
<button
|
||||
v-if="isDev && isClient"
|
||||
@click="showCacheInfo = !showCacheInfo"
|
||||
class="fixed bottom-4 right-4 z-40 w-12 h-12 bg-purple-500 text-white rounded-full shadow-lg hover:bg-purple-600 transition-colors flex items-center justify-center"
|
||||
title="系统配置缓存信息"
|
||||
>
|
||||
<i class="fas fa-database"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
||||
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
const showCacheInfo = ref(false)
|
||||
|
||||
// 检查是否为开发环境
|
||||
const isDev = computed(() => {
|
||||
return process.env.NODE_ENV === 'development'
|
||||
})
|
||||
|
||||
// 检查是否为客户端
|
||||
const isClient = computed(() => {
|
||||
return process.client
|
||||
})
|
||||
|
||||
// 获取状态信息 - 直接访问store的响应式状态以确保正确更新
|
||||
const status = computed(() => ({
|
||||
initialized: systemConfigStore.initialized,
|
||||
isLoading: systemConfigStore.isLoading,
|
||||
error: systemConfigStore.error,
|
||||
lastFetchTime: systemConfigStore.lastFetchTime,
|
||||
cacheTimeRemaining: systemConfigStore.cacheTimeRemaining,
|
||||
isCacheValid: systemConfigStore.isCacheValid
|
||||
}))
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (seconds <= 0) return '已过期'
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}分${remainingSeconds}秒`
|
||||
} else {
|
||||
return `${remainingSeconds}秒`
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化最后获取时间
|
||||
const formatLastFetch = (timestamp: number): string => {
|
||||
if (!timestamp) return '从未'
|
||||
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
|
||||
if (diff < 60 * 1000) {
|
||||
return '刚刚'
|
||||
} else if (diff < 60 * 60 * 1000) {
|
||||
const minutes = Math.floor(diff / (60 * 1000))
|
||||
return `${minutes}分钟前`
|
||||
} else if (diff < 24 * 60 * 60 * 1000) {
|
||||
const hours = Math.floor(diff / (60 * 60 * 1000))
|
||||
return `${hours}小时前`
|
||||
} else {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新缓存
|
||||
const refreshCache = async () => {
|
||||
try {
|
||||
await systemConfigStore.refreshConfig()
|
||||
console.log('[CacheInfo] 手动刷新缓存完成')
|
||||
} catch (error) {
|
||||
console.error('[CacheInfo] 刷新缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
const clearCache = () => {
|
||||
systemConfigStore.clearCache()
|
||||
console.log('[CacheInfo] 手动清除缓存完成')
|
||||
}
|
||||
|
||||
// 键盘快捷键支持
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
// Ctrl+Shift+C 显示/隐藏缓存信息(仅在开发环境)
|
||||
if (isDev.value && e.ctrlKey && e.shiftKey && e.key === 'C') {
|
||||
e.preventDefault()
|
||||
showCacheInfo.value = !showCacheInfo.value
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isClient.value) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isClient.value) {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加一些动画效果 */
|
||||
.transition-colors {
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
</style>
|
||||
@@ -279,6 +279,16 @@
|
||||
<n-tag :type="channel.is_active ? 'success' : 'warning'" size="small">
|
||||
{{ channel.is_active ? '活跃' : '非活跃' }}
|
||||
</n-tag>
|
||||
<n-button
|
||||
v-if="channel.push_enabled"
|
||||
size="small"
|
||||
@click="manualPushToChannel(channel)"
|
||||
:loading="manualPushingChannel === channel.id"
|
||||
title="手动推送">
|
||||
<template #icon>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button size="small" @click="editChannel(channel)">
|
||||
<template #icon>
|
||||
<i class="fas fa-edit"></i>
|
||||
@@ -715,6 +725,8 @@ const loadingLogs = ref(false)
|
||||
const logHours = ref(24)
|
||||
const editingChannel = ref<any>(null)
|
||||
const savingChannel = ref(false)
|
||||
const testingPush = ref(false)
|
||||
const manualPushingChannel = ref<number | null>(null)
|
||||
|
||||
// 机器人状态相关变量
|
||||
const botStatus = ref<any>(null)
|
||||
@@ -1469,6 +1481,55 @@ const refreshBotStatus = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 手动推送内容到频道
|
||||
const manualPushToChannel = async (channel: any) => {
|
||||
if (!channel || !channel.id) {
|
||||
notification.warning({
|
||||
content: '频道信息不完整',
|
||||
duration: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!telegramBotConfig.value.bot_enabled) {
|
||||
notification.warning({
|
||||
content: '请先启用机器人并配置API Key',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
manualPushingChannel.value = channel.id
|
||||
try {
|
||||
await telegramApi.manualPushToChannel(channel.id)
|
||||
|
||||
notification.success({
|
||||
content: `手动推送请求已提交至频道 "${channel.chat_name}"`,
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
// 更新频道推送时间
|
||||
const updatedChannels = telegramChannels.value.map(c => {
|
||||
if (c.id === channel.id) {
|
||||
c.last_push_at = new Date().toISOString()
|
||||
}
|
||||
return c
|
||||
})
|
||||
telegramChannels.value = updatedChannels
|
||||
} catch (error: any) {
|
||||
console.error('手动推送失败:', error)
|
||||
notification.error({
|
||||
content: `手动推送失败: ${error?.message || '请稍后重试'}`,
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
// 只有当当前频道ID与推送中的频道ID匹配时才清除状态
|
||||
if (manualPushingChannel.value === channel.id) {
|
||||
manualPushingChannel.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调试机器人连接
|
||||
const debugBotConnection = async () => {
|
||||
try {
|
||||
|
||||
@@ -47,7 +47,9 @@ export const parseApiResponse = <T>(response: any): T => {
|
||||
|
||||
export const useResourceApi = () => {
|
||||
const getResources = (params?: any) => useApiFetch('/resources', { params }).then(parseApiResponse)
|
||||
const getHotResources = (params?: any) => useApiFetch('/resources/hot', { params }).then(parseApiResponse)
|
||||
const getResource = (id: number) => useApiFetch(`/resources/${id}`).then(parseApiResponse)
|
||||
const getResourcesByKey = (key: string) => useApiFetch(`/resources/key/${key}`).then(parseApiResponse)
|
||||
const createResource = (data: any) => useApiFetch('/resources', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const updateResource = (id: number, data: any) => useApiFetch(`/resources/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
|
||||
const deleteResource = (id: number) => useApiFetch(`/resources/${id}`, { method: 'DELETE' }).then(parseApiResponse)
|
||||
@@ -59,7 +61,36 @@ export const useResourceApi = () => {
|
||||
const batchDeleteResources = (ids: number[]) => useApiFetch('/resources/batch', { method: 'DELETE', body: { ids } }).then(parseApiResponse)
|
||||
// 新增:获取资源链接(智能转存)
|
||||
const getResourceLink = (id: number) => useApiFetch(`/resources/${id}/link`).then(parseApiResponse)
|
||||
return { getResources, getResource, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink }
|
||||
// 新增:获取相关资源
|
||||
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)
|
||||
// 新增:提交版权申述
|
||||
const submitCopyrightClaim = (data: any) => useApiFetch('/copyright-claims', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
|
||||
// 新增:管理后台举报相关API
|
||||
const getReportsRaw = (params?: any) => useApiFetch('/reports', { params })
|
||||
const getReports = (params?: any) => getReportsRaw(params).then(parseApiResponse)
|
||||
const getReport = (id: number) => useApiFetch(`/reports/${id}`).then(parseApiResponse)
|
||||
const updateReport = (id: number, data: any) => useApiFetch(`/reports/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
|
||||
const deleteReport = (id: number) => useApiFetch(`/reports/${id}`, { method: 'DELETE' }).then(parseApiResponse)
|
||||
|
||||
// 新增:管理后台版权申述相关API
|
||||
const getCopyrightClaims = (params?: any) => useApiFetch('/copyright-claims', { params }).then(parseApiResponse)
|
||||
const getCopyrightClaim = (id: number) => useApiFetch(`/copyright-claims/${id}`).then(parseApiResponse)
|
||||
const updateCopyrightClaim = (id: number, data: any) => useApiFetch(`/copyright-claims/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
|
||||
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, checkResourceValidity, batchCheckResourceValidity,
|
||||
submitReport, submitCopyrightClaim,
|
||||
getReports, getReport, updateReport, deleteReport, getReportsRaw,
|
||||
getCopyrightClaims, getCopyrightClaim, updateCopyrightClaim, deleteCopyrightClaim
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthApi = () => {
|
||||
@@ -274,6 +305,7 @@ export const useTelegramApi = () => {
|
||||
const debugBotConnection = () => useApiFetch('/telegram/debug-connection').then(parseApiResponse)
|
||||
const reloadBotConfig = () => useApiFetch('/telegram/reload-config', { method: 'POST' }).then(parseApiResponse)
|
||||
const testBotMessage = (data: any) => useApiFetch('/telegram/test-message', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const manualPushToChannel = (channelId: number) => useApiFetch(`/telegram/manual-push/${channelId}`, { method: 'POST' }).then(parseApiResponse)
|
||||
const getChannels = () => useApiFetch('/telegram/channels').then(parseApiResponse)
|
||||
const createChannel = (data: any) => useApiFetch('/telegram/channels', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const updateChannel = (id: number, data: any) => useApiFetch(`/telegram/channels/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
|
||||
@@ -289,6 +321,7 @@ export const useTelegramApi = () => {
|
||||
debugBotConnection,
|
||||
reloadBotConfig,
|
||||
testBotMessage,
|
||||
manualPushToChannel,
|
||||
getChannels,
|
||||
createChannel,
|
||||
updateChannel,
|
||||
@@ -369,4 +402,51 @@ export const useWechatApi = () => {
|
||||
getBotStatus,
|
||||
uploadVerifyFile
|
||||
}
|
||||
}
|
||||
|
||||
// Sitemap管理API
|
||||
export const useSitemapApi = () => {
|
||||
const getSitemapConfig = () => useApiFetch('/sitemap/config').then(parseApiResponse)
|
||||
const updateSitemapConfig = (data: any) => useApiFetch('/sitemap/config', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const generateSitemap = () => useApiFetch('/sitemap/generate', { method: 'POST' }).then(parseApiResponse)
|
||||
const getSitemapStatus = () => useApiFetch('/sitemap/status').then(parseApiResponse)
|
||||
const fullGenerateSitemap = () => useApiFetch('/sitemap/full-generate', { method: 'POST' }).then(parseApiResponse)
|
||||
const getSitemapIndex = () => useApiFetch('/sitemap.xml')
|
||||
const getSitemapPage = (page: number) => useApiFetch(`/sitemap-${page}.xml`)
|
||||
|
||||
return {
|
||||
getSitemapConfig,
|
||||
updateSitemapConfig,
|
||||
generateSitemap,
|
||||
getSitemapStatus,
|
||||
fullGenerateSitemap,
|
||||
getSitemapIndex,
|
||||
getSitemapPage
|
||||
}
|
||||
}
|
||||
|
||||
// 统一API访问函数
|
||||
export const useApi = () => {
|
||||
return {
|
||||
resourceApi: useResourceApi(),
|
||||
authApi: useAuthApi(),
|
||||
categoryApi: useCategoryApi(),
|
||||
panApi: usePanApi(),
|
||||
cksApi: useCksApi(),
|
||||
tagApi: useTagApi(),
|
||||
readyResourceApi: useReadyResourceApi(),
|
||||
statsApi: useStatsApi(),
|
||||
searchStatsApi: useSearchStatsApi(),
|
||||
systemConfigApi: useSystemConfigApi(),
|
||||
hotDramaApi: useHotDramaApi(),
|
||||
monitorApi: useMonitorApi(),
|
||||
userApi: useUserApi(),
|
||||
taskApi: useTaskApi(),
|
||||
telegramApi: useTelegramApi(),
|
||||
meilisearchApi: useMeilisearchApi(),
|
||||
apiAccessLogApi: useApiAccessLogApi(),
|
||||
systemLogApi: useSystemLogApi(),
|
||||
wechatApi: useWechatApi(),
|
||||
sitemapApi: useSitemapApi()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { usePublicSystemConfigApi } from './useApi'
|
||||
|
||||
interface SystemConfig {
|
||||
id: number
|
||||
@@ -60,19 +61,28 @@ export const useSeo = () => {
|
||||
}
|
||||
|
||||
// 生成动态OG图片URL
|
||||
const generateOgImageUrl = (title: string, description?: string, theme: string = 'default') => {
|
||||
const generateOgImageUrl = (keyOrTitle: string, descriptionOrEmpty: string = '', theme: string = 'default') => {
|
||||
// 获取运行时配置
|
||||
const config = useRuntimeConfig()
|
||||
const ogApiUrl = config.public.ogApiUrl || '/api/og-image'
|
||||
|
||||
// 构建URL参数
|
||||
const params = new URLSearchParams()
|
||||
params.set('title', title)
|
||||
|
||||
if (description) {
|
||||
// 限制描述长度
|
||||
const trimmedDesc = description.length > 200 ? description.substring(0, 200) + '...' : description
|
||||
params.set('description', trimmedDesc)
|
||||
// 检测第一个参数是key还是title(通过长度和格式判断)
|
||||
// 如果是较短的字符串且符合key格式(通常是字母数字组合),则当作key处理
|
||||
if (keyOrTitle.length <= 50 && /^[a-zA-Z0-9_-]+$/.test(keyOrTitle)) {
|
||||
// 作为key参数使用
|
||||
params.set('key', keyOrTitle)
|
||||
} else {
|
||||
// 作为title参数使用
|
||||
params.set('title', keyOrTitle)
|
||||
|
||||
if (descriptionOrEmpty) {
|
||||
// 限制描述长度
|
||||
const trimmedDesc = descriptionOrEmpty.length > 200 ? descriptionOrEmpty.substring(0, 200) + '...' : descriptionOrEmpty
|
||||
params.set('description', trimmedDesc)
|
||||
}
|
||||
}
|
||||
|
||||
params.set('site_name', (systemConfig.value && systemConfig.value.site_title) || '老九网盘资源数据库')
|
||||
@@ -117,9 +127,12 @@ export const useSeo = () => {
|
||||
dynamicKeywords = `${searchKeyword},${meta.keywords}`
|
||||
}
|
||||
|
||||
// 生成动态OG图片URL
|
||||
const theme = searchKeyword ? 'blue' : platformId ? 'green' : 'default'
|
||||
const ogImageUrl = generateOgImageUrl(title, dynamicDescription, theme)
|
||||
// 生成动态OG图片URL,支持自定义OG图片
|
||||
let ogImageUrl = customMeta?.ogImage
|
||||
if (!ogImageUrl) {
|
||||
const theme = searchKeyword ? 'blue' : platformId ? 'green' : 'default'
|
||||
ogImageUrl = generateOgImageUrl(title, dynamicDescription, theme)
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
|
||||
21
web/ecosystem.config.cjs
Normal file
21
web/ecosystem.config.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'urldb-nuxt',
|
||||
port: '3030',
|
||||
exec_mode: 'cluster',
|
||||
instances: 'max', // 使用所有可用的CPU核心
|
||||
script: './.output/server/index.mjs',
|
||||
error_file: './logs/err.log',
|
||||
out_file: './logs/out.log',
|
||||
log_file: './logs/combined.log',
|
||||
time: true,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
HOST: '0.0.0.0',
|
||||
PORT: 3030,
|
||||
NUXT_PUBLIC_API_SERVER: 'http://localhost:8080/api'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -479,6 +479,18 @@ const dataManagementItems = ref([
|
||||
label: '文件管理',
|
||||
icon: 'fas fa-file-upload',
|
||||
active: (route: any) => route.path.startsWith('/admin/files')
|
||||
},
|
||||
{
|
||||
to: '/admin/reports',
|
||||
label: '举报管理',
|
||||
icon: 'fas fa-flag',
|
||||
active: (route: any) => route.path.startsWith('/admin/reports')
|
||||
},
|
||||
{
|
||||
to: '/admin/copyright-claims',
|
||||
label: '版权申述',
|
||||
icon: 'fas fa-balance-scale',
|
||||
active: (route: any) => route.path.startsWith('/admin/copyright-claims')
|
||||
}
|
||||
])
|
||||
|
||||
@@ -559,7 +571,7 @@ const autoExpandCurrentGroup = () => {
|
||||
const currentPath = useRoute().path
|
||||
|
||||
// 检查当前页面属于哪个分组并展开
|
||||
if (currentPath.startsWith('/admin/resources') || currentPath.startsWith('/admin/ready-resources') || currentPath.startsWith('/admin/tags') || currentPath.startsWith('/admin/categories') || currentPath.startsWith('/admin/accounts') || currentPath.startsWith('/admin/files')) {
|
||||
if (currentPath.startsWith('/admin/resources') || currentPath.startsWith('/admin/ready-resources') || currentPath.startsWith('/admin/tags') || currentPath.startsWith('/admin/categories') || currentPath.startsWith('/admin/accounts') || currentPath.startsWith('/admin/files') || currentPath.startsWith('/admin/reports') || currentPath.startsWith('/admin/copyright-claims')) {
|
||||
expandedGroups.value.dataManagement = true
|
||||
} else if (currentPath.startsWith('/admin/site-config') || currentPath.startsWith('/admin/feature-config') || currentPath.startsWith('/admin/dev-config') || currentPath.startsWith('/admin/users') || currentPath.startsWith('/admin/version')) {
|
||||
expandedGroups.value.systemConfig = true
|
||||
@@ -581,7 +593,7 @@ watch(() => useRoute().path, (newPath) => {
|
||||
}
|
||||
|
||||
// 根据新路径展开对应分组
|
||||
if (newPath.startsWith('/admin/resources') || newPath.startsWith('/admin/ready-resources') || newPath.startsWith('/admin/tags') || newPath.startsWith('/admin/categories') || newPath.startsWith('/admin/accounts') || newPath.startsWith('/admin/files')) {
|
||||
if (newPath.startsWith('/admin/resources') || newPath.startsWith('/admin/ready-resources') || newPath.startsWith('/admin/tags') || newPath.startsWith('/admin/categories') || newPath.startsWith('/admin/accounts') || newPath.startsWith('/admin/files') || newPath.startsWith('/admin/reports') || newPath.startsWith('/admin/copyright-claims')) {
|
||||
expandedGroups.value.dataManagement = true
|
||||
} else if (newPath.startsWith('/admin/site-config') || newPath.startsWith('/admin/feature-config') || newPath.startsWith('/admin/dev-config') || newPath.startsWith('/admin/users') || newPath.startsWith('/admin/version')) {
|
||||
expandedGroups.value.systemConfig = true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<!-- 暗色模式切换按钮 -->
|
||||
<button
|
||||
class="fixed top-4 right-4 z-50 w-8 h-8 flex items-center justify-center rounded-full shadow-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 transition-all duration-200 hover:bg-blue-100 dark:hover:bg-blue-900 hover:scale-110 focus:outline-none"
|
||||
class="fixed top-4 left-4 z-50 w-8 h-8 flex items-center justify-center rounded-full shadow-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 transition-all duration-200 hover:bg-blue-100 dark:hover:bg-blue-900 hover:scale-110 focus:outline-none"
|
||||
@click="toggleDarkMode"
|
||||
aria-label="切换明暗模式"
|
||||
>
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
<n-notification-provider>
|
||||
<n-dialog-provider>
|
||||
<NuxtPage />
|
||||
<n-message-provider>
|
||||
<NuxtPage />
|
||||
</n-message-provider>
|
||||
</n-dialog-provider>
|
||||
</n-notification-provider>
|
||||
|
||||
|
||||
31
web/middleware/admin.ts
Normal file
31
web/middleware/admin.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
// 只在客户端执行认证检查
|
||||
if (!process.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 初始化用户状态
|
||||
userStore.initAuth()
|
||||
|
||||
// 等待一小段时间确保认证状态初始化完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 检查认证状态
|
||||
if (!userStore.isAuthenticated) {
|
||||
console.log('admin middleware - 用户未认证,重定向到登录页面')
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
// 检查用户是否为管理员(通常通过用户角色或权限判断)
|
||||
// 这里可以根据具体实现来调整,例如检查 userStore.user?.is_admin 字段
|
||||
const isAdmin = userStore.user?.is_admin || userStore.user?.role === 'admin' || userStore.user?.username === 'admin'
|
||||
|
||||
if (!isAdmin) {
|
||||
console.log('admin middleware - 用户不是管理员,重定向到首页')
|
||||
return navigateTo('/')
|
||||
}
|
||||
|
||||
console.log('admin middleware - 用户已认证且为管理员,继续访问')
|
||||
})
|
||||
@@ -66,6 +66,7 @@ export default defineNuxtConfig({
|
||||
{ name: 'theme-color', content: '#3b82f6' },
|
||||
{ property: 'og:site_name', content: '老九网盘资源数据库' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:image', content: '/assets/images/og.webp' },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' }
|
||||
],
|
||||
link: [
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@nuxtjs/tailwindcss": "^6.8.0",
|
||||
"@pinia/nuxt": "^0.5.0",
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"naive-ui": "^2.42.0",
|
||||
"pinia": "^2.1.0",
|
||||
|
||||
825
web/pages/admin/copyright-claims.vue
Normal file
825
web/pages/admin/copyright-claims.vue
Normal file
@@ -0,0 +1,825 @@
|
||||
<template>
|
||||
<AdminPageLayout>
|
||||
<template #page-header>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-balance-scale text-blue-500 mr-2"></i>
|
||||
版权申述管理
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">管理用户提交的版权申述信息</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 过滤栏 - 搜索和操作 -->
|
||||
<template #filter-bar>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<!-- 空白区域用于按钮 -->
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative">
|
||||
<n-input
|
||||
v-model:value="filters.resourceKey"
|
||||
@input="debounceSearch"
|
||||
type="text"
|
||||
placeholder="搜索资源Key..."
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-gray-400 text-sm"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<n-select
|
||||
v-model:value="filters.status"
|
||||
:options="[
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已批准', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' }
|
||||
]"
|
||||
placeholder="状态"
|
||||
clearable
|
||||
@update:value="fetchClaims"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<n-button @click="resetFilters" type="tertiary">
|
||||
<template #icon>
|
||||
<i class="fas fa-redo"></i>
|
||||
</template>
|
||||
重置
|
||||
</n-button>
|
||||
<n-button @click="fetchClaims" type="tertiary">
|
||||
<template #icon>
|
||||
<i class="fas fa-refresh"></i>
|
||||
</template>
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区 - 版权申述数据 -->
|
||||
<template #content>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex h-full items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="claims.length === 0" class="text-center py-8">
|
||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无版权申述记录</div>
|
||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">目前没有用户提交的版权申述信息</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 - 自适应高度 -->
|
||||
<div v-else class="flex flex-col h-full overflow-auto">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="claims"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
:loading="loading"
|
||||
:scroll-x="1020"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区footer - 分页组件 -->
|
||||
<template #content-footer>
|
||||
<div class="p-4">
|
||||
<div class="flex justify-center">
|
||||
<n-pagination
|
||||
v-model:page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:item-count="pagination.total"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
show-size-picker
|
||||
@update:page="fetchClaims"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</AdminPageLayout>
|
||||
|
||||
<!-- 查看申述详情模态框 -->
|
||||
<n-modal v-model:show="showDetailModal" :mask-closable="false" preset="card" :style="{ maxWidth: '600px', width: '90%' }" title="版权申述详情">
|
||||
<div v-if="selectedClaim" class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">申述ID</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">资源Key</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.resource_key }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{{ getIdentityLabel(selectedClaim.identity) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{{ getProofTypeLabel(selectedClaim.proof_type) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{{ selectedClaim.reason }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{{ selectedClaim.contact_info }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{{ selectedClaim.claimant_name }}</p>
|
||||
</div>
|
||||
<div v-if="selectedClaim.proof_files">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">证明文件</h3>
|
||||
<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>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ formatDateTime(selectedClaim.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">IP地址</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.ip_address || '未知' }}</p>
|
||||
</div>
|
||||
<div v-if="selectedClaim.note">
|
||||
<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">{{ selectedClaim.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面标题和元信息
|
||||
useHead({
|
||||
title: '版权申述管理 - 管理后台',
|
||||
meta: [
|
||||
{ name: 'description', content: '管理用户提交的版权申述信息' }
|
||||
]
|
||||
})
|
||||
|
||||
// 设置页面布局和认证保护
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
import { h } from 'vue'
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
const dialog = useDialog()
|
||||
|
||||
const { resourceApi } = useApi()
|
||||
const loading = ref(false)
|
||||
const claims = ref<any[]>([])
|
||||
const showDetailModal = ref(false)
|
||||
const selectedClaim = ref<any>(null)
|
||||
|
||||
// 分页和筛选状态
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const filters = ref({
|
||||
status: '',
|
||||
resourceKey: ''
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
render: (row: any) => {
|
||||
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: '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('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: 'claim_details',
|
||||
width: 280,
|
||||
render: (row: any) => {
|
||||
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: 100,
|
||||
render: (row: any) => {
|
||||
const type = getStatusType(row.status)
|
||||
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: 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 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 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 w-full',
|
||||
onClick: () => updateClaimStatus(row, 'rejected')
|
||||
}, [
|
||||
h('i', { class: 'fas fa-times mr-1 text-xs' }),
|
||||
'拒绝'
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
return h('div', { class: 'flex flex-col gap-1' }, buttons)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 搜索防抖
|
||||
let searchTimeout: NodeJS.Timeout | null = null
|
||||
const debounceSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.value.page = 1
|
||||
fetchClaims()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 获取版权申述列表
|
||||
const fetchClaims = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.pageSize
|
||||
}
|
||||
|
||||
if (filters.value.status) params.status = filters.value.status
|
||||
if (filters.value.resourceKey) params.resource_key = filters.value.resourceKey
|
||||
|
||||
const response = await resourceApi.getCopyrightClaims(params)
|
||||
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)
|
||||
// 显示错误提示
|
||||
if (process.client) {
|
||||
notification.error({
|
||||
content: '获取版权申述列表失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
status: '',
|
||||
resourceKey: ''
|
||||
}
|
||||
pagination.value.page = 1
|
||||
fetchClaims()
|
||||
}
|
||||
|
||||
// 处理页面大小变化
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.page = 1
|
||||
fetchClaims()
|
||||
}
|
||||
|
||||
// 查看申述详情
|
||||
const viewClaim = (claim: any) => {
|
||||
selectedClaim.value = claim
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
// 更新申述状态
|
||||
const updateClaimStatus = async (claim: any, status: string) => {
|
||||
try {
|
||||
// 获取处理备注(如果需要)
|
||||
let note = ''
|
||||
if (status === 'rejected') {
|
||||
note = await getRejectionNote()
|
||||
if (note === null) return // 用户取消操作
|
||||
}
|
||||
|
||||
const response = await resourceApi.updateCopyrightClaim(claim.id, {
|
||||
status,
|
||||
note
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
const index = claims.value.findIndex(c => c.id === claim.id)
|
||||
if (index !== -1) {
|
||||
claims.value[index] = response
|
||||
}
|
||||
|
||||
// 更新详情模态框中的数据
|
||||
if (selectedClaim.value && selectedClaim.value.id === claim.id) {
|
||||
selectedClaim.value = response
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
notification.success({
|
||||
content: '状态更新成功',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新版权申述状态失败:', error)
|
||||
if (process.client) {
|
||||
notification.error({
|
||||
content: '状态更新失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取拒绝原因输入
|
||||
const getRejectionNote = (): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
// 使用naive-ui的dialog API
|
||||
const { dialog } = useDialog()
|
||||
|
||||
let inputValue = ''
|
||||
|
||||
dialog.warning({
|
||||
title: '输入拒绝原因',
|
||||
content: () => h(nInput, {
|
||||
value: inputValue,
|
||||
onUpdateValue: (value) => inputValue = value,
|
||||
placeholder: '请输入拒绝的原因...',
|
||||
type: 'textarea',
|
||||
rows: 4
|
||||
}),
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
if (!inputValue.trim()) {
|
||||
const { message } = useNotification()
|
||||
message.warning('请输入拒绝原因')
|
||||
return false // 不关闭对话框
|
||||
}
|
||||
resolve(inputValue)
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 状态类型和标签
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'warning'
|
||||
case 'approved': return 'success'
|
||||
case 'rejected': return 'error'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return '待处理'
|
||||
case 'approved': return '已批准'
|
||||
case 'rejected': return '已拒绝'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
// 申述人身份标签
|
||||
const getIdentityLabel = (identity: string) => {
|
||||
const identityMap: Record<string, string> = {
|
||||
'copyright_owner': '版权所有者',
|
||||
'authorized_agent': '授权代表',
|
||||
'law_firm': '律师事务所',
|
||||
'other': '其他'
|
||||
}
|
||||
return identityMap[identity] || identity
|
||||
}
|
||||
|
||||
// 证明类型标签
|
||||
const getProofTypeLabel = (proofType: string) => {
|
||||
const proofTypeMap: Record<string, string> = {
|
||||
'copyright_certificate': '版权登记证书',
|
||||
'first_publish_proof': '作品首发证明',
|
||||
'authorization_letter': '授权委托书',
|
||||
'identity_document': '身份证明文件',
|
||||
'other_proof': '其他证明材料'
|
||||
}
|
||||
return proofTypeMap[proofType] || proofType
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateString: string) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
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()
|
||||
})
|
||||
</script>
|
||||
559
web/pages/admin/reports.vue
Normal file
559
web/pages/admin/reports.vue
Normal file
@@ -0,0 +1,559 @@
|
||||
<template>
|
||||
<AdminPageLayout>
|
||||
<template #page-header>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-flag text-red-500 mr-2"></i>
|
||||
举报管理
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">管理用户提交的资源举报信息</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 过滤栏 - 搜索和操作 -->
|
||||
<template #filter-bar>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<!-- 空白区域用于按钮 -->
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative">
|
||||
<n-input
|
||||
v-model:value="filters.resourceKey"
|
||||
@input="debounceSearch"
|
||||
type="text"
|
||||
placeholder="搜索资源Key..."
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-gray-400 text-sm"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<n-select
|
||||
v-model:value="filters.status"
|
||||
:options="[
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已批准', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' }
|
||||
]"
|
||||
placeholder="状态"
|
||||
clearable
|
||||
@update:value="fetchReports"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<n-button @click="resetFilters" type="tertiary">
|
||||
<template #icon>
|
||||
<i class="fas fa-redo"></i>
|
||||
</template>
|
||||
重置
|
||||
</n-button>
|
||||
<n-button @click="fetchReports" type="tertiary">
|
||||
<template #icon>
|
||||
<i class="fas fa-refresh"></i>
|
||||
</template>
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区 - 举报数据 -->
|
||||
<template #content>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex h-full items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="reports.length === 0" class="text-center py-8">
|
||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无举报记录</div>
|
||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">目前没有用户提交的举报信息</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 - 自适应高度 -->
|
||||
<div v-else class="flex flex-col h-full overflow-auto">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="reports"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
:loading="loading"
|
||||
:scroll-x="1200"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区footer - 分页组件 -->
|
||||
<template #content-footer>
|
||||
<div class="p-4">
|
||||
<div class="flex justify-center">
|
||||
<n-pagination
|
||||
v-model:page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:item-count="pagination.total"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
show-size-picker
|
||||
@update:page="fetchReports"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</AdminPageLayout>
|
||||
|
||||
<!-- 查看举报详情模态框 -->
|
||||
<n-modal v-model:show="showDetailModal" :mask-closable="false" preset="card" :style="{ maxWidth: '600px', width: '90%' }" title="举报详情">
|
||||
<div v-if="selectedReport" class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">举报ID</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">资源Key</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.resource_key }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{{ getReasonLabel(selectedReport.reason) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{{ selectedReport.description }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{{ selectedReport.contact || '未提供' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{{ formatDateTime(selectedReport.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">IP地址</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.ip_address || '未知' }}</p>
|
||||
</div>
|
||||
<div v-if="selectedReport.note">
|
||||
<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">{{ selectedReport.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面标题和元信息
|
||||
useHead({
|
||||
title: '举报管理 - 管理后台',
|
||||
meta: [
|
||||
{ name: 'description', content: '管理用户提交的资源举报信息' }
|
||||
]
|
||||
})
|
||||
|
||||
// 设置页面布局和认证保护
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
import { h } from 'vue'
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
const dialog = useDialog()
|
||||
|
||||
const { resourceApi } = useApi()
|
||||
const loading = ref(false)
|
||||
const reports = ref<any[]>([])
|
||||
const showDetailModal = ref(false)
|
||||
const selectedReport = ref<any>(null)
|
||||
|
||||
// 分页和筛选状态
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const filters = ref({
|
||||
status: '',
|
||||
resourceKey: ''
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 30,
|
||||
render: (row: any) => {
|
||||
return h('span', { class: 'font-medium' }, row.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
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: 'reason',
|
||||
width: 100,
|
||||
render: (row: any) => {
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
// 举报原因和描述提示
|
||||
h('div', {
|
||||
class: 'flex items-center gap-1 truncate max-w-[80px]',
|
||||
style: { maxWidth: '80px' }
|
||||
}, [
|
||||
h('span', null, getReasonLabel(row.reason)),
|
||||
// 添加描述提示图片
|
||||
h('i', {
|
||||
class: 'fas fa-info-circle text-blue-400 cursor-pointer text-xs ml-1',
|
||||
title: row.description // 鼠标hover显示描述
|
||||
})
|
||||
]),
|
||||
// 举报时间
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-400 truncate max-w-[80px]',
|
||||
style: { maxWidth: '80px' }
|
||||
}, `举报时间: ${formatDateTime(row.created_at)}`),
|
||||
// 联系方式
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[80px]',
|
||||
style: { maxWidth: '80px' }
|
||||
}, `联系方式: ${row.contact || '未提供'}`)
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 50,
|
||||
render: (row: any) => {
|
||||
const type = getStatusType(row.status)
|
||||
return h('n-tag', {
|
||||
type: type,
|
||||
size: 'small',
|
||||
bordered: false
|
||||
}, { default: () => getStatusLabel(row.status) })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
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',
|
||||
onClick: () => viewReport(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',
|
||||
onClick: () => updateReportStatus(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',
|
||||
onClick: () => updateReportStatus(row, 'rejected')
|
||||
}, [
|
||||
h('i', { class: 'fas fa-times mr-1 text-xs' }),
|
||||
'拒绝'
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
return h('div', { class: 'flex items-center gap-1' }, buttons)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 搜索防抖
|
||||
let searchTimeout: NodeJS.Timeout | null = null
|
||||
const debounceSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.value.page = 1
|
||||
fetchReports()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 获取举报列表
|
||||
const fetchReports = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.pageSize
|
||||
}
|
||||
|
||||
if (filters.value.status) params.status = filters.value.status
|
||||
if (filters.value.resourceKey) params.resource_key = filters.value.resourceKey
|
||||
|
||||
// 使用原始API调用以获取完整的分页信息
|
||||
const rawResponse = await resourceApi.getReportsRaw(params)
|
||||
console.log(rawResponse)
|
||||
|
||||
// 检查响应格式并处理
|
||||
if (rawResponse && rawResponse.data && rawResponse.data.list !== undefined) {
|
||||
// 如果后端返回了分页格式,使用正确的字段
|
||||
reports.value = rawResponse.data.list || []
|
||||
pagination.value.total = rawResponse.data.total || 0
|
||||
} else {
|
||||
// 如果是其他格式,尝试直接使用响应
|
||||
reports.value = rawResponse || []
|
||||
pagination.value.total = rawResponse.length || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取举报列表失败:', error)
|
||||
// 显示错误提示
|
||||
if (process.client) {
|
||||
notification.error({
|
||||
content: '获取举报列表失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
status: '',
|
||||
resourceKey: ''
|
||||
}
|
||||
pagination.value.page = 1
|
||||
fetchReports()
|
||||
}
|
||||
|
||||
// 处理页面大小变化
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.page = 1
|
||||
fetchReports()
|
||||
}
|
||||
|
||||
// 查看举报详情
|
||||
const viewReport = (report: any) => {
|
||||
selectedReport.value = report
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
// 更新举报状态
|
||||
const updateReportStatus = async (report: any, status: string) => {
|
||||
try {
|
||||
// 获取处理备注(如果需要)
|
||||
let note = ''
|
||||
if (status === 'rejected') {
|
||||
note = await getRejectionNote()
|
||||
if (note === null) return // 用户取消操作
|
||||
}
|
||||
|
||||
const response = await resourceApi.updateReport(report.id, {
|
||||
status,
|
||||
note
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
const index = reports.value.findIndex(r => r.id === report.id)
|
||||
if (index !== -1) {
|
||||
reports.value[index] = response
|
||||
}
|
||||
|
||||
// 更新详情模态框中的数据
|
||||
if (selectedReport.value && selectedReport.value.id === report.id) {
|
||||
selectedReport.value = response
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
notification.success({
|
||||
content: '状态更新成功',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新举报状态失败:', error)
|
||||
if (process.client) {
|
||||
notification.error({
|
||||
content: '状态更新失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取拒绝原因输入
|
||||
const getRejectionNote = (): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
// 使用naive-ui的dialog API
|
||||
const { dialog } = useDialog()
|
||||
|
||||
let inputValue = ''
|
||||
|
||||
dialog.warning({
|
||||
title: '输入拒绝原因',
|
||||
content: () => h(nInput, {
|
||||
value: inputValue,
|
||||
onUpdateValue: (value) => inputValue = value,
|
||||
placeholder: '请输入拒绝的原因...',
|
||||
type: 'textarea',
|
||||
rows: 4
|
||||
}),
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
if (!inputValue.trim()) {
|
||||
const { message } = useNotification()
|
||||
message.warning('请输入拒绝原因')
|
||||
return false // 不关闭对话框
|
||||
}
|
||||
resolve(inputValue)
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 状态类型和标签
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'warning'
|
||||
case 'approved': return 'success'
|
||||
case 'rejected': return 'error'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return '待处理'
|
||||
case 'approved': return '已批准'
|
||||
case 'rejected': return '已拒绝'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
// 举报原因标签
|
||||
const getReasonLabel = (reason: string) => {
|
||||
const reasonMap: Record<string, string> = {
|
||||
'link_invalid': '链接已失效',
|
||||
'download_failed': '资源无法下载',
|
||||
'content_mismatch': '资源内容不符',
|
||||
'malicious': '包含恶意软件',
|
||||
'copyright': '版权问题',
|
||||
'other': '其他问题'
|
||||
}
|
||||
return reasonMap[reason] || reason
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateString: string) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
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: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
onMounted(() => {
|
||||
fetchReports()
|
||||
})
|
||||
</script>
|
||||
@@ -274,6 +274,125 @@
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="sitemap" tab="Sitemap管理">
|
||||
<div class="tab-content-container">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Sitemap管理</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">管理和生成网站sitemap文件,提升搜索引擎收录效果</p>
|
||||
</div>
|
||||
|
||||
<!-- Sitemap配置 -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-2">自动生成功能</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
开启后系统将定期自动生成sitemap文件
|
||||
</p>
|
||||
</div>
|
||||
<n-switch
|
||||
v-model:value="sitemapConfig.autoGenerate"
|
||||
@update:value="updateSitemapConfig"
|
||||
:loading="configLoading"
|
||||
size="large"
|
||||
>
|
||||
<template #checked>已开启</template>
|
||||
<template #unchecked>已关闭</template>
|
||||
</n-switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sitemap统计 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<i class="fas fa-database text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">资源总数</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ sitemapStats.total_resources }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<i class="fas fa-file-code text-green-600 dark:text-green-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Sitemap数量</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ sitemapStats.total_pages }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<i class="fas fa-history text-purple-600 dark:text-purple-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">最后生成</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ sitemapStats.last_generate || '从未' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="generateSitemap"
|
||||
:loading="isGenerating"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-cogs"></i>
|
||||
</template>
|
||||
手动生成Sitemap
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
type="default"
|
||||
@click="viewSitemap"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</template>
|
||||
查看Sitemap
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
type="info"
|
||||
@click="refreshSitemapStatus"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</template>
|
||||
刷新状态
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 生成状态 -->
|
||||
<div v-if="generateStatus" class="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-yellow-600 dark:text-yellow-400 mt-0.5 mr-2"></i>
|
||||
<div>
|
||||
<h4 class="font-medium text-yellow-800 dark:text-yellow-200 mb-1">生成状态</h4>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300">{{ generateStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
@@ -503,9 +622,104 @@ const deleteLink = (row: any) => {
|
||||
message.warning(`删除外链: ${row.title}`)
|
||||
}
|
||||
|
||||
// Sitemap管理相关
|
||||
const sitemapConfig = ref({
|
||||
autoGenerate: false,
|
||||
lastGenerate: '',
|
||||
lastUpdate: ''
|
||||
})
|
||||
|
||||
const sitemapStats = ref({
|
||||
total_resources: 0,
|
||||
total_pages: 0,
|
||||
last_generate: ''
|
||||
})
|
||||
|
||||
const configLoading = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const generateStatus = ref('')
|
||||
|
||||
// 获取Sitemap配置
|
||||
const loadSitemapConfig = async () => {
|
||||
try {
|
||||
const sitemapApi = useSitemapApi()
|
||||
const response = await sitemapApi.getSitemapConfig()
|
||||
if (response) {
|
||||
sitemapConfig.value = response
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取Sitemap配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 更新Sitemap配置
|
||||
const updateSitemapConfig = async (value: boolean) => {
|
||||
configLoading.value = true
|
||||
try {
|
||||
const sitemapApi = useSitemapApi()
|
||||
await sitemapApi.updateSitemapConfig({
|
||||
autoGenerate: value,
|
||||
lastGenerate: sitemapConfig.value.lastGenerate,
|
||||
lastUpdate: new Date().toISOString()
|
||||
})
|
||||
message.success(value ? '自动生成功能已开启' : '自动生成功能已关闭')
|
||||
} catch (error) {
|
||||
message.error('更新配置失败')
|
||||
// 恢复之前的值
|
||||
sitemapConfig.value.autoGenerate = !value
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成Sitemap
|
||||
const generateSitemap = async () => {
|
||||
isGenerating.value = true
|
||||
generateStatus.value = '正在启动生成任务...'
|
||||
|
||||
try {
|
||||
const sitemapApi = useSitemapApi()
|
||||
const response = await sitemapApi.generateSitemap()
|
||||
|
||||
if (response) {
|
||||
generateStatus.value = response.message || '生成任务已启动'
|
||||
message.success('Sitemap生成任务已启动')
|
||||
// 更新统计信息
|
||||
sitemapStats.value.total_resources = response.total_resources || 0
|
||||
sitemapStats.value.total_pages = response.total_pages || 0
|
||||
}
|
||||
} catch (error: any) {
|
||||
generateStatus.value = '生成失败: ' + (error.message || '未知错误')
|
||||
message.error('Sitemap生成失败')
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新Sitemap状态
|
||||
const refreshSitemapStatus = async () => {
|
||||
try {
|
||||
const sitemapApi = useSitemapApi()
|
||||
const response = await sitemapApi.getSitemapStatus()
|
||||
if (response) {
|
||||
sitemapStats.value = response
|
||||
generateStatus.value = '状态已刷新'
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('刷新状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看Sitemap
|
||||
const viewSitemap = () => {
|
||||
window.open('/sitemap.xml', '_blank')
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loadLinkList()
|
||||
await loadSitemapConfig()
|
||||
await refreshSitemapStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -173,8 +173,9 @@
|
||||
<tr
|
||||
v-for="(resource, index) in safeResources"
|
||||
:key="resource.id"
|
||||
:class="isUpdatedToday(resource.updated_at) ? 'hover:bg-pink-50 dark:hover:bg-pink-500/10 bg-pink-50/30 dark:bg-pink-500/5' : 'hover:bg-gray-50 dark:hover:bg-slate-700/50'"
|
||||
:class="isUpdatedToday(resource.updated_at) ? 'hover:bg-pink-50 dark:hover:bg-pink-500/10 bg-pink-50/30 dark:bg-pink-500/5 cursor-pointer' : 'hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer'"
|
||||
:data-index="index"
|
||||
@click="navigateToDetail(resource.key)"
|
||||
>
|
||||
<td class="text-xs sm:text-sm w-20 pl-2 sm:pl-3">
|
||||
<div class="flex justify-center">
|
||||
@@ -229,23 +230,25 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex justify-end">
|
||||
<button
|
||||
class="mobile-link-btn flex items-center gap-1 text-xs"
|
||||
@click="toggleLink(resource)"
|
||||
<NuxtLink
|
||||
:to="`/r/${resource.key}`"
|
||||
class="mobile-link-btn flex items-center gap-1 text-xs no-underline"
|
||||
@click.stop
|
||||
>
|
||||
<i class="fas fa-eye"></i> 显示链接
|
||||
</button>
|
||||
<i class="fas fa-eye"></i> 查看详情
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm hidden sm:table-cell w-32">
|
||||
<button
|
||||
class="text-blue-600 hover:text-blue-800 flex items-center gap-1 show-link-btn"
|
||||
@click="toggleLink(resource)"
|
||||
<NuxtLink
|
||||
:to="`/r/${resource.key}`"
|
||||
class="text-blue-600 hover:text-blue-800 flex items-center gap-1 show-link-btn"
|
||||
@click.stop
|
||||
>
|
||||
<i class="fas fa-eye"></i> 显示链接
|
||||
</button>
|
||||
<i class="fas fa-eye"></i> 查看详情
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm text-gray-500 hidden sm:table-cell w-32" :title="resource.updated_at">
|
||||
<span v-html="formatRelativeTime(resource.updated_at)"></span>
|
||||
@@ -298,25 +301,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开发环境缓存信息组件 -->
|
||||
<SystemConfigCacheInfo />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 获取运行时配置
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
import { useResourceApi, useStatsApi, usePanApi, useSystemConfigApi, usePublicSystemConfigApi, useSearchStatsApi } from '~/composables/useApi'
|
||||
import { useResourceApi, useStatsApi, usePanApi, useSearchStatsApi } from '~/composables/useApi'
|
||||
import SystemConfigCacheInfo from '~/components/SystemConfigCacheInfo.vue'
|
||||
|
||||
const resourceApi = useResourceApi()
|
||||
const statsApi = useStatsApi()
|
||||
const panApi = usePanApi()
|
||||
const publicSystemConfigApi = usePublicSystemConfigApi()
|
||||
|
||||
// 路由参数已通过自动导入提供,直接使用
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 页面元数据 - 使用系统配置的标题
|
||||
const { data: systemConfigData } = await useAsyncData('systemConfig', () => publicSystemConfigApi.getPublicSystemConfig())
|
||||
// 使用系统配置Store(带缓存支持)
|
||||
const { useSystemConfigStore } = await import('~/stores/systemConfig')
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
|
||||
// 初始化系统配置(会自动使用缓存)
|
||||
await systemConfigStore.initConfig()
|
||||
|
||||
// 检查并自动刷新即将过期的缓存
|
||||
await systemConfigStore.checkAndRefreshCache()
|
||||
|
||||
// 获取平台名称的辅助函数
|
||||
const getPlatformName = (platformId: string) => {
|
||||
@@ -326,12 +339,11 @@ const getPlatformName = (platformId: string) => {
|
||||
return platform?.name || ''
|
||||
}
|
||||
|
||||
// 动态生成页面标题和meta信息 - 修复安全访问问题
|
||||
// 动态生成页面标题和meta信息 - 使用缓存的系统配置
|
||||
const pageTitle = computed(() => {
|
||||
try {
|
||||
const config = systemConfigData.value as any
|
||||
const siteTitle = (config?.data?.site_title) ? config.data.site_title :
|
||||
(config?.site_title) ? config.site_title : '老九网盘资源数据库'
|
||||
const config = systemConfigStore.config
|
||||
const siteTitle = config?.site_title || '老九网盘资源数据库'
|
||||
const searchKeyword = (route.query?.search) ? route.query.search as string : ''
|
||||
const platformId = (route.query?.platform) ? route.query.platform as string : ''
|
||||
const platformName = getPlatformName(platformId)
|
||||
@@ -357,9 +369,8 @@ const pageTitle = computed(() => {
|
||||
|
||||
const pageDescription = computed(() => {
|
||||
try {
|
||||
const config = systemConfigData.value as any
|
||||
const baseDescription = (config?.data?.site_description) ? config.data.site_description :
|
||||
(config?.site_description) ? config.site_description : '老九网盘资源管理系统, 一个现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘,迅雷云盘,123云盘,115网盘,UC网盘'
|
||||
const config = systemConfigStore.config
|
||||
const baseDescription = config?.site_description || '老九网盘资源管理系统, 一个现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘,迅雷云盘,123云盘,115网盘,UC网盘'
|
||||
|
||||
const searchKeyword = (route.query && route.query.search) ? route.query.search as string : ''
|
||||
const platformId = (route.query && route.query.platform) ? route.query.platform as string : ''
|
||||
@@ -385,9 +396,8 @@ const pageDescription = computed(() => {
|
||||
|
||||
const pageKeywords = computed(() => {
|
||||
try {
|
||||
const config = systemConfigData.value as any
|
||||
const baseKeywords = (config?.data?.keywords) ? config.data.keywords :
|
||||
(config?.keywords) ? config.keywords : '网盘资源,资源管理,数据库'
|
||||
const config = systemConfigStore.config
|
||||
const baseKeywords = config?.keywords || '网盘资源,资源管理,数据库'
|
||||
|
||||
const searchKeyword = (route.query && route.query.search) ? route.query.search as string : ''
|
||||
const platformId = (route.query && route.query.platform) ? route.query.platform as string : ''
|
||||
@@ -419,7 +429,8 @@ const updatePageSeo = () => {
|
||||
// 使用动态计算的标题,而不是默认的"首页"
|
||||
setPageSeo(pageTitle.value, {
|
||||
description: pageDescription.value,
|
||||
keywords: pageKeywords.value
|
||||
keywords: pageKeywords.value,
|
||||
ogImage: '/assets/images/og.webp' // 使用默认的OG图片
|
||||
})
|
||||
|
||||
// 设置HTML属性和canonical链接
|
||||
@@ -441,6 +452,12 @@ const updatePageSeo = () => {
|
||||
href: canonicalUrl
|
||||
}
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
property: 'og:image',
|
||||
content: '/assets/images/og.webp'
|
||||
}
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
@@ -449,7 +466,8 @@ const updatePageSeo = () => {
|
||||
"@type": "WebSite",
|
||||
"name": (seoSystemConfig.value && seoSystemConfig.value.site_title) || '老九网盘资源数据库',
|
||||
"description": pageDescription.value,
|
||||
"url": canonicalUrl
|
||||
"url": canonicalUrl,
|
||||
"image": '/assets/images/og.webp'
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -463,7 +481,7 @@ onBeforeMount(async () => {
|
||||
|
||||
// 监听路由变化和系统配置数据,当搜索条件或配置改变时更新SEO
|
||||
watch(
|
||||
() => [route.query?.search, route.query?.platform, systemConfigData.value],
|
||||
() => [route.query?.search, route.query?.platform, systemConfigStore.config],
|
||||
() => {
|
||||
// 使用nextTick确保响应式数据已更新
|
||||
nextTick(() => {
|
||||
@@ -615,7 +633,7 @@ const safeResources = computed(() => {
|
||||
})
|
||||
const safeStats = computed(() => (statsData.value as any) || { total_resources: 0, total_categories: 0, total_tags: 0, total_views: 0, today_resources: 0 })
|
||||
const platforms = computed(() => (platformsData.value as any) || [])
|
||||
const systemConfig = computed(() => (systemConfigData.value as any)?.data || { site_title: '老九网盘资源数据库' })
|
||||
const systemConfig = computed(() => systemConfigStore.config || { site_title: '老九网盘资源数据库' })
|
||||
const safeLoading = computed(() => pending.value)
|
||||
|
||||
|
||||
@@ -675,49 +693,14 @@ const getPlatformIcon = (panId: string | number) => {
|
||||
|
||||
// 注意:链接访问统计已整合到 getResourceLink API 中
|
||||
|
||||
// 切换链接显示
|
||||
const toggleLink = async (resource: any) => {
|
||||
// 如果包含违禁词,直接显示禁止访问,不发送请求
|
||||
if (resource.has_forbidden_words) {
|
||||
selectedResource.value = {
|
||||
...resource,
|
||||
forbidden: true,
|
||||
error: '该资源包含违禁内容,无法访问',
|
||||
forbidden_words: resource.forbidden_words || []
|
||||
}
|
||||
showLinkModal.value = true
|
||||
return
|
||||
}
|
||||
// 导航到详情页
|
||||
const navigateToDetail = (key: string) => {
|
||||
router.push(`/r/${key}`)
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
selectedResource.value = { ...resource, loading: true }
|
||||
showLinkModal.value = true
|
||||
|
||||
try {
|
||||
// 调用新的获取链接API(同时统计访问次数)
|
||||
const linkData = await resourceApi.getResourceLink(resource.id) as any
|
||||
console.log('获取到的链接数据:', linkData)
|
||||
|
||||
// 更新资源信息,包含新的链接信息
|
||||
selectedResource.value = {
|
||||
...resource,
|
||||
url: linkData.url,
|
||||
save_url: linkData.type === 'transferred' ? linkData.url : resource.save_url,
|
||||
loading: false,
|
||||
linkType: linkData.type,
|
||||
platform: linkData.platform,
|
||||
message: linkData.message
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取资源链接失败:', error)
|
||||
|
||||
// 其他错误
|
||||
selectedResource.value = {
|
||||
...resource,
|
||||
loading: false,
|
||||
error: '检测有效性失败,请自行验证'
|
||||
}
|
||||
}
|
||||
// 切换链接显示(保留用于其他可能的用途)
|
||||
const toggleLink = async (resource: any) => {
|
||||
navigateToDetail(resource.key)
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
|
||||
1402
web/pages/r/[key].vue
Normal file
1402
web/pages/r/[key].vue
Normal file
File diff suppressed because it is too large
Load Diff
39
web/pnpm-lock.yaml
generated
39
web/pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
||||
'@vicons/ionicons5':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
'@vueuse/core':
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0(vue@3.5.18(typescript@5.8.3))
|
||||
chart.js:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0
|
||||
@@ -74,7 +77,7 @@ importers:
|
||||
version: 5.8.3
|
||||
unplugin-auto-import:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5))
|
||||
version: 19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5))(@vueuse/core@14.0.0(vue@3.5.18(typescript@5.8.3)))
|
||||
unplugin-vue-components:
|
||||
specifier: ^28.8.0
|
||||
version: 28.8.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.7(magicast@0.3.5))(vue@3.5.18(typescript@5.8.3))
|
||||
@@ -1265,6 +1268,9 @@ packages:
|
||||
'@types/uglify-js@3.17.5':
|
||||
resolution: {integrity: sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/webpack-bundle-analyzer@3.9.5':
|
||||
resolution: {integrity: sha512-QlyDyX7rsOIJHASzXWlih8DT9fR+XCG9cwIV/4pKrtScdHv4XFshdEf/7iiqLqG0lzWcoBdzG8ylMHQ5XLNixw==}
|
||||
|
||||
@@ -1401,6 +1407,19 @@ packages:
|
||||
'@vue/shared@3.5.18':
|
||||
resolution: {integrity: sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==}
|
||||
|
||||
'@vueuse/core@14.0.0':
|
||||
resolution: {integrity: sha512-d6tKRWkZE8IQElX2aHBxXOMD478fHIYV+Dzm2y9Ag122ICBpNKtGICiXKOhWU3L1kKdttDD9dCMS4bGP3jhCTQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/metadata@14.0.0':
|
||||
resolution: {integrity: sha512-6yoGqbJcMldVCevkFiHDBTB1V5Hq+G/haPlGIuaFZHpXC0HADB0EN1ryQAAceiW+ryS3niUwvdFbGiqHqBrfVA==}
|
||||
|
||||
'@vueuse/shared@14.0.0':
|
||||
resolution: {integrity: sha512-mTCA0uczBgurRlwVaQHfG0Ja7UdGe4g9mwffiJmvLiTtp1G4AQyIjej6si/k8c8pUwTfVpNufck+23gXptPAkw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||
|
||||
@@ -5904,6 +5923,8 @@ snapshots:
|
||||
dependencies:
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/webpack-bundle-analyzer@3.9.5':
|
||||
dependencies:
|
||||
'@types/webpack': 4.41.40
|
||||
@@ -6133,6 +6154,19 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.5.18': {}
|
||||
|
||||
'@vueuse/core@14.0.0(vue@3.5.18(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 14.0.0
|
||||
'@vueuse/shared': 14.0.0(vue@3.5.18(typescript@5.8.3))
|
||||
vue: 3.5.18(typescript@5.8.3)
|
||||
|
||||
'@vueuse/metadata@14.0.0': {}
|
||||
|
||||
'@vueuse/shared@14.0.0(vue@3.5.18(typescript@5.8.3))':
|
||||
dependencies:
|
||||
vue: 3.5.18(typescript@5.8.3)
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
dependencies:
|
||||
'@webassemblyjs/helper-numbers': 1.13.2
|
||||
@@ -9257,7 +9291,7 @@ snapshots:
|
||||
dependencies:
|
||||
normalize-path: 2.1.1
|
||||
|
||||
unplugin-auto-import@19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5)):
|
||||
unplugin-auto-import@19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5))(@vueuse/core@14.0.0(vue@3.5.18(typescript@5.8.3))):
|
||||
dependencies:
|
||||
local-pkg: 1.1.1
|
||||
magic-string: 0.30.17
|
||||
@@ -9267,6 +9301,7 @@ snapshots:
|
||||
unplugin-utils: 0.2.4
|
||||
optionalDependencies:
|
||||
'@nuxt/kit': 3.17.7(magicast@0.3.5)
|
||||
'@vueuse/core': 14.0.0(vue@3.5.18(typescript@5.8.3))
|
||||
|
||||
unplugin-utils@0.2.4:
|
||||
dependencies:
|
||||
|
||||
BIN
web/public/assets/images/og.webp
Normal file
BIN
web/public/assets/images/og.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -2,38 +2,265 @@ import { defineStore } from 'pinia'
|
||||
import { useApiFetch } from '~/composables/useApiFetch'
|
||||
import { parseApiResponse } from '~/composables/useApi'
|
||||
|
||||
// 缓存配置
|
||||
const CACHE_KEY = 'system-config-cache'
|
||||
const CACHE_TIMESTAMP_KEY = 'system-config-cache-timestamp'
|
||||
const CACHE_DURATION = 30 * 60 * 1000 // 30分钟缓存
|
||||
|
||||
// 安全的客户端检查函数
|
||||
const isClient = () => {
|
||||
return typeof process !== 'undefined' && process.client
|
||||
}
|
||||
|
||||
interface CacheData {
|
||||
config: any
|
||||
timestamp: number
|
||||
version?: string
|
||||
}
|
||||
|
||||
export const useSystemConfigStore = defineStore('systemConfig', {
|
||||
state: () => ({
|
||||
config: null as any,
|
||||
initialized: false
|
||||
initialized: false,
|
||||
lastFetchTime: 0 as number,
|
||||
isLoading: false as boolean,
|
||||
error: null as string | null
|
||||
}),
|
||||
actions: {
|
||||
async initConfig(force = false, useAdminApi = false) {
|
||||
if (this.initialized && !force) return
|
||||
|
||||
getters: {
|
||||
// 检查缓存是否有效
|
||||
isCacheValid(): boolean {
|
||||
if (!isClient()) return false
|
||||
|
||||
try {
|
||||
const cacheData = localStorage.getItem(CACHE_KEY)
|
||||
const cacheTimestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY)
|
||||
|
||||
if (!cacheData || !cacheTimestamp) return false
|
||||
|
||||
const timestamp = parseInt(cacheTimestamp)
|
||||
const now = Date.now()
|
||||
|
||||
// 检查缓存是否过期
|
||||
const isValid = (now - timestamp) < CACHE_DURATION
|
||||
|
||||
// console.log(`[SystemConfig] 缓存检查: ${isValid ? '有效' : '已过期'}, 剩余时间: ${Math.max(0, CACHE_DURATION - (now - timestamp)) / 1000 / 60}分钟`)
|
||||
|
||||
return isValid
|
||||
} catch (error) {
|
||||
console.error('[SystemConfig] 缓存检查失败:', error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// 获取缓存的数据
|
||||
cachedConfig(): any {
|
||||
if (!isClient() || !this.isCacheValid) return null
|
||||
|
||||
try {
|
||||
const cacheData = localStorage.getItem(CACHE_KEY)
|
||||
if (cacheData) {
|
||||
const parsed = JSON.parse(cacheData) as CacheData
|
||||
// console.log('[SystemConfig] 使用缓存数据')
|
||||
return parsed.config
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SystemConfig] 读取缓存失败:', error)
|
||||
this.clearCache()
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
// 获取缓存剩余时间(秒)
|
||||
cacheTimeRemaining(): number {
|
||||
if (!isClient()) return 0
|
||||
|
||||
try {
|
||||
const cacheTimestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY)
|
||||
if (!cacheTimestamp) return 0
|
||||
|
||||
const timestamp = parseInt(cacheTimestamp)
|
||||
const now = Date.now()
|
||||
const remaining = Math.max(0, CACHE_DURATION - (now - timestamp))
|
||||
|
||||
return Math.floor(remaining / 1000)
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 清除缓存
|
||||
clearCache() {
|
||||
if (!isClient()) return
|
||||
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY)
|
||||
localStorage.removeItem(CACHE_TIMESTAMP_KEY)
|
||||
// console.log('[SystemConfig] 缓存已清除')
|
||||
} catch (error) {
|
||||
console.error('[SystemConfig] 清除缓存失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 保存到缓存
|
||||
saveToCache(config: any) {
|
||||
if (!isClient()) return
|
||||
|
||||
try {
|
||||
const cacheData: CacheData = {
|
||||
config,
|
||||
timestamp: Date.now(),
|
||||
version: '1.0'
|
||||
}
|
||||
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData))
|
||||
localStorage.setItem(CACHE_TIMESTAMP_KEY, cacheData.timestamp.toString())
|
||||
|
||||
// console.log('[SystemConfig] 配置已缓存,有效期30分钟')
|
||||
} catch (error) {
|
||||
console.error('[SystemConfig] 保存缓存失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 从缓存加载
|
||||
loadFromCache(): boolean {
|
||||
if (!isClient()) return false
|
||||
|
||||
const cachedConfig = this.cachedConfig
|
||||
if (cachedConfig) {
|
||||
this.config = cachedConfig
|
||||
this.initialized = true
|
||||
|
||||
// 从缓存时间戳设置 lastFetchTime
|
||||
const cacheTimestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY)
|
||||
if (cacheTimestamp) {
|
||||
this.lastFetchTime = parseInt(cacheTimestamp)
|
||||
} else {
|
||||
this.lastFetchTime = Date.now()
|
||||
}
|
||||
|
||||
// console.log('[SystemConfig] 从缓存加载配置成功')
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
// 初始化配置(带缓存支持)
|
||||
async initConfig(force = false, useAdminApi = false) {
|
||||
// 如果已经初始化且不强制刷新,直接返回
|
||||
if (this.initialized && !force) {
|
||||
// console.log('[SystemConfig] 配置已初始化,直接返回')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果不强制刷新,先尝试从缓存加载
|
||||
if (!force && this.loadFromCache()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 防止重复请求
|
||||
if (this.isLoading) {
|
||||
// console.log('[SystemConfig] 正在加载中,等待完成...')
|
||||
return
|
||||
}
|
||||
|
||||
this.isLoading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
// console.log(`[SystemConfig] 开始获取配置 (force: ${force}, useAdminApi: ${useAdminApi})`)
|
||||
|
||||
// 根据上下文选择API:管理员页面使用管理员API,其他页面使用公开API
|
||||
const apiUrl = useAdminApi ? '/system/config' : '/public/system-config'
|
||||
const response = await useApiFetch(apiUrl)
|
||||
// console.log('Store API响应:', response) // 调试信息
|
||||
|
||||
|
||||
// 使用parseApiResponse正确解析API响应
|
||||
const data = parseApiResponse(response)
|
||||
// console.log('Store 处理后的数据:', data) // 调试信息
|
||||
// console.log('Store 自动处理状态:', data.auto_process_ready_resources)
|
||||
// console.log('Store 自动转存状态:', data.auto_transfer_enabled)
|
||||
|
||||
|
||||
this.config = data
|
||||
this.initialized = true
|
||||
} catch (e) {
|
||||
console.error('Store 获取系统配置失败:', e) // 调试信息
|
||||
// 可根据需要处理错误
|
||||
this.lastFetchTime = Date.now()
|
||||
this.isLoading = false
|
||||
|
||||
// 保存到缓存(仅在客户端)
|
||||
this.saveToCache(data)
|
||||
|
||||
// console.log('[SystemConfig] 配置获取并缓存成功')
|
||||
// console.log('[SystemConfig] 自动处理状态:', data.auto_process_ready_resources)
|
||||
// console.log('[SystemConfig] 自动转存状态:', data.auto_transfer_enabled)
|
||||
|
||||
} catch (error) {
|
||||
this.isLoading = false
|
||||
this.error = error instanceof Error ? error.message : '获取配置失败'
|
||||
// console.error('[SystemConfig] 获取系统配置失败:', error)
|
||||
|
||||
// 如果网络请求失败,尝试使用过期的缓存作为降级方案
|
||||
if (!force) {
|
||||
try {
|
||||
const expiredCache = localStorage.getItem(CACHE_KEY)
|
||||
if (expiredCache) {
|
||||
const parsed = JSON.parse(expiredCache) as CacheData
|
||||
this.config = parsed.config
|
||||
this.initialized = true
|
||||
console.log('[SystemConfig] 网络请求失败,使用过期缓存作为降级方案')
|
||||
return
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.error('[SystemConfig] 降级缓存方案也失败:', cacheError)
|
||||
}
|
||||
}
|
||||
|
||||
this.config = null
|
||||
this.initialized = false
|
||||
}
|
||||
},
|
||||
|
||||
// 强制刷新配置
|
||||
async refreshConfig(useAdminApi = false) {
|
||||
console.log('[SystemConfig] 强制刷新配置')
|
||||
this.clearCache()
|
||||
await this.initConfig(true, useAdminApi)
|
||||
},
|
||||
|
||||
// 检查并自动刷新缓存(如果即将过期)
|
||||
async checkAndRefreshCache(useAdminApi = false) {
|
||||
if (!isClient()) return
|
||||
|
||||
const timeRemaining = this.cacheTimeRemaining
|
||||
|
||||
// 如果缓存剩余时间少于5分钟,自动刷新
|
||||
if (timeRemaining > 0 && timeRemaining < 5 * 60) {
|
||||
console.log(`[SystemConfig] 缓存即将过期(剩余${timeRemaining}秒),自动刷新`)
|
||||
await this.refreshConfig(useAdminApi)
|
||||
}
|
||||
},
|
||||
|
||||
// 手动设置配置(用于管理员更新配置后)
|
||||
setConfig(newConfig: any) {
|
||||
this.config = newConfig
|
||||
this.initialized = true
|
||||
this.lastFetchTime = Date.now()
|
||||
|
||||
// 更新缓存
|
||||
this.saveToCache(newConfig)
|
||||
|
||||
console.log('[SystemConfig] 配置已手动更新并缓存')
|
||||
},
|
||||
|
||||
// 获取配置状态信息
|
||||
getStatus() {
|
||||
return {
|
||||
initialized: this.initialized,
|
||||
isLoading: this.isLoading,
|
||||
error: this.error,
|
||||
lastFetchTime: this.lastFetchTime,
|
||||
cacheTimeRemaining: this.cacheTimeRemaining,
|
||||
isCacheValid: this.isCacheValid
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user