feat: 完善养成管理后台功能
This commit is contained in:
@@ -19,6 +19,21 @@ type ReviewReq struct {
|
||||
sysin.CultivateContentReviewInp
|
||||
}
|
||||
type ReviewRes struct{}
|
||||
type BatchReviewReq struct {
|
||||
g.Meta `path:"/cultivate/content/batch-review" method:"post" tags:"养成管理" summary:"批量审核或下架动态评论"`
|
||||
sysin.CultivateContentBatchReviewInp
|
||||
}
|
||||
type BatchReviewRes struct {
|
||||
*sysin.CultivateContentBatchReviewModel
|
||||
}
|
||||
|
||||
type ViewReq struct {
|
||||
g.Meta `path:"/cultivate/content/view" method:"get" tags:"养成管理" summary:"获取内容审核详情"`
|
||||
sysin.CultivateContentViewInp
|
||||
}
|
||||
type ViewRes struct {
|
||||
*sysin.CultivateContentViewModel
|
||||
}
|
||||
type ReportListReq struct {
|
||||
g.Meta `path:"/cultivate/reports/list" method:"get" tags:"养成管理" summary:"获取举报列表"`
|
||||
sysin.CultivateReportListInp
|
||||
@@ -32,6 +47,13 @@ type ReportHandleReq struct {
|
||||
sysin.CultivateReportHandleInp
|
||||
}
|
||||
type ReportHandleRes struct{}
|
||||
type ReportViewReq struct {
|
||||
g.Meta `path:"/cultivate/reports/view" method:"get" tags:"养成管理" summary:"获取举报详情"`
|
||||
sysin.CultivateReportViewInp
|
||||
}
|
||||
type ReportViewRes struct {
|
||||
*sysin.CultivateReportViewModel
|
||||
}
|
||||
type LogListReq struct {
|
||||
g.Meta `path:"/cultivate/logs/list" method:"get" tags:"养成管理" summary:"获取养成管理操作日志"`
|
||||
sysin.CultivateOperationLogListInp
|
||||
@@ -40,3 +62,8 @@ type LogListRes struct {
|
||||
form.PageRes
|
||||
List []*sysin.CultivateOperationLogListModel `json:"list"`
|
||||
}
|
||||
type DashboardReq struct {
|
||||
g.Meta `path:"/cultivate/dashboard" method:"get" tags:"养成管理" summary:"获取养成管理统计"`
|
||||
sysin.CultivateDashboardInp
|
||||
}
|
||||
type DashboardRes struct{ *sysin.CultivateDashboardModel }
|
||||
|
||||
@@ -11,6 +11,14 @@ var CultivateContent = cCultivateContent{}
|
||||
|
||||
type cCultivateContent struct{}
|
||||
|
||||
func (*cCultivateContent) Dashboard(ctx context.Context, req *cultivatecontent.DashboardReq) (res *cultivatecontent.DashboardRes, err error) {
|
||||
data, err := service.SysCultivateContent().Dashboard(ctx, &req.CultivateDashboardInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cultivatecontent.DashboardRes{CultivateDashboardModel: data}, nil
|
||||
}
|
||||
|
||||
func (*cCultivateContent) List(ctx context.Context, req *cultivatecontent.ListReq) (res *cultivatecontent.ListRes, err error) {
|
||||
list, total, err := service.SysCultivateContent().List(ctx, &req.CultivateContentListInp)
|
||||
if err != nil {
|
||||
@@ -23,10 +31,24 @@ func (*cCultivateContent) List(ctx context.Context, req *cultivatecontent.ListRe
|
||||
res.PageRes.Pack(req, total)
|
||||
return
|
||||
}
|
||||
func (*cCultivateContent) View(ctx context.Context, req *cultivatecontent.ViewReq) (res *cultivatecontent.ViewRes, err error) {
|
||||
data, err := service.SysCultivateContent().View(ctx, &req.CultivateContentViewInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cultivatecontent.ViewRes{CultivateContentViewModel: data}, nil
|
||||
}
|
||||
func (*cCultivateContent) Review(ctx context.Context, req *cultivatecontent.ReviewReq) (res *cultivatecontent.ReviewRes, err error) {
|
||||
err = service.SysCultivateContent().Review(ctx, &req.CultivateContentReviewInp)
|
||||
return
|
||||
}
|
||||
func (*cCultivateContent) BatchReview(ctx context.Context, req *cultivatecontent.BatchReviewReq) (res *cultivatecontent.BatchReviewRes, err error) {
|
||||
data, err := service.SysCultivateContent().BatchReview(ctx, &req.CultivateContentBatchReviewInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cultivatecontent.BatchReviewRes{CultivateContentBatchReviewModel: data}, nil
|
||||
}
|
||||
func (*cCultivateContent) ReportList(ctx context.Context, req *cultivatecontent.ReportListReq) (res *cultivatecontent.ReportListRes, err error) {
|
||||
list, total, err := service.SysCultivateContent().ReportList(ctx, &req.CultivateReportListInp)
|
||||
if err != nil {
|
||||
@@ -43,6 +65,13 @@ func (*cCultivateContent) ReportHandle(ctx context.Context, req *cultivateconten
|
||||
err = service.SysCultivateContent().ReportHandle(ctx, &req.CultivateReportHandleInp)
|
||||
return
|
||||
}
|
||||
func (*cCultivateContent) ReportView(ctx context.Context, req *cultivatecontent.ReportViewReq) (res *cultivatecontent.ReportViewRes, err error) {
|
||||
data, err := service.SysCultivateContent().ReportView(ctx, &req.CultivateReportViewInp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cultivatecontent.ReportViewRes{CultivateReportViewModel: data}, nil
|
||||
}
|
||||
func (*cCultivateContent) LogList(ctx context.Context, req *cultivatecontent.LogListReq) (res *cultivatecontent.LogListRes, err error) {
|
||||
list, total, err := service.SysCultivateContent().LogList(ctx, &req.CultivateOperationLogListInp)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,8 +15,10 @@ import (
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"hotgo/internal/consts"
|
||||
"hotgo/internal/dao"
|
||||
"hotgo/internal/library/contexts"
|
||||
"hotgo/internal/library/imadmin"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"hotgo/internal/model/entity"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
@@ -26,6 +30,46 @@ type sSysCultivateContent struct{}
|
||||
func NewSysCultivateContent() *sSysCultivateContent { return &sSysCultivateContent{} }
|
||||
func init() { service.RegisterSysCultivateContent(NewSysCultivateContent()) }
|
||||
|
||||
func (s *sSysCultivateContent) Dashboard(ctx context.Context, _ *sysin.CultivateDashboardInp) (*sysin.CultivateDashboardModel, error) {
|
||||
tenantID := cultivateTenantID(ctx)
|
||||
out := new(sysin.CultivateDashboardModel)
|
||||
db := g.DB("cultivate")
|
||||
queries := []struct {
|
||||
target *int64
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{&out.PlantCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=?", []any{tenantID}},
|
||||
{&out.GrowingCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='growing'", []any{tenantID}},
|
||||
{&out.BloomCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='bloomed'", []any{tenantID}},
|
||||
{&out.HarvestedCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='harvested'", []any{tenantID}},
|
||||
{&out.RemovedCount, "SELECT COUNT(*) FROM cv_user_flower WHERE tenant_id=? AND status='removed'", []any{tenantID}},
|
||||
{&out.Active7d, "SELECT COUNT(DISTINCT user_id) FROM cv_garden_active_day WHERE tenant_id=? AND biz_date>=DATE_SUB(CURDATE(),INTERVAL 6 DAY)", []any{tenantID}},
|
||||
{&out.Active30d, "SELECT COUNT(DISTINCT user_id) FROM cv_garden_active_day WHERE tenant_id=? AND biz_date>=DATE_SUB(CURDATE(),INTERVAL 29 DAY)", []any{tenantID}},
|
||||
{&out.PostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published'", []any{tenantID}},
|
||||
{&out.PublicPostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published' AND visibility='public'", []any{tenantID}},
|
||||
{&out.FriendsPostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published' AND visibility='friends'", []any{tenantID}},
|
||||
{&out.PrivatePostCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='published' AND visibility='private'", []any{tenantID}},
|
||||
{&out.CommentCount, "SELECT COUNT(*) FROM cv_feed_comment WHERE tenant_id=? AND status='published'", []any{tenantID}},
|
||||
{&out.LikeCount, "SELECT COALESCE(SUM(like_count),0) FROM cv_feed_post WHERE tenant_id=?", []any{tenantID}},
|
||||
{&out.TakenDownCount, "SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='taken_down'", []any{tenantID}},
|
||||
{&out.PendingAuditCount, "SELECT (SELECT COUNT(*) FROM cv_feed_post WHERE tenant_id=? AND status='reviewing')+(SELECT COUNT(*) FROM cv_feed_comment WHERE tenant_id=? AND status='reviewing')", []any{tenantID, tenantID}},
|
||||
{&out.ApprovedAuditCount, "SELECT COUNT(*) FROM cv_content_audit_log WHERE tenant_id=? AND status_after='published'", []any{tenantID}},
|
||||
{&out.RejectedAuditCount, "SELECT COUNT(*) FROM cv_content_audit_log WHERE tenant_id=? AND status_after='rejected'", []any{tenantID}},
|
||||
{&out.PendingReportCount, "SELECT COUNT(*) FROM cv_feed_report WHERE tenant_id=? AND status='submitted'", []any{tenantID}},
|
||||
{&out.ProcessingReportCount, "SELECT COUNT(*) FROM cv_feed_report WHERE tenant_id=? AND status='processing'", []any{tenantID}},
|
||||
{&out.ProcessedReportCount, "SELECT COUNT(*) FROM cv_feed_report WHERE tenant_id=? AND status IN ('resolved','rejected')", []any{tenantID}},
|
||||
}
|
||||
for _, q := range queries {
|
||||
value, err := db.GetValue(ctx, q.sql, q.args...)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取养成统计失败")
|
||||
}
|
||||
*q.target = value.Int64()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) List(ctx context.Context, in *sysin.CultivateContentListInp) (list []*sysin.CultivateContentListModel, totalCount int, err error) {
|
||||
tenantID := cultivateTenantID(ctx)
|
||||
if in.ObjectType == "feed_comment" {
|
||||
@@ -80,14 +124,108 @@ func (s *sSysCultivateContent) List(ctx context.Context, in *sysin.CultivateCont
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) View(ctx context.Context, in *sysin.CultivateContentViewInp) (*sysin.CultivateContentViewModel, error) {
|
||||
tenantID := cultivateTenantID(ctx)
|
||||
base := &sysin.CultivateContentViewModel{Media: []*sysin.CultivateMediaModel{}, Audits: []*sysin.CultivateAuditModel{}, RelatedReports: []*sysin.CultivateReportListModel{}}
|
||||
if in.ObjectType == "feed_comment" {
|
||||
c := dao.FeedComment.Columns()
|
||||
var row entity.FeedComment
|
||||
if err := dao.FeedComment.Ctx(ctx).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Id == 0 {
|
||||
return nil, gerror.New("评论不存在")
|
||||
}
|
||||
base.CultivateContentListModel = sysin.CultivateContentListModel{Id: in.Id, ObjectType: in.ObjectType, PostId: strconv.FormatUint(row.PostId, 10), AuthorId: strconv.FormatUint(row.AuthorId, 10), ContentText: row.ContentText, Status: row.Status, AuditStatus: row.AuditStatus, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
} else {
|
||||
c := dao.FeedPost.Columns()
|
||||
var row entity.FeedPost
|
||||
if err := dao.FeedPost.Ctx(ctx).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Id == 0 {
|
||||
return nil, gerror.New("动态不存在")
|
||||
}
|
||||
base.CultivateContentListModel = sysin.CultivateContentListModel{Id: in.Id, ObjectType: in.ObjectType, AuthorId: strconv.FormatUint(row.AuthorId, 10), ContentText: row.ContentText, FlowerCode: row.FlowerCode, Visibility: row.Visibility, Status: row.Status, AuditStatus: row.AuditStatus, RejectReason: row.RejectReasonCode, LikeCount: row.LikeCount, CommentCount: row.CommentCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
mc := dao.FeedMedia.Columns()
|
||||
var media []*entity.FeedMedia
|
||||
if err := dao.FeedMedia.Ctx(ctx).Where(g.Map{mc.TenantId: tenantID, mc.PostId: in.ObjectId}).OrderAsc(mc.SortNo).Scan(&media); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range media {
|
||||
base.Media = append(base.Media, &sysin.CultivateMediaModel{Id: strconv.FormatUint(m.Id, 10), FileId: m.FileId, ThumbnailFileId: m.ThumbnailFileId, MediaType: m.MediaType, AuditStatus: m.AuditStatus, SortNo: m.SortNo})
|
||||
}
|
||||
}
|
||||
ac := dao.ContentAuditLog.Columns()
|
||||
var audits []*entity.ContentAuditLog
|
||||
if err := dao.ContentAuditLog.Ctx(ctx).Where(g.Map{ac.TenantId: tenantID, ac.ObjectType: in.ObjectType, ac.ObjectId: in.ObjectId}).OrderDesc(ac.Id).Scan(&audits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range audits {
|
||||
base.Audits = append(base.Audits, &sysin.CultivateAuditModel{Id: strconv.FormatUint(a.Id, 10), AuditSource: a.AuditSource, StatusBefore: a.StatusBefore, StatusAfter: a.StatusAfter, ResultCode: a.ResultCode, RiskLabels: jsonValue(a.RiskLabels), OperatorId: optionalUint(a.OperatorId), OccurredAt: a.OccurredAt})
|
||||
}
|
||||
rc := dao.FeedReport.Columns()
|
||||
var reports []*entity.FeedReport
|
||||
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{rc.TenantId: tenantID, rc.ObjectType: in.ObjectType, rc.ObjectId: in.Id}).OrderDesc(rc.Id).Scan(&reports); err == nil {
|
||||
for _, r := range reports {
|
||||
base.RelatedReports = append(base.RelatedReports, reportModel(r))
|
||||
}
|
||||
}
|
||||
profiles, _ := cultivateUserProfiles(ctx, tenantID, []string{base.AuthorId})
|
||||
if p := profiles[base.AuthorId]; p != nil {
|
||||
base.AuthorNickname = p.Nickname
|
||||
base.AuthorAvatarFileId = p.AvatarFileId
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) Review(ctx context.Context, in *sysin.CultivateContentReviewInp) error {
|
||||
tenantID, operatorID := cultivateTenantID(ctx), contexts.GetUserId(ctx)
|
||||
return dao.FeedPost.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
|
||||
var authorID uint64
|
||||
err := dao.FeedPost.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
|
||||
if in.ObjectType == "feed_comment" {
|
||||
var row entity.FeedComment
|
||||
c := dao.FeedComment.Columns()
|
||||
if e := tx.Model(dao.FeedComment.Table()).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); e != nil {
|
||||
return e
|
||||
}
|
||||
authorID = row.AuthorId
|
||||
return s.reviewComment(txCtx, tx, tenantID, operatorID, in)
|
||||
}
|
||||
var row entity.FeedPost
|
||||
c := dao.FeedPost.Columns()
|
||||
if e := tx.Model(dao.FeedPost.Table()).Where(g.Map{c.Id: in.ObjectId, c.TenantId: tenantID}).Scan(&row); e != nil {
|
||||
return e
|
||||
}
|
||||
authorID = row.AuthorId
|
||||
return s.reviewPost(txCtx, tx, tenantID, operatorID, in)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if authorID > 0 {
|
||||
publishCultivateNotice(ctx, tenantID, authorID, fmt.Sprintf("admin.content.%s.%s.%s", in.ObjectType, in.Id, in.Action), "cultivate.content.reviewed", "内容审核结果", cultivateReviewSummary(in.Action), map[string]any{"type": in.ObjectType, "object_type": in.ObjectType, "object_id": in.Id})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) BatchReview(ctx context.Context, in *sysin.CultivateContentBatchReviewInp) (*sysin.CultivateContentBatchReviewModel, error) {
|
||||
result := &sysin.CultivateContentBatchReviewModel{Failures: make(map[string]string)}
|
||||
for _, id := range in.Ids {
|
||||
item := &sysin.CultivateContentReviewInp{ObjectType: in.ObjectType, Id: id, Action: in.Action, Reason: in.Reason}
|
||||
if err := item.Filter(ctx); err != nil {
|
||||
result.FailedCount++
|
||||
result.Failures[id] = err.Error()
|
||||
continue
|
||||
}
|
||||
if err := s.Review(ctx, item); err != nil {
|
||||
result.FailedCount++
|
||||
result.Failures[id] = err.Error()
|
||||
continue
|
||||
}
|
||||
result.SuccessCount++
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) reviewPost(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, in *sysin.CultivateContentReviewInp) error {
|
||||
@@ -223,7 +361,8 @@ func writeContentAudit(ctx context.Context, tx gdb.TX, tenantID, operatorID int6
|
||||
return err
|
||||
}
|
||||
columns := dao.ContentAuditLog.Columns()
|
||||
_, err = tx.Model(dao.ContentAuditLog.Table()).Data(g.Map{columns.Id: id, columns.TenantId: tenantID, columns.ObjectType: objectType, columns.ObjectId: objectID, columns.AuditSource: "manual", columns.StatusBefore: before, columns.StatusAfter: after, columns.ResultCode: result, columns.RiskLabels: "[]", columns.OperatorId: operatorID, columns.OccurredAt: now, columns.CreatedAt: now}).Insert()
|
||||
requestID := fmt.Sprintf("manual-%s-%d-%d", objectType, objectID, id)
|
||||
_, err = tx.Model(dao.ContentAuditLog.Table()).Data(g.Map{columns.Id: id, columns.TenantId: tenantID, columns.ObjectType: objectType, columns.ObjectId: objectID, columns.AuditRequestId: requestID, columns.AuditSource: "manual", columns.StatusBefore: before, columns.StatusAfter: after, columns.ResultCode: result, columns.RiskLabels: "[]", columns.OperatorId: operatorID, columns.OccurredAt: now, columns.CreatedAt: now}).Insert()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -248,14 +387,75 @@ func (s *sSysCultivateContent) ReportList(ctx context.Context, in *sysin.Cultiva
|
||||
}
|
||||
list = make([]*sysin.CultivateReportListModel, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
list = append(list, &sysin.CultivateReportListModel{Id: strconv.FormatUint(row.Id, 10), ReporterId: strconv.FormatUint(row.ReporterId, 10), ReportedUserId: strconv.FormatUint(row.ReportedUserId, 10), ObjectType: row.ObjectType, ObjectId: row.ObjectId, ReasonCode: row.ReasonCode, Description: row.Description, EvidenceFileIds: row.EvidenceFileIds, SourcePage: row.SourcePage, Status: row.Status, HandlerId: optionalUint(row.HandlerId), HandleResult: row.HandleResult, HandledAt: row.HandledAt, CreatedAt: row.CreatedAt})
|
||||
list = append(list, reportModel(row))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func reportModel(row *entity.FeedReport) *sysin.CultivateReportListModel {
|
||||
return &sysin.CultivateReportListModel{Id: strconv.FormatUint(row.Id, 10), ReporterId: strconv.FormatUint(row.ReporterId, 10), ReportedUserId: strconv.FormatUint(row.ReportedUserId, 10), ObjectType: row.ObjectType, ObjectId: row.ObjectId, ReasonCode: row.ReasonCode, Description: row.Description, EvidenceFileIds: row.EvidenceFileIds, SourcePage: row.SourcePage, Status: row.Status, HandlerId: optionalUint(row.HandlerId), HandleResult: row.HandleResult, HandledAt: row.HandledAt, CreatedAt: row.CreatedAt}
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) ReportView(ctx context.Context, in *sysin.CultivateReportViewInp) (*sysin.CultivateReportViewModel, error) {
|
||||
tenantID := cultivateTenantID(ctx)
|
||||
c := dao.FeedReport.Columns()
|
||||
var row entity.FeedReport
|
||||
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{c.Id: in.ReportId, c.TenantId: tenantID}).Scan(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Id == 0 {
|
||||
return nil, gerror.New("举报记录不存在")
|
||||
}
|
||||
out := &sysin.CultivateReportViewModel{CultivateReportListModel: *reportModel(&row), History: []*sysin.CultivateReportListModel{}}
|
||||
profiles, _ := cultivateUserProfiles(ctx, tenantID, []string{out.ReporterId, out.ReportedUserId})
|
||||
if p := profiles[out.ReporterId]; p != nil {
|
||||
out.ReporterNickname = p.Nickname
|
||||
}
|
||||
if p := profiles[out.ReportedUserId]; p != nil {
|
||||
out.ReportedUserNickname = p.Nickname
|
||||
}
|
||||
var history []*entity.FeedReport
|
||||
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{c.TenantId: tenantID, c.ReportedUserId: row.ReportedUserId}).OrderDesc(c.Id).Limit(20).Scan(&history); err == nil {
|
||||
for _, item := range history {
|
||||
out.History = append(out.History, reportModel(item))
|
||||
}
|
||||
}
|
||||
if row.ObjectType == "feed_post" || row.ObjectType == "feed_comment" {
|
||||
view, err := s.View(ctx, &sysin.CultivateContentViewInp{ObjectType: row.ObjectType, Id: row.ObjectId, ObjectId: mustUint(row.ObjectId)})
|
||||
if err == nil {
|
||||
out.TargetContent = &view.CultivateContentListModel
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) ReportHandle(ctx context.Context, in *sysin.CultivateReportHandleInp) error {
|
||||
tenantID, operatorID := cultivateTenantID(ctx), contexts.GetUserId(ctx)
|
||||
return dao.FeedReport.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
|
||||
columns := dao.FeedReport.Columns()
|
||||
var report entity.FeedReport
|
||||
if err := dao.FeedReport.Ctx(ctx).Where(g.Map{columns.Id: in.ReportId, columns.TenantId: tenantID}).Scan(&report); err != nil {
|
||||
return err
|
||||
}
|
||||
if report.Id == 0 {
|
||||
return gerror.New("举报记录不存在")
|
||||
}
|
||||
if report.Status == "resolved" || report.Status == "rejected" {
|
||||
return gerror.New("举报已办结,不能重复处理")
|
||||
}
|
||||
if in.ContentAction == "take_down" {
|
||||
if err := validateReportedContentCanTakeDown(ctx, tenantID, &report); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if in.UserAction != "none" && report.ReportedUserId == 0 {
|
||||
return gerror.New("被举报用户不存在,无法执行用户处置")
|
||||
}
|
||||
if in.UserAction != "none" {
|
||||
if err := governCultivateUser(ctx, tenantID, report.ReportedUserId, in.UserAction, in.Reason); err != nil {
|
||||
return gerror.Wrap(err, "用户联动处置失败")
|
||||
}
|
||||
}
|
||||
err := dao.FeedReport.Transaction(ctx, func(txCtx context.Context, tx gdb.TX) error {
|
||||
columns := dao.FeedReport.Columns()
|
||||
var before entity.FeedReport
|
||||
err := tx.Model(dao.FeedReport.Table()).Where(g.Map{columns.Id: in.ReportId, columns.TenantId: tenantID}).LockUpdate().Scan(&before)
|
||||
@@ -277,11 +477,30 @@ func (s *sSysCultivateContent) ReportHandle(ctx context.Context, in *sysin.Culti
|
||||
if status == "resolved" || status == "rejected" {
|
||||
data[columns.HandledAt] = now
|
||||
}
|
||||
if in.ContentAction == "take_down" {
|
||||
if err = takeDownReportedContent(txCtx, tx, tenantID, operatorID, &before, in.Reason, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.Model(dao.FeedReport.Table()).Where(columns.Id, before.Id).Data(data).Update(); err != nil {
|
||||
return gerror.Wrap(err, "更新举报处理状态失败")
|
||||
}
|
||||
return writeCultivateOperation(txCtx, tx, tenantID, operatorID, "report."+in.Action, "feed_report", in.Id, before, data, in.HandleResult, now)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Action == "resolve" || in.Action == "reject" {
|
||||
summary := "你的举报已处理,感谢你帮助维护社区环境"
|
||||
if in.Action == "reject" {
|
||||
summary = "你的举报已完成核查,暂未发现违规"
|
||||
}
|
||||
publishCultivateNotice(ctx, tenantID, report.ReporterId, "admin.report."+in.Id, "cultivate.report.handled", "举报处理结果", summary, map[string]any{"type": "report", "report_id": in.Id})
|
||||
}
|
||||
if in.ContentAction != "none" || in.UserAction != "none" {
|
||||
publishCultivateNotice(ctx, tenantID, report.ReportedUserId, "admin.report.target."+in.Id, "cultivate.governance.applied", "社区治理通知", "你的内容或账号已根据社区规则进行处置", map[string]any{"type": "report", "report_id": in.Id})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateContent) LogList(ctx context.Context, in *sysin.CultivateOperationLogListInp) (list []*sysin.CultivateOperationLogListModel, totalCount int, err error) {
|
||||
@@ -296,6 +515,12 @@ func (s *sSysCultivateContent) LogList(ctx context.Context, in *sysin.CultivateO
|
||||
if in.TargetType != "" {
|
||||
mod = mod.Where(columns.TargetType, in.TargetType)
|
||||
}
|
||||
if in.TargetId != "" {
|
||||
mod = mod.Where(columns.TargetId, strings.TrimSpace(in.TargetId))
|
||||
}
|
||||
if in.RequestId != "" {
|
||||
mod = mod.Where(columns.RequestId, strings.TrimSpace(in.RequestId))
|
||||
}
|
||||
if len(in.CreatedAt) == 2 {
|
||||
mod = mod.WhereBetween(columns.CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
|
||||
}
|
||||
@@ -322,3 +547,171 @@ func jsonValue(value *gjson.Json) any {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type cultivateProfile struct {
|
||||
UserId int64 `json:"user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarFileId string `json:"avatar_file_id"`
|
||||
}
|
||||
|
||||
func cultivateUserProfiles(ctx context.Context, tenantID int64, userIDs []string) (map[string]*cultivateProfile, error) {
|
||||
result := make(map[string]*cultivateProfile)
|
||||
ids := make([]int64, 0, len(userIDs))
|
||||
for _, value := range userIDs {
|
||||
id, err := strconv.ParseInt(value, 10, 64)
|
||||
if err == nil && id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
tenant, err := resolveImAdminTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Items []*cultivateProfile `json:"items"`
|
||||
}
|
||||
path := "/internal/cultivate/tenants/" + url.PathEscape(tenant.Code) + "/users/batch"
|
||||
if err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, map[string]any{"user_ids": ids}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range out.Items {
|
||||
result[strconv.FormatInt(item.UserId, 10)] = item
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func publishCultivateNotice(ctx context.Context, tenantID int64, targetUserID uint64, eventID, eventType, title, summary string, action map[string]any) {
|
||||
if targetUserID == 0 {
|
||||
return
|
||||
}
|
||||
tenant, err := resolveImAdminTenant(ctx, tenantID)
|
||||
if err == nil {
|
||||
path := "/internal/cultivate/tenants/" + url.PathEscape(tenant.Code) + "/events/publish"
|
||||
payload := map[string]any{"event_id": eventID, "event_type": eventType, "actor_id": contexts.GetUserId(ctx), "target_user_id": targetUserID, "title": title, "summary": summary, "action": action}
|
||||
err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, nil)
|
||||
}
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "养成通知投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func cultivateReviewSummary(action string) string {
|
||||
switch action {
|
||||
case "approve":
|
||||
return "你的内容已审核通过"
|
||||
case "reject":
|
||||
return "你的内容未通过审核,请修改后重试"
|
||||
case "take_down":
|
||||
return "你的内容已根据社区规则下架"
|
||||
case "restore":
|
||||
return "你的内容已恢复展示"
|
||||
default:
|
||||
return "你的内容审核状态已更新"
|
||||
}
|
||||
}
|
||||
|
||||
func mustUint(value string) uint64 {
|
||||
id, _ := strconv.ParseUint(value, 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
func governCultivateUser(ctx context.Context, tenantID int64, userID uint64, action, reason string) error {
|
||||
tenant, err := resolveImAdminTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventType := consts.ImAdminCommandUserFreeze
|
||||
if action == "ban" {
|
||||
eventType = consts.ImAdminCommandUserBan
|
||||
}
|
||||
path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/users/" + strconv.FormatUint(userID, 10) + "/action"
|
||||
payload := map[string]any{"event_type": eventType, "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), "reason": reason}
|
||||
return imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, nil)
|
||||
}
|
||||
|
||||
func takeDownReportedContent(ctx context.Context, tx gdb.TX, tenantID, operatorID int64, report *entity.FeedReport, reason string, now time.Time) error {
|
||||
id := mustUint(report.ObjectId)
|
||||
if id == 0 {
|
||||
return gerror.New("举报对象ID不正确")
|
||||
}
|
||||
if report.ObjectType == "feed_post" {
|
||||
columns := dao.FeedPost.Columns()
|
||||
var before entity.FeedPost
|
||||
if err := tx.Model(dao.FeedPost.Table()).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).LockUpdate().Scan(&before); err != nil {
|
||||
return err
|
||||
}
|
||||
if before.Id == 0 {
|
||||
return gerror.New("被举报动态不存在")
|
||||
}
|
||||
if before.Status != "published" {
|
||||
return gerror.New("仅已发布动态可联动下架")
|
||||
}
|
||||
data := g.Map{columns.Status: "taken_down", columns.TakenDownAt: now, columns.RejectReasonCode: reason, columns.UpdatedAt: now, columns.Version: gdb.Raw(columns.Version + "+1")}
|
||||
if _, err := tx.Model(dao.FeedPost.Table()).Where(columns.Id, id).Data(data).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeContentAudit(ctx, tx, tenantID, operatorID, report.ObjectType, id, before.Status, "taken_down", "report_take_down", now); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCultivateOperation(ctx, tx, tenantID, operatorID, "content.report_take_down", report.ObjectType, report.ObjectId, before, data, reason, now)
|
||||
}
|
||||
if report.ObjectType == "feed_comment" {
|
||||
columns := dao.FeedComment.Columns()
|
||||
var before entity.FeedComment
|
||||
if err := tx.Model(dao.FeedComment.Table()).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).LockUpdate().Scan(&before); err != nil {
|
||||
return err
|
||||
}
|
||||
if before.Id == 0 {
|
||||
return gerror.New("被举报评论不存在")
|
||||
}
|
||||
if before.Status != "published" {
|
||||
return gerror.New("仅已发布评论可联动下架")
|
||||
}
|
||||
data := g.Map{columns.Status: "taken_down", columns.DeleteReason: reason, columns.UpdatedAt: now}
|
||||
if _, err := tx.Model(dao.FeedComment.Table()).Where(columns.Id, id).Data(data).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
postColumns := dao.FeedPost.Columns()
|
||||
if _, err := tx.Model(dao.FeedPost.Table()).Where(g.Map{postColumns.Id: before.PostId, postColumns.TenantId: tenantID}).Data(g.Map{postColumns.CommentCount: gdb.Raw("GREATEST(`comment_count` - 1, 0)"), postColumns.UpdatedAt: now}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeContentAudit(ctx, tx, tenantID, operatorID, report.ObjectType, id, before.Status, "taken_down", "report_take_down", now); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCultivateOperation(ctx, tx, tenantID, operatorID, "content.report_take_down", report.ObjectType, report.ObjectId, before, data, reason, now)
|
||||
}
|
||||
return gerror.New("该举报对象不支持内容联动下架")
|
||||
}
|
||||
|
||||
func validateReportedContentCanTakeDown(ctx context.Context, tenantID int64, report *entity.FeedReport) error {
|
||||
id := mustUint(report.ObjectId)
|
||||
if id == 0 {
|
||||
return gerror.New("举报对象ID不正确")
|
||||
}
|
||||
switch report.ObjectType {
|
||||
case "feed_post":
|
||||
columns := dao.FeedPost.Columns()
|
||||
value, err := dao.FeedPost.Ctx(ctx).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).Value(columns.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value.String() != "published" {
|
||||
return gerror.New("仅已发布动态可联动下架")
|
||||
}
|
||||
case "feed_comment":
|
||||
columns := dao.FeedComment.Columns()
|
||||
value, err := dao.FeedComment.Ctx(ctx).Where(g.Map{columns.Id: id, columns.TenantId: tenantID}).Value(columns.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value.String() != "published" {
|
||||
return gerror.New("仅已发布评论可联动下架")
|
||||
}
|
||||
default:
|
||||
return gerror.New("该举报对象不支持内容联动下架")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,11 +12,14 @@ import (
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
|
||||
"hotgo/internal/dao"
|
||||
"hotgo/internal/library/contexts"
|
||||
"hotgo/internal/library/imadmin"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"hotgo/internal/library/location"
|
||||
"hotgo/internal/model/entity"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
@@ -86,6 +90,9 @@ func (s *sSysCultivateFlower) View(ctx context.Context, in *sysin.CultivateFlowe
|
||||
|
||||
func (s *sSysCultivateFlower) Edit(ctx context.Context, in *sysin.CultivateFlowerEditInp) (err error) {
|
||||
tenantID := cultivateTenantID(ctx)
|
||||
if err = validateCultivateAssets(ctx, tenantID, in); err != nil {
|
||||
return err
|
||||
}
|
||||
operatorID := contexts.GetUserId(ctx)
|
||||
now := time.Now().UTC()
|
||||
version := "cat_" + now.Format("20060102_150405")
|
||||
@@ -151,6 +158,59 @@ func (s *sSysCultivateFlower) Edit(ctx context.Context, in *sysin.CultivateFlowe
|
||||
})
|
||||
}
|
||||
|
||||
func validateCultivateAssets(ctx context.Context, tenantID int64, in *sysin.CultivateFlowerEditInp) error {
|
||||
seen := make(map[string]struct{})
|
||||
fileIDs := make([]string, 0, 1+len(in.StageRules)*2)
|
||||
add := func(fileID string) {
|
||||
fileID = strings.TrimSpace(fileID)
|
||||
if fileID == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[fileID]; ok {
|
||||
return
|
||||
}
|
||||
seen[fileID] = struct{}{}
|
||||
fileIDs = append(fileIDs, fileID)
|
||||
}
|
||||
add(in.IconFileId)
|
||||
for _, rule := range in.StageRules {
|
||||
add(rule.ImageFileId)
|
||||
add(rule.AnimationFileId)
|
||||
}
|
||||
if len(fileIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
tenant, err := resolveImAdminTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/internal/cultivate/tenants/" + url.PathEscape(tenant.Code) + "/files/validate"
|
||||
for offset := 0; offset < len(fileIDs); offset += 20 {
|
||||
end := offset + 20
|
||||
if end > len(fileIDs) {
|
||||
end = len(fileIDs)
|
||||
}
|
||||
var out struct {
|
||||
Items []struct {
|
||||
FileId string `json:"file_id"`
|
||||
ReviewStatus string `json:"review_status"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, map[string]any{"user_id": 0, "file_ids": fileIDs[offset:end]}, &out); err != nil {
|
||||
return gerror.Wrap(err, "校验花种素材失败")
|
||||
}
|
||||
for _, item := range out.Items {
|
||||
if item.ReviewStatus == "risky" {
|
||||
return gerror.Newf("素材 %s 审核未通过", item.FileId)
|
||||
}
|
||||
if item.ReviewStatus == "pending" {
|
||||
return gerror.Newf("素材 %s 尚在审核中", item.FileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysCultivateFlower) Status(ctx context.Context, in *sysin.CultivateFlowerStatusInp) error {
|
||||
tenantID := cultivateTenantID(ctx)
|
||||
operatorID := contexts.GetUserId(ctx)
|
||||
@@ -185,7 +245,12 @@ func writeCultivateOperation(ctx context.Context, tx gdb.TX, tenantID, operatorI
|
||||
}
|
||||
beforeJSON, _ := json.Marshal(before)
|
||||
afterJSON, _ := json.Marshal(after)
|
||||
ipAddress, userAgent := "", ""
|
||||
if request := ghttp.RequestFromCtx(ctx); request != nil {
|
||||
ipAddress = location.GetClientIp(request)
|
||||
userAgent = request.UserAgent()
|
||||
}
|
||||
c := dao.AdminOperationLog.Columns()
|
||||
_, e = tx.Model(dao.AdminOperationLog.Table()).Data(g.Map{c.Id: id, c.TenantId: tenantID, c.OperatorId: operatorID, c.OperationType: operation, c.TargetType: targetType, c.TargetId: targetID, c.RequestId: gctx.CtxId(ctx), c.BeforeSnapshot: gjson.New(beforeJSON).MustToJsonString(), c.AfterSnapshot: gjson.New(afterJSON).MustToJsonString(), c.Reason: reason, c.IpAddress: "", c.UserAgent: "", c.CreatedAt: now}).Insert()
|
||||
_, e = tx.Model(dao.AdminOperationLog.Table()).Data(g.Map{c.Id: id, c.TenantId: tenantID, c.OperatorId: operatorID, c.OperationType: operation, c.TargetType: targetType, c.TargetId: targetID, c.RequestId: gctx.CtxId(ctx), c.BeforeSnapshot: gjson.New(beforeJSON).MustToJsonString(), c.AfterSnapshot: gjson.New(afterJSON).MustToJsonString(), c.Reason: reason, c.IpAddress: ipAddress, c.UserAgent: userAgent, c.CreatedAt: now}).Insert()
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -50,6 +50,48 @@ type CultivateContentListModel struct {
|
||||
UpdatedAt *gtime.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type CultivateContentViewInp struct {
|
||||
ObjectType string `json:"objectType" v:"required|in:feed_post,feed_comment#内容类型不能为空|内容类型不正确"`
|
||||
Id string `json:"id" v:"required#内容ID不能为空"`
|
||||
ObjectId uint64 `json:"-"`
|
||||
}
|
||||
|
||||
func (in *CultivateContentViewInp) Filter(context.Context) error {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(in.Id), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return gerror.New("内容ID不正确")
|
||||
}
|
||||
in.ObjectId = id
|
||||
return nil
|
||||
}
|
||||
|
||||
type CultivateMediaModel struct {
|
||||
Id string `json:"id"`
|
||||
FileId string `json:"fileId"`
|
||||
ThumbnailFileId string `json:"thumbnailFileId"`
|
||||
MediaType string `json:"mediaType"`
|
||||
AuditStatus string `json:"auditStatus"`
|
||||
SortNo uint `json:"sortNo"`
|
||||
}
|
||||
type CultivateAuditModel struct {
|
||||
Id string `json:"id"`
|
||||
AuditSource string `json:"auditSource"`
|
||||
StatusBefore string `json:"statusBefore"`
|
||||
StatusAfter string `json:"statusAfter"`
|
||||
ResultCode string `json:"resultCode"`
|
||||
RiskLabels any `json:"riskLabels"`
|
||||
OperatorId string `json:"operatorId"`
|
||||
OccurredAt *gtime.Time `json:"occurredAt"`
|
||||
}
|
||||
type CultivateContentViewModel struct {
|
||||
CultivateContentListModel
|
||||
AuthorNickname string `json:"authorNickname"`
|
||||
AuthorAvatarFileId string `json:"authorAvatarFileId"`
|
||||
Media []*CultivateMediaModel `json:"media"`
|
||||
Audits []*CultivateAuditModel `json:"audits"`
|
||||
RelatedReports []*CultivateReportListModel `json:"relatedReports"`
|
||||
}
|
||||
|
||||
type CultivateContentReviewInp struct {
|
||||
ObjectType string `json:"objectType" v:"required|in:feed_post,feed_comment#内容类型不能为空|内容类型不正确"`
|
||||
Id string `json:"id" v:"required#内容ID不能为空"`
|
||||
@@ -58,6 +100,36 @@ type CultivateContentReviewInp struct {
|
||||
ObjectId uint64 `json:"-"`
|
||||
}
|
||||
|
||||
type CultivateContentBatchReviewInp struct {
|
||||
ObjectType string `json:"objectType" v:"required|in:feed_post,feed_comment#内容类型不能为空|内容类型不正确"`
|
||||
Ids []string `json:"ids" v:"required#请选择内容"`
|
||||
Action string `json:"action" v:"required|in:approve,reject,take_down,restore#审核动作不能为空|审核动作不正确"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (in *CultivateContentBatchReviewInp) Filter(ctx context.Context) error {
|
||||
if len(in.Ids) == 0 || len(in.Ids) > 100 {
|
||||
return gerror.New("单次批量处理数量必须为1至100条")
|
||||
}
|
||||
in.Reason = strings.TrimSpace(in.Reason)
|
||||
if (in.Action == "reject" || in.Action == "take_down") && in.Reason == "" {
|
||||
return gerror.New("拒绝或下架时必须填写原因")
|
||||
}
|
||||
for _, id := range in.Ids {
|
||||
item := &CultivateContentReviewInp{ObjectType: in.ObjectType, Id: id, Action: in.Action, Reason: in.Reason}
|
||||
if err := item.Filter(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CultivateContentBatchReviewModel struct {
|
||||
SuccessCount int `json:"successCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
Failures map[string]string `json:"failures"`
|
||||
}
|
||||
|
||||
func (in *CultivateContentReviewInp) Filter(context.Context) error {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(in.Id), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
@@ -105,10 +177,35 @@ type CultivateReportListModel struct {
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CultivateReportViewInp struct {
|
||||
Id string `json:"id" v:"required#举报ID不能为空"`
|
||||
ReportId uint64 `json:"-"`
|
||||
}
|
||||
|
||||
func (in *CultivateReportViewInp) Filter(context.Context) error {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(in.Id), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return gerror.New("举报ID不正确")
|
||||
}
|
||||
in.ReportId = id
|
||||
return nil
|
||||
}
|
||||
|
||||
type CultivateReportViewModel struct {
|
||||
CultivateReportListModel
|
||||
ReporterNickname string `json:"reporterNickname"`
|
||||
ReportedUserNickname string `json:"reportedUserNickname"`
|
||||
TargetContent *CultivateContentListModel `json:"targetContent"`
|
||||
History []*CultivateReportListModel `json:"history"`
|
||||
}
|
||||
|
||||
type CultivateReportHandleInp struct {
|
||||
Id string `json:"id" v:"required#举报ID不能为空"`
|
||||
Action string `json:"action" v:"required|in:processing,resolve,reject#处理动作不能为空|处理动作不正确"`
|
||||
HandleResult string `json:"handleResult"`
|
||||
ContentAction string `json:"contentAction" v:"in:none,take_down#内容处置不正确"`
|
||||
UserAction string `json:"userAction" v:"in:none,freeze,ban#用户处置不正确"`
|
||||
Reason string `json:"reason"`
|
||||
ReportId uint64 `json:"-"`
|
||||
}
|
||||
|
||||
@@ -119,9 +216,24 @@ func (in *CultivateReportHandleInp) Filter(context.Context) error {
|
||||
}
|
||||
in.ReportId = id
|
||||
in.HandleResult = strings.TrimSpace(in.HandleResult)
|
||||
in.ContentAction = strings.TrimSpace(in.ContentAction)
|
||||
in.UserAction = strings.TrimSpace(in.UserAction)
|
||||
in.Reason = strings.TrimSpace(in.Reason)
|
||||
if in.ContentAction == "" {
|
||||
in.ContentAction = "none"
|
||||
}
|
||||
if in.UserAction == "" {
|
||||
in.UserAction = "none"
|
||||
}
|
||||
if in.Action != "processing" && in.HandleResult == "" {
|
||||
return gerror.New("办结举报时必须填写处理结果")
|
||||
}
|
||||
if (in.ContentAction != "none" || in.UserAction != "none") && in.Reason == "" {
|
||||
return gerror.New("联动处置时必须填写处置原因")
|
||||
}
|
||||
if in.Action != "resolve" && (in.ContentAction != "none" || in.UserAction != "none") {
|
||||
return gerror.New("仅确认处理时可执行内容或用户处置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -130,9 +242,38 @@ type CultivateOperationLogListInp struct {
|
||||
OperatorId string `json:"operatorId"`
|
||||
OperationType string `json:"operationType"`
|
||||
TargetType string `json:"targetType"`
|
||||
TargetId string `json:"targetId"`
|
||||
RequestId string `json:"requestId"`
|
||||
CreatedAt []*gtime.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CultivateDashboardInp struct{}
|
||||
|
||||
func (*CultivateDashboardInp) Filter(context.Context) error { return nil }
|
||||
|
||||
type CultivateDashboardModel struct {
|
||||
PlantCount int64 `json:"plantCount"`
|
||||
GrowingCount int64 `json:"growingCount"`
|
||||
BloomCount int64 `json:"bloomCount"`
|
||||
HarvestedCount int64 `json:"harvestedCount"`
|
||||
RemovedCount int64 `json:"removedCount"`
|
||||
Active7d int64 `json:"active7d"`
|
||||
Active30d int64 `json:"active30d"`
|
||||
PostCount int64 `json:"postCount"`
|
||||
PublicPostCount int64 `json:"publicPostCount"`
|
||||
FriendsPostCount int64 `json:"friendsPostCount"`
|
||||
PrivatePostCount int64 `json:"privatePostCount"`
|
||||
CommentCount int64 `json:"commentCount"`
|
||||
LikeCount int64 `json:"likeCount"`
|
||||
TakenDownCount int64 `json:"takenDownCount"`
|
||||
PendingAuditCount int64 `json:"pendingAuditCount"`
|
||||
ApprovedAuditCount int64 `json:"approvedAuditCount"`
|
||||
RejectedAuditCount int64 `json:"rejectedAuditCount"`
|
||||
PendingReportCount int64 `json:"pendingReportCount"`
|
||||
ProcessingReportCount int64 `json:"processingReportCount"`
|
||||
ProcessedReportCount int64 `json:"processedReportCount"`
|
||||
}
|
||||
|
||||
func (*CultivateOperationLogListInp) Filter(context.Context) error { return nil }
|
||||
|
||||
type CultivateOperationLogListModel struct {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package sysin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCultivateContentBatchReviewFilter(t *testing.T) {
|
||||
in := &CultivateContentBatchReviewInp{ObjectType: "feed_post", Ids: []string{"123"}, Action: "take_down", Reason: "违规内容"}
|
||||
if err := in.Filter(context.Background()); err != nil {
|
||||
t.Fatalf("Filter returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCultivateContentBatchReviewFilterRequiresReason(t *testing.T) {
|
||||
in := &CultivateContentBatchReviewInp{ObjectType: "feed_post", Ids: []string{"123"}, Action: "reject"}
|
||||
if err := in.Filter(context.Background()); err == nil {
|
||||
t.Fatal("expected missing reason error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCultivateReportHandleFilterGovernanceRules(t *testing.T) {
|
||||
in := &CultivateReportHandleInp{Id: "123", Action: "processing", UserAction: "freeze", Reason: "恶意行为"}
|
||||
if err := in.Filter(context.Background()); err == nil {
|
||||
t.Fatal("expected governance action to require resolve")
|
||||
}
|
||||
}
|
||||
@@ -179,9 +179,13 @@ type (
|
||||
Status(ctx context.Context, in *sysin.CultivateFlowerStatusInp) (err error)
|
||||
}
|
||||
ISysCultivateContent interface {
|
||||
Dashboard(ctx context.Context, in *sysin.CultivateDashboardInp) (*sysin.CultivateDashboardModel, error)
|
||||
List(ctx context.Context, in *sysin.CultivateContentListInp) (list []*sysin.CultivateContentListModel, totalCount int, err error)
|
||||
View(ctx context.Context, in *sysin.CultivateContentViewInp) (*sysin.CultivateContentViewModel, error)
|
||||
Review(ctx context.Context, in *sysin.CultivateContentReviewInp) error
|
||||
BatchReview(ctx context.Context, in *sysin.CultivateContentBatchReviewInp) (*sysin.CultivateContentBatchReviewModel, error)
|
||||
ReportList(ctx context.Context, in *sysin.CultivateReportListInp) (list []*sysin.CultivateReportListModel, totalCount int, err error)
|
||||
ReportView(ctx context.Context, in *sysin.CultivateReportViewInp) (*sysin.CultivateReportViewModel, error)
|
||||
ReportHandle(ctx context.Context, in *sysin.CultivateReportHandleInp) error
|
||||
LogList(ctx context.Context, in *sysin.CultivateOperationLogListInp) (list []*sysin.CultivateOperationLogListModel, totalCount int, err error)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,14 @@ SET NAMES utf8mb4;
|
||||
SET @now := NOW();
|
||||
|
||||
INSERT INTO hg_admin_menu (pid,level,tree,title,name,path,icon,type,redirect,permissions,permission_name,component,always_show,active_menu,is_root,is_frame,frame_src,keep_alive,hidden,affix,sort,remark,status,created_at,updated_at)
|
||||
SELECT 0,1,'','养成管理','cultivateManager','/cultivate','ExperimentOutlined',1,'/cultivate/flowers','','','LAYOUT',1,'',0,0,'',0,0,0,124,'后院养成与物友动态管理',1,@now,@now
|
||||
SELECT 0,1,'','养成管理','cultivateManager','/cultivate','ExperimentOutlined',1,'/cultivate/dashboard','','','LAYOUT',1,'',0,0,'',0,0,0,124,'后院养成与物友动态管理',1,@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name='cultivateManager');
|
||||
SET @cultivateId := (SELECT id FROM hg_admin_menu WHERE name='cultivateManager' LIMIT 1);
|
||||
UPDATE hg_admin_menu SET redirect='/cultivate/dashboard', updated_at=@now WHERE id=@cultivateId;
|
||||
|
||||
INSERT INTO hg_admin_menu (pid,level,tree,title,name,path,icon,type,redirect,permissions,permission_name,component,always_show,active_menu,is_root,is_frame,frame_src,keep_alive,hidden,affix,sort,remark,status,created_at,updated_at)
|
||||
SELECT @cultivateId,2,CONCAT('tr_',@cultivateId,' '),'数据概览','cultivateDashboard','dashboard','',2,'','/cultivate/dashboard','','/cultivate/dashboard/index',1,'cultivateDashboard',0,0,'',1,0,0,50,'养成与物友治理核心指标',1,@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name='cultivateDashboard');
|
||||
|
||||
INSERT INTO hg_admin_menu (pid,level,tree,title,name,path,icon,type,redirect,permissions,permission_name,component,always_show,active_menu,is_root,is_frame,frame_src,keep_alive,hidden,affix,sort,remark,status,created_at,updated_at)
|
||||
SELECT @cultivateId,2,CONCAT('tr_',@cultivateId,' '),'花种配置','cultivateFlowers','flowers','',2,'','/cultivate/flowers/list','','/cultivate/flowers/index',1,'cultivateFlowers',0,0,'',1,0,0,40,'花种、成长阶段、每日限额与素材配置',1,@now,@now
|
||||
@@ -33,6 +38,14 @@ INSERT INTO hg_admin_menu (pid,level,tree,title,name,path,icon,type,redirect,per
|
||||
SELECT @contentId,3,CONCAT('tr_',@cultivateId,' tr_',@contentId,' '),'审核内容','cultivateContentReview','','',3,'','/cultivate/content/review','','',1,'',0,0,'',0,1,0,20,'',1,@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name='cultivateContentReview');
|
||||
|
||||
INSERT INTO hg_admin_menu (pid,level,tree,title,name,path,icon,type,redirect,permissions,permission_name,component,always_show,active_menu,is_root,is_frame,frame_src,keep_alive,hidden,affix,sort,remark,status,created_at,updated_at)
|
||||
SELECT @contentId,3,CONCAT('tr_',@cultivateId,' tr_',@contentId,' '),'批量审核内容','cultivateContentBatchReview','','',3,'','/cultivate/content/batch-review','','',1,'',0,0,'',0,1,0,10,'',1,@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name='cultivateContentBatchReview');
|
||||
|
||||
INSERT INTO hg_admin_menu (pid,level,tree,title,name,path,icon,type,redirect,permissions,permission_name,component,always_show,active_menu,is_root,is_frame,frame_src,keep_alive,hidden,affix,sort,remark,status,created_at,updated_at)
|
||||
SELECT @contentId,3,CONCAT('tr_',@cultivateId,' tr_',@contentId,' '),'查看内容详情','cultivateContentView','','',3,'','/cultivate/content/view','','',1,'',0,0,'',0,1,0,30,'',1,@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name='cultivateContentView');
|
||||
|
||||
INSERT INTO hg_admin_menu (pid,level,tree,title,name,path,icon,type,redirect,permissions,permission_name,component,always_show,active_menu,is_root,is_frame,frame_src,keep_alive,hidden,affix,sort,remark,status,created_at,updated_at)
|
||||
SELECT @cultivateId,2,CONCAT('tr_',@cultivateId,' '),'举报处理','cultivateReports','reports','',2,'','/cultivate/reports/list','','/cultivate/reports/index',1,'cultivateReports',0,0,'',1,0,0,20,'物友内容与用户举报处理',1,@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name='cultivateReports');
|
||||
@@ -42,6 +55,10 @@ INSERT INTO hg_admin_menu (pid,level,tree,title,name,path,icon,type,redirect,per
|
||||
SELECT @reportsId,3,CONCAT('tr_',@cultivateId,' tr_',@reportsId,' '),'处理举报','cultivateReportsHandle','','',3,'','/cultivate/reports/handle','','',1,'',0,0,'',0,1,0,20,'',1,@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name='cultivateReportsHandle');
|
||||
|
||||
INSERT INTO hg_admin_menu (pid,level,tree,title,name,path,icon,type,redirect,permissions,permission_name,component,always_show,active_menu,is_root,is_frame,frame_src,keep_alive,hidden,affix,sort,remark,status,created_at,updated_at)
|
||||
SELECT @reportsId,3,CONCAT('tr_',@cultivateId,' tr_',@reportsId,' '),'查看举报详情','cultivateReportsView','','',3,'','/cultivate/reports/view','','',1,'',0,0,'',0,1,0,30,'',1,@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name='cultivateReportsView');
|
||||
|
||||
INSERT INTO hg_admin_menu (pid,level,tree,title,name,path,icon,type,redirect,permissions,permission_name,component,always_show,active_menu,is_root,is_frame,frame_src,keep_alive,hidden,affix,sort,remark,status,created_at,updated_at)
|
||||
SELECT @cultivateId,2,CONCAT('tr_',@cultivateId,' '),'操作日志','cultivateLogs','logs','',2,'','/cultivate/logs/list','','/cultivate/logs/index',1,'cultivateLogs',0,0,'',1,0,0,10,'养成管理操作审计日志',1,@now,@now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name='cultivateLogs');
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
export function ContentList(params) { return http.request({ url: '/cultivate/content/list', method: 'get', params }); }
|
||||
export function ContentReview(params) { return http.request({ url: '/cultivate/content/review', method: 'POST', params }); }
|
||||
export function ReportList(params) { return http.request({ url: '/cultivate/reports/list', method: 'get', params }); }
|
||||
export function ReportHandle(params) { return http.request({ url: '/cultivate/reports/handle', method: 'POST', params }); }
|
||||
export function OperationLogList(params) { return http.request({ url: '/cultivate/logs/list', method: 'get', params }); }
|
||||
export function ContentList(params) {
|
||||
return http.request({ url: '/cultivate/content/list', method: 'get', params });
|
||||
}
|
||||
export function ContentView(params) {
|
||||
return http.request({ url: '/cultivate/content/view', method: 'get', params });
|
||||
}
|
||||
export function ContentReview(params) {
|
||||
return http.request({ url: '/cultivate/content/review', method: 'POST', params });
|
||||
}
|
||||
export function ContentBatchReview(params) {
|
||||
return http.request({ url: '/cultivate/content/batch-review', method: 'POST', params });
|
||||
}
|
||||
export function ReportList(params) {
|
||||
return http.request({ url: '/cultivate/reports/list', method: 'get', params });
|
||||
}
|
||||
export function ReportView(params) {
|
||||
return http.request({ url: '/cultivate/reports/view', method: 'get', params });
|
||||
}
|
||||
export function ReportHandle(params) {
|
||||
return http.request({ url: '/cultivate/reports/handle', method: 'POST', params });
|
||||
}
|
||||
export function OperationLogList(params) {
|
||||
return http.request({ url: '/cultivate/logs/list', method: 'get', params });
|
||||
}
|
||||
export function CultivateDashboard() {
|
||||
return http.request({ url: '/cultivate/dashboard', method: 'get' });
|
||||
}
|
||||
|
||||
@@ -1,37 +1,236 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="内容审核">审核物友动态与评论,支持拒绝、下架和恢复。</n-card></div>
|
||||
<div class="n-layout-page-header"
|
||||
><n-card :bordered="false" title="内容审核"
|
||||
>审核物友动态与评论,支持拒绝、下架和恢复。</n-card
|
||||
></div
|
||||
>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" @keyup.enter="reloadTable" />
|
||||
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="(row) => row.id" :actionColumn="actionColumn" :scroll-x="1450" />
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="reloadTable"
|
||||
@keyup.enter="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:openChecked="true"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="1450"
|
||||
:checked-row-keys="checkedIds"
|
||||
@update:checked-row-keys="onCheckedRow"
|
||||
>
|
||||
<template #tableTitle>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="checkedIds.length === 0"
|
||||
v-if="hasPermission(['/cultivate/content/batch-review'])"
|
||||
@click="openBatchReview"
|
||||
>批量审核({{ checkedIds.length }})</n-button
|
||||
>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</n-card>
|
||||
<n-modal v-model:show="showReview" preset="dialog" title="内容审核" positive-text="确认" negative-text="取消" @positive-click="submitReview">
|
||||
<n-modal
|
||||
v-model:show="showReview"
|
||||
preset="dialog"
|
||||
:title="selected ? '内容审核' : '批量内容审核'"
|
||||
positive-text="确认"
|
||||
negative-text="取消"
|
||||
@positive-click="submitReview"
|
||||
>
|
||||
<n-space vertical>
|
||||
<n-select v-model:value="reviewForm.action" :options="reviewOptions" placeholder="请选择审核动作" />
|
||||
<n-input v-model:value="reviewForm.reason" type="textarea" placeholder="拒绝或下架时必须填写原因" />
|
||||
<n-select
|
||||
v-model:value="reviewForm.action"
|
||||
:options="reviewOptions"
|
||||
placeholder="请选择审核动作"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="reviewForm.reason"
|
||||
type="textarea"
|
||||
placeholder="拒绝或下架时必须填写原因"
|
||||
/>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDetail" preset="card" title="内容详情" class="detail-modal">
|
||||
<n-spin :show="detailLoading">
|
||||
<n-descriptions v-if="detail" bordered :column="2" label-placement="left">
|
||||
<n-descriptions-item label="内容ID">{{ detail.id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="作者"
|
||||
>{{ detail.authorNickname || '-' }}({{ detail.authorId }})</n-descriptions-item
|
||||
>
|
||||
<n-descriptions-item label="状态">{{ detail.status }}</n-descriptions-item>
|
||||
<n-descriptions-item label="审核状态">{{
|
||||
detail.auditStatus || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="正文" :span="2">{{
|
||||
detail.contentText || '-'
|
||||
}}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-divider title-placement="left">媒体素材</n-divider>
|
||||
<n-data-table :columns="mediaColumns" :data="detail?.media || []" :pagination="false" />
|
||||
<n-divider title-placement="left">审核记录</n-divider>
|
||||
<n-data-table :columns="auditColumns" :data="detail?.audits || []" :pagination="false" />
|
||||
<n-divider title-placement="left">关联举报</n-divider>
|
||||
<n-data-table
|
||||
:columns="reportColumns"
|
||||
:data="detail?.relatedReports || []"
|
||||
:pagination="false"
|
||||
/>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { h, onMounted, reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { ContentList, ContentReview } from '@/api/cultivate/content';
|
||||
import { columns, ContentState, loadOptions, schemas } from './model';
|
||||
import { h, onMounted, reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import {
|
||||
ContentBatchReview,
|
||||
ContentList,
|
||||
ContentReview,
|
||||
ContentView,
|
||||
} from '@/api/cultivate/content';
|
||||
import { columns, ContentState, loadOptions, schemas } from './model';
|
||||
|
||||
const actionRef = ref(); const searchFormRef = ref<any>({}); const showReview = ref(false); const selected = ref<ContentState>();
|
||||
const message = useMessage(); const { hasPermission } = usePermission();
|
||||
const reviewForm = reactive({ action: 'approve', reason: '' });
|
||||
const reviewOptions = [{ label: '审核通过', value: 'approve' }, { label: '审核拒绝', value: 'reject' }, { label: '下架', value: 'take_down' }, { label: '恢复发布', value: 'restore' }];
|
||||
const [register] = useForm({ gridProps: { cols: '1 s:1 m:2 l:4 xl:4 2xl:4' }, labelWidth: 90, schemas });
|
||||
const loadDataTable = (res) => ContentList({ ...searchFormRef.value?.formModel, ...res });
|
||||
const reloadTable = () => actionRef.value?.reload();
|
||||
function openReview(row: ContentState) { selected.value = row; reviewForm.action = row.status === 'reviewing' ? 'approve' : row.status === 'taken_down' ? 'restore' : 'take_down'; reviewForm.reason = ''; showReview.value = true; }
|
||||
async function submitReview() { if (!selected.value) return false; if ((reviewForm.action === 'reject' || reviewForm.action === 'take_down') && !reviewForm.reason.trim()) { message.warning('请填写原因'); return false; } await ContentReview({ objectType: selected.value.objectType, id: selected.value.id, ...reviewForm }); message.success('处理成功'); reloadTable(); return true; }
|
||||
const actionColumn = reactive({ width: 120, title: '操作', key: 'action', fixed: 'right', render(row: ContentState) { return h(TableAction as any, { style: 'button', actions: [{ label: '审核处理', onClick: () => openReview(row), auth: ['/cultivate/content/review'], disabled: !hasPermission(['/cultivate/content/review']) }] }); } });
|
||||
onMounted(loadOptions);
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const showReview = ref(false);
|
||||
const selected = ref<ContentState>();
|
||||
const checkedIds = ref<Array<string | number>>([]);
|
||||
const message = useMessage();
|
||||
const { hasPermission } = usePermission();
|
||||
const reviewForm = reactive({ action: 'approve', reason: '' });
|
||||
const showDetail = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detail = ref<any>();
|
||||
const mediaColumns = [
|
||||
{ title: '类型', key: 'mediaType' },
|
||||
{ title: '文件ID', key: 'fileId' },
|
||||
{ title: '缩略图ID', key: 'thumbnailFileId' },
|
||||
{ title: '审核状态', key: 'auditStatus' },
|
||||
];
|
||||
const auditColumns = [
|
||||
{ title: '来源', key: 'auditSource' },
|
||||
{
|
||||
title: '状态变化',
|
||||
key: 'statusAfter',
|
||||
render: (row) => `${row.statusBefore || '-'} → ${row.statusAfter}`,
|
||||
},
|
||||
{ title: '结果', key: 'resultCode' },
|
||||
{ title: '操作人', key: 'operatorId' },
|
||||
{ title: '时间', key: 'occurredAt' },
|
||||
];
|
||||
const reportColumns = [
|
||||
{ title: '举报ID', key: 'id' },
|
||||
{ title: '原因', key: 'reasonCode' },
|
||||
{ title: '状态', key: 'status' },
|
||||
{ title: '时间', key: 'createdAt' },
|
||||
];
|
||||
const reviewOptions = [
|
||||
{ label: '审核通过', value: 'approve' },
|
||||
{ label: '审核拒绝', value: 'reject' },
|
||||
{ label: '下架', value: 'take_down' },
|
||||
{ label: '恢复发布', value: 'restore' },
|
||||
];
|
||||
const [register] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:4 xl:4 2xl:4' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
});
|
||||
const loadDataTable = (res) => ContentList({ ...searchFormRef.value?.formModel, ...res });
|
||||
const reloadTable = () => actionRef.value?.reload();
|
||||
function openReview(row: ContentState) {
|
||||
selected.value = row;
|
||||
reviewForm.action =
|
||||
row.status === 'reviewing'
|
||||
? 'approve'
|
||||
: row.status === 'taken_down'
|
||||
? 'restore'
|
||||
: 'take_down';
|
||||
reviewForm.reason = '';
|
||||
showReview.value = true;
|
||||
}
|
||||
function onCheckedRow(rowKeys: Array<string | number>) {
|
||||
checkedIds.value = rowKeys;
|
||||
}
|
||||
function openBatchReview() {
|
||||
selected.value = undefined;
|
||||
reviewForm.action = 'approve';
|
||||
reviewForm.reason = '';
|
||||
showReview.value = true;
|
||||
}
|
||||
async function openDetail(row: ContentState) {
|
||||
showDetail.value = true;
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await ContentView({ objectType: row.objectType, id: row.id });
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
async function submitReview() {
|
||||
if (
|
||||
(reviewForm.action === 'reject' || reviewForm.action === 'take_down') &&
|
||||
!reviewForm.reason.trim()
|
||||
) {
|
||||
message.warning('请填写原因');
|
||||
return false;
|
||||
}
|
||||
if (selected.value) {
|
||||
await ContentReview({
|
||||
objectType: selected.value.objectType,
|
||||
id: selected.value.id,
|
||||
...reviewForm,
|
||||
});
|
||||
message.success('处理成功');
|
||||
} else {
|
||||
const result: any = await ContentBatchReview({
|
||||
objectType: searchFormRef.value?.formModel?.objectType || 'feed_post',
|
||||
ids: checkedIds.value.map(String),
|
||||
...reviewForm,
|
||||
});
|
||||
if (result.failedCount) {
|
||||
message.warning(`成功 ${result.successCount} 条,失败 ${result.failedCount} 条`);
|
||||
} else {
|
||||
message.success(`已处理 ${result.successCount} 条内容`);
|
||||
}
|
||||
checkedIds.value = [];
|
||||
}
|
||||
reloadTable();
|
||||
return true;
|
||||
}
|
||||
const actionColumn = reactive({
|
||||
width: 170,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(row: ContentState) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{ label: '详情', onClick: () => openDetail(row), auth: ['/cultivate/content/view'] },
|
||||
{
|
||||
label: '审核处理',
|
||||
onClick: () => openReview(row),
|
||||
auth: ['/cultivate/content/review'],
|
||||
disabled: !hasPermission(['/cultivate/content/review']),
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
<style scoped>
|
||||
.detail-modal {
|
||||
width: min(1100px, 92vw);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="养成概览"
|
||||
>汇总后院养成、物友动态、内容审核与举报治理数据。</n-card
|
||||
>
|
||||
</div>
|
||||
<n-spin :show="loading">
|
||||
<n-grid cols="1 s:2 m:3 l:4" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<n-grid-item v-for="item in cards" :key="item.key">
|
||||
<n-card :title="item.label" size="small">
|
||||
<n-statistic :value="statistics[item.key] || 0" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { CultivateDashboard } from '@/api/cultivate/content';
|
||||
|
||||
const loading = ref(false);
|
||||
const statistics = reactive<Record<string, number>>({});
|
||||
const cards = [
|
||||
{ key: 'plantCount', label: '累计种植' },
|
||||
{ key: 'growingCount', label: '生长中' },
|
||||
{ key: 'bloomCount', label: '已开花' },
|
||||
{ key: 'harvestedCount', label: '已收获' },
|
||||
{ key: 'active7d', label: '近 7 日活跃用户' },
|
||||
{ key: 'active30d', label: '近 30 日活跃用户' },
|
||||
{ key: 'postCount', label: '已发布动态' },
|
||||
{ key: 'commentCount', label: '已发布评论' },
|
||||
{ key: 'likeCount', label: '累计点赞' },
|
||||
{ key: 'pendingAuditCount', label: '待审核内容' },
|
||||
{ key: 'pendingReportCount', label: '待处理举报' },
|
||||
{ key: 'processingReportCount', label: '处理中举报' },
|
||||
];
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true;
|
||||
try {
|
||||
Object.assign(statistics, await CultivateDashboard());
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDashboard);
|
||||
</script>
|
||||
@@ -1,12 +1,44 @@
|
||||
import { ref } from 'vue';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{ field: 'operatorId', component: 'NInput', label: '操作人ID', componentProps: { placeholder: '请输入操作人ID' } },
|
||||
{ field: 'operationType', component: 'NInput', label: '操作类型', componentProps: { placeholder: '如 content、report、flower' } },
|
||||
{ field: 'targetType', component: 'NInput', label: '对象类型', componentProps: { placeholder: '请输入对象类型' } },
|
||||
{
|
||||
field: 'operatorId',
|
||||
component: 'NInput',
|
||||
label: '操作人ID',
|
||||
componentProps: { placeholder: '请输入操作人ID' },
|
||||
},
|
||||
{
|
||||
field: 'operationType',
|
||||
component: 'NInput',
|
||||
label: '操作类型',
|
||||
componentProps: { placeholder: '如 content、report、flower' },
|
||||
},
|
||||
{
|
||||
field: 'targetType',
|
||||
component: 'NInput',
|
||||
label: '对象类型',
|
||||
componentProps: { placeholder: '请输入对象类型' },
|
||||
},
|
||||
{
|
||||
field: 'targetId',
|
||||
component: 'NInput',
|
||||
label: '对象ID',
|
||||
componentProps: { placeholder: '请输入对象ID' },
|
||||
},
|
||||
{
|
||||
field: 'requestId',
|
||||
component: 'NInput',
|
||||
label: '请求ID',
|
||||
componentProps: { placeholder: '请输入请求ID' },
|
||||
},
|
||||
]);
|
||||
export const columns = [
|
||||
{ title: '日志ID', key: 'id', width: 190 }, { title: '操作人', key: 'operatorId', width: 150 }, { title: '操作类型', key: 'operationType', width: 160 },
|
||||
{ title: '对象类型', key: 'targetType', width: 130 }, { title: '对象ID', key: 'targetId', width: 190 }, { title: '原因/结果', key: 'reason', width: 260, ellipsis: { tooltip: true } },
|
||||
{ title: '请求ID', key: 'requestId', width: 220 }, { title: '操作时间', key: 'createdAt', width: 180 },
|
||||
{ title: '日志ID', key: 'id', width: 190 },
|
||||
{ title: '操作人', key: 'operatorId', width: 150 },
|
||||
{ title: '操作类型', key: 'operationType', width: 160 },
|
||||
{ title: '对象类型', key: 'targetType', width: 130 },
|
||||
{ title: '对象ID', key: 'targetId', width: 190 },
|
||||
{ title: '原因/结果', key: 'reason', width: 260, ellipsis: { tooltip: true } },
|
||||
{ title: '请求ID', key: 'requestId', width: 220 },
|
||||
{ title: '操作时间', key: 'createdAt', width: 180 },
|
||||
];
|
||||
|
||||
@@ -1,25 +1,214 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="举报处理">查看物友动态、评论及用户举报并记录处理结果。</n-card></div>
|
||||
<div class="n-layout-page-header"
|
||||
><n-card :bordered="false" title="举报处理"
|
||||
>查看物友动态、评论及用户举报并记录处理结果。</n-card
|
||||
></div
|
||||
>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="(row) => row.id" :actionColumn="actionColumn" :scroll-x="1650" />
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="1650"
|
||||
/>
|
||||
</n-card>
|
||||
<n-modal v-model:show="showHandle" preset="dialog" title="处理举报" positive-text="确认" negative-text="取消" @positive-click="submitHandle">
|
||||
<n-space vertical><n-select v-model:value="handleForm.action" :options="handleOptions" /><n-input v-model:value="handleForm.handleResult" type="textarea" placeholder="办结或驳回时必须填写处理结果" /></n-space>
|
||||
<n-modal
|
||||
v-model:show="showHandle"
|
||||
preset="dialog"
|
||||
title="处理举报"
|
||||
positive-text="确认"
|
||||
negative-text="取消"
|
||||
@positive-click="submitHandle"
|
||||
>
|
||||
<n-space vertical>
|
||||
<n-select v-model:value="handleForm.action" :options="handleOptions" />
|
||||
<n-select
|
||||
v-model:value="handleForm.contentAction"
|
||||
:options="contentActionOptions"
|
||||
:disabled="handleForm.action !== 'resolve'"
|
||||
placeholder="内容联动处置"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="handleForm.userAction"
|
||||
:options="userActionOptions"
|
||||
:disabled="handleForm.action !== 'resolve'"
|
||||
placeholder="用户联动处置"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="handleForm.reason"
|
||||
type="textarea"
|
||||
placeholder="联动处置时必须填写原因"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="handleForm.handleResult"
|
||||
type="textarea"
|
||||
placeholder="办结或驳回时必须填写处理结果"
|
||||
/>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDetail" preset="card" title="举报详情" class="detail-modal">
|
||||
<n-spin :show="detailLoading">
|
||||
<n-descriptions v-if="detail" bordered :column="2">
|
||||
<n-descriptions-item label="举报人"
|
||||
>{{ detail.reporterNickname || '-' }}({{ detail.reporterId }})</n-descriptions-item
|
||||
>
|
||||
<n-descriptions-item label="被举报人"
|
||||
>{{ detail.reportedUserNickname || '-' }}({{
|
||||
detail.reportedUserId
|
||||
}})</n-descriptions-item
|
||||
>
|
||||
<n-descriptions-item label="对象"
|
||||
>{{ detail.objectType }} / {{ detail.objectId }}</n-descriptions-item
|
||||
>
|
||||
<n-descriptions-item label="原因">{{ detail.reasonCode }}</n-descriptions-item>
|
||||
<n-descriptions-item label="说明" :span="2">{{
|
||||
detail.description || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="证据文件" :span="2">{{
|
||||
formatEvidence(detail.evidenceFileIds)
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detail.targetContent" label="目标内容" :span="2">{{
|
||||
detail.targetContent.contentText || '-'
|
||||
}}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-divider title-placement="left">该用户近期举报记录</n-divider>
|
||||
<n-data-table :columns="historyColumns" :data="detail?.history || []" :pagination="false" />
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { h, onMounted, reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table'; import { BasicForm, useForm } from '@/components/Form';
|
||||
import { ReportHandle, ReportList } from '@/api/cultivate/content'; import { columns, loadOptions, ReportState, schemas } from './model';
|
||||
const actionRef = ref(); const searchFormRef = ref<any>({}); const showHandle = ref(false); const selected = ref<ReportState>(); const message = useMessage();
|
||||
const handleForm = reactive({ action: 'processing', handleResult: '' }); const handleOptions = [{ label: '标记处理中', value: 'processing' }, { label: '确认处理', value: 'resolve' }, { label: '驳回举报', value: 'reject' }];
|
||||
const [register] = useForm({ gridProps: { cols: '1 s:1 m:2 l:4' }, labelWidth: 90, schemas }); const loadDataTable = (res) => ReportList({ ...searchFormRef.value?.formModel, ...res }); const reloadTable = () => actionRef.value?.reload();
|
||||
function openHandle(row: ReportState) { selected.value = row; handleForm.action = row.status === 'submitted' ? 'processing' : 'resolve'; handleForm.handleResult = ''; showHandle.value = true; }
|
||||
async function submitHandle() { if (!selected.value) return false; if (handleForm.action !== 'processing' && !handleForm.handleResult.trim()) { message.warning('请填写处理结果'); return false; } await ReportHandle({ id: selected.value.id, ...handleForm }); message.success('处理成功'); reloadTable(); return true; }
|
||||
const actionColumn = reactive({ width: 110, title: '操作', key: 'action', fixed: 'right', render(row: ReportState) { return h(TableAction as any, { style: 'button', actions: [{ label: '处理', onClick: () => openHandle(row), auth: ['/cultivate/reports/handle'], disabled: row.status === 'resolved' || row.status === 'rejected' }] }); } });
|
||||
onMounted(loadOptions);
|
||||
import { h, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { ReportHandle, ReportList, ReportView } from '@/api/cultivate/content';
|
||||
import { columns, loadOptions, ReportState, schemas } from './model';
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const showHandle = ref(false);
|
||||
const showDetail = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detail = ref<any>();
|
||||
const selected = ref<ReportState>();
|
||||
const message = useMessage();
|
||||
const handleForm = reactive({
|
||||
action: 'processing',
|
||||
handleResult: '',
|
||||
contentAction: 'none',
|
||||
userAction: 'none',
|
||||
reason: '',
|
||||
});
|
||||
const handleOptions = [
|
||||
{ label: '标记处理中', value: 'processing' },
|
||||
{ label: '确认处理', value: 'resolve' },
|
||||
{ label: '驳回举报', value: 'reject' },
|
||||
];
|
||||
const contentActionOptions = [
|
||||
{ label: '不处置内容', value: 'none' },
|
||||
{ label: '下架被举报内容', value: 'take_down' },
|
||||
];
|
||||
const userActionOptions = [
|
||||
{ label: '不处置用户', value: 'none' },
|
||||
{ label: '冻结用户', value: 'freeze' },
|
||||
{ label: '封禁用户', value: 'ban' },
|
||||
];
|
||||
const historyColumns = [
|
||||
{ title: '举报ID', key: 'id' },
|
||||
{ title: '对象类型', key: 'objectType' },
|
||||
{ title: '原因', key: 'reasonCode' },
|
||||
{ title: '状态', key: 'status' },
|
||||
{ title: '提交时间', key: 'createdAt' },
|
||||
];
|
||||
watch(
|
||||
() => handleForm.action,
|
||||
(action) => {
|
||||
if (action !== 'resolve') {
|
||||
handleForm.contentAction = 'none';
|
||||
handleForm.userAction = 'none';
|
||||
handleForm.reason = '';
|
||||
}
|
||||
}
|
||||
);
|
||||
const [register] = useForm({ gridProps: { cols: '1 s:1 m:2 l:4' }, labelWidth: 90, schemas });
|
||||
const loadDataTable = (res) => ReportList({ ...searchFormRef.value?.formModel, ...res });
|
||||
const reloadTable = () => actionRef.value?.reload();
|
||||
function openHandle(row: ReportState) {
|
||||
selected.value = row;
|
||||
Object.assign(handleForm, {
|
||||
action: row.status === 'submitted' ? 'processing' : 'resolve',
|
||||
handleResult: '',
|
||||
contentAction: 'none',
|
||||
userAction: 'none',
|
||||
reason: '',
|
||||
});
|
||||
showHandle.value = true;
|
||||
}
|
||||
async function openDetail(row: ReportState) {
|
||||
showDetail.value = true;
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await ReportView({ id: row.id });
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
function formatEvidence(value: any) {
|
||||
if (!value) return '-';
|
||||
if (Array.isArray(value)) return value.join('、');
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
async function submitHandle() {
|
||||
if (!selected.value) return false;
|
||||
if (handleForm.action !== 'processing' && !handleForm.handleResult.trim()) {
|
||||
message.warning('请填写处理结果');
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(handleForm.contentAction !== 'none' || handleForm.userAction !== 'none') &&
|
||||
!handleForm.reason.trim()
|
||||
) {
|
||||
message.warning('请填写联动处置原因');
|
||||
return false;
|
||||
}
|
||||
await ReportHandle({ id: selected.value.id, ...handleForm });
|
||||
message.success('处理成功');
|
||||
reloadTable();
|
||||
return true;
|
||||
}
|
||||
const actionColumn = reactive({
|
||||
width: 160,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(row: ReportState) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{ label: '详情', onClick: () => openDetail(row), auth: ['/cultivate/reports/view'] },
|
||||
{
|
||||
label: '处理',
|
||||
onClick: () => openHandle(row),
|
||||
auth: ['/cultivate/reports/handle'],
|
||||
disabled: row.status === 'resolved' || row.status === 'rejected',
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
<style scoped>
|
||||
.detail-modal {
|
||||
width: min(1000px, 92vw);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user