diff --git a/backend/app/http/super/content.go b/backend/app/http/super/content.go new file mode 100644 index 0000000..9d508a8 --- /dev/null +++ b/backend/app/http/super/content.go @@ -0,0 +1,33 @@ +package super + +import ( + "quyun/v2/app/http/super/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + + "github.com/gofiber/fiber/v3" +) + +// content provides superadmin content endpoints. +// +// @provider +type content struct{} + +// list +// +// @Summary 内容列表(平台侧汇总) +// @Tags Super +// @Accept json +// @Produce json +// @Param filter query dto.SuperContentPageFilter true "Filter" +// @Success 200 {object} requests.Pager{items=dto.SuperContentItem} +// +// @Router /super/v1/contents [get] +// @Bind filter query +func (*content) list(ctx fiber.Ctx, filter *dto.SuperContentPageFilter) (*requests.Pager, error) { + if filter == nil { + filter = &dto.SuperContentPageFilter{} + } + filter.Pagination.Format() + return services.Content.SuperContentPage(ctx, filter) +} diff --git a/backend/app/http/super/dto/content_page.go b/backend/app/http/super/dto/content_page.go new file mode 100644 index 0000000..ae5f011 --- /dev/null +++ b/backend/app/http/super/dto/content_page.go @@ -0,0 +1,81 @@ +package dto + +import ( + "strings" + "time" + + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" +) + +type SuperContentPageFilter struct { + requests.Pagination `json:",inline" query:",inline"` + requests.SortQueryFilter `json:",inline" query:",inline"` + + ID *int64 `json:"id,omitempty" query:"id"` + + TenantID *int64 `json:"tenant_id,omitempty" query:"tenant_id"` + TenantCode *string `json:"tenant_code,omitempty" query:"tenant_code"` + TenantName *string `json:"tenant_name,omitempty" query:"tenant_name"` + + UserID *int64 `json:"user_id,omitempty" query:"user_id"` + Username *string `json:"username,omitempty" query:"username"` + + Keyword *string `json:"keyword,omitempty" query:"keyword"` + + Status *consts.ContentStatus `json:"status,omitempty" query:"status"` + Visibility *consts.ContentVisibility `json:"visibility,omitempty" query:"visibility"` + + 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"` + + PriceAmountMin *int64 `json:"price_amount_min,omitempty" query:"price_amount_min"` + PriceAmountMax *int64 `json:"price_amount_max,omitempty" query:"price_amount_max"` +} + +func (f *SuperContentPageFilter) KeywordTrimmed() string { + if f == nil || f.Keyword == nil { + return "" + } + return strings.TrimSpace(*f.Keyword) +} + +func (f *SuperContentPageFilter) TenantCodeTrimmed() string { + if f == nil || f.TenantCode == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(*f.TenantCode)) +} + +func (f *SuperContentPageFilter) TenantNameTrimmed() string { + if f == nil || f.TenantName == nil { + return "" + } + return strings.TrimSpace(*f.TenantName) +} + +func (f *SuperContentPageFilter) UsernameTrimmed() string { + if f == nil || f.Username == nil { + return "" + } + return strings.TrimSpace(*f.Username) +} + +type SuperContentTenantLite struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` +} + +type SuperContentItem struct { + Content *models.Content `json:"content,omitempty"` + Price *models.ContentPrice `json:"price,omitempty"` + Tenant *SuperContentTenantLite `json:"tenant,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/provider.gen.go b/backend/app/http/super/provider.gen.go index 7dc44ce..58625e0 100755 --- a/backend/app/http/super/provider.gen.go +++ b/backend/app/http/super/provider.gen.go @@ -25,6 +25,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*content, error) { + obj := &content{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*order, error) { obj := &order{} @@ -34,6 +41,7 @@ func Provide(opts ...opt.Option) error { } if err := container.Container.Provide(func( auth *auth, + content *content, middlewares *middlewares.Middlewares, order *order, tenant *tenant, @@ -41,6 +49,7 @@ func Provide(opts ...opt.Option) error { ) (contracts.HttpRoute, error) { obj := &Routes{ auth: auth, + content: content, middlewares: middlewares, order: order, tenant: tenant, diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go index 141eeb5..1e01dcc 100644 --- a/backend/app/http/super/routes.gen.go +++ b/backend/app/http/super/routes.gen.go @@ -24,10 +24,11 @@ type Routes struct { log *log.Entry `inject:"false"` middlewares *middlewares.Middlewares // Controller instances - auth *auth - order *order - tenant *tenant - user *user + auth *auth + content *content + order *order + tenant *tenant + user *user } // Prepare initializes the routes provider with logging configuration. @@ -55,6 +56,12 @@ func (r *Routes) Register(router fiber.Router) { r.auth.login, Body[dto.LoginForm]("form"), )) + // Register routes for controller: content + r.log.Debugf("Registering route: Get /super/v1/contents -> content.list") + router.Get("/super/v1/contents"[len(r.Path()):], DataFunc1( + r.content.list, + Query[dto.SuperContentPageFilter]("filter"), + )) // Register routes for controller: order r.log.Debugf("Registering route: Get /super/v1/orders -> order.list") router.Get("/super/v1/orders"[len(r.Path()):], DataFunc1( diff --git a/backend/app/services/content_super.go b/backend/app/services/content_super.go index bf6bb23..0076df6 100644 --- a/backend/app/services/content_super.go +++ b/backend/app/services/content_super.go @@ -181,6 +181,262 @@ func (s *content) SuperTenantContentsPage(ctx context.Context, tenantID int64, f }, nil } +func (s *content) SuperContentPage(ctx context.Context, filter *superdto.SuperContentPageFilter) (*requests.Pager, error) { + if filter == nil { + filter = &superdto.SuperContentPageFilter{} + } + + log.WithFields(log.Fields{ + "tenant_id": lo.FromPtr(filter.TenantID), + "tenant_code": filter.TenantCodeTrimmed(), + "tenant_name": filter.TenantNameTrimmed(), + "user_id": lo.FromPtr(filter.UserID), + "username": filter.UsernameTrimmed(), + "keyword": filter.KeywordTrimmed(), + "status": lo.FromPtr(filter.Status), + "visibility": lo.FromPtr(filter.Visibility), + "page": filter.Page, + "limit": filter.Limit, + }).Info("services.content.super_page") + + filter.Pagination.Format() + + cTbl, query := models.ContentQuery.QueryContext(ctx) + // 注意:该查询会按需 join users/tenants/content_prices;必须显式 select contents.*, + // 否则重复列名(id/created_at/updated_at 等)会被扫描到 Content 模型上导致字段错乱。 + query = query.Select(cTbl.ALL) + + conds := []gen.Condition{ + cTbl.DeletedAt.IsNull(), + } + + if filter.ID != nil && *filter.ID > 0 { + conds = append(conds, cTbl.ID.Eq(*filter.ID)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + conds = append(conds, cTbl.TenantID.Eq(*filter.TenantID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + conds = append(conds, cTbl.UserID.Eq(*filter.UserID)) + } + if kw := strings.TrimSpace(filter.KeywordTrimmed()); kw != "" { + conds = append(conds, cTbl.Title.Like(database.WrapLike(kw))) + } + if filter.Status != nil { + conds = append(conds, cTbl.Status.Eq(*filter.Status)) + } + if filter.Visibility != nil { + conds = append(conds, cTbl.Visibility.Eq(*filter.Visibility)) + } + if filter.PublishedAtFrom != nil { + conds = append(conds, cTbl.PublishedAt.Gte(*filter.PublishedAtFrom)) + } + if filter.PublishedAtTo != nil { + conds = append(conds, cTbl.PublishedAt.Lte(*filter.PublishedAtTo)) + } + if filter.CreatedAtFrom != nil { + conds = append(conds, cTbl.CreatedAt.Gte(*filter.CreatedAtFrom)) + } + if filter.CreatedAtTo != nil { + conds = append(conds, cTbl.CreatedAt.Lte(*filter.CreatedAtTo)) + } + + // Owner username keyword. + if username := filter.UsernameTrimmed(); username != "" { + uTbl, _ := models.UserQuery.QueryContext(ctx) + query = query.LeftJoin(uTbl, uTbl.ID.EqCol(cTbl.UserID)) + conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) + } + + // Tenant code/name keyword. + tenantCode := filter.TenantCodeTrimmed() + tenantName := filter.TenantNameTrimmed() + if tenantCode != "" || tenantName != "" { + tTbl, _ := models.TenantQuery.QueryContext(ctx) + query = query.LeftJoin(tTbl, tTbl.ID.EqCol(cTbl.TenantID)) + if tenantCode != "" { + conds = append(conds, tTbl.Code.Like(database.WrapLike(tenantCode))) + } + if tenantName != "" { + conds = append(conds, tTbl.Name.Like(database.WrapLike(tenantName))) + } + } + + // Price amount range filter (content_prices is 1:1 by content_id within tenant). + needPriceJoin := (filter.PriceAmountMin != nil && *filter.PriceAmountMin >= 0) || (filter.PriceAmountMax != nil && *filter.PriceAmountMax >= 0) + if needPriceJoin { + cpTbl, _ := models.ContentPriceQuery.QueryContext(ctx) + query = query.LeftJoin(cpTbl, cpTbl.ContentID.EqCol(cTbl.ID)) + if filter.PriceAmountMin != nil && *filter.PriceAmountMin >= 0 { + conds = append(conds, cpTbl.PriceAmount.Gte(*filter.PriceAmountMin)) + } + if filter.PriceAmountMax != nil && *filter.PriceAmountMax >= 0 { + conds = append(conds, cpTbl.PriceAmount.Lte(*filter.PriceAmountMax)) + } + } + + // Sort whitelist. + orderBys := make([]field.Expr, 0, 8) + allowedAsc := map[string]field.Expr{ + "id": cTbl.ID.Asc(), + "tenant_id": cTbl.TenantID.Asc(), + "user_id": cTbl.UserID.Asc(), + "title": cTbl.Title.Asc(), + "status": cTbl.Status.Asc(), + "visibility": cTbl.Visibility.Asc(), + "published_at": cTbl.PublishedAt.Asc(), + "created_at": cTbl.CreatedAt.Asc(), + "updated_at": cTbl.UpdatedAt.Asc(), + } + allowedDesc := map[string]field.Expr{ + "id": cTbl.ID.Desc(), + "tenant_id": cTbl.TenantID.Desc(), + "user_id": cTbl.UserID.Desc(), + "title": cTbl.Title.Desc(), + "status": cTbl.Status.Desc(), + "visibility": cTbl.Visibility.Desc(), + "published_at": cTbl.PublishedAt.Desc(), + "created_at": cTbl.CreatedAt.Desc(), + "updated_at": cTbl.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, cTbl.ID.Desc()) + } else { + orderBys = append(orderBys, cTbl.ID.Desc()) + } + + items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + tenantIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { + if item == nil || item.TenantID <= 0 { + return 0, false + } + return item.TenantID, true + })) + 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 + })) + contentIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { + if item == nil || item.ID <= 0 { + return 0, false + } + return item.ID, true + })) + + tenantMap := map[int64]*models.Tenant{} + if len(tenantIDs) > 0 { + tTbl, tQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := tQuery.Where(tTbl.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, err + } + for _, te := range tenants { + if te == nil { + continue + } + tenantMap[te.ID] = te + } + } + + 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(), + } + } + } + + priceByContent := map[int64]*models.ContentPrice{} + if len(contentIDs) > 0 { + cpTbl, cpQuery := models.ContentPriceQuery.QueryContext(ctx) + conds := []gen.Condition{ + cpTbl.ContentID.In(contentIDs...), + } + if len(tenantIDs) > 0 { + conds = append(conds, cpTbl.TenantID.In(tenantIDs...)) + } + prices, err := cpQuery.Where(conds...).Find() + if err != nil { + return nil, err + } + for _, p := range prices { + if p == nil { + continue + } + priceByContent[p.ContentID] = p + } + } + + respItems := lo.Map(items, func(model *models.Content, _ int) *superdto.SuperContentItem { + if model == nil { + return nil + } + te := tenantMap[model.TenantID] + var lite *superdto.SuperContentTenantLite + if te != nil { + lite = &superdto.SuperContentTenantLite{ + ID: te.ID, + Code: te.Code, + Name: te.Name, + } + } + return &superdto.SuperContentItem{ + Content: model, + Price: priceByContent[model.ID], + Tenant: lite, + 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, diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 8638dc5..a75d4f6 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -77,6 +77,165 @@ const docTemplate = `{ } } }, + "/super/v1/contents": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Super" + ], + "summary": "内容列表(平台侧汇总)", + "parameters": [ + { + "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": "integer", + "name": "id", + "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": "integer", + "name": "price_amount_max", + "in": "query" + }, + { + "type": "integer", + "name": "price_amount_min", + "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": "string", + "name": "tenant_code", + "in": "query" + }, + { + "type": "integer", + "name": "tenant_id", + "in": "query" + }, + { + "type": "string", + "name": "tenant_name", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "type": "string", + "name": "username", + "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.SuperContentItem" + } + } + } + ] + } + } + } + } + }, "/super/v1/orders": { "get": { "consumes": [ @@ -4467,6 +4626,43 @@ const docTemplate = `{ } } }, + "dto.SuperContentItem": { + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/models.Content" + }, + "owner": { + "$ref": "#/definitions/dto.SuperUserLite" + }, + "price": { + "$ref": "#/definitions/models.ContentPrice" + }, + "status_description": { + "type": "string" + }, + "tenant": { + "$ref": "#/definitions/dto.SuperContentTenantLite" + }, + "visibility_description": { + "type": "string" + } + } + }, + "dto.SuperContentTenantLite": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, "dto.SuperOrderDetail": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index c57471d..ed961f0 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -71,6 +71,165 @@ } } }, + "/super/v1/contents": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Super" + ], + "summary": "内容列表(平台侧汇总)", + "parameters": [ + { + "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": "integer", + "name": "id", + "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": "integer", + "name": "price_amount_max", + "in": "query" + }, + { + "type": "integer", + "name": "price_amount_min", + "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": "string", + "name": "tenant_code", + "in": "query" + }, + { + "type": "integer", + "name": "tenant_id", + "in": "query" + }, + { + "type": "string", + "name": "tenant_name", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "type": "string", + "name": "username", + "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.SuperContentItem" + } + } + } + ] + } + } + } + } + }, "/super/v1/orders": { "get": { "consumes": [ @@ -4461,6 +4620,43 @@ } } }, + "dto.SuperContentItem": { + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/models.Content" + }, + "owner": { + "$ref": "#/definitions/dto.SuperUserLite" + }, + "price": { + "$ref": "#/definitions/models.ContentPrice" + }, + "status_description": { + "type": "string" + }, + "tenant": { + "$ref": "#/definitions/dto.SuperContentTenantLite" + }, + "visibility_description": { + "type": "string" + } + } + }, + "dto.SuperContentTenantLite": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, "dto.SuperOrderDetail": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 6032523..cfd63cf 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -651,6 +651,30 @@ definitions: description: Order is the created or existing order record (may be nil for owner/free-path without order). type: object + dto.SuperContentItem: + properties: + content: + $ref: '#/definitions/models.Content' + owner: + $ref: '#/definitions/dto.SuperUserLite' + price: + $ref: '#/definitions/models.ContentPrice' + status_description: + type: string + tenant: + $ref: '#/definitions/dto.SuperContentTenantLite' + visibility_description: + type: string + type: object + dto.SuperContentTenantLite: + properties: + code: + type: string + id: + type: integer + name: + type: string + type: object dto.SuperOrderDetail: properties: buyer: @@ -1557,6 +1581,109 @@ paths: $ref: '#/definitions/dto.LoginResponse' tags: - Super + /super/v1/contents: + get: + consumes: + - application/json + parameters: + - 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: id + type: integer + - 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: price_amount_max + type: integer + - in: query + name: price_amount_min + 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: tenant_code + type: string + - in: query + name: tenant_id + type: integer + - in: query + name: tenant_name + type: string + - in: query + name: user_id + type: integer + - in: query + name: username + type: string + - 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.SuperContentItem' + type: object + summary: 内容列表(平台侧汇总) + tags: + - Super /super/v1/orders: get: consumes: diff --git a/frontend/superadmin/SUPERADMIN_PAGES.md b/frontend/superadmin/SUPERADMIN_PAGES.md index f170bf2..d415b33 100644 --- a/frontend/superadmin/SUPERADMIN_PAGES.md +++ b/frontend/superadmin/SUPERADMIN_PAGES.md @@ -22,6 +22,7 @@ - `/superadmin/tenants`:租户管理 - `/superadmin/users`:用户管理 - `/superadmin/orders`:订单管理 +- `/superadmin/contents`:内容管理(跨租户汇总) ## 1.1 迭代路线(按优先级依次实现) @@ -34,7 +35,10 @@ - 平台侧退款(支持强制退款,记录操作人) 3) **租户管理增强** - 租户详情页(基本信息、过期续期、状态变更、管理员/成员/内容管理) -4) **用户管理增强** +4) **内容管理** + - 内容列表(跨租户查询/筛选/分页/排序、下架/封禁) + - 可选:内容详情页(资源/定价/审计) +5) **用户管理增强** - 用户详情页(角色、状态、余额/冻结、加入/拥有的租户、操作记录) - 角色授予/回收(`super_admin`) 5) **审计与运维** diff --git a/frontend/superadmin/dist/index.html b/frontend/superadmin/dist/index.html index 0be2339..aed97c4 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/layout/AppMenu.vue b/frontend/superadmin/src/layout/AppMenu.vue index 2f1d83c..68fc8b5 100644 --- a/frontend/superadmin/src/layout/AppMenu.vue +++ b/frontend/superadmin/src/layout/AppMenu.vue @@ -13,7 +13,8 @@ const model = ref([ items: [ { label: 'Tenants', icon: 'pi pi-fw pi-building', to: '/superadmin/tenants' }, { label: 'Users', icon: 'pi pi-fw pi-users', to: '/superadmin/users' }, - { label: 'Orders', icon: 'pi pi-fw pi-shopping-cart', to: '/superadmin/orders' } + { label: 'Orders', icon: 'pi pi-fw pi-shopping-cart', to: '/superadmin/orders' }, + { label: 'Contents', icon: 'pi pi-fw pi-file', to: '/superadmin/contents' } ] } ]); diff --git a/frontend/superadmin/src/router/index.js b/frontend/superadmin/src/router/index.js index 1ebea8a..7819a87 100644 --- a/frontend/superadmin/src/router/index.js +++ b/frontend/superadmin/src/router/index.js @@ -139,6 +139,11 @@ const router = createRouter({ name: 'superadmin-orders', component: () => import('@/views/superadmin/Orders.vue') }, + { + path: '/superadmin/contents', + name: 'superadmin-contents', + component: () => import('@/views/superadmin/Contents.vue') + }, { path: '/superadmin/orders/:orderID', name: 'superadmin-order-detail', diff --git a/frontend/superadmin/src/service/ContentService.js b/frontend/superadmin/src/service/ContentService.js index 3640d10..7cca17e 100644 --- a/frontend/superadmin/src/service/ContentService.js +++ b/frontend/superadmin/src/service/ContentService.js @@ -7,6 +7,66 @@ function normalizeItems(items) { } export const ContentService = { + async listContents({ + page, + limit, + id, + tenant_id, + tenant_code, + tenant_name, + user_id, + username, + keyword, + status, + visibility, + published_at_from, + published_at_to, + created_at_from, + created_at_to, + price_amount_min, + price_amount_max, + sortField, + sortOrder + } = {}) { + 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, + id, + tenant_id, + tenant_code, + tenant_name, + user_id, + username, + keyword, + status, + visibility, + 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), + price_amount_min, + price_amount_max + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/contents', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, async listTenantContents( tenantID, { diff --git a/frontend/superadmin/src/views/superadmin/Contents.vue b/frontend/superadmin/src/views/superadmin/Contents.vue new file mode 100644 index 0000000..3de178d --- /dev/null +++ b/frontend/superadmin/src/views/superadmin/Contents.vue @@ -0,0 +1,390 @@ + + +