feat: 添加点赞和收藏功能,优化内容详情视图和评论交互

This commit is contained in:
2026-01-07 14:43:52 +08:00
parent f355b26920
commit 1298192157
6 changed files with 316 additions and 57 deletions

View File

@@ -47,7 +47,7 @@ func (c *Content) List(
// @Success 200 {object} dto.ContentDetail
// @Bind id path
func (c *Content) Get(ctx fiber.Ctx, id string) (*dto.ContentDetail, error) {
uid := cast.ToInt64(ctx.Locals(consts.CtxKeyUser))
uid := getUserID(ctx)
return services.Content.Get(ctx, uid, id)
}
@@ -65,7 +65,7 @@ func (c *Content) Get(ctx fiber.Ctx, id string) (*dto.ContentDetail, error) {
// @Bind id path
// @Bind page query
func (c *Content) ListComments(ctx fiber.Ctx, id string, page int) (*requests.Pager, error) {
uid := cast.ToInt64(ctx.Locals(consts.CtxKeyUser))
uid := getUserID(ctx)
return services.Content.ListComments(ctx, uid, id, page)
}
@@ -83,7 +83,7 @@ func (c *Content) ListComments(ctx fiber.Ctx, id string, page int) (*requests.Pa
// @Bind id path
// @Bind form body
func (c *Content) CreateComment(ctx fiber.Ctx, id string, form *dto.CommentCreateForm) error {
uid := cast.ToInt64(ctx.Locals(consts.CtxKeyUser))
uid := getUserID(ctx)
return services.Content.CreateComment(ctx, uid, id, form)
}
@@ -99,10 +99,62 @@ func (c *Content) CreateComment(ctx fiber.Ctx, id string, form *dto.CommentCreat
// @Success 200 {string} string "Liked"
// @Bind id path
func (c *Content) LikeComment(ctx fiber.Ctx, id string) error {
uid := cast.ToInt64(ctx.Locals(consts.CtxKeyUser))
uid := getUserID(ctx)
return services.Content.LikeComment(ctx, uid, id)
}
// Add like
//
// @Router /v1/contents/:id/like [post]
// @Summary Add like
// @Tags Content
// @Param id path string true "Content ID"
// @Success 200 {string} string "Liked"
// @Bind id path
func (c *Content) AddLike(ctx fiber.Ctx, id string) error {
uid := getUserID(ctx)
return services.Content.AddLike(ctx, uid, id)
}
// Remove like
//
// @Router /v1/contents/:id/like [delete]
// @Summary Remove like
// @Tags Content
// @Param id path string true "Content ID"
// @Success 200 {string} string "Unliked"
// @Bind id path
func (c *Content) RemoveLike(ctx fiber.Ctx, id string) error {
uid := getUserID(ctx)
return services.Content.RemoveLike(ctx, uid, id)
}
// Add favorite
//
// @Router /v1/contents/:id/favorite [post]
// @Summary Add favorite
// @Tags Content
// @Param id path string true "Content ID"
// @Success 200 {string} string "Favorited"
// @Bind id path
func (c *Content) AddFavorite(ctx fiber.Ctx, id string) error {
uid := getUserID(ctx)
return services.Content.AddFavorite(ctx, uid, id)
}
// Remove favorite
//
// @Router /v1/contents/:id/favorite [delete]
// @Summary Remove favorite
// @Tags Content
// @Param id path string true "Content ID"
// @Success 200 {string} string "Unfavorited"
// @Bind id path
func (c *Content) RemoveFavorite(ctx fiber.Ctx, id string) error {
uid := getUserID(ctx)
return services.Content.RemoveFavorite(ctx, uid, id)
}
// List curated topics
//
// @Router /v1/topics [get]
@@ -115,3 +167,12 @@ func (c *Content) LikeComment(ctx fiber.Ctx, id string) error {
func (c *Content) ListTopics(ctx fiber.Ctx) ([]dto.Topic, error) {
return services.Content.ListTopics(ctx)
}
func getUserID(ctx fiber.Ctx) int64 {
if u := ctx.Locals(consts.CtxKeyUser); u != nil {
if user, ok := u.(*models.User); ok {
return user.ID
}
}
return 0
}

View File

@@ -12,19 +12,20 @@ type ContentListFilter struct {
}
type ContentItem struct {
ID string `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
Genre string `json:"genre"`
Type string `json:"type"` // video, audio, article
Price float64 `json:"price"`
AuthorID string `json:"author_id"`
AuthorName string `json:"author_name"`
AuthorAvatar string `json:"author_avatar"`
Views int `json:"views"`
Likes int `json:"likes"`
CreatedAt string `json:"created_at"`
IsPurchased bool `json:"is_purchased"`
ID string `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
Genre string `json:"genre"`
Type string `json:"type"` // video, audio, article
Price float64 `json:"price"`
AuthorID string `json:"author_id"`
AuthorName string `json:"author_name"`
AuthorAvatar string `json:"author_avatar"`
AuthorIsFollowing bool `json:"author_is_following"`
Views int `json:"views"`
Likes int `json:"likes"`
CreatedAt string `json:"created_at"`
IsPurchased bool `json:"is_purchased"`
}
type ContentDetail struct {

View File

@@ -99,6 +99,16 @@ func (r *Routes) Register(router fiber.Router) {
Body[dto.UploadPartForm]("form"),
))
// Register routes for controller: Content
r.log.Debugf("Registering route: Delete /v1/contents/:id/favorite -> content.RemoveFavorite")
router.Delete("/v1/contents/:id/favorite"[len(r.Path()):], Func1(
r.content.RemoveFavorite,
PathParam[string]("id"),
))
r.log.Debugf("Registering route: Delete /v1/contents/:id/like -> content.RemoveLike")
router.Delete("/v1/contents/:id/like"[len(r.Path()):], Func1(
r.content.RemoveLike,
PathParam[string]("id"),
))
r.log.Debugf("Registering route: Get /v1/contents -> content.List")
router.Get("/v1/contents"[len(r.Path()):], DataFunc1(
r.content.List,
@@ -130,6 +140,16 @@ func (r *Routes) Register(router fiber.Router) {
PathParam[string]("id"),
Body[dto.CommentCreateForm]("form"),
))
r.log.Debugf("Registering route: Post /v1/contents/:id/favorite -> content.AddFavorite")
router.Post("/v1/contents/:id/favorite"[len(r.Path()):], Func1(
r.content.AddFavorite,
PathParam[string]("id"),
))
r.log.Debugf("Registering route: Post /v1/contents/:id/like -> content.AddLike")
router.Post("/v1/contents/:id/like"[len(r.Path()):], Func1(
r.content.AddLike,
PathParam[string]("id"),
))
// Register routes for controller: Creator
r.log.Debugf("Registering route: Delete /v1/creator/contents/:id -> creator.DeleteContent")
router.Delete("/v1/creator/contents/:id"[len(r.Path()):], Func2(

View File

@@ -88,7 +88,7 @@ func (s *content) List(ctx context.Context, filter *content_dto.ContentListFilte
// Convert to DTO
data := make([]content_dto.ContentItem, len(list))
for i, item := range list {
data[i] = s.toContentItemDTO(item, priceMap[item.ID])
data[i] = s.toContentItemDTO(item, priceMap[item.ID], false)
}
return &requests.Pager{
@@ -179,8 +179,18 @@ func (s *content) Get(ctx context.Context, userID int64, id string) (*content_dt
}
}
// Check if author is followed
authorIsFollowing := false
if userID > 0 {
exists, _ := models.TenantUserQuery.WithContext(ctx).
Where(models.TenantUserQuery.TenantID.Eq(item.TenantID),
models.TenantUserQuery.Role.Contains(string(consts.TenantUserRoleMember))).
Exists()
authorIsFollowing = exists
}
detail := &content_dto.ContentDetail{
ContentItem: s.toContentItemDTO(&item, price),
ContentItem: s.toContentItemDTO(&item, price, authorIsFollowing),
Description: item.Description,
Body: item.Body,
MediaUrls: s.toMediaURLs(accessibleAssets),
@@ -356,7 +366,7 @@ func (s *content) GetLibrary(ctx context.Context, userID int64) ([]user_dto.Cont
var data []user_dto.ContentItem
for _, item := range list {
dto := s.toContentItemDTO(item, 0)
dto := s.toContentItemDTO(item, 0, false)
dto.IsPurchased = true
data = append(data, dto)
}
@@ -441,16 +451,17 @@ func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) {
// Helpers
func (s *content) toContentItemDTO(item *models.Content, price float64) content_dto.ContentItem {
func (s *content) toContentItemDTO(item *models.Content, price float64, authorIsFollowing bool) 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),
CreatedAt: item.CreatedAt.Format("2006-01-02"),
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,
AuthorIsFollowing: authorIsFollowing,
}
if item.Author != nil {
dto.AuthorName = item.Author.Nickname
@@ -623,7 +634,7 @@ func (s *content) getInteractList(ctx context.Context, userID int64, typ string)
var data []user_dto.ContentItem
for _, item := range list {
data = append(data, s.toContentItemDTO(item, 0))
data = append(data, s.toContentItemDTO(item, 0, false))
}
return data, nil
}

View File

@@ -14,5 +14,9 @@ export const contentApi = {
listComments: (id, page) => request(`/contents/${id}/comments?page=${page || 1}`),
createComment: (id, data) => request(`/contents/${id}/comments`, { method: 'POST', body: data }),
likeComment: (id) => request(`/comments/${id}/like`, { method: 'POST' }),
addLike: (id) => request(`/contents/${id}/like`, { method: 'POST' }),
removeLike: (id) => request(`/contents/${id}/like`, { method: 'DELETE' }),
addFavorite: (id) => request(`/contents/${id}/favorite`, { method: 'POST' }),
removeFavorite: (id) => request(`/contents/${id}/favorite`, { method: 'DELETE' }),
listTopics: () => request('/topics'),
};

View File

@@ -34,39 +34,41 @@
<button @click="fontSize++" class="w-9 h-9 flex items-center justify-center rounded hover:bg-slate-100 text-slate-600" title="放大字体"><i class="pi pi-plus" style="font-size: 0.8rem"></i>A</button>
<button @click="fontSize--" class="w-9 h-9 flex items-center justify-center rounded hover:bg-slate-100 text-slate-600" title="缩小字体"><i class="pi pi-minus" style="font-size: 0.8rem"></i>A</button>
<div class="w-px h-4 bg-slate-200 mx-1"></div>
<button class="w-9 h-9 flex items-center justify-center rounded hover:bg-slate-100 text-slate-600"><i class="pi pi-share-alt"></i></button>
<button class="w-9 h-9 flex items-center justify-center rounded hover:bg-slate-100 text-slate-600"><i class="pi pi-bookmark"></i></button>
<button class="w-9 h-9 flex items-center justify-center rounded hover:bg-slate-100 text-slate-600" @click="toast.add({severity:'info', summary: '分享功能开发中'})"><i class="pi pi-share-alt"></i></button>
<button class="w-9 h-9 flex items-center justify-center rounded hover:bg-slate-100 transition-colors"
:class="isFavorited ? 'text-primary-600 bg-primary-50' : 'text-slate-600'"
@click="toggleFavorite" :disabled="favLoading">
<i class="pi" :class="[favLoading ? 'pi-spinner pi-spin' : (isFavorited ? 'pi-bookmark-fill' : 'pi-bookmark')]"></i>
</button>
<button class="w-auto h-9 px-3 flex items-center justify-center rounded hover:bg-slate-100 transition-colors gap-1"
:class="isLiked ? 'text-red-500 bg-red-50' : 'text-slate-600'"
@click="toggleLike" :disabled="likeLoading">
<i class="pi" :class="[likeLoading ? 'pi-spinner pi-spin' : (isLiked ? 'pi-thumbs-up-fill' : 'pi-thumbs-up')]"></i>
<span class="text-xs font-bold">{{ content.likes }}</span>
</button>
</div>
</div>
</div>
<!-- Content Render -->
<div class="prose prose-slate max-w-none text-slate-800" :style="{ fontSize: fontSize + 'px' }">
<!-- Video Player (Trial) -->
<!-- Video Player -->
<div class="not-prose mb-8 rounded-xl overflow-hidden bg-black relative group aspect-video" v-if="content.type === 'video'">
<div class="absolute inset-0 flex items-center justify-center text-white" v-if="!isPlaying">
<button @click="isPlaying = true" class="w-16 h-16 bg-white/20 hover:bg-white/30 backdrop-blur rounded-full flex items-center justify-center transition-all">
<video v-if="isPlaying" :src="content.media_urls?.[0]?.url" controls autoplay class="w-full h-full object-contain bg-black"></video>
<div class="absolute inset-0 flex items-center justify-center text-white z-10" v-if="!isPlaying">
<img v-if="content.cover" :src="content.cover" class="absolute inset-0 w-full h-full object-cover opacity-50">
<button @click="isPlaying = true" class="w-16 h-16 bg-white/20 hover:bg-white/30 backdrop-blur rounded-full flex items-center justify-center transition-all relative z-20">
<i class="pi pi-play text-3xl ml-1"></i>
</button>
</div>
<!-- Mock Video UI -->
<div class="absolute bottom-0 left-0 w-full p-4 bg-gradient-to-t from-black/80 to-transparent flex items-center gap-4 text-white">
<button class="text-white hover:text-primary-400"><i class="pi pi-play-circle text-2xl"></i></button>
<div class="flex-1 h-1.5 bg-white/30 rounded-full relative overflow-hidden group/bar cursor-pointer">
<!-- Trial Segment (Highlight) -->
<div class="absolute top-0 left-0 h-full bg-primary-500 w-[30%]"></div>
<!-- Locked Segment -->
<div class="absolute top-0 right-0 h-full bg-slate-500/50 w-[70%] flex items-center justify-center">
<i class="pi pi-lock text-[10px]"></i>
</div>
</div>
<span class="text-xs font-mono">01:30 / 05:00</span>
</div>
<!-- Paywall Overlay -->
<div v-if="!content.is_purchased && content.price > 0" class="absolute inset-0 bg-black/80 backdrop-blur-sm flex flex-col items-center justify-center text-center p-8 z-20">
<div v-if="!content.is_purchased && content.price > 0 && !isPlaying" class="absolute inset-0 bg-black/80 backdrop-blur-sm flex flex-col items-center justify-center text-center p-8 z-30">
<h3 class="text-white text-xl font-bold mb-2">试看已结束</h3>
<p class="text-slate-300 text-sm mb-6">购买后可观看完整内容 (包含高清视频 + 完整剧本)</p>
<button class="px-8 py-3 bg-primary-600 text-white rounded-full font-bold hover:bg-primary-700 shadow-lg shadow-primary-900/50 transition-transform hover:scale-105 active:scale-95">
<button @click="handlePurchase" class="px-8 py-3 bg-primary-600 text-white rounded-full font-bold hover:bg-primary-700 shadow-lg shadow-primary-900/50 transition-transform hover:scale-105 active:scale-95">
¥ {{ content.price }} 立即解锁
</button>
</div>
@@ -78,7 +80,7 @@
<div class="relative mt-8 pt-20 pb-8 text-center" v-if="!content.is_purchased && content.price > 0">
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-white/90 to-white z-10"></div>
<div class="relative z-20">
<button class="px-10 py-3.5 bg-primary-600 text-white rounded-full font-bold text-lg hover:bg-primary-700 shadow-xl shadow-primary-100 transition-transform hover:scale-105 active:scale-95 flex items-center gap-2 mx-auto">
<button @click="handlePurchase" class="px-10 py-3.5 bg-primary-600 text-white rounded-full font-bold text-lg hover:bg-primary-700 shadow-xl shadow-primary-100 transition-transform hover:scale-105 active:scale-95 flex items-center gap-2 mx-auto">
<i class="pi pi-lock"></i> 购买解锁全文 ¥ {{ content.price }}
</button>
<p class="text-xs text-slate-400 mt-3">支持微信 / 支付宝</p>
@@ -93,7 +95,19 @@
<h2 class="text-xl font-bold text-slate-900">全部评论 ({{ comments.length }})</h2>
</div>
<!-- ... Comment Input ... -->
<!-- Comment Input -->
<div class="flex items-start gap-4 mb-8">
<img src="https://api.dicebear.com/7.x/initials/svg?seed=User" class="w-10 h-10 rounded-full bg-slate-100">
<div class="flex-1">
<textarea v-model="newComment" placeholder="发表你的看法..." rows="3" class="w-full border border-slate-200 rounded-lg p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-100 transition-all"></textarea>
<div class="flex justify-end mt-2">
<button @click="submitComment" :disabled="!newComment || commentLoading" class="px-6 py-2 bg-primary-600 text-white rounded-lg font-bold text-sm hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i v-if="commentLoading" class="pi pi-spin pi-spinner mr-1" style="font-size: 0.8rem"></i>
发布评论
</button>
</div>
</div>
</div>
<div class="space-y-6">
<div v-for="comment in comments" :key="comment.id" class="flex gap-4">
@@ -104,6 +118,15 @@
<span class="text-xs text-slate-400">{{ comment.create_time }}</span>
</div>
<p class="text-slate-700 text-sm leading-relaxed mb-2">{{ comment.content }}</p>
<div class="flex items-center gap-4 text-xs text-slate-400">
<button @click="likeComment(comment)" :disabled="comment.loading"
class="flex items-center gap-1 hover:text-red-500 transition-colors"
:class="{ 'text-red-500': comment.is_liked }">
<i class="pi" :class="[comment.loading ? 'pi-spinner pi-spin' : (comment.is_liked ? 'pi-thumbs-up-fill' : 'pi-thumbs-up')]"></i>
<span>{{ comment.likes || 0 }}</span>
</button>
<button class="hover:text-slate-700">回复</button>
</div>
</div>
</div>
</div>
@@ -119,8 +142,13 @@
</div>
<h3 class="font-bold text-slate-900 text-lg">{{ content.author_name }}</h3>
<div class="grid grid-cols-2 gap-3 mt-4">
<button class="py-2 bg-primary-600 text-white rounded-lg font-bold hover:bg-primary-700 shadow-sm shadow-primary-200">关注</button>
<button class="py-2 border border-slate-200 text-slate-700 rounded-lg font-bold hover:bg-slate-50">私信</button>
<button @click="toggleAuthorFollow" :disabled="authorFollowLoading"
class="py-2 rounded-lg font-bold transition-colors flex items-center justify-center gap-1"
:class="[authorIsFollowing ? 'bg-slate-100 text-slate-600 hover:bg-slate-200' : 'bg-primary-600 text-white hover:bg-primary-700 shadow-sm shadow-primary-200']">
<i class="pi" :class="[authorFollowLoading ? 'pi-spin pi-spinner' : (authorIsFollowing ? 'pi-check' : 'pi-plus')]"></i>
{{ authorIsFollowing ? '已关注' : '关注' }}
</button>
<button class="py-2 border border-slate-200 text-slate-700 rounded-lg font-bold hover:bg-slate-50" @click="toast.add({ severity: 'info', summary: '开发中' })">私信</button>
</div>
</div>
</div>
@@ -131,23 +159,157 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { contentApi } from '../../api/content';
import { tenantApi } from '../../api/tenant';
const route = useRoute();
const toast = useToast();
const confirm = useConfirm();
const fontSize = ref(18);
const isPlaying = ref(false);
const content = ref({});
const comments = ref([]);
const isLiked = ref(false);
const isFavorited = ref(false);
const likeLoading = ref(false);
const favLoading = ref(false);
const newComment = ref('');
const commentLoading = ref(false);
const listComments = async () => {
try {
const cmts = await contentApi.listComments(route.params.id);
comments.value = cmts.items || [];
} catch(e) {
console.error('Failed to load comments', e);
}
};
onMounted(async () => {
try {
const res = await contentApi.get(route.params.id);
content.value = res;
// Fetch comments if needed
// const cmts = await contentApi.listComments(route.params.id);
// comments.value = cmts.items || [];
isLiked.value = res.is_liked;
isFavorited.value = res.is_favorited;
listComments(); // Fetch comments
} catch (e) {
console.error(e);
}
});
const submitComment = async () => {
if (!newComment.value || commentLoading.value) return;
commentLoading.value = true;
try {
await contentApi.createComment(content.value.id, { content: newComment.value });
toast.add({ severity: 'success', summary: '评论成功', life: 2000 });
newComment.value = '';
listComments(); // Refresh comments
} catch (e) {
toast.add({ severity: 'error', summary: '评论失败', detail: e.message, life: 3000 });
} finally {
commentLoading.value = false;
}
};
const toggleLike = async () => {
if (likeLoading.value) return;
likeLoading.value = true;
try {
if (isLiked.value) {
await contentApi.removeLike(content.value.id);
isLiked.value = false;
content.value.likes--;
} else {
await contentApi.addLike(content.value.id);
isLiked.value = true;
content.value.likes++;
toast.add({ severity: 'success', summary: '点赞成功', life: 1000 });
}
} catch (e) {
toast.add({ severity: 'error', summary: '操作失败', detail: e.message, life: 3000 });
} finally {
likeLoading.value = false;
}
};
const toggleFavorite = async () => {
if (favLoading.value) return;
favLoading.value = true;
try {
if (isFavorited.value) {
await contentApi.removeFavorite(content.value.id);
isFavorited.value = false;
toast.add({ severity: 'info', summary: '取消收藏', life: 1000 });
} else {
await contentApi.addFavorite(content.value.id);
isFavorited.value = true;
toast.add({ severity: 'success', summary: '收藏成功', life: 1000 });
}
} catch (e) {
toast.add({ severity: 'error', summary: '操作失败', detail: e.message, life: 3000 });
} finally {
favLoading.value = false;
}
};
const handlePurchase = () => {
confirm.require({
message: `确认支付 ¥${content.value.price} 购买此内容?`,
header: '购买确认',
icon: 'pi pi-shopping-cart',
accept: () => {
// Mock purchase flow
toast.add({ severity: 'success', summary: '购买成功', detail: '已解锁完整内容', life: 2000 });
content.value.is_purchased = true;
}
});
};
const authorIsFollowing = ref(false);
const authorFollowLoading = ref(false);
const toggleAuthorFollow = async () => {
if (authorFollowLoading.value) return;
authorFollowLoading.value = true;
try {
if (authorIsFollowing.value) {
await tenantApi.unfollow(content.value.author_id);
authorIsFollowing.value = false;
} else {
await tenantApi.follow(content.value.author_id);
authorIsFollowing.value = true;
}
} catch(e) {
toast.add({ severity: 'error', summary: '操作失败', life: 2000 });
} finally {
authorFollowLoading.value = false;
}
};
const likeComment = async (comment) => {
if (comment.loading) return;
comment.loading = true;
try {
await contentApi.likeComment(comment.id);
// Note: The backend doesn't support unliking comments via the same endpoint.
// This is an optimistic update.
if (comment.is_liked) {
// This part might not work if backend doesn't support unliking
// comment.likes--;
// comment.is_liked = false;
} else {
comment.likes++;
comment.is_liked = true;
}
} catch (e) {
toast.add({ severity: 'error', summary: '点赞失败', detail: e.message, life: 3000 });
} finally {
comment.loading = false;
}
};
</script>