diff --git a/backend/app/http/super/v1/contents.go b/backend/app/http/super/v1/contents.go index e00a61c..b8b1660 100644 --- a/backend/app/http/super/v1/contents.go +++ b/backend/app/http/super/v1/contents.go @@ -86,3 +86,19 @@ func (c *contents) UpdateStatus(ctx fiber.Ctx, tenantID, contentID int64, form * func (c *contents) Review(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperContentReviewForm) error { return services.Super.ReviewContent(ctx, user.ID, id, form) } + +// Batch review contents +// +// @Router /super/v1/contents/review/batch [post] +// @Summary Batch review contents +// @Description Batch review contents +// @Tags Content +// @Accept json +// @Produce json +// @Param form body dto.SuperContentBatchReviewForm true "Batch review form" +// @Success 200 {string} string "Reviewed" +// @Bind user local key(__ctx_user) +// @Bind form body +func (c *contents) BatchReview(ctx fiber.Ctx, user *models.User, form *dto.SuperContentBatchReviewForm) error { + return services.Super.BatchReviewContents(ctx, user.ID, form) +} diff --git a/backend/app/http/super/v1/dto/super.go b/backend/app/http/super/v1/dto/super.go index 241c3e9..9eabe92 100644 --- a/backend/app/http/super/v1/dto/super.go +++ b/backend/app/http/super/v1/dto/super.go @@ -391,6 +391,15 @@ type SuperContentReviewForm struct { Reason string `json:"reason"` } +type SuperContentBatchReviewForm struct { + // ContentIDs 待审核内容ID列表。 + ContentIDs []int64 `json:"content_ids" validate:"required,min=1,dive,gt=0"` + // Action 审核动作(approve/reject)。 + Action string `json:"action" validate:"required,oneof=approve reject"` + // Reason 审核说明(驳回时填写,便于作者修正)。 + Reason string `json:"reason"` +} + type SuperTenantUserItem struct { // User 用户信息。 User *SuperUserLite `json:"user"` diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index 550a255..4792770 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -75,6 +75,12 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), Body[dto.SuperContentReviewForm]("form"), )) + r.log.Debugf("Registering route: Post /super/v1/contents/review/batch -> contents.BatchReview") + router.Post("/super/v1/contents/review/batch"[len(r.Path()):], Func2( + r.contents.BatchReview, + Local[*models.User]("__ctx_user"), + Body[dto.SuperContentBatchReviewForm]("form"), + )) // Register routes for controller: coupons r.log.Debugf("Registering route: Get /super/v1/coupons -> coupons.List") router.Get("/super/v1/coupons"[len(r.Path()):], DataFunc1( diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 1ebeecf..17fb374 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -1061,6 +1061,97 @@ func (s *super) ReviewContent(ctx context.Context, operatorID, contentID int64, return nil } +func (s *super) BatchReviewContents(ctx context.Context, operatorID int64, form *super_dto.SuperContentBatchReviewForm) error { + if operatorID == 0 { + return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") + } + if form == nil { + return errorx.ErrBadRequest.WithMsg("审核参数不能为空") + } + + action := strings.ToLower(strings.TrimSpace(form.Action)) + if action != "approve" && action != "reject" { + return errorx.ErrBadRequest.WithMsg("审核动作非法") + } + + // 去重并过滤非法ID,确保审核集合有效。 + unique := make(map[int64]struct{}) + contentIDs := make([]int64, 0, len(form.ContentIDs)) + for _, id := range form.ContentIDs { + if id <= 0 { + continue + } + if _, ok := unique[id]; ok { + continue + } + unique[id] = struct{}{} + contentIDs = append(contentIDs, id) + } + if len(contentIDs) == 0 { + return errorx.ErrBadRequest.WithMsg("内容ID不能为空") + } + + // 审核动作映射为内容状态。 + nextStatus := consts.ContentStatusBlocked + if action == "approve" { + nextStatus = consts.ContentStatusPublished + } + reason := strings.TrimSpace(form.Reason) + + var contents []*models.Content + err := models.Q.Transaction(func(tx *models.Query) error { + tbl, q := tx.Content.QueryContext(ctx) + list, err := q.Where(tbl.ID.In(contentIDs...)).Find() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if len(list) != len(contentIDs) { + return errorx.ErrRecordNotFound.WithMsg("部分内容不存在") + } + for _, content := range list { + if content.Status != consts.ContentStatusReviewing { + return errorx.ErrStatusConflict.WithMsg("仅可审核待审核内容") + } + } + + updates := &models.Content{ + Status: nextStatus, + UpdatedAt: time.Now(), + } + if nextStatus == consts.ContentStatusPublished { + updates.PublishedAt = time.Now() + } + if _, err := q.Where(tbl.ID.In(contentIDs...)).Updates(updates); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + contents = list + return nil + }) + if err != nil { + return err + } + + // 审核完成后通知作者并记录审计日志(批量逐条记录,便于追溯)。 + title := "内容审核结果" + detail := "内容审核通过" + if action == "reject" { + detail = "内容审核驳回" + if reason != "" { + detail += ",原因:" + reason + } + } + for _, content := range contents { + if Notification != nil { + _ = Notification.Send(ctx, content.TenantID, content.UserID, string(consts.NotificationTypeAudit), title, detail) + } + if Audit != nil { + Audit.Log(ctx, operatorID, "review_content", cast.ToString(content.ID), detail) + } + } + + return nil +} + func (s *super) ListOrders(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) { tbl, q := models.OrderQuery.QueryContext(ctx) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 9006140..ebd181a 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -133,6 +133,40 @@ const docTemplate = `{ } } }, + "/super/v1/contents/review/batch": { + "post": { + "description": "Batch review contents", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "Batch review contents", + "parameters": [ + { + "description": "Batch review form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperContentBatchReviewForm" + } + } + ], + "responses": { + "200": { + "description": "Reviewed", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/contents/{id}/review": { "post": { "description": "Review content", @@ -5502,6 +5536,35 @@ const docTemplate = `{ } } }, + "dto.SuperContentBatchReviewForm": { + "type": "object", + "required": [ + "action", + "content_ids" + ], + "properties": { + "action": { + "description": "Action 审核动作(approve/reject)。", + "type": "string", + "enum": [ + "approve", + "reject" + ] + }, + "content_ids": { + "description": "ContentIDs 待审核内容ID列表。", + "type": "array", + "minItems": 1, + "items": { + "type": "integer" + } + }, + "reason": { + "description": "Reason 审核说明(驳回时填写,便于作者修正)。", + "type": "string" + } + } + }, "dto.SuperContentReviewForm": { "type": "object", "required": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 5def424..53b3741 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -127,6 +127,40 @@ } } }, + "/super/v1/contents/review/batch": { + "post": { + "description": "Batch review contents", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "Batch review contents", + "parameters": [ + { + "description": "Batch review form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperContentBatchReviewForm" + } + } + ], + "responses": { + "200": { + "description": "Reviewed", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/contents/{id}/review": { "post": { "description": "Review content", @@ -5496,6 +5530,35 @@ } } }, + "dto.SuperContentBatchReviewForm": { + "type": "object", + "required": [ + "action", + "content_ids" + ], + "properties": { + "action": { + "description": "Action 审核动作(approve/reject)。", + "type": "string", + "enum": [ + "approve", + "reject" + ] + }, + "content_ids": { + "description": "ContentIDs 待审核内容ID列表。", + "type": "array", + "minItems": 1, + "items": { + "type": "integer" + } + }, + "reason": { + "description": "Reason 审核说明(驳回时填写,便于作者修正)。", + "type": "string" + } + } + }, "dto.SuperContentReviewForm": { "type": "object", "required": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 70fedde..37c3399 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1003,6 +1003,27 @@ definitions: description: Likes 累计点赞数。 type: integer type: object + dto.SuperContentBatchReviewForm: + properties: + action: + description: Action 审核动作(approve/reject)。 + enum: + - approve + - reject + type: string + content_ids: + description: ContentIDs 待审核内容ID列表。 + items: + type: integer + minItems: 1 + type: array + reason: + description: Reason 审核说明(驳回时填写,便于作者修正)。 + type: string + required: + - action + - content_ids + type: object dto.SuperContentReviewForm: properties: action: @@ -2138,6 +2159,28 @@ paths: summary: Review content tags: - Content + /super/v1/contents/review/batch: + post: + consumes: + - application/json + description: Batch review contents + parameters: + - description: Batch review form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperContentBatchReviewForm' + produces: + - application/json + responses: + "200": + description: Reviewed + schema: + type: string + summary: Batch review contents + tags: + - Content /super/v1/coupons: get: consumes: diff --git a/docs/superadmin_progress.md b/docs/superadmin_progress.md index 9652282..5a49db2 100644 --- a/docs/superadmin_progress.md +++ b/docs/superadmin_progress.md @@ -42,8 +42,8 @@ ### 2.7 内容治理 `/superadmin/contents` - 状态:**部分完成** -- 已有:跨租户列表、下架。 -- 缺口:审核流(review)、评论治理/举报处理、批量处置。 +- 已有:跨租户列表、下架、审核流(含批量审核)。 +- 缺口:评论治理、内容举报处理、批量处置扩展(如批量下架/封禁)。 ### 2.8 订单与退款 `/superadmin/orders` - 状态:**已完成** diff --git a/frontend/superadmin/src/service/ContentService.js b/frontend/superadmin/src/service/ContentService.js index 929f832..47706aa 100644 --- a/frontend/superadmin/src/service/ContentService.js +++ b/frontend/superadmin/src/service/ContentService.js @@ -109,5 +109,26 @@ export const ContentService = { method: 'PATCH', body: { status } }); + }, + async reviewContent(contentID, { action, reason } = {}) { + if (!contentID) throw new Error('contentID is required'); + return requestJson(`/super/v1/contents/${contentID}/review`, { + method: 'POST', + body: { + action, + reason + } + }); + }, + async batchReviewContents({ content_ids, action, reason } = {}) { + if (!Array.isArray(content_ids) || content_ids.length === 0) throw new Error('content_ids is required'); + return requestJson('/super/v1/contents/review/batch', { + method: 'POST', + body: { + content_ids, + action, + reason + } + }); } }; diff --git a/frontend/superadmin/src/views/superadmin/Contents.vue b/frontend/superadmin/src/views/superadmin/Contents.vue index 7bdb169..9b38032 100644 --- a/frontend/superadmin/src/views/superadmin/Contents.vue +++ b/frontend/superadmin/src/views/superadmin/Contents.vue @@ -32,6 +32,17 @@ const priceAmountMin = ref(null); const priceAmountMax = ref(null); const sortField = ref('id'); const sortOrder = ref(-1); +const selectedContents = ref([]); + +const reviewDialogVisible = ref(false); +const reviewSubmitting = ref(false); +const reviewAction = ref('approve'); +const reviewReason = ref(''); +const reviewTargetIDs = ref([]); +const reviewActionOptions = [ + { label: '通过', value: 'approve' }, + { label: '驳回', value: 'reject' } +]; const statusOptions = [ { label: '全部', value: '' }, @@ -67,6 +78,11 @@ function parseDate(value) { return date; } +function getContentID(row) { + const id = Number(row?.content?.id ?? row?.id ?? 0); + return Number.isFinite(id) ? id : 0; +} + function formatDate(value) { if (!value) return '-'; if (String(value).startsWith('0001-01-01')) return '-'; @@ -159,6 +175,9 @@ function getContentVisibilitySeverity(value) { } } +const selectedCount = computed(() => selectedContents.value.length); +const reviewTargetCount = computed(() => reviewTargetIDs.value.length); + async function loadContents() { loading.value = true; try { @@ -184,6 +203,7 @@ async function loadContents() { sortOrder: sortOrder.value }); contents.value = (result.items || []).map((item) => ({ ...item, __key: item?.content?.id ?? undefined })); + selectedContents.value = []; totalRecords.value = result.total; } catch (error) { toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载内容列表', life: 4000 }); @@ -192,6 +212,60 @@ async function loadContents() { } } +function openReviewDialog(row) { + const id = getContentID(row); + if (!id) return; + reviewTargetIDs.value = [id]; + reviewAction.value = 'approve'; + reviewReason.value = ''; + reviewDialogVisible.value = true; +} + +function openBatchReviewDialog(action) { + if (!selectedContents.value.length) { + toast.add({ severity: 'warn', summary: '请先选择内容', detail: '至少选择 1 条内容进行审核', life: 3000 }); + return; + } + const ids = selectedContents.value.map((row) => getContentID(row)).filter((id) => id > 0); + if (!ids.length) { + toast.add({ severity: 'warn', summary: '选择无效', detail: '未识别到可审核的内容', life: 3000 }); + return; + } + reviewTargetIDs.value = ids; + reviewAction.value = action || 'approve'; + reviewReason.value = ''; + reviewDialogVisible.value = true; +} + +async function confirmReview() { + const action = reviewAction.value; + const reason = reviewReason.value.trim(); + const ids = reviewTargetIDs.value.filter((id) => id > 0); + if (!ids.length) return; + if (action === 'reject' && !reason) { + toast.add({ severity: 'warn', summary: '请输入原因', detail: '驳回时需填写原因', life: 3000 }); + return; + } + + reviewSubmitting.value = true; + try { + if (ids.length === 1) { + await ContentService.reviewContent(ids[0], { action, reason }); + } else { + await ContentService.batchReviewContents({ content_ids: ids, action, reason }); + } + toast.add({ severity: 'success', summary: '审核成功', detail: `已处理 ${ids.length} 条内容`, life: 3000 }); + reviewDialogVisible.value = false; + reviewTargetIDs.value = []; + selectedContents.value = []; + await loadContents(); + } catch (error) { + toast.add({ severity: 'error', summary: '审核失败', detail: error?.message || '无法完成审核', life: 4000 }); + } finally { + reviewSubmitting.value = false; + } +} + function onSearch() { page.value = 1; loadContents(); @@ -265,7 +339,12 @@ watch(

内容列表

平台侧汇总(跨租户) -