From 568f5cda4395b95ae2d8502cece3dc35db494b72 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 24 Dec 2025 16:10:07 +0800 Subject: [PATCH] feat: add tenant content management features for superadmin - Implemented API endpoints for listing tenant contents and updating content status. - Added Swagger documentation for new endpoints: - GET /super/v1/tenants/{tenantID}/contents - PATCH /super/v1/tenants/{tenantID}/contents/{contentID}/status - Created DTOs for content item and status update form. - Enhanced frontend to support content management in the tenant detail page. - Added search and filter functionalities for tenant contents. - Implemented unpublish functionality with confirmation dialog. - Updated service layer to handle new content management logic. --- backend/app/http/super/dto/content.go | 44 +++ backend/app/http/super/dto/content_status.go | 8 + backend/app/http/super/routes.gen.go | 13 + backend/app/http/super/tenant_content.go | 30 ++ .../app/http/super/tenant_content_status.go | 47 +++ backend/app/services/content_super.go | 238 ++++++++++++++ backend/docs/docs.go | 221 +++++++++++++ backend/docs/swagger.json | 221 +++++++++++++ backend/docs/swagger.yaml | 146 +++++++++ frontend/superadmin/SUPERADMIN_PAGES.md | 2 +- frontend/superadmin/dist/index.html | 4 +- .../superadmin/src/service/ContentService.js | 70 ++++ .../src/views/superadmin/TenantDetail.vue | 303 ++++++++++++++++++ 13 files changed, 1344 insertions(+), 3 deletions(-) create mode 100644 backend/app/http/super/dto/content.go create mode 100644 backend/app/http/super/dto/content_status.go create mode 100644 backend/app/http/super/tenant_content.go create mode 100644 backend/app/http/super/tenant_content_status.go create mode 100644 backend/app/services/content_super.go create mode 100644 frontend/superadmin/src/service/ContentService.js diff --git a/backend/app/http/super/dto/content.go b/backend/app/http/super/dto/content.go new file mode 100644 index 0000000..de8de68 --- /dev/null +++ b/backend/app/http/super/dto/content.go @@ -0,0 +1,44 @@ +package dto + +import ( + "strings" + "time" + + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" +) + +// TenantContentFilter defines list query filters for tenant contents (superadmin). +type TenantContentFilter struct { + requests.Pagination `json:",inline" query:",inline"` + requests.SortQueryFilter `json:",inline" query:",inline"` + + Keyword *string `json:"keyword,omitempty" query:"keyword"` + + Status *consts.ContentStatus `json:"status,omitempty" query:"status"` + Visibility *consts.ContentVisibility `json:"visibility,omitempty" query:"visibility"` + + UserID *int64 `json:"user_id,omitempty" query:"user_id"` + + PublishedAtFrom *time.Time `json:"published_at_from,omitempty" query:"published_at_from"` + PublishedAtTo *time.Time `json:"published_at_to,omitempty" query:"published_at_to"` + CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` + CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` +} + +func (f *TenantContentFilter) KeywordTrimmed() string { + if f == nil || f.Keyword == nil { + return "" + } + return strings.TrimSpace(*f.Keyword) +} + +type SuperTenantContentItem struct { + Content *models.Content `json:"content,omitempty"` + Price *models.ContentPrice `json:"price,omitempty"` + Owner *SuperUserLite `json:"owner,omitempty"` + + StatusDescription string `json:"status_description,omitempty"` + VisibilityDescription string `json:"visibility_description,omitempty"` +} diff --git a/backend/app/http/super/dto/content_status.go b/backend/app/http/super/dto/content_status.go new file mode 100644 index 0000000..6fc5229 --- /dev/null +++ b/backend/app/http/super/dto/content_status.go @@ -0,0 +1,8 @@ +package dto + +import "quyun/v2/pkg/consts" + +type SuperTenantContentStatusUpdateForm struct { + // Status supports: unpublished (下架) / blocked (封禁) + Status consts.ContentStatus `json:"status" validate:"required,oneof=unpublished blocked"` +} diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go index ebd1ddd..141eeb5 100644 --- a/backend/app/http/super/routes.gen.go +++ b/backend/app/http/super/routes.gen.go @@ -87,6 +87,12 @@ func (r *Routes) Register(router fiber.Router) { r.tenant.detail, PathParam[int64]("tenantID"), )) + r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID/contents -> tenant.contents") + router.Get("/super/v1/tenants/:tenantID/contents"[len(r.Path()):], DataFunc2( + r.tenant.contents, + PathParam[int64]("tenantID"), + Query[dto.TenantContentFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID/users -> tenant.users") router.Get("/super/v1/tenants/:tenantID/users"[len(r.Path()):], DataFunc2( r.tenant.users, @@ -103,6 +109,13 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("tenantID"), Body[dto.TenantExpireUpdateForm]("form"), )) + r.log.Debugf("Registering route: Patch /super/v1/tenants/:tenantID/contents/:contentID/status -> tenant.updateContentStatus") + router.Patch("/super/v1/tenants/:tenantID/contents/:contentID/status"[len(r.Path()):], DataFunc3( + r.tenant.updateContentStatus, + PathParam[int64]("tenantID"), + PathParam[int64]("contentID"), + Body[dto.SuperTenantContentStatusUpdateForm]("form"), + )) r.log.Debugf("Registering route: Patch /super/v1/tenants/:tenantID/status -> tenant.updateStatus") router.Patch("/super/v1/tenants/:tenantID/status"[len(r.Path()):], Func2( r.tenant.updateStatus, diff --git a/backend/app/http/super/tenant_content.go b/backend/app/http/super/tenant_content.go new file mode 100644 index 0000000..8ac7dad --- /dev/null +++ b/backend/app/http/super/tenant_content.go @@ -0,0 +1,30 @@ +package super + +import ( + "quyun/v2/app/http/super/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + + "github.com/gofiber/fiber/v3" +) + +// contents +// +// @Summary 租户内容列表(平台侧) +// @Tags Super +// @Accept json +// @Produce json +// @Param tenantID path int64 true "TenantID" +// @Param filter query dto.TenantContentFilter true "Filter" +// @Success 200 {object} requests.Pager{items=dto.SuperTenantContentItem} +// +// @Router /super/v1/tenants/:tenantID/contents [get] +// @Bind tenantID path +// @Bind filter query +func (*tenant) contents(ctx fiber.Ctx, tenantID int64, filter *dto.TenantContentFilter) (*requests.Pager, error) { + if filter == nil { + filter = &dto.TenantContentFilter{} + } + filter.Pagination.Format() + return services.Content.SuperTenantContentsPage(ctx, tenantID, filter) +} diff --git a/backend/app/http/super/tenant_content_status.go b/backend/app/http/super/tenant_content_status.go new file mode 100644 index 0000000..c48c4fb --- /dev/null +++ b/backend/app/http/super/tenant_content_status.go @@ -0,0 +1,47 @@ +package super + +import ( + "time" + + "quyun/v2/app/errorx" + "quyun/v2/app/http/super/dto" + "quyun/v2/app/services" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + "quyun/v2/providers/jwt" + + "github.com/gofiber/fiber/v3" +) + +// updateContentStatus +// +// @Summary 更新租户内容状态(平台侧:下架/封禁) +// @Tags Super +// @Accept json +// @Produce json +// @Param tenantID path int64 true "TenantID" +// @Param contentID path int64 true "ContentID" +// @Param form body dto.SuperTenantContentStatusUpdateForm true "Form" +// @Success 200 {object} models.Content +// +// @Router /super/v1/tenants/:tenantID/contents/:contentID/status [patch] +// @Bind tenantID path +// @Bind contentID path +// @Bind form body +func (*tenant) updateContentStatus( + ctx fiber.Ctx, + tenantID int64, + contentID int64, + form *dto.SuperTenantContentStatusUpdateForm, +) (*models.Content, error) { + if form == nil { + return nil, errorx.ErrInvalidParameter + } + + claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) + if !ok || claims == nil || claims.UserID <= 0 { + return nil, errorx.ErrTokenInvalid + } + + return services.Content.SuperUpdateTenantContentStatus(ctx, claims.UserID, tenantID, contentID, form.Status, time.Now()) +} diff --git a/backend/app/services/content_super.go b/backend/app/services/content_super.go new file mode 100644 index 0000000..bf6bb23 --- /dev/null +++ b/backend/app/services/content_super.go @@ -0,0 +1,238 @@ +package services + +import ( + "context" + "strings" + "time" + + "quyun/v2/app/errorx" + superdto "quyun/v2/app/http/super/dto" + "quyun/v2/app/requests" + "quyun/v2/database" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + "github.com/pkg/errors" + "github.com/samber/lo" + log "github.com/sirupsen/logrus" + "go.ipao.vip/gen" + "go.ipao.vip/gen/field" +) + +// SuperTenantContentsPage returns tenant contents list for superadmin. +func (s *content) SuperTenantContentsPage(ctx context.Context, tenantID int64, filter *superdto.TenantContentFilter) (*requests.Pager, error) { + if tenantID <= 0 { + return nil, errors.New("tenant_id must be > 0") + } + if filter == nil { + filter = &superdto.TenantContentFilter{} + } + + log.WithFields(log.Fields{ + "tenant_id": tenantID, + "page": filter.Page, + "limit": filter.Limit, + }).Info("services.content.super_tenant_contents_page") + + tbl, query := models.ContentQuery.QueryContext(ctx) + conds := []gen.Condition{ + tbl.TenantID.Eq(tenantID), + tbl.DeletedAt.IsNull(), + } + + if kw := strings.TrimSpace(filter.KeywordTrimmed()); kw != "" { + conds = append(conds, tbl.Title.Like(database.WrapLike(kw))) + } + if filter.Status != nil { + conds = append(conds, tbl.Status.Eq(*filter.Status)) + } + if filter.Visibility != nil { + conds = append(conds, tbl.Visibility.Eq(*filter.Visibility)) + } + if filter.UserID != nil && *filter.UserID > 0 { + conds = append(conds, tbl.UserID.Eq(*filter.UserID)) + } + if filter.PublishedAtFrom != nil { + conds = append(conds, tbl.PublishedAt.Gte(*filter.PublishedAtFrom)) + } + if filter.PublishedAtTo != nil { + conds = append(conds, tbl.PublishedAt.Lte(*filter.PublishedAtTo)) + } + if filter.CreatedAtFrom != nil { + conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) + } + if filter.CreatedAtTo != nil { + conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) + } + + filter.Pagination.Format() + + orderBys := make([]field.Expr, 0, 6) + allowedAsc := map[string]field.Expr{ + "id": tbl.ID.Asc(), + "title": tbl.Title.Asc(), + "user_id": tbl.UserID.Asc(), + "status": tbl.Status.Asc(), + "visibility": tbl.Visibility.Asc(), + "published_at": tbl.PublishedAt.Asc(), + "created_at": tbl.CreatedAt.Asc(), + "updated_at": tbl.UpdatedAt.Asc(), + } + allowedDesc := map[string]field.Expr{ + "id": tbl.ID.Desc(), + "title": tbl.Title.Desc(), + "user_id": tbl.UserID.Desc(), + "status": tbl.Status.Desc(), + "visibility": tbl.Visibility.Desc(), + "published_at": tbl.PublishedAt.Desc(), + "created_at": tbl.CreatedAt.Desc(), + "updated_at": tbl.UpdatedAt.Desc(), + } + for _, f := range filter.AscFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedAsc[f]; ok { + orderBys = append(orderBys, ob) + } + } + for _, f := range filter.DescFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedDesc[f]; ok { + orderBys = append(orderBys, ob) + } + } + if len(orderBys) == 0 { + orderBys = append(orderBys, tbl.ID.Desc()) + } else { + orderBys = append(orderBys, tbl.ID.Desc()) + } + + items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + contentIDs := lo.Map(items, func(item *models.Content, _ int) int64 { + if item == nil { + return 0 + } + return item.ID + }) + contentIDs = lo.Filter(contentIDs, func(id int64, _ int) bool { return id > 0 }) + + priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs) + if err != nil { + return nil, err + } + + ownerIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { + if item == nil || item.UserID <= 0 { + return 0, false + } + return item.UserID, true + })) + + ownerMap := map[int64]*superdto.SuperUserLite{} + if len(ownerIDs) > 0 { + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + users, err := uQuery.Where(uTbl.ID.In(ownerIDs...)).Find() + if err != nil { + return nil, err + } + for _, u := range users { + if u == nil { + continue + } + ownerMap[u.ID] = &superdto.SuperUserLite{ + ID: u.ID, + Username: u.Username, + Status: u.Status, + Roles: u.Roles, + VerifiedAt: u.VerifiedAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + StatusDescription: u.Status.Description(), + } + } + } + + respItems := lo.Map(items, func(model *models.Content, _ int) *superdto.SuperTenantContentItem { + if model == nil { + return nil + } + return &superdto.SuperTenantContentItem{ + Content: model, + Price: priceByContent[model.ID], + Owner: ownerMap[model.UserID], + StatusDescription: model.Status.Description(), + VisibilityDescription: model.Visibility.Description(), + } + }) + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: respItems, + }, nil +} + +func (s *content) SuperUpdateTenantContentStatus( + ctx context.Context, + operatorUserID, tenantID, contentID int64, + status consts.ContentStatus, + now time.Time, +) (*models.Content, error) { + if operatorUserID <= 0 { + return nil, errorx.ErrTokenInvalid + } + if tenantID <= 0 { + return nil, errors.New("tenant_id must be > 0") + } + if contentID <= 0 { + return nil, errors.New("content_id must be > 0") + } + if status != consts.ContentStatusUnpublished && status != consts.ContentStatusBlocked { + return nil, errorx.ErrInvalidParameter.WithMsg("invalid status") + } + + log.WithFields(log.Fields{ + "operator_user_id": operatorUserID, + "tenant_id": tenantID, + "content_id": contentID, + "status": status, + }).Info("services.content.super_update_tenant_content_status") + + tbl, query := models.ContentQuery.QueryContext(ctx) + + model, err := query.Where( + tbl.TenantID.Eq(tenantID), + tbl.ID.Eq(contentID), + tbl.DeletedAt.IsNull(), + ).First() + if err != nil { + return nil, err + } + + if status == consts.ContentStatusUnpublished && model.Status != consts.ContentStatusPublished { + return nil, errorx.ErrPreconditionFailed.WithMsg("content is not published") + } + + if _, err := query.Where( + tbl.TenantID.Eq(tenantID), + tbl.ID.Eq(contentID), + tbl.DeletedAt.IsNull(), + ).UpdateSimple( + tbl.Status.Value(status), + ); err != nil { + return nil, err + } + + model.Status = status + model.UpdatedAt = now + return model, nil +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 99ea164..8638dc5 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -570,6 +570,187 @@ const docTemplate = `{ "responses": {} } }, + "/super/v1/tenants/{tenantID}/contents": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Super" + ], + "summary": "租户内容列表(平台侧)", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "TenantID", + "name": "tenantID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", + "name": "asc", + "in": "query" + }, + { + "type": "string", + "name": "created_at_from", + "in": "query" + }, + { + "type": "string", + "name": "created_at_to", + "in": "query" + }, + { + "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", + "name": "desc", + "in": "query" + }, + { + "type": "string", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "type": "string", + "name": "published_at_from", + "in": "query" + }, + { + "type": "string", + "name": "published_at_to", + "in": "query" + }, + { + "enum": [ + "draft", + "reviewing", + "published", + "unpublished", + "blocked" + ], + "type": "string", + "x-enum-varnames": [ + "ContentStatusDraft", + "ContentStatusReviewing", + "ContentStatusPublished", + "ContentStatusUnpublished", + "ContentStatusBlocked" + ], + "name": "status", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "enum": [ + "public", + "tenant_only", + "private" + ], + "type": "string", + "x-enum-varnames": [ + "ContentVisibilityPublic", + "ContentVisibilityTenantOnly", + "ContentVisibilityPrivate" + ], + "name": "visibility", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/dto.SuperTenantContentItem" + } + } + } + ] + } + } + } + } + }, + "/super/v1/tenants/{tenantID}/contents/{contentID}/status": { + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Super" + ], + "summary": "更新租户内容状态(平台侧:下架/封禁)", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "TenantID", + "name": "tenantID", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperTenantContentStatusUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Content" + } + } + } + } + }, "/super/v1/tenants/{tenantID}/status": { "patch": { "consumes": [ @@ -4364,6 +4545,46 @@ const docTemplate = `{ } } }, + "dto.SuperTenantContentItem": { + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/models.Content" + }, + "owner": { + "$ref": "#/definitions/dto.SuperUserLite" + }, + "price": { + "$ref": "#/definitions/models.ContentPrice" + }, + "status_description": { + "type": "string" + }, + "visibility_description": { + "type": "string" + } + } + }, + "dto.SuperTenantContentStatusUpdateForm": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "description": "Status supports: unpublished (下架) / blocked (封禁)", + "enum": [ + "unpublished", + "blocked" + ], + "allOf": [ + { + "$ref": "#/definitions/consts.ContentStatus" + } + ] + } + } + }, "dto.SuperTenantUserItem": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 92b1a80..c57471d 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -564,6 +564,187 @@ "responses": {} } }, + "/super/v1/tenants/{tenantID}/contents": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Super" + ], + "summary": "租户内容列表(平台侧)", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "TenantID", + "name": "tenantID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", + "name": "asc", + "in": "query" + }, + { + "type": "string", + "name": "created_at_from", + "in": "query" + }, + { + "type": "string", + "name": "created_at_to", + "in": "query" + }, + { + "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", + "name": "desc", + "in": "query" + }, + { + "type": "string", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "type": "string", + "name": "published_at_from", + "in": "query" + }, + { + "type": "string", + "name": "published_at_to", + "in": "query" + }, + { + "enum": [ + "draft", + "reviewing", + "published", + "unpublished", + "blocked" + ], + "type": "string", + "x-enum-varnames": [ + "ContentStatusDraft", + "ContentStatusReviewing", + "ContentStatusPublished", + "ContentStatusUnpublished", + "ContentStatusBlocked" + ], + "name": "status", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "enum": [ + "public", + "tenant_only", + "private" + ], + "type": "string", + "x-enum-varnames": [ + "ContentVisibilityPublic", + "ContentVisibilityTenantOnly", + "ContentVisibilityPrivate" + ], + "name": "visibility", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/dto.SuperTenantContentItem" + } + } + } + ] + } + } + } + } + }, + "/super/v1/tenants/{tenantID}/contents/{contentID}/status": { + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Super" + ], + "summary": "更新租户内容状态(平台侧:下架/封禁)", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "TenantID", + "name": "tenantID", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ContentID", + "name": "contentID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperTenantContentStatusUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Content" + } + } + } + } + }, "/super/v1/tenants/{tenantID}/status": { "patch": { "consumes": [ @@ -4358,6 +4539,46 @@ } } }, + "dto.SuperTenantContentItem": { + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/models.Content" + }, + "owner": { + "$ref": "#/definitions/dto.SuperUserLite" + }, + "price": { + "$ref": "#/definitions/models.ContentPrice" + }, + "status_description": { + "type": "string" + }, + "visibility_description": { + "type": "string" + } + } + }, + "dto.SuperTenantContentStatusUpdateForm": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "description": "Status supports: unpublished (下架) / blocked (封禁)", + "enum": [ + "unpublished", + "blocked" + ], + "allOf": [ + { + "$ref": "#/definitions/consts.ContentStatus" + } + ] + } + } + }, "dto.SuperTenantUserItem": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 8e5a5b3..6032523 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -704,6 +704,31 @@ definitions: description: Reason is the human-readable refund reason used for audit. type: string type: object + dto.SuperTenantContentItem: + properties: + content: + $ref: '#/definitions/models.Content' + owner: + $ref: '#/definitions/dto.SuperUserLite' + price: + $ref: '#/definitions/models.ContentPrice' + status_description: + type: string + visibility_description: + type: string + type: object + dto.SuperTenantContentStatusUpdateForm: + properties: + status: + allOf: + - $ref: '#/definitions/consts.ContentStatus' + description: 'Status supports: unpublished (下架) / blocked (封禁)' + enum: + - unpublished + - blocked + required: + - status + type: object dto.SuperTenantUserItem: properties: tenant_user: @@ -1835,6 +1860,127 @@ paths: summary: 更新过期时间 tags: - Super + /super/v1/tenants/{tenantID}/contents: + get: + consumes: + - application/json + parameters: + - description: TenantID + format: int64 + in: path + name: tenantID + required: true + type: integer + - description: Asc specifies comma-separated field names to sort ascending by. + in: query + name: asc + type: string + - in: query + name: created_at_from + type: string + - in: query + name: created_at_to + type: string + - description: Desc specifies comma-separated field names to sort descending + by. + in: query + name: desc + type: string + - in: query + name: keyword + type: string + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query + name: limit + type: integer + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query + name: page + type: integer + - in: query + name: published_at_from + type: string + - in: query + name: published_at_to + type: string + - enum: + - draft + - reviewing + - published + - unpublished + - blocked + in: query + name: status + type: string + x-enum-varnames: + - ContentStatusDraft + - ContentStatusReviewing + - ContentStatusPublished + - ContentStatusUnpublished + - ContentStatusBlocked + - in: query + name: user_id + type: integer + - enum: + - public + - tenant_only + - private + in: query + name: visibility + type: string + x-enum-varnames: + - ContentVisibilityPublic + - ContentVisibilityTenantOnly + - ContentVisibilityPrivate + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + $ref: '#/definitions/dto.SuperTenantContentItem' + type: object + summary: 租户内容列表(平台侧) + tags: + - Super + /super/v1/tenants/{tenantID}/contents/{contentID}/status: + patch: + consumes: + - application/json + parameters: + - description: TenantID + format: int64 + in: path + name: tenantID + required: true + type: integer + - description: ContentID + format: int64 + in: path + name: contentID + required: true + type: integer + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperTenantContentStatusUpdateForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Content' + summary: 更新租户内容状态(平台侧:下架/封禁) + tags: + - Super /super/v1/tenants/{tenantID}/status: patch: consumes: diff --git a/frontend/superadmin/SUPERADMIN_PAGES.md b/frontend/superadmin/SUPERADMIN_PAGES.md index fe20756..f170bf2 100644 --- a/frontend/superadmin/SUPERADMIN_PAGES.md +++ b/frontend/superadmin/SUPERADMIN_PAGES.md @@ -33,7 +33,7 @@ - 订单详情(含 items / snapshot 展示) - 平台侧退款(支持强制退款,记录操作人) 3) **租户管理增强** - - 租户详情页(基本信息、过期续期、状态变更、管理员/成员管理) + - 租户详情页(基本信息、过期续期、状态变更、管理员/成员/内容管理) 4) **用户管理增强** - 用户详情页(角色、状态、余额/冻结、加入/拥有的租户、操作记录) - 角色授予/回收(`super_admin`) diff --git a/frontend/superadmin/dist/index.html b/frontend/superadmin/dist/index.html index df37dd5..0be2339 100644 --- a/frontend/superadmin/dist/index.html +++ b/frontend/superadmin/dist/index.html @@ -7,8 +7,8 @@ Sakai Vue - - + + diff --git a/frontend/superadmin/src/service/ContentService.js b/frontend/superadmin/src/service/ContentService.js new file mode 100644 index 0000000..3640d10 --- /dev/null +++ b/frontend/superadmin/src/service/ContentService.js @@ -0,0 +1,70 @@ +import { requestJson } from './apiClient'; + +function normalizeItems(items) { + if (Array.isArray(items)) return items; + if (items && typeof items === 'object') return [items]; + return []; +} + +export const ContentService = { + async listTenantContents( + tenantID, + { + page, + limit, + keyword, + status, + visibility, + user_id, + published_at_from, + published_at_to, + created_at_from, + created_at_to, + sortField, + sortOrder + } = {} + ) { + if (!tenantID) throw new Error('tenantID is required'); + + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + keyword, + status, + visibility, + user_id, + published_at_from: iso(published_at_from), + published_at_to: iso(published_at_to), + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson(`/super/v1/tenants/${tenantID}/contents`, { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + } + , + async updateTenantContentStatus(tenantID, contentID, { status } = {}) { + if (!tenantID) throw new Error('tenantID is required'); + if (!contentID) throw new Error('contentID is required'); + return requestJson(`/super/v1/tenants/${tenantID}/contents/${contentID}/status`, { + method: 'PATCH', + body: { status } + }); + } +}; diff --git a/frontend/superadmin/src/views/superadmin/TenantDetail.vue b/frontend/superadmin/src/views/superadmin/TenantDetail.vue index 864f56b..f6f352c 100644 --- a/frontend/superadmin/src/views/superadmin/TenantDetail.vue +++ b/frontend/superadmin/src/views/superadmin/TenantDetail.vue @@ -1,6 +1,7 @@