feat: 添加内容创建时间字段,优化内容列表和详情视图;添加创作者内容列表接口;优化上传和删除功能

This commit is contained in:
2026-01-06 17:03:11 +08:00
parent 1723802722
commit ab5bc8d41a
7 changed files with 177 additions and 62 deletions

View File

@@ -23,6 +23,7 @@ type ContentItem struct {
AuthorAvatar string `json:"author_avatar"`
Views int `json:"views"`
Likes int `json:"likes"`
CreatedAt string `json:"created_at"`
IsPurchased bool `json:"is_purchased"`
}

View File

@@ -50,6 +50,18 @@ func (r *Routes) Name() string {
// Each route is registered with its corresponding controller action and parameter bindings.
func (r *Routes) Register(router fiber.Router) {
// 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")
router.Get("/v1/common/options"[len(r.Path()):], DataFunc0(
r.common.GetOptions,
@@ -60,6 +72,19 @@ func (r *Routes) Register(router fiber.Router) {
Local[*models.User]("__ctx_user"),
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")
router.Post("/v1/upload/init"[len(r.Path()):], DataFunc2(
r.common.InitUpload,
@@ -73,31 +98,6 @@ func (r *Routes) Register(router fiber.Router) {
File[multipart.FileHeader]("file"),
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
r.log.Debugf("Registering route: Get /v1/contents -> content.List")
router.Get("/v1/contents"[len(r.Path()):], DataFunc1(
@@ -242,6 +242,12 @@ func (r *Routes) Register(router fiber.Router) {
Local[*models.User]("__ctx_user"),
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")
router.Get("/v1/tenants/:id"[len(r.Path()):], DataFunc2(
r.tenant.Get,

View File

@@ -2,6 +2,7 @@ package v1
import (
"quyun/v2/app/http/v1/dto"
"quyun/v2/app/requests"
"quyun/v2/app/services"
"quyun/v2/database/models"
@@ -11,6 +12,28 @@ import (
// @provider
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
//
// @Router /v1/tenants/:id [get]

View File

@@ -50,7 +50,7 @@ func (s *content) List(ctx context.Context, filter *content_dto.ContentListFilte
case "price_asc":
q = q.Order(tbl.ID.Desc())
default: // latest
q = q.Order(tbl.PublishedAt.Desc())
q = q.Order(tbl.ID.Desc())
}
// 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 {
dto := content_dto.ContentItem{
ID: cast.ToString(item.ID),
Title: item.Title,
Genre: item.Genre,
AuthorID: cast.ToString(item.UserID),
Views: int(item.Views),
Likes: int(item.Likes),
Price: price,
ID: cast.ToString(item.ID),
Title: item.Title,
Genre: item.Genre,
AuthorID: cast.ToString(item.UserID),
Views: int(item.Views),
Likes: int(item.Likes),
CreatedAt: item.CreatedAt.Format("2006-01-02"),
Price: price,
}
if item.Author != nil {
dto.AuthorName = item.Author.Nickname

View File

@@ -252,6 +252,7 @@ func (s *creator) ListContents(
Items: data,
}, nil
}
func (s *creator) CreateContent(ctx context.Context, userID int64, form *creator_dto.ContentCreateForm) error {
tid, err := s.getTenantID(ctx, userID)
if err != nil {

View File

@@ -2,6 +2,11 @@ import { request } from '../utils/request';
export const contentApi = {
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();
return request(`/contents?${qs}`);
},

View File

@@ -34,15 +34,22 @@
<!-- Actions & Stats -->
<div class="flex flex-col items-end gap-5 pb-2">
<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="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'">
<i class="pi" :class="isFollowing ? 'pi-check' : 'pi-plus'"></i>
: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',
followLoading ? 'cursor-wait' : ''
]">
<i class="pi" :class="{
'pi-spin pi-spinner': followLoading,
'pi-check': !followLoading && isFollowing,
'pi-plus': !followLoading && !isFollowing
}"></i>
{{ isFollowing ? '已关注' : '关注' }}
</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>
<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="pi pi-ellipsis-h"></i></button>
</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.contents }}</span> 内容</div>
<div><span class="font-bold text-white text-xl">{{ tenant.stats.likes }}</span> 获赞</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>
<!-- Sticky Nav -->
<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">
@@ -71,7 +83,7 @@
<div class="relative group">
<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">
</div>
</div>
@@ -92,10 +104,11 @@
<div class="absolute bottom-0 left-0 p-8 text-white w-full">
<div class="flex items-center justify-between">
<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>
<div class="text-sm opacity-80">{{ featuredContent.created_at }}</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>
</div>
@@ -114,20 +127,27 @@
</div>
<div class="flex-1 flex flex-col">
<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>
<p class="text-xs text-slate-400 mb-2">{{ item.created_at }}</p>
</div>
<div class="mt-auto pt-4 flex items-center justify-between">
<div class="text-xs text-slate-400">
<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>
</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>
</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>
@@ -143,7 +163,7 @@
<!-- Floating Share FAB -->
<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"
title="分享频道">
<i class="pi pi-share-alt text-xl"></i>
@@ -167,29 +187,84 @@ const tenant = ref({});
const contents = ref([]);
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 {
const id = route.params.id;
const [t, c, f] = await Promise.all([
tenantApi.get(id),
contentApi.list({ tenantId: id, sort: 'latest' }),
contentApi.list({ tenantId: id, is_pinned: true })
]);
const query = {
tenantId: id,
sort: 'latest',
page: page.value,
limit: limit,
keyword: searchKeyword.value
};
const reqs = [
contentApi.list(query)
];
tenant.value = t || {};
contents.value = c?.items || [];
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) {
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 () => {
if (followLoading.value) return;
followLoading.value = true;
try {
if (isFollowing.value) {
await tenantApi.unfollow(route.params.id);
@@ -201,6 +276,9 @@ const toggleFollow = async () => {
}
} catch(e) {
console.error(e);
toast.add({ severity: 'error', summary: '操作失败', detail: e.message, life: 3000 });
} finally {
followLoading.value = false;
}
};