diff --git a/backend/internal/video/video_entity.go b/backend/internal/video/video_entity.go index 67bf022..d0d0b5a 100644 --- a/backend/internal/video/video_entity.go +++ b/backend/internal/video/video_entity.go @@ -14,7 +14,6 @@ type Video struct { LikesCount int64 `gorm:"column:likes_count;not null;default:0;index:idx_videos_likes_count_id,priority:1,sort:desc" json:"likes_count"` Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"` } -} type PublishVideoRequest struct { Title string `json:"title"` diff --git a/frontend/src/components/CommentDrawer.vue b/frontend/src/components/CommentDrawer.vue new file mode 100644 index 0000000..4a0954e --- /dev/null +++ b/frontend/src/components/CommentDrawer.vue @@ -0,0 +1,153 @@ + + + + + + + {{ video?.title ?? '评论' }} + × + + + + 加载中… + {{ drawer.error }} + 暂无评论 + + + + {{ c.username }} + #{{ c.id }} · {{ new Date(c.created_at).toLocaleString() }} + + {{ c.content }} + + 删除 + + + + + + + + 刷新 + 发送 + + + + + + + diff --git a/frontend/src/composables/useLikeFollow.ts b/frontend/src/composables/useLikeFollow.ts new file mode 100644 index 0000000..ed263c3 --- /dev/null +++ b/frontend/src/composables/useLikeFollow.ts @@ -0,0 +1,67 @@ +import { reactive } from 'vue' +import { ApiError } from '../api/client' +import * as likeApi from '../api/like' +import type { FeedVideoItem } from '../api/types' +import { useAuthStore } from '../stores/auth' +import { useSocialStore } from '../stores/social' +import { useToastStore } from '../stores/toast' + +export function useLikeFollow(needLogin: () => void) { + const auth = useAuthStore() + const social = useSocialStore() + const toast = useToastStore() + + const likeBusy = reactive>({}) + const followBusy = reactive>({}) + + async function toggleLike(item: FeedVideoItem) { + if (!auth.isLoggedIn) return needLogin() + const key = String(item.id) + if (likeBusy[key]) return + likeBusy[key] = true + try { + if (item.is_liked) await likeApi.unlike(item.id) + else await likeApi.like(item.id) + item.is_liked = !item.is_liked + item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1)) + } catch (e) { + const msg = e instanceof ApiError ? e.message : String(e) + toast.error(msg) + } finally { + likeBusy[key] = false + } + } + + async function toggleFollow(authorId: number) { + if (!auth.isLoggedIn) return needLogin() + const key = String(authorId) + if (followBusy[key]) return + followBusy[key] = true + try { + if (social.isFollowing(authorId)) { + await social.unfollow(authorId) + toast.info('已取关') + } else { + await social.follow(authorId) + toast.success('已关注') + } + } catch (e) { + const msg = e instanceof ApiError ? e.message : String(e) + toast.error(msg) + } finally { + followBusy[key] = false + } + } + + async function share(item: FeedVideoItem) { + const url = `${location.origin}/video/${item.id}` + try { + await navigator.clipboard.writeText(url) + toast.success('链接已复制') + } catch { + window.prompt('复制链接', url) + } + } + + return { likeBusy, followBusy, toggleLike, toggleFollow, share } +} diff --git a/frontend/src/composables/useVideoFeed.ts b/frontend/src/composables/useVideoFeed.ts new file mode 100644 index 0000000..b5e227b --- /dev/null +++ b/frontend/src/composables/useVideoFeed.ts @@ -0,0 +1,112 @@ +import { computed, reactive, ref } from 'vue' +import { ApiError } from '../api/client' +import * as feedApi from '../api/feed' +import type { FeedVideoItem } from '../api/types' +import { useAuthStore } from '../stores/auth' + +export type TabKey = 'recommend' | 'hot' | 'following' + +export function useVideoFeed() { + const auth = useAuthStore() + const tab = ref('recommend') + + const recommend = reactive({ + items: [] as FeedVideoItem[], + loading: false, error: '', + hasMore: false, nextTime: 0, + }) + + const hot = reactive({ + items: [] as FeedVideoItem[], + loading: false, error: '', + hasMore: false, + nextLikesCountBefore: undefined as number | undefined, + nextIdBefore: undefined as number | undefined, + }) + + const following = reactive({ + items: [] as FeedVideoItem[], + loading: false, error: '', + hasMore: false, nextTime: 0, + }) + + const currentState = computed(() => { + if (tab.value === 'hot') return hot + if (tab.value === 'following') return following + return recommend + }) + + async function loadRecommend(reset: boolean) { + if (recommend.loading) return + recommend.loading = true + recommend.error = '' + try { + const res = await feedApi.listLatest({ limit: 10, latest_time: reset ? 0 : recommend.nextTime }) + recommend.hasMore = res.has_more + recommend.nextTime = res.next_time + recommend.items = reset ? res.video_list : recommend.items.concat(res.video_list) + } catch (e) { + recommend.error = e instanceof ApiError ? e.message : String(e) + } finally { + recommend.loading = false + } + } + + async function loadHot(reset: boolean) { + if (hot.loading) return + hot.loading = true + hot.error = '' + try { + const res = await feedApi.listLikesCount({ + limit: 10, + likes_count_before: reset ? undefined : hot.nextLikesCountBefore, + id_before: reset ? undefined : hot.nextIdBefore, + }) + hot.hasMore = res.has_more + hot.nextLikesCountBefore = res.next_likes_count_before + hot.nextIdBefore = res.next_id_before + hot.items = reset ? res.video_list : hot.items.concat(res.video_list) + } catch (e) { + hot.error = e instanceof ApiError ? e.message : String(e) + } finally { + hot.loading = false + } + } + + async function loadFollowing(reset: boolean) { + if (!auth.isLoggedIn) { + following.error = '登录后才能查看关注流' + return + } + if (following.loading) return + following.loading = true + following.error = '' + try { + const res = await feedApi.listByFollowing({ limit: 10, latest_time: reset ? 0 : following.nextTime }) + following.hasMore = res.has_more + following.nextTime = res.next_time + following.items = reset ? res.video_list : following.items.concat(res.video_list) + } catch (e) { + following.error = e instanceof ApiError ? e.message : String(e) + } finally { + following.loading = false + } + } + + async function ensureTabLoaded() { + if (tab.value === 'recommend' && recommend.items.length === 0) await loadRecommend(true) + if (tab.value === 'hot' && hot.items.length === 0) await loadHot(true) + if (tab.value === 'following' && following.items.length === 0) await loadFollowing(true) + } + + async function loadMoreIfNeeded(activeIndex: number) { + const items = currentState.value.items + if (items.length === 0) return + if (activeIndex < items.length - 3) return + if (tab.value === 'recommend' && recommend.hasMore) await loadRecommend(false) + if (tab.value === 'hot' && hot.hasMore) await loadHot(false) + if (tab.value === 'following' && following.hasMore) await loadFollowing(false) + } + + return { tab, recommend, hot, following, currentState, loadRecommend, loadHot, loadFollowing, ensureTabLoaded, loadMoreIfNeeded } +} diff --git a/frontend/src/composables/useVideoPlayer.ts b/frontend/src/composables/useVideoPlayer.ts new file mode 100644 index 0000000..39a4607 --- /dev/null +++ b/frontend/src/composables/useVideoPlayer.ts @@ -0,0 +1,78 @@ +import { ref } from 'vue' +import { useToastStore } from '../stores/toast' + +export function useVideoPlayer(scrollerRef: ReturnType>) { + const toast = useToastStore() + const muted = ref(true) + const activeIndex = ref(0) + const videoMap = new Map() + + function getScrollerHeight() { + return scrollerRef.value?.clientHeight ?? 0 + } + + function setVideoRef(id: number, el: HTMLVideoElement | null) { + if (el) { + el.muted = muted.value + videoMap.set(id, el) + } else { + videoMap.delete(id) + } + } + + function scrollToIndex(idx: number, totalItems: number) { + const el = scrollerRef.value + if (!el) return + const h = getScrollerHeight() + if (!h) return + const next = Math.max(0, Math.min(idx, Math.max(0, totalItems - 1))) + el.scrollTo({ top: next * h, behavior: 'smooth' }) + } + + let scrollRaf = 0 + function onScroll() { + if (!scrollerRef.value) return + if (scrollRaf) return + scrollRaf = window.requestAnimationFrame(() => { + scrollRaf = 0 + const el = scrollerRef.value + if (!el) return + const h = el.clientHeight + if (!h) return + const idx = Math.round(el.scrollTop / h) + if (idx !== activeIndex.value) activeIndex.value = idx + }) + } + + async function playActive(activeItemId: number | undefined) { + if (!activeItemId) return + for (const [id, v] of videoMap.entries()) { + if (id === activeItemId) continue + v.pause() + } + const video = videoMap.get(activeItemId) + if (!video) return + video.muted = muted.value + try { + await video.play() + } catch { + /* ignore autoplay errors */ + } + } + + function toggleMute() { + muted.value = !muted.value + for (const v of videoMap.values()) v.muted = muted.value + toast.info(muted.value ? '已静音' : '已取消静音') + } + + function togglePlayPause(activeItemId: number | undefined) { + if (!activeItemId) return + const video = videoMap.get(activeItemId) + if (!video) return + if (video.paused) void video.play() + else video.pause() + } + + return { muted, activeIndex, videoMap, setVideoRef, scrollToIndex, onScroll, playActive, toggleMute, togglePlayPause } +} diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index ccb3065..367ecb9 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,924 +1,215 @@ - - - - - - - 推荐 - 关注 - 点赞榜 - - - {{ muted ? '静音' : '有声' }} - 详情 - - - - - 加载中… - - {{ currentState.error }} - - 没有匹配内容 - - - - - - - - - - @{{ item.author.username }} - - {{ item.title }} - {{ item.description }} - - - - - ♥ - {{ item.likes_count }} - - - - 💬 - 评论 - - - - + - {{ social.isFollowing(item.author.id) ? '已关注' : '关注' }} - - - - ↗ - 分享 - - - - - ↑ ↓ 切换 - 空格 暂停 - M 静音 - C 评论 - - - - - - - - - {{ drawer.video?.title ?? '评论' }} - × - - - - 加载中… - {{ drawer.error }} - 暂无评论 - - - - {{ c.username }} - - #{{ c.id }} · {{ new Date(c.created_at).toLocaleString() }} - - - {{ c.content }} - - - 删除 - - - - - - - - - 刷新 - - 发送 - - - - - - - - - - + + + + + + + 推荐 + 关注 + 点赞榜 + + {{ muted ? '静音' : '有声' }} + 详情 + + + + + 加载中… + {{ currentState.error }} + 没有匹配内容 + + + + + + + + + @{{ item.author.username }} + + {{ item.title }} + {{ item.description }} + + + + ♥ + {{ item.likes_count }} + + + 💬 + 评论 + + + + + {{ social.isFollowing(item.author.id) ? '已关注' : '关注' }} + + + ↗ + 分享 + + + + ↑ ↓ 切换 + 空格 暂停 + M 静音 + C 评论 + + + + + + + + + + +