refactor(project): Reorganize frontend and backend directories

This commit is contained in:
2026-03-18 10:45:15 +08:00
parent cc9eaf4cdf
commit 92a139e470
513 changed files with 28251 additions and 287 deletions
@@ -0,0 +1,38 @@
<template>
<div class="not-found">
<div class="content">
<h1>404</h1>
<p>页面未找到</p>
<van-button type="primary" to="/">返回首页</van-button>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.not-found {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
.content {
text-align: center;
h1 {
font-size: 120px;
color: #00a1d6;
margin: 0;
}
p {
font-size: 24px;
color: #666;
margin: 20px 0 30px;
}
}
}
</style>
@@ -0,0 +1,94 @@
<template>
<div class="favorite-page">
<van-nav-bar title="我的收藏" fixed placeholder left-arrow @click-left="router.back()" />
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="favorites.length" inset>
<van-cell
v-for="fav in favorites"
:key="fav.id"
:title="fav.name"
:label="`${fav.videoCount}个视频`"
is-link
@click="goToFavorite(fav.id)"
>
<template #icon>
<van-icon name="star" class="fav-icon" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无收藏夹" />
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
const router = useRouter()
const favorites = ref<Favorite[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const loadFavorites = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
favorites.value = []
finished.value = false
}
loading.value = true
try {
const data = await favoriteApi.getList()
favorites.value = data
finished.value = true
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadFavorites()
}
const onRefresh = () => {
loadFavorites(true)
}
const goToFavorite = (id: number) => {
router.push(`/favorite/${id}`)
}
onMounted(() => {
loadFavorites()
})
</script>
<style scoped lang="scss">
.favorite-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.fav-icon {
color: #f7ba2a;
margin-right: 8px;
font-size: 20px;
}
}
</style>
@@ -0,0 +1,146 @@
<template>
<div class="category-page">
<van-nav-bar title="分类" fixed placeholder />
<div class="category-content">
<van-sidebar v-model="activeCategory">
<van-sidebar-item
v-for="cat in categories"
:key="cat.id"
:title="cat.name"
/>
</van-sidebar>
<div class="video-list">
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<div class="video-grid">
<VideoCard
v-for="video in videos"
:key="video.id"
:video="video"
@click="goToVideo(video.id)"
/>
</div>
</van-list>
</van-pull-refresh>
</div>
</div>
<AppTabbar />
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import AppTabbar from '@/components/layout/AppTabbar.vue'
import { categoryApi, type Category } from '@/api/category.ts'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
const router = useRouter()
const categories = ref<Category[]>([])
const activeCategory = ref(0)
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const loadCategories = async () => {
try {
categories.value = await categoryApi.getList()
} catch (error) {
showToast('加载分类失败')
}
}
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
const category = categories.value[activeCategory.value]
if (!category) return
loading.value = true
try {
const res = await videoApi.getList({
page: currentPage.value,
size: 20,
categoryId: category.id
})
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
watch(activeCategory, () => {
loadVideos(true)
})
onMounted(() => {
loadCategories()
})
</script>
<style scoped lang="scss">
.category-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
}
.category-content {
display: flex;
height: calc(100vh - 46px);
.video-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
</style>
@@ -0,0 +1,205 @@
<template>
<div class="home-page">
<!-- 顶部导航 -->
<van-nav-bar title="HAOVP" fixed placeholder>
<template #right>
<van-icon name="search" size="22" @click="router.push('/search')" />
</template>
</van-nav-bar>
<!-- 分类导航 -->
<div class="category-nav">
<van-tabs v-model:active="activeCategory" sticky shrink @change="onCategoryChange">
<van-tab title="推荐" name="recommend" />
<van-tab title="热门" name="hot" />
<van-tab v-for="cat in categories" :key="cat.id" :title="cat.name" :name="cat.id" />
</van-tabs>
</div>
<!-- 视频列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
class="video-list"
>
<div class="video-grid">
<VideoCard
v-for="video in videos"
:key="video.id"
:video="video"
@click="goToVideo(video.id)"
/>
</div>
</van-list>
</van-pull-refresh>
<!-- 底部导航 -->
<AppTabbar />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import AppTabbar from '@/components/layout/AppTabbar.vue'
import { videoApi } from '@/api/video.ts'
import { categoryApi, type Category } from '@/api/category.ts'
import { recommendApi } from '@/api/recommend.ts'
import type { VideoInfo } from '@/types'
const router = useRouter()
// 分类
const categories = ref<Category[]>([])
const activeCategory = ref<string | number>('recommend')
// 视频列表
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const pageSize = 20
// 加载分类
const loadCategories = async () => {
try {
const res = await categoryApi.getList()
categories.value = res
} catch (error) {
console.error('加载分类失败:', error)
}
}
// 加载视频列表
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
loading.value = true
try {
let res
const params = {
page: currentPage.value,
size: pageSize,
...(typeof activeCategory.value === 'number' && { categoryId: activeCategory.value })
}
if (activeCategory.value === 'recommend') {
res = await recommendApi.getHome(params)
} else if (activeCategory.value === 'hot') {
res = await recommendApi.getHot(params)
} else {
res = await videoApi.getList(params)
}
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
// 判断是否还有更多
if (videos.value.length >= res.total || res.records.length < pageSize) {
finished.value = true
}
currentPage.value++
} catch (error: any) {
showToast(error.message || '加载失败')
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
// 加载更多
const loadMore = () => {
if (!finished.value) {
loadVideos()
}
}
// 下拉刷新
const onRefresh = () => {
loadVideos(true)
}
// 分类切换
const onCategoryChange = () => {
loadVideos(true)
}
// 跳转视频详情
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadCategories()
loadVideos()
})
</script>
<style scoped lang="scss">
.home-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
@media (min-width: 768px) {
padding-bottom: 0;
}
}
.category-nav {
background: #fff;
position: sticky;
top: 46px;
z-index: 100;
:deep(.van-tabs__nav) {
padding: 0 12px;
}
:deep(.van-tab) {
padding: 0 12px;
font-size: 14px;
}
:deep(.van-tabs__line) {
background: #00a1d6;
}
}
.video-list {
padding: 12px;
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
@media (min-width: 768px) {
grid-template-columns: repeat(4, 1fr);
}
@media (min-width: 1200px) {
grid-template-columns: repeat(5, 1fr);
}
@media (min-width: 1600px) {
grid-template-columns: repeat(6, 1fr);
}
}
</style>
@@ -0,0 +1,47 @@
<template>
<div class="main-layout">
<!-- 顶部导航 -->
<AppHeader />
<!-- 主内容区 -->
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<!-- 底部导航(移动端) -->
<AppTabbar />
</div>
</template>
<script setup lang="ts">
import AppHeader from '@/components/layout/AppHeader.vue'
import AppTabbar from '@/components/layout/AppTabbar.vue'
import { useAppStore } from '@/stores'
import { onMounted } from 'vue'
const appStore = useAppStore()
onMounted(() => {
appStore.initApp()
})
</script>
<style scoped lang="scss">
.main-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding-top: var(--header-height);
padding-bottom: 50px; // 移动端 tabbar 高度
@media (min-width: 768px) {
padding-bottom: 0;
}
}
</style>
@@ -0,0 +1,247 @@
<template>
<div class="login-page">
<div class="login-container">
<!-- Logo -->
<div class="login-header">
<h1 class="logo">HAOVP</h1>
<p class="slogan">高可用在线视频平台</p>
</div>
<!-- 登录表单 -->
<van-form @submit="handleLogin" class="login-form">
<!-- 验证码 -->
<div class="captcha-row">
<van-image
:src="captchaImage"
fit="contain"
class="captcha-image"
@click="refreshCaptcha"
>
<template v-slot:error>
<div class="captcha-error" @click="refreshCaptcha">点击刷新</div>
</template>
</van-image>
</div>
<van-cell-group inset>
<van-field
v-model="form.username"
name="username"
placeholder="用户名/邮箱/手机号"
:rules="[{ required: true, message: '请输入用户名' }]"
left-icon="user-o"
/>
<van-field
v-model="form.password"
type="password"
name="password"
placeholder="密码"
:rules="[{ required: true, message: '请输入密码' }]"
left-icon="lock"
/>
<van-field
v-model="form.captcha"
name="captcha"
placeholder="验证码"
:rules="[{ required: true, message: '请输入验证码' }]"
left-icon="shield-o"
>
<template #button>
<van-image
:src="captchaImage"
fit="contain"
class="captcha-btn"
@click="refreshCaptcha"
/>
</template>
</van-field>
</van-cell-group>
<div class="login-actions">
<van-button
round
block
type="primary"
native-type="submit"
:loading="loading"
color="#00a1d6"
>
登录
</van-button>
</div>
</van-form>
<!-- 其他登录方式 -->
<div class="login-footer">
<router-link to="/register" class="link">注册账号</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { authApi, type CaptchaResult } from '@/api/auth.ts'
import { useUserStore } from '@/stores/user.ts'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
const captchaKey = ref('')
const captchaImage = ref('')
const form = reactive({
username: '',
password: '',
captcha: ''
})
// 获取验证码
const refreshCaptcha = async () => {
try {
const res: CaptchaResult = await authApi.getCaptcha()
captchaKey.value = res.captchaKey
captchaImage.value = res.captchaImage
} catch (error) {
showToast('获取验证码失败')
}
}
// 登录
const handleLogin = async () => {
if (!form.username || !form.password || !form.captcha) {
return
}
loading.value = true
try {
const result = await authApi.login({
username: form.username,
password: form.password,
captcha: form.captcha,
captchaKey: captchaKey.value
})
// 保存登录信息
userStore.setToken(result.accessToken, result.refreshToken)
userStore.setUserInfo({
id: result.userId,
username: result.username
})
showSuccessToast('登录成功')
// 跳转到之前的页面或首页
const redirect = route.query.redirect as string
router.replace(redirect || '/')
} catch (error: any) {
showToast(error.message || '登录失败')
refreshCaptcha()
} finally {
loading.value = false
}
}
onMounted(() => {
refreshCaptcha()
})
</script>
<style scoped lang="scss">
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
width: 100%;
max-width: 400px;
background: #fff;
border-radius: 16px;
padding: 40px 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.login-header {
text-align: center;
margin-bottom: 32px;
.logo {
font-size: 36px;
font-weight: bold;
color: #00a1d6;
margin: 0;
}
.slogan {
color: #999;
font-size: 14px;
margin-top: 8px;
}
}
.captcha-row {
display: flex;
justify-content: center;
margin-bottom: 16px;
.captcha-image {
height: 40px;
cursor: pointer;
}
.captcha-error {
width: 100px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
color: #999;
font-size: 12px;
cursor: pointer;
}
}
.login-form {
:deep(.van-cell-group--inset) {
margin: 0;
}
:deep(.van-field) {
padding: 12px 16px;
}
.captcha-btn {
height: 32px;
cursor: pointer;
}
}
.login-actions {
margin-top: 24px;
}
.login-footer {
text-align: center;
margin-top: 24px;
.link {
color: #00a1d6;
font-size: 14px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
</style>
@@ -0,0 +1,196 @@
<template>
<div class="register-page">
<div class="register-container">
<div class="register-header">
<h1 class="logo">HAOVP</h1>
<p class="slogan">注册新账号</p>
</div>
<van-form @submit="handleRegister">
<van-cell-group inset>
<van-field
v-model="form.username"
name="username"
label="用户名"
placeholder="请输入用户名"
:rules="[{ required: true, message: '请输入用户名' }]"
/>
<van-field
v-model="form.email"
name="email"
label="邮箱"
placeholder="请输入邮箱"
:rules="[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '邮箱格式不正确' }]"
/>
<van-field
v-model="form.phone"
name="phone"
label="手机号"
placeholder="请输入手机号"
/>
<van-field
v-model="form.password"
type="password"
name="password"
label="密码"
placeholder="请输入密码"
:rules="[{ required: true, message: '请输入密码' }, { pattern: /^.{6,}$/, message: '密码至少6位' }]"
/>
<van-field
v-model="form.confirmPassword"
type="password"
name="confirmPassword"
label="确认密码"
placeholder="请再次输入密码"
:rules="[{ required: true, message: '请确认密码' }, { validator: validatePassword, message: '两次密码不一致' }]"
/>
<van-field
v-model="form.captcha"
name="captcha"
label="验证码"
placeholder="请输入验证码"
:rules="[{ required: true, message: '请输入验证码' }]"
>
<template #button>
<van-image :src="captchaImage" fit="contain" class="captcha-btn" @click="refreshCaptcha" />
</template>
</van-field>
</van-cell-group>
<div class="register-actions">
<van-button round block type="primary" native-type="submit" :loading="loading" color="#00a1d6">
注册
</van-button>
</div>
</van-form>
<div class="register-footer">
<router-link to="/login" class="link">已有账号?去登录</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { authApi, type CaptchaResult } from '@/api/auth.ts'
const router = useRouter()
const loading = ref(false)
const captchaKey = ref('')
const captchaImage = ref('')
const form = reactive({
username: '',
email: '',
phone: '',
password: '',
confirmPassword: '',
captcha: ''
})
const validatePassword = () => {
return form.password === form.confirmPassword
}
const refreshCaptcha = async () => {
try {
const res: CaptchaResult = await authApi.getCaptcha()
captchaKey.value = res.captchaKey
captchaImage.value = res.captchaImage
} catch (error) {
showToast('获取验证码失败')
}
}
const handleRegister = async () => {
if (!validatePassword()) {
showToast('两次密码不一致')
return
}
loading.value = true
try {
await authApi.register({
username: form.username,
email: form.email,
phone: form.phone,
password: form.password,
confirmPassword: form.confirmPassword,
captcha: form.captcha,
captchaKey: captchaKey.value
})
showSuccessToast('注册成功')
router.push('/login')
} catch (error: any) {
showToast(error.message || '注册失败')
refreshCaptcha()
} finally {
loading.value = false
}
}
onMounted(() => {
refreshCaptcha()
})
</script>
<style scoped lang="scss">
.register-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.register-container {
width: 100%;
max-width: 400px;
background: #fff;
border-radius: 16px;
padding: 30px 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.register-header {
text-align: center;
margin-bottom: 24px;
.logo {
font-size: 32px;
font-weight: bold;
color: #00a1d6;
margin: 0;
}
.slogan {
color: #999;
font-size: 14px;
margin-top: 8px;
}
}
.register-actions {
margin-top: 20px;
}
.register-footer {
text-align: center;
margin-top: 20px;
.link {
color: #00a1d6;
font-size: 14px;
text-decoration: none;
}
}
.captcha-btn {
height: 32px;
cursor: pointer;
}
</style>
@@ -0,0 +1,177 @@
<template>
<div class="message-detail-page">
<van-nav-bar title="聊天详情" fixed placeholder left-arrow @click-left="router.back()" />
<div class="message-list" ref="listRef">
<div
v-for="msg in messages"
:key="msg.id"
:class="['message-item', { 'is-self': msg.senderId === userStore.userId }]"
>
<van-image
v-if="msg.senderId !== userStore.userId"
:src="msg.senderAvatar || defaultAvatar"
round
width="36"
height="36"
class="avatar"
/>
<div class="message-content">
<div class="bubble">{{ msg.content }}</div>
<div class="time">{{ formatRelativeTime(msg.createTime) }}</div>
</div>
<van-image
v-if="msg.senderId === userStore.userId"
:src="userStore.userInfo?.avatar || defaultAvatar"
round
width="36"
height="36"
class="avatar"
/>
</div>
</div>
<div class="message-input">
<van-field
v-model="content"
placeholder="输入消息..."
:border="false"
@keyup.enter="sendMessage"
/>
<van-button type="primary" size="small" :loading="sending" @click="sendMessage">
发送
</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { userApi, type Message } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatRelativeTime } from '@/utils'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const targetId = Number(route.params.id)
const messages = ref<Message[]>([])
const content = ref('')
const sending = ref(false)
const listRef = ref<HTMLElement>()
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
const loadMessages = async () => {
try {
const data = await userApi.getMessageDetail(targetId)
messages.value = data
await nextTick()
scrollToBottom()
} catch (error) {
showToast('加载失败')
}
}
const sendMessage = async () => {
if (!content.value.trim()) return
sending.value = true
try {
const msg = await userApi.sendMessage({
receiverId: targetId,
content: content.value
})
messages.value.push(msg)
content.value = ''
await nextTick()
scrollToBottom()
} catch (error) {
showToast('发送失败')
} finally {
sending.value = false
}
}
const scrollToBottom = () => {
if (listRef.value) {
listRef.value.scrollTop = listRef.value.scrollHeight
}
}
onMounted(() => {
loadMessages()
})
</script>
<style scoped lang="scss">
.message-detail-page {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 12px;
.message-item {
display: flex;
gap: 8px;
margin-bottom: 12px;
&.is-self {
flex-direction: row-reverse;
.message-content {
align-items: flex-end;
}
.bubble {
background: #00a1d6;
color: #fff;
}
}
.avatar {
flex-shrink: 0;
}
.message-content {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 70%;
.bubble {
padding: 10px 14px;
background: #fff;
border-radius: 8px;
word-break: break-all;
}
.time {
font-size: 12px;
color: #999;
}
}
}
}
.message-input {
display: flex;
gap: 8px;
padding: 8px 12px;
background: #fff;
border-top: 1px solid #eee;
.van-field {
flex: 1;
}
}
</style>
@@ -0,0 +1,112 @@
<template>
<div class="message-page">
<van-nav-bar title="私信" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-icon name="plus" size="22" />
</template>
</van-nav-bar>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="conversations.length" inset>
<van-cell
v-for="conv in conversations"
:key="conv.targetId"
:title="conv.targetNickname"
:label="conv.lastMessage"
is-link
@click="goToDetail(conv.targetId)"
>
<template #icon>
<van-image :src="conv.targetAvatar || defaultAvatar" round width="40" height="40" />
</template>
<template #value>
<van-badge :content="conv.unreadCount" :show-zero="false">
<span class="time">{{ formatRelativeTime(conv.lastTime) }}</span>
</van-badge>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无私信" />
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { formatRelativeTime } from '@/utils'
interface Conversation {
targetId: number
targetNickname: string
targetAvatar: string
lastMessage: string
lastTime: string
unreadCount: number
}
const router = useRouter()
const conversations = ref<Conversation[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
const loadConversations = async (isRefresh = false) => {
if (isRefresh) {
conversations.value = []
finished.value = false
}
loading.value = true
try {
const data = await userApi.getMessageList()
conversations.value = data
finished.value = true
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadConversations()
}
const onRefresh = () => {
loadConversations(true)
}
const goToDetail = (targetId: number) => {
router.push(`/messages/${targetId}`)
}
onMounted(() => {
loadConversations()
})
</script>
<style scoped lang="scss">
.message-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.time {
font-size: 12px;
color: #999;
}
}
</style>
@@ -0,0 +1,117 @@
<template>
<div class="notifications-page">
<van-nav-bar title="通知" fixed placeholder left-arrow @click-left="router.back()" />
<van-tabs v-model:active="activeTab">
<van-tab title="全部" name="all" />
<van-tab title="点赞" name="like" />
<van-tab title="评论" name="comment" />
<van-tab title="关注" name="follow" />
<van-tab title="系统" name="system" />
</van-tabs>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="notifications.length" inset>
<van-cell
v-for="notif in notifications"
:key="notif.id"
:title="notif.content"
:label="formatRelativeTime(notif.createTime)"
>
<template #icon>
<van-icon :name="getIcon(notif.type)" :color="getColor(notif.type)" class="notif-icon" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无通知" />
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { userApi, type Notification } from '@/api/user.ts'
import { formatRelativeTime } from '@/utils'
const router = useRouter()
const activeTab = ref('all')
const notifications = ref<Notification[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const getIcon = (type: number) => {
const icons: Record<number, string> = {
1: 'good-job',
2: 'chat-o',
3: 'user-o',
4: 'bell'
}
return icons[type] || 'info-o'
}
const getColor = (type: number) => {
const colors: Record<number, string> = {
1: '#f56c6c',
2: '#e6a23c',
3: '#409eff',
4: '#909399'
}
return colors[type] || '#909399'
}
const loadNotifications = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
notifications.value = []
finished.value = false
}
loading.value = true
try {
const data = await userApi.getNotifications()
notifications.value = data
finished.value = true
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadNotifications()
}
const onRefresh = () => {
loadNotifications(true)
}
onMounted(() => {
loadNotifications()
})
</script>
<style scoped lang="scss">
.notifications-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.notif-icon {
font-size: 20px;
margin-right: 8px;
}
}
</style>
@@ -0,0 +1,130 @@
<template>
<div class="search-page">
<van-nav-bar title="搜索" fixed placeholder>
<template #right>
<van-icon name="search" size="22" @click="handleSearch" />
</template>
</van-nav-bar>
<div class="search-content">
<van-search
v-model="keyword"
placeholder="搜索视频、用户"
shape="round"
@search="handleSearch"
/>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<div class="video-grid">
<VideoCard
v-for="video in videos"
:key="video.id"
:video="video"
@click="goToVideo(video.id)"
/>
</div>
</van-list>
</van-pull-refresh>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
const router = useRouter()
const route = useRoute()
const keyword = ref('')
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const loadVideos = async (isRefresh = false) => {
if (!keyword.value.trim()) return
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
loading.value = true
try {
const res = await videoApi.getList({
page: currentPage.value,
size: 20,
keyword: keyword.value
})
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
showToast('搜索失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const handleSearch = () => {
loadVideos(true)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
if (route.query.keyword) {
keyword.value = route.query.keyword as string
loadVideos(true)
}
})
</script>
<style scoped lang="scss">
.search-page {
min-height: 100vh;
background: #f5f5f5;
}
.search-content {
padding: 12px;
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 12px;
}
}
</style>
@@ -0,0 +1,203 @@
<template>
<div class="upload-page">
<van-nav-bar title="上传视频" fixed placeholder left-arrow @click-left="router.back()" />
<van-form @submit="handleSubmit">
<van-cell-group inset>
<van-field
v-model="form.title"
name="title"
label="标题"
placeholder="请输入视频标题"
:rules="[{ required: true, message: '请输入标题' }]"
/>
<van-field
v-model="form.description"
name="description"
label="简介"
type="textarea"
rows="3"
autosize
placeholder="请输入视频简介"
/>
<van-field
v-model="categoryName"
is-link
readonly
name="category"
label="分类"
placeholder="请选择分类"
@click="showCategoryPicker = true"
/>
<van-field name="cover" label="封面">
<template #input>
<van-uploader
v-model="coverFile"
max-count="1"
accept="image/*"
:after-read="afterReadCover"
/>
</template>
</van-field>
</van-cell-group>
<div class="upload-area">
<van-uploader
v-model="videoFile"
max-count="1"
accept="video/*"
:after-read="afterReadVideo"
>
<van-button type="primary" block>选择视频文件</van-button>
</van-uploader>
<div v-if="uploadProgress > 0" class="progress">
<van-progress :percentage="uploadProgress" />
<span>上传中 {{ uploadProgress }}%</span>
</div>
</div>
<div class="submit-btn">
<van-button type="primary" block native-type="submit" :loading="submitting">
发布视频
</van-button>
</div>
</van-form>
<van-popup v-model:show="showCategoryPicker" position="bottom" round>
<van-picker
:columns="categories"
@confirm="onCategoryConfirm"
@cancel="showCategoryPicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { videoApi } from '@/api/video.ts'
import { categoryApi, type Category } from '@/api/category.ts'
const router = useRouter()
const form = reactive({
title: '',
description: '',
categoryId: 0
})
const categoryName = ref('')
const coverFile = ref<any[]>([])
const videoFile = ref<any[]>([])
const uploadProgress = ref(0)
const submitting = ref(false)
const showCategoryPicker = ref(false)
const categories = ref<{ text: string; value: number }[]>([])
const videoUrl = ref('')
const coverUrl = ref('')
const videoInfo = ref<any>(null)
const loadCategories = async () => {
try {
const data: Category[] = await categoryApi.getList()
categories.value = data.map((cat) => ({
text: cat.name,
value: cat.id
}))
} catch (error) {
console.error('加载分类失败:', error)
}
}
const onCategoryConfirm = ({ selectedOptions }: any) => {
const option = selectedOptions[0]
form.categoryId = option.value
categoryName.value = option.text
showCategoryPicker.value = false
}
const afterReadCover = async (file: any) => {
// TODO: 上传封面
}
const afterReadVideo = async (file: any) => {
if (!file.file) return
uploadProgress.value = 0
// TODO: 实际上传逻辑
try {
const result = await videoApi.upload(file.file, (percent) => {
uploadProgress.value = percent
})
videoUrl.value = result.videoUrl
coverUrl.value = result.coverUrl
} catch (error) {
showToast('上传失败')
}
}
const handleSubmit = async () => {
if (!videoUrl.value) {
showToast('请先上传视频')
return
}
if (!form.categoryId) {
showToast('请选择分类')
return
}
submitting.value = true
try {
await videoApi.create({
title: form.title,
description: form.description,
videoUrl: videoUrl.value,
coverUrl: coverUrl.value,
duration: videoInfo.value?.duration || 0,
width: videoInfo.value?.width || 0,
height: videoInfo.value?.height || 0,
format: videoInfo.value?.format || '',
size: videoInfo.value?.size || 0,
categoryId: form.categoryId
})
showSuccessToast('发布成功')
router.push('/my/videos')
} catch (error: any) {
showToast(error.message || '发布失败')
} finally {
submitting.value = false
}
}
onMounted(() => {
loadCategories()
})
</script>
<style scoped lang="scss">
.upload-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.upload-area {
padding: 16px;
background: #fff;
margin: 12px;
border-radius: 8px;
.progress {
margin-top: 12px;
text-align: center;
font-size: 14px;
color: #666;
}
}
.submit-btn {
padding: 16px;
}
}
</style>
@@ -0,0 +1,114 @@
<template>
<div class="follow-page">
<van-nav-bar title="粉丝" fixed placeholder left-arrow @click-left="router.back()" />
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group inset>
<van-cell v-for="user in users" :key="user.id" @click="goToProfile(user.id)">
<template #icon>
<van-image :src="user.avatar || defaultAvatar" round width="40" height="40" />
</template>
<template #title>
<span class="nickname">{{ user.nickname }}</span>
</template>
<template #label>
<span class="signature">{{ user.signature || '这个人很懒' }}</span>
</template>
<template #value>
<van-button size="small" :type="user.isFollowed ? 'default' : 'primary'" @click.stop="toggleFollow(user)">
{{ user.isFollowed ? '已关注' : '关注' }}
</van-button>
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="!loading && !users.length" description="暂无粉丝" />
</van-list>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi, type UserFollow } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const userId = Number(route.params.id)
const users = ref<UserFollow[]>([])
const loading = ref(false)
const finished = ref(false)
const currentPage = ref(1)
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
const loadUsers = async () => {
loading.value = true
try {
const data = await userApi.getFollowers(userId)
users.value = data
finished.value = true
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!finished.value) loadUsers()
}
const toggleFollow = async (user: UserFollow) => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
try {
if (user.isFollowed) {
await userApi.unfollow(user.id)
user.isFollowed = false
showSuccessToast('已取消关注')
} else {
await userApi.follow(user.id)
user.isFollowed = true
showSuccessToast('关注成功')
}
} catch (error) {
showToast('操作失败')
}
}
const goToProfile = (id: number) => {
router.push(`/user/${id}`)
}
onMounted(() => {
loadUsers()
})
</script>
<style scoped lang="scss">
.follow-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.nickname {
font-weight: 500;
}
.signature {
font-size: 12px;
color: #999;
}
}
</style>
@@ -0,0 +1,101 @@
<template>
<div class="follow-page">
<van-nav-bar title="关注" fixed placeholder left-arrow @click-left="router.back()" />
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group inset>
<van-cell v-for="user in users" :key="user.id" @click="goToProfile(user.id)">
<template #icon>
<van-image :src="user.avatar || defaultAvatar" round width="40" height="40" />
</template>
<template #title>
<span class="nickname">{{ user.nickname }}</span>
</template>
<template #label>
<span class="signature">{{ user.signature || '这个人很懒' }}</span>
</template>
<template #value>
<van-button size="small" type="default" @click.stop="unfollow(user)">
已关注
</van-button>
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="!loading && !users.length" description="暂无关注" />
</van-list>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi, type UserFollow } from '@/api/user.ts'
const router = useRouter()
const route = useRoute()
const userId = Number(route.params.id)
const users = ref<UserFollow[]>([])
const loading = ref(false)
const finished = ref(false)
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
const loadUsers = async () => {
loading.value = true
try {
const data = await userApi.getFollowing(userId)
users.value = data
finished.value = true
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!finished.value) loadUsers()
}
const unfollow = async (user: UserFollow) => {
try {
await userApi.unfollow(user.id)
users.value = users.value.filter((u) => u.id !== user.id)
showSuccessToast('已取消关注')
} catch (error) {
showToast('操作失败')
}
}
const goToProfile = (id: number) => {
router.push(`/user/${id}`)
}
onMounted(() => {
loadUsers()
})
</script>
<style scoped lang="scss">
.follow-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.nickname {
font-weight: 500;
}
.signature {
font-size: 12px;
color: #999;
}
}
</style>
@@ -0,0 +1,117 @@
<template>
<div class="history-page">
<van-nav-bar title="播放历史" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-icon name="delete-o" size="22" @click="clearHistory" />
</template>
</van-nav-bar>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="videos.length" inset>
<van-cell v-for="item in videos" :key="item.videoId" @click="goToVideo(item.videoId)">
<template #icon>
<van-image :src="item.coverUrl" width="80" height="45" fit="cover" radius="4" />
</template>
<template #title>
<div class="video-title">{{ item.title }}</div>
</template>
<template #label>
<div class="video-progress">
观看至 {{ formatDuration(item.progress) }} / {{ formatDuration(item.duration) }}
</div>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无播放记录" />
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showConfirmDialog } from 'vant'
import { formatDuration } from '@/utils'
interface HistoryItem {
videoId: number
title: string
coverUrl: string
progress: number
duration: number
}
const router = useRouter()
const videos = ref<HistoryItem[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
videos.value = []
finished.value = false
}
loading.value = true
// TODO: 实际从API获取播放历史
finished.value = true
loading.value = false
refreshing.value = false
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const clearHistory = async () => {
try {
await showConfirmDialog({
title: '提示',
message: '确定要清空播放历史吗?'
})
videos.value = []
showToast('已清空')
} catch {
// 取消
}
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadVideos(true)
})
</script>
<style scoped lang="scss">
.history-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.video-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-progress {
font-size: 12px;
color: #999;
}
}
</style>
@@ -0,0 +1,147 @@
<template>
<div class="my-videos-page">
<van-nav-bar title="我的视频" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-icon name="plus" size="22" @click="router.push('/upload')" />
</template>
</van-nav-bar>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="videos.length" inset>
<van-cell v-for="video in videos" :key="video.id" @click="goToVideo(video.id)">
<template #icon>
<van-image :src="video.coverUrl" width="80" height="45" fit="cover" radius="4" />
</template>
<template #title>
<div class="video-title">{{ video.title }}</div>
</template>
<template #label>
<div class="video-stats">
<span>{{ formatNumber(video.viewCount) }}播放</span>
<span>{{ formatNumber(video.likeCount) }}赞</span>
</div>
</template>
<template #value>
<van-tag :type="getStatusType(video.status)">{{ getStatusText(video.status) }}</van-tag>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无视频">
<van-button type="primary" round @click="router.push('/upload')">去上传</van-button>
</van-empty>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { videoApi } from '@/api/video.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatNumber } from '@/utils'
import type { VideoInfo } from '@/types'
const router = useRouter()
const userStore = useUserStore()
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const getStatusType = (status: number) => {
const types: Record<number, string> = {
0: 'warning',
1: 'success',
2: 'danger',
3: 'default'
}
return types[status] || 'default'
}
const getStatusText = (status: number) => {
const texts: Record<number, string> = {
0: '审核中',
1: '已发布',
2: '审核拒绝',
3: '已下架'
}
return texts[status] || '未知'
}
const loadVideos = async (isRefresh = false) => {
if (!userStore.userId) return
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
loading.value = true
try {
const res = await videoApi.getUserVideos(userStore.userId, { page: currentPage.value, size: 20 })
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadVideos(true)
})
</script>
<style scoped lang="scss">
.my-videos-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.video-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-stats {
display: flex;
gap: 12px;
font-size: 12px;
color: #999;
}
}
</style>
@@ -0,0 +1,147 @@
<template>
<div class="profile-edit-page">
<van-nav-bar title="编辑资料" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-button type="primary" size="small" :loading="saving" @click="handleSave">保存</van-button>
</template>
</van-nav-bar>
<van-form @submit="handleSave">
<van-cell-group inset>
<van-cell title="头像" is-link @click="chooseAvatar">
<template #value>
<van-image :src="form.avatar || defaultAvatar" round width="48" height="48" fit="cover" />
</template>
</van-cell>
<van-field v-model="form.nickname" name="nickname" label="昵称" placeholder="请输入昵称" />
<van-field v-model="form.signature" name="signature" label="签名" type="textarea" rows="2" autosize placeholder="介绍一下自己" maxlength="100" show-word-limit />
<van-field name="gender" label="性别">
<template #input>
<van-radio-group v-model="form.gender" direction="horizontal">
<van-radio :value="0">保密</van-radio>
<van-radio :value="1">男</van-radio>
<van-radio :value="2">女</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field v-model="form.birthday" name="birthday" label="生日" is-link readonly placeholder="选择生日" @click="showBirthdayPicker = true" />
<van-field v-model="form.region" name="region" label="地区" placeholder="请输入地区" />
</van-cell-group>
</van-form>
<van-popup v-model:show="showBirthdayPicker" position="bottom" round>
<van-date-picker v-model="selectedDate" title="选择生日" @confirm="onBirthdayConfirm" @cancel="showBirthdayPicker = false" />
</van-popup>
<input ref="avatarInput" type="file" accept="image/*" style="display: none" @change="onAvatarChange" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
const router = useRouter()
const userStore = useUserStore()
const saving = ref(false)
const showBirthdayPicker = ref(false)
const avatarInput = ref<HTMLInputElement>()
const selectedDate = ref(['2000', '01', '01'])
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
const form = reactive({
avatar: '',
nickname: '',
signature: '',
gender: 0,
birthday: '',
region: ''
})
const loadProfile = async () => {
try {
const profile = await userApi.getCurrentProfile()
form.avatar = profile.avatar || ''
form.nickname = profile.nickname || ''
form.signature = profile.signature || ''
form.gender = profile.gender || 0
form.birthday = profile.birthday || ''
form.region = profile.region || ''
if (form.birthday) {
selectedDate.value = form.birthday.split('-')
}
} catch (error) {
showToast('加载资料失败')
}
}
const chooseAvatar = () => {
avatarInput.value?.click()
}
const onAvatarChange = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
try {
const url = await userApi.uploadAvatar(file)
form.avatar = url
showSuccessToast('头像上传成功')
} catch (error) {
showToast('头像上传失败')
}
}
const onBirthdayConfirm = ({ selectedValues }: any) => {
form.birthday = selectedValues.join('-')
showBirthdayPicker.value = false
}
const handleSave = async () => {
if (!form.nickname.trim()) {
showToast('请输入昵称')
return
}
saving.value = true
try {
await userApi.updateProfile({
nickname: form.nickname,
signature: form.signature,
gender: form.gender,
birthday: form.birthday,
region: form.region
})
if (form.avatar) {
// 头像已单独上传
}
showSuccessToast('保存成功')
router.back()
} catch (error) {
showToast('保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
loadProfile()
})
</script>
<style scoped lang="scss">
.profile-edit-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.van-cell-group {
margin: 12px;
}
}
</style>
@@ -0,0 +1,222 @@
<template>
<div class="profile-page">
<van-nav-bar title="用户主页" fixed placeholder left-arrow @click-left="router.back()" />
<div v-if="loading" class="loading-container">
<van-loading size="24px">加载中...</van-loading>
</div>
<template v-else-if="userInfo">
<div class="user-header">
<div class="user-info">
<van-image :src="userInfo.avatar || defaultAvatar" round width="80" height="80" />
<div class="info">
<h2 class="nickname">{{ userInfo.nickname }}</h2>
<div class="level">Lv.{{ userInfo.level }}</div>
<div class="signature">{{ userInfo.signature || '这个人很懒,什么都没写' }}</div>
</div>
</div>
<div class="stats">
<div class="stat-item">
<div class="value">{{ userInfo.videoCount }}</div>
<div class="label">视频</div>
</div>
<div class="stat-item">
<div class="value">{{ userInfo.followerCount }}</div>
<div class="label">粉丝</div>
</div>
<div class="stat-item">
<div class="value">{{ userInfo.followingCount }}</div>
<div class="label">关注</div>
</div>
<div class="stat-item">
<div class="value">{{ userInfo.likeCount }}</div>
<div class="label">获赞</div>
</div>
</div>
<div class="actions">
<van-button v-if="!isSelf" :type="isFollowed ? 'default' : 'primary'" size="small" @click="toggleFollow">
{{ isFollowed ? '已关注' : '关注' }}
</van-button>
<van-button v-else type="primary" size="small" @click="router.push('/settings/profile')">
编辑资料
</van-button>
</div>
</div>
<van-tabs v-model:active="activeTab" sticky>
<van-tab title="视频" name="videos">
<div class="video-grid">
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
</div>
</van-tab>
<van-tab title="收藏" name="favorites">
<van-empty description="暂无收藏" />
</van-tab>
</van-tabs>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { userApi } from '@/api/user.ts'
import { videoApi } from '@/api/video.ts'
import { useUserStore } from '@/stores/user.ts'
import type { UserInfo, VideoInfo } from '@/types'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const userId = computed(() => Number(route.params.id))
const isSelf = computed(() => userStore.userId === userId.value)
const loading = ref(true)
const userInfo = ref<UserInfo | null>(null)
const isFollowed = ref(false)
const activeTab = ref('videos')
const videos = ref<VideoInfo[]>([])
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
const loadUserInfo = async () => {
try {
userInfo.value = await userApi.getPublicProfile(userId.value)
if (!isSelf.value && userStore.isLoggedIn) {
isFollowed.value = await userApi.checkFollow(userId.value)
}
} catch (error) {
showToast('加载用户信息失败')
}
}
const loadUserVideos = async () => {
try {
const res = await videoApi.getUserVideos(userId.value, { page: 1, size: 20 })
videos.value = res.records
} catch (error) {
console.error('加载视频失败:', error)
}
}
const toggleFollow = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
try {
if (isFollowed.value) {
await userApi.unfollow(userId.value)
isFollowed.value = false
showSuccessToast('已取消关注')
} else {
await userApi.follow(userId.value)
isFollowed.value = true
showSuccessToast('关注成功')
}
} catch (error) {
showToast('操作失败')
}
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(async () => {
loading.value = true
await Promise.all([loadUserInfo(), loadUserVideos()])
loading.value = false
})
</script>
<style scoped lang="scss">
.profile-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
}
.loading-container {
display: flex;
justify-content: center;
padding: 40px;
}
.user-header {
background: #fff;
padding: 20px;
.user-info {
display: flex;
gap: 16px;
margin-bottom: 16px;
.info {
flex: 1;
.nickname {
font-size: 18px;
font-weight: bold;
margin: 0 0 4px;
}
.level {
display: inline-block;
padding: 2px 8px;
background: linear-gradient(to right, #00a1d6, #fb7299);
color: #fff;
font-size: 12px;
border-radius: 10px;
margin-bottom: 8px;
}
.signature {
font-size: 14px;
color: #666;
}
}
}
.stats {
display: flex;
justify-content: space-around;
padding: 16px 0;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
.stat-item {
text-align: center;
.value {
font-size: 18px;
font-weight: bold;
}
.label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
}
}
.actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 16px;
}
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 12px;
}
</style>
@@ -0,0 +1,68 @@
<template>
<div class="settings-page">
<van-nav-bar title="设置" fixed placeholder left-arrow @click-left="router.back()" />
<van-cell-group inset>
<van-cell title="账号与安全" is-link to="/settings/profile" />
<van-cell title="隐私设置" is-link to="/settings/privacy" />
<van-cell title="通知设置" is-link to="/settings/notification" />
</van-cell-group>
<van-cell-group inset>
<van-cell title="清除缓存" :value="cacheSize" @click="clearCache" />
<van-cell title="关于我们" is-link to="/about" />
<van-cell title="版本号" value="1.0.0" />
</van-cell-group>
<div class="logout-btn">
<van-button type="danger" block @click="handleLogout">退出登录</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog, showToast, showSuccessToast } from 'vant'
import { useUserStore } from '@/stores/user.ts'
const router = useRouter()
const userStore = useUserStore()
const cacheSize = ref('0KB')
const clearCache = () => {
showToast('缓存已清除')
cacheSize.value = '0KB'
}
const handleLogout = async () => {
try {
await showConfirmDialog({
title: '提示',
message: '确定要退出登录吗?'
})
await userStore.logout()
showSuccessToast('已退出登录')
router.push('/')
} catch {
// 取消
}
}
</script>
<style scoped lang="scss">
.settings-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.van-cell-group {
margin: 12px;
}
.logout-btn {
padding: 20px 16px;
}
}
</style>
@@ -0,0 +1,95 @@
<template>
<div class="user-videos-page">
<van-nav-bar title="用户视频" fixed placeholder left-arrow @click-left="router.back()" />
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<div class="video-grid">
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
const router = useRouter()
const route = useRoute()
const userId = Number(route.params.id)
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
loading.value = true
try {
const res = await videoApi.getUserVideos(userId, { page: currentPage.value, size: 20 })
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadVideos(true)
})
</script>
<style scoped lang="scss">
.user-videos-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 12px;
}
</style>
@@ -0,0 +1,424 @@
<template>
<div class="video-detail-page">
<!-- 视频播放器 -->
<div class="player-container">
<VideoPlayer
v-if="video"
:src="video.videoUrl"
:poster="video.coverUrl"
:danmaku-list="danmakuList"
@timeupdate="onTimeUpdate"
@ended="onEnded"
/>
<van-skeleton v-else title :row="3" />
</div>
<!-- 视频信息 -->
<div class="video-info" v-if="video">
<h1 class="video-title">{{ video.title }}</h1>
<!-- 统计数据 -->
<div class="video-stats">
<span>{{ formatPlayCount(video.viewCount) }}播放</span>
<span>{{ formatNumber(video.danmakuCount) }}弹幕</span>
<span>{{ formatDateTime(video.publishTime || video.createTime) }}</span>
</div>
<!-- 操作按钮 -->
<div class="video-actions">
<van-button size="small" :type="video.isLiked ? 'primary' : 'default'" @click="toggleLike">
<van-icon :name="video.isLiked ? 'good-job' : 'good-job-o'" />
{{ formatNumber(video.likeCount) }}
</van-button>
<van-button size="small" :type="video.isCoined ? 'primary' : 'default'" @click="toggleCoin">
<van-icon name="coin" />
{{ formatNumber(video.coinCount) }}
</van-button>
<van-button size="small" :type="video.isFavorited ? 'primary' : 'default'" @click="toggleFavorite">
<van-icon :name="video.isFavorited ? 'star' : 'star-o'" />
{{ formatNumber(video.favoriteCount) }}
</van-button>
<van-button size="small" @click="shareVideo">
<van-icon name="share-o" />
分享
</van-button>
</div>
<!-- 作者信息 -->
<div class="author-info" @click="goToUser(video.userId)">
<van-image :src="video.userAvatar || defaultAvatar" round class="author-avatar" />
<div class="author-detail">
<div class="author-name">{{ video.userNickname }}</div>
<div class="author-fans">{{ formatNumber(video.likeCount) }}粉丝</div>
</div>
<van-button
size="small"
:type="isFollowed ? 'default' : 'primary'"
@click.stop="toggleFollow"
>
{{ isFollowed ? '已关注' : '关注' }}
</van-button>
</div>
<!-- 简介 -->
<div class="video-desc">
<van-collapse v-model="activeCollapse">
<van-collapse-item title="简介" name="desc">
<div class="desc-content">{{ video.description || '暂无简介' }}</div>
<div class="video-tags" v-if="video.tagNames?.length">
<van-tag v-for="tag in video.tagNames" :key="tag" plain>{{ tag }}</van-tag>
</div>
</van-collapse-item>
</van-collapse>
</div>
</div>
<!-- 标签页 -->
<van-tabs v-model:active="activeTab" sticky class="detail-tabs">
<!-- 评论 -->
<van-tab title="评论" name="comment">
<CommentSection v-if="video" :video-id="video.id" />
</van-tab>
<!-- 弹幕 -->
<van-tab title="弹幕" name="danmaku">
<DanmakuList v-if="video" :video-id="video.id" />
</van-tab>
<!-- 相关推荐 -->
<van-tab title="推荐" name="recommend">
<div class="recommend-list">
<VideoCard
v-for="item in recommendVideos"
:key="item.id"
:video="item"
@click="goToVideo(item.id)"
/>
</div>
</van-tab>
</van-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showSuccessToast, showConfirmDialog } from 'vant'
import VideoPlayer from '@/components/video/VideoPlayer.vue'
import CommentSection from '@/components/comment/CommentSection.vue'
import DanmakuList from '@/components/danmaku/DanmakuList.vue'
import VideoCard from '@/components/video/VideoCard.vue'
import { videoApi } from '@/api/video.ts'
import { danmakuApi, type Danmaku } from '@/api/danmaku.ts'
import { recommendApi } from '@/api/recommend.ts'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import type { VideoInfo } from '@/types'
import { formatDateTime, formatNumber, formatPlayCount } from '@/utils'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const videoId = computed(() => Number(route.params.id))
const video = ref<VideoInfo | null>(null)
const danmakuList = ref<Danmaku[]>([])
const recommendVideos = ref<VideoInfo[]>([])
const isFollowed = ref(false)
const activeTab = ref('comment')
const activeCollapse = ref<string[]>([])
const currentTime = ref(0)
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
// 加载视频详情
const loadVideo = async () => {
try {
video.value = await videoApi.getDetail(videoId.value)
// 记录播放
videoApi.recordPlay({
videoId: videoId.value,
progress: 0,
duration: video.value.duration
})
// 加载弹幕
loadDanmaku()
// 加载推荐
loadRecommend()
// 检查关注状态
if (userStore.isLoggedIn) {
checkFollowStatus()
}
} catch (error: any) {
showToast(error.message || '加载失败')
}
}
// 加载弹幕
const loadDanmaku = async () => {
try {
danmakuList.value = await danmakuApi.getAll(videoId.value)
} catch (error) {
console.error('加载弹幕失败:', error)
}
}
// 加载推荐
const loadRecommend = async () => {
try {
const res = await recommendApi.getSimilar(videoId.value, { page: 1, size: 10 })
recommendVideos.value = res.records
} catch (error) {
console.error('加载推荐失败:', error)
}
}
// 检查关注状态
const checkFollowStatus = async () => {
if (!video.value) return
try {
isFollowed.value = await userApi.checkFollow(video.value.userId)
} catch (error) {
console.error('检查关注状态失败:', error)
}
}
// 点赞
const toggleLike = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
if (!video.value) return
try {
if (video.value.isLiked) {
await videoApi.unlike(video.value.id)
video.value.likeCount--
video.value.isLiked = false
} else {
await videoApi.like(video.value.id)
video.value.likeCount++
video.value.isLiked = true
}
} catch (error: any) {
showToast(error.message || '操作失败')
}
}
// 投币
const toggleCoin = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
if (!video.value) return
try {
await videoApi.coin(video.value.id)
video.value.coinCount++
showSuccessToast('投币成功')
} catch (error: any) {
showToast(error.message || '投币失败')
}
}
// 收藏
const toggleFavorite = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
if (!video.value) return
try {
if (video.value.isFavorited) {
await videoApi.unfavorite(video.value.id)
video.value.favoriteCount--
video.value.isFavorited = false
} else {
// TODO: 选择收藏夹
await videoApi.favorite({ videoId: video.value.id, favoriteId: 0 })
video.value.favoriteCount++
video.value.isFavorited = true
}
} catch (error: any) {
showToast(error.message || '操作失败')
}
}
// 分享
const shareVideo = async () => {
const url = window.location.href
try {
await navigator.clipboard.writeText(url)
showSuccessToast('链接已复制')
videoApi.share(videoId.value)
} catch {
showToast('复制失败')
}
}
// 关注
const toggleFollow = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
if (!video.value) return
try {
if (isFollowed.value) {
await userApi.unfollow(video.value.userId)
isFollowed.value = false
showSuccessToast('已取消关注')
} else {
await userApi.follow(video.value.userId)
isFollowed.value = true
showSuccessToast('关注成功')
}
} catch (error: any) {
showToast(error.message || '操作失败')
}
}
// 跳转用户主页
const goToUser = (userId: number) => {
router.push(`/user/${userId}`)
}
// 跳转视频
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
// 播放时间更新
const onTimeUpdate = (time: number) => {
currentTime.value = time
}
// 播放结束
const onEnded = () => {
// 播放完成逻辑
}
onMounted(() => {
loadVideo()
})
</script>
<style scoped lang="scss">
.video-detail-page {
min-height: 100vh;
background: #f5f5f5;
}
.player-container {
width: 100%;
background: #000;
aspect-ratio: 16 / 9;
position: sticky;
top: 0;
z-index: 100;
@media (min-width: 768px) {
max-height: 70vh;
}
}
.video-info {
background: #fff;
padding: 12px 16px;
.video-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0 0 8px;
line-height: 1.4;
}
.video-stats {
display: flex;
gap: 16px;
font-size: 13px;
color: #999;
margin-bottom: 12px;
}
.video-actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
.van-button {
flex: 1;
}
}
.author-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
cursor: pointer;
.author-avatar {
width: 40px;
height: 40px;
}
.author-detail {
flex: 1;
.author-name {
font-size: 15px;
font-weight: 500;
color: #333;
}
.author-fans {
font-size: 12px;
color: #999;
margin-top: 2px;
}
}
}
.video-desc {
margin-top: 12px;
.desc-content {
font-size: 14px;
color: #666;
line-height: 1.6;
white-space: pre-wrap;
}
.video-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
}
}
.detail-tabs {
background: #fff;
margin-top: 12px;
}
.recommend-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 12px;
@media (min-width: 768px) {
grid-template-columns: repeat(4, 1fr);
}
}
</style>