Files
VLoop/frontend/src/views/AccountView.vue

569 lines
15 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import UserAvatar from '../components/UserAvatar.vue'
import { ApiError } from '../api/client'
import * as accountApi from '../api/account'
import * as likeApi from '../api/like'
import type { Video } from '../api/types'
import * as videoApi from '../api/video'
import { useAuthStore } from '../stores/auth'
import { useSocialStore } from '../stores/social'
import { useToastStore } from '../stores/toast'
const router = useRouter()
const auth = useAuthStore()
const social = useSocialStore()
const toast = useToastStore()
const busy = ref(false)
const loginForm = reactive({ username: '', password: '' })
const me = computed(() => ({
id: auth.claims?.account_id ?? 0,
username: auth.claims?.username ?? '',
}))
const myVideos = reactive({
loading: false,
error: '',
items: [] as Video[],
})
type VideoTab = 'works' | 'likes'
const videoTab = ref<VideoTab>('works')
let myVideosReq = 0
async function loadMyVideos() {
const id = me.value.id
if (!auth.isLoggedIn || !id) {
myVideos.items = []
myVideos.error = ''
myVideos.loading = false
return
}
if (myVideos.loading) return
const req = ++myVideosReq
myVideos.loading = true
myVideos.error = ''
try {
const vids = await videoApi.listByAuthorId(id)
if (req !== myVideosReq) return
myVideos.items = vids
} catch (e) {
if (req !== myVideosReq) return
myVideos.error = e instanceof ApiError ? e.message : String(e)
myVideos.items = []
} finally {
if (req === myVideosReq) myVideos.loading = false
}
}
const likedVideos = reactive({
loading: false,
loaded: false,
error: '',
items: [] as Video[],
})
let likedVideosReq = 0
async function loadLikedVideos() {
if (!auth.isLoggedIn || !me.value.id) {
likedVideosReq += 1
likedVideos.loading = false
likedVideos.loaded = false
likedVideos.error = ''
likedVideos.items = []
return
}
if (likedVideos.loading) return
const req = ++likedVideosReq
likedVideos.loading = true
likedVideos.error = ''
try {
const vids = await likeApi.listMyLikedVideos()
if (req !== likedVideosReq) return
likedVideos.items = vids
likedVideos.loaded = true
} catch (e) {
if (req !== likedVideosReq) return
likedVideos.error = e instanceof ApiError ? e.message : String(e)
likedVideos.items = []
likedVideos.loaded = true
} finally {
if (req === likedVideosReq) likedVideos.loading = false
}
}
async function goVideo(id: number) {
await router.push(`/video/${id}`)
}
function openWorksVideos() {
videoTab.value = 'works'
void loadMyVideos()
}
function openLikedVideos() {
videoTab.value = 'likes'
void loadLikedVideos()
}
async function onLogin() {
if (busy.value) return
const username = loginForm.username.trim()
const password = loginForm.password.trim()
if (!username || !password) {
toast.error('请输入用户名和密码')
return
}
busy.value = true
try {
const res = await accountApi.login(username, password)
auth.setTokens(res.token, res.refresh_token ?? '')
toast.success('登录成功')
await social.refreshMine()
await loadMyVideos()
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
}
}
async function goRegister() {
await router.push('/account/register')
}
async function goChangePassword() {
await router.push('/account/change-password')
}
async function goSettings() {
await router.push('/settings')
}
type ListTab = 'followers' | 'following'
const drawer = reactive({
open: false,
tab: 'followers' as ListTab,
})
function openFollowers() {
drawer.tab = 'followers'
drawer.open = true
}
function openFollowing() {
drawer.tab = 'following'
drawer.open = true
}
function closeDrawer() {
drawer.open = false
}
const listTitle = computed(() => (drawer.tab === 'followers' ? '粉丝' : '关注'))
const listItems = computed(() => (drawer.tab === 'followers' ? social.followers : social.vloggers))
const drawerLoading = computed(() => (drawer.tab === 'followers' ? social.followersLoading : social.vloggersLoading))
const drawerError = computed(() => (drawer.tab === 'followers' ? social.followersError : social.vloggersError))
const socialErrorHint = computed(() => social.followersError || social.vloggersError)
async function goUser(id: number) {
drawer.open = false
await router.push(`/u/${id}`)
}
watch(
() => auth.isLoggedIn,
(v) => {
if (!v) {
drawer.open = false
myVideosReq += 1
myVideos.loading = false
myVideos.items = []
myVideos.error = ''
likedVideosReq += 1
likedVideos.loading = false
likedVideos.loaded = false
likedVideos.items = []
likedVideos.error = ''
videoTab.value = 'works'
}
},
)
watch(
() => me.value.id,
(id) => {
if (auth.isLoggedIn && id) {
void loadMyVideos()
if (videoTab.value === 'likes') void loadLikedVideos()
}
},
{ immediate: true },
)
</script>
<template>
<AppShell>
<div v-if="!auth.isLoggedIn" class="login-wrap">
<div class="card login-card">
<p class="title">登录</p>
<div class="grid" style="margin-top: 10px">
<div>
<label>username</label>
<input v-model.trim="loginForm.username" autocomplete="username" />
</div>
<div>
<label>password</label>
<input v-model.trim="loginForm.password" type="password" autocomplete="current-password" @keydown.enter="onLogin" />
</div>
<button class="primary" type="button" :disabled="busy" @click="onLogin">登录</button>
</div>
<div class="row" style="justify-content: space-between; margin-top: 14px">
<button class="ghost" type="button" :disabled="busy" @click="goRegister">注册账号</button>
<button class="ghost" type="button" :disabled="busy" @click="goChangePassword">修改密码</button>
</div>
</div>
</div>
<template v-else>
<div class="card">
<div class="row" style="justify-content: space-between; align-items: flex-start">
<div class="row" style="gap: 12px; align-items: center">
<UserAvatar :username="me.username" :id="me.id" :size="64" />
<div>
<div class="title" style="margin: 0">@{{ me.username }}</div>
<div class="subtle mono">#{{ me.id }}</div>
</div>
</div>
<div class="row">
<button class="ghost" type="button" @click="goSettings">设置</button>
</div>
</div>
<div class="row" style="margin-top: 14px">
<button class="metric" type="button" :disabled="social.followersLoading" @click="openFollowers">
<div class="metric-num">{{ social.followersLoading ? '…' : social.followerCount }}</div>
<div class="metric-label">粉丝</div>
</button>
<button class="metric" type="button" :disabled="social.vloggersLoading" @click="openFollowing">
<div class="metric-num">{{ social.vloggersLoading ? '…' : social.followingCount }}</div>
<div class="metric-label">关注</div>
</button>
<button class="metric" type="button" :class="{ active: videoTab === 'works' }" @click="openWorksVideos">
<div class="metric-num">{{ myVideos.loading ? '…' : myVideos.items.length }}</div>
<div class="metric-label">作品</div>
</button>
<button class="metric" type="button" :class="{ active: videoTab === 'likes' }" @click="openLikedVideos">
<div class="metric-num">{{ likedVideos.loading ? '…' : likedVideos.loaded ? likedVideos.items.length : '—' }}</div>
<div class="metric-label">点赞</div>
</button>
<div v-if="socialErrorHint" class="subtle" style="margin-left: 8px">社交信息加载失败{{ socialErrorHint }}</div>
</div>
</div>
<div class="card" style="margin-top: 14px">
<div class="row" style="justify-content: space-between">
<p class="title" style="margin: 0">{{ videoTab === 'works' ? '作品' : '点赞视频' }}</p>
<div class="subtle">点击封面进入播放页</div>
</div>
<template v-if="videoTab === 'works'">
<div v-if="myVideos.loading" class="hint" style="margin-top: 12px">加载中</div>
<div v-else-if="myVideos.error" class="hint bad" style="margin-top: 12px">{{ myVideos.error }}</div>
<div v-else-if="myVideos.items.length === 0" class="hint" style="margin-top: 12px">暂无作品</div>
<div v-else class="video-grid" style="margin-top: 12px">
<button v-for="v in myVideos.items" :key="v.id" class="video-card" type="button" @click="goVideo(v.id)">
<img class="video-cover" :src="v.cover_url" :alt="v.title" loading="lazy" />
<div class="video-meta">
<div class="video-title">{{ v.title }}</div>
<div class="video-sub subtle"> {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }}</div>
</div>
</button>
</div>
</template>
<template v-else>
<div v-if="likedVideos.loading" class="hint" style="margin-top: 12px">加载中</div>
<div v-else-if="likedVideos.error" class="hint bad" style="margin-top: 12px">{{ likedVideos.error }}</div>
<div v-else-if="likedVideos.items.length === 0" class="hint" style="margin-top: 12px">暂无点赞视频</div>
<div v-else class="video-grid" style="margin-top: 12px">
<button v-for="v in likedVideos.items" :key="v.id" class="video-card" type="button" @click="goVideo(v.id)">
<img class="video-cover" :src="v.cover_url" :alt="v.title" loading="lazy" />
<div class="video-meta">
<div class="video-title">{{ v.title }}</div>
<div class="video-sub subtle"> {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }}</div>
</div>
</button>
</div>
</template>
</div>
</template>
<div v-if="drawer.open" class="drawer-backdrop" @click.self="closeDrawer">
<div class="drawer">
<div class="drawer-head">
<div class="drawer-title">{{ listTitle }}</div>
<button class="drawer-x" type="button" @click="closeDrawer">×</button>
</div>
<div class="drawer-body">
<div v-if="drawerLoading" class="drawer-hint">加载中</div>
<div v-else-if="drawerError" class="drawer-hint bad">{{ drawerError }}</div>
<div v-else-if="listItems.length === 0" class="drawer-hint">暂无</div>
<button v-for="u in listItems" v-if="!drawerLoading && !drawerError" :key="u.id" class="user-row" type="button" @click="goUser(u.id)">
<UserAvatar :username="u.username" :id="u.id" :size="40" />
<div class="user-meta">
<div class="user-name">@{{ u.username }}</div>
<div class="user-id mono">#{{ u.id }}</div>
</div>
</button>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.login-wrap {
display: grid;
justify-items: center;
align-content: start;
padding: clamp(56px, 14vh, 160px) 16px 40px;
}
.login-card {
width: min(420px, 100%);
}
2026-04-26 15:47:17 +08:00
.ghost {
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.055);
color: var(--text);
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
}
.ghost:hover {
background: rgba(255, 255, 255, 0.1);
}
2026-04-26 15:47:17 +08:00
.metric {
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 12px 14px;
min-width: 120px;
cursor: pointer;
display: grid;
gap: 4px;
text-align: left;
}
.metric:hover {
background: rgba(255, 255, 255, 0.1);
}
2026-04-26 15:47:17 +08:00
.metric.active {
background: rgba(43, 161, 255, 0.14);
border-color: rgba(43, 161, 255, 0.55);
}
.metric.static {
cursor: default;
}
.metric:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.metric-num {
font-size: 18px;
font-weight: 900;
letter-spacing: 0.2px;
}
2026-04-26 15:47:17 +08:00
.metric-label {
font-size: 12px;
color: var(--muted);
}
.hint {
color: var(--muted);
}
.hint.bad {
color: var(--danger);
}
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(10px);
z-index: 120;
display: grid;
justify-items: center;
align-items: center;
padding: 16px;
}
2026-04-26 15:47:17 +08:00
.drawer {
width: min(520px, calc(100vw - 18px));
max-height: min(78vh, 720px);
2026-04-26 15:47:17 +08:00
background: rgba(13, 18, 29, 0.92);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
}
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px;
2026-04-26 15:47:17 +08:00
border-bottom: 1px solid var(--border);
}
.drawer-title {
font-weight: 900;
}
.drawer-x {
width: 34px;
height: 34px;
2026-04-26 15:47:17 +08:00
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 20px;
line-height: 1;
}
.drawer-body {
overflow: auto;
padding: 12px 14px;
display: grid;
gap: 10px;
}
2026-04-26 15:47:17 +08:00
.drawer-hint {
color: var(--muted);
padding: 12px 0;
}
2026-04-26 15:47:17 +08:00
.drawer-hint.bad {
color: var(--danger);
}
.video-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1100px) {
.video-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 800px) {
.video-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
2026-04-26 15:47:17 +08:00
.video-card {
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
padding: 0;
text-align: left;
}
.video-card:hover {
background: rgba(255, 255, 255, 0.08);
}
.video-cover {
width: 100%;
aspect-ratio: 9/12;
object-fit: cover;
display: block;
background: rgba(0, 0, 0, 0.35);
}
.video-meta {
padding: 10px 10px;
}
.video-title {
font-weight: 800;
font-size: 13px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.video-sub {
margin-top: 6px;
font-size: 12px;
}
.user-row {
text-align: left;
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: center;
padding: 10px 10px;
2026-04-26 15:47:17 +08:00
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
}
.user-row:hover {
background: rgba(255, 255, 255, 0.08);
}
.user-meta {
min-width: 0;
}
.user-name {
font-weight: 800;
}
2026-04-26 15:47:17 +08:00
.user-id {
font-size: 12px;
color: var(--muted);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
</style>