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