Compare commits
2 Commits
31d6192816
...
ab5bc8d41a
| Author | SHA1 | Date | |
|---|---|---|---|
| ab5bc8d41a | |||
| 1723802722 |
@@ -2,6 +2,7 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"quyun/v2/app/http/v1/dto"
|
"quyun/v2/app/http/v1/dto"
|
||||||
|
"quyun/v2/app/requests"
|
||||||
"quyun/v2/app/services"
|
"quyun/v2/app/services"
|
||||||
"quyun/v2/database/models"
|
"quyun/v2/database/models"
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ func (c *Creator) ListContents(
|
|||||||
ctx fiber.Ctx,
|
ctx fiber.Ctx,
|
||||||
user *models.User,
|
user *models.User,
|
||||||
filter *dto.CreatorContentListFilter,
|
filter *dto.CreatorContentListFilter,
|
||||||
) ([]dto.CreatorContentItem, error) {
|
) (*requests.Pager, error) {
|
||||||
return services.Creator.ListContents(ctx, user.ID, filter)
|
return services.Creator.ListContents(ctx, user.ID, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type ContentItem struct {
|
|||||||
AuthorAvatar string `json:"author_avatar"`
|
AuthorAvatar string `json:"author_avatar"`
|
||||||
Views int `json:"views"`
|
Views int `json:"views"`
|
||||||
Likes int `json:"likes"`
|
Likes int `json:"likes"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
IsPurchased bool `json:"is_purchased"`
|
IsPurchased bool `json:"is_purchased"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,8 +93,9 @@ type CreatorContentListFilter struct {
|
|||||||
Status *string `query:"status"`
|
Status *string `query:"status"`
|
||||||
Visibility *string `query:"visibility"`
|
Visibility *string `query:"visibility"`
|
||||||
Genre *string `query:"genre"`
|
Genre *string `query:"genre"`
|
||||||
Key *string `query:"key"`
|
Key *string `query:"key"`
|
||||||
Keyword *string `query:"keyword"`
|
Keyword *string `query:"keyword"`
|
||||||
|
Sort *string `query:"sort"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatorOrderListFilter struct {
|
type CreatorOrderListFilter struct {
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ func (r *Routes) Name() string {
|
|||||||
// Each route is registered with its corresponding controller action and parameter bindings.
|
// Each route is registered with its corresponding controller action and parameter bindings.
|
||||||
func (r *Routes) Register(router fiber.Router) {
|
func (r *Routes) Register(router fiber.Router) {
|
||||||
// Register routes for controller: Common
|
// Register routes for controller: Common
|
||||||
|
r.log.Debugf("Registering route: Delete /v1/media-assets/:id -> common.DeleteMediaAsset")
|
||||||
|
router.Delete("/v1/media-assets/:id"[len(r.Path()):], Func2(
|
||||||
|
r.common.DeleteMediaAsset,
|
||||||
|
Local[*models.User]("__ctx_user"),
|
||||||
|
PathParam[string]("id"),
|
||||||
|
))
|
||||||
|
r.log.Debugf("Registering route: Delete /v1/upload/:uploadId -> common.AbortUpload")
|
||||||
|
router.Delete("/v1/upload/:uploadId"[len(r.Path()):], Func2(
|
||||||
|
r.common.AbortUpload,
|
||||||
|
Local[*models.User]("__ctx_user"),
|
||||||
|
PathParam[string]("uploadId"),
|
||||||
|
))
|
||||||
r.log.Debugf("Registering route: Get /v1/common/options -> common.GetOptions")
|
r.log.Debugf("Registering route: Get /v1/common/options -> common.GetOptions")
|
||||||
router.Get("/v1/common/options"[len(r.Path()):], DataFunc0(
|
router.Get("/v1/common/options"[len(r.Path()):], DataFunc0(
|
||||||
r.common.GetOptions,
|
r.common.GetOptions,
|
||||||
@@ -60,6 +72,19 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
Local[*models.User]("__ctx_user"),
|
Local[*models.User]("__ctx_user"),
|
||||||
QueryParam[string]("hash"),
|
QueryParam[string]("hash"),
|
||||||
))
|
))
|
||||||
|
r.log.Debugf("Registering route: Post /v1/upload -> common.Upload")
|
||||||
|
router.Post("/v1/upload"[len(r.Path()):], DataFunc3(
|
||||||
|
r.common.Upload,
|
||||||
|
Local[*models.User]("__ctx_user"),
|
||||||
|
File[multipart.FileHeader]("file"),
|
||||||
|
Body[dto.UploadForm]("form"),
|
||||||
|
))
|
||||||
|
r.log.Debugf("Registering route: Post /v1/upload/complete -> common.CompleteUpload")
|
||||||
|
router.Post("/v1/upload/complete"[len(r.Path()):], DataFunc2(
|
||||||
|
r.common.CompleteUpload,
|
||||||
|
Local[*models.User]("__ctx_user"),
|
||||||
|
Body[dto.UploadCompleteForm]("form"),
|
||||||
|
))
|
||||||
r.log.Debugf("Registering route: Post /v1/upload/init -> common.InitUpload")
|
r.log.Debugf("Registering route: Post /v1/upload/init -> common.InitUpload")
|
||||||
router.Post("/v1/upload/init"[len(r.Path()):], DataFunc2(
|
router.Post("/v1/upload/init"[len(r.Path()):], DataFunc2(
|
||||||
r.common.InitUpload,
|
r.common.InitUpload,
|
||||||
@@ -73,31 +98,6 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
File[multipart.FileHeader]("file"),
|
File[multipart.FileHeader]("file"),
|
||||||
Body[dto.UploadPartForm]("form"),
|
Body[dto.UploadPartForm]("form"),
|
||||||
))
|
))
|
||||||
r.log.Debugf("Registering route: Post /v1/upload/complete -> common.CompleteUpload")
|
|
||||||
router.Post("/v1/upload/complete"[len(r.Path()):], DataFunc2(
|
|
||||||
r.common.CompleteUpload,
|
|
||||||
Local[*models.User]("__ctx_user"),
|
|
||||||
Body[dto.UploadCompleteForm]("form"),
|
|
||||||
))
|
|
||||||
r.log.Debugf("Registering route: Delete /v1/upload/:uploadId -> common.AbortUpload")
|
|
||||||
router.Delete("/v1/upload/:uploadId"[len(r.Path()):], Func2(
|
|
||||||
r.common.AbortUpload,
|
|
||||||
Local[*models.User]("__ctx_user"),
|
|
||||||
PathParam[string]("uploadId"),
|
|
||||||
))
|
|
||||||
r.log.Debugf("Registering route: Delete /v1/media-assets/:id -> common.DeleteMediaAsset")
|
|
||||||
router.Delete("/v1/media-assets/:id"[len(r.Path()):], Func2(
|
|
||||||
r.common.DeleteMediaAsset,
|
|
||||||
Local[*models.User]("__ctx_user"),
|
|
||||||
PathParam[string]("id"),
|
|
||||||
))
|
|
||||||
r.log.Debugf("Registering route: Post /v1/upload -> common.Upload")
|
|
||||||
router.Post("/v1/upload"[len(r.Path()):], DataFunc3(
|
|
||||||
r.common.Upload,
|
|
||||||
Local[*models.User]("__ctx_user"),
|
|
||||||
File[multipart.FileHeader]("file"),
|
|
||||||
Body[dto.UploadForm]("form"),
|
|
||||||
))
|
|
||||||
// Register routes for controller: Content
|
// Register routes for controller: Content
|
||||||
r.log.Debugf("Registering route: Get /v1/contents -> content.List")
|
r.log.Debugf("Registering route: Get /v1/contents -> content.List")
|
||||||
router.Get("/v1/contents"[len(r.Path()):], DataFunc1(
|
router.Get("/v1/contents"[len(r.Path()):], DataFunc1(
|
||||||
@@ -242,6 +242,12 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
Local[*models.User]("__ctx_user"),
|
Local[*models.User]("__ctx_user"),
|
||||||
PathParam[string]("id"),
|
PathParam[string]("id"),
|
||||||
))
|
))
|
||||||
|
r.log.Debugf("Registering route: Get /v1/creators/:id/contents -> tenant.ListContents")
|
||||||
|
router.Get("/v1/creators/:id/contents"[len(r.Path()):], DataFunc2(
|
||||||
|
r.tenant.ListContents,
|
||||||
|
PathParam[string]("id"),
|
||||||
|
Query[dto.ContentListFilter]("filter"),
|
||||||
|
))
|
||||||
r.log.Debugf("Registering route: Get /v1/tenants/:id -> tenant.Get")
|
r.log.Debugf("Registering route: Get /v1/tenants/:id -> tenant.Get")
|
||||||
router.Get("/v1/tenants/:id"[len(r.Path()):], DataFunc2(
|
router.Get("/v1/tenants/:id"[len(r.Path()):], DataFunc2(
|
||||||
r.tenant.Get,
|
r.tenant.Get,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"quyun/v2/app/http/v1/dto"
|
"quyun/v2/app/http/v1/dto"
|
||||||
|
"quyun/v2/app/requests"
|
||||||
"quyun/v2/app/services"
|
"quyun/v2/app/services"
|
||||||
"quyun/v2/database/models"
|
"quyun/v2/database/models"
|
||||||
|
|
||||||
@@ -11,6 +12,28 @@ import (
|
|||||||
// @provider
|
// @provider
|
||||||
type Tenant struct{}
|
type Tenant struct{}
|
||||||
|
|
||||||
|
// List creator contents
|
||||||
|
//
|
||||||
|
// @Router /v1/creators/:id/contents [get]
|
||||||
|
// @Summary List creator contents
|
||||||
|
// @Description List contents of a specific creator
|
||||||
|
// @Tags TenantPublic
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Tenant ID"
|
||||||
|
// @Param page query int false "Page"
|
||||||
|
// @Param limit query int false "Limit"
|
||||||
|
// @Success 200 {object} requests.Pager
|
||||||
|
// @Bind id path
|
||||||
|
// @Bind filter query
|
||||||
|
func (t *Tenant) ListContents(ctx fiber.Ctx, id string, filter *dto.ContentListFilter) (*requests.Pager, error) {
|
||||||
|
if filter == nil {
|
||||||
|
filter = &dto.ContentListFilter{}
|
||||||
|
}
|
||||||
|
filter.TenantID = &id
|
||||||
|
return services.Content.List(ctx, filter)
|
||||||
|
}
|
||||||
|
|
||||||
// Get tenant public profile
|
// Get tenant public profile
|
||||||
//
|
//
|
||||||
// @Router /v1/tenants/:id [get]
|
// @Router /v1/tenants/:id [get]
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func (s *content) List(ctx context.Context, filter *content_dto.ContentListFilte
|
|||||||
case "price_asc":
|
case "price_asc":
|
||||||
q = q.Order(tbl.ID.Desc())
|
q = q.Order(tbl.ID.Desc())
|
||||||
default: // latest
|
default: // latest
|
||||||
q = q.Order(tbl.PublishedAt.Desc())
|
q = q.Order(tbl.ID.Desc())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
@@ -443,13 +443,14 @@ func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) {
|
|||||||
|
|
||||||
func (s *content) toContentItemDTO(item *models.Content, price float64) content_dto.ContentItem {
|
func (s *content) toContentItemDTO(item *models.Content, price float64) content_dto.ContentItem {
|
||||||
dto := content_dto.ContentItem{
|
dto := content_dto.ContentItem{
|
||||||
ID: cast.ToString(item.ID),
|
ID: cast.ToString(item.ID),
|
||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
Genre: item.Genre,
|
Genre: item.Genre,
|
||||||
AuthorID: cast.ToString(item.UserID),
|
AuthorID: cast.ToString(item.UserID),
|
||||||
Views: int(item.Views),
|
Views: int(item.Views),
|
||||||
Likes: int(item.Likes),
|
Likes: int(item.Likes),
|
||||||
Price: price,
|
CreatedAt: item.CreatedAt.Format("2006-01-02"),
|
||||||
|
Price: price,
|
||||||
}
|
}
|
||||||
if item.Author != nil {
|
if item.Author != nil {
|
||||||
dto.AuthorName = item.Author.Nickname
|
dto.AuthorName = item.Author.Nickname
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"quyun/v2/app/errorx"
|
"quyun/v2/app/errorx"
|
||||||
creator_dto "quyun/v2/app/http/v1/dto"
|
creator_dto "quyun/v2/app/http/v1/dto"
|
||||||
|
"quyun/v2/app/requests"
|
||||||
"quyun/v2/database/fields"
|
"quyun/v2/database/fields"
|
||||||
"quyun/v2/database/models"
|
"quyun/v2/database/models"
|
||||||
"quyun/v2/pkg/consts"
|
"quyun/v2/pkg/consts"
|
||||||
@@ -109,7 +110,7 @@ func (s *creator) ListContents(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int64,
|
userID int64,
|
||||||
filter *creator_dto.CreatorContentListFilter,
|
filter *creator_dto.CreatorContentListFilter,
|
||||||
) ([]creator_dto.CreatorContentItem, error) {
|
) (*requests.Pager, error) {
|
||||||
tid, err := s.getTenantID(ctx, userID)
|
tid, err := s.getTenantID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -143,8 +144,32 @@ func (s *creator) ListContents(
|
|||||||
q = q.Where(tbl.Title.Like("%" + *filter.Keyword + "%"))
|
q = q.Where(tbl.Title.Like("%" + *filter.Keyword + "%"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
filter.Pagination.Format()
|
||||||
|
total, err := q.Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
var list []*models.Content
|
var list []*models.Content
|
||||||
err = q.Order(tbl.ID.Desc()).
|
|
||||||
|
// Sorting
|
||||||
|
sort := "latest"
|
||||||
|
if filter.Sort != nil && *filter.Sort != "" {
|
||||||
|
sort = *filter.Sort
|
||||||
|
}
|
||||||
|
switch sort {
|
||||||
|
case "oldest":
|
||||||
|
q = q.Order(tbl.ID.Asc())
|
||||||
|
case "views":
|
||||||
|
q = q.Order(tbl.Views.Desc())
|
||||||
|
case "likes":
|
||||||
|
q = q.Order(tbl.Likes.Desc())
|
||||||
|
default:
|
||||||
|
q = q.Order(tbl.ID.Desc())
|
||||||
|
}
|
||||||
|
|
||||||
|
err = q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).
|
||||||
UnderlyingDB().
|
UnderlyingDB().
|
||||||
Preload("ContentAssets").
|
Preload("ContentAssets").
|
||||||
Preload("ContentAssets.Asset").
|
Preload("ContentAssets.Asset").
|
||||||
@@ -220,7 +245,12 @@ func (s *creator) ListContents(
|
|||||||
IsPurchased: false,
|
IsPurchased: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return data, nil
|
|
||||||
|
return &requests.Pager{
|
||||||
|
Pagination: filter.Pagination,
|
||||||
|
Total: total,
|
||||||
|
Items: data,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *creator) CreateContent(ctx context.Context, userID int64, form *creator_dto.ContentCreateForm) error {
|
func (s *creator) CreateContent(ctx context.Context, userID int64, form *creator_dto.ContentCreateForm) error {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { request } from '../utils/request';
|
|||||||
|
|
||||||
export const contentApi = {
|
export const contentApi = {
|
||||||
list: (params) => {
|
list: (params) => {
|
||||||
|
if (params.tenantId) {
|
||||||
|
const { tenantId, ...rest } = params;
|
||||||
|
const qs = new URLSearchParams(rest).toString();
|
||||||
|
return request(`/creators/${tenantId}/contents?${qs}`);
|
||||||
|
}
|
||||||
const qs = new URLSearchParams(params).toString();
|
const qs = new URLSearchParams(params).toString();
|
||||||
return request(`/contents?${qs}`);
|
return request(`/contents?${qs}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-6 flex flex-wrap gap-4 items-center">
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-6 flex flex-wrap gap-4 items-center">
|
||||||
|
<!-- ... existing filters ... -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm font-bold text-slate-500">状态:</span>
|
<span class="text-sm font-bold text-slate-500">状态:</span>
|
||||||
<select v-model="filterStatus"
|
<select v-model="filterStatus"
|
||||||
@@ -42,6 +43,19 @@
|
|||||||
<option v-for="k in keys" :key="k" :value="k">{{ k }}</option>
|
<option v-for="k in keys" :key="k" :value="k">{{ k }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-bold text-slate-500">排序:</span>
|
||||||
|
<select v-model="filterSort"
|
||||||
|
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]">
|
||||||
|
<option v-for="opt in sortOptions" :key="opt.key" :value="opt.key">{{ opt.value }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="handleResetFilters" class="h-9 px-3 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors text-sm font-bold flex items-center gap-1">
|
||||||
|
<i class="pi pi-refresh"></i> 重置
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="ml-auto relative">
|
<div class="ml-auto relative">
|
||||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
|
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
|
||||||
<input type="text" placeholder="搜索标题..." v-model="searchKeyword" @keyup.enter="handleSearch"
|
<input type="text" placeholder="搜索标题..." v-model="searchKeyword" @keyup.enter="handleSearch"
|
||||||
@@ -50,10 +64,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="space-y-4">
|
||||||
|
<div v-for="i in 3" :key="i" class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex gap-6 animate-pulse">
|
||||||
|
<div class="w-40 h-[90px] bg-slate-200 rounded-lg"></div>
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<div class="h-6 bg-slate-200 rounded w-1/3"></div>
|
||||||
|
<div class="h-4 bg-slate-200 rounded w-1/4"></div>
|
||||||
|
<div class="h-8 bg-slate-200 rounded w-full mt-4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="!loading && contents.length === 0" class="flex flex-col items-center justify-center py-20 bg-white rounded-xl border border-slate-100 border-dashed">
|
||||||
|
<div class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<i class="pi pi-folder-open text-3xl text-slate-300"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-slate-900 font-bold mb-1">暂无内容</h3>
|
||||||
|
<p class="text-slate-500 text-sm mb-6">您还没有发布任何内容,快去创作吧!</p>
|
||||||
|
<router-link to="/creator/contents/new" class="px-5 py-2 bg-primary-600 text-white rounded-lg text-sm font-bold hover:bg-primary-700 transition-colors">
|
||||||
|
立即发布
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Content List -->
|
<!-- Content List -->
|
||||||
<div class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div v-for="item in contents" :key="item.id"
|
<div v-for="item in contents" :key="item.id"
|
||||||
class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex gap-6 hover:shadow-md transition-shadow group relative">
|
class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex gap-6 hover:shadow-md transition-shadow group relative">
|
||||||
|
<!-- ... existing list item ... -->
|
||||||
|
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
<div class="w-40 h-[90px] bg-slate-100 rounded-lg flex-shrink-0 overflow-hidden relative">
|
<div class="w-40 h-[90px] bg-slate-100 rounded-lg flex-shrink-0 overflow-hidden relative">
|
||||||
@@ -176,6 +215,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { useConfirm } from 'primevue/useconfirm';
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
import ConfirmDialog from 'primevue/confirmdialog';
|
import ConfirmDialog from 'primevue/confirmdialog';
|
||||||
|
import Paginator from 'primevue/paginator';
|
||||||
import { commonApi } from '../../api/common';
|
import { commonApi } from '../../api/common';
|
||||||
import { creatorApi } from '../../api/creator';
|
import { creatorApi } from '../../api/creator';
|
||||||
|
|
||||||
@@ -183,10 +223,12 @@ const router = useRouter();
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const contents = ref([]);
|
const contents = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
const filterStatus = ref('all');
|
const filterStatus = ref('all');
|
||||||
const filterVisibility = ref('all');
|
const filterVisibility = ref('all');
|
||||||
const filterGenre = ref('all');
|
const filterGenre = ref('all');
|
||||||
const filterKey = ref('all');
|
const filterKey = ref('all');
|
||||||
|
const filterSort = ref('latest');
|
||||||
const searchKeyword = ref('');
|
const searchKeyword = ref('');
|
||||||
const statusOptions = ref([]);
|
const statusOptions = ref([]);
|
||||||
const visibilityOptions = [
|
const visibilityOptions = [
|
||||||
@@ -194,7 +236,16 @@ const visibilityOptions = [
|
|||||||
{ key: 'tenant_only', value: '仅会员' },
|
{ key: 'tenant_only', value: '仅会员' },
|
||||||
{ key: 'private', value: '私有' }
|
{ key: 'private', value: '私有' }
|
||||||
];
|
];
|
||||||
|
const sortOptions = [
|
||||||
|
{ key: 'latest', value: '最新发布' },
|
||||||
|
{ key: 'oldest', value: '最早发布' },
|
||||||
|
{ key: 'views', value: '最多浏览' },
|
||||||
|
{ key: 'likes', value: '最多点赞' }
|
||||||
|
];
|
||||||
const genreOptions = ref([]);
|
const genreOptions = ref([]);
|
||||||
|
const totalRecords = ref(0);
|
||||||
|
const rows = ref(10);
|
||||||
|
const first = ref(0);
|
||||||
const keys = ['C大调', 'D大调', 'E大调', 'F大调', 'G大调', 'A大调', 'B大调', '降E大调'];
|
const keys = ['C大调', 'D大调', 'E大调', 'F大调', 'G大调', 'A大调', 'B大调', '降E大调'];
|
||||||
|
|
||||||
const fetchOptions = async () => {
|
const fetchOptions = async () => {
|
||||||
@@ -210,30 +261,59 @@ const fetchOptions = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchContents = async () => {
|
const fetchContents = async () => {
|
||||||
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const params = {};
|
const params = {
|
||||||
|
page: (first.value / rows.value) + 1,
|
||||||
|
limit: rows.value
|
||||||
|
};
|
||||||
if (filterStatus.value !== 'all') params.status = filterStatus.value;
|
if (filterStatus.value !== 'all') params.status = filterStatus.value;
|
||||||
if (filterVisibility.value !== 'all') params.visibility = filterVisibility.value;
|
if (filterVisibility.value !== 'all') params.visibility = filterVisibility.value;
|
||||||
if (filterGenre.value !== 'all') params.genre = filterGenre.value;
|
if (filterGenre.value !== 'all') params.genre = filterGenre.value;
|
||||||
if (filterKey.value !== 'all') params.key = filterKey.value;
|
if (filterKey.value !== 'all') params.key = filterKey.value;
|
||||||
|
if (filterSort.value !== 'latest') params.sort = filterSort.value;
|
||||||
if (searchKeyword.value) params.keyword = searchKeyword.value;
|
if (searchKeyword.value) params.keyword = searchKeyword.value;
|
||||||
|
|
||||||
const res = await creatorApi.listContents(params);
|
const res = await creatorApi.listContents(params);
|
||||||
contents.value = res || [];
|
if (res && res.items) {
|
||||||
|
contents.value = res.items;
|
||||||
|
totalRecords.value = res.total;
|
||||||
|
} else {
|
||||||
|
contents.value = [];
|
||||||
|
totalRecords.value = 0;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPage = (event) => {
|
||||||
|
first.value = event.first;
|
||||||
|
rows.value = event.rows;
|
||||||
|
fetchContents();
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchOptions();
|
fetchOptions();
|
||||||
fetchContents();
|
fetchContents();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch([filterStatus, filterVisibility, filterGenre, filterKey], () => {
|
watch([filterStatus, filterVisibility, filterGenre, filterKey, filterSort], () => {
|
||||||
fetchContents();
|
fetchContents();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
filterStatus.value = 'all';
|
||||||
|
filterVisibility.value = 'all';
|
||||||
|
filterGenre.value = 'all';
|
||||||
|
filterKey.value = 'all';
|
||||||
|
filterSort.value = 'latest';
|
||||||
|
searchKeyword.value = '';
|
||||||
|
fetchContents();
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
fetchContents();
|
fetchContents();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,15 +34,22 @@
|
|||||||
<!-- Actions & Stats -->
|
<!-- Actions & Stats -->
|
||||||
<div class="flex flex-col items-end gap-5 pb-2">
|
<div class="flex flex-col items-end gap-5 pb-2">
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="toggleFollow"
|
<button @click="toggleFollow" :disabled="followLoading"
|
||||||
class="h-11 w-32 rounded-full font-bold text-base transition-all flex items-center justify-center gap-2 backdrop-blur-md"
|
class="h-11 w-32 rounded-full font-bold text-base transition-all flex items-center justify-center gap-2 backdrop-blur-md"
|
||||||
:class="isFollowing ? 'bg-white/10 text-white border border-white/20 hover:bg-white/20' : 'bg-primary-600 text-white hover:bg-primary-700 border border-transparent shadow-lg shadow-primary-900/30'">
|
:class="[
|
||||||
<i class="pi" :class="isFollowing ? 'pi-check' : 'pi-plus'"></i>
|
isFollowing ? 'bg-white/10 text-white border border-white/20 hover:bg-white/20' : 'bg-primary-600 text-white hover:bg-primary-700 border border-transparent shadow-lg shadow-primary-900/30',
|
||||||
|
followLoading ? 'cursor-wait' : ''
|
||||||
|
]">
|
||||||
|
<i class="pi" :class="{
|
||||||
|
'pi-spin pi-spinner': followLoading,
|
||||||
|
'pi-check': !followLoading && isFollowing,
|
||||||
|
'pi-plus': !followLoading && !isFollowing
|
||||||
|
}"></i>
|
||||||
{{ isFollowing ? '已关注' : '关注' }}
|
{{ isFollowing ? '已关注' : '关注' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button @click="toast.add({ severity: 'info', summary: '开发中', detail: '私信功能即将上线', life: 2000 })"
|
||||||
class="h-11 px-6 border border-white/20 text-white rounded-full font-bold hover:bg-white/10 backdrop-blur-md transition-colors">私信</button>
|
class="h-11 px-6 border border-white/20 text-white rounded-full font-bold hover:bg-white/10 backdrop-blur-md transition-colors">私信</button>
|
||||||
<button
|
<button @click="toast.add({ severity: 'info', summary: '开发中', detail: '更多功能敬请期待', life: 2000 })"
|
||||||
class="h-11 w-11 border border-white/20 text-white rounded-full flex items-center justify-center hover:bg-white/10 backdrop-blur-md transition-colors"><i
|
class="h-11 w-11 border border-white/20 text-white rounded-full flex items-center justify-center hover:bg-white/10 backdrop-blur-md transition-colors"><i
|
||||||
class="pi pi-ellipsis-h"></i></button>
|
class="pi pi-ellipsis-h"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,12 +57,17 @@
|
|||||||
<div><span class="font-bold text-white text-xl">{{ tenant.stats.followers }}</span> 关注</div>
|
<div><span class="font-bold text-white text-xl">{{ tenant.stats.followers }}</span> 关注</div>
|
||||||
<div><span class="font-bold text-white text-xl">{{ tenant.stats.contents }}</span> 内容</div>
|
<div><span class="font-bold text-white text-xl">{{ tenant.stats.contents }}</span> 内容</div>
|
||||||
<div><span class="font-bold text-white text-xl">{{ tenant.stats.likes }}</span> 获赞</div>
|
<div><span class="font-bold text-white text-xl">{{ tenant.stats.likes }}</span> 获赞</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="text-center mt-10" v-if="hasMore">
|
||||||
</div>
|
<button @click="loadMore" :disabled="loading" class="h-11 px-8 bg-slate-100 text-slate-600 rounded-full font-bold hover:bg-slate-200 transition-colors disabled:cursor-wait disabled:opacity-70">
|
||||||
|
<span v-if="loading">加载中...</span>
|
||||||
|
<span v-else>加载更多</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Sticky Nav -->
|
<!-- Sticky Nav -->
|
||||||
<div class="sticky top-16 z-20 bg-white border-b border-slate-200 shadow-sm">
|
<div class="sticky top-16 z-20 bg-white border-b border-slate-200 shadow-sm">
|
||||||
<div class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 flex items-center justify-between h-14">
|
<div class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 flex items-center justify-between h-14">
|
||||||
@@ -71,7 +83,7 @@
|
|||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<i
|
<i
|
||||||
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-primary-500 transition-colors"></i>
|
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-primary-500 transition-colors"></i>
|
||||||
<input type="text" placeholder="搜素频道内容"
|
<input type="text" placeholder="搜素频道内容" v-model="searchKeyword" @keyup.enter="handleSearch"
|
||||||
class="h-9 pl-9 pr-4 rounded-full bg-slate-100 border-none text-sm focus:bg-white focus:ring-2 focus:ring-primary-100 transition-all w-48 focus:w-64">
|
class="h-9 pl-9 pr-4 rounded-full bg-slate-100 border-none text-sm focus:bg-white focus:ring-2 focus:ring-primary-100 transition-all w-48 focus:w-64">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,10 +104,11 @@
|
|||||||
<div class="absolute bottom-0 left-0 p-8 text-white w-full">
|
<div class="absolute bottom-0 left-0 p-8 text-white w-full">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-bold opacity-80 mb-2">[{{ featuredContent.genre }}]</div>
|
<span class="bg-white/10 text-white text-xs px-2 py-1 rounded-full font-bold mb-2 inline-block">{{ featuredContent.genre }}</span>
|
||||||
<h2 class="text-3xl font-bold mb-2">{{ featuredContent.title }}</h2>
|
<h2 class="text-3xl font-bold mb-2">{{ featuredContent.title }}</h2>
|
||||||
|
<div class="text-sm opacity-80">{{ featuredContent.created_at }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-2xl font-bold text-amber-400" v-if="featuredContent.price > 0">¥ {{ featuredContent.price }}</div>
|
<div class="text-2xl font-bold text-amber-400" v-if="featuredContent.price > 0">¥ {{ featuredContent.price.toFixed(2) }}</div>
|
||||||
<div class="text-2xl font-bold text-green-400" v-else>免费</div>
|
<div class="text-2xl font-bold text-green-400" v-else>免费</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,20 +127,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex flex-col">
|
<div class="flex-1 flex flex-col">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold text-slate-900 mb-2 group-hover:text-primary-600 transition-colors line-clamp-1">
|
<h3 class="text-lg font-bold text-slate-900 mb-1 group-hover:text-primary-600 transition-colors line-clamp-1">
|
||||||
{{ item.title }}</h3>
|
{{ item.title }}</h3>
|
||||||
|
<p class="text-xs text-slate-400 mb-2">{{ item.created_at }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-auto pt-4 flex items-center justify-between">
|
<div class="mt-auto pt-4 flex items-center justify-between">
|
||||||
<div class="text-xs text-slate-400">
|
<div class="text-xs text-slate-400">
|
||||||
<span><i class="pi pi-eye mr-1"></i> {{ item.views }}</span>
|
<span><i class="pi pi-eye mr-1"></i> {{ item.views }}</span>
|
||||||
<span class="ml-4"><i class="pi pi-thumbs-up mr-1"></i> {{ item.likes }}</span>
|
<span class="ml-4"><i class="pi pi-thumbs-up mr-1"></i> {{ item.likes }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-lg font-bold text-red-600" v-if="item.price > 0">¥ {{ item.price }}</div>
|
<div class="text-lg font-bold text-red-600" v-if="item.price > 0">¥ {{ item.price.toFixed(2) }}</div>
|
||||||
<div class="text-lg font-bold text-green-600" v-else>免费</div>
|
<div class="text-lg font-bold text-green-600" v-else>免费</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-center mt-10" v-if="hasMore">
|
||||||
|
<button @click="loadMore" :disabled="loading" class="h-11 px-8 bg-slate-100 text-slate-600 rounded-full font-bold hover:bg-slate-200 transition-colors disabled:cursor-wait disabled:opacity-70">
|
||||||
|
<span v-if="loading">加载中...</span>
|
||||||
|
<span v-else>加载更多</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,7 +163,7 @@
|
|||||||
|
|
||||||
<!-- Floating Share FAB -->
|
<!-- Floating Share FAB -->
|
||||||
<div class="fixed bottom-8 right-8 z-50">
|
<div class="fixed bottom-8 right-8 z-50">
|
||||||
<button
|
<button @click="toast.add({ severity: 'info', summary: '开发中', detail: '分享功能即将上线', life: 2000 })"
|
||||||
class="w-14 h-14 bg-slate-900 text-white rounded-full shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
|
class="w-14 h-14 bg-slate-900 text-white rounded-full shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
|
||||||
title="分享频道">
|
title="分享频道">
|
||||||
<i class="pi pi-share-alt text-xl"></i>
|
<i class="pi pi-share-alt text-xl"></i>
|
||||||
@@ -167,29 +187,84 @@ const tenant = ref({});
|
|||||||
const contents = ref([]);
|
const contents = ref([]);
|
||||||
const featuredContent = ref(null);
|
const featuredContent = ref(null);
|
||||||
|
|
||||||
const fetchData = async () => {
|
// New States
|
||||||
|
const loading = ref(true);
|
||||||
|
const followLoading = ref(false);
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const page = ref(1);
|
||||||
|
const hasMore = ref(false);
|
||||||
|
const limit = 10;
|
||||||
|
|
||||||
|
const fetchData = async (isLoadMore = false) => {
|
||||||
|
if (!isLoadMore) loading.value = true;
|
||||||
try {
|
try {
|
||||||
const id = route.params.id;
|
const id = route.params.id;
|
||||||
const [t, c, f] = await Promise.all([
|
const query = {
|
||||||
tenantApi.get(id),
|
tenantId: id,
|
||||||
contentApi.list({ tenantId: id, sort: 'latest' }),
|
sort: 'latest',
|
||||||
contentApi.list({ tenantId: id, is_pinned: true })
|
page: page.value,
|
||||||
]);
|
limit: limit,
|
||||||
|
keyword: searchKeyword.value
|
||||||
|
};
|
||||||
|
|
||||||
tenant.value = t || {};
|
const reqs = [
|
||||||
contents.value = c?.items || [];
|
contentApi.list(query)
|
||||||
if (f && f.items && f.items.length > 0) {
|
];
|
||||||
featuredContent.value = f.items[0];
|
|
||||||
|
// Only fetch tenant info & featured on first load
|
||||||
|
if (!isLoadMore && page.value === 1) {
|
||||||
|
reqs.push(tenantApi.get(id));
|
||||||
|
reqs.push(contentApi.list({ tenantId: id, is_pinned: true }));
|
||||||
}
|
}
|
||||||
isFollowing.value = t?.is_following || false;
|
|
||||||
|
const results = await Promise.all(reqs);
|
||||||
|
const c = results[0]; // Content List
|
||||||
|
|
||||||
|
if (!isLoadMore && page.value === 1) {
|
||||||
|
const t = results[1];
|
||||||
|
const f = results[2];
|
||||||
|
tenant.value = t || {};
|
||||||
|
isFollowing.value = t?.is_following || false;
|
||||||
|
|
||||||
|
if (f && f.items && f.items.length > 0) {
|
||||||
|
featuredContent.value = f.items[0];
|
||||||
|
} else {
|
||||||
|
featuredContent.value = null;
|
||||||
|
}
|
||||||
|
contents.value = c?.items || [];
|
||||||
|
} else {
|
||||||
|
// Append mode
|
||||||
|
if (c?.items) {
|
||||||
|
contents.value.push(...c.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if more
|
||||||
|
hasMore.value = (c?.total > contents.value.length);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
toast.add({ severity: 'error', summary: '加载失败', detail: '请稍后重试', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(fetchData);
|
onMounted(() => fetchData());
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
page.value = 1;
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
page.value++;
|
||||||
|
fetchData(true);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleFollow = async () => {
|
const toggleFollow = async () => {
|
||||||
|
if (followLoading.value) return;
|
||||||
|
followLoading.value = true;
|
||||||
try {
|
try {
|
||||||
if (isFollowing.value) {
|
if (isFollowing.value) {
|
||||||
await tenantApi.unfollow(route.params.id);
|
await tenantApi.unfollow(route.params.id);
|
||||||
@@ -201,6 +276,9 @@ const toggleFollow = async () => {
|
|||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
toast.add({ severity: 'error', summary: '操作失败', detail: e.message, life: 3000 });
|
||||||
|
} finally {
|
||||||
|
followLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user