feat: 添加点赞和收藏功能,优化内容详情视图和评论交互
This commit is contained in:
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user