2 Commits

Author SHA1 Message Date
ctwj
3bd0fde82f update: report 2025-11-19 08:32:01 +08:00
ctwj
61e5cbf80d add report 2025-11-19 02:22:04 +08:00
24 changed files with 2524 additions and 121 deletions

View File

@@ -109,6 +109,8 @@ func InitDB() error {
&entity.APIAccessLog{},
&entity.APIAccessLogStats{},
&entity.APIAccessLogSummary{},
&entity.Report{},
&entity.CopyrightClaim{},
)
if err != nil {
utils.Fatal("数据库迁移失败: %v", err)

View File

@@ -0,0 +1,40 @@
package converter
import (
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
"time"
)
// 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),
}
}
// CopyrightClaimsToResponse 将版权申述实体列表转换为响应对象列表
func CopyrightClaimsToResponse(claims []*entity.CopyrightClaim) []*dto.CopyrightClaimResponse {
var responses []*dto.CopyrightClaimResponse
for _, claim := range claims {
responses = append(responses, CopyrightClaimToResponse(claim))
}
return responses
}

View 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
}

45
db/dto/copyright_claim.go Normal file
View File

@@ -0,0 +1,45 @@
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"`
}
// 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
View 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"`
}

View 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
View 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"
}

View 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
}

View File

@@ -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),
}
}

View 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
}

View File

@@ -48,6 +48,7 @@ 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)
}
@@ -788,3 +789,13 @@ func (r *ResourceRepositoryImpl) GetHotResources(limit int) ([]entity.Resource,
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
}

2
go.mod
View File

@@ -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

View File

@@ -0,0 +1,278 @@
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 CopyrightClaimHandler struct {
copyrightClaimRepo repo.CopyrightClaimRepository
validate *validator.Validate
}
func NewCopyrightClaimHandler(copyrightClaimRepo repo.CopyrightClaimRepository) *CopyrightClaimHandler {
return &CopyrightClaimHandler{
copyrightClaimRepo: copyrightClaimRepo,
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
}
PageResponse(c, converter.CopyrightClaimsToResponse(claims), total, req.Page, req.PageSize)
}
// 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) {
handler := NewCopyrightClaimHandler(copyrightClaimRepo)
claims := router.Group("/copyright-claims")
{
claims.POST("", handler.CreateCopyrightClaim) // 创建版权申述
claims.GET("/:id", handler.GetCopyrightClaim) // 获取版权申述详情
claims.GET("", handler.ListCopyrightClaims) // 获取版权申述列表
claims.PUT("/:id", handler.UpdateCopyrightClaim) // 更新版权申述状态
claims.DELETE("/:id", handler.DeleteCopyrightClaim) // 删除版权申述
claims.GET("/resource/:resource_key", handler.GetCopyrightClaimByResource) // 获取资源版权申述列表
}
}

310
handlers/report_handler.go Normal file
View 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) // 获取资源举报列表
}
}

19
main.go
View File

@@ -211,6 +211,10 @@ func main() {
// 创建OG图片处理器
ogImageHandler := handlers.NewOGImageHandler()
// 创建举报和版权申述处理器
reportHandler := handlers.NewReportHandler(repoManager.ReportRepository, repoManager.ResourceRepository)
copyrightClaimHandler := handlers.NewCopyrightClaimHandler(repoManager.CopyrightClaimRepository)
// API路由
api := r.Group("/api")
{
@@ -443,6 +447,21 @@ 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)
}
// 设置监控系统

View File

@@ -129,6 +129,9 @@
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import { useResourceApi } from '~/composables/useApi'
const resourceApi = useResourceApi()
interface Props {
visible: boolean
@@ -248,28 +251,41 @@ const handleSubmit = async () => {
submitting.value = true
// 这里可以调用实际的版权申述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: formData.value.proof_files,
// user_agent: navigator.userAgent,
// ip_address: await getClientIP()
// }
// await copyrightApi.submitCopyrightClaim(copyrightData)
// 构建证明文件数组(从文件列表转换为字符串)
const proofFilesArray = formData.value.proof_files.map((file: any) => ({
id: file.id,
name: file.name,
status: file.status,
percentage: file.percentage
}))
// 模拟提交过程
await new Promise(resolve => setTimeout(resolve, 2000))
// 调用实际的版权申述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) {
emit('submitted') // 发送提交事件
} catch (error: any) {
console.error('提交版权申述失败:', error)
message.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
}

View File

@@ -74,6 +74,9 @@
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import { useResourceApi } from '~/composables/useApi'
const resourceApi = useResourceApi()
interface Props {
visible: boolean
@@ -152,25 +155,30 @@ const handleSubmit = async () => {
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: await getClientIP()
// }
// await reportApi.submitReport(reportData)
// 调用实际的举报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
}
// 模拟提交过程
await new Promise(resolve => setTimeout(resolve, 1500))
const result = await resourceApi.submitReport(reportData)
console.log('举报提交结果:', result)
message.success('举报提交成功,我们会尽快核实处理')
emit('submitted')
} catch (error) {
emit('submitted') // 发送提交事件
} catch (error: any) {
console.error('提交举报失败:', error)
message.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
}

View File

@@ -35,23 +35,47 @@ const socialShareElement = ref(null)
// 计算属性 - 避免在SSR中访问客户端API
const shareTitle = computed(() => {
return props.title || '精彩资源分享'
return props.title && props.title !== 'undefined' ? props.title : '精彩资源分享'
})
const shareDescription = computed(() => {
return props.description || '发现更多优质资源尽在urlDB'
return props.description && props.description !== 'undefined' ? props.description : '发现更多优质资源尽在urlDB'
})
const shareTags = computed(() => {
return props.tags?.slice(0, 3).join(',') || '资源分享,网盘,urldb'
if (props.tags && Array.isArray(props.tags) && props.tags.length > 0) {
return props.tags.filter(tag => tag && tag !== 'undefined').slice(0, 3).join(',') || '资源分享,网盘,urldb'
}
return '资源分享,网盘,urldb'
})
// 获取完整URL - 仅在客户端调用
// 获取完整URL - 使用运行时配置
const getFullUrl = () => {
if (props.url) return props.url
const config = useRuntimeConfig()
if (props.url) {
// 如果props.url已经是完整URL则直接返回
if (props.url.startsWith('http://') || props.url.startsWith('https://')) {
return props.url
}
// 否则拼接站点URL
let siteUrl = config.public.siteUrl
if (!siteUrl || siteUrl === 'https://yourdomain.com') {
// 优先在客户端使用当前页面的origin
if (typeof window !== 'undefined') {
siteUrl = window.location.origin
} else {
// 在服务端渲染时使用默认值
siteUrl = process.env.NUXT_PUBLIC_SITE_URL || 'https://yourdomain.com'
}
}
return `${siteUrl}${props.url.startsWith('/') ? props.url : '/' + props.url}`
}
if (typeof window !== 'undefined') {
return `${window.location.origin}${route.fullPath}`
}
return route.fullPath
}
@@ -66,11 +90,13 @@ const initSocialShare = () => {
// 创建 social-share 元素
const shareElement = document.createElement('div')
shareElement.className = 'social-share'
shareElement.setAttribute('data-sites', 'weibo,qq,wechat,qzone,twitter,telegram')
shareElement.setAttribute('data-sites', 'facebook,twitter,reddit')
shareElement.setAttribute('data-title', shareTitle.value)
shareElement.setAttribute('data-description', shareDescription.value)
shareElement.setAttribute('data-url', getFullUrl())
shareElement.setAttribute('data-twitter', shareTags.value)
shareElement.setAttribute('data-image', '') // 设置默认图片
shareElement.setAttribute('data-pics', '') // 设置图片QQ空间使用
shareElement.setAttribute('data-via', '') // Twitter via
shareElement.setAttribute('data-wechat-qrcode-title', '微信扫一扫:分享')
shareElement.setAttribute('data-wechat-qrcode-helper', '<p>微信里点"发现",扫一下</p><p>二维码便可将本文分享至朋友圈。</p>')
@@ -81,67 +107,35 @@ const initSocialShare = () => {
console.log('检查 SocialShare 对象:', window.SocialShare)
console.log('检查 social-share 元素:', shareElement)
// 尝试多种初始化方式
if (window.SocialShare) {
if (typeof window.SocialShare.init === 'function') {
// 尝试使用 social-share.js 的正确初始化方式
if (window.socialShare) {
try {
// 传入选择器来初始化
window.socialShare('.social-share')
console.log('socialShare() 函数调用成功')
} catch (error) {
console.error('socialShare 初始化失败:', error)
// 如果上面失败,尝试另一种方式
try {
if (typeof window.socialShare === 'function') {
window.socialShare()
console.log('socialShare 全局调用成功')
}
} catch (error2) {
console.error('socialShare 全局调用也失败:', error2)
}
}
} else if (window.SocialShare) {
try {
window.SocialShare.init()
console.log('SocialShare.init() 调用成功')
} else if (typeof window.SocialShare === 'function') {
window.SocialShare()
console.log('SocialShare() 函数调用成功')
} else {
console.log('SocialShare 对象存在但不是函数:', typeof window.SocialShare)
// 尝试手动初始化
try {
const socialShareElements = document.querySelectorAll('.social-share')
console.log('找到 social-share 元素:', socialShareElements.length)
if (socialShareElements.length > 0) {
// 检查是否已经生成了分享按钮
const generatedButtons = socialShareElements[0].querySelectorAll('.social-share-icon')
console.log('已生成的分享按钮:', generatedButtons.length)
}
} catch (e) {
console.error('手动检查失败:', e)
}
}
} else if (window.socialShare) {
// 尝试使用 socialShare 变量
console.log('找到 socialShare 全局变量,尝试初始化')
console.log('socialShare 对象类型:', typeof window.socialShare)
console.log('socialShare 对象内容:', window.socialShare)
if (typeof window.socialShare.init === 'function') {
try {
window.socialShare.init()
console.log('socialShare.init() 调用成功')
} catch (error) {
console.error('socialShare.init() 调用失败:', error)
}
} else if (typeof window.socialShare === 'function') {
try {
// social-share.js 需要传入选择器作为参数
window.socialShare('.social-share')
console.log('socialShare() 函数调用成功')
} catch (error) {
console.error('socialShare() 调用失败:', error)
// 尝试不带参数调用
try {
window.socialShare()
console.log('socialShare() 无参数调用成功')
} catch (error2) {
console.error('socialShare() 无参数调用也失败:', error2)
}
}
} else {
console.log('socialShare 对象存在但不是函数:', typeof window.socialShare)
console.log('socialShare 对象的属性:', Object.keys(window.socialShare || {}))
} catch (error) {
console.error('SocialShare 初始化失败:', error)
}
} else {
console.error('SocialShare 对象不存在,检查库是否正确加载')
// 检查是否有其他全局变量
console.log('可用全局变量:', Object.keys(window).filter(key => key.toLowerCase().includes('social')))
console.error('SocialShare 对象不存在,库可能未正确加载')
}
}, 500)
}, 300)
}
}
@@ -159,34 +153,114 @@ const loadSocialShare = () => {
}
link.onerror = () => {
console.error('social-share.css 加载失败')
// 如果CDN加载失败尝试备用链接
const backupLink = document.createElement('link')
backupLink.rel = 'stylesheet'
backupLink.href = 'https://unpkg.com/social-share.js@1.0.16/dist/css/share.min.css'
backupLink.onload = () => {
console.log('备用 social-share.css 加载完成')
}
backupLink.onerror = () => {
console.error('备用 social-share.css 也加载失败')
}
document.head.appendChild(backupLink)
}
document.head.appendChild(link)
}
if (!window.SocialShare) {
if (!window.socialShare) {
console.log('开始加载 social-share.js...')
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/social-share.js@1.0.16/dist/js/social-share.min.js'
script.onload = () => {
console.log('social-share.js 加载完成,检查全局对象:', window.SocialShare)
console.log('social-share.js 加载完成,检查全局对象:', window.socialShare)
// 加载完成后初始化
nextTick(() => {
setTimeout(() => {
initSocialShare()
}, 200) // 增加等待时间确保CSS和JS都完全加载
}, 300) // 稍微增加等待时间确保CSS和JS都完全加载
})
}
script.onerror = () => {
console.error('social-share.js 加载失败')
// 如果CDN加载失败尝试备用链接
const backupScript = document.createElement('script')
backupScript.src = 'https://unpkg.com/social-share.js@1.0.16/dist/js/social-share.min.js'
backupScript.onload = () => {
console.log('备用 social-share.js 加载完成,检查全局对象:', window.socialShare)
nextTick(() => {
setTimeout(() => {
initSocialShare()
}, 300)
})
}
backupScript.onerror = () => {
console.error('备用 social-share.js 也加载失败')
// 如果无法加载外部库,创建基本分享按钮
createFallbackShareButtons()
}
document.head.appendChild(backupScript)
}
document.head.appendChild(script)
} else {
// 如果已经加载过,直接初始化
console.log('SocialShare 已存在,直接初始化')
console.log('socialShare 已存在,直接初始化')
initSocialShare()
}
}
// 创建备选分享按钮当social-share.js无法加载时使用
const createFallbackShareButtons = () => {
if (typeof window === 'undefined' || !socialShareElement.value) return
// 清空容器
socialShareElement.value.innerHTML = ''
// 创建包含基本分享功能的按钮
const shareContainer = document.createElement('div')
shareContainer.className = 'fallback-share-buttons'
const fullUrl = getFullUrl()
const encodedUrl = encodeURIComponent(fullUrl)
const encodedTitle = encodeURIComponent(shareTitle.value)
const encodedDesc = encodeURIComponent(shareDescription.value)
// Facebook分享链接
const facebookLink = document.createElement('a')
facebookLink.href = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}&t=${encodedTitle}`
facebookLink.target = '_blank'
facebookLink.innerHTML = '<i class="fa fa-facebook" style="font-size: 20px; color: #1877f2;"></i>'
facebookLink.style.display = 'inline-block'
facebookLink.style.margin = '0 3px'
facebookLink.title = '分享到Facebook'
// Twitter分享链接
const twitterLink = document.createElement('a')
twitterLink.href = `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`
twitterLink.target = '_blank'
twitterLink.innerHTML = '<i class="fa fa-twitter" style="font-size: 20px; color: #1da1f2;"></i>'
twitterLink.style.display = 'inline-block'
twitterLink.style.margin = '0 3px'
twitterLink.title = '分享到Twitter'
// Reddit分享链接
const redditLink = document.createElement('a')
redditLink.href = `https://www.reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`
redditLink.target = '_blank'
redditLink.innerHTML = '<i class="fa fa-reddit" style="font-size: 20px; color: #ff4500;"></i>'
redditLink.style.display = 'inline-block'
redditLink.style.margin = '0 3px'
redditLink.title = '分享到Reddit'
// 添加到容器
shareContainer.appendChild(facebookLink)
shareContainer.appendChild(twitterLink)
shareContainer.appendChild(redditLink)
socialShareElement.value.appendChild(shareContainer)
}
// 组件挂载时直接初始化 - 仅在客户端执行
onMounted(() => {
if (typeof window !== 'undefined') {
@@ -237,9 +311,33 @@ onMounted(() => {
filter: brightness(0.9);
}
/* 备选分享按钮样式 */
.fallback-share-buttons {
display: flex;
gap: 6px;
align-items: center;
}
.fallback-share-buttons a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
transition: all 0.2s ease;
text-decoration: none;
}
.fallback-share-buttons a:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* 响应式设计 */
@media (max-width: 640px) {
.social-share-wrapper .social-share-icon {
.social-share-wrapper .social-share-icon,
.fallback-share-buttons a {
width: 26px !important;
height: 26px !important;
}

View File

@@ -63,7 +63,30 @@ export const useResourceApi = () => {
const getResourceLink = (id: number) => useApiFetch(`/resources/${id}/link`).then(parseApiResponse)
// 新增:获取相关资源
const getRelatedResources = (params?: any) => useApiFetch('/resources/related', { params }).then(parseApiResponse)
return { getResources, getHotResources, getResource, getResourcesByKey, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink, getRelatedResources }
// 新增:提交举报
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,
submitReport, submitCopyrightClaim,
getReports, getReport, updateReport, deleteReport, getReportsRaw,
getCopyrightClaims, getCopyrightClaim, updateCopyrightClaim, deleteCopyrightClaim
}
}
export const useAuthApi = () => {
@@ -373,4 +396,29 @@ export const useWechatApi = () => {
getBotStatus,
uploadVerifyFile
}
}
// 统一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()
}
}

View File

@@ -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

31
web/middleware/admin.ts Normal file
View 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 - 用户已认证且为管理员,继续访问')
})

View File

@@ -0,0 +1,510 @@
<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="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="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>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100 break-all">{{ selectedClaim.proof_files }}</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(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: 80,
render: (row: any) => {
return h('span', { class: 'font-medium' }, row.id)
}
},
{
title: '资源Key',
key: 'resource_key',
width: 180,
render: (row: any) => {
return h('n-tag', {
type: 'info',
size: 'small',
class: 'truncate max-w-xs'
}, { default: () => row.resource_key })
}
},
{
title: '申述人身份',
key: 'identity',
width: 120,
render: (row: any) => {
return h('span', null, getIdentityLabel(row.identity))
}
},
{
title: '证明类型',
key: 'proof_type',
width: 140,
render: (row: any) => {
return h('span', null, getProofTypeLabel(row.proof_type))
}
},
{
title: '申述人姓名',
key: 'claimant_name',
width: 120,
render: (row: any) => {
return h('span', null, row.claimant_name)
}
},
{
title: '联系方式',
key: 'contact_info',
width: 150,
render: (row: any) => {
return h('span', null, row.contact_info)
}
},
{
title: '状态',
key: 'status',
width: 120,
render: (row: any) => {
const type = getStatusType(row.status)
return h('n-tag', {
type: type,
size: 'small',
bordered: false
}, { default: () => getStatusLabel(row.status) })
}
},
{
title: '提交时间',
key: 'created_at',
width: 180,
render: (row: any) => {
return h('span', null, formatDateTime(row.created_at))
}
},
{
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: () => viewClaim(row)
}, [
h('i', { class: 'fas fa-eye mr-1 text-xs' }),
'查看'
])
]
if (row.status === 'pending') {
buttons.push(
h('button', {
class: 'px-2 py-1 text-xs bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/20 dark:text-green-400 rounded transition-colors mr-1',
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',
onClick: () => updateClaimStatus(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
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)
claims.value = response.items || []
pagination.value.total = response.total || 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')
}
// 初始化数据
onMounted(() => {
fetchClaims()
})
</script>

559
web/pages/admin/reports.vue Normal file
View 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>

View File

@@ -137,7 +137,7 @@
<ShareButtons
:title="mainResource?.title"
:description="mainResource?.description"
:url="`/r/${mainResource?.key}`"
:url="getResourceUrl"
:tags="mainResource?.tags?.map(tag => tag.name)"
/>
</div>
@@ -494,6 +494,27 @@ const mainResource = computed(() => {
return resources && resources.length > 0 ? resources[0] : null
})
// 生成完整的资源URL
const getResourceUrl = computed(() => {
const config = useRuntimeConfig()
const key = mainResource.value?.key
if (!key) return ''
// 优先使用配置中的站点URL如果未设置则使用当前页面的origin
let siteUrl = config.public.siteUrl
if (!siteUrl || siteUrl === 'https://yourdomain.com') {
// 在客户端使用当前页面的origin
if (typeof window !== 'undefined') {
siteUrl = window.location.origin
} else {
// 在服务端渲染时,使用默认值(这应该在部署时被环境变量覆盖)
siteUrl = process.env.NUXT_PUBLIC_SITE_URL || 'https://yourdomain.com'
}
}
return `${siteUrl}/r/${key}`
})
// 服务端相关资源处理(去重)
const serverRelatedResources = computed(() => {
const resources = Array.isArray(relatedResourcesData.value?.data) ? relatedResourcesData.value.data : []
@@ -690,11 +711,15 @@ const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
// 显示复制成功提示
const notification = useNotification()
notification.success({
content: '已复制到剪贴板',
duration: 2000
})
if (process.client) {
const notification = useNotification()
if (notification) {
notification.success({
content: '已复制到剪贴板',
duration: 2000
})
}
}
} catch (error) {
console.error('复制失败:', error)
}
@@ -703,21 +728,29 @@ const copyToClipboard = async (text: string) => {
// 处理举报提交
const handleReportSubmitted = () => {
showReportModal.value = false
const notification = useNotification()
notification.success({
content: '举报已提交,感谢您的反馈',
duration: 3000
})
if (process.client) {
const notification = useNotification()
if (notification) {
notification.success({
content: '举报已提交,感谢您的反馈',
duration: 3000
})
}
}
}
// 处理版权申述提交
const handleCopyrightSubmitted = () => {
showCopyrightModal.value = false
const notification = useNotification()
notification.success({
content: '版权申述已提交,我们会尽快处理',
duration: 3000
})
if (process.client) {
const notification = useNotification()
if (notification) {
notification.success({
content: '版权申述已提交,我们会尽快处理',
duration: 3000
})
}
}
}
// 获取相关资源(客户端更新,用于交互优化)