mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-24 19:12:52 +08:00
add report
This commit is contained in:
@@ -109,6 +109,8 @@ func InitDB() error {
|
||||
&entity.APIAccessLog{},
|
||||
&entity.APIAccessLogStats{},
|
||||
&entity.APIAccessLogSummary{},
|
||||
&entity.Report{},
|
||||
&entity.CopyrightClaim{},
|
||||
)
|
||||
if err != nil {
|
||||
utils.Fatal("数据库迁移失败: %v", err)
|
||||
|
||||
40
db/converter/copyright_claim_converter.go
Normal file
40
db/converter/copyright_claim_converter.go
Normal 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
|
||||
}
|
||||
37
db/converter/report_converter.go
Normal file
37
db/converter/report_converter.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
45
db/dto/copyright_claim.go
Normal 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"`
|
||||
}
|
||||
39
db/dto/report.go
Normal file
39
db/dto/report.go
Normal file
@@ -0,0 +1,39 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
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"
|
||||
}
|
||||
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
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
|
||||
|
||||
278
handlers/copyright_claim_handler.go
Normal file
278
handlers/copyright_claim_handler.go
Normal 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) // 获取资源版权申述列表
|
||||
}
|
||||
}
|
||||
276
handlers/report_handler.go
Normal file
276
handlers/report_handler.go
Normal file
@@ -0,0 +1,276 @@
|
||||
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
|
||||
validate *validator.Validate
|
||||
}
|
||||
|
||||
func NewReportHandler(reportRepo repo.ReportRepository) *ReportHandler {
|
||||
return &ReportHandler{
|
||||
reportRepo: reportRepo,
|
||||
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
|
||||
}
|
||||
|
||||
PageResponse(c, converter.ReportsToResponse(reports), total, req.Page, req.PageSize)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
handler := NewReportHandler(reportRepo)
|
||||
|
||||
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
19
main.go
@@ -211,6 +211,10 @@ func main() {
|
||||
// 创建OG图片处理器
|
||||
ogImageHandler := handlers.NewOGImageHandler()
|
||||
|
||||
// 创建举报和版权申述处理器
|
||||
reportHandler := handlers.NewReportHandler(repoManager.ReportRepository)
|
||||
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)
|
||||
}
|
||||
|
||||
// 设置监控系统
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,29 @@ 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 getReports = (params?: any) => useApiFetch('/reports', { 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,
|
||||
getCopyrightClaims, getCopyrightClaim, updateCopyrightClaim, deleteCopyrightClaim
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthApi = () => {
|
||||
@@ -373,4 +395,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()
|
||||
}
|
||||
}
|
||||
@@ -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
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 - 用户已认证且为管理员,继续访问')
|
||||
})
|
||||
510
web/pages/admin/copyright-claims.vue
Normal file
510
web/pages/admin/copyright-claims.vue
Normal 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>
|
||||
483
web/pages/admin/reports.vue
Normal file
483
web/pages/admin/reports.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<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: 80,
|
||||
render: (row: any) => {
|
||||
return h('span', { class: 'font-medium' }, row.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '资源Key',
|
||||
key: 'resource_key',
|
||||
width: 200,
|
||||
render: (row: any) => {
|
||||
return h('n-tag', {
|
||||
type: 'info',
|
||||
size: 'small',
|
||||
class: 'truncate max-w-xs'
|
||||
}, { default: () => row.resource_key })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '举报原因',
|
||||
key: 'reason',
|
||||
width: 150,
|
||||
render: (row: any) => {
|
||||
return h('span', null, getReasonLabel(row.reason))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
key: 'description',
|
||||
width: 250,
|
||||
render: (row: any) => {
|
||||
return h('span', {
|
||||
class: 'line-clamp-2 text-sm',
|
||||
title: row.description
|
||||
}, row.description)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '联系方式',
|
||||
key: 'contact',
|
||||
width: 150,
|
||||
render: (row: any) => {
|
||||
return h('span', null, row.contact || '未提供')
|
||||
}
|
||||
},
|
||||
{
|
||||
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: () => 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
|
||||
|
||||
const response = await resourceApi.getReports(params)
|
||||
reports.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
|
||||
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')
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
onMounted(() => {
|
||||
fetchReports()
|
||||
})
|
||||
</script>
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取相关资源(客户端更新,用于交互优化)
|
||||
|
||||
Reference in New Issue
Block a user