feat: add tenant admin invite management, ledger overview, order details, and order management features

- Implemented Invite management with creation, searching, and disabling functionalities.
- Added Ledger overview for financial transactions with filtering options.
- Developed Order Detail view for individual order insights and refund capabilities.
- Created Orders management page with search, reset, and pagination features.
- Enhanced user experience with toast notifications for actions and error handling.
This commit is contained in:
2025-12-24 19:41:44 +08:00
parent 1d0b38bbb7
commit 87f569cc6a
27 changed files with 3652 additions and 151 deletions

View File

@@ -11,6 +11,7 @@ import (
"quyun/v2/app/http/tenant_join"
"quyun/v2/app/http/tenant_media"
"quyun/v2/app/http/tenant_public"
"quyun/v2/app/http/web"
"quyun/v2/app/jobs"
"quyun/v2/app/middlewares"
"quyun/v2/app/services"
@@ -60,7 +61,7 @@ func Command() atom.Option {
tenant_public.Provide,
tenant_media.Provide,
// {Provider: api.Provide},
// {Provider: web.Provide},
web.Provide,
),
),
)

View File

@@ -5,6 +5,7 @@ import (
"quyun/v2/app/errorx"
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/app/services"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
@@ -28,6 +29,45 @@ func requireTenantAdmin(tenantUser *models.TenantUser) error {
return nil
}
// list
//
// @Summary 内容列表(租户管理)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenantCode path string true "Tenant Code"
// @Param filter query dto.AdminContentListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=dto.AdminContentItem}
//
// @Router /t/:tenantCode/v1/admin/contents [get]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind filter query
func (*contentAdmin) list(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, filter *dto.AdminContentListFilter) (*requests.Pager, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
if filter == nil {
filter = &dto.AdminContentListFilter{}
}
filter.Pagination.Format()
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": tenantUser.UserID,
"query_user_id": filter.UserID,
"keyword": filter.KeywordTrimmed(),
"status": filter.Status,
"visibility": filter.Visibility,
"published_at_from": filter.PublishedAtFrom,
"published_at_to": filter.PublishedAtTo,
"created_at_from": filter.CreatedAtFrom,
"created_at_to": filter.CreatedAtTo,
}).Info("tenant.admin.contents.list")
return services.Content.AdminContentPage(ctx.Context(), tenant.ID, filter)
}
// create
//
// @Summary 创建内容(草稿)

View File

@@ -0,0 +1,56 @@
package dto
import (
"strings"
"time"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"go.ipao.vip/gen/types"
)
// AdminContentListFilter 租户管理员查询“内容列表(含草稿/已发布/已下架等)”的过滤条件。
type AdminContentListFilter struct {
requests.Pagination `json:",inline" query:",inline"`
requests.SortQueryFilter `json:",inline" query:",inline"`
ID *int64 `json:"id,omitempty" query:"id"`
UserID *int64 `json:"user_id,omitempty" query:"user_id"`
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"`
}
func (f *AdminContentListFilter) KeywordTrimmed() string {
if f == nil || f.Keyword == nil {
return ""
}
return strings.TrimSpace(*f.Keyword)
}
type AdminContentOwnerLite struct {
ID int64 `json:"id"`
Username string `json:"username"`
Status consts.UserStatus `json:"status"`
Roles types.Array[consts.Role] `json:"roles"`
}
type AdminContentItem struct {
Content *models.Content `json:"content,omitempty"`
Price *models.ContentPrice `json:"price,omitempty"`
Owner *AdminContentOwnerLite `json:"owner,omitempty"`
StatusDescription string `json:"status_description,omitempty"`
VisibilityDescription string `json:"visibility_description,omitempty"`
}

View File

@@ -82,6 +82,13 @@ func (r *Routes) Register(router fiber.Router) {
PathParam[int64]("contentID"),
))
// Register routes for controller: contentAdmin
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/contents -> contentAdmin.list")
router.Get("/t/:tenantCode/v1/admin/contents"[len(r.Path()):], DataFunc3(
r.contentAdmin.list,
Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"),
Query[dto.AdminContentListFilter]("filter"),
))
r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/admin/contents/:contentID -> contentAdmin.update")
router.Patch("/t/:tenantCode/v1/admin/contents/:contentID"[len(r.Path()):], DataFunc4(
r.contentAdmin.update,

View File

@@ -0,0 +1,69 @@
package web
import (
"quyun/v2/app/errorx"
"quyun/v2/app/http/web/dto"
"quyun/v2/app/services"
"quyun/v2/pkg/consts"
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
)
// @provider
type auth struct {
jwt *jwt.JWT
}
// Login 用户登录(平台侧,非超级管理员)。
//
// @Summary 用户登录
// @Tags Web
// @Accept json
// @Produce json
// @Param form body dto.LoginForm true "form"
// @Success 200 {object} dto.LoginResponse "成功"
// @Router /v1/auth/login [post]
// @Bind form body
func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse, error) {
m, err := services.User.FindByUsername(ctx, form.Username)
if err != nil {
return nil, errorx.Wrap(err).WithMsg("用户名或密码错误")
}
if ok := m.ComparePassword(ctx, form.Password); !ok {
return nil, errorx.Wrap(errorx.ErrInvalidCredentials).WithMsg("用户名或密码错误")
}
token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{
UserID: m.ID,
}))
if err != nil {
return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败")
}
return &dto.LoginResponse{Token: token}, nil
}
// Token 刷新登录凭证。
//
// @Summary 刷新 Token
// @Tags Web
// @Accept json
// @Produce json
// @Success 200 {object} dto.LoginResponse "成功"
// @Router /v1/auth/token [get]
func (ctl *auth) token(ctx fiber.Ctx) (*dto.LoginResponse, error) {
claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims)
if !ok || claims == nil || claims.UserID <= 0 {
return nil, errorx.ErrTokenInvalid
}
token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{
UserID: claims.UserID,
}))
if err != nil {
return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败")
}
return &dto.LoginResponse{Token: token}, nil
}

View File

@@ -0,0 +1,16 @@
package dto
// LoginForm 平台侧用户登录表单(用于获取 JWT 访问凭证)。
// 注意:此登录是“用户身份”登录(非超级管理员),用于进入租户管理后台前的身份校验与租户列表查询。
type LoginForm struct {
// Username 用户名;必须与数据库 users.username 精确匹配。
Username string `json:"username,omitempty"`
// Password 明文密码;后端会与 users.password 的 bcrypt hash 做比对。
Password string `json:"password,omitempty"`
}
// LoginResponse 登录响应。
type LoginResponse struct {
// Token JWT 访问令牌;前端应以 `Authorization: Bearer <token>` 方式携带。
Token string `json:"token"`
}

View File

@@ -0,0 +1,51 @@
package dto
import (
"time"
"quyun/v2/pkg/consts"
"go.ipao.vip/gen/types"
)
// MeResponse 当前登录用户信息(脱敏)。
type MeResponse struct {
// ID 用户ID全局唯一
ID int64 `json:"id"`
// Username 用户名。
Username string `json:"username"`
// Roles 用户全局角色数组(如 user/super_admin 等)。
Roles types.Array[consts.Role] `json:"roles"`
// Status 用户状态active/verified/banned 等)。
Status consts.UserStatus `json:"status"`
// StatusDescription 用户状态描述(便于前端展示)。
StatusDescription string `json:"status_description"`
// CreatedAt 用户创建时间。
CreatedAt time.Time `json:"created_at"`
// UpdatedAt 用户更新时间。
UpdatedAt time.Time `json:"updated_at"`
}
// MyTenantItem 当前用户可进入的租户条目(用于“选择租户进入后台”页面)。
type MyTenantItem struct {
// TenantID 租户ID数值型主键
TenantID int64 `json:"tenant_id"`
// TenantCode 租户Code路由使用/t/:tenantCode/...)。
TenantCode string `json:"tenant_code"`
// TenantName 租户名称。
TenantName string `json:"tenant_name"`
// TenantStatus 租户状态pending/verified/expired 等)。
TenantStatus consts.TenantStatus `json:"tenant_status"`
// TenantStatusDescription 租户状态描述(便于前端展示)。
TenantStatusDescription string `json:"tenant_status_description"`
// IsOwner 是否为租户Ownertenants.user_id == 当前用户)。
// 说明Owner 通常也在 tenant_users 里具备 tenant_admin 角色,但此字段更直观。
IsOwner bool `json:"is_owner"`
// MemberRoles 当前用户在该租户下的角色tenant_admin/member 等)。
MemberRoles types.Array[consts.TenantUserRole] `json:"member_roles"`
// MemberStatus 当前用户在该租户下的成员状态。
MemberStatus consts.UserStatus `json:"member_status"`
// JoinedAt 加入租户时间tenant_users.created_at
JoinedAt time.Time `json:"joined_at"`
}

View File

@@ -0,0 +1,61 @@
package web
import (
"quyun/v2/app/errorx"
"quyun/v2/app/http/web/dto"
"quyun/v2/app/services"
"quyun/v2/pkg/consts"
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
)
// @provider
type me struct{}
// Me 获取当前登录用户信息(脱敏)。
//
// @Summary 当前用户信息
// @Tags Web
// @Accept json
// @Produce json
// @Success 200 {object} dto.MeResponse "成功"
// @Router /v1/me [get]
func (ctl *me) me(ctx fiber.Ctx) (*dto.MeResponse, error) {
claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims)
if !ok || claims == nil || claims.UserID <= 0 {
return nil, errorx.ErrTokenInvalid
}
m, err := services.User.FindByID(ctx, claims.UserID)
if err != nil {
return nil, err
}
return &dto.MeResponse{
ID: m.ID,
Username: m.Username,
Roles: m.Roles,
Status: m.Status,
StatusDescription: m.Status.Description(),
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}, nil
}
// MyTenants 获取当前用户可进入的租户列表。
//
// @Summary 我的租户列表
// @Tags Web
// @Accept json
// @Produce json
// @Success 200 {array} dto.MyTenantItem "成功"
// @Router /v1/me/tenants [get]
func (ctl *me) myTenants(ctx fiber.Ctx) ([]*dto.MyTenantItem, error) {
claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims)
if !ok || claims == nil || claims.UserID <= 0 {
return nil, errorx.ErrTokenInvalid
}
return services.Tenant.UserTenants(ctx, claims.UserID)
}

View File

@@ -0,0 +1,51 @@
package web
import (
"quyun/v2/app/middlewares"
"quyun/v2/providers/jwt"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"go.ipao.vip/atom/opt"
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func(
jwt *jwt.JWT,
) (*auth, error) {
obj := &auth{
jwt: jwt,
}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*me, error) {
obj := &me{}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func(
auth *auth,
me *me,
middlewares *middlewares.Middlewares,
) (contracts.HttpRoute, error) {
obj := &Routes{
auth: auth,
me: me,
middlewares: middlewares,
}
if err := obj.Prepare(); err != nil {
return nil, err
}
return obj, nil
}, atom.GroupRoutes); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,66 @@
// Code generated by atomctl. DO NOT EDIT.
// Package web provides HTTP route definitions and registration
// for the quyun/v2 application.
package web
import (
"quyun/v2/app/http/web/dto"
"quyun/v2/app/middlewares"
"github.com/gofiber/fiber/v3"
log "github.com/sirupsen/logrus"
_ "go.ipao.vip/atom"
_ "go.ipao.vip/atom/contracts"
. "go.ipao.vip/atom/fen"
)
// Routes implements the HttpRoute contract and provides route registration
// for all controllers in the web module.
//
// @provider contracts.HttpRoute atom.GroupRoutes
type Routes struct {
log *log.Entry `inject:"false"`
middlewares *middlewares.Middlewares
// Controller instances
auth *auth
me *me
}
// Prepare initializes the routes provider with logging configuration.
func (r *Routes) Prepare() error {
r.log = log.WithField("module", "routes.web")
r.log.Info("Initializing routes module")
return nil
}
// Name returns the unique identifier for this routes provider.
func (r *Routes) Name() string {
return "web"
}
// Register registers all HTTP routes with the provided fiber router.
// Each route is registered with its corresponding controller action and parameter bindings.
func (r *Routes) Register(router fiber.Router) {
// Register routes for controller: auth
r.log.Debugf("Registering route: Get /v1/auth/token -> auth.token")
router.Get("/v1/auth/token"[len(r.Path()):], DataFunc0(
r.auth.token,
))
r.log.Debugf("Registering route: Post /v1/auth/login -> auth.login")
router.Post("/v1/auth/login"[len(r.Path()):], DataFunc1(
r.auth.login,
Body[dto.LoginForm]("form"),
))
// Register routes for controller: me
r.log.Debugf("Registering route: Get /v1/me -> me.me")
router.Get("/v1/me"[len(r.Path()):], DataFunc0(
r.me.me,
))
r.log.Debugf("Registering route: Get /v1/me/tenants -> me.myTenants")
router.Get("/v1/me/tenants"[len(r.Path()):], DataFunc0(
r.me.myTenants,
))
r.log.Info("Successfully registered all routes")
}

View File

@@ -0,0 +1,11 @@
package web
func (r *Routes) Path() string {
return "/v1"
}
func (r *Routes) Middlewares() []any {
return []any{
r.middlewares.UserAuth,
}
}

View File

@@ -0,0 +1,57 @@
package middlewares
import (
"strings"
"quyun/v2/app/errorx"
"quyun/v2/pkg/consts"
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
)
func shouldSkipUserJWTAuth(path string) bool {
// 登录接口无需鉴权。
if strings.Contains(path, "/v1/auth/login") {
return true
}
return false
}
// UserAuth 为平台通用(非租户域)接口提供 JWT 校验,并写入 claims 到 ctx locals。
func (f *Middlewares) UserAuth(c fiber.Ctx) error {
if shouldSkipUserJWTAuth(c.Path()) {
f.log.Debug("middlewares.user.auth.skipped")
return c.Next()
}
authHeader := c.Get(jwt.HttpHeader)
if authHeader == "" {
f.log.Info("middlewares.user.auth.missing_token")
return errorx.ErrTokenMissing
}
claims, err := f.jwt.Parse(authHeader)
if err != nil {
f.log.WithError(err).Warn("middlewares.user.auth.invalid_token")
switch err {
case jwt.TokenExpired:
return errorx.ErrTokenExpired
case jwt.TokenMalformed, jwt.TokenNotValidYet, jwt.TokenInvalid:
return errorx.ErrTokenInvalid
default:
return errorx.ErrTokenInvalid
}
}
if claims.UserID == 0 {
f.log.Warn("middlewares.user.auth.missing_user_id")
return errorx.ErrTokenInvalid
}
f.log.WithFields(map[string]any{
"user_id": claims.UserID,
}).Info("middlewares.user.auth.ok")
c.Locals(consts.CtxKeyClaims, claims)
return c.Next()
}

View File

@@ -0,0 +1,180 @@
package services
import (
"context"
"strings"
tenantdto "quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
"quyun/v2/database/models"
"github.com/pkg/errors"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"go.ipao.vip/gen"
"go.ipao.vip/gen/field"
)
// AdminContentPage returns contents list for tenant admin (includes drafts/unpublished/etc).
func (s *content) AdminContentPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminContentListFilter) (*requests.Pager, error) {
if tenantID <= 0 {
return nil, errors.New("tenant_id must be > 0")
}
if filter == nil {
filter = &tenantdto.AdminContentListFilter{}
}
log.WithFields(log.Fields{
"tenant_id": tenantID,
"page": filter.Page,
"limit": filter.Limit,
"user_id": lo.FromPtr(filter.UserID),
"keyword": filter.KeywordTrimmed(),
"status": lo.FromPtr(filter.Status),
}).Info("services.content.admin.page")
filter.Pagination.Format()
cTbl, query := models.ContentQuery.QueryContext(ctx)
query = query.Select(cTbl.ALL)
conds := []gen.Condition{
cTbl.TenantID.Eq(tenantID),
cTbl.DeletedAt.IsNull(),
}
if filter.ID != nil && *filter.ID > 0 {
conds = append(conds, cTbl.ID.Eq(*filter.ID))
}
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))
}
orderBys := make([]field.Expr, 0, 6)
allowedAsc := map[string]field.Expr{
"id": cTbl.ID.Asc(),
"title": cTbl.Title.Asc(),
"user_id": cTbl.UserID.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(),
"title": cTbl.Title.Desc(),
"user_id": cTbl.UserID.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
}
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
}))
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
}))
priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs)
if err != nil {
return nil, err
}
ownerMap := map[int64]*tenantdto.AdminContentOwnerLite{}
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] = &tenantdto.AdminContentOwnerLite{
ID: u.ID,
Username: u.Username,
Status: u.Status,
Roles: u.Roles,
}
}
}
respItems := lo.Map(items, func(model *models.Content, _ int) *tenantdto.AdminContentItem {
if model == nil {
return nil
}
return &tenantdto.AdminContentItem{
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
}

View File

@@ -7,6 +7,7 @@ import (
superdto "quyun/v2/app/http/super/dto"
tenantdto "quyun/v2/app/http/tenant/dto"
web_dto "quyun/v2/app/http/web/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
"quyun/v2/database/models"
@@ -26,6 +27,72 @@ import (
// @provider
type tenant struct{}
// UserTenants 查询“当前用户可进入的租户列表”(平台通用域 /v1
// - 返回 tenant_users 维度的角色与加入时间,便于前端做“选择租户进入后台”的交互。
// - 不做租户状态过滤:前端可以根据 TenantStatusDescription 做提示或禁用进入按钮。
func (t *tenant) UserTenants(ctx context.Context, userID int64) ([]*web_dto.MyTenantItem, error) {
if userID <= 0 {
return nil, errors.New("user_id must be > 0")
}
// 先查 tenant_users拿到用户加入的租户ID与角色信息避免 join scan 冲突/字段覆盖问题)。
tuTbl, tuQuery := models.TenantUserQuery.QueryContext(ctx)
tenantUsers, err := tuQuery.Where(tuTbl.UserID.Eq(userID)).Order(tuTbl.ID.Desc()).Find()
if err != nil {
return nil, err
}
if len(tenantUsers) == 0 {
return []*web_dto.MyTenantItem{}, nil
}
tenantIDs := lo.Uniq(lo.FilterMap(tenantUsers, func(tu *models.TenantUser, _ int) (int64, bool) {
if tu == nil || tu.TenantID <= 0 {
return 0, false
}
return tu.TenantID, true
}))
if len(tenantIDs) == 0 {
return []*web_dto.MyTenantItem{}, nil
}
// 再查 tenants批量并构建映射保持输出顺序以 tenant_users 为准。
teTbl, teQuery := models.TenantQuery.QueryContext(ctx)
tenants, err := teQuery.Where(teTbl.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, err
}
tenantMap := make(map[int64]*models.Tenant, len(tenants))
for _, te := range tenants {
if te == nil {
continue
}
tenantMap[te.ID] = te
}
items := make([]*web_dto.MyTenantItem, 0, len(tenantUsers))
for _, tu := range tenantUsers {
if tu == nil {
continue
}
te := tenantMap[tu.TenantID]
if te == nil {
continue
}
items = append(items, &web_dto.MyTenantItem{
TenantID: te.ID,
TenantCode: te.Code,
TenantName: te.Name,
TenantStatus: te.Status,
TenantStatusDescription: te.Status.Description(),
IsOwner: te.UserID == userID,
MemberRoles: tu.Role,
MemberStatus: tu.Status,
JoinedAt: tu.CreatedAt,
})
}
return items, nil
}
// SuperDetail 查询单个租户详情(平台侧)。
func (t *tenant) SuperDetail(ctx context.Context, tenantID int64) (*superdto.TenantItem, error) {
if tenantID <= 0 {

View File

@@ -42,7 +42,7 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.LoginForm"
"$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginForm"
}
}
],
@@ -50,7 +50,7 @@ const docTemplate = `{
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/dto.LoginResponse"
"$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginResponse"
}
}
}
@@ -71,7 +71,7 @@ const docTemplate = `{
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/dto.LoginResponse"
"$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginResponse"
}
}
}
@@ -1429,6 +1429,140 @@ const docTemplate = `{
}
},
"/t/{tenantCode}/v1/admin/contents": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Tenant"
],
"summary": "内容列表(租户管理)",
"parameters": [
{
"type": "string",
"description": "Tenant Code",
"name": "tenantCode",
"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": "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": "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.AdminContentItem"
}
}
}
]
}
}
}
},
"post": {
"consumes": [
"application/json"
@@ -3295,7 +3429,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.MeResponse"
"$ref": "#/definitions/quyun_v2_app_http_tenant_dto.MeResponse"
}
}
}
@@ -3717,6 +3851,108 @@ const docTemplate = `{
}
}
}
},
"/v1/auth/login": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "用户登录",
"parameters": [
{
"description": "form",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginForm"
}
}
],
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginResponse"
}
}
}
}
},
"/v1/auth/token": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "刷新 Token",
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginResponse"
}
}
}
}
},
"/v1/me": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "当前用户信息",
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/quyun_v2_app_http_web_dto.MeResponse"
}
}
}
}
},
"/v1/me/tenants": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "我的租户列表",
"responses": {
"200": {
"description": "成功",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.MyTenantItem"
}
}
}
}
}
}
},
"definitions": {
@@ -3958,6 +4194,46 @@ const docTemplate = `{
"UserStatusBanned"
]
},
"dto.AdminContentItem": {
"type": "object",
"properties": {
"content": {
"$ref": "#/definitions/models.Content"
},
"owner": {
"$ref": "#/definitions/dto.AdminContentOwnerLite"
},
"price": {
"$ref": "#/definitions/models.ContentPrice"
},
"status_description": {
"type": "string"
},
"visibility_description": {
"type": "string"
}
}
},
"dto.AdminContentOwnerLite": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"roles": {
"type": "array",
"items": {
"$ref": "#/definitions/consts.Role"
}
},
"status": {
"$ref": "#/definitions/consts.UserStatus"
},
"username": {
"type": "string"
}
}
},
"dto.AdminLedgerItem": {
"type": "object",
"properties": {
@@ -4435,25 +4711,6 @@ const docTemplate = `{
}
}
},
"dto.LoginForm": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"dto.LoginResponse": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"dto.MeBalanceResponse": {
"type": "object",
"properties": {
@@ -4479,35 +4736,6 @@ const docTemplate = `{
}
}
},
"dto.MeResponse": {
"type": "object",
"properties": {
"tenant": {
"description": "Tenant is the resolved tenant by ` + "`" + `tenantCode` + "`" + `.",
"allOf": [
{
"$ref": "#/definitions/models.Tenant"
}
]
},
"tenant_user": {
"description": "TenantUser is the membership record of the authenticated user within the tenant.",
"allOf": [
{
"$ref": "#/definitions/models.TenantUser"
}
]
},
"user": {
"description": "User is the authenticated user derived from JWT ` + "`" + `user_id` + "`" + `.",
"allOf": [
{
"$ref": "#/definitions/models.User"
}
]
}
}
},
"dto.MyLedgerItem": {
"type": "object",
"properties": {
@@ -4525,6 +4753,58 @@ const docTemplate = `{
}
}
},
"dto.MyTenantItem": {
"type": "object",
"properties": {
"is_owner": {
"description": "IsOwner 是否为租户Ownertenants.user_id == 当前用户)。\n说明Owner 通常也在 tenant_users 里具备 tenant_admin 角色,但此字段更直观。",
"type": "boolean"
},
"joined_at": {
"description": "JoinedAt 加入租户时间tenant_users.created_at。",
"type": "string"
},
"member_roles": {
"description": "MemberRoles 当前用户在该租户下的角色tenant_admin/member 等)。",
"type": "array",
"items": {
"$ref": "#/definitions/consts.TenantUserRole"
}
},
"member_status": {
"description": "MemberStatus 当前用户在该租户下的成员状态。",
"allOf": [
{
"$ref": "#/definitions/consts.UserStatus"
}
]
},
"tenant_code": {
"description": "TenantCode 租户Code路由使用/t/:tenantCode/...)。",
"type": "string"
},
"tenant_id": {
"description": "TenantID 租户ID数值型主键。",
"type": "integer"
},
"tenant_name": {
"description": "TenantName 租户名称。",
"type": "string"
},
"tenant_status": {
"description": "TenantStatus 租户状态pending/verified/expired 等)。",
"allOf": [
{
"$ref": "#/definitions/consts.TenantStatus"
}
]
},
"tenant_status_description": {
"description": "TenantStatusDescription 租户状态描述(便于前端展示)。",
"type": "string"
}
}
},
"dto.OrderBuyerLite": {
"type": "object",
"properties": {
@@ -5899,6 +6179,116 @@ const docTemplate = `{
}
}
},
"quyun_v2_app_http_super_dto.LoginForm": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"quyun_v2_app_http_super_dto.LoginResponse": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"quyun_v2_app_http_tenant_dto.MeResponse": {
"type": "object",
"properties": {
"tenant": {
"description": "Tenant is the resolved tenant by ` + "`" + `tenantCode` + "`" + `.",
"allOf": [
{
"$ref": "#/definitions/models.Tenant"
}
]
},
"tenant_user": {
"description": "TenantUser is the membership record of the authenticated user within the tenant.",
"allOf": [
{
"$ref": "#/definitions/models.TenantUser"
}
]
},
"user": {
"description": "User is the authenticated user derived from JWT ` + "`" + `user_id` + "`" + `.",
"allOf": [
{
"$ref": "#/definitions/models.User"
}
]
}
}
},
"quyun_v2_app_http_web_dto.LoginForm": {
"type": "object",
"properties": {
"password": {
"description": "Password 明文密码;后端会与 users.password 的 bcrypt hash 做比对。",
"type": "string"
},
"username": {
"description": "Username 用户名;必须与数据库 users.username 精确匹配。",
"type": "string"
}
}
},
"quyun_v2_app_http_web_dto.LoginResponse": {
"type": "object",
"properties": {
"token": {
"description": "Token JWT 访问令牌;前端应以 ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + ` 方式携带。",
"type": "string"
}
}
},
"quyun_v2_app_http_web_dto.MeResponse": {
"type": "object",
"properties": {
"created_at": {
"description": "CreatedAt 用户创建时间。",
"type": "string"
},
"id": {
"description": "ID 用户ID全局唯一。",
"type": "integer"
},
"roles": {
"description": "Roles 用户全局角色数组(如 user/super_admin 等)。",
"type": "array",
"items": {
"$ref": "#/definitions/consts.Role"
}
},
"status": {
"description": "Status 用户状态active/verified/banned 等)。",
"allOf": [
{
"$ref": "#/definitions/consts.UserStatus"
}
]
},
"status_description": {
"description": "StatusDescription 用户状态描述(便于前端展示)。",
"type": "string"
},
"updated_at": {
"description": "UpdatedAt 用户更新时间。",
"type": "string"
},
"username": {
"description": "Username 用户名。",
"type": "string"
}
}
},
"requests.KV": {
"type": "object",
"properties": {

View File

@@ -36,7 +36,7 @@
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.LoginForm"
"$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginForm"
}
}
],
@@ -44,7 +44,7 @@
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/dto.LoginResponse"
"$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginResponse"
}
}
}
@@ -65,7 +65,7 @@
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/dto.LoginResponse"
"$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginResponse"
}
}
}
@@ -1423,6 +1423,140 @@
}
},
"/t/{tenantCode}/v1/admin/contents": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Tenant"
],
"summary": "内容列表(租户管理)",
"parameters": [
{
"type": "string",
"description": "Tenant Code",
"name": "tenantCode",
"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": "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": "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.AdminContentItem"
}
}
}
]
}
}
}
},
"post": {
"consumes": [
"application/json"
@@ -3289,7 +3423,7 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.MeResponse"
"$ref": "#/definitions/quyun_v2_app_http_tenant_dto.MeResponse"
}
}
}
@@ -3711,6 +3845,108 @@
}
}
}
},
"/v1/auth/login": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "用户登录",
"parameters": [
{
"description": "form",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginForm"
}
}
],
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginResponse"
}
}
}
}
},
"/v1/auth/token": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "刷新 Token",
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginResponse"
}
}
}
}
},
"/v1/me": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "当前用户信息",
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/quyun_v2_app_http_web_dto.MeResponse"
}
}
}
}
},
"/v1/me/tenants": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "我的租户列表",
"responses": {
"200": {
"description": "成功",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/dto.MyTenantItem"
}
}
}
}
}
}
},
"definitions": {
@@ -3952,6 +4188,46 @@
"UserStatusBanned"
]
},
"dto.AdminContentItem": {
"type": "object",
"properties": {
"content": {
"$ref": "#/definitions/models.Content"
},
"owner": {
"$ref": "#/definitions/dto.AdminContentOwnerLite"
},
"price": {
"$ref": "#/definitions/models.ContentPrice"
},
"status_description": {
"type": "string"
},
"visibility_description": {
"type": "string"
}
}
},
"dto.AdminContentOwnerLite": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"roles": {
"type": "array",
"items": {
"$ref": "#/definitions/consts.Role"
}
},
"status": {
"$ref": "#/definitions/consts.UserStatus"
},
"username": {
"type": "string"
}
}
},
"dto.AdminLedgerItem": {
"type": "object",
"properties": {
@@ -4429,25 +4705,6 @@
}
}
},
"dto.LoginForm": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"dto.LoginResponse": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"dto.MeBalanceResponse": {
"type": "object",
"properties": {
@@ -4473,35 +4730,6 @@
}
}
},
"dto.MeResponse": {
"type": "object",
"properties": {
"tenant": {
"description": "Tenant is the resolved tenant by `tenantCode`.",
"allOf": [
{
"$ref": "#/definitions/models.Tenant"
}
]
},
"tenant_user": {
"description": "TenantUser is the membership record of the authenticated user within the tenant.",
"allOf": [
{
"$ref": "#/definitions/models.TenantUser"
}
]
},
"user": {
"description": "User is the authenticated user derived from JWT `user_id`.",
"allOf": [
{
"$ref": "#/definitions/models.User"
}
]
}
}
},
"dto.MyLedgerItem": {
"type": "object",
"properties": {
@@ -4519,6 +4747,58 @@
}
}
},
"dto.MyTenantItem": {
"type": "object",
"properties": {
"is_owner": {
"description": "IsOwner 是否为租户Ownertenants.user_id == 当前用户)。\n说明Owner 通常也在 tenant_users 里具备 tenant_admin 角色,但此字段更直观。",
"type": "boolean"
},
"joined_at": {
"description": "JoinedAt 加入租户时间tenant_users.created_at。",
"type": "string"
},
"member_roles": {
"description": "MemberRoles 当前用户在该租户下的角色tenant_admin/member 等)。",
"type": "array",
"items": {
"$ref": "#/definitions/consts.TenantUserRole"
}
},
"member_status": {
"description": "MemberStatus 当前用户在该租户下的成员状态。",
"allOf": [
{
"$ref": "#/definitions/consts.UserStatus"
}
]
},
"tenant_code": {
"description": "TenantCode 租户Code路由使用/t/:tenantCode/...)。",
"type": "string"
},
"tenant_id": {
"description": "TenantID 租户ID数值型主键。",
"type": "integer"
},
"tenant_name": {
"description": "TenantName 租户名称。",
"type": "string"
},
"tenant_status": {
"description": "TenantStatus 租户状态pending/verified/expired 等)。",
"allOf": [
{
"$ref": "#/definitions/consts.TenantStatus"
}
]
},
"tenant_status_description": {
"description": "TenantStatusDescription 租户状态描述(便于前端展示)。",
"type": "string"
}
}
},
"dto.OrderBuyerLite": {
"type": "object",
"properties": {
@@ -5893,6 +6173,116 @@
}
}
},
"quyun_v2_app_http_super_dto.LoginForm": {
"type": "object",
"properties": {
"password": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"quyun_v2_app_http_super_dto.LoginResponse": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"quyun_v2_app_http_tenant_dto.MeResponse": {
"type": "object",
"properties": {
"tenant": {
"description": "Tenant is the resolved tenant by `tenantCode`.",
"allOf": [
{
"$ref": "#/definitions/models.Tenant"
}
]
},
"tenant_user": {
"description": "TenantUser is the membership record of the authenticated user within the tenant.",
"allOf": [
{
"$ref": "#/definitions/models.TenantUser"
}
]
},
"user": {
"description": "User is the authenticated user derived from JWT `user_id`.",
"allOf": [
{
"$ref": "#/definitions/models.User"
}
]
}
}
},
"quyun_v2_app_http_web_dto.LoginForm": {
"type": "object",
"properties": {
"password": {
"description": "Password 明文密码;后端会与 users.password 的 bcrypt hash 做比对。",
"type": "string"
},
"username": {
"description": "Username 用户名;必须与数据库 users.username 精确匹配。",
"type": "string"
}
}
},
"quyun_v2_app_http_web_dto.LoginResponse": {
"type": "object",
"properties": {
"token": {
"description": "Token JWT 访问令牌;前端应以 `Authorization: Bearer \u003ctoken\u003e` 方式携带。",
"type": "string"
}
}
},
"quyun_v2_app_http_web_dto.MeResponse": {
"type": "object",
"properties": {
"created_at": {
"description": "CreatedAt 用户创建时间。",
"type": "string"
},
"id": {
"description": "ID 用户ID全局唯一。",
"type": "integer"
},
"roles": {
"description": "Roles 用户全局角色数组(如 user/super_admin 等)。",
"type": "array",
"items": {
"$ref": "#/definitions/consts.Role"
}
},
"status": {
"description": "Status 用户状态active/verified/banned 等)。",
"allOf": [
{
"$ref": "#/definitions/consts.UserStatus"
}
]
},
"status_description": {
"description": "StatusDescription 用户状态描述(便于前端展示)。",
"type": "string"
},
"updated_at": {
"description": "UpdatedAt 用户更新时间。",
"type": "string"
},
"username": {
"description": "Username 用户名。",
"type": "string"
}
}
},
"requests.KV": {
"type": "object",
"properties": {

View File

@@ -184,6 +184,32 @@ definitions:
- UserStatusPendingVerify
- UserStatusVerified
- UserStatusBanned
dto.AdminContentItem:
properties:
content:
$ref: '#/definitions/models.Content'
owner:
$ref: '#/definitions/dto.AdminContentOwnerLite'
price:
$ref: '#/definitions/models.ContentPrice'
status_description:
type: string
visibility_description:
type: string
type: object
dto.AdminContentOwnerLite:
properties:
id:
type: integer
roles:
items:
$ref: '#/definitions/consts.Role'
type: array
status:
$ref: '#/definitions/consts.UserStatus'
username:
type: string
type: object
dto.AdminLedgerItem:
properties:
ledger:
@@ -531,18 +557,6 @@ definitions:
description: Reason 申请原因(可选):用于向租户管理员说明申请加入的目的。
type: string
type: object
dto.LoginForm:
properties:
password:
type: string
username:
type: string
type: object
dto.LoginResponse:
properties:
token:
type: string
type: object
dto.MeBalanceResponse:
properties:
balance:
@@ -559,22 +573,6 @@ definitions:
description: UpdatedAt 更新时间:余额变更时更新。
type: string
type: object
dto.MeResponse:
properties:
tenant:
allOf:
- $ref: '#/definitions/models.Tenant'
description: Tenant is the resolved tenant by `tenantCode`.
tenant_user:
allOf:
- $ref: '#/definitions/models.TenantUser'
description: TenantUser is the membership record of the authenticated user
within the tenant.
user:
allOf:
- $ref: '#/definitions/models.User'
description: User is the authenticated user derived from JWT `user_id`.
type: object
dto.MyLedgerItem:
properties:
ledger:
@@ -585,6 +583,42 @@ definitions:
description: TypeDescription 流水类型中文说明(用于前端展示)。
type: string
type: object
dto.MyTenantItem:
properties:
is_owner:
description: |-
IsOwner 是否为租户Ownertenants.user_id == 当前用户)。
说明Owner 通常也在 tenant_users 里具备 tenant_admin 角色,但此字段更直观。
type: boolean
joined_at:
description: JoinedAt 加入租户时间tenant_users.created_at
type: string
member_roles:
description: MemberRoles 当前用户在该租户下的角色tenant_admin/member 等)。
items:
$ref: '#/definitions/consts.TenantUserRole'
type: array
member_status:
allOf:
- $ref: '#/definitions/consts.UserStatus'
description: MemberStatus 当前用户在该租户下的成员状态。
tenant_code:
description: TenantCode 租户Code路由使用/t/:tenantCode/...)。
type: string
tenant_id:
description: TenantID 租户ID数值型主键
type: integer
tenant_name:
description: TenantName 租户名称。
type: string
tenant_status:
allOf:
- $ref: '#/definitions/consts.TenantStatus'
description: TenantStatus 租户状态pending/verified/expired 等)。
tenant_status_description:
description: TenantStatusDescription 租户状态描述(便于前端展示)。
type: string
type: object
dto.OrderBuyerLite:
properties:
id:
@@ -1503,6 +1537,76 @@ definitions:
verified_at:
type: string
type: object
quyun_v2_app_http_super_dto.LoginForm:
properties:
password:
type: string
username:
type: string
type: object
quyun_v2_app_http_super_dto.LoginResponse:
properties:
token:
type: string
type: object
quyun_v2_app_http_tenant_dto.MeResponse:
properties:
tenant:
allOf:
- $ref: '#/definitions/models.Tenant'
description: Tenant is the resolved tenant by `tenantCode`.
tenant_user:
allOf:
- $ref: '#/definitions/models.TenantUser'
description: TenantUser is the membership record of the authenticated user
within the tenant.
user:
allOf:
- $ref: '#/definitions/models.User'
description: User is the authenticated user derived from JWT `user_id`.
type: object
quyun_v2_app_http_web_dto.LoginForm:
properties:
password:
description: Password 明文密码;后端会与 users.password 的 bcrypt hash 做比对。
type: string
username:
description: Username 用户名;必须与数据库 users.username 精确匹配。
type: string
type: object
quyun_v2_app_http_web_dto.LoginResponse:
properties:
token:
description: 'Token JWT 访问令牌;前端应以 `Authorization: Bearer <token>` 方式携带。'
type: string
type: object
quyun_v2_app_http_web_dto.MeResponse:
properties:
created_at:
description: CreatedAt 用户创建时间。
type: string
id:
description: ID 用户ID全局唯一
type: integer
roles:
description: Roles 用户全局角色数组(如 user/super_admin 等)。
items:
$ref: '#/definitions/consts.Role'
type: array
status:
allOf:
- $ref: '#/definitions/consts.UserStatus'
description: Status 用户状态active/verified/banned 等)。
status_description:
description: StatusDescription 用户状态描述(便于前端展示)。
type: string
updated_at:
description: UpdatedAt 用户更新时间。
type: string
username:
description: Username 用户名。
type: string
type: object
requests.KV:
properties:
key:
@@ -1558,14 +1662,14 @@ paths:
name: form
required: true
schema:
$ref: '#/definitions/dto.LoginForm'
$ref: '#/definitions/quyun_v2_app_http_super_dto.LoginForm'
produces:
- application/json
responses:
"200":
description: 成功
schema:
$ref: '#/definitions/dto.LoginResponse'
$ref: '#/definitions/quyun_v2_app_http_super_dto.LoginResponse'
tags:
- Super
/super/v1/auth/token:
@@ -1578,7 +1682,7 @@ paths:
"200":
description: 成功
schema:
$ref: '#/definitions/dto.LoginResponse'
$ref: '#/definitions/quyun_v2_app_http_super_dto.LoginResponse'
tags:
- Super
/super/v1/contents:
@@ -2465,6 +2569,95 @@ paths:
tags:
- Super
/t/{tenantCode}/v1/admin/contents:
get:
consumes:
- application/json
parameters:
- description: Tenant Code
in: path
name: tenantCode
required: true
type: string
- 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: 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.AdminContentItem'
type: object
summary: 内容列表(租户管理)
tags:
- Tenant
post:
consumes:
- application/json
@@ -3717,7 +3910,7 @@ paths:
"200":
description: OK
schema:
$ref: '#/definitions/dto.MeResponse'
$ref: '#/definitions/quyun_v2_app_http_tenant_dto.MeResponse'
summary: 当前租户上下文信息
tags:
- Tenant
@@ -3994,6 +4187,71 @@ paths:
summary: 获取公开试看资源preview role
tags:
- TenantPublic
/v1/auth/login:
post:
consumes:
- application/json
parameters:
- description: form
in: body
name: form
required: true
schema:
$ref: '#/definitions/quyun_v2_app_http_web_dto.LoginForm'
produces:
- application/json
responses:
"200":
description: 成功
schema:
$ref: '#/definitions/quyun_v2_app_http_web_dto.LoginResponse'
summary: 用户登录
tags:
- Web
/v1/auth/token:
get:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: 成功
schema:
$ref: '#/definitions/quyun_v2_app_http_web_dto.LoginResponse'
summary: 刷新 Token
tags:
- Web
/v1/me:
get:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: 成功
schema:
$ref: '#/definitions/quyun_v2_app_http_web_dto.MeResponse'
summary: 当前用户信息
tags:
- Web
/v1/me/tenants:
get:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: 成功
schema:
items:
$ref: '#/definitions/dto.MyTenantItem'
type: array
summary: 我的租户列表
tags:
- Web
securityDefinitions:
BasicAuth:
type: basic

View File

@@ -24,7 +24,11 @@ const model = computed(() => {
items: [
{ label: '概览', icon: 'pi pi-fw pi-home', to: { name: 'tenantadmin-dashboard', params: { tenantCode: code } } },
{ label: '入驻申请', icon: 'pi pi-fw pi-inbox', to: { name: 'tenantadmin-join-requests', params: { tenantCode: code } } },
{ label: '成员管理', icon: 'pi pi-fw pi-users', to: { name: 'tenantadmin-members', params: { tenantCode: code } } }
{ label: '成员管理', icon: 'pi pi-fw pi-users', to: { name: 'tenantadmin-members', params: { tenantCode: code } } },
{ label: '内容管理', icon: 'pi pi-fw pi-file', to: { name: 'tenantadmin-contents', params: { tenantCode: code } } },
{ label: '订单与退款', icon: 'pi pi-fw pi-shopping-cart', to: { name: 'tenantadmin-orders', params: { tenantCode: code } } },
{ label: '邀请码', icon: 'pi pi-fw pi-ticket', to: { name: 'tenantadmin-invites', params: { tenantCode: code } } },
{ label: '财务流水', icon: 'pi pi-fw pi-wallet', to: { name: 'tenantadmin-ledgers', params: { tenantCode: code } } }
]
}
];

View File

@@ -28,6 +28,31 @@ const router = createRouter({
path: 'members',
name: 'tenantadmin-members',
component: () => import('@/views/tenantadmin/Members.vue')
},
{
path: 'contents',
name: 'tenantadmin-contents',
component: () => import('@/views/tenantadmin/Contents.vue')
},
{
path: 'orders',
name: 'tenantadmin-orders',
component: () => import('@/views/tenantadmin/Orders.vue')
},
{
path: 'orders/:orderID',
name: 'tenantadmin-order-detail',
component: () => import('@/views/tenantadmin/OrderDetail.vue')
},
{
path: 'invites',
name: 'tenantadmin-invites',
component: () => import('@/views/tenantadmin/Invites.vue')
},
{
path: 'ledgers',
name: 'tenantadmin-ledgers',
component: () => import('@/views/tenantadmin/Ledgers.vue')
}
]
},
@@ -40,6 +65,8 @@ const router = createRouter({
router.beforeEach((to) => {
if (to.meta?.requiresAuth !== true) return true;
const token = String(localStorage.getItem('token') || '').trim();
if (!token) return { name: 'tenantadmin-enter' };
const tenantCode = String(to.params?.tenantCode ?? '').trim();
if (!tenantCode) return { name: 'tenantadmin-enter' };
return true;

View File

@@ -7,6 +7,25 @@ function normalizeItems(items) {
}
export const TenantAdminService = {
async login({ username, password } = {}) {
const data = await requestJson('/v1/auth/login', {
method: 'POST',
body: { username, password }
});
return { token: data?.token || '' };
},
async refreshToken() {
const data = await requestJson('/v1/auth/token');
return { token: data?.token || '' };
},
async getMeGlobal() {
return requestJson('/v1/me');
},
async listMyTenants() {
const data = await requestJson('/v1/me/tenants');
if (Array.isArray(data)) return data;
return normalizeItems(data);
},
async getMe(tenantCode) {
return requestJson(tenantApiPath(tenantCode, '/me'));
},
@@ -61,6 +80,207 @@ export const TenantAdminService = {
return requestJson(tenantApiPath(tenantCode, `/admin/users/${userID}/join`), {
method: 'POST'
});
},
async listOrders(
tenantCode,
{
page,
limit,
user_id,
username,
content_id,
content_title,
type,
status,
created_at_from,
created_at_to,
paid_at_from,
paid_at_to,
amount_paid_min,
amount_paid_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,
user_id,
username,
content_id,
content_title,
type,
status,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to),
paid_at_from: iso(paid_at_from),
paid_at_to: iso(paid_at_to),
amount_paid_min,
amount_paid_max
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
}
const data = await requestJson(tenantApiPath(tenantCode, '/admin/orders'), { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async getOrderDetail(tenantCode, orderID) {
if (!orderID) throw new Error('orderID is required');
return requestJson(tenantApiPath(tenantCode, `/admin/orders/${orderID}`));
},
async refundOrder(tenantCode, orderID, { force, reason, idempotency_key } = {}) {
if (!orderID) throw new Error('orderID is required');
return requestJson(tenantApiPath(tenantCode, `/admin/orders/${orderID}/refund`), {
method: 'POST',
body: { force: Boolean(force), reason, idempotency_key }
});
},
async listInvites(tenantCode, { page, limit, status, code } = {}) {
const data = await requestJson(tenantApiPath(tenantCode, '/admin/invites'), {
query: { page, limit, status, code }
});
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async createInvite(tenantCode, { code, max_uses, expires_at, remark } = {}) {
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();
};
return requestJson(tenantApiPath(tenantCode, '/admin/invites'), {
method: 'POST',
body: {
code,
max_uses: max_uses ?? undefined,
expires_at: iso(expires_at),
remark
}
});
},
async disableInvite(tenantCode, inviteID, { reason } = {}) {
if (!inviteID) throw new Error('inviteID is required');
return requestJson(tenantApiPath(tenantCode, `/admin/invites/${inviteID}/disable`), {
method: 'PATCH',
body: { reason }
});
},
async listLedgers(
tenantCode,
{ page, limit, operator_user_id, user_id, type, order_id, biz_ref_type, biz_ref_id, created_at_from, created_at_to } = {}
) {
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 data = await requestJson(tenantApiPath(tenantCode, '/admin/ledgers'), {
query: {
page,
limit,
operator_user_id,
user_id,
type,
order_id,
biz_ref_type,
biz_ref_id,
created_at_from: iso(created_at_from),
created_at_to: iso(created_at_to)
}
});
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async listContents(
tenantCode,
{
page,
limit,
id,
user_id,
keyword,
status,
visibility,
published_at_from,
published_at_to,
created_at_from,
created_at_to,
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,
user_id,
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)
};
if (sortField && sortOrder) {
if (sortOrder === 1) query.asc = sortField;
if (sortOrder === -1) query.desc = sortField;
}
const data = await requestJson(tenantApiPath(tenantCode, '/admin/contents'), { query });
return {
page: data?.page ?? page ?? 1,
limit: data?.limit ?? limit ?? 10,
total: data?.total ?? 0,
items: normalizeItems(data?.items)
};
},
async updateContent(tenantCode, contentID, { title, description, visibility, status, preview_seconds } = {}) {
if (!contentID) throw new Error('contentID is required');
return requestJson(tenantApiPath(tenantCode, `/admin/contents/${contentID}`), {
method: 'PATCH',
body: {
title: title ?? undefined,
description: description ?? undefined,
visibility: visibility ?? undefined,
status: status ?? undefined,
preview_seconds: preview_seconds ?? undefined
}
});
}
};

View File

@@ -0,0 +1,355 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { TenantAdminService } from '@/service/TenantAdminService';
const toast = useToast();
const route = useRoute();
const tenantCode = computed(() => String(route.params.tenantCode || ''));
const loading = ref(false);
const items = ref([]);
const total = ref(0);
const page = ref(1);
const rows = ref(10);
const sortField = ref('id');
const sortOrder = ref(-1);
const contentID = ref(null);
const ownerUserID = ref(null);
const keyword = ref('');
const status = ref('');
const visibility = ref('');
const publishedAtFrom = ref(null);
const publishedAtTo = ref(null);
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const statusOptions = [
{ label: '全部', value: '' },
{ label: 'draft', value: 'draft' },
{ label: 'reviewing', value: 'reviewing' },
{ label: 'published', value: 'published' },
{ label: 'unpublished', value: 'unpublished' },
{ label: 'blocked', value: 'blocked' }
];
const visibilityOptions = [
{ label: '全部', value: '' },
{ label: 'public', value: 'public' },
{ label: 'tenant_only', value: 'tenant_only' },
{ label: 'private', value: 'private' }
];
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
}
function getStatusSeverity(value) {
switch (value) {
case 'published':
return 'success';
case 'reviewing':
return 'warn';
case 'blocked':
return 'danger';
default:
return 'secondary';
}
}
function getVisibilitySeverity(value) {
switch (value) {
case 'public':
return 'info';
case 'tenant_only':
return 'warn';
case 'private':
default:
return 'secondary';
}
}
async function load() {
loading.value = true;
try {
const result = await TenantAdminService.listContents(tenantCode.value, {
page: page.value,
limit: rows.value,
id: contentID.value || undefined,
user_id: ownerUserID.value || undefined,
keyword: keyword.value,
status: status.value || undefined,
visibility: visibility.value || undefined,
published_at_from: publishedAtFrom.value || undefined,
published_at_to: publishedAtTo.value || undefined,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
items.value = result.items || [];
total.value = result.total ?? 0;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载内容列表', life: 5000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
load();
}
function onReset() {
contentID.value = null;
ownerUserID.value = null;
keyword.value = '';
status.value = '';
visibility.value = '';
publishedAtFrom.value = null;
publishedAtTo.value = null;
createdAtFrom.value = null;
createdAtTo.value = null;
sortField.value = 'id';
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
load();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
load();
}
function onSort(event) {
sortField.value = event.sortField ?? sortField.value;
sortOrder.value = event.sortOrder ?? sortOrder.value;
load();
}
const statusDialogVisible = ref(false);
const statusLoading = ref(false);
const statusTarget = ref(null);
const statusNext = ref('');
function openPublish(row) {
statusTarget.value = row;
statusNext.value = 'published';
statusDialogVisible.value = true;
}
function openUnpublish(row) {
statusTarget.value = row;
statusNext.value = 'unpublished';
statusDialogVisible.value = true;
}
async function confirmStatusChange() {
const contentIDValue = statusTarget.value?.content?.id;
if (!contentIDValue) return;
statusLoading.value = true;
try {
await TenantAdminService.updateContent(tenantCode.value, contentIDValue, { status: statusNext.value });
toast.add({ severity: 'success', summary: '已更新状态', detail: `ContentID: ${contentIDValue}`, life: 3000 });
statusDialogVisible.value = false;
await load();
} catch (error) {
toast.add({ severity: 'error', summary: '操作失败', detail: error?.message || '无法更新内容状态', life: 5000 });
} finally {
statusLoading.value = false;
}
}
onMounted(() => {
load();
});
</script>
<template>
<div class="card">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="m-0 text-2xl">内容管理</h3>
<div class="text-lg text-muted-color">查看筛选并上架/下架内容</div>
</div>
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="load" :loading="loading" />
</div>
<div class="grid grid-cols-12 gap-4 text-lg mb-4">
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">ContentID</label>
<InputNumber v-model="contentID" :min="1" class="w-full text-lg" placeholder="精确匹配" />
</div>
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">Owner UserID</label>
<InputNumber v-model="ownerUserID" :min="1" class="w-full text-lg" placeholder="精确匹配" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">关键词标题</label>
<InputText v-model="keyword" class="w-full text-lg" placeholder="模糊匹配" @keyup.enter="onSearch" />
</div>
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">状态</label>
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">可见性</label>
<Select v-model="visibility" :options="visibilityOptions" optionLabel="label" optionValue="value" class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-6 flex items-end gap-3">
<Button label="查询" icon="pi pi-search" class="text-lg px-6" @click="onSearch" :disabled="loading" />
<Button label="重置" icon="pi pi-refresh" severity="secondary" class="text-lg px-6" @click="onReset" :disabled="loading" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">发布时间 From</label>
<DatePicker v-model="publishedAtFrom" showIcon showButtonBar class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">发布时间 To</label>
<DatePicker v-model="publishedAtTo" showIcon showButtonBar class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">创建时间 From</label>
<DatePicker v-model="createdAtFrom" showIcon showButtonBar class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">创建时间 To</label>
<DatePicker v-model="createdAtTo" showIcon showButtonBar class="w-full text-lg" />
</div>
</div>
<DataTable
:value="items"
dataKey="content.id"
:loading="loading"
lazy
:paginator="true"
:rows="rows"
:totalRecords="total"
:first="(page - 1) * rows"
:rowsPerPageOptions="[10, 20, 50]"
sortMode="single"
:sortField="sortField"
:sortOrder="sortOrder"
@page="onPage"
@sort="onSort"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
responsiveLayout="scroll"
>
<Column header="内容" sortField="id" sortable style="min-width: 22rem">
<template #body="{ data }">
<div class="flex flex-col">
<div class="text-lg font-semibold">#{{ data?.content?.id ?? '-' }} {{ data?.content?.title ?? '-' }}</div>
<div class="text-muted-color">创建{{ formatDate(data?.content?.created_at) }}</div>
</div>
</template>
</Column>
<Column header="作者" sortField="user_id" sortable style="min-width: 16rem">
<template #body="{ data }">
<div class="flex flex-col">
<div class="text-lg font-semibold">{{ data?.owner?.username ?? '-' }}</div>
<div class="text-muted-color">ID: {{ data?.owner?.id ?? data?.content?.user_id ?? '-' }}</div>
</div>
</template>
</Column>
<Column header="价格" style="min-width: 12rem">
<template #body="{ data }">
<div class="text-lg font-semibold">{{ formatCny(data?.price?.price_amount) }}</div>
</template>
</Column>
<Column header="状态" sortField="status" sortable style="min-width: 12rem">
<template #body="{ data }">
<Tag
:value="data?.status_description ?? data?.content?.status ?? '-'"
:severity="getStatusSeverity(data?.content?.status)"
class="text-base"
/>
</template>
</Column>
<Column header="可见性" sortField="visibility" sortable style="min-width: 12rem">
<template #body="{ data }">
<Tag
:value="data?.visibility_description ?? data?.content?.visibility ?? '-'"
:severity="getVisibilitySeverity(data?.content?.visibility)"
class="text-base"
/>
</template>
</Column>
<Column header="发布时间" sortField="published_at" sortable style="min-width: 16rem">
<template #body="{ data }">
<span class="text-lg">{{ formatDate(data?.content?.published_at) }}</span>
</template>
</Column>
<Column header="操作" style="min-width: 20rem">
<template #body="{ data }">
<div class="flex flex-wrap gap-3">
<Button
v-if="data?.content?.status !== 'published'"
label="上架"
icon="pi pi-upload"
class="text-lg px-5"
@click="openPublish(data)"
/>
<Button
v-else
label="下架"
icon="pi pi-download"
severity="danger"
class="text-lg px-5"
@click="openUnpublish(data)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="statusDialogVisible" :modal="true" :style="{ width: '560px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium text-xl">{{ statusNext === 'published' ? '确认上架' : '确认下架' }}</span>
<span class="text-muted-color truncate max-w-[260px]">#{{ statusTarget?.content?.id ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-3 text-lg">
<div class="text-muted-color">
内容{{ statusTarget?.content?.title ?? '-' }}
<span v-if="statusTarget?.owner?.username">作者{{ statusTarget.owner.username }}</span>
</div>
<div>
当前状态
<Tag :value="statusTarget?.content?.status ?? '-'" :severity="getStatusSeverity(statusTarget?.content?.status)" class="text-base" />
<span class="mx-2"></span>
<Tag :value="statusNext" :severity="getStatusSeverity(statusNext)" class="text-base" />
</div>
<div v-if="statusNext === 'unpublished'" class="text-muted-color">下架后用户将无法继续购买/访问取决于可见性与权限</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text class="text-lg" @click="statusDialogVisible = false" :disabled="statusLoading" />
<Button
:label="statusNext === 'published' ? '确认上架' : '确认下架'"
icon="pi pi-check"
class="text-lg"
@click="confirmStatusChange"
:loading="statusLoading"
/>
</template>
</Dialog>
</template>

View File

@@ -1,37 +1,173 @@
<script setup>
import { ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { TenantAdminService } from '@/service/TenantAdminService';
const toast = useToast();
const router = useRouter();
const tenantCode = ref('');
function go() {
const code = tenantCode.value.trim();
const token = ref(String(localStorage.getItem('token') || '').trim());
const isLoggedIn = computed(() => token.value.length > 0);
const username = ref('');
const password = ref('');
const loginLoading = ref(false);
const me = ref(null);
const tenantsLoading = ref(false);
const tenants = ref([]);
const keyword = ref('');
function setToken(value) {
const v = String(value || '').trim();
token.value = v;
if (!v) {
localStorage.removeItem('token');
return;
}
localStorage.setItem('token', v.startsWith('Bearer ') ? v.slice('Bearer '.length) : v);
}
const filteredTenants = computed(() => {
const kw = keyword.value.trim().toLowerCase();
if (!kw) return tenants.value || [];
return (tenants.value || []).filter((t) => {
const code = String(t?.tenant_code || '').toLowerCase();
const name = String(t?.tenant_name || '').toLowerCase();
return code.includes(kw) || name.includes(kw);
});
});
async function loadTenants() {
tenantsLoading.value = true;
try {
me.value = await TenantAdminService.getMeGlobal();
tenants.value = await TenantAdminService.listMyTenants();
} catch (error) {
const status = error?.status;
if (status === 401 || status === 403) {
setToken('');
}
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载租户列表', life: 5000 });
} finally {
tenantsLoading.value = false;
}
}
async function doLogin() {
loginLoading.value = true;
try {
const resp = await TenantAdminService.login({ username: username.value.trim(), password: password.value });
if (!resp?.token) throw new Error('登录失败:未返回 token');
setToken(resp.token);
toast.add({ severity: 'success', summary: '登录成功', detail: '正在加载租户列表', life: 2500 });
await loadTenants();
} catch (error) {
toast.add({ severity: 'error', summary: '登录失败', detail: error?.message || '用户名或密码错误', life: 5000 });
} finally {
loginLoading.value = false;
}
}
function logout() {
setToken('');
me.value = null;
tenants.value = [];
username.value = '';
password.value = '';
}
function enterTenant(item) {
const code = String(item?.tenant_code || '').trim();
if (!code) return;
localStorage.setItem('last_tenant_code', code);
router.push({ name: 'tenantadmin-dashboard', params: { tenantCode: code } });
}
onMounted(() => {
if (isLoggedIn.value) {
loadTenants();
} else {
const last = String(localStorage.getItem('last_username') || '').trim();
if (last) username.value = last;
}
});
</script>
<template>
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
<div class="w-full max-w-[560px] px-6">
<div class="card">
<div class="flex flex-col gap-3">
<div class="text-2xl font-semibold">进入租户管理后台</div>
<div class="text-lg text-muted-color">输入租户编码Tenant Code</div>
<div class="flex flex-col gap-3" v-if="!isLoggedIn">
<div class="text-2xl font-semibold">登录后进入租户后台</div>
<div class="text-lg text-muted-color">先登录系统再选择要管理的租户</div>
<div class="flex flex-col gap-2 mt-2">
<label class="text-lg font-medium">租户编码</label>
<InputText v-model="tenantCode" class="w-full text-lg py-3" placeholder="例如abc" @keyup.enter="go" />
<label class="text-lg font-medium">用户名</label>
<InputText
v-model="username"
class="w-full text-lg py-3"
placeholder="请输入用户名"
@keyup.enter="doLogin"
@blur="localStorage.setItem('last_username', username.trim())"
/>
</div>
<Button label="进入" class="w-full text-lg py-3 mt-4" @click="go" :disabled="tenantCode.trim().length === 0" />
<div class="flex flex-col gap-2 mt-2">
<label class="text-lg font-medium">密码</label>
<InputText v-model="password" type="password" class="w-full text-lg py-3" placeholder="请输入密码" @keyup.enter="doLogin" />
</div>
<Button label="登录" icon="pi pi-sign-in" class="w-full text-lg py-3 mt-3" @click="doLogin" :loading="loginLoading" :disabled="!username.trim() || !password" />
<div class="text-sm text-muted-color mt-2">
提示若提示无权限请先使用用户端登录并确保你是该租户管理
说明仅登录成功且拥有对应租户权限才可进入租户管理后台
</div>
</div>
<div class="flex flex-col gap-3" v-else>
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col">
<div class="text-2xl font-semibold">选择租户进入后台</div>
<div class="text-lg text-muted-color">
当前用户<span class="font-medium">{{ me?.username ?? '-' }}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<Button label="退出登录" icon="pi pi-sign-out" severity="secondary" class="text-lg" @click="logout" />
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="loadTenants" :loading="tenantsLoading" />
</div>
</div>
<div class="flex flex-col gap-2 mt-2">
<label class="text-lg font-medium">搜索租户</label>
<InputText v-model="keyword" class="w-full text-lg py-3" placeholder="输入租户编码或名称" />
</div>
<div v-if="tenantsLoading" class="text-lg text-muted-color mt-2">正在加载租户列表...</div>
<div v-else>
<div v-if="filteredTenants.length === 0" class="text-lg text-muted-color mt-2">暂无可进入的租户</div>
<div v-else class="flex flex-col gap-3 mt-3">
<div v-for="t in filteredTenants" :key="t.tenant_id" class="p-4 border rounded-lg bg-surface-0 dark:bg-surface-900">
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col">
<div class="text-xl font-semibold">{{ t?.tenant_name || '-' }}</div>
<div class="text-lg text-muted-color">Code: {{ t?.tenant_code || '-' }} · ID: {{ t?.tenant_id || '-' }}</div>
<div class="text-lg text-muted-color">状态{{ t?.tenant_status_description || t?.tenant_status || '-' }}</div>
</div>
<Button label="进入管理" icon="pi pi-arrow-right" class="text-lg px-6" @click="enterTenant(t)" />
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Tag v-if="t?.is_owner" value="Owner" severity="info" class="text-base" />
<Tag v-for="r in t?.member_roles || []" :key="r" :value="r" severity="secondary" class="text-base" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,300 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { TenantAdminService } from '@/service/TenantAdminService';
const toast = useToast();
const route = useRoute();
const tenantCode = computed(() => String(route.params.tenantCode || ''));
const loading = ref(false);
const items = ref([]);
const total = ref(0);
const page = ref(1);
const rows = ref(10);
const status = ref('active');
const code = ref('');
const statusOptions = [
{ label: '启用中', value: 'active' },
{ label: '已禁用', value: 'disabled' },
{ label: '已过期', value: 'expired' },
{ label: '全部', value: '' }
];
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
async function load() {
loading.value = true;
try {
const result = await TenantAdminService.listInvites(tenantCode.value, {
page: page.value,
limit: rows.value,
status: status.value || undefined,
code: code.value
});
items.value = result.items || [];
total.value = result.total ?? 0;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载邀请码列表', life: 5000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
load();
}
function onReset() {
status.value = 'active';
code.value = '';
page.value = 1;
rows.value = 10;
load();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
load();
}
const createDialogVisible = ref(false);
const createLoading = ref(false);
const createCode = ref('');
const createMaxUses = ref(null);
const createExpiresAt = ref(null);
const createRemark = ref('');
const createdInvite = ref(null);
function openCreateDialog() {
createCode.value = '';
createMaxUses.value = null;
createExpiresAt.value = null;
createRemark.value = '';
createdInvite.value = null;
createDialogVisible.value = true;
}
async function confirmCreate() {
createLoading.value = true;
try {
const inv = await TenantAdminService.createInvite(tenantCode.value, {
code: createCode.value.trim() || '',
max_uses: createMaxUses.value ?? undefined,
expires_at: createExpiresAt.value ?? undefined,
remark: createRemark.value.trim() || ''
});
createdInvite.value = inv;
toast.add({ severity: 'success', summary: '创建成功', detail: `邀请码ID: ${inv?.id ?? '-'}`, life: 3000 });
await load();
} catch (error) {
toast.add({ severity: 'error', summary: '创建失败', detail: error?.message || '无法创建邀请码', life: 5000 });
} finally {
createLoading.value = false;
}
}
async function copyText(text) {
const value = String(text ?? '');
if (!value) return;
try {
await navigator.clipboard.writeText(value);
toast.add({ severity: 'success', summary: '已复制', detail: value, life: 1500 });
} catch {
toast.add({ severity: 'warn', summary: '复制失败', detail: '请手动复制', life: 3000 });
}
}
const disableDialogVisible = ref(false);
const disableLoading = ref(false);
const disableTarget = ref(null);
const disableReason = ref('');
function openDisableDialog(row) {
disableTarget.value = row;
disableReason.value = '';
disableDialogVisible.value = true;
}
async function confirmDisable() {
const id = disableTarget.value?.id;
if (!id) return;
disableLoading.value = true;
try {
await TenantAdminService.disableInvite(tenantCode.value, id, { reason: disableReason.value });
toast.add({ severity: 'success', summary: '已禁用', detail: `邀请码ID: ${id}`, life: 3000 });
disableDialogVisible.value = false;
await load();
} catch (error) {
toast.add({ severity: 'error', summary: '禁用失败', detail: error?.message || '无法禁用邀请码', life: 5000 });
} finally {
disableLoading.value = false;
}
}
onMounted(() => {
load();
});
</script>
<template>
<div class="card">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="m-0 text-2xl">邀请码管理</h3>
<div class="text-lg text-muted-color">创建邀请码并复制给对方加入租户</div>
</div>
<div class="flex gap-3">
<Button label="创建邀请码" icon="pi pi-plus" class="text-lg" @click="openCreateDialog" />
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="load" :loading="loading" />
</div>
</div>
<div class="grid grid-cols-12 gap-4 text-lg mb-4">
<div class="col-span-12 md:col-span-4">
<label class="block font-medium mb-2">状态</label>
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-4">
<label class="block font-medium mb-2">邀请码</label>
<InputText v-model="code" class="w-full text-lg" placeholder="包含匹配" @keyup.enter="onSearch" />
</div>
<div class="col-span-12 md:col-span-4 flex items-end gap-3">
<Button label="查询" icon="pi pi-search" class="text-lg px-6" @click="onSearch" :disabled="loading" />
<Button label="重置" icon="pi pi-refresh" severity="secondary" class="text-lg px-6" @click="onReset" :disabled="loading" />
</div>
</div>
<DataTable
:value="items"
dataKey="id"
:loading="loading"
lazy
:paginator="true"
:rows="rows"
:totalRecords="total"
:first="(page - 1) * rows"
:rowsPerPageOptions="[10, 20, 50]"
@page="onPage"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
responsiveLayout="scroll"
>
<Column field="id" header="ID" style="min-width: 8rem">
<template #body="{ data }">
<span class="text-lg">{{ data?.id ?? '-' }}</span>
</template>
</Column>
<Column field="code" header="邀请码" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<span class="text-lg font-semibold">{{ data?.code ?? '-' }}</span>
<Button label="复制" icon="pi pi-copy" severity="secondary" class="text-lg px-4" @click="copyText(data?.code)" />
</div>
</template>
</Column>
<Column field="status" header="状态" style="min-width: 12rem">
<template #body="{ data }">
<Tag :value="data?.status ?? '-'" severity="secondary" class="text-base" />
</template>
</Column>
<Column field="max_uses" header="次数" style="min-width: 10rem">
<template #body="{ data }">
<span class="text-lg">{{ data?.max_uses ?? 0 }}</span>
</template>
</Column>
<Column field="expires_at" header="过期时间" style="min-width: 16rem">
<template #body="{ data }">
<span class="text-lg">{{ formatDate(data?.expires_at) }}</span>
</template>
</Column>
<Column header="操作" style="min-width: 16rem">
<template #body="{ data }">
<Button
v-if="data?.status === 'active'"
label="禁用"
icon="pi pi-ban"
severity="danger"
class="text-lg px-6"
@click="openDisableDialog(data)"
/>
<span v-else class="text-muted-color">-</span>
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="createDialogVisible" :modal="true" :style="{ width: '640px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium text-xl">创建邀请码</span>
</div>
</template>
<div class="flex flex-col gap-4 text-lg">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">邀请码可不填</label>
<InputText v-model="createCode" class="w-full text-lg" placeholder="不填则自动生成" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">最大使用次数0=不限</label>
<InputNumber v-model="createMaxUses" :min="0" class="w-full text-lg" placeholder="例如1" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">过期时间可不填</label>
<DatePicker v-model="createExpiresAt" showIcon showButtonBar class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">备注可不填</label>
<InputText v-model="createRemark" class="w-full text-lg" placeholder="例如:线下活动" />
</div>
</div>
<div v-if="createdInvite" class="p-4 rounded-lg bg-surface-50 dark:bg-surface-800">
<div class="text-lg font-semibold mb-2">创建结果</div>
<div class="flex items-center gap-2">
<span class="text-lg">邀请码</span>
<span class="text-lg font-semibold">{{ createdInvite?.code ?? '-' }}</span>
<Button label="复制" icon="pi pi-copy" severity="secondary" class="text-lg px-4" @click="copyText(createdInvite?.code)" />
</div>
<div class="text-muted-color mt-2">提示把邀请码发给对方对方使用邀请码加入租户流程即可</div>
</div>
</div>
<template #footer>
<Button label="关闭" icon="pi pi-times" text class="text-lg" @click="createDialogVisible = false" :disabled="createLoading" />
<Button label="确认创建" icon="pi pi-check" class="text-lg" @click="confirmCreate" :loading="createLoading" />
</template>
</Dialog>
<Dialog v-model:visible="disableDialogVisible" :modal="true" :style="{ width: '560px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium text-xl">禁用邀请码</span>
<span class="text-muted-color">ID: {{ disableTarget?.id ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-3 text-lg">
<div class="text-muted-color">禁用后该邀请码不可再使用</div>
<div>
<label class="block font-medium mb-2">原因可不填</label>
<Textarea v-model="disableReason" rows="3" autoResize class="w-full text-lg" placeholder="例如:误发" />
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text class="text-lg" @click="disableDialogVisible = false" :disabled="disableLoading" />
<Button label="确认禁用" icon="pi pi-ban" severity="danger" class="text-lg" @click="confirmDisable" :loading="disableLoading" />
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,200 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { TenantAdminService } from '@/service/TenantAdminService';
const toast = useToast();
const route = useRoute();
const tenantCode = computed(() => String(route.params.tenantCode || ''));
const loading = ref(false);
const items = ref([]);
const total = ref(0);
const page = ref(1);
const rows = ref(10);
const operatorUserID = ref(null);
const userID = ref(null);
const orderID = ref(null);
const type = ref('');
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const typeOptions = [
{ label: '全部', value: '' },
{ label: 'debit_purchase', value: 'debit_purchase' },
{ label: 'credit_refund', value: 'credit_refund' },
{ label: 'freeze', value: 'freeze' },
{ label: 'unfreeze', value: 'unfreeze' },
{ label: 'adjustment', value: 'adjustment' }
];
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
}
async function load() {
loading.value = true;
try {
const result = await TenantAdminService.listLedgers(tenantCode.value, {
page: page.value,
limit: rows.value,
operator_user_id: operatorUserID.value || undefined,
user_id: userID.value || undefined,
order_id: orderID.value || undefined,
type: type.value || undefined,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined
});
items.value = result.items || [];
total.value = result.total ?? 0;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载流水', life: 5000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
load();
}
function onReset() {
operatorUserID.value = null;
userID.value = null;
orderID.value = null;
type.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
page.value = 1;
rows.value = 10;
load();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
load();
}
onMounted(() => {
load();
});
</script>
<template>
<div class="card">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="m-0 text-2xl">财务流水</h3>
<div class="text-lg text-muted-color">用于对账与审计</div>
</div>
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="load" :loading="loading" />
</div>
<div class="grid grid-cols-12 gap-4 text-lg mb-4">
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">操作者UserID</label>
<InputNumber v-model="operatorUserID" :min="1" class="w-full text-lg" placeholder="可不填" />
</div>
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">账户UserID</label>
<InputNumber v-model="userID" :min="1" class="w-full text-lg" placeholder="可不填" />
</div>
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">订单ID</label>
<InputNumber v-model="orderID" :min="1" class="w-full text-lg" placeholder="可不填" />
</div>
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">类型</label>
<Select v-model="type" :options="typeOptions" optionLabel="label" optionValue="value" class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">创建时间 From</label>
<DatePicker v-model="createdAtFrom" showIcon showButtonBar class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">创建时间 To</label>
<DatePicker v-model="createdAtTo" showIcon showButtonBar class="w-full text-lg" />
</div>
<div class="col-span-12 flex items-center gap-3">
<Button label="查询" icon="pi pi-search" class="text-lg px-6" @click="onSearch" :disabled="loading" />
<Button label="重置" icon="pi pi-refresh" severity="secondary" class="text-lg px-6" @click="onReset" :disabled="loading" />
</div>
</div>
<DataTable
:value="items"
dataKey="ledger.id"
:loading="loading"
lazy
:paginator="true"
:rows="rows"
:totalRecords="total"
:first="(page - 1) * rows"
:rowsPerPageOptions="[10, 20, 50]"
@page="onPage"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
responsiveLayout="scroll"
>
<Column header="ID" style="min-width: 9rem">
<template #body="{ data }">
<span class="text-lg">{{ data?.ledger?.id ?? '-' }}</span>
</template>
</Column>
<Column header="类型" style="min-width: 16rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="text-lg font-semibold">{{ data?.type_description ?? data?.ledger?.type ?? '-' }}</span>
<span class="text-muted-color">{{ data?.ledger?.type ?? '-' }}</span>
</div>
</template>
</Column>
<Column header="金额" style="min-width: 12rem">
<template #body="{ data }">
<span class="text-lg font-semibold">{{ formatCny(data?.ledger?.amount) }}</span>
</template>
</Column>
<Column header="余额变化" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="text-lg">可用{{ formatCny(data?.ledger?.balance_before) }} {{ formatCny(data?.ledger?.balance_after) }}</span>
<span class="text-muted-color">冻结{{ formatCny(data?.ledger?.frozen_before) }} {{ formatCny(data?.ledger?.frozen_after) }}</span>
</div>
</template>
</Column>
<Column header="用户/操作者" style="min-width: 16rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="text-lg">用户ID: {{ data?.ledger?.user_id ?? '-' }}</span>
<span class="text-muted-color">操作者ID: {{ data?.ledger?.operator_user_id ?? '-' }}</span>
</div>
</template>
</Column>
<Column header="订单ID" style="min-width: 12rem">
<template #body="{ data }">
<span class="text-lg">{{ data?.ledger?.order_id ?? '-' }}</span>
</template>
</Column>
<Column header="创建时间" style="min-width: 16rem">
<template #body="{ data }">
<span class="text-lg">{{ formatDate(data?.ledger?.created_at) }}</span>
</template>
</Column>
</DataTable>
</div>
</template>

View File

@@ -0,0 +1,166 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { TenantAdminService } from '@/service/TenantAdminService';
const toast = useToast();
const route = useRoute();
const router = useRouter();
const tenantCode = computed(() => String(route.params.tenantCode || ''));
const orderID = computed(() => Number(route.params.orderID || 0));
const loading = ref(false);
const detail = ref(null);
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
}
async function load() {
if (!orderID.value) return;
loading.value = true;
try {
detail.value = await TenantAdminService.getOrderDetail(tenantCode.value, orderID.value);
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载订单详情', life: 5000 });
} finally {
loading.value = false;
}
}
const refundDialogVisible = ref(false);
const refundLoading = ref(false);
const refundReason = ref('');
const refundForce = ref(false);
function openRefund() {
refundReason.value = '';
refundForce.value = false;
refundDialogVisible.value = true;
}
async function confirmRefund() {
const id = orderID.value;
if (!id) return;
refundLoading.value = true;
try {
await TenantAdminService.refundOrder(tenantCode.value, id, {
force: refundForce.value,
reason: refundReason.value,
idempotency_key: `tenant_admin_refund_${id}`
});
toast.add({ severity: 'success', summary: '已提交退款', detail: `订单ID: ${id}`, life: 3000 });
refundDialogVisible.value = false;
await load();
} catch (error) {
toast.add({ severity: 'error', summary: '退款失败', detail: error?.message || '无法发起退款', life: 5000 });
} finally {
refundLoading.value = false;
}
}
onMounted(() => {
load();
});
</script>
<template>
<div class="card">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="m-0 text-2xl">订单详情</h3>
<div class="text-lg text-muted-color">订单ID{{ orderID || '-' }}</div>
</div>
<div class="flex gap-3">
<Button label="返回" icon="pi pi-arrow-left" severity="secondary" class="text-lg" @click="router.back()" />
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="load" :loading="loading" />
</div>
</div>
<div v-if="loading" class="flex items-center justify-center py-10">
<ProgressSpinner style="width: 40px; height: 40px" strokeWidth="6" />
</div>
<div v-else class="grid grid-cols-12 gap-4 text-lg">
<div class="col-span-12 md:col-span-4">
<div class="text-muted-color">买家UserID</div>
<div class="font-semibold">{{ detail?.order?.user_id ?? '-' }}</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="text-muted-color">状态</div>
<div class="font-semibold">{{ detail?.order?.status ?? '-' }}</div>
</div>
<div class="col-span-12 md:col-span-4">
<div class="text-muted-color">实付金额</div>
<div class="font-semibold">{{ formatCny(detail?.order?.amount_paid) }}</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="text-muted-color">创建时间</div>
<div class="font-semibold">{{ formatDate(detail?.order?.created_at) }}</div>
</div>
<div class="col-span-12 md:col-span-6">
<div class="text-muted-color">支付时间</div>
<div class="font-semibold">{{ formatDate(detail?.order?.paid_at) }}</div>
</div>
<div class="col-span-12">
<div class="flex items-center justify-between mt-2 mb-2">
<div class="text-xl font-medium">订单明细</div>
<Button
v-if="detail?.order?.status === 'paid'"
label="退款"
icon="pi pi-undo"
severity="danger"
class="text-lg"
@click="openRefund"
/>
</div>
<DataTable :value="detail?.order?.items || []" dataKey="id" responsiveLayout="scroll">
<Column field="id" header="ItemID" style="min-width: 8rem" />
<Column field="content_id" header="ContentID" style="min-width: 10rem" />
<Column field="quantity" header="数量" style="min-width: 8rem" />
<Column header="成交价" style="min-width: 12rem">
<template #body="{ data }">
<span class="text-lg">{{ formatCny(data?.amount_paid) }}</span>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<Dialog v-model:visible="refundDialogVisible" :modal="true" :style="{ width: '560px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium text-xl">发起退款</span>
<span class="text-muted-color">订单ID: {{ orderID || '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-4 text-lg">
<div>
<label class="block font-medium mb-2">退款原因建议填写</label>
<Textarea v-model="refundReason" rows="3" autoResize class="w-full text-lg" placeholder="例如:用户申请退款" />
</div>
<div class="flex items-center gap-3">
<Checkbox v-model="refundForce" binary />
<span>强制退款绕过默认时间限制谨慎使用</span>
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text class="text-lg" @click="refundDialogVisible = false" :disabled="refundLoading" />
<Button label="确认退款" icon="pi pi-undo" severity="danger" class="text-lg" @click="confirmRefund" :loading="refundLoading" />
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,307 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { TenantAdminService } from '@/service/TenantAdminService';
const toast = useToast();
const route = useRoute();
const router = useRouter();
const tenantCode = computed(() => String(route.params.tenantCode || ''));
const loading = ref(false);
const orders = ref([]);
const total = ref(0);
const page = ref(1);
const rows = ref(10);
const sortField = ref('id');
const sortOrder = ref(-1);
const buyerUserID = ref(null);
const buyerUsername = ref('');
const status = ref('');
const createdAtFrom = ref(null);
const createdAtTo = ref(null);
const paidAtFrom = ref(null);
const paidAtTo = ref(null);
const statusOptions = [
{ label: '全部', value: '' },
{ label: 'paid', value: 'paid' },
{ label: 'refunding', value: 'refunding' },
{ label: 'refunded', value: 'refunded' },
{ label: 'created', value: 'created' },
{ label: 'canceled', value: 'canceled' },
{ label: 'failed', value: 'failed' }
];
function formatDate(value) {
if (!value) return '-';
if (String(value).startsWith('0001-01-01')) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
}
function getStatusSeverity(value) {
switch (value) {
case 'paid':
return 'success';
case 'created':
case 'refunding':
return 'warn';
case 'failed':
case 'canceled':
return 'danger';
default:
return 'secondary';
}
}
async function load() {
loading.value = true;
try {
const result = await TenantAdminService.listOrders(tenantCode.value, {
page: page.value,
limit: rows.value,
user_id: buyerUserID.value || undefined,
username: buyerUsername.value,
status: status.value || undefined,
created_at_from: createdAtFrom.value || undefined,
created_at_to: createdAtTo.value || undefined,
paid_at_from: paidAtFrom.value || undefined,
paid_at_to: paidAtTo.value || undefined,
sortField: sortField.value,
sortOrder: sortOrder.value
});
orders.value = result.items || [];
total.value = result.total ?? 0;
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载订单列表', life: 5000 });
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
load();
}
function onReset() {
buyerUserID.value = null;
buyerUsername.value = '';
status.value = '';
createdAtFrom.value = null;
createdAtTo.value = null;
paidAtFrom.value = null;
paidAtTo.value = null;
sortField.value = 'id';
sortOrder.value = -1;
page.value = 1;
rows.value = 10;
load();
}
function onPage(event) {
page.value = (event.page ?? 0) + 1;
rows.value = event.rows ?? rows.value;
load();
}
function onSort(event) {
sortField.value = event.sortField ?? sortField.value;
sortOrder.value = event.sortOrder ?? sortOrder.value;
load();
}
function openDetail(row) {
const orderID = row?.id;
if (!orderID) return;
router.push({ name: 'tenantadmin-order-detail', params: { tenantCode: tenantCode.value, orderID } });
}
const refundDialogVisible = ref(false);
const refundLoading = ref(false);
const refundTarget = ref(null);
const refundReason = ref('');
const refundForce = ref(false);
function openRefund(row) {
refundTarget.value = row;
refundReason.value = '';
refundForce.value = false;
refundDialogVisible.value = true;
}
async function confirmRefund() {
const orderID = refundTarget.value?.id;
if (!orderID) return;
refundLoading.value = true;
try {
await TenantAdminService.refundOrder(tenantCode.value, orderID, {
force: refundForce.value,
reason: refundReason.value,
idempotency_key: `tenant_admin_refund_${orderID}`
});
toast.add({ severity: 'success', summary: '已提交退款', detail: `订单ID: ${orderID}`, life: 3000 });
refundDialogVisible.value = false;
await load();
} catch (error) {
toast.add({ severity: 'error', summary: '退款失败', detail: error?.message || '无法发起退款', life: 5000 });
} finally {
refundLoading.value = false;
}
}
onMounted(() => {
load();
});
</script>
<template>
<div class="card">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="m-0 text-2xl">订单与退款</h3>
<div class="text-lg text-muted-color">查询订单并处理退款</div>
</div>
<Button label="刷新" icon="pi pi-refresh" severity="secondary" class="text-lg" @click="load" :loading="loading" />
</div>
<div class="grid grid-cols-12 gap-4 text-lg mb-4">
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">买家UserID</label>
<InputNumber v-model="buyerUserID" :min="1" class="w-full text-lg" placeholder="可不填" />
</div>
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">买家用户名</label>
<InputText v-model="buyerUsername" class="w-full text-lg" placeholder="包含匹配" @keyup.enter="onSearch" />
</div>
<div class="col-span-12 md:col-span-3">
<label class="block font-medium mb-2">状态</label>
<Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-3 flex items-end gap-3">
<Button label="查询" icon="pi pi-search" class="text-lg px-6" @click="onSearch" :disabled="loading" />
<Button label="重置" icon="pi pi-refresh" severity="secondary" class="text-lg px-6" @click="onReset" :disabled="loading" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">创建时间 From</label>
<DatePicker v-model="createdAtFrom" showIcon showButtonBar class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">创建时间 To</label>
<DatePicker v-model="createdAtTo" showIcon showButtonBar class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">支付时间 From</label>
<DatePicker v-model="paidAtFrom" showIcon showButtonBar class="w-full text-lg" />
</div>
<div class="col-span-12 md:col-span-6">
<label class="block font-medium mb-2">支付时间 To</label>
<DatePicker v-model="paidAtTo" showIcon showButtonBar class="w-full text-lg" />
</div>
</div>
<DataTable
:value="orders"
dataKey="id"
:loading="loading"
lazy
:paginator="true"
:rows="rows"
:totalRecords="total"
:first="(page - 1) * rows"
:rowsPerPageOptions="[10, 20, 50]"
sortMode="single"
:sortField="sortField"
:sortOrder="sortOrder"
@page="onPage"
@sort="onSort"
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
responsiveLayout="scroll"
>
<Column field="id" header="订单ID" sortable style="min-width: 10rem">
<template #body="{ data }">
<Button label="详情" icon="pi pi-search" text class="p-0 mr-3 text-lg" @click="openDetail(data)" />
<span class="text-lg">{{ data?.id ?? '-' }}</span>
</template>
</Column>
<Column field="user_id" header="买家" sortable style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="text-lg font-semibold">ID: {{ data?.user_id ?? '-' }}</span>
<span class="text-muted-color">类型{{ data?.type ?? '-' }}</span>
</div>
</template>
</Column>
<Column field="status" header="状态" sortable style="min-width: 12rem">
<template #body="{ data }">
<Tag :value="data?.status ?? '-'" :severity="getStatusSeverity(data?.status)" class="text-base" />
</template>
</Column>
<Column field="amount_paid" header="实付" sortable style="min-width: 12rem">
<template #body="{ data }">
<span class="text-lg font-semibold">{{ formatCny(data?.amount_paid) }}</span>
</template>
</Column>
<Column field="created_at" header="创建时间" sortable style="min-width: 16rem">
<template #body="{ data }">
<span class="text-lg">{{ formatDate(data?.created_at) }}</span>
</template>
</Column>
<Column field="paid_at" header="支付时间" sortable style="min-width: 16rem">
<template #body="{ data }">
<span class="text-lg">{{ formatDate(data?.paid_at) }}</span>
</template>
</Column>
<Column header="操作" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex gap-3">
<Button
v-if="data?.status === 'paid'"
label="退款"
icon="pi pi-undo"
severity="danger"
class="text-lg px-6"
@click="openRefund(data)"
/>
<span v-else class="text-muted-color">-</span>
</div>
</template>
</Column>
</DataTable>
</div>
<Dialog v-model:visible="refundDialogVisible" :modal="true" :style="{ width: '560px' }">
<template #header>
<div class="flex items-center gap-2">
<span class="font-medium text-xl">发起退款</span>
<span class="text-muted-color">订单ID: {{ refundTarget?.id ?? '-' }}</span>
</div>
</template>
<div class="flex flex-col gap-4 text-lg">
<div>
<label class="block font-medium mb-2">退款原因建议填写</label>
<Textarea v-model="refundReason" rows="3" autoResize class="w-full text-lg" placeholder="例如:用户申请退款" />
</div>
<div class="flex items-center gap-3">
<Checkbox v-model="refundForce" binary />
<span>强制退款绕过默认时间限制谨慎使用</span>
</div>
</div>
<template #footer>
<Button label="取消" icon="pi pi-times" text class="text-lg" @click="refundDialogVisible = false" :disabled="refundLoading" />
<Button label="确认退款" icon="pi pi-undo" severity="danger" class="text-lg" @click="confirmRefund" :loading="refundLoading" />
</template>
</Dialog>
</template>

View File

@@ -11,6 +11,21 @@ export default defineConfig({
optimizeDeps: {
noDiscovery: true
},
server: {
host: '0.0.0.0',
port: 5174,
strictPort: true,
proxy: {
'/v1': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/t': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
},
plugins: [
vue(),
tailwindcss(),