feat:前端部分
This commit is contained in:
157
frontend/src/views/HotView.vue
Normal file
157
frontend/src/views/HotView.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
|
||||
import { ApiError } from '../api/client'
|
||||
import * as feedApi from '../api/feed'
|
||||
import * as likeApi from '../api/like'
|
||||
import type { FeedVideoItem } from '../api/types'
|
||||
import AppShell from '../components/AppShell.vue'
|
||||
import FeedVideoCard from '../components/FeedVideoCard.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const canLike = computed(() => auth.isLoggedIn)
|
||||
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
error: '',
|
||||
items: [] as FeedVideoItem[],
|
||||
hasMore: false,
|
||||
limit: 10,
|
||||
asOf: 0,
|
||||
nextOffset: 0,
|
||||
})
|
||||
|
||||
const likeBusy = reactive<Record<string, boolean>>({})
|
||||
|
||||
async function loadHot(reset: boolean) {
|
||||
if (state.loading) return
|
||||
state.loading = true
|
||||
state.error = ''
|
||||
try {
|
||||
const res = await feedApi.listByPopularity({
|
||||
limit: state.limit,
|
||||
as_of: reset ? 0 : state.asOf,
|
||||
offset: reset ? 0 : state.nextOffset,
|
||||
})
|
||||
state.hasMore = res.has_more
|
||||
state.asOf = res.as_of
|
||||
state.nextOffset = res.next_offset
|
||||
state.items = reset ? res.video_list : state.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
state.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLike(item: FeedVideoItem) {
|
||||
if (!auth.isLoggedIn) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadHot(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content: space-between; align-items: baseline">
|
||||
<div>
|
||||
<p class="title" style="margin: 0">热榜</p>
|
||||
<p class="subtle" style="margin: 6px 0 0">按热度排序(/feed/listByPopularity)</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label class="subtle" style="margin: 0">limit</label>
|
||||
<input v-model.number="state.limit" type="number" min="1" max="50" style="width: 90px" :disabled="state.loading" />
|
||||
<button class="primary" type="button" :disabled="state.loading" @click="loadHot(true)">刷新</button>
|
||||
<button type="button" :disabled="state.loading || !state.hasMore" @click="loadHot(false)">加载更多</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="state.error" class="pill bad" style="margin-top: 12px">错误:{{ state.error }}</div>
|
||||
<div v-else-if="state.loading && state.items.length === 0" class="subtle" style="margin-top: 12px">加载中…</div>
|
||||
<div v-else-if="state.items.length === 0" class="subtle" style="margin-top: 12px">暂无内容</div>
|
||||
|
||||
<div v-if="state.items.length" class="rank-list" style="margin-top: 14px">
|
||||
<div v-for="(item, idx) in state.items" :key="`hot-${item.id}`" class="rank-row">
|
||||
<div class="rank-num" :class="idx < 3 ? 'top' : ''">{{ idx + 1 }}</div>
|
||||
<FeedVideoCard
|
||||
:item="item"
|
||||
:can-like="canLike"
|
||||
:busy="!!likeBusy[String(item.id)]"
|
||||
@toggle-like="toggleLike"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rank-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rank-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.rank-num {
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.rank-num.top {
|
||||
border-color: rgba(254, 44, 85, 0.55);
|
||||
background: rgba(254, 44, 85, 0.18);
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.rank-row {
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.rank-num {
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user