feat: 前端

This commit is contained in:
Leon
2025-12-25 22:40:56 +08:00
parent 027df9d32b
commit ce0aec6110
42 changed files with 6170 additions and 0 deletions

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

28
frontend/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

View 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 })
}

View 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
}

View 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
View 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
View 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 })
}

View 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
View 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
View 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 })
}

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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')

View 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

View 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 }
})

View 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,
}
})

View 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
View 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
View 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
}
}

View 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>

View 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>

View 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">关注流listByFollowingJWT</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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/, ''),
},
},
},
})