Files
VLoop/frontend/src/views/AccountView.vue
2026-04-26 15:47:17 +08:00

569 lines
15 KiB
Vue
Vendored
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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%);
}
.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);
}
.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);
}
.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;
}
.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;
}
.drawer {
width: min(520px, calc(100vw - 18px));
max-height: min(78vh, 720px);
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;
border-bottom: 1px solid var(--border);
}
.drawer-title {
font-weight: 900;
}
.drawer-x {
width: 34px;
height: 34px;
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;
}
.drawer-hint {
color: var(--muted);
padding: 12px 0;
}
.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));
}
}
.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;
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;
}
.user-id {
font-size: 12px;
color: var(--muted);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
</style>