refactor(project): Reorganize frontend and backend directories
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user