feat: 前端
This commit is contained in:
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
28
frontend/README.md
Normal file
28
frontend/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# feedsystem_video_go frontend
|
||||
|
||||
这是对接 `backend/`(Gin + GORM + MySQL + JWT)的一套 Vue3 前端调试 UI,覆盖全部后端路由:
|
||||
- Account:注册 / 登录 / 改密码 / 查找 / 改名 / 登出
|
||||
- Video:发布 / 按作者列出 / 详情
|
||||
- Like:点赞 / 取消点赞 / 是否点赞
|
||||
- Comment:列表 / 发布 / 删除
|
||||
- Social:关注 / 取关 / 粉丝列表 / 关注列表
|
||||
- Feed:最新流 / 点赞数流 / 关注流
|
||||
|
||||
## 开发启动
|
||||
|
||||
先启动后端:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go run ./cmd
|
||||
```
|
||||
|
||||
再启动前端:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认通过 Vite 代理转发请求:前端访问 `/api/...` → `http://localhost:8080/...`(见 `frontend/vite.config.ts`)。
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1387
frontend/package-lock.json
generated
Normal file
1387
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "npm:rolldown-vite@7.2.5",
|
||||
"vue-tsc": "^3.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
34
frontend/src/api/account.ts
Normal file
34
frontend/src/api/account.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { postJson } from './client'
|
||||
import type { Account, MessageResponse, TokenResponse } from './types'
|
||||
|
||||
export function register(username: string, password: string) {
|
||||
return postJson<MessageResponse>('/account/register', { username, password })
|
||||
}
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
return postJson<TokenResponse>('/account/login', { username, password })
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return postJson<MessageResponse>('/account/logout', {}, { authRequired: true })
|
||||
}
|
||||
|
||||
export function rename(newUsername: string) {
|
||||
return postJson<TokenResponse>('/account/rename', { new_username: newUsername }, { authRequired: true })
|
||||
}
|
||||
|
||||
export function changePassword(username: string, oldPassword: string, newPassword: string) {
|
||||
return postJson<MessageResponse>('/account/changePassword', {
|
||||
username,
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
}
|
||||
|
||||
export function findById(id: number) {
|
||||
return postJson<Account>('/account/findByID', { id })
|
||||
}
|
||||
|
||||
export function findByUsername(username: string) {
|
||||
return postJson<Account>('/account/findByUsername', { username })
|
||||
}
|
||||
58
frontend/src/api/client.ts
Normal file
58
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
payload?: unknown
|
||||
|
||||
constructor(message: string, status: number, payload?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.payload = payload
|
||||
}
|
||||
}
|
||||
|
||||
type ApiErrorBody = { error?: string }
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
||||
|
||||
export async function postJson<T>(path: string, body: unknown, options?: { authRequired?: boolean }): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const token = auth.token
|
||||
|
||||
if (options?.authRequired && !token) {
|
||||
throw new ApiError('需要先登录(缺少 token)', 401)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
let data: unknown = null
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = text
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
auth.clearToken()
|
||||
}
|
||||
const msg =
|
||||
data && typeof data === 'object' && (data as ApiErrorBody).error
|
||||
? String((data as ApiErrorBody).error)
|
||||
: `请求失败 (${res.status})`
|
||||
throw new ApiError(msg, res.status, data)
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
14
frontend/src/api/comment.ts
Normal file
14
frontend/src/api/comment.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { postJson } from './client'
|
||||
import type { Comment, MessageResponse } from './types'
|
||||
|
||||
export function listAll(videoId: number) {
|
||||
return postJson<Comment[]>('/comment/listAll', { video_id: videoId })
|
||||
}
|
||||
|
||||
export function publish(videoId: number, content: string) {
|
||||
return postJson<MessageResponse>('/comment/publish', { video_id: videoId, content }, { authRequired: true })
|
||||
}
|
||||
|
||||
export function remove(commentId: number) {
|
||||
return postJson<MessageResponse>('/comment/delete', { comment_id: commentId }, { authRequired: true })
|
||||
}
|
||||
19
frontend/src/api/feed.ts
Normal file
19
frontend/src/api/feed.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { postJson } from './client'
|
||||
import type { ListByFollowingResponse, ListLatestResponse, ListLikesCountResponse } from './types'
|
||||
|
||||
export function listLatest(input: { limit: number; latest_time: number }) {
|
||||
return postJson<ListLatestResponse>('/feed/listLatest', input)
|
||||
}
|
||||
|
||||
export function listLikesCount(input: { limit: number; likes_count_before?: number; id_before?: number }) {
|
||||
const body: Record<string, unknown> = { limit: input.limit }
|
||||
if (typeof input.likes_count_before === 'number' || typeof input.id_before === 'number') {
|
||||
body.likes_count_before = input.likes_count_before ?? 0
|
||||
body.id_before = input.id_before ?? 0
|
||||
}
|
||||
return postJson<ListLikesCountResponse>('/feed/listLikesCount', body)
|
||||
}
|
||||
|
||||
export function listByFollowing(input: { limit: number; latest_time: number }) {
|
||||
return postJson<ListByFollowingResponse>('/feed/listByFollowing', input, { authRequired: true })
|
||||
}
|
||||
14
frontend/src/api/like.ts
Normal file
14
frontend/src/api/like.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { postJson } from './client'
|
||||
import type { IsLikedResponse, MessageResponse } from './types'
|
||||
|
||||
export function like(videoId: number) {
|
||||
return postJson<MessageResponse>('/like/like', { video_id: videoId }, { authRequired: true })
|
||||
}
|
||||
|
||||
export function unlike(videoId: number) {
|
||||
return postJson<MessageResponse>('/like/unlike', { video_id: videoId }, { authRequired: true })
|
||||
}
|
||||
|
||||
export function isLiked(videoId: number) {
|
||||
return postJson<IsLikedResponse>('/like/isLiked', { video_id: videoId }, { authRequired: true })
|
||||
}
|
||||
26
frontend/src/api/social.ts
Normal file
26
frontend/src/api/social.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { postJson } from './client'
|
||||
import type { GetAllFollowersResponse, GetAllVloggersResponse, MessageResponse } from './types'
|
||||
|
||||
export function follow(vloggerId: number) {
|
||||
return postJson<MessageResponse>('/social/follow', { vlogger_id: vloggerId }, { authRequired: true })
|
||||
}
|
||||
|
||||
export function unfollow(vloggerId: number) {
|
||||
return postJson<MessageResponse>('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true })
|
||||
}
|
||||
|
||||
export function getAllFollowers(vloggerId?: number) {
|
||||
return postJson<GetAllFollowersResponse>(
|
||||
'/social/getAllFollowers',
|
||||
vloggerId ? { vlogger_id: vloggerId } : {},
|
||||
{ authRequired: true },
|
||||
)
|
||||
}
|
||||
|
||||
export function getAllVloggers(followerId?: number) {
|
||||
return postJson<GetAllVloggersResponse>(
|
||||
'/social/getAllVloggers',
|
||||
followerId ? { follower_id: followerId } : {},
|
||||
{ authRequired: true },
|
||||
)
|
||||
}
|
||||
77
frontend/src/api/types.ts
Normal file
77
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type MessageResponse = { message: string }
|
||||
|
||||
export type TokenResponse = { token: string }
|
||||
|
||||
export type Account = {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export type Video = {
|
||||
id: number
|
||||
author_id: number
|
||||
username: string
|
||||
title: string
|
||||
description?: string
|
||||
play_url: string
|
||||
cover_url: string
|
||||
create_time: string
|
||||
likes_count: number
|
||||
}
|
||||
|
||||
export type Comment = {
|
||||
id: number
|
||||
username: string
|
||||
video_id: number
|
||||
author_id: number
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type FeedAuthor = {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export type FeedVideoItem = {
|
||||
id: number
|
||||
author: FeedAuthor
|
||||
title: string
|
||||
description?: string
|
||||
play_url: string
|
||||
cover_url: string
|
||||
create_time: number
|
||||
likes_count: number
|
||||
is_liked: boolean
|
||||
}
|
||||
|
||||
export type ListLatestResponse = {
|
||||
video_list: FeedVideoItem[]
|
||||
next_time: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export type ListLikesCountResponse = {
|
||||
video_list: FeedVideoItem[]
|
||||
next_likes_count_before?: number
|
||||
next_id_before?: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export type ListByFollowingResponse = {
|
||||
video_list: FeedVideoItem[]
|
||||
next_time: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export type IsLikedResponse = {
|
||||
is_liked: boolean
|
||||
}
|
||||
|
||||
export type GetAllFollowersResponse = {
|
||||
followers: Account[]
|
||||
}
|
||||
|
||||
export type GetAllVloggersResponse = {
|
||||
vloggers: Account[]
|
||||
}
|
||||
14
frontend/src/api/video.ts
Normal file
14
frontend/src/api/video.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { postJson } from './client'
|
||||
import type { Video } from './types'
|
||||
|
||||
export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) {
|
||||
return postJson<Video>('/video/publish', input, { authRequired: true })
|
||||
}
|
||||
|
||||
export function listByAuthorId(authorId: number) {
|
||||
return postJson<Video[]>('/video/listByAuthorID', { author_id: authorId })
|
||||
}
|
||||
|
||||
export function getDetail(id: number) {
|
||||
return postJson<Video>('/video/getDetail', { id })
|
||||
}
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
321
frontend/src/components/AppShell.vue
Normal file
321
frontend/src/components/AppShell.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useSocialStore } from '../stores/social'
|
||||
import Toaster from './Toaster.vue'
|
||||
|
||||
const props = defineProps<{ full?: boolean }>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const social = useSocialStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const search = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(v) => {
|
||||
search.value = typeof v === 'string' ? v : ''
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => auth.isLoggedIn,
|
||||
(v) => {
|
||||
if (v) void social.refreshMine()
|
||||
else social.clear()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const userLabel = computed(() => {
|
||||
if (!auth.isLoggedIn) return '未登录'
|
||||
const username = auth.claims?.username ?? '(unknown)'
|
||||
const accountId = auth.claims?.account_id
|
||||
return accountId ? `${username} #${accountId}` : username
|
||||
})
|
||||
|
||||
async function onSearch() {
|
||||
const q = search.value.trim()
|
||||
await router.push({ path: '/', query: q ? { q } : {} })
|
||||
}
|
||||
|
||||
async function goLogin() {
|
||||
await router.push('/account')
|
||||
}
|
||||
|
||||
async function goSettings() {
|
||||
await router.push('/settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dy-shell">
|
||||
<aside class="dy-aside">
|
||||
<RouterLink class="dy-logo" to="/">ShortVideo</RouterLink>
|
||||
|
||||
<nav class="dy-nav">
|
||||
<RouterLink class="dy-nav-link" to="/">推荐</RouterLink>
|
||||
<RouterLink class="dy-nav-link" to="/video">发布</RouterLink>
|
||||
<RouterLink class="dy-nav-link" to="/account">账号</RouterLink>
|
||||
<RouterLink class="dy-nav-link" to="/settings">设置</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="dy-aside-foot">
|
||||
<div class="dy-user">
|
||||
<span class="dy-user-dot" :class="auth.isLoggedIn ? 'ok' : 'bad'" />
|
||||
<span class="dy-user-name">{{ userLabel }}</span>
|
||||
</div>
|
||||
<div class="dy-user-actions">
|
||||
<button v-if="!auth.isLoggedIn" class="dy-btn dy-btn-primary" type="button" @click="goLogin">登录</button>
|
||||
<button v-else class="dy-btn dy-btn-primary" type="button" @click="goSettings">设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="dy-main">
|
||||
<header class="dy-topbar">
|
||||
<div class="dy-top-left">
|
||||
<div class="dy-tabs-hint">{{ route.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="dy-search">
|
||||
<input v-model="search" class="dy-search-input" placeholder="搜索标题 / 作者(本地过滤)" @keydown.enter="onSearch" />
|
||||
<button class="dy-btn dy-btn-primary" type="button" @click="onSearch">搜索</button>
|
||||
</div>
|
||||
|
||||
<div class="dy-top-right">
|
||||
<RouterLink class="dy-btn dy-btn-ghost" to="/video">+ 发布视频</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dy-content" :class="props.full ? 'full' : 'padded'">
|
||||
<template v-if="props.full">
|
||||
<slot />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="container">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dy-shell {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
background: radial-gradient(1200px 900px at 20% -25%, rgba(254, 44, 85, 0.18), transparent 60%),
|
||||
radial-gradient(900px 700px at 90% 10%, rgba(37, 244, 238, 0.12), transparent 55%), transparent;
|
||||
}
|
||||
|
||||
.dy-aside {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(16px);
|
||||
padding: 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dy-logo {
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.4px;
|
||||
font-size: 18px;
|
||||
padding: 10px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dy-nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dy-nav-link {
|
||||
padding: 10px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.dy-nav-link.router-link-active {
|
||||
border-color: rgba(254, 44, 85, 0.42);
|
||||
background: rgba(254, 44, 85, 0.12);
|
||||
}
|
||||
|
||||
.dy-aside-foot {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.dy-user {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dy-user-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.dy-user-dot.ok {
|
||||
background: rgba(34, 197, 94, 1);
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14);
|
||||
}
|
||||
|
||||
.dy-user-dot.bad {
|
||||
background: rgba(254, 44, 85, 1);
|
||||
box-shadow: 0 0 0 3px rgba(254, 44, 85, 0.14);
|
||||
}
|
||||
|
||||
.dy-user-name {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dy-user-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dy-btn {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dy-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dy-btn-primary {
|
||||
border-color: rgba(254, 44, 85, 0.5);
|
||||
background: rgba(254, 44, 85, 0.16);
|
||||
}
|
||||
|
||||
.dy-btn-primary:hover {
|
||||
background: rgba(254, 44, 85, 0.24);
|
||||
}
|
||||
|
||||
.dy-btn-ghost {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dy-main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dy-topbar {
|
||||
height: 56px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(16px);
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 180px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.dy-tabs-hint {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.dy-search {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
max-width: 680px;
|
||||
width: 100%;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.dy-search-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dy-search-input:focus {
|
||||
border-color: rgba(37, 244, 238, 0.42);
|
||||
box-shadow: 0 0 0 3px rgba(37, 244, 238, 0.14);
|
||||
}
|
||||
|
||||
.dy-top-right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dy-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dy-content.padded {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.dy-content.full {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dy-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dy-aside {
|
||||
display: none;
|
||||
}
|
||||
.dy-topbar {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.dy-top-left {
|
||||
display: none;
|
||||
}
|
||||
.dy-top-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
frontend/src/components/FeedVideoCard.vue
Normal file
89
frontend/src/components/FeedVideoCard.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import type { FeedVideoItem } from '../api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
item: FeedVideoItem
|
||||
canLike: boolean
|
||||
busy?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-like', item: FeedVideoItem): void
|
||||
}>()
|
||||
|
||||
function onToggle() {
|
||||
emit('toggle-like', props.item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="feed-card">
|
||||
<div class="cover">
|
||||
<img :src="item.cover_url" :alt="item.title" loading="lazy" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div>
|
||||
<div class="title">
|
||||
<RouterLink :to="`/video/${item.id}`">{{ item.title }}</RouterLink>
|
||||
</div>
|
||||
<div class="subtle">
|
||||
作者:{{ item.author.username }} (#{{ item.author.id }}) · 创建时间:{{ new Date(item.create_time * 1000).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="pill mono">❤️ {{ item.likes_count }}</span>
|
||||
<button
|
||||
v-if="canLike"
|
||||
class="primary"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="onToggle"
|
||||
:title="item.is_liked ? '取消点赞' : '点赞'"
|
||||
>
|
||||
{{ item.is_liked ? '已赞' : '点赞' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="item.description" class="muted" style="margin-top: 8px">{{ item.description }}</div>
|
||||
<div class="row" style="margin-top: 10px">
|
||||
<a class="pill mono" :href="item.play_url" target="_blank" rel="noreferrer">播放地址</a>
|
||||
<RouterLink class="pill" :to="`/video/${item.id}`">查看详情 / 评论</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feed-card {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 12px 12px 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.feed-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
17
frontend/src/components/JsonBox.vue
Normal file
17
frontend/src/components/JsonBox.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ value: unknown }>()
|
||||
|
||||
const text = computed(() => {
|
||||
try {
|
||||
return JSON.stringify(props.value, null, 2)
|
||||
} catch {
|
||||
return String(props.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<pre class="pre mono">{{ text }}</pre>
|
||||
</template>
|
||||
73
frontend/src/components/Toaster.vue
Normal file
73
frontend/src/components/Toaster.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
const toast = useToastStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-wrap" aria-live="polite" aria-relevant="additions removals">
|
||||
<div v-for="t in toast.toasts" :key="t.id" class="toast" :class="t.type">
|
||||
<div class="toast-msg">{{ t.message }}</div>
|
||||
<button class="toast-x" type="button" aria-label="关闭" @click="toast.remove(t.id)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-wrap {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
z-index: 200;
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: rgba(254, 44, 85, 0.45);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-color: rgba(37, 244, 238, 0.3);
|
||||
}
|
||||
|
||||
.toast-msg {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.toast-x {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
54
frontend/src/components/UserAvatar.vue
Normal file
54
frontend/src/components/UserAvatar.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
username: string
|
||||
id?: number
|
||||
size?: number
|
||||
}>()
|
||||
|
||||
function hashToHue(input: string) {
|
||||
let h = 0
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
h = (h * 31 + input.charCodeAt(i)) >>> 0
|
||||
}
|
||||
return h % 360
|
||||
}
|
||||
|
||||
const initial = computed(() => {
|
||||
const s = (props.username ?? '').trim()
|
||||
if (!s) return '?'
|
||||
return s.slice(0, 1).toUpperCase()
|
||||
})
|
||||
|
||||
const sizePx = computed(() => `${props.size ?? 40}px`)
|
||||
|
||||
const bg = computed(() => {
|
||||
const seed = typeof props.id === 'number' ? String(props.id) : props.username
|
||||
const hue = hashToHue(seed || '0')
|
||||
const h1 = hue
|
||||
const h2 = (hue + 40) % 360
|
||||
return `linear-gradient(135deg, hsl(${h1} 90% 55%), hsl(${h2} 90% 55%))`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="avatar" :style="{ width: sizePx, height: sizePx, backgroundImage: bg }" aria-hidden="true">
|
||||
{{ initial }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2px;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
10
frontend/src/main.ts
Normal file
10
frontend/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
27
frontend/src/router/index.ts
Normal file
27
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import VideoView from '../views/VideoView.vue'
|
||||
import VideoDetailView from '../views/VideoDetailView.vue'
|
||||
import AccountView from '../views/AccountView.vue'
|
||||
import ChangePasswordView from '../views/ChangePasswordView.vue'
|
||||
import RegisterView from '../views/RegisterView.vue'
|
||||
import SettingsView from '../views/SettingsView.vue'
|
||||
import UserProfileView from '../views/UserProfileView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/feed', redirect: '/' },
|
||||
{ path: '/video', name: 'video', component: VideoView },
|
||||
{ path: '/video/:id', name: 'video-detail', component: VideoDetailView, props: true },
|
||||
{ path: '/account', name: 'account', component: AccountView },
|
||||
{ path: '/account/register', name: 'account-register', component: RegisterView },
|
||||
{ path: '/account/change-password', name: 'account-change-password', component: ChangePasswordView },
|
||||
{ path: '/settings', name: 'settings', component: SettingsView },
|
||||
{ path: '/u/:id', name: 'user-profile', component: UserProfileView, props: true },
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
45
frontend/src/stores/auth.ts
Normal file
45
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { decodeJwtPayload, type JwtPayload } from '../utils/jwt'
|
||||
|
||||
const TOKEN_KEY = 'jwt_token'
|
||||
|
||||
function readToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
function removeToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(readToken())
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const claims = computed<JwtPayload | null>(() => (token.value ? decodeJwtPayload(token.value) : null))
|
||||
|
||||
function setToken(newToken: string) {
|
||||
token.value = newToken
|
||||
writeToken(newToken)
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
token.value = null
|
||||
removeToken()
|
||||
}
|
||||
|
||||
function syncFromStorage() {
|
||||
token.value = readToken()
|
||||
}
|
||||
|
||||
return { token, isLoggedIn, claims, setToken, clearToken, syncFromStorage }
|
||||
})
|
||||
109
frontend/src/stores/social.ts
Normal file
109
frontend/src/stores/social.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { ApiError } from '../api/client'
|
||||
import type { Account } from '../api/types'
|
||||
import * as socialApi from '../api/social'
|
||||
import { useAuthStore } from './auth'
|
||||
|
||||
export const useSocialStore = defineStore('social', () => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
const followers = ref<Account[]>([])
|
||||
const vloggers = ref<Account[]>([])
|
||||
|
||||
const followersLoading = ref(false)
|
||||
const vloggersLoading = ref(false)
|
||||
|
||||
const followersError = ref('')
|
||||
const vloggersError = ref('')
|
||||
|
||||
const followerCount = computed(() => followers.value.length)
|
||||
const followingCount = computed(() => vloggers.value.length)
|
||||
|
||||
function clear() {
|
||||
followers.value = []
|
||||
vloggers.value = []
|
||||
followersError.value = ''
|
||||
vloggersError.value = ''
|
||||
followersLoading.value = false
|
||||
vloggersLoading.value = false
|
||||
}
|
||||
|
||||
function isFollowing(accountId: number) {
|
||||
return vloggers.value.some((a) => a.id === accountId)
|
||||
}
|
||||
|
||||
async function refreshFollowers(vloggerId?: number) {
|
||||
if (!auth.isLoggedIn) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
followersLoading.value = true
|
||||
followersError.value = ''
|
||||
try {
|
||||
const res = await socialApi.getAllFollowers(vloggerId)
|
||||
followers.value = res.followers
|
||||
} catch (e) {
|
||||
followersError.value = e instanceof ApiError ? e.message : String(e)
|
||||
followers.value = []
|
||||
} finally {
|
||||
followersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshVloggers(followerId?: number) {
|
||||
if (!auth.isLoggedIn) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
vloggersLoading.value = true
|
||||
vloggersError.value = ''
|
||||
try {
|
||||
const res = await socialApi.getAllVloggers(followerId)
|
||||
vloggers.value = res.vloggers
|
||||
} catch (e) {
|
||||
vloggersError.value = e instanceof ApiError ? e.message : String(e)
|
||||
vloggers.value = []
|
||||
} finally {
|
||||
vloggersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMine() {
|
||||
await Promise.all([refreshFollowers(), refreshVloggers()])
|
||||
}
|
||||
|
||||
async function follow(vloggerId: number) {
|
||||
if (!auth.isLoggedIn) throw new ApiError('需要先登录', 401)
|
||||
await socialApi.follow(vloggerId)
|
||||
await refreshVloggers()
|
||||
}
|
||||
|
||||
async function unfollow(vloggerId: number) {
|
||||
if (!auth.isLoggedIn) throw new ApiError('需要先登录', 401)
|
||||
await socialApi.unfollow(vloggerId)
|
||||
await refreshVloggers()
|
||||
}
|
||||
|
||||
return {
|
||||
followers,
|
||||
vloggers,
|
||||
followerCount,
|
||||
followingCount,
|
||||
followersLoading,
|
||||
vloggersLoading,
|
||||
followersError,
|
||||
vloggersError,
|
||||
clear,
|
||||
isFollowing,
|
||||
refreshMine,
|
||||
refreshFollowers,
|
||||
refreshVloggers,
|
||||
follow,
|
||||
unfollow,
|
||||
}
|
||||
})
|
||||
|
||||
40
frontend/src/stores/toast.ts
Normal file
40
frontend/src/stores/toast.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info'
|
||||
|
||||
export type Toast = {
|
||||
id: number
|
||||
type: ToastType
|
||||
message: string
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
export const useToastStore = defineStore('toast', () => {
|
||||
const toasts = ref<Toast[]>([])
|
||||
|
||||
function remove(id: number) {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
function push(type: ToastType, message: string, ttlMs = 2600) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, type, message })
|
||||
window.setTimeout(() => remove(id), ttlMs)
|
||||
}
|
||||
|
||||
function success(message: string) {
|
||||
push('success', message)
|
||||
}
|
||||
|
||||
function error(message: string) {
|
||||
push('error', message, 3600)
|
||||
}
|
||||
|
||||
function info(message: string) {
|
||||
push('info', message)
|
||||
}
|
||||
|
||||
return { toasts, push, remove, success, error, info }
|
||||
})
|
||||
207
frontend/src/style.css
Normal file
207
frontend/src/style.css
Normal file
@@ -0,0 +1,207 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b0b0f;
|
||||
--panel: rgba(255, 255, 255, 0.06);
|
||||
--panel-2: rgba(255, 255, 255, 0.1);
|
||||
--text: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.64);
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--primary: #fe2c55;
|
||||
--primary-2: #25f4ee;
|
||||
--danger: #fe2c55;
|
||||
--ok: #22c55e;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji';
|
||||
line-height: 1.5;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(1200px 900px at 20% -25%, rgba(254, 44, 85, 0.18), transparent 60%),
|
||||
radial-gradient(900px 700px at 90% 10%, rgba(37, 244, 238, 0.12), transparent 55%), var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 56px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card + .card {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.grid.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid.two,
|
||||
.grid.three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
display: inline-block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: rgba(124, 92, 255, 0.8);
|
||||
box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.22);
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: 120ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
border-color: rgba(254, 44, 85, 0.55);
|
||||
background: rgba(254, 44, 85, 0.18);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: rgba(254, 44, 85, 0.26);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
border-color: rgba(255, 77, 109, 0.65);
|
||||
background: rgba(255, 77, 109, 0.18);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: rgba(255, 77, 109, 0.26);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.pill.bad {
|
||||
border-color: rgba(255, 77, 109, 0.55);
|
||||
background: rgba(255, 77, 109, 0.12);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
28
frontend/src/utils/jwt.ts
Normal file
28
frontend/src/utils/jwt.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type JwtPayload = {
|
||||
account_id?: number
|
||||
username?: string
|
||||
exp?: number
|
||||
iat?: number
|
||||
nbf?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function base64UrlToBase64(input: string) {
|
||||
const base64 = input.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const pad = base64.length % 4
|
||||
return pad === 0 ? base64 : base64 + '='.repeat(4 - pad)
|
||||
}
|
||||
|
||||
export function decodeJwtPayload(token: string): JwtPayload | null {
|
||||
const [, payload] = token.split('.')
|
||||
if (!payload) return null
|
||||
|
||||
try {
|
||||
const json = atob(base64UrlToBase64(payload))
|
||||
const parsed = JSON.parse(json)
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
return parsed as JwtPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
479
frontend/src/views/AccountView.vue
Normal file
479
frontend/src/views/AccountView.vue
Normal file
@@ -0,0 +1,479 @@
|
||||
<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 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[],
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function goVideo(id: number) {
|
||||
await router.push(`/video/${id}`)
|
||||
}
|
||||
|
||||
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.setToken(res.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.items = []
|
||||
myVideos.error = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => me.value.id,
|
||||
(id) => {
|
||||
if (auth.isLoggedIn && id) void loadMyVideos()
|
||||
},
|
||||
{ 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>
|
||||
<div class="metric static">
|
||||
<div class="metric-num">{{ myVideos.loading ? '…' : myVideos.items.length }}</div>
|
||||
<div class="metric-label">作品</div>
|
||||
</div>
|
||||
<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">作品</p>
|
||||
<div class="subtle">点击封面进入播放页</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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 {
|
||||
min-height: calc(100vh - 56px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px 0 40px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.metric {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
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.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: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.hint.bad {
|
||||
color: rgba(254, 44, 85, 0.92);
|
||||
}
|
||||
|
||||
.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(0, 0, 0, 0.65);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
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 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.drawer-x {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
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: rgba(255, 255, 255, 0.78);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.drawer-hint.bad {
|
||||
color: rgba(254, 44, 85, 0.92);
|
||||
}
|
||||
|
||||
.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 rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
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: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
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: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
72
frontend/src/views/ChangePasswordView.vue
Normal file
72
frontend/src/views/ChangePasswordView.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppShell from '../components/AppShell.vue'
|
||||
import { ApiError } from '../api/client'
|
||||
import * as accountApi from '../api/account'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToastStore()
|
||||
|
||||
const busy = ref(false)
|
||||
const form = reactive({ username: '', oldPassword: '', newPassword: '' })
|
||||
|
||||
async function submit() {
|
||||
if (busy.value) return
|
||||
const username = form.username.trim()
|
||||
const oldPassword = form.oldPassword.trim()
|
||||
const newPassword = form.newPassword.trim()
|
||||
if (!username || !oldPassword || !newPassword) {
|
||||
toast.error('请把信息填完整')
|
||||
return
|
||||
}
|
||||
|
||||
busy.value = true
|
||||
try {
|
||||
await accountApi.changePassword(username, oldPassword, newPassword)
|
||||
toast.success('密码已修改,请重新登录')
|
||||
await router.push('/account')
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="grid two">
|
||||
<div class="card">
|
||||
<p class="title">修改密码</p>
|
||||
<p class="subtle">不需要登录(对应后端 `/account/changePassword`)。</p>
|
||||
<div class="grid" style="margin-top: 12px">
|
||||
<div>
|
||||
<label>username</label>
|
||||
<input v-model.trim="form.username" autocomplete="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label>old_password</label>
|
||||
<input v-model.trim="form.oldPassword" type="password" autocomplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label>new_password</label>
|
||||
<input v-model.trim="form.newPassword" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="row" style="justify-content: flex-end">
|
||||
<button class="primary" type="button" :disabled="busy" @click="submit">提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="title">提示</p>
|
||||
<p class="muted">改密成功后后端会让旧 token 失效;请在「账号」页重新登录。</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
262
frontend/src/views/FeedView.vue
Normal file
262
frontend/src/views/FeedView.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, watch } from 'vue'
|
||||
|
||||
import AppShell from '../components/AppShell.vue'
|
||||
import JsonBox from '../components/JsonBox.vue'
|
||||
import FeedVideoCard from '../components/FeedVideoCard.vue'
|
||||
import { ApiError } from '../api/client'
|
||||
import * as feedApi from '../api/feed'
|
||||
import * as likeApi from '../api/like'
|
||||
import type { FeedVideoItem } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
type ListState = {
|
||||
loading: boolean
|
||||
error: string
|
||||
items: FeedVideoItem[]
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
const latest = reactive<ListState & { limit: number; next_time: number }>({
|
||||
loading: false,
|
||||
error: '',
|
||||
items: [],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
next_time: 0,
|
||||
})
|
||||
|
||||
const likesCount = reactive<ListState & { limit: number; next_likes_count_before?: number; next_id_before?: number }>({
|
||||
loading: false,
|
||||
error: '',
|
||||
items: [],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
next_likes_count_before: undefined,
|
||||
next_id_before: undefined,
|
||||
})
|
||||
|
||||
const following = reactive<ListState & { limit: number; next_time: number }>({
|
||||
loading: false,
|
||||
error: '',
|
||||
items: [],
|
||||
has_more: false,
|
||||
limit: 10,
|
||||
next_time: 0,
|
||||
})
|
||||
|
||||
const action = reactive<{ loading: boolean; error: string; payload: unknown; name: string }>({
|
||||
loading: false,
|
||||
error: '',
|
||||
payload: null,
|
||||
name: '',
|
||||
})
|
||||
|
||||
const canLike = computed(() => auth.isLoggedIn)
|
||||
|
||||
async function runAction(name: string, fn: () => Promise<unknown>) {
|
||||
action.name = name
|
||||
action.loading = true
|
||||
action.error = ''
|
||||
action.payload = null
|
||||
try {
|
||||
action.payload = await fn()
|
||||
} catch (e) {
|
||||
action.error = e instanceof ApiError ? e.message : String(e)
|
||||
action.payload = e instanceof ApiError ? e.payload : null
|
||||
} finally {
|
||||
action.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatest(reset: boolean) {
|
||||
latest.loading = true
|
||||
latest.error = ''
|
||||
try {
|
||||
const latest_time = reset ? 0 : latest.next_time
|
||||
const res = await feedApi.listLatest({ limit: latest.limit, latest_time })
|
||||
latest.has_more = res.has_more
|
||||
latest.next_time = res.next_time
|
||||
latest.items = reset ? res.video_list : latest.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
latest.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
latest.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLikesCount(reset: boolean) {
|
||||
likesCount.loading = true
|
||||
likesCount.error = ''
|
||||
try {
|
||||
const res = await feedApi.listLikesCount({
|
||||
limit: likesCount.limit,
|
||||
likes_count_before: reset ? undefined : likesCount.next_likes_count_before,
|
||||
id_before: reset ? undefined : likesCount.next_id_before,
|
||||
})
|
||||
likesCount.has_more = res.has_more
|
||||
likesCount.next_likes_count_before = res.next_likes_count_before
|
||||
likesCount.next_id_before = res.next_id_before
|
||||
likesCount.items = reset ? res.video_list : likesCount.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
likesCount.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
likesCount.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFollowing(reset: boolean) {
|
||||
following.loading = true
|
||||
following.error = ''
|
||||
try {
|
||||
const latest_time = reset ? 0 : following.next_time
|
||||
const res = await feedApi.listByFollowing({ limit: following.limit, latest_time })
|
||||
following.has_more = res.has_more
|
||||
following.next_time = res.next_time
|
||||
following.items = reset ? res.video_list : following.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
following.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
following.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLike(item: FeedVideoItem) {
|
||||
if (!auth.isLoggedIn) return
|
||||
|
||||
await runAction(item.is_liked ? '取消点赞' : '点赞', async () => {
|
||||
if (item.is_liked) await likeApi.unlike(item.id)
|
||||
else await likeApi.like(item.id)
|
||||
|
||||
item.is_liked = !item.is_liked
|
||||
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
|
||||
return { ok: true, is_liked: item.is_liked, likes_count: item.likes_count }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLatest(true)
|
||||
await loadLikesCount(true)
|
||||
if (auth.isLoggedIn) {
|
||||
await loadFollowing(true)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => auth.isLoggedIn,
|
||||
async (v) => {
|
||||
if (v && following.items.length === 0) {
|
||||
await loadFollowing(true)
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="grid two">
|
||||
<div class="card">
|
||||
<p class="title">Feed</p>
|
||||
<p class="subtle">`/feed/listLatest` 与 `/feed/listLikesCount` 支持匿名(可选 JWT);`/feed/listByFollowing` 需要 JWT。</p>
|
||||
|
||||
<div class="card" style="margin-top: 12px">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div>
|
||||
<p class="title">最新流(listLatest)</p>
|
||||
<div class="subtle">limit:{{ latest.limit }} · next_time:{{ latest.next_time }} · has_more:{{ latest.has_more }}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="subtle" style="margin: 0">limit</label>
|
||||
<input v-model.number="latest.limit" type="number" min="1" max="50" style="width: 90px" />
|
||||
<button class="primary" type="button" :disabled="latest.loading" @click="loadLatest(true)">刷新</button>
|
||||
<button type="button" :disabled="latest.loading || !latest.has_more" @click="loadLatest(false)">加载更多</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="latest.error" class="pill bad" style="margin-top: 10px">错误:{{ latest.error }}</div>
|
||||
<div class="grid" style="gap: 10px; margin-top: 12px">
|
||||
<FeedVideoCard
|
||||
v-for="item in latest.items"
|
||||
:key="`latest-${item.id}`"
|
||||
:item="item"
|
||||
:can-like="canLike"
|
||||
:busy="action.loading"
|
||||
@toggle-like="toggleLike"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 12px">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div>
|
||||
<p class="title">点赞数流(listLikesCount)</p>
|
||||
<div class="subtle">
|
||||
limit:{{ likesCount.limit }} · next=(likes={{ likesCount.next_likes_count_before }}, id={{ likesCount.next_id_before }})
|
||||
· has_more:{{ likesCount.has_more }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="subtle" style="margin: 0">limit</label>
|
||||
<input v-model.number="likesCount.limit" type="number" min="1" max="50" style="width: 90px" />
|
||||
<button class="primary" type="button" :disabled="likesCount.loading" @click="loadLikesCount(true)">刷新</button>
|
||||
<button type="button" :disabled="likesCount.loading || !likesCount.has_more" @click="loadLikesCount(false)">
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="likesCount.error" class="pill bad" style="margin-top: 10px">错误:{{ likesCount.error }}</div>
|
||||
<div class="grid" style="gap: 10px; margin-top: 12px">
|
||||
<FeedVideoCard
|
||||
v-for="item in likesCount.items"
|
||||
:key="`likes-${item.id}`"
|
||||
:item="item"
|
||||
:can-like="canLike"
|
||||
:busy="action.loading"
|
||||
@toggle-like="toggleLike"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 12px">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div>
|
||||
<p class="title">关注流(listByFollowing,JWT)</p>
|
||||
<div class="subtle">
|
||||
limit:{{ following.limit }} · next_time:{{ following.next_time }} · has_more:{{ following.has_more }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="subtle" style="margin: 0">limit</label>
|
||||
<input v-model.number="following.limit" type="number" min="1" max="50" style="width: 90px" />
|
||||
<button class="primary" type="button" :disabled="following.loading" @click="loadFollowing(true)">刷新</button>
|
||||
<button type="button" :disabled="following.loading || !following.has_more" @click="loadFollowing(false)">加载更多</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!auth.isLoggedIn" class="pill bad" style="margin-top: 10px">未登录:无法访问关注流</div>
|
||||
<div v-if="following.error" class="pill bad" style="margin-top: 10px">错误:{{ following.error }}</div>
|
||||
<div class="grid" style="gap: 10px; margin-top: 12px">
|
||||
<FeedVideoCard
|
||||
v-for="item in following.items"
|
||||
:key="`following-${item.id}`"
|
||||
:item="item"
|
||||
:can-like="canLike"
|
||||
:busy="action.loading"
|
||||
@toggle-like="toggleLike"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="title">动作输出(点赞等)</p>
|
||||
<div class="row" style="margin-bottom: 10px">
|
||||
<span class="pill">动作:{{ action.name || '-' }}</span>
|
||||
<span v-if="action.loading" class="pill">请求中…</span>
|
||||
<span v-if="action.error" class="pill bad">错误:{{ action.error }}</span>
|
||||
</div>
|
||||
<JsonBox :value="action.payload" />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
911
frontend/src/views/HomeView.vue
Normal file
911
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,911 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppShell from '../components/AppShell.vue'
|
||||
import UserAvatar from '../components/UserAvatar.vue'
|
||||
import { ApiError } from '../api/client'
|
||||
import * as commentApi from '../api/comment'
|
||||
import * as feedApi from '../api/feed'
|
||||
import * as likeApi from '../api/like'
|
||||
import type { Comment, FeedVideoItem } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useSocialStore } from '../stores/social'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
type TabKey = 'recommend' | 'hot' | 'following'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const social = useSocialStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const tab = ref<TabKey>('recommend')
|
||||
const scroller = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const q = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
|
||||
const recommend = reactive({
|
||||
items: [] as FeedVideoItem[],
|
||||
loading: false,
|
||||
error: '',
|
||||
hasMore: false,
|
||||
nextTime: 0,
|
||||
})
|
||||
|
||||
const hot = reactive({
|
||||
items: [] as FeedVideoItem[],
|
||||
loading: false,
|
||||
error: '',
|
||||
hasMore: false,
|
||||
nextLikesCountBefore: undefined as number | undefined,
|
||||
nextIdBefore: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const following = reactive({
|
||||
items: [] as FeedVideoItem[],
|
||||
loading: false,
|
||||
error: '',
|
||||
hasMore: false,
|
||||
nextTime: 0,
|
||||
})
|
||||
|
||||
const likeBusy = reactive<Record<string, boolean>>({})
|
||||
const followBusy = reactive<Record<string, boolean>>({})
|
||||
|
||||
const muted = ref(true)
|
||||
const activeIndex = ref(0)
|
||||
const videoMap = new Map<number, HTMLVideoElement>()
|
||||
|
||||
const currentState = computed(() => {
|
||||
if (tab.value === 'hot') return hot
|
||||
if (tab.value === 'following') return following
|
||||
return recommend
|
||||
})
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const items = currentState.value.items
|
||||
if (!q.value) return items
|
||||
return items.filter((v) => v.title.toLowerCase().includes(q.value) || v.author.username.toLowerCase().includes(q.value))
|
||||
})
|
||||
|
||||
const activeItem = computed(() => filteredItems.value[activeIndex.value] ?? null)
|
||||
const myAccountId = computed(() => auth.claims?.account_id ?? 0)
|
||||
|
||||
function setVideoRef(id: number, el: HTMLVideoElement | null) {
|
||||
if (el) {
|
||||
el.muted = muted.value
|
||||
videoMap.set(id, el)
|
||||
} else {
|
||||
videoMap.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function getScrollerHeight() {
|
||||
return scroller.value?.clientHeight ?? 0
|
||||
}
|
||||
|
||||
function scrollToIndex(idx: number) {
|
||||
const el = scroller.value
|
||||
if (!el) return
|
||||
const h = getScrollerHeight()
|
||||
if (!h) return
|
||||
const next = Math.max(0, Math.min(idx, Math.max(0, filteredItems.value.length - 1)))
|
||||
el.scrollTo({ top: next * h, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
let scrollRaf = 0
|
||||
function onScroll() {
|
||||
if (!scroller.value) return
|
||||
if (scrollRaf) return
|
||||
scrollRaf = window.requestAnimationFrame(() => {
|
||||
scrollRaf = 0
|
||||
const el = scroller.value
|
||||
if (!el) return
|
||||
const h = el.clientHeight
|
||||
if (!h) return
|
||||
const idx = Math.round(el.scrollTop / h)
|
||||
if (idx !== activeIndex.value) activeIndex.value = idx
|
||||
})
|
||||
}
|
||||
|
||||
async function playActive() {
|
||||
const item = activeItem.value
|
||||
if (!item) return
|
||||
for (const [id, v] of videoMap.entries()) {
|
||||
if (id === item.id) continue
|
||||
v.pause()
|
||||
}
|
||||
const video = videoMap.get(item.id)
|
||||
if (!video) return
|
||||
video.muted = muted.value
|
||||
try {
|
||||
await video.play()
|
||||
} catch {
|
||||
// ignore autoplay errors
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
muted.value = !muted.value
|
||||
for (const v of videoMap.values()) v.muted = muted.value
|
||||
toast.info(muted.value ? '已静音' : '已取消静音')
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
const item = activeItem.value
|
||||
if (!item) return
|
||||
const video = videoMap.get(item.id)
|
||||
if (!video) return
|
||||
if (video.paused) {
|
||||
void video.play()
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
}
|
||||
|
||||
async function needLogin() {
|
||||
toast.error('请先登录')
|
||||
await router.push('/account')
|
||||
}
|
||||
|
||||
async function loadRecommend(reset: boolean) {
|
||||
if (recommend.loading) return
|
||||
recommend.loading = true
|
||||
recommend.error = ''
|
||||
try {
|
||||
const res = await feedApi.listLatest({ limit: 10, latest_time: reset ? 0 : recommend.nextTime })
|
||||
recommend.hasMore = res.has_more
|
||||
recommend.nextTime = res.next_time
|
||||
recommend.items = reset ? res.video_list : recommend.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
recommend.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
recommend.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHot(reset: boolean) {
|
||||
if (hot.loading) return
|
||||
hot.loading = true
|
||||
hot.error = ''
|
||||
try {
|
||||
const res = await feedApi.listLikesCount({
|
||||
limit: 10,
|
||||
likes_count_before: reset ? undefined : hot.nextLikesCountBefore,
|
||||
id_before: reset ? undefined : hot.nextIdBefore,
|
||||
})
|
||||
hot.hasMore = res.has_more
|
||||
hot.nextLikesCountBefore = res.next_likes_count_before
|
||||
hot.nextIdBefore = res.next_id_before
|
||||
hot.items = reset ? res.video_list : hot.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
hot.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
hot.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFollowing(reset: boolean) {
|
||||
if (!auth.isLoggedIn) {
|
||||
following.error = '登录后才能查看关注流'
|
||||
return
|
||||
}
|
||||
if (following.loading) return
|
||||
following.loading = true
|
||||
following.error = ''
|
||||
try {
|
||||
const res = await feedApi.listByFollowing({ limit: 10, latest_time: reset ? 0 : following.nextTime })
|
||||
following.hasMore = res.has_more
|
||||
following.nextTime = res.next_time
|
||||
following.items = reset ? res.video_list : following.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
following.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
following.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTabLoaded() {
|
||||
if (tab.value === 'recommend' && recommend.items.length === 0) await loadRecommend(true)
|
||||
if (tab.value === 'hot' && hot.items.length === 0) await loadHot(true)
|
||||
if (tab.value === 'following' && following.items.length === 0) await loadFollowing(true)
|
||||
}
|
||||
|
||||
async function loadMoreIfNeeded() {
|
||||
const idx = activeIndex.value
|
||||
const items = filteredItems.value
|
||||
if (items.length === 0) return
|
||||
if (idx < items.length - 3) return
|
||||
|
||||
if (tab.value === 'recommend' && recommend.hasMore) await loadRecommend(false)
|
||||
if (tab.value === 'hot' && hot.hasMore) await loadHot(false)
|
||||
if (tab.value === 'following' && following.hasMore) await loadFollowing(false)
|
||||
}
|
||||
|
||||
async function toggleLike(item: FeedVideoItem) {
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
const key = String(item.id)
|
||||
if (likeBusy[key]) return
|
||||
likeBusy[key] = true
|
||||
try {
|
||||
if (item.is_liked) await likeApi.unlike(item.id)
|
||||
else await likeApi.like(item.id)
|
||||
item.is_liked = !item.is_liked
|
||||
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
likeBusy[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollow(authorId: number) {
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
const key = String(authorId)
|
||||
if (followBusy[key]) return
|
||||
followBusy[key] = true
|
||||
try {
|
||||
if (social.isFollowing(authorId)) {
|
||||
await social.unfollow(authorId)
|
||||
toast.info('已取关')
|
||||
} else {
|
||||
await social.follow(authorId)
|
||||
toast.success('已关注')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
followBusy[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function share(item: FeedVideoItem) {
|
||||
const url = `${location.origin}/video/${item.id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success('链接已复制')
|
||||
} catch {
|
||||
window.prompt('复制链接', url)
|
||||
}
|
||||
}
|
||||
|
||||
const drawer = reactive({
|
||||
open: false,
|
||||
video: null as FeedVideoItem | null,
|
||||
loading: false,
|
||||
error: '',
|
||||
comments: [] as Comment[],
|
||||
content: '',
|
||||
})
|
||||
|
||||
function closeDrawer() {
|
||||
drawer.open = false
|
||||
drawer.video = null
|
||||
drawer.comments = []
|
||||
drawer.content = ''
|
||||
drawer.error = ''
|
||||
}
|
||||
|
||||
async function openComments(item: FeedVideoItem) {
|
||||
drawer.open = true
|
||||
drawer.video = item
|
||||
drawer.content = ''
|
||||
await loadComments()
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
if (!drawer.video) return
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
drawer.comments = await commentApi.listAll(drawer.video.id)
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publishComment() {
|
||||
if (!drawer.video) return
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
const content = drawer.content.trim()
|
||||
if (!content) return
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
await commentApi.publish(drawer.video.id, content)
|
||||
drawer.content = ''
|
||||
await loadComments()
|
||||
toast.success('评论已发布')
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(drawer.error)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function canDeleteComment(c: Comment) {
|
||||
const myId = auth.claims?.account_id
|
||||
return !!myId && myId === c.author_id
|
||||
}
|
||||
|
||||
async function deleteComment(commentId: number) {
|
||||
if (!drawer.video) return
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
if (!window.confirm('确认删除这条评论?')) return
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
await commentApi.remove(commentId)
|
||||
await loadComments()
|
||||
toast.info('评论已删除')
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(drawer.error)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onKeydown(e: KeyboardEvent) {
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return
|
||||
if (drawer.open) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
scrollToIndex(activeIndex.value + 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
scrollToIndex(activeIndex.value - 1)
|
||||
} else if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
togglePlayPause()
|
||||
} else if (e.key.toLowerCase() === 'm') {
|
||||
e.preventDefault()
|
||||
toggleMute()
|
||||
} else if (e.key.toLowerCase() === 'c') {
|
||||
if (activeItem.value) {
|
||||
e.preventDefault()
|
||||
await openComments(activeItem.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeItem, async () => {
|
||||
await nextTick()
|
||||
await playActive()
|
||||
await loadMoreIfNeeded()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => tab.value,
|
||||
async () => {
|
||||
activeIndex.value = 0
|
||||
videoMap.clear()
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
await ensureTabLoaded()
|
||||
await nextTick()
|
||||
await playActive()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => q.value,
|
||||
async () => {
|
||||
activeIndex.value = 0
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
await nextTick()
|
||||
await playActive()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filteredItems.value.length,
|
||||
(len) => {
|
||||
if (len === 0) activeIndex.value = 0
|
||||
else if (activeIndex.value > len - 1) activeIndex.value = len - 1
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => auth.isLoggedIn,
|
||||
async (v) => {
|
||||
if (tab.value === 'following' && v && following.items.length === 0) {
|
||||
await loadFollowing(true)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureTabLoaded()
|
||||
await nextTick()
|
||||
await playActive()
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell full>
|
||||
<div class="page">
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ on: tab === 'recommend' }" type="button" @click="tab = 'recommend'">推荐</button>
|
||||
<button class="tab" :class="{ on: tab === 'following' }" type="button" @click="tab = 'following'">关注</button>
|
||||
<button class="tab" :class="{ on: tab === 'hot' }" type="button" @click="tab = 'hot'">热榜</button>
|
||||
|
||||
<div class="tabs-right">
|
||||
<button class="chip" type="button" @click="toggleMute">{{ muted ? '静音' : '有声' }}</button>
|
||||
<RouterLink class="chip" :to="activeItem ? `/video/${activeItem.id}` : '/video'">详情</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="scroller" class="scroller" @scroll="onScroll">
|
||||
<div v-if="currentState.loading && currentState.items.length === 0" class="center-hint">加载中…</div>
|
||||
<div v-else-if="currentState.error && currentState.items.length === 0" class="center-hint bad">
|
||||
{{ currentState.error }}
|
||||
</div>
|
||||
<div v-else-if="filteredItems.length === 0" class="center-hint">没有匹配内容</div>
|
||||
|
||||
<section
|
||||
v-for="(item, idx) in filteredItems"
|
||||
:key="`${tab}-${item.id}`"
|
||||
class="slide"
|
||||
:class="{ active: idx === activeIndex }"
|
||||
>
|
||||
<div class="stage" @click="togglePlayPause" @dblclick.prevent="toggleLike(item)">
|
||||
<video
|
||||
class="video"
|
||||
:ref="(el) => setVideoRef(item.id, el as HTMLVideoElement | null)"
|
||||
:src="item.play_url"
|
||||
:poster="item.cover_url"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
loop
|
||||
/>
|
||||
<div class="grad" />
|
||||
|
||||
<div class="meta">
|
||||
<RouterLink class="author-link" :to="`/u/${item.author.id}`" @click.stop>
|
||||
<UserAvatar :username="item.author.username" :id="item.author.id" :size="34" />
|
||||
<span class="author-name">@{{ item.author.username }}</span>
|
||||
</RouterLink>
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div v-if="item.description" class="desc">{{ item.description }}</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="act" type="button" :disabled="!!likeBusy[String(item.id)]" @click.stop="toggleLike(item)">
|
||||
<span class="icon" :class="{ liked: item.is_liked }">♥</span>
|
||||
<span class="count">{{ item.likes_count }}</span>
|
||||
</button>
|
||||
|
||||
<button class="act" type="button" @click.stop="openComments(item)">
|
||||
<span class="icon">💬</span>
|
||||
<span class="count">评论</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!myAccountId || myAccountId !== item.author.id"
|
||||
class="act"
|
||||
type="button"
|
||||
:disabled="!!followBusy[String(item.author.id)]"
|
||||
@click.stop="toggleFollow(item.author.id)"
|
||||
>
|
||||
<span class="icon">+</span>
|
||||
<span class="count">{{ social.isFollowing(item.author.id) ? '已关注' : '关注' }}</span>
|
||||
</button>
|
||||
|
||||
<button class="act" type="button" @click.stop="share(item)">
|
||||
<span class="icon">↗</span>
|
||||
<span class="count">分享</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
<span class="chip mono">↑ ↓ 切换</span>
|
||||
<span class="chip mono">空格 暂停</span>
|
||||
<span class="chip mono">M 静音</span>
|
||||
<span class="chip mono">C 评论</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="drawer.open" class="drawer-backdrop" @click.self="closeDrawer">
|
||||
<div class="drawer">
|
||||
<div class="drawer-head">
|
||||
<div class="drawer-title">{{ drawer.video?.title ?? '评论' }}</div>
|
||||
<button class="drawer-x" type="button" @click="closeDrawer">×</button>
|
||||
</div>
|
||||
|
||||
<div class="drawer-body">
|
||||
<div v-if="drawer.loading" class="drawer-hint">加载中…</div>
|
||||
<div v-else-if="drawer.error" class="drawer-hint bad">{{ drawer.error }}</div>
|
||||
<div v-else-if="drawer.comments.length === 0" class="drawer-hint">暂无评论</div>
|
||||
|
||||
<div class="comment" v-for="c in drawer.comments" :key="c.id">
|
||||
<div class="comment-top">
|
||||
<div class="comment-user">{{ c.username }}</div>
|
||||
<div class="comment-meta mono">
|
||||
#{{ c.id }} · {{ new Date(c.created_at).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-content">{{ c.content }}</div>
|
||||
<div class="comment-actions">
|
||||
<button v-if="canDeleteComment(c)" class="chip danger" type="button" :disabled="drawer.loading" @click="deleteComment(c.id)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-foot">
|
||||
<textarea v-model="drawer.content" placeholder="说点什么…" :disabled="drawer.loading" />
|
||||
<div class="row" style="justify-content: space-between; margin-top: 8px">
|
||||
<button class="chip" type="button" :disabled="drawer.loading" @click="loadComments">刷新</button>
|
||||
<button class="chip primary" type="button" :disabled="drawer.loading || !drawer.content.trim()" @click="publishComment">
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-radius: 999px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.on {
|
||||
border-color: rgba(254, 44, 85, 0.5);
|
||||
background: rgba(254, 44, 85, 0.16);
|
||||
}
|
||||
|
||||
.tabs-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: y mandatory;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.center-hint {
|
||||
height: calc(100% - 60px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.center-hint.bad {
|
||||
color: rgba(254, 44, 85, 0.92);
|
||||
}
|
||||
|
||||
.slide {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
scroll-snap-align: start;
|
||||
padding: 18px 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.stage {
|
||||
width: min(980px, calc(100vw - 28px));
|
||||
height: calc(100vh - 56px - 52px - 36px);
|
||||
position: relative;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.grad {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.68), rgba(0, 0, 0, 0.12) 40%, rgba(0, 0, 0, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.meta {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 18px;
|
||||
max-width: min(620px, calc(100% - 96px));
|
||||
}
|
||||
|
||||
.author-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2px;
|
||||
margin-bottom: 6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.author-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
text-shadow: 0 14px 30px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.act {
|
||||
width: 70px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
padding: 10px 10px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.act:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.act:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.icon.liked {
|
||||
color: rgba(254, 44, 85, 1);
|
||||
text-shadow: 0 10px 20px rgba(254, 44, 85, 0.25);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 14px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chip.primary {
|
||||
border-color: rgba(254, 44, 85, 0.45);
|
||||
background: rgba(254, 44, 85, 0.14);
|
||||
}
|
||||
|
||||
.chip.danger {
|
||||
border-color: rgba(254, 44, 85, 0.55);
|
||||
background: rgba(254, 44, 85, 0.12);
|
||||
}
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 120;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: min(420px, calc(100vw - 18px));
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.drawer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-x {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
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-foot {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.drawer-foot textarea {
|
||||
width: 100%;
|
||||
min-height: 82px;
|
||||
resize: none;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drawer-hint {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.drawer-hint.bad {
|
||||
color: rgba(254, 44, 85, 0.92);
|
||||
}
|
||||
|
||||
.comment {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 14px;
|
||||
padding: 10px 10px;
|
||||
}
|
||||
|
||||
.comment-top {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.comment-user {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stage {
|
||||
width: calc(100vw - 28px);
|
||||
height: calc(100vh - 56px - 52px - 36px);
|
||||
}
|
||||
.drawer-backdrop {
|
||||
justify-items: center;
|
||||
align-items: end;
|
||||
}
|
||||
.drawer {
|
||||
width: calc(100vw - 16px);
|
||||
height: min(72vh, 560px);
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px 18px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
frontend/src/views/RegisterView.vue
Normal file
67
frontend/src/views/RegisterView.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppShell from '../components/AppShell.vue'
|
||||
import { ApiError } from '../api/client'
|
||||
import * as accountApi from '../api/account'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToastStore()
|
||||
|
||||
const busy = ref(false)
|
||||
const form = reactive({ username: '', password: '' })
|
||||
|
||||
async function submit() {
|
||||
if (busy.value) return
|
||||
const username = form.username.trim()
|
||||
const password = form.password.trim()
|
||||
if (!username || !password) {
|
||||
toast.error('请输入 username 和 password')
|
||||
return
|
||||
}
|
||||
|
||||
busy.value = true
|
||||
try {
|
||||
await accountApi.register(username, password)
|
||||
toast.success('注册成功,请登录')
|
||||
await router.push('/account')
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="grid two">
|
||||
<div class="card">
|
||||
<p class="title">注册</p>
|
||||
<p class="subtle">创建新账号(对应后端 `/account/register`)。</p>
|
||||
<div class="grid" style="margin-top: 12px">
|
||||
<div>
|
||||
<label>username</label>
|
||||
<input v-model.trim="form.username" autocomplete="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label>password</label>
|
||||
<input v-model.trim="form.password" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="row" style="justify-content: flex-end">
|
||||
<button class="primary" type="button" :disabled="busy" @click="submit">注册</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="title">提示</p>
|
||||
<p class="muted">注册成功后会跳回「账号」页进行登录。</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
168
frontend/src/views/SettingsView.vue
Normal file
168
frontend/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } 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 { useAuthStore } from '../stores/auth'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const busy = ref(false)
|
||||
|
||||
const me = computed(() => ({
|
||||
id: auth.claims?.account_id ?? 0,
|
||||
username: auth.claims?.username ?? '',
|
||||
}))
|
||||
|
||||
const rename = reactive({
|
||||
open: false,
|
||||
newUsername: '',
|
||||
})
|
||||
|
||||
async function openRename() {
|
||||
if (!auth.isLoggedIn) return
|
||||
rename.open = true
|
||||
rename.newUsername = me.value.username
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function submitRename() {
|
||||
if (!auth.isLoggedIn) return
|
||||
if (busy.value) return
|
||||
const newUsername = rename.newUsername.trim()
|
||||
if (!newUsername) {
|
||||
toast.error('请输入新用户名')
|
||||
return
|
||||
}
|
||||
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await accountApi.rename(newUsername)
|
||||
auth.setToken(res.token)
|
||||
rename.open = false
|
||||
toast.success('改名成功(已刷新 token)')
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function goLogin() {
|
||||
await router.push('/account')
|
||||
}
|
||||
|
||||
async function goChangePassword() {
|
||||
await router.push('/account/change-password')
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
if (!auth.isLoggedIn) return
|
||||
if (busy.value) return
|
||||
if (!window.confirm('确认退出登录?')) return
|
||||
|
||||
busy.value = true
|
||||
try {
|
||||
await accountApi.logout()
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(`登出失败:${msg}`)
|
||||
} finally {
|
||||
auth.clearToken()
|
||||
rename.open = false
|
||||
toast.info('已退出登录')
|
||||
busy.value = false
|
||||
await router.push('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div v-if="!auth.isLoggedIn" class="grid two">
|
||||
<div class="card">
|
||||
<p class="title">设置</p>
|
||||
<p class="subtle">需要先登录后才能进行改名/退出等操作。</p>
|
||||
<div class="row" style="margin-top: 12px; justify-content: flex-end">
|
||||
<button class="primary" type="button" @click="goLogin">去登录</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="title">提示</p>
|
||||
<p class="muted">登录入口在「账号」页。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid two">
|
||||
<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="56" />
|
||||
<div>
|
||||
<div class="title" style="margin: 0">@{{ me.username }}</div>
|
||||
<div class="subtle mono">#{{ me.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 14px">
|
||||
<div class="row" style="justify-content: space-between; align-items: center">
|
||||
<p class="title" style="margin: 0">账号设置</p>
|
||||
<button class="ghost" type="button" :disabled="busy" @click="openRename">改名</button>
|
||||
</div>
|
||||
|
||||
<div v-if="rename.open" class="grid" style="margin-top: 12px">
|
||||
<div>
|
||||
<label>new_username</label>
|
||||
<input v-model.trim="rename.newUsername" @keydown.enter="submitRename" />
|
||||
</div>
|
||||
<div class="row" style="justify-content: flex-end">
|
||||
<button type="button" :disabled="busy" @click="rename.open = false">取消</button>
|
||||
<button class="primary" type="button" :disabled="busy" @click="submitRename">提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 14px">
|
||||
<p class="title">账号安全</p>
|
||||
<div class="row">
|
||||
<button class="ghost" type="button" :disabled="busy" @click="goChangePassword">修改密码</button>
|
||||
<button class="danger" type="button" :disabled="busy" @click="onLogout">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="title">说明</p>
|
||||
<div class="grid" style="margin-top: 10px">
|
||||
<div class="pill ok">改名后会返回新 token,旧 token 立即失效</div>
|
||||
<div class="pill ok">退出登录会清空本地 token</div>
|
||||
<div class="pill">修改密码无需登录,但成功后会让旧 token 失效</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ghost {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
455
frontend/src/views/UserProfileView.vue
Normal file
455
frontend/src/views/UserProfileView.vue
Normal file
@@ -0,0 +1,455 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, watch } from 'vue'
|
||||
import { useRoute, 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 socialApi from '../api/social'
|
||||
import type { Account, 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 route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const social = useSocialStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const userId = computed(() => Number(route.params.id))
|
||||
const myId = computed(() => auth.claims?.account_id ?? 0)
|
||||
const isMe = computed(() => myId.value > 0 && myId.value === userId.value)
|
||||
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
error: '',
|
||||
user: null as Account | null,
|
||||
videos: [] as Video[],
|
||||
followers: [] as Account[],
|
||||
vloggers: [] as Account[],
|
||||
socialLoading: false,
|
||||
socialError: '',
|
||||
})
|
||||
|
||||
const isFollowing = computed(() => (auth.isLoggedIn ? social.isFollowing(userId.value) : false))
|
||||
|
||||
async function loadProfile() {
|
||||
if (!Number.isFinite(userId.value) || userId.value <= 0) {
|
||||
state.error = '无效的用户 id'
|
||||
return
|
||||
}
|
||||
|
||||
state.loading = true
|
||||
state.error = ''
|
||||
try {
|
||||
const [u, vids] = await Promise.all([accountApi.findById(userId.value), videoApi.listByAuthorId(userId.value)])
|
||||
state.user = u
|
||||
state.videos = vids
|
||||
} catch (e) {
|
||||
state.error = e instanceof ApiError ? e.message : String(e)
|
||||
state.user = null
|
||||
state.videos = []
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
|
||||
await loadSocialCounts()
|
||||
}
|
||||
|
||||
async function loadSocialCounts() {
|
||||
state.socialError = ''
|
||||
state.followers = []
|
||||
state.vloggers = []
|
||||
|
||||
if (!auth.isLoggedIn) return
|
||||
if (!Number.isFinite(userId.value) || userId.value <= 0) return
|
||||
|
||||
state.socialLoading = true
|
||||
try {
|
||||
const [followersRes, vloggersRes] = await Promise.all([
|
||||
socialApi.getAllFollowers(userId.value),
|
||||
socialApi.getAllVloggers(userId.value),
|
||||
])
|
||||
state.followers = followersRes.followers
|
||||
state.vloggers = vloggersRes.vloggers
|
||||
} catch (e) {
|
||||
state.socialError = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
state.socialLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollow() {
|
||||
if (isMe.value) return
|
||||
if (!auth.isLoggedIn) {
|
||||
toast.error('请先登录')
|
||||
await router.push('/account')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (isFollowing.value) {
|
||||
await social.unfollow(userId.value)
|
||||
toast.info('已取关')
|
||||
} else {
|
||||
await social.follow(userId.value)
|
||||
toast.success('已关注')
|
||||
}
|
||||
await loadSocialCounts()
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
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' ? state.followers : state.vloggers))
|
||||
|
||||
async function goUser(id: number) {
|
||||
drawer.open = false
|
||||
await router.push(`/u/${id}`)
|
||||
}
|
||||
|
||||
async function goVideo(videoId: number) {
|
||||
await router.push(`/video/${videoId}`)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async () => {
|
||||
drawer.open = false
|
||||
await loadProfile()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => auth.isLoggedIn,
|
||||
async () => {
|
||||
await loadSocialCounts()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(loadProfile)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<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="state.user?.username ?? 'User'" :id="state.user?.id ?? userId" :size="64" />
|
||||
<div>
|
||||
<div class="title" style="margin: 0">@{{ state.user?.username ?? '-' }}</div>
|
||||
<div class="subtle mono">#{{ state.user?.id ?? userId }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button v-if="isMe" class="ghost" type="button" @click="router.push('/account')">我的账号</button>
|
||||
<button v-else class="primary" type="button" :disabled="!state.user || state.loading" @click="toggleFollow">
|
||||
{{ isFollowing ? '已关注' : '关注' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="state.loading" class="hint" style="margin-top: 12px">加载中…</div>
|
||||
<div v-else-if="state.error" class="hint bad" style="margin-top: 12px">{{ state.error }}</div>
|
||||
|
||||
<div v-else class="row" style="margin-top: 14px">
|
||||
<button class="metric" type="button" :disabled="!auth.isLoggedIn || state.socialLoading" @click="openFollowers">
|
||||
<div class="metric-num">{{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.followers.length) : '—' }}</div>
|
||||
<div class="metric-label">粉丝</div>
|
||||
</button>
|
||||
<button class="metric" type="button" :disabled="!auth.isLoggedIn || state.socialLoading" @click="openFollowing">
|
||||
<div class="metric-num">{{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.vloggers.length) : '—' }}</div>
|
||||
<div class="metric-label">关注</div>
|
||||
</button>
|
||||
<div class="metric static">
|
||||
<div class="metric-num">{{ state.videos.length }}</div>
|
||||
<div class="metric-label">作品</div>
|
||||
</div>
|
||||
<div v-if="!auth.isLoggedIn" class="subtle" style="margin-left: 8px">登录后可查看粉丝/关注列表</div>
|
||||
<div v-else-if="state.socialError" class="subtle" style="margin-left: 8px">社交信息加载失败:{{ state.socialError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 14px">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<p class="title" style="margin: 0">作品</p>
|
||||
<div class="subtle">点击封面进入播放页</div>
|
||||
</div>
|
||||
|
||||
<div v-if="state.videos.length === 0" class="hint" style="margin-top: 12px">暂无作品</div>
|
||||
|
||||
<div v-else class="video-grid" style="margin-top: 12px">
|
||||
<button v-for="v in state.videos" :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>
|
||||
</div>
|
||||
|
||||
<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="state.socialLoading" class="drawer-hint">加载中…</div>
|
||||
<div v-else-if="state.socialError" class="drawer-hint bad">{{ state.socialError }}</div>
|
||||
<div v-else-if="listItems.length === 0" class="drawer-hint">暂无</div>
|
||||
|
||||
<button v-for="u in listItems" :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>
|
||||
.ghost {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.metric {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
min-width: 120px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.metric.static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.metric:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.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: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.hint.bad {
|
||||
color: rgba(254, 44, 85, 0.92);
|
||||
}
|
||||
|
||||
.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 rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
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;
|
||||
}
|
||||
|
||||
.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(0, 0, 0, 0.65);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
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 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.drawer-x {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
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: rgba(255, 255, 255, 0.78);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.drawer-hint.bad {
|
||||
color: rgba(254, 44, 85, 0.92);
|
||||
}
|
||||
|
||||
.user-row {
|
||||
text-align: left;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
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: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
|
||||
686
frontend/src/views/VideoDetailView.vue
Normal file
686
frontend/src/views/VideoDetailView.vue
Normal file
@@ -0,0 +1,686 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppShell from '../components/AppShell.vue'
|
||||
import UserAvatar from '../components/UserAvatar.vue'
|
||||
import { ApiError } from '../api/client'
|
||||
import * as commentApi from '../api/comment'
|
||||
import * as likeApi from '../api/like'
|
||||
import type { Comment, 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 route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const social = useSocialStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const id = computed(() => Number(route.params.id))
|
||||
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
error: '',
|
||||
video: null as Video | null,
|
||||
isLiked: null as boolean | null,
|
||||
busy: false,
|
||||
})
|
||||
|
||||
const muted = ref(true)
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
const drawer = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
comments: [] as Comment[],
|
||||
content: '',
|
||||
})
|
||||
|
||||
async function needLogin() {
|
||||
toast.error('请先登录')
|
||||
await router.push('/account')
|
||||
}
|
||||
|
||||
async function loadVideo() {
|
||||
if (!Number.isFinite(id.value) || id.value <= 0) {
|
||||
state.error = '无效的 video id'
|
||||
return
|
||||
}
|
||||
state.loading = true
|
||||
state.error = ''
|
||||
try {
|
||||
state.video = await videoApi.getDetail(id.value)
|
||||
} catch (e) {
|
||||
state.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIsLiked() {
|
||||
if (!auth.isLoggedIn) {
|
||||
state.isLiked = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await likeApi.isLiked(id.value)
|
||||
state.isLiked = res.is_liked
|
||||
} catch {
|
||||
state.isLiked = null
|
||||
}
|
||||
}
|
||||
|
||||
async function play() {
|
||||
if (!videoEl.value) return
|
||||
videoEl.value.muted = muted.value
|
||||
try {
|
||||
await videoEl.value.play()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
muted.value = !muted.value
|
||||
if (videoEl.value) videoEl.value.muted = muted.value
|
||||
toast.info(muted.value ? '已静音' : '已取消静音')
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
const v = videoEl.value
|
||||
if (!v) return
|
||||
if (v.paused) void v.play()
|
||||
else v.pause()
|
||||
}
|
||||
|
||||
async function toggleLike() {
|
||||
if (!state.video) return
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
if (state.busy) return
|
||||
|
||||
state.busy = true
|
||||
try {
|
||||
if (state.isLiked) {
|
||||
await likeApi.unlike(id.value)
|
||||
state.isLiked = false
|
||||
state.video.likes_count = Math.max(0, state.video.likes_count - 1)
|
||||
} else {
|
||||
await likeApi.like(id.value)
|
||||
state.isLiked = true
|
||||
state.video.likes_count += 1
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
state.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollow() {
|
||||
if (!state.video) return
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
if (state.busy) return
|
||||
if (auth.claims?.account_id && auth.claims.account_id === state.video.author_id) return
|
||||
|
||||
state.busy = true
|
||||
try {
|
||||
if (social.isFollowing(state.video.author_id)) {
|
||||
await social.unfollow(state.video.author_id)
|
||||
toast.info('已取关')
|
||||
} else {
|
||||
await social.follow(state.video.author_id)
|
||||
toast.success('已关注')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
state.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function share() {
|
||||
if (!state.video) return
|
||||
const url = `${location.origin}/video/${state.video.id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success('链接已复制')
|
||||
} catch {
|
||||
window.prompt('复制链接', url)
|
||||
}
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawer.open = false
|
||||
drawer.comments = []
|
||||
drawer.content = ''
|
||||
drawer.error = ''
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
if (!state.video) return
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
drawer.comments = await commentApi.listAll(state.video.id)
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openComments() {
|
||||
drawer.open = true
|
||||
drawer.content = ''
|
||||
await loadComments()
|
||||
}
|
||||
|
||||
async function publishComment() {
|
||||
if (!state.video) return
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
const content = drawer.content.trim()
|
||||
if (!content) return
|
||||
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
await commentApi.publish(state.video.id, content)
|
||||
drawer.content = ''
|
||||
await loadComments()
|
||||
toast.success('评论已发布')
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(drawer.error)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function canDeleteComment(c: Comment) {
|
||||
const myId = auth.claims?.account_id
|
||||
return !!myId && myId === c.author_id
|
||||
}
|
||||
|
||||
async function deleteComment(commentId: number) {
|
||||
if (!state.video) return
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
if (!window.confirm('确认删除这条评论?')) return
|
||||
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
await commentApi.remove(commentId)
|
||||
await loadComments()
|
||||
toast.info('评论已删除')
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(drawer.error)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => id.value,
|
||||
async () => {
|
||||
closeDrawer()
|
||||
await loadVideo()
|
||||
await loadIsLiked()
|
||||
await nextTick()
|
||||
await play()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => auth.isLoggedIn,
|
||||
async () => {
|
||||
await loadIsLiked()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadVideo()
|
||||
await loadIsLiked()
|
||||
await nextTick()
|
||||
await play()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell full>
|
||||
<div class="page">
|
||||
<div class="top">
|
||||
<div class="top-left">
|
||||
<RouterLink class="chip" to="/">← 返回推荐</RouterLink>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="chip" type="button" @click="toggleMute">{{ muted ? '静音' : '有声' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div v-if="state.loading" class="center-hint">加载中…</div>
|
||||
<div v-else-if="state.error" class="center-hint bad">{{ state.error }}</div>
|
||||
|
||||
<div v-else-if="state.video" class="stage" @click="togglePlayPause">
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="video"
|
||||
:src="state.video.play_url"
|
||||
:poster="state.video.cover_url"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
loop
|
||||
/>
|
||||
<div class="grad" />
|
||||
|
||||
<div class="meta">
|
||||
<RouterLink class="author-link" :to="`/u/${state.video.author_id}`" @click.stop>
|
||||
<UserAvatar :username="state.video.username" :id="state.video.author_id" :size="34" />
|
||||
<span class="author-name">@{{ state.video.username }}</span>
|
||||
</RouterLink>
|
||||
<div class="title">{{ state.video.title }}</div>
|
||||
<div v-if="state.video.description" class="desc">{{ state.video.description }}</div>
|
||||
<div class="row" style="margin-top: 10px">
|
||||
<a class="chip mono" :href="state.video.play_url" target="_blank" rel="noreferrer">play_url</a>
|
||||
<a class="chip mono" :href="state.video.cover_url" target="_blank" rel="noreferrer">cover_url</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="act" type="button" :disabled="state.busy" @click.stop="toggleLike">
|
||||
<span class="icon" :class="{ liked: !!state.isLiked }">♥</span>
|
||||
<span class="count">{{ state.video.likes_count }}</span>
|
||||
</button>
|
||||
|
||||
<button class="act" type="button" @click.stop="openComments">
|
||||
<span class="icon">💬</span>
|
||||
<span class="count">评论</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!auth.claims?.account_id || auth.claims.account_id !== state.video.author_id"
|
||||
class="act"
|
||||
type="button"
|
||||
:disabled="state.busy"
|
||||
@click.stop="toggleFollow"
|
||||
>
|
||||
<span class="icon">+</span>
|
||||
<span class="count">{{ social.isFollowing(state.video.author_id) ? '已关注' : '关注' }}</span>
|
||||
</button>
|
||||
|
||||
<button class="act" type="button" @click.stop="share">
|
||||
<span class="icon">↗</span>
|
||||
<span class="count">分享</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
<span class="chip mono">点击 暂停/播放</span>
|
||||
<span class="chip mono">双击 点赞</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="drawer.open" class="drawer-backdrop" @click.self="closeDrawer">
|
||||
<div class="drawer">
|
||||
<div class="drawer-head">
|
||||
<div class="drawer-title">评论</div>
|
||||
<button class="drawer-x" type="button" @click="closeDrawer">×</button>
|
||||
</div>
|
||||
|
||||
<div class="drawer-body">
|
||||
<div v-if="drawer.loading" class="drawer-hint">加载中…</div>
|
||||
<div v-else-if="drawer.error" class="drawer-hint bad">{{ drawer.error }}</div>
|
||||
<div v-else-if="drawer.comments.length === 0" class="drawer-hint">暂无评论</div>
|
||||
|
||||
<div class="comment" v-for="c in drawer.comments" :key="c.id">
|
||||
<div class="comment-top">
|
||||
<div class="comment-user">{{ c.username }}</div>
|
||||
<div class="comment-meta mono">
|
||||
#{{ c.id }} · {{ new Date(c.created_at).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-content">{{ c.content }}</div>
|
||||
<div class="comment-actions">
|
||||
<button v-if="canDeleteComment(c)" class="chip danger" type="button" :disabled="drawer.loading" @click="deleteComment(c.id)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-foot">
|
||||
<textarea v-model="drawer.content" placeholder="说点什么…" :disabled="drawer.loading" />
|
||||
<div class="row" style="justify-content: space-between; margin-top: 8px">
|
||||
<button class="chip" type="button" :disabled="drawer.loading" @click="loadComments">刷新</button>
|
||||
<button class="chip primary" type="button" :disabled="drawer.loading || !drawer.content.trim()" @click="publishComment">
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.center-hint {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.center-hint.bad {
|
||||
color: rgba(254, 44, 85, 0.92);
|
||||
}
|
||||
|
||||
.stage {
|
||||
width: min(980px, calc(100vw - 28px));
|
||||
height: calc(100vh - 56px - 52px - 36px);
|
||||
position: relative;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.grad {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.68), rgba(0, 0, 0, 0.12) 40%, rgba(0, 0, 0, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.meta {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 18px;
|
||||
max-width: min(620px, calc(100% - 96px));
|
||||
}
|
||||
|
||||
.author-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2px;
|
||||
margin-bottom: 6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.author-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
text-shadow: 0 14px 30px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.act {
|
||||
width: 70px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
padding: 10px 10px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.act:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.act:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.icon.liked {
|
||||
color: rgba(254, 44, 85, 1);
|
||||
text-shadow: 0 10px 20px rgba(254, 44, 85, 0.25);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 14px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chip.primary {
|
||||
border-color: rgba(254, 44, 85, 0.45);
|
||||
background: rgba(254, 44, 85, 0.14);
|
||||
}
|
||||
|
||||
.chip.danger {
|
||||
border-color: rgba(254, 44, 85, 0.55);
|
||||
background: rgba(254, 44, 85, 0.12);
|
||||
}
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 120;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: min(420px, calc(100vw - 18px));
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.drawer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.drawer-x {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
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-foot {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.drawer-foot textarea {
|
||||
width: 100%;
|
||||
min-height: 82px;
|
||||
resize: none;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drawer-hint {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.drawer-hint.bad {
|
||||
color: rgba(254, 44, 85, 0.92);
|
||||
}
|
||||
|
||||
.comment {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 14px;
|
||||
padding: 10px 10px;
|
||||
}
|
||||
|
||||
.comment-top {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.comment-user {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stage {
|
||||
width: calc(100vw - 28px);
|
||||
height: calc(100vh - 56px - 52px - 36px);
|
||||
}
|
||||
.drawer-backdrop {
|
||||
justify-items: center;
|
||||
align-items: end;
|
||||
}
|
||||
.drawer {
|
||||
width: calc(100vw - 16px);
|
||||
height: min(72vh, 560px);
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px 18px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
225
frontend/src/views/VideoView.vue
Normal file
225
frontend/src/views/VideoView.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import AppShell from '../components/AppShell.vue'
|
||||
import JsonBox from '../components/JsonBox.vue'
|
||||
import UserAvatar from '../components/UserAvatar.vue'
|
||||
import { ApiError } from '../api/client'
|
||||
import * as videoApi from '../api/video'
|
||||
import type { Video } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useSocialStore } from '../stores/social'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const social = useSocialStore()
|
||||
const toast = useToastStore()
|
||||
const myId = computed(() => auth.claims?.account_id ?? 0)
|
||||
|
||||
const last = reactive<{ action: string; loading: boolean; data: unknown }>({
|
||||
action: '',
|
||||
loading: false,
|
||||
data: null,
|
||||
})
|
||||
|
||||
async function exec(action: string, fn: () => Promise<unknown>) {
|
||||
last.action = action
|
||||
last.loading = true
|
||||
last.data = null
|
||||
try {
|
||||
const res = await fn()
|
||||
last.data = res
|
||||
return res
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
last.data = e instanceof ApiError ? e.payload : null
|
||||
return null
|
||||
} finally {
|
||||
last.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const publishForm = reactive({ title: '', description: '', play_url: '', cover_url: '' })
|
||||
const listAuthorId = ref<number>(1)
|
||||
const detailId = ref<number>(1)
|
||||
|
||||
const listResult = ref<Video[] | null>(null)
|
||||
const followBusy = reactive<Record<string, boolean>>({})
|
||||
|
||||
async function onPublish() {
|
||||
const res = await exec('发布视频', () => videoApi.publishVideo(publishForm))
|
||||
if (res) toast.success('已发布')
|
||||
}
|
||||
|
||||
async function onListByAuthor() {
|
||||
await exec('按作者列出视频', async () => {
|
||||
const res = await videoApi.listByAuthorId(listAuthorId.value)
|
||||
listResult.value = res
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
async function onGetDetail() {
|
||||
await exec('获取视频详情', () => videoApi.getDetail(detailId.value))
|
||||
}
|
||||
|
||||
async function toggleFollow(authorId: number) {
|
||||
if (!auth.isLoggedIn) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
if (myId.value && myId.value === authorId) return
|
||||
|
||||
const key = String(authorId)
|
||||
if (followBusy[key]) return
|
||||
followBusy[key] = true
|
||||
try {
|
||||
if (social.isFollowing(authorId)) {
|
||||
await social.unfollow(authorId)
|
||||
toast.info('已取关')
|
||||
} else {
|
||||
await social.follow(authorId)
|
||||
toast.success('已关注')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
followBusy[key] = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="grid two">
|
||||
<div class="card">
|
||||
<p class="title">视频</p>
|
||||
<p class="subtle">发布需要 JWT;列表/详情无需 JWT。</p>
|
||||
|
||||
<div class="card" style="margin-top: 12px">
|
||||
<p class="title">发布视频(JWT)</p>
|
||||
<div class="grid two">
|
||||
<div>
|
||||
<label>title</label>
|
||||
<input v-model.trim="publishForm.title" />
|
||||
</div>
|
||||
<div>
|
||||
<label>description</label>
|
||||
<input v-model.trim="publishForm.description" />
|
||||
</div>
|
||||
<div>
|
||||
<label>play_url</label>
|
||||
<input v-model.trim="publishForm.play_url" placeholder="http://..." />
|
||||
</div>
|
||||
<div>
|
||||
<label>cover_url</label>
|
||||
<input v-model.trim="publishForm.cover_url" placeholder="http://..." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 10px">
|
||||
<button class="primary" type="button" :disabled="last.loading" @click="onPublish">发布</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid two" style="margin-top: 12px">
|
||||
<div class="card">
|
||||
<p class="title">按作者列出</p>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>author_id</label>
|
||||
<input v-model.number="listAuthorId" type="number" min="1" />
|
||||
</div>
|
||||
<button class="primary" type="button" :disabled="last.loading" @click="onListByAuthor">查询</button>
|
||||
<div v-if="listResult" class="subtle">共 {{ listResult.length }} 条</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="title">详情</p>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>id</label>
|
||||
<input v-model.number="detailId" type="number" min="1" />
|
||||
</div>
|
||||
<button class="primary" type="button" :disabled="last.loading" @click="onGetDetail">获取</button>
|
||||
<RouterLink class="pill" :to="`/video/${detailId}`">打开详情页(含评论/点赞)</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="listResult" class="card" style="margin-top: 12px">
|
||||
<p class="title">列表结果</p>
|
||||
<div class="grid" style="gap: 10px">
|
||||
<div v-for="v in listResult" :key="v.id" class="card" style="background: rgba(255, 255, 255, 0.05)">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div>
|
||||
<div class="title">
|
||||
<RouterLink :to="`/video/${v.id}`">{{ v.title }}</RouterLink>
|
||||
</div>
|
||||
<div class="row" style="gap: 10px; margin-top: 6px">
|
||||
<RouterLink class="author-link" :to="`/u/${v.author_id}`">
|
||||
<UserAvatar :username="v.username" :id="v.author_id" :size="28" />
|
||||
<span class="author-name">@{{ v.username }}</span>
|
||||
</RouterLink>
|
||||
<span class="subtle mono">#{{ v.author_id }}</span>
|
||||
<span class="subtle">❤️ {{ v.likes_count }}</span>
|
||||
<span class="subtle">{{ new Date(v.create_time).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button
|
||||
v-if="auth.isLoggedIn && (!myId || myId !== v.author_id)"
|
||||
class="primary"
|
||||
type="button"
|
||||
style="padding: 8px 10px"
|
||||
:disabled="!!followBusy[String(v.author_id)]"
|
||||
@click.stop="toggleFollow(v.author_id)"
|
||||
>
|
||||
{{ social.isFollowing(v.author_id) ? '已关注' : '关注' }}
|
||||
</button>
|
||||
<RouterLink class="pill" :to="`/video/${v.id}`">详情</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 10px">
|
||||
<a class="pill mono" :href="v.play_url" target="_blank" rel="noreferrer">play_url</a>
|
||||
<a class="pill mono" :href="v.cover_url" target="_blank" rel="noreferrer">cover_url</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="title">最近响应</p>
|
||||
<div class="row" style="margin-bottom: 10px">
|
||||
<span class="pill">动作:{{ last.action || '-' }}</span>
|
||||
<span v-if="last.loading" class="pill">请求中…</span>
|
||||
</div>
|
||||
<JsonBox :value="last.data" />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.author-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.author-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
16
frontend/tsconfig.app.json
Normal file
16
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user