From 339fd4fb1dea387fe16e5cc6b1a0ae8f371f38bc Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 15 Jan 2026 15:51:26 +0800 Subject: [PATCH] feat: add superadmin user interaction views --- backend/app/http/super/v1/dto/super_user.go | 35 ++ backend/app/http/super/v1/routes.gen.go | 18 + backend/app/http/super/v1/users.go | 54 +++ backend/app/services/super.go | 223 +++++++++ backend/docs/docs.go | 220 +++++++++ backend/docs/swagger.json | 220 +++++++++ backend/docs/swagger.yaml | 136 ++++++ docs/superadmin_progress.md | 8 +- .../superadmin/src/service/UserService.js | 101 ++++ .../src/views/superadmin/UserDetail.vue | 452 ++++++++++++++++++ 10 files changed, 1463 insertions(+), 4 deletions(-) diff --git a/backend/app/http/super/v1/dto/super_user.go b/backend/app/http/super/v1/dto/super_user.go index 195ba5a..64fe932 100644 --- a/backend/app/http/super/v1/dto/super_user.go +++ b/backend/app/http/super/v1/dto/super_user.go @@ -116,3 +116,38 @@ type SuperUserRealNameResponse struct { // IDCardMasked 身份证号脱敏展示。 IDCardMasked string `json:"id_card_masked"` } + +// SuperUserContentActionListFilter 超管用户互动内容列表过滤条件。 +type SuperUserContentActionListFilter struct { + requests.Pagination + // TenantID 内容所属租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码,模糊匹配。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称,模糊匹配。 + TenantName *string `query:"tenant_name"` + // ContentID 内容ID,精确匹配。 + ContentID *int64 `query:"content_id"` + // Keyword 内容标题/摘要/描述关键字,模糊匹配。 + Keyword *string `query:"keyword"` + // CreatedAtFrom 互动时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 互动时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // Asc 升序字段(id/created_at)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at)。 + Desc *string `query:"desc"` +} + +// SuperUserContentActionItem 超管用户互动内容条目。 +type SuperUserContentActionItem struct { + // ActionID 互动记录ID。 + ActionID int64 `json:"action_id"` + // ActionType 互动类型(like/favorite)。 + ActionType consts.UserContentActionType `json:"action_type"` + // ActionAt 互动发生时间(RFC3339)。 + ActionAt string `json:"action_at"` + // Content 互动对应内容详情(含租户与作者信息)。 + Content *AdminContentItem `json:"content"` +} diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index 57727ae..30a9f1b 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -316,6 +316,24 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), Query[dto.SuperUserCouponListFilter]("filter"), )) + r.log.Debugf("Registering route: Get /super/v1/users/:id/favorites -> users.ListFavorites") + router.Get("/super/v1/users/:id/favorites"[len(r.Path()):], DataFunc2( + r.users.ListFavorites, + PathParam[int64]("id"), + Query[dto.SuperUserContentActionListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /super/v1/users/:id/following -> users.ListFollowing") + router.Get("/super/v1/users/:id/following"[len(r.Path()):], DataFunc2( + r.users.ListFollowing, + PathParam[int64]("id"), + Query[dto.SuperUserTenantListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /super/v1/users/:id/likes -> users.ListLikes") + router.Get("/super/v1/users/:id/likes"[len(r.Path()):], DataFunc2( + r.users.ListLikes, + PathParam[int64]("id"), + Query[dto.SuperUserContentActionListFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/users/:id/notifications -> users.ListNotifications") router.Get("/super/v1/users/:id/notifications"[len(r.Path()):], DataFunc2( r.users.ListNotifications, diff --git a/backend/app/http/super/v1/users.go b/backend/app/http/super/v1/users.go index 76d75ab..e915d1d 100644 --- a/backend/app/http/super/v1/users.go +++ b/backend/app/http/super/v1/users.go @@ -127,6 +127,60 @@ func (c *users) ListTenants(ctx fiber.Ctx, id int64, filter *dto.SuperUserTenant return services.Super.ListUserTenants(ctx, id, filter) } +// List user favorites +// +// @Router /super/v1/users/:id/favorites [get] +// @Summary List user favorites +// @Description List user's favorited contents +// @Tags User +// @Accept json +// @Produce json +// @Param id path int64 true "User ID" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperUserContentActionItem} +// @Bind id path +// @Bind filter query +func (c *users) ListFavorites(ctx fiber.Ctx, id int64, filter *dto.SuperUserContentActionListFilter) (*requests.Pager, error) { + return services.Super.ListUserFavorites(ctx, id, filter) +} + +// List user likes +// +// @Router /super/v1/users/:id/likes [get] +// @Summary List user likes +// @Description List user's liked contents +// @Tags User +// @Accept json +// @Produce json +// @Param id path int64 true "User ID" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperUserContentActionItem} +// @Bind id path +// @Bind filter query +func (c *users) ListLikes(ctx fiber.Ctx, id int64, filter *dto.SuperUserContentActionListFilter) (*requests.Pager, error) { + return services.Super.ListUserLikes(ctx, id, filter) +} + +// List user following tenants +// +// @Router /super/v1/users/:id/following [get] +// @Summary List user following tenants +// @Description List tenants followed by user +// @Tags User +// @Accept json +// @Produce json +// @Param id path int64 true "User ID" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.UserTenantItem} +// @Bind id path +// @Bind filter query +func (c *users) ListFollowing(ctx fiber.Ctx, id int64, filter *dto.SuperUserTenantListFilter) (*requests.Pager, error) { + return services.Super.ListUserFollowing(ctx, id, filter) +} + // Update user status // // @Router /super/v1/users/:id/status [patch] diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 1fcac89..e8f223e 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -1704,6 +1704,176 @@ func (s *super) ListUserTenants(ctx context.Context, userID int64, filter *super }, nil } +func (s *super) ListUserFavorites(ctx context.Context, userID int64, filter *super_dto.SuperUserContentActionListFilter) (*requests.Pager, error) { + return s.listUserContentActions(ctx, userID, consts.UserContentActionTypeFavorite, filter) +} + +func (s *super) ListUserLikes(ctx context.Context, userID int64, filter *super_dto.SuperUserContentActionListFilter) (*requests.Pager, error) { + return s.listUserContentActions(ctx, userID, consts.UserContentActionTypeLike, filter) +} + +func (s *super) ListUserFollowing(ctx context.Context, userID int64, filter *super_dto.SuperUserTenantListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperUserTenantListFilter{} + } + // 关注列表默认只展示普通成员关注关系。 + role := consts.TenantUserRoleMember + filter.Role = &role + return s.ListUserTenants(ctx, userID, filter) +} + +func (s *super) listUserContentActions( + ctx context.Context, + userID int64, + actionType consts.UserContentActionType, + filter *super_dto.SuperUserContentActionListFilter, +) (*requests.Pager, error) { + if userID == 0 { + return nil, errorx.ErrBadRequest.WithMsg("用户ID不能为空") + } + if filter == nil { + filter = &super_dto.SuperUserContentActionListFilter{} + } + + tbl, q := models.UserContentActionQuery.QueryContext(ctx) + q = q.Where(tbl.UserID.Eq(userID), tbl.Type.Eq(string(actionType))) + + contentIDs, contentFilter, err := s.filterContentIDsForUserActions(ctx, filter) + if err != nil { + return nil, err + } + if contentFilter { + if len(contentIDs) == 0 { + filter.Pagination.Format() + return &requests.Pager{ + Pagination: filter.Pagination, + Total: 0, + Items: []super_dto.SuperUserContentActionItem{}, + }, nil + } + q = q.Where(tbl.ContentID.In(contentIDs...)) + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "created_at": + q = q.Order(tbl.CreatedAt) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.CreatedAt.Desc()) + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + if len(list) == 0 { + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: []super_dto.SuperUserContentActionItem{}, + }, nil + } + + contentIDSet := make(map[int64]struct{}, len(list)) + contentIDs = make([]int64, 0, len(list)) + for _, action := range list { + if action.ContentID == 0 { + continue + } + if _, ok := contentIDSet[action.ContentID]; ok { + continue + } + contentIDSet[action.ContentID] = struct{}{} + contentIDs = append(contentIDs, action.ContentID) + } + + var contents []*models.Content + if len(contentIDs) > 0 { + contentTbl, contentQuery := models.ContentQuery.QueryContext(ctx) + err := contentQuery.Where(contentTbl.ID.In(contentIDs...)). + UnderlyingDB(). + Preload("Author"). + Preload("ContentAssets.Asset"). + Find(&contents).Error + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + } + + priceMap, err := s.contentPriceMap(ctx, contents) + if err != nil { + return nil, err + } + tenantMap, err := s.contentTenantMap(ctx, contents) + if err != nil { + return nil, err + } + + contentMap := make(map[int64]*models.Content, len(contents)) + for _, content := range contents { + contentMap[content.ID] = content + } + + items := make([]super_dto.SuperUserContentActionItem, 0, len(list)) + for _, action := range list { + var contentItem *super_dto.AdminContentItem + if content := contentMap[action.ContentID]; content != nil { + item := s.toSuperContentItem(content, priceMap[content.ID], tenantMap[content.TenantID]) + contentItem = &item + } + items = append(items, super_dto.SuperUserContentActionItem{ + ActionID: action.ID, + ActionType: actionType, + ActionAt: s.formatTime(action.CreatedAt), + Content: contentItem, + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + func (s *super) ListContents(ctx context.Context, filter *super_dto.SuperContentListFilter) (*requests.Pager, error) { tbl, q := models.ContentQuery.QueryContext(ctx) @@ -3444,6 +3614,59 @@ func (s *super) lookupUserIDs(ctx context.Context, username *string) ([]int64, b return ids, true, nil } +func (s *super) filterContentIDsForUserActions( + ctx context.Context, + filter *super_dto.SuperUserContentActionListFilter, +) ([]int64, bool, error) { + if filter == nil { + return nil, false, nil + } + + tbl, q := models.ContentQuery.QueryContext(ctx) + filterApplied := false + + if filter.ContentID != nil && *filter.ContentID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ContentID)) + filterApplied = true + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + filterApplied = true + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, false, err + } + if tenantFilter { + filterApplied = true + if len(tenantIDs) == 0 { + return []int64{}, true, nil + } + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + + if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { + keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%" + q = q.Where(field.Or( + tbl.Title.Like(keyword), + tbl.Description.Like(keyword), + tbl.Summary.Like(keyword), + )) + filterApplied = true + } + + if !filterApplied { + return nil, false, nil + } + + ids, err := q.PluckIDs() + if err != nil { + return nil, true, errorx.ErrDatabaseError.WithCause(err) + } + return ids, true, nil +} + func (s *super) lookupOrderIDsByContent(ctx context.Context, contentID *int64, contentTitle *string) ([]int64, bool, error) { var id int64 if contentID != nil { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index e8e5a47..051ecba 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -2059,6 +2059,186 @@ const docTemplate = `{ } } }, + "/super/v1/users/{id}/favorites": { + "get": { + "description": "List user's favorited contents", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "List user favorites", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperUserContentActionItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/users/{id}/following": { + "get": { + "description": "List tenants followed by user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "List user following tenants", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.UserTenantItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/users/{id}/likes": { + "get": { + "description": "List user's liked contents", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "List user likes", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperUserContentActionItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/users/{id}/notifications": { "get": { "description": "List notifications of a user", @@ -5371,6 +5551,17 @@ const docTemplate = `{ "TenantUserRoleTenantAdmin" ] }, + "consts.UserContentActionType": { + "type": "string", + "enum": [ + "like", + "favorite" + ], + "x-enum-varnames": [ + "UserContentActionTypeLike", + "UserContentActionTypeFavorite" + ] + }, "consts.UserCouponStatus": { "type": "string", "enum": [ @@ -7605,6 +7796,35 @@ const docTemplate = `{ } } }, + "dto.SuperUserContentActionItem": { + "type": "object", + "properties": { + "action_at": { + "description": "ActionAt 互动发生时间(RFC3339)。", + "type": "string" + }, + "action_id": { + "description": "ActionID 互动记录ID。", + "type": "integer" + }, + "action_type": { + "description": "ActionType 互动类型(like/favorite)。", + "allOf": [ + { + "$ref": "#/definitions/consts.UserContentActionType" + } + ] + }, + "content": { + "description": "Content 互动对应内容详情(含租户与作者信息)。", + "allOf": [ + { + "$ref": "#/definitions/dto.AdminContentItem" + } + ] + } + } + }, "dto.SuperUserCouponItem": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index b7b4a50..66fced1 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -2053,6 +2053,186 @@ } } }, + "/super/v1/users/{id}/favorites": { + "get": { + "description": "List user's favorited contents", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "List user favorites", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperUserContentActionItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/users/{id}/following": { + "get": { + "description": "List tenants followed by user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "List user following tenants", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.UserTenantItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/users/{id}/likes": { + "get": { + "description": "List user's liked contents", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "List user likes", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperUserContentActionItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/users/{id}/notifications": { "get": { "description": "List notifications of a user", @@ -5365,6 +5545,17 @@ "TenantUserRoleTenantAdmin" ] }, + "consts.UserContentActionType": { + "type": "string", + "enum": [ + "like", + "favorite" + ], + "x-enum-varnames": [ + "UserContentActionTypeLike", + "UserContentActionTypeFavorite" + ] + }, "consts.UserCouponStatus": { "type": "string", "enum": [ @@ -7599,6 +7790,35 @@ } } }, + "dto.SuperUserContentActionItem": { + "type": "object", + "properties": { + "action_at": { + "description": "ActionAt 互动发生时间(RFC3339)。", + "type": "string" + }, + "action_id": { + "description": "ActionID 互动记录ID。", + "type": "integer" + }, + "action_type": { + "description": "ActionType 互动类型(like/favorite)。", + "allOf": [ + { + "$ref": "#/definitions/consts.UserContentActionType" + } + ] + }, + "content": { + "description": "Content 互动对应内容详情(含租户与作者信息)。", + "allOf": [ + { + "$ref": "#/definitions/dto.AdminContentItem" + } + ] + } + } + }, "dto.SuperUserCouponItem": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 5549a7b..158c069 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -136,6 +136,14 @@ definitions: x-enum-varnames: - TenantUserRoleMember - TenantUserRoleTenantAdmin + consts.UserContentActionType: + enum: + - like + - favorite + type: string + x-enum-varnames: + - UserContentActionTypeLike + - UserContentActionTypeFavorite consts.UserCouponStatus: enum: - unused @@ -1691,6 +1699,23 @@ definitions: - $ref: '#/definitions/dto.SuperUserLite' description: User 用户信息。 type: object + dto.SuperUserContentActionItem: + properties: + action_at: + description: ActionAt 互动发生时间(RFC3339)。 + type: string + action_id: + description: ActionID 互动记录ID。 + type: integer + action_type: + allOf: + - $ref: '#/definitions/consts.UserContentActionType' + description: ActionType 互动类型(like/favorite)。 + content: + allOf: + - $ref: '#/definitions/dto.AdminContentItem' + description: Content 互动对应内容详情(含租户与作者信息)。 + type: object dto.SuperUserCouponItem: properties: coupon_id: @@ -3916,6 +3941,117 @@ paths: summary: List user coupons tags: - User + /super/v1/users/{id}/favorites: + get: + consumes: + - application/json + description: List user's favorited contents + parameters: + - description: User ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperUserContentActionItem' + type: array + type: object + summary: List user favorites + tags: + - User + /super/v1/users/{id}/following: + get: + consumes: + - application/json + description: List tenants followed by user + parameters: + - description: User ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.UserTenantItem' + type: array + type: object + summary: List user following tenants + tags: + - User + /super/v1/users/{id}/likes: + get: + consumes: + - application/json + description: List user's liked contents + parameters: + - description: User ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperUserContentActionItem' + type: array + type: object + summary: List user likes + tags: + - User /super/v1/users/{id}/notifications: get: consumes: diff --git a/docs/superadmin_progress.md b/docs/superadmin_progress.md index a3acb76..383faa1 100644 --- a/docs/superadmin_progress.md +++ b/docs/superadmin_progress.md @@ -4,7 +4,7 @@ ## 1) 总体结论 -- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)。 +- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录与互动(收藏/点赞/关注)视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)。 - **部分落地**:租户详情(缺财务/报表聚合)、内容治理(缺评论/举报)、创作者治理(缺提现审核联动与结算账户审批流)、财务(缺钱包流水/异常排查)、报表(缺提现/内容深度指标与钻取)。 - **未落地**:审计与系统配置类能力。 @@ -38,7 +38,7 @@ ### 2.6 用户详情 `/superadmin/users/:userID` - 状态:**部分完成** - 已有:用户资料、租户关系、订单查询、钱包余额与流水、充值记录、通知、优惠券、实名认证详情。 -- 缺口:收藏/点赞、关注、内容消费明细等用户互动视图。 +- 缺口:内容消费明细等用户互动深度视图。 ### 2.7 内容治理 `/superadmin/contents` - 状态:**部分完成** @@ -83,11 +83,11 @@ ## 3) `/super/v1` 接口覆盖度概览 - **已具备**:Auth、Tenants(含成员审核/邀请)、Users(含钱包/通知/优惠券/实名)、Contents、Orders、Withdrawals、Reports、Coupons(列表/创建/编辑/发放/冻结/记录)、Creators(列表/申请/成员审核)、Payout Accounts(列表/删除)、Assets(列表/用量/删除)、Notifications(列表/群发/模板)。 -- **缺失/待补**:用户互动明细(收藏/点赞/关注)、创作者提现审核、优惠券异常核查与风控、审计与系统配置类能力。 +- **缺失/待补**:内容消费明细、创作者提现审核、优惠券异常核查与风控、审计与系统配置类能力。 ## 4) 建议的下一步(按优先级) -1. **用户互动明细**:补齐收藏/点赞/关注等互动明细视图与聚合能力。 +1. **内容消费明细**:补齐用户内容消费/访问的明细与聚合能力。 2. **优惠券异常核查**:完善发放/核销异常监测与风控处理流程。 3. **报表深化**:补齐提现/内容维度指标与多维钻取能力。 4. **审计与系统配置**:完善全量操作审计与系统级配置能力。 diff --git a/frontend/superadmin/src/service/UserService.js b/frontend/superadmin/src/service/UserService.js index bf1f1bc..014ce5b 100644 --- a/frontend/superadmin/src/service/UserService.js +++ b/frontend/superadmin/src/service/UserService.js @@ -118,6 +118,107 @@ export const UserService = { items: normalizeItems(data?.items) }; }, + async listUserFavorites(userID, { page, limit, tenant_id, tenant_code, tenant_name, content_id, keyword, created_at_from, created_at_to, sortField, sortOrder } = {}) { + if (!userID) throw new Error('userID 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, + tenant_id, + tenant_code, + tenant_name, + content_id, + keyword, + 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/users/${userID}/favorites`, { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async listUserLikes(userID, { page, limit, tenant_id, tenant_code, tenant_name, content_id, keyword, created_at_from, created_at_to, sortField, sortOrder } = {}) { + if (!userID) throw new Error('userID 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, + tenant_id, + tenant_code, + tenant_name, + content_id, + keyword, + 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/users/${userID}/likes`, { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async listUserFollowing(userID, { page, limit, tenant_id, code, name, status, created_at_from, created_at_to, sortField, sortOrder } = {}) { + if (!userID) throw new Error('userID 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, + tenant_id, + code, + name, + status, + 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/users/${userID}/following`, { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, async getUserRealName(userID) { if (!userID) throw new Error('userID is required'); return requestJson(`/super/v1/users/${userID}/realname`); diff --git a/frontend/superadmin/src/views/superadmin/UserDetail.vue b/frontend/superadmin/src/views/superadmin/UserDetail.vue index d743bd1..0232771 100644 --- a/frontend/superadmin/src/views/superadmin/UserDetail.vue +++ b/frontend/superadmin/src/views/superadmin/UserDetail.vue @@ -34,6 +34,24 @@ const couponsTotal = ref(0); const couponsPage = ref(1); const couponsRows = ref(10); +const favorites = ref([]); +const favoritesLoading = ref(false); +const favoritesTotal = ref(0); +const favoritesPage = ref(1); +const favoritesRows = ref(10); + +const likes = ref([]); +const likesLoading = ref(false); +const likesTotal = ref(0); +const likesPage = ref(1); +const likesRows = ref(10); + +const followingTenants = ref([]); +const followingTenantsLoading = ref(false); +const followingTenantsTotal = ref(0); +const followingTenantsPage = ref(1); +const followingTenantsRows = ref(10); + const rechargeOrders = ref([]); const rechargeOrdersLoading = ref(false); const rechargeOrdersTotal = ref(0); @@ -207,6 +225,80 @@ async function loadCoupons() { } } +async function loadFavorites() { + const id = userID.value; + if (!id || Number.isNaN(id)) return; + favoritesLoading.value = true; + try { + const result = await UserService.listUserFavorites(id, { + page: favoritesPage.value, + limit: favoritesRows.value, + tenant_id: favoritesTenantID.value || undefined, + tenant_code: favoritesTenantCode.value || undefined, + tenant_name: favoritesTenantName.value || undefined, + content_id: favoritesContentID.value || undefined, + keyword: favoritesKeyword.value || undefined, + created_at_from: favoritesCreatedAtFrom.value || undefined, + created_at_to: favoritesCreatedAtTo.value || undefined + }); + favorites.value = result.items; + favoritesTotal.value = result.total; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载收藏列表', life: 4000 }); + } finally { + favoritesLoading.value = false; + } +} + +async function loadLikes() { + const id = userID.value; + if (!id || Number.isNaN(id)) return; + likesLoading.value = true; + try { + const result = await UserService.listUserLikes(id, { + page: likesPage.value, + limit: likesRows.value, + tenant_id: likesTenantID.value || undefined, + tenant_code: likesTenantCode.value || undefined, + tenant_name: likesTenantName.value || undefined, + content_id: likesContentID.value || undefined, + keyword: likesKeyword.value || undefined, + created_at_from: likesCreatedAtFrom.value || undefined, + created_at_to: likesCreatedAtTo.value || undefined + }); + likes.value = result.items; + likesTotal.value = result.total; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载点赞列表', life: 4000 }); + } finally { + likesLoading.value = false; + } +} + +async function loadFollowingTenants() { + const id = userID.value; + if (!id || Number.isNaN(id)) return; + followingTenantsLoading.value = true; + try { + const result = await UserService.listUserFollowing(id, { + page: followingTenantsPage.value, + limit: followingTenantsRows.value, + tenant_id: followingTenantID.value || undefined, + code: followingCode.value || undefined, + name: followingName.value || undefined, + status: followingStatus.value || undefined, + created_at_from: followingJoinedAtFrom.value || undefined, + created_at_to: followingJoinedAtTo.value || undefined + }); + followingTenants.value = result.items; + followingTenantsTotal.value = result.total; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载关注列表', life: 4000 }); + } finally { + followingTenantsLoading.value = false; + } +} + async function loadRechargeOrders() { const id = userID.value; if (!id || Number.isNaN(id)) return; @@ -345,6 +437,29 @@ const joinedTenantsStatus = ref(''); const joinedTenantsJoinedAtFrom = ref(null); const joinedTenantsJoinedAtTo = ref(null); +const favoritesTenantID = ref(null); +const favoritesTenantCode = ref(''); +const favoritesTenantName = ref(''); +const favoritesContentID = ref(null); +const favoritesKeyword = ref(''); +const favoritesCreatedAtFrom = ref(null); +const favoritesCreatedAtTo = ref(null); + +const likesTenantID = ref(null); +const likesTenantCode = ref(''); +const likesTenantName = ref(''); +const likesContentID = ref(null); +const likesKeyword = ref(''); +const likesCreatedAtFrom = ref(null); +const likesCreatedAtTo = ref(null); + +const followingTenantID = ref(null); +const followingCode = ref(''); +const followingName = ref(''); +const followingStatus = ref(''); +const followingJoinedAtFrom = ref(null); +const followingJoinedAtTo = ref(null); + async function loadJoinedTenants() { const uid = userID.value; if (!uid) return; @@ -395,6 +510,77 @@ function onJoinedTenantsPage(event) { loadJoinedTenants(); } +function onFavoritesSearch() { + favoritesPage.value = 1; + loadFavorites(); +} + +function onFavoritesReset() { + favoritesTenantID.value = null; + favoritesTenantCode.value = ''; + favoritesTenantName.value = ''; + favoritesContentID.value = null; + favoritesKeyword.value = ''; + favoritesCreatedAtFrom.value = null; + favoritesCreatedAtTo.value = null; + favoritesPage.value = 1; + favoritesRows.value = 10; + loadFavorites(); +} + +function onFavoritesPage(event) { + favoritesPage.value = (event.page ?? 0) + 1; + favoritesRows.value = event.rows ?? favoritesRows.value; + loadFavorites(); +} + +function onLikesSearch() { + likesPage.value = 1; + loadLikes(); +} + +function onLikesReset() { + likesTenantID.value = null; + likesTenantCode.value = ''; + likesTenantName.value = ''; + likesContentID.value = null; + likesKeyword.value = ''; + likesCreatedAtFrom.value = null; + likesCreatedAtTo.value = null; + likesPage.value = 1; + likesRows.value = 10; + loadLikes(); +} + +function onLikesPage(event) { + likesPage.value = (event.page ?? 0) + 1; + likesRows.value = event.rows ?? likesRows.value; + loadLikes(); +} + +function onFollowingSearch() { + followingTenantsPage.value = 1; + loadFollowingTenants(); +} + +function onFollowingReset() { + followingTenantID.value = null; + followingCode.value = ''; + followingName.value = ''; + followingStatus.value = ''; + followingJoinedAtFrom.value = null; + followingJoinedAtTo.value = null; + followingTenantsPage.value = 1; + followingTenantsRows.value = 10; + loadFollowingTenants(); +} + +function onFollowingPage(event) { + followingTenantsPage.value = (event.page ?? 0) + 1; + followingTenantsRows.value = event.rows ?? followingTenantsRows.value; + loadFollowingTenants(); +} + function onNotificationsPage(event) { notificationsPage.value = (event.page ?? 0) + 1; notificationsRows.value = event.rows ?? notificationsRows.value; @@ -418,16 +604,22 @@ watch( () => { ownedTenantsPage.value = 1; joinedTenantsPage.value = 1; + followingTenantsPage.value = 1; notificationsPage.value = 1; couponsPage.value = 1; + favoritesPage.value = 1; + likesPage.value = 1; rechargeOrdersPage.value = 1; loadUser(); loadWallet(); loadRealName(); loadOwnedTenants(); loadJoinedTenants(); + loadFollowingTenants(); loadNotifications(); loadCoupons(); + loadFavorites(); + loadLikes(); loadRechargeOrders(); }, { immediate: true } @@ -496,6 +688,9 @@ onMounted(() => { 拥有的租户 加入的租户 + 关注 + 收藏 + 点赞 钱包 充值记录 优惠券 @@ -648,6 +843,263 @@ onMounted(() => { + +
+
+ 共 {{ followingTenantsTotal }} 条 +
+ + + + + + + + + + + + +