fix: resolve frontend build error and order refund bug, add member price filter
This commit is contained in:
@@ -9,6 +9,7 @@ type ContentListFilter struct {
|
||||
TenantID *string `query:"tenantId"`
|
||||
Sort *string `query:"sort"`
|
||||
IsPinned *bool `query:"is_pinned"`
|
||||
PriceType *string `query:"price_type"`
|
||||
}
|
||||
|
||||
type ContentItem struct {
|
||||
|
||||
@@ -5,6 +5,7 @@ import "quyun/v2/app/requests"
|
||||
type TenantListFilter struct {
|
||||
requests.Pagination
|
||||
Keyword *string `query:"keyword"`
|
||||
Sort *string `query:"sort"`
|
||||
}
|
||||
|
||||
type TenantProfile struct {
|
||||
|
||||
@@ -53,6 +53,10 @@ type Order struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
IsVirtual bool `json:"is_virtual"`
|
||||
BuyerName string `json:"buyer_name"`
|
||||
BuyerAvatar string `json:"buyer_avatar"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
|
||||
@@ -351,6 +351,17 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
r.user.Likes,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /v1/me/notifications/:id/read -> user.MarkNotificationRead")
|
||||
router.Post("/v1/me/notifications/:id/read"[len(r.Path()):], Func2(
|
||||
r.user.MarkNotificationRead,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
PathParam[string]("id"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /v1/me/notifications/read-all -> user.MarkAllNotificationsRead")
|
||||
router.Post("/v1/me/notifications/read-all"[len(r.Path()):], Func1(
|
||||
r.user.MarkAllNotificationsRead,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /v1/me/notifications -> user.Notifications")
|
||||
router.Get("/v1/me/notifications"[len(r.Path()):], DataFunc3(
|
||||
r.user.Notifications,
|
||||
|
||||
@@ -260,6 +260,34 @@ func (u *User) Notifications(ctx fiber.Ctx, user *models.User, typeArg string, p
|
||||
return services.Notification.List(ctx, user.ID, page, typeArg)
|
||||
}
|
||||
|
||||
// Mark notification as read
|
||||
//
|
||||
// @Router /v1/me/notifications/:id/read [post]
|
||||
// @Summary Mark as read
|
||||
// @Tags UserCenter
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Notification ID"
|
||||
// @Success 200 {string} string "OK"
|
||||
// @Bind user local key(__ctx_user)
|
||||
// @Bind id path
|
||||
func (u *User) MarkNotificationRead(ctx fiber.Ctx, user *models.User, id string) error {
|
||||
return services.Notification.MarkRead(ctx, user.ID, id)
|
||||
}
|
||||
|
||||
// Mark all notifications as read
|
||||
//
|
||||
// @Router /v1/me/notifications/read-all [post]
|
||||
// @Summary Mark all as read
|
||||
// @Tags UserCenter
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "OK"
|
||||
// @Bind user local key(__ctx_user)
|
||||
func (u *User) MarkAllNotificationsRead(ctx fiber.Ctx, user *models.User) error {
|
||||
return services.Notification.MarkAllRead(ctx, user.ID)
|
||||
}
|
||||
|
||||
// List my coupons
|
||||
//
|
||||
// @Router /v1/me/coupons [get]
|
||||
|
||||
@@ -38,6 +38,37 @@ func (s *content) List(ctx context.Context, filter *content_dto.ContentListFilte
|
||||
q = q.Where(tbl.IsPinned.Is(*filter.IsPinned))
|
||||
}
|
||||
|
||||
if filter.PriceType != nil && *filter.PriceType != "all" {
|
||||
if *filter.PriceType == "member" {
|
||||
q = q.Where(tbl.Visibility.Eq(consts.ContentVisibilityTenantOnly))
|
||||
} else {
|
||||
pTbl, pQ := models.ContentPriceQuery.QueryContext(ctx)
|
||||
|
||||
var shouldFilter bool
|
||||
var prices []*models.ContentPrice
|
||||
|
||||
if *filter.PriceType == "free" {
|
||||
shouldFilter = true
|
||||
prices, _ = pQ.Where(pTbl.PriceAmount.Eq(0)).Select(pTbl.ContentID).Find()
|
||||
} else if *filter.PriceType == "paid" {
|
||||
shouldFilter = true
|
||||
prices, _ = pQ.Where(pTbl.PriceAmount.Gt(0)).Select(pTbl.ContentID).Find()
|
||||
}
|
||||
|
||||
if shouldFilter {
|
||||
ids := make([]int64, len(prices))
|
||||
for i, p := range prices {
|
||||
ids[i] = p.ContentID
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
q = q.Where(tbl.ID.In(ids...))
|
||||
} else {
|
||||
q = q.Where(tbl.ID.Eq(-1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort
|
||||
sort := "latest"
|
||||
if filter.Sort != nil && *filter.Sort != "" {
|
||||
|
||||
@@ -570,7 +570,25 @@ func (s *creator) ListOrders(
|
||||
if filter.Status != nil && *filter.Status != "" {
|
||||
q = q.Where(tbl.Status.Eq(consts.OrderStatus(*filter.Status)))
|
||||
}
|
||||
// Keyword could match ID or other fields if needed
|
||||
|
||||
if filter.Keyword != nil && *filter.Keyword != "" {
|
||||
k := *filter.Keyword
|
||||
if id, err := cast.ToInt64E(k); err == nil {
|
||||
q = q.Where(tbl.ID.Eq(id))
|
||||
} else {
|
||||
uTbl, uQ := models.UserQuery.QueryContext(ctx)
|
||||
users, _ := uQ.Where(uTbl.Nickname.Like("%" + k + "%")).Find()
|
||||
uids := make([]int64, len(users))
|
||||
for i, u := range users {
|
||||
uids[i] = u.ID
|
||||
}
|
||||
if len(uids) > 0 {
|
||||
q = q.Where(tbl.UserID.In(uids...))
|
||||
} else {
|
||||
q = q.Where(tbl.ID.Eq(-1)) // Match nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list, err := q.Order(tbl.CreatedAt.Desc()).Find()
|
||||
if err != nil {
|
||||
@@ -579,11 +597,46 @@ func (s *creator) ListOrders(
|
||||
|
||||
var data []creator_dto.Order
|
||||
for _, o := range list {
|
||||
// Fetch Buyer Info
|
||||
u, _ := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(o.UserID)).First()
|
||||
buyerName := "未知用户"
|
||||
buyerAvatar := ""
|
||||
if u != nil {
|
||||
buyerName = u.Nickname
|
||||
buyerAvatar = u.Avatar
|
||||
}
|
||||
|
||||
// Fetch Content Info
|
||||
var title, cover string
|
||||
item, _ := models.OrderItemQuery.WithContext(ctx).Where(models.OrderItemQuery.OrderID.Eq(o.ID)).First()
|
||||
if item != nil {
|
||||
var c models.Content
|
||||
err := models.ContentQuery.WithContext(ctx).
|
||||
Where(models.ContentQuery.ID.Eq(item.ContentID)).
|
||||
UnderlyingDB().
|
||||
Preload("ContentAssets.Asset").
|
||||
First(&c).Error
|
||||
|
||||
if err == nil {
|
||||
title = c.Title
|
||||
for _, ca := range c.ContentAssets {
|
||||
if ca.Role == consts.ContentAssetRoleCover && ca.Asset != nil {
|
||||
cover = Common.GetAssetURL(ca.Asset.ObjectKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data = append(data, creator_dto.Order{
|
||||
ID: cast.ToString(o.ID),
|
||||
Status: string(o.Status), // Enum conversion
|
||||
Status: string(o.Status),
|
||||
Amount: float64(o.AmountPaid) / 100.0,
|
||||
CreateTime: o.CreatedAt.Format(time.RFC3339),
|
||||
BuyerName: buyerName,
|
||||
BuyerAvatar: buyerAvatar,
|
||||
Title: title,
|
||||
Cover: cover,
|
||||
})
|
||||
}
|
||||
return data, nil
|
||||
@@ -803,6 +856,15 @@ func (s *creator) Withdraw(ctx context.Context, userID int64, form *creator_dto.
|
||||
}
|
||||
uid := userID
|
||||
|
||||
// Validate User Real-name Status
|
||||
user, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(uid)).First()
|
||||
if err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
if !user.IsRealNameVerified {
|
||||
return errorx.ErrPreconditionFailed.WithMsg("请先完成实名认证后再申请提现")
|
||||
}
|
||||
|
||||
amount := int64(form.Amount * 100)
|
||||
if amount <= 0 {
|
||||
return errorx.ErrBadRequest.WithMsg("金额无效")
|
||||
|
||||
@@ -71,6 +71,16 @@ func (s *notification) MarkRead(ctx context.Context, userID int64, id string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *notification) MarkAllRead(ctx context.Context, userID int64) error {
|
||||
_, err := models.NotificationQuery.WithContext(ctx).
|
||||
Where(models.NotificationQuery.UserID.Eq(userID), models.NotificationQuery.IsRead.Is(false)).
|
||||
UpdateSimple(models.NotificationQuery.IsRead.Value(true))
|
||||
if err != nil {
|
||||
return errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *notification) Send(ctx context.Context, userID int64, typ, title, content string) error {
|
||||
arg := args.NotificationArgs{
|
||||
UserID: userID,
|
||||
|
||||
120
docs/review_report.md
Normal file
120
docs/review_report.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# QuyUn v2 代码审查与优化建议(backend + frontend/portal)
|
||||
|
||||
> 审查范围:`backend/`、`frontend/portal/`、`specs/`、`docs/`、`frontend/superadmin/SUPERADMIN_PAGES.md`。
|
||||
|
||||
## 一、关键问题(按严重程度)
|
||||
|
||||
### P0 / 安全与数据隔离
|
||||
|
||||
1) 多租户路由与上下文缺失,无法满足 `/t/:tenant_code/v1` 规范,存在跨租户数据泄漏风险。
|
||||
- 后端路由基座仍为 `/v1`:`backend/app/http/v1/routes.manual.go`。
|
||||
- 前端路由与 API 基座未含 tenant_code:`frontend/portal/src/router/index.js`、`frontend/portal/src/utils/request.js`。
|
||||
- 多数服务查询未强制带 `tenant_id` 条件(仅依赖 query 传参):如 `backend/app/services/content.go`、`backend/app/services/order.go`、`backend/app/services/common.go`。
|
||||
|
||||
2) 鉴权为“可选”,导致受保护接口可能被匿名访问;超级管理员接口无角色校验。
|
||||
- `Auth` 中间件未携带 Authorization 时直接放行:`backend/app/middlewares/middlewares.go`。
|
||||
- `/super/v1/*` 复用普通 `Auth`,无 `super_admin` 权限检查:`backend/app/http/super/v1/routes.manual.go`。
|
||||
|
||||
3) 超级管理员登录与鉴权核心逻辑为空实现,接口可用性与安全性均不足。
|
||||
- `services.Super.Login / CheckToken` 返回空结构:`backend/app/services/super.go`。
|
||||
|
||||
### P1 / 功能缺失与接口不一致
|
||||
|
||||
4) 后端多个关键接口未实现或返回空结果,前端调用会失败或表现异常。
|
||||
- `services.Order.Status` 返回 `nil`:`backend/app/services/order.go`。
|
||||
- 超管统计/订单详情/退款等均为空:`backend/app/services/super.go`。
|
||||
- 超管内容/订单列表 DTO 映射 TODO:`backend/app/services/super.go`。
|
||||
|
||||
5) 认证方式与规格偏离:现实现 OTP 登录 + JWT;规格为 WeChat OAuth + Cookie。
|
||||
- 认证流程:`backend/app/http/v1/auth/auth.go`、`backend/app/services/user.go`。
|
||||
- 前端调用 OTP 登录:`frontend/portal/src/api/auth.js`。
|
||||
|
||||
6) API 调用参数与后端 `@Bind` 约定不一致,导致请求失效。
|
||||
- `contentId` 参数期望为 `contentId`,前端用 `content_id`:`frontend/portal/src/api/user.js` ↔ `backend/app/http/v1/user.go`。
|
||||
|
||||
### P2 / 代码规范与可维护性
|
||||
|
||||
7) ID 类型全链路使用 `string`,与 DB `BIGINT` 不一致,导致重复转换与潜在路由冲突。
|
||||
- controller/service/DTO 使用 `string`:`backend/app/http/v1/*`、`backend/app/http/v1/dto/*`、`backend/app/services/*`。
|
||||
- 路由注解未使用 `:id<int>`:`backend/app/http/**`。
|
||||
|
||||
8) DTO 结构缺少字段级中文注释,业务注释大量为英文,违反 `backend/llm.txt` 规范。
|
||||
- DTO 示例:`backend/app/http/v1/dto/user.go`、`backend/app/http/v1/dto/content.go`。
|
||||
|
||||
9) Service 读取 `ctx` 获取用户信息,违反“上下文提取必须在 Controller 层”的规则。
|
||||
- `services.audit.Log` 直接读 `ctx.Value`:`backend/app/services/audit.go`。
|
||||
|
||||
10) 数据库迁移与规格不一致,初始迁移为空,实际表结构仅在 `specs/DB.sql`。
|
||||
- `backend/database/migrations/20251227112605_init.sql` 为空。
|
||||
|
||||
11) N+1 查询与聚合性能风险。
|
||||
- 订单列表逐条查询租户/内容/订单项:`backend/app/services/order.go`。
|
||||
- 租户列表逐条统计 followers/contents:`backend/app/services/tenant.go`。
|
||||
|
||||
12) 代码残留调试输出。
|
||||
- `fmt.Printf` 直接打印配置:`backend/app/services/tenant.go`。
|
||||
|
||||
## 二、架构与实现优化建议
|
||||
|
||||
### 1) 多租户“强隔离”落地
|
||||
- 路由:将 `/v1` 全部改为 `/t/:tenantCode/v1`(注意 `@Router` 的 `:tenantCode` 与 `<int>` 规则)。
|
||||
- 中间件:新增 `TenantResolver`(解析 tenant_code → tenant_id → ctx locals)。
|
||||
- 服务层:所有查询必须带 `tenant_id`(禁止依赖 query 传参)。
|
||||
- 前端:Router base + API base 统一从 URL 中解析 tenant_code。
|
||||
|
||||
### 2) 鉴权与权限体系补全
|
||||
- 拆分 `AuthOptional` 与 `AuthRequired` 两种中间件,受保护接口必须硬校验。
|
||||
- 超管接口增加 `super_admin` 角色校验(JWT claims 中带 roles)。
|
||||
- 完成 `Super.Login` / `CheckToken` 逻辑,支持 token 续期与失效。
|
||||
|
||||
### 3) ID 类型统一(int64 / model 注入)
|
||||
- 统一所有 path/query/body 里的 ID 为 `int64`。
|
||||
- 控制器签名支持 `@Bind id path` + `int64` 或 `@Bind tenant model(id)` 进行 model 注入。
|
||||
- swagger `@Router` 必须使用 `:id<int>` / `:tenantID<int>`。
|
||||
|
||||
### 4) 规格一致性修复
|
||||
- 统一认证模型(OTP vs WeChat):要么按规格实现 WeChat OAuth,要么更新规格文档。
|
||||
- 统一存储方案(OSS + tenant_uuid + md5 命名);当前本地存储仅作开发 fallback。
|
||||
- 将 `specs/DB.sql` 迁入真实迁移文件并补齐中文字段注释。
|
||||
|
||||
### 5) 性能与稳定性
|
||||
- 列表场景引入预加载或批量查询,规避 N+1。
|
||||
- 订单/租户统计使用聚合 SQL 一次性获取。
|
||||
- 关键写操作增加幂等与重试策略。
|
||||
|
||||
### 6) 规范化与可维护性
|
||||
- DTO 增加字段级中文注释(含业务语义/约束)。
|
||||
- Service 层中文注释补齐业务意图与边界条件。
|
||||
- `Audit` 改为显式传入 operatorID 参数。
|
||||
|
||||
## 三、ID 类型统一与 Model 注入的推荐步骤(遵循 backend/llm.txt)
|
||||
|
||||
1) **定义目标规范**
|
||||
- 所有业务 ID 使用 `int64`(JSON 输出为数值)。
|
||||
- 路由中数字 ID 使用 `:id<int>`。
|
||||
|
||||
2) **先改 Controller/DTO**
|
||||
- `backend/app/http/v1/**` 和 `backend/app/http/super/v1/**` 中 path/query 的 `string` 改为 `int64`。
|
||||
- DTO 中 `ID` 字段统一改为 `int64`;所有 DTO 增加中文字段注释。
|
||||
- `@Router` 的路径参数补全 `<int>` 约束。
|
||||
|
||||
3) **再改 Service**
|
||||
- 将 `id string` 改为 `id int64`,移除 `cast.ToInt64`。
|
||||
- 依赖 model 的场景改为 `@Bind xxx model(id)` + `*models.Xxx` 直接注入。
|
||||
|
||||
4) **同步前端调用**
|
||||
- URL/query 参数统一为 `contentId` / `userId` / `tenantId`(与 `@Bind` 的 key 对齐)。
|
||||
- 处理 ID 显示与输入时的类型转换(数值 ↔ 字符串)。
|
||||
|
||||
5) **生成与验证**
|
||||
- 运行 `atomctl gen route`、`atomctl gen provider`、`atomctl swag init`。
|
||||
- 回归测试:关键列表、详情、操作接口。
|
||||
|
||||
## 四、建议的修复优先级
|
||||
|
||||
1) 多租户路由 + tenant_id 强隔离
|
||||
2) 超管鉴权/角色校验 + Login/Token 实现
|
||||
3) ID 类型统一与 `:id<int>` 路由规范
|
||||
4) 规格一致性(认证/存储/迁移)
|
||||
5) 性能优化(N+1/聚合)与可维护性补齐
|
||||
|
||||
148
docs/superadmin_plan.md
Normal file
148
docs/superadmin_plan.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 超级管理员后台功能规划(按页面拆解)
|
||||
|
||||
> 目标:基于现有 `/super/v1/*` 能力,补齐平台级“管理 + 统计”闭环。以下按页面拆分,分别给出管理动作、统计指标与接口对照。
|
||||
|
||||
## 0) 全局约定
|
||||
|
||||
- **鉴权**:`Authorization: Bearer <token>`;登录后本地持久化 token。
|
||||
- **路由基座**:`/super/`(前端),API 基座 `/super/v1`。
|
||||
- **分页**:统一 `page/limit`,响应为 `requests.Pager`。
|
||||
- **枚举**:优先取 `/super/v1/tenants/statuses`、`/super/v1/users/statuses`。
|
||||
|
||||
## 1) 登录 `/auth/login`
|
||||
|
||||
- 管理功能:账号登录、token 写入、自动续期。
|
||||
- 统计功能:可选记录登录失败次数、IP、设备指纹(审计)。
|
||||
- 现有接口:
|
||||
- `POST /super/v1/auth/login`(需补齐实现)
|
||||
- `GET /super/v1/auth/token`(token 校验/续期)
|
||||
|
||||
## 2) 概览 Dashboard `/`
|
||||
|
||||
- 管理功能:快捷入口(租户/用户/订单/内容)。
|
||||
- 统计指标(建议):
|
||||
- 租户总数/活跃数/过期数
|
||||
- 用户总数/活跃数(按状态拆分)
|
||||
- 订单数/成交额/退款额(按日、按状态)
|
||||
- 内容总数/新增内容/被封禁内容
|
||||
- 现有接口:
|
||||
- `GET /super/v1/users/statistics`(需补齐实现)
|
||||
- `GET /super/v1/orders/statistics`(需补齐实现)
|
||||
- `GET /super/v1/tenants?limit=1&page=1`(可取 total)
|
||||
- `GET /super/v1/contents?limit=1&page=1`(可取 total)
|
||||
|
||||
## 3) 租户管理 `/superadmin/tenants`
|
||||
|
||||
- 管理功能:
|
||||
- 新建租户(绑定管理员)
|
||||
- 更新租户状态(正常/禁用)
|
||||
- 续期/变更过期时间
|
||||
- 统计指标:
|
||||
- 状态分布(待审核/正常/禁用)
|
||||
- 即将过期租户数(7/30 天)
|
||||
- 租户 GMV Top N(需补接口)
|
||||
- 现有接口:
|
||||
- `POST /super/v1/tenants`
|
||||
- `GET /super/v1/tenants`
|
||||
- `PATCH /super/v1/tenants/{tenantID}/status`
|
||||
- `PATCH /super/v1/tenants/{tenantID}`(续期)
|
||||
- `GET /super/v1/tenants/statuses`
|
||||
|
||||
## 4) 租户详情 `/superadmin/tenants/:tenantID`
|
||||
|
||||
- 管理功能(建议):
|
||||
- 基本信息/状态/过期时间编辑
|
||||
- 管理员与成员列表(角色管理)
|
||||
- 内容列表、订单列表、资金汇总
|
||||
- 统计指标(建议):
|
||||
- 租户用户数、内容数、订单数、GMV
|
||||
- 现有接口:
|
||||
- `GET /super/v1/tenants/{tenantID}`(已有)
|
||||
- 建议补充接口:
|
||||
- `GET /super/v1/tenants/{tenantID}/users`
|
||||
- `GET /super/v1/tenants/{tenantID}/contents`
|
||||
- `GET /super/v1/tenants/{tenantID}/orders`
|
||||
- `GET /super/v1/tenants/{tenantID}/statistics`
|
||||
|
||||
## 5) 用户管理 `/superadmin/users`
|
||||
|
||||
- 管理功能:
|
||||
- 用户列表筛选(用户名/状态/角色/所属租户)
|
||||
- 状态变更、角色授予
|
||||
- 统计指标:
|
||||
- 用户状态统计(已提供)
|
||||
- 现有接口:
|
||||
- `GET /super/v1/users`
|
||||
- `PATCH /super/v1/users/{userID}/status`
|
||||
- `PATCH /super/v1/users/{userID}/roles`
|
||||
- `GET /super/v1/users/statistics`
|
||||
- `GET /super/v1/users/statuses`
|
||||
|
||||
## 6) 用户详情 `/superadmin/users/:userID`
|
||||
|
||||
- 管理功能(建议):
|
||||
- 用户资料、角色、状态
|
||||
- 用户所属/拥有租户列表
|
||||
- 用户订单与内容购买记录
|
||||
- 统计指标(建议):
|
||||
- 用户消费总额、退款次数
|
||||
- 现有接口:
|
||||
- `GET /super/v1/users/{userID}`(已有)
|
||||
- 建议补充接口:
|
||||
- `GET /super/v1/users/{userID}/tenants`
|
||||
- `GET /super/v1/users/{userID}/orders`
|
||||
- `GET /super/v1/users/{userID}/contents`
|
||||
|
||||
## 7) 订单管理 `/superadmin/orders`
|
||||
|
||||
- 管理功能:
|
||||
- 订单列表(按租户/用户/状态/时间过滤)
|
||||
- 退款操作(平台侧)
|
||||
- 统计指标:
|
||||
- 订单状态分布、GMV、退款额
|
||||
- 现有接口:
|
||||
- `GET /super/v1/orders`
|
||||
- `POST /super/v1/orders/{orderID}/refund`(需补齐实现)
|
||||
- `GET /super/v1/orders/statistics`(需补齐实现)
|
||||
|
||||
## 8) 订单详情 `/superadmin/orders/:orderID`
|
||||
|
||||
- 管理功能:
|
||||
- 查看订单快照、支付信息、退款信息
|
||||
- 退款/强制关闭
|
||||
- 现有接口:
|
||||
- `GET /super/v1/orders/{orderID}`(需补齐实现)
|
||||
|
||||
## 9) 内容管理 `/superadmin/contents`
|
||||
|
||||
- 管理功能:
|
||||
- 跨租户内容列表
|
||||
- 内容状态更新(封禁/下架)
|
||||
- 统计指标:
|
||||
- 内容状态分布、热门内容 Top N
|
||||
- 现有接口:
|
||||
- `GET /super/v1/contents`
|
||||
- `PATCH /super/v1/tenants/{tenantID}/contents/{contentID}/status`
|
||||
|
||||
## 10) 财务/提现(可选)
|
||||
|
||||
- 管理功能:
|
||||
- 提现订单审核(通过/驳回)
|
||||
- 记录操作原因
|
||||
- 统计指标:
|
||||
- 提现订单数、金额、失败率
|
||||
- 现有接口:无(服务层有 `ListWithdrawals/Approve/Reject`,需补 controller + route)
|
||||
|
||||
## 11) 审计日志 / 操作记录(建议)
|
||||
|
||||
- 管理功能:
|
||||
- 展示后台操作日志(操作人、对象、动作、时间)
|
||||
- 支持导出
|
||||
- 现有接口:无(可基于 `services.Audit` 扩展)
|
||||
|
||||
## 12) 系统配置 / 平台策略(建议)
|
||||
|
||||
- 管理功能:
|
||||
- 平台佣金比例、内容审核策略、默认到期策略
|
||||
- 现有接口:无(需新增配置表与接口)
|
||||
|
||||
@@ -16,6 +16,8 @@ export const userApi = {
|
||||
addLike: (contentId) => request(`/me/likes?content_id=${contentId}`, { method: 'POST' }),
|
||||
removeLike: (contentId) => request(`/me/likes/${contentId}`, { method: 'DELETE' }),
|
||||
getNotifications: (type, page) => request(`/me/notifications?type=${type || 'all'}&page=${page || 1}`),
|
||||
markNotificationRead: (id) => request(`/me/notifications/${id}/read`, { method: 'POST' }),
|
||||
markAllNotificationsRead: () => request('/me/notifications/read-all', { method: 'POST' }),
|
||||
getFollowing: () => request('/me/following'),
|
||||
getCoupons: (status) => request(`/me/coupons?status=${status || 'unused'}`),
|
||||
};
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<!-- User Brief -->
|
||||
<div class="p-6 border-b border-slate-100 bg-slate-50/50">
|
||||
<div class="flex items-center gap-4">
|
||||
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Felix"
|
||||
<img :src="user.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id || 'default'}`"
|
||||
class="w-12 h-12 rounded-full border-2 border-white shadow-sm" />
|
||||
<div class="overflow-hidden">
|
||||
<div class="font-bold text-slate-900 truncate">Felix Demo</div>
|
||||
<div class="text-xs text-slate-500">ID: 9527330</div>
|
||||
<div class="font-bold text-slate-900 truncate">{{ user.nickname || '用户' }}</div>
|
||||
<div class="text-xs text-slate-500">ID: {{ user.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,6 +35,11 @@
|
||||
<i class="pi pi-wallet text-lg"></i>
|
||||
<span>我的钱包</span>
|
||||
</router-link>
|
||||
<router-link to="/me/coupons" active-class="bg-primary-50 text-primary-600 font-semibold"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors">
|
||||
<i class="pi pi-ticket text-lg"></i>
|
||||
<span>我的优惠券</span>
|
||||
</router-link>
|
||||
<router-link to="/me/library" active-class="bg-primary-50 text-primary-600 font-semibold"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors">
|
||||
<i class="pi pi-book text-lg"></i>
|
||||
@@ -80,6 +85,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import AppFooter from '../components/AppFooter.vue';
|
||||
import TopNavbar from '../components/TopNavbar.vue';
|
||||
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || '{}'));
|
||||
</script>
|
||||
|
||||
@@ -83,6 +83,11 @@ const router = createRouter({
|
||||
name: 'user-wallet',
|
||||
component: () => import('../views/user/WalletView.vue')
|
||||
},
|
||||
{
|
||||
path: 'coupons',
|
||||
name: 'user-coupons',
|
||||
component: () => import('../views/user/CouponsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'library',
|
||||
name: 'user-library',
|
||||
|
||||
@@ -105,7 +105,8 @@ const fetchContents = async (append = false) => {
|
||||
const params = {
|
||||
page: page.value,
|
||||
limit: 12,
|
||||
sort: sort.value
|
||||
sort: sort.value,
|
||||
price_type: selectedPrice.value
|
||||
};
|
||||
if (selectedGenre.value !== '全部') params.genre = selectedGenre.value;
|
||||
if (keyword.value) params.keyword = keyword.value;
|
||||
@@ -127,7 +128,7 @@ const loadMore = () => {
|
||||
fetchContents(true);
|
||||
};
|
||||
|
||||
watch([selectedGenre, sort], () => {
|
||||
watch([selectedGenre, selectedPrice, sort], () => {
|
||||
page.value = 1;
|
||||
fetchContents();
|
||||
});
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { contentApi } from '../../api/content';
|
||||
import { tenantApi } from '../../api/tenant';
|
||||
|
||||
const contents = ref([]);
|
||||
const bannerItems = ref([]);
|
||||
const trendingItems = ref([]);
|
||||
const recommendedCreators = ref([]);
|
||||
const matchedCreators = ref([]);
|
||||
const searchKeyword = ref('');
|
||||
const loading = ref(true);
|
||||
const page = ref(1);
|
||||
const hasMore = ref(false);
|
||||
const activeBannerIndex = ref(0);
|
||||
|
||||
const fetchContents = async (append = false) => {
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
limit: 10,
|
||||
sort: 'latest',
|
||||
keyword: searchKeyword.value
|
||||
};
|
||||
const res = await contentApi.list(params);
|
||||
if (append) {
|
||||
contents.value.push(...(res.items || []));
|
||||
} else {
|
||||
contents.value = res.items || [];
|
||||
const [bannerRes, trendingRes, creatorsRes, feedRes] = await Promise.all([
|
||||
contentApi.list({ is_pinned: true, limit: 5 }),
|
||||
contentApi.list({ sort: 'hot', limit: 3 }),
|
||||
tenantApi.list({ limit: 5 }),
|
||||
contentApi.list({ page: 1, limit: 10, sort: 'latest' })
|
||||
]);
|
||||
|
||||
if (bannerRes.items && bannerRes.items.length > 0) {
|
||||
bannerItems.value = bannerRes.items;
|
||||
} else if (feedRes.items && feedRes.items.length > 0) {
|
||||
bannerItems.value = feedRes.items.slice(0, 5);
|
||||
}
|
||||
hasMore.value = (res.total > contents.value.length);
|
||||
|
||||
trendingItems.value = trendingRes.items || [];
|
||||
recommendedCreators.value = creatorsRes.items || [];
|
||||
|
||||
contents.value = feedRes.items || [];
|
||||
hasMore.value = (feedRes.total > contents.value.length);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
@@ -31,46 +42,78 @@ const fetchContents = async (append = false) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
const handleSearch = async () => {
|
||||
page.value = 1;
|
||||
fetchContents();
|
||||
loading.value = true;
|
||||
matchedCreators.value = [];
|
||||
try {
|
||||
const promises = [
|
||||
contentApi.list({ page: 1, limit: 10, keyword: searchKeyword.value })
|
||||
];
|
||||
if (searchKeyword.value) {
|
||||
promises.push(tenantApi.list({ keyword: searchKeyword.value, limit: 5 }));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const contentRes = results[0];
|
||||
|
||||
contents.value = contentRes.items || [];
|
||||
hasMore.value = (contentRes.total > contents.value.length);
|
||||
|
||||
if (results[1]) {
|
||||
matchedCreators.value = results[1].items || [];
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
const loadMore = async () => {
|
||||
page.value++;
|
||||
fetchContents(true);
|
||||
const res = await contentApi.list({
|
||||
page: page.value,
|
||||
limit: 10,
|
||||
keyword: searchKeyword.value
|
||||
});
|
||||
if (res.items) {
|
||||
contents.value.push(...res.items);
|
||||
hasMore.value = (res.total > contents.value.length);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => fetchContents());
|
||||
onMounted(fetchData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-screen-xl py-8">
|
||||
<!-- Hero Banner -->
|
||||
<div class="relative w-full h-[400px] rounded-2xl overflow-hidden bg-slate-900 mb-8 group">
|
||||
<!-- Mock Carousel Image -->
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80"
|
||||
class="w-full h-full object-cover opacity-80 transition-transform duration-700 group-hover:scale-105"
|
||||
alt="Banner">
|
||||
<div class="relative w-full h-[400px] rounded-2xl overflow-hidden bg-slate-900 mb-8 group" v-if="bannerItems.length > 0">
|
||||
<div v-for="(item, index) in bannerItems" :key="item.id"
|
||||
class="absolute inset-0 transition-opacity duration-700"
|
||||
:class="{ 'opacity-100 z-10': activeBannerIndex === index, 'opacity-0 z-0': activeBannerIndex !== index }">
|
||||
<img :src="item.cover" class="w-full h-full object-cover opacity-80" alt="Banner">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
||||
<div class="absolute bottom-0 left-0 p-10 max-w-2xl text-white">
|
||||
<div class="inline-block px-3 py-1 bg-red-600 text-white text-xs font-bold rounded mb-3">置顶推荐</div>
|
||||
<h2 class="text-4xl font-bold mb-4 leading-tight">京剧《霸王别姬》全本实录:程派艺术的巅峰演绎</h2>
|
||||
<p class="text-lg text-slate-200 line-clamp-2">梅兰芳大师经典之作,高清修复版独家上线。感受国粹魅力,重温梨园风华。</p>
|
||||
<h2 class="text-4xl font-bold mb-4 leading-tight cursor-pointer hover:underline" @click="$router.push(`/contents/${item.id}`)">{{ item.title }}</h2>
|
||||
<p class="text-lg text-slate-200 line-clamp-2">{{ item.description || item.title }}</p>
|
||||
</div>
|
||||
<!-- Arrows (Always visible as per spec) -->
|
||||
<button
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/50 text-white rounded-full flex items-center justify-center backdrop-blur-sm transition-all"><i
|
||||
</div>
|
||||
|
||||
<!-- Arrows -->
|
||||
<button @click="activeBannerIndex = (activeBannerIndex - 1 + bannerItems.length) % bannerItems.length"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/50 text-white rounded-full flex items-center justify-center backdrop-blur-sm transition-all z-20"><i
|
||||
class="pi pi-chevron-left text-xl"></i></button>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/50 text-white rounded-full flex items-center justify-center backdrop-blur-sm transition-all"><i
|
||||
<button @click="activeBannerIndex = (activeBannerIndex + 1) % bannerItems.length"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/50 text-white rounded-full flex items-center justify-center backdrop-blur-sm transition-all z-20"><i
|
||||
class="pi pi-chevron-right text-xl"></i></button>
|
||||
|
||||
<!-- Indicators -->
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-white"></span>
|
||||
<span class="w-2 h-2 rounded-full bg-white/50"></span>
|
||||
<span class="w-2 h-2 rounded-full bg-white/50"></span>
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-20">
|
||||
<span v-for="(item, index) in bannerItems" :key="index"
|
||||
class="w-2 h-2 rounded-full cursor-pointer transition-colors"
|
||||
:class="activeBannerIndex === index ? 'bg-white' : 'bg-white/50'"
|
||||
@click="activeBannerIndex = index"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +154,22 @@ onMounted(() => fetchContents());
|
||||
<!-- Main Feed (Left 9) -->
|
||||
<div class="col-span-12 lg:col-span-8 xl:col-span-9 space-y-6">
|
||||
|
||||
<!-- Matched Creators (Search Result) -->
|
||||
<div v-if="searchKeyword && matchedCreators.length > 0" class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||||
<h3 class="font-bold text-slate-900 mb-4">相关频道</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div v-for="creator in matchedCreators" :key="creator.id"
|
||||
class="flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors cursor-pointer border border-transparent hover:border-slate-200"
|
||||
@click="$router.push(`/creators/${creator.id}`)">
|
||||
<img :src="creator.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`" class="w-12 h-12 rounded-full border border-slate-100">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-slate-900 truncate">{{ creator.name }}</div>
|
||||
<div class="text-xs text-slate-500 truncate">{{ creator.bio || '暂无简介' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-link v-for="item in contents" :key="item.id" :to="`/contents/${item.id}`"
|
||||
class="block bg-white rounded-2xl shadow-sm border border-slate-100 p-6 hover:shadow-xl hover:border-primary-100 transition-all duration-300 group cursor-pointer active:scale-[0.99]">
|
||||
<div class="flex gap-8">
|
||||
@@ -173,32 +232,15 @@ onMounted(() => fetchContents());
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
|
||||
<h3 class="font-bold text-slate-900 mb-4">推荐名家</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Master1" class="w-10 h-10 rounded-full">
|
||||
<div v-for="creator in recommendedCreators" :key="creator.id" class="flex items-center gap-3">
|
||||
<img :src="creator.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`" class="w-10 h-10 rounded-full cursor-pointer" @click="$router.push(`/creators/${creator.id}`)">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-slate-900 text-sm truncate">梅派传人小林</div>
|
||||
<div class="text-xs text-slate-500 truncate">粉丝 12.5万</div>
|
||||
<div class="font-bold text-slate-900 text-sm truncate hover:text-primary-600 cursor-pointer" @click="$router.push(`/creators/${creator.id}`)">{{ creator.name }}</div>
|
||||
<div class="text-xs text-slate-500 truncate">粉丝 {{ creator.stats?.followers || 0 }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 bg-primary-50 text-primary-600 text-xs font-bold rounded-full hover:bg-primary-100">关注</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Master2" class="w-10 h-10 rounded-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-slate-900 text-sm truncate">豫剧李大师</div>
|
||||
<div class="text-xs text-slate-500 truncate">粉丝 8.9万</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 bg-primary-50 text-primary-600 text-xs font-bold rounded-full hover:bg-primary-100">关注</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Master3" class="w-10 h-10 rounded-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-slate-900 text-sm truncate">越剧小生阿强</div>
|
||||
<div class="text-xs text-slate-500 truncate">粉丝 5.2万</div>
|
||||
</div>
|
||||
<button class="px-3 py-1 bg-slate-100 text-slate-400 text-xs font-bold rounded-full">已关注</button>
|
||||
<button class="px-3 py-1 bg-primary-50 text-primary-600 text-xs font-bold rounded-full hover:bg-primary-100">关注</button>
|
||||
</div>
|
||||
<div v-if="recommendedCreators.length === 0" class="text-center text-slate-400 text-sm">暂无推荐</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -208,33 +250,16 @@ onMounted(() => fetchContents());
|
||||
<i class="pi pi-chart-line text-red-500"></i> 本周热门
|
||||
</h3>
|
||||
<ul class="space-y-4">
|
||||
<li class="flex gap-3 items-start">
|
||||
<span class="text-red-500 font-bold italic text-lg w-4">1</span>
|
||||
<li v-for="(item, index) in trendingItems" :key="item.id" class="flex gap-3 items-start">
|
||||
<span class="font-bold italic text-lg w-4" :class="index === 0 ? 'text-red-500' : (index === 1 ? 'text-orange-500' : 'text-yellow-500')">{{ index + 1 }}</span>
|
||||
<div class="flex-1">
|
||||
<h4
|
||||
<h4 @click="$router.push(`/contents/${item.id}`)"
|
||||
class="text-sm font-medium text-slate-800 line-clamp-2 hover:text-primary-600 cursor-pointer">
|
||||
《智取威虎山》选段:今日痛饮庆功酒</h4>
|
||||
<span class="text-xs text-slate-400 mt-1 block">15.2万 阅读</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3 items-start">
|
||||
<span class="text-orange-500 font-bold italic text-lg w-4">2</span>
|
||||
<div class="flex-1">
|
||||
<h4
|
||||
class="text-sm font-medium text-slate-800 line-clamp-2 hover:text-primary-600 cursor-pointer">
|
||||
【深度解析】京剧脸谱颜色的含义</h4>
|
||||
<span class="text-xs text-slate-400 mt-1 block">9.8万 阅读</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3 items-start">
|
||||
<span class="text-yellow-500 font-bold italic text-lg w-4">3</span>
|
||||
<div class="flex-1">
|
||||
<h4
|
||||
class="text-sm font-medium text-slate-800 line-clamp-2 hover:text-primary-600 cursor-pointer">
|
||||
黄梅戏《女驸马》全场高清</h4>
|
||||
<span class="text-xs text-slate-400 mt-1 block">7.5万 阅读</span>
|
||||
{{ item.title }}</h4>
|
||||
<span class="text-xs text-slate-400 mt-1 block">{{ item.views }} 阅读</span>
|
||||
</div>
|
||||
</li>
|
||||
<div v-if="trendingItems.length === 0" class="text-center text-slate-400 text-sm">暂无热门</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -251,34 +276,4 @@ onMounted(() => fetchContents());
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { contentApi } from '../api/content';
|
||||
|
||||
const contents = ref([]);
|
||||
const searchKeyword = ref('');
|
||||
const loading = ref(true);
|
||||
|
||||
const fetchContents = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
limit: 20,
|
||||
sort: 'latest',
|
||||
keyword: searchKeyword.value
|
||||
};
|
||||
const res = await contentApi.list(params);
|
||||
contents.value = res.items || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchContents();
|
||||
};
|
||||
|
||||
onMounted(fetchContents);
|
||||
</script>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<div class="ml-auto relative w-64">
|
||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
|
||||
<input type="text" placeholder="搜索订单号或买家..."
|
||||
<input type="text" v-model="searchKeyword" @keyup.enter="fetchOrders" placeholder="搜索订单号或买家..."
|
||||
class="w-full h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="refundAction === 'reject'" class="mt-4">
|
||||
<textarea class="w-full p-2 border border-slate-200 rounded text-sm focus:border-red-500 outline-none"
|
||||
<textarea v-model="refundReason" class="w-full p-2 border border-slate-200 rounded text-sm focus:border-red-500 outline-none"
|
||||
rows="2" placeholder="请输入拒绝理由..."></textarea>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -188,51 +188,54 @@ import Dialog from "primevue/dialog";
|
||||
import RadioButton from "primevue/radiobutton";
|
||||
import Toast from "primevue/toast";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, onMounted, watch } from "vue";
|
||||
import { creatorApi } from "../../api/creator";
|
||||
|
||||
const toast = useToast();
|
||||
const filterStatus = ref("all");
|
||||
const searchKeyword = ref("");
|
||||
const detailDialog = ref(false);
|
||||
const refundDialog = ref(false);
|
||||
const selectedOrder = ref(null);
|
||||
const refundAction = ref("accept");
|
||||
const refundReason = ref("");
|
||||
const orders = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const orders = ref([
|
||||
{
|
||||
id: "82934712",
|
||||
title: "《霸王别姬》全本实录珍藏版",
|
||||
type: "视频",
|
||||
cover:
|
||||
"https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=100&q=60",
|
||||
buyerName: "戏迷小张",
|
||||
buyerAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Zhang",
|
||||
buyerId: "9527",
|
||||
amount: "9.90",
|
||||
date: "2025-12-24 14:30",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "82934715",
|
||||
title: "京剧打击乐基础教程",
|
||||
type: "视频",
|
||||
cover:
|
||||
"https://images.unsplash.com/photo-1533174072545-e8d4aa97edf9?ixlib=rb-1.2.1&auto=format&fit=crop&w=100&q=60",
|
||||
buyerName: "票友老李",
|
||||
buyerAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Li",
|
||||
buyerId: "8848",
|
||||
amount: "19.90",
|
||||
date: "2025-12-25 09:15",
|
||||
status: "refunding",
|
||||
},
|
||||
]);
|
||||
const fetchOrders = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
status: filterStatus.value === 'all' ? '' : filterStatus.value,
|
||||
keyword: searchKeyword.value
|
||||
};
|
||||
const res = await creatorApi.listOrders(params);
|
||||
orders.value = (res || []).map(o => ({
|
||||
id: o.id,
|
||||
title: o.title || '未知内容',
|
||||
type: '数字内容',
|
||||
cover: o.cover,
|
||||
buyerName: o.buyer_name,
|
||||
buyerAvatar: o.buyer_avatar,
|
||||
amount: o.amount,
|
||||
date: o.create_time,
|
||||
status: o.status
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (filterStatus.value === "all") return orders.value;
|
||||
return orders.value.filter((o) => o.status === filterStatus.value);
|
||||
});
|
||||
onMounted(fetchOrders);
|
||||
watch(filterStatus, fetchOrders);
|
||||
|
||||
const filteredOrders = computed(() => orders.value);
|
||||
|
||||
const statusStyle = (status) => {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
case "completed":
|
||||
return { bg: "bg-green-50", text: "text-green-600", label: "已完成" };
|
||||
case "refunding":
|
||||
@@ -252,18 +255,21 @@ const viewDetail = (order) => {
|
||||
const handleRefund = (order) => {
|
||||
selectedOrder.value = order;
|
||||
refundAction.value = "accept";
|
||||
refundReason.value = "";
|
||||
refundDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmRefund = () => {
|
||||
// Mock API
|
||||
refundDialog.value = false;
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: "处理完成",
|
||||
detail: refundAction.value === "accept" ? "已同意退款" : "已拒绝退款申请",
|
||||
life: 3000,
|
||||
const confirmRefund = async () => {
|
||||
try {
|
||||
const res = await creatorApi.refundOrder(selectedOrder.value.id, {
|
||||
action: refundAction.value,
|
||||
reason: refundReason.value
|
||||
});
|
||||
// In real app, refresh list
|
||||
refundDialog.value = false;
|
||||
toast.add({ severity: "success", summary: "处理成功", life: 3000 });
|
||||
fetchOrders();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "处理失败", detail: e.message, life: 3000 });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
81
frontend/portal/src/views/user/CouponsView.vue
Normal file
81
frontend/portal/src/views/user/CouponsView.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { userApi } from '../../api/user';
|
||||
|
||||
const currentTab = ref('unused');
|
||||
const coupons = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const tabs = [
|
||||
{ label: '未使用', value: 'unused' },
|
||||
{ label: '已使用', value: 'used' },
|
||||
{ label: '已过期', value: 'expired' }
|
||||
];
|
||||
|
||||
const fetchCoupons = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await userApi.getCoupons(currentTab.value);
|
||||
coupons.value = res || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchCoupons);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 min-h-[600px] p-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900 mb-8">我的优惠券</h1>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex items-center gap-8 mb-8 border-b border-slate-100">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
@click="currentTab = tab.value; fetchCoupons()"
|
||||
class="pb-4 text-sm font-bold transition-colors border-b-2 cursor-pointer focus:outline-none"
|
||||
:class="currentTab === tab.value ? 'text-primary-600 border-primary-600' : 'text-slate-500 border-transparent hover:text-slate-700'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div v-for="coupon in coupons" :key="coupon.id"
|
||||
class="flex bg-white border border-slate-200 rounded-xl overflow-hidden group hover:border-primary-300 transition-all"
|
||||
:class="{ 'opacity-60 grayscale': currentTab !== 'unused' }">
|
||||
<!-- Left: Amount -->
|
||||
<div class="w-32 bg-primary-50 flex flex-col items-center justify-center border-r border-dashed border-slate-200 p-4">
|
||||
<div class="text-primary-600 font-bold">
|
||||
<span class="text-sm">¥</span>
|
||||
<span class="text-3xl">{{ coupon.value / 100 }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-primary-500 mt-1">满{{ coupon.min_order_amount / 100 }}可用</div>
|
||||
</div>
|
||||
<!-- Right: Info -->
|
||||
<div class="flex-1 p-4 relative">
|
||||
<h3 class="font-bold text-slate-900 mb-1">{{ coupon.title }}</h3>
|
||||
<p class="text-xs text-slate-500 mb-4">{{ coupon.description }}</p>
|
||||
<div class="text-[10px] text-slate-400">有效期至: {{ coupon.end_at }}</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div v-if="currentTab === 'used'" class="absolute top-2 right-2 border-2 border-slate-300 text-slate-400 text-[10px] font-bold px-1 py-0.5 rounded rotate-12">已使用</div>
|
||||
<div v-if="currentTab === 'expired'" class="absolute top-2 right-2 border-2 border-red-300 text-red-400 text-[10px] font-bold px-1 py-0.5 rounded rotate-12">已过期</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-if="!loading && coupons.length === 0" class="text-center py-20 text-slate-400">
|
||||
<div class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="pi pi-ticket text-2xl text-slate-300"></i>
|
||||
</div>
|
||||
<p>暂无相关优惠券</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,3 +1,83 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Button from 'primevue/button';
|
||||
import { userApi } from '../../api/user';
|
||||
|
||||
const router = useRouter();
|
||||
const currentTab = ref('all');
|
||||
const dialogVisible = ref(false);
|
||||
const selectedNotification = ref(null);
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
|
||||
const tabs = ref([
|
||||
{ label: '全部', value: 'all', count: 0 },
|
||||
{ label: '系统通知', value: 'system', count: 0 },
|
||||
{ label: '订单通知', value: 'order', count: 0 },
|
||||
{ label: '审核通知', value: 'audit', count: 0 },
|
||||
{ label: '互动消息', value: 'interaction', count: 0 }
|
||||
]);
|
||||
|
||||
const notifications = ref([]);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await userApi.getNotifications(currentTab.value, page.value);
|
||||
notifications.value = res.items || [];
|
||||
} catch (e) {
|
||||
console.error("Fetch notifications failed:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchNotifications);
|
||||
|
||||
watch(currentTab, () => {
|
||||
page.value = 1;
|
||||
fetchNotifications();
|
||||
});
|
||||
|
||||
const getIconStyle = (type) => {
|
||||
switch(type) {
|
||||
case 'system': return { bg: 'bg-blue-50', color: 'text-blue-600', icon: 'pi-megaphone' };
|
||||
case 'order': return { bg: 'bg-green-50', color: 'text-green-600', icon: 'pi-shopping-bag' };
|
||||
case 'audit': return { bg: 'bg-orange-50', color: 'text-orange-600', icon: 'pi-file-edit' };
|
||||
case 'interaction': return { bg: 'bg-purple-50', color: 'text-purple-600', icon: 'pi-comments' };
|
||||
default: return { bg: 'bg-slate-100', color: 'text-slate-500', icon: 'pi-bell' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (item) => {
|
||||
if (!item.read) {
|
||||
try {
|
||||
await userApi.markNotificationRead(item.id);
|
||||
item.read = true;
|
||||
} catch (e) {
|
||||
console.error("Mark read failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'system') {
|
||||
selectedNotification.value = item;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await userApi.markAllNotificationsRead();
|
||||
notifications.value.forEach(n => n.read = true);
|
||||
tabs.value.forEach(t => t.count = 0);
|
||||
} catch (e) {
|
||||
console.error("Mark all read failed:", e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 min-h-[600px]">
|
||||
<!-- Header & Tabs -->
|
||||
@@ -14,16 +94,16 @@
|
||||
<span v-if="tab.count > 0" class="absolute -top-1 -right-4 min-w-[1.25rem] h-5 px-1.5 bg-red-500 text-white text-[10px] rounded-full flex items-center justify-center">{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="mb-4 text-base font-medium text-slate-500 hover:text-primary-600 cursor-pointer flex items-center gap-1">
|
||||
<button @click="handleMarkAllRead" class="mb-4 text-base font-medium text-slate-500 hover:text-primary-600 cursor-pointer flex items-center gap-1">
|
||||
<i class="pi pi-check-circle"></i> 全部已读
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Notification List -->
|
||||
<div class="p-0">
|
||||
<div v-if="filteredNotifications.length > 0">
|
||||
<div v-if="notifications.length > 0">
|
||||
<div
|
||||
v-for="item in filteredNotifications"
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
@click="handleNotificationClick(item)"
|
||||
class="flex items-start gap-4 p-5 border-b border-slate-50 hover:bg-slate-50 transition-colors cursor-pointer group"
|
||||
@@ -62,10 +142,6 @@
|
||||
<div class="p-4">
|
||||
<div class="text-slate-500 text-sm mb-4">{{ selectedNotification?.time }}</div>
|
||||
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">{{ selectedNotification?.content }}</div>
|
||||
<!-- Mock rich content / image -->
|
||||
<div v-if="selectedNotification?.id === 1" class="mt-4 p-4 bg-slate-50 rounded text-sm text-slate-500">
|
||||
(此处为富文本内容展示区,可能包含图片、链接等)
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="关闭" icon="pi pi-check" @click="dialogVisible = false" autofocus />
|
||||
@@ -73,103 +149,3 @@
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const router = useRouter();
|
||||
const currentTab = ref('all');
|
||||
const dialogVisible = ref(false);
|
||||
const selectedNotification = ref(null);
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: 'all', count: 3 },
|
||||
{ label: '系统通知', value: 'system', count: 1 },
|
||||
{ label: '订单通知', value: 'order', count: 1 },
|
||||
{ label: '审核通知', value: 'audit', count: 0 },
|
||||
{ label: '互动消息', value: 'interaction', count: 1 }
|
||||
];
|
||||
|
||||
const notifications = ref([
|
||||
{
|
||||
id: 1,
|
||||
type: 'system',
|
||||
title: '平台服务协议更新通知',
|
||||
content: '为了更好地保障您的权益,我们更新了《用户服务协议》和《隐私政策》,主要变更涉及账户安全与数据保护。\n\n具体变更内容如下:\n1. 明确了数据采集范围...\n2. 优化了账号注销流程...',
|
||||
time: '10分钟前',
|
||||
read: false,
|
||||
link: null
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'order',
|
||||
title: '订单支付成功',
|
||||
content: '您购买的《霸王别姬》全本实录珍藏版已支付成功,订单号:82934712,请前往已购内容查看。',
|
||||
time: '2小时前',
|
||||
read: false,
|
||||
link: '/me/orders/82934712'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'interaction',
|
||||
title: '收到新的评论回复',
|
||||
content: '梅派传人小林 回复了您的评论:“感谢您的支持,这版录音确实非常珍贵...”。',
|
||||
time: '昨天 14:30',
|
||||
read: false,
|
||||
link: '/contents/1'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'audit',
|
||||
title: '内容审核通过',
|
||||
content: '恭喜!您发布的文章《京剧脸谱赏析》已通过审核并发布上线。',
|
||||
time: '3天前',
|
||||
read: true,
|
||||
link: '/creator/contents'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'system',
|
||||
title: '春节期间服务调整公告',
|
||||
content: '春节期间(2月9日-2月17日),提现申请处理时效将有所延迟,敬请谅解。',
|
||||
time: '5天前',
|
||||
read: true,
|
||||
link: null
|
||||
}
|
||||
]);
|
||||
|
||||
const filteredNotifications = computed(() => {
|
||||
if (currentTab.value === 'all') return notifications.value;
|
||||
return notifications.value.filter(n => n.type === currentTab.value);
|
||||
});
|
||||
|
||||
const getIconStyle = (type) => {
|
||||
switch(type) {
|
||||
case 'system': return { bg: 'bg-blue-50', color: 'text-blue-600', icon: 'pi-megaphone' };
|
||||
case 'order': return { bg: 'bg-green-50', color: 'text-green-600', icon: 'pi-shopping-bag' };
|
||||
case 'audit': return { bg: 'bg-orange-50', color: 'text-orange-600', icon: 'pi-file-edit' };
|
||||
case 'interaction': return { bg: 'bg-purple-50', color: 'text-purple-600', icon: 'pi-comments' };
|
||||
default: return { bg: 'bg-slate-100', color: 'text-slate-500', icon: 'pi-bell' };
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = (item) => {
|
||||
// 1. Mark as read
|
||||
item.read = true;
|
||||
|
||||
// 2. Handle System type separately
|
||||
if (item.type === 'system') {
|
||||
selectedNotification.value = item;
|
||||
dialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Navigate if link exists
|
||||
if (item.link) {
|
||||
router.push(item.link);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user