diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7c2aa3f..d26adbe 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,3 +1,3 @@ - - - + + + diff --git a/frontend/src/api/account.ts b/frontend/src/api/account.ts index b0cec2e..8910197 100644 --- a/frontend/src/api/account.ts +++ b/frontend/src/api/account.ts @@ -1,34 +1,48 @@ -import { postJson } from './client' -import type { Account, MessageResponse, TokenResponse } from './types' - -export function register(username: string, password: string) { - return postJson('/account/register', { username, password }) -} - -export function login(username: string, password: string) { - return postJson('/account/login', { username, password }) -} - -export function logout() { - return postJson('/account/logout', {}, { authRequired: true }) -} - -export function rename(newUsername: string) { - return postJson('/account/rename', { new_username: newUsername }, { authRequired: true }) -} - -export function changePassword(username: string, oldPassword: string, newPassword: string) { - return postJson('/account/changePassword', { - username, - old_password: oldPassword, - new_password: newPassword, - }) -} - -export function findById(id: number) { - return postJson('/account/findByID', { id }) -} - -export function findByUsername(username: string) { - return postJson('/account/findByUsername', { username }) -} +import { postForm, postJson } from './client' +import type { Account, MessageResponse, TokenResponse } from './types' + +export function register(username: string, password: string) { + return postJson('/account/register', { username, password }) +} + +export function login(username: string, password: string) { + return postJson('/account/login', { username, password }) +} + +export function logout() { + return postJson('/account/logout', {}, { authRequired: true }) +} + +export function rename(newUsername: string) { + return postJson('/account/rename', { new_username: newUsername }, { authRequired: true }) +} + +export function changePassword(username: string, oldPassword: string, newPassword: string) { + return postJson('/account/changePassword', { + username, + old_password: oldPassword, + new_password: newPassword, + }) +} + +export function findById(id: number) { + return postJson('/account/findByID', { id }) +} + +export function findByUsername(username: string) { + return postJson('/account/findByUsername', { username }) +} + +export function uploadAvatar(file: File) { + const fd = new FormData() + fd.append('file', file) + return postForm<{ avatar_url: string }>('/account/uploadAvatar', fd, { authRequired: true }) +} + +export function updateProfile(data: { avatar_url?: string; bio?: string }) { + return postJson('/account/updateProfile', data, { authRequired: true }) +} + +export function refresh(refreshToken: string) { + return postJson('/account/refresh', { refresh_token: refreshToken }) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7f4bbd9..408f841 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,104 +1,130 @@ -import { useAuthStore } from '../stores/auth' -import { reportError } from '../utils/error-reporter' - -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(path: string, body: unknown, options?: { authRequired?: boolean }): Promise { - const auth = useAuthStore() - const token = auth.token - - if (options?.authRequired && !token) { - throw new ApiError('需要先登录(缺少 token)', 401) - } - - const headers: Record = { '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})` - const apiErr = new ApiError(msg, res.status, data) - reportError(apiErr, { path, status: res.status }) - throw apiErr - } - - return data as T -} - -export async function postForm(path: string, body: FormData, options?: { authRequired?: boolean }): Promise { - const auth = useAuthStore() - const token = auth.token - - if (options?.authRequired && !token) { - throw new ApiError('需要先登录(缺少 token)', 401) - } - - const headers: Record = {} - if (token) headers.Authorization = `Bearer ${token}` - - const res = await fetch(`${API_BASE}${path}`, { - method: 'POST', - headers, - 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})` - const apiErr = new ApiError(msg, res.status, data) - reportError(apiErr, { path, status: res.status }) - throw apiErr - } - - return data as T -} +import { useAuthStore } from '../stores/auth' +import { reportError } from '../utils/error-reporter' + +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' + +let isRefreshing = false +let refreshPromise: Promise | null = null + +async function tryRefresh(): Promise { + const auth = useAuthStore() + if (!auth.refreshToken) return null + if (isRefreshing) return refreshPromise + isRefreshing = true + refreshPromise = (async () => { + try { + const res = await fetch(`${API_BASE}/account/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: auth.refreshToken }), + }) + if (!res.ok) { auth.clearTokens(); return null } + const data = await res.json() + auth.setToken(data.token) + return data.token as string + } catch { + auth.clearTokens() + return null + } finally { + isRefreshing = false + } + })() + return refreshPromise +} + +export async function postJson(path: string, body: unknown, options?: { authRequired?: boolean }): Promise { + const auth = useAuthStore() + const token = auth.token + + if (options?.authRequired && !token) { + throw new ApiError('需要先登录(缺少 token)', 401) + } + + const headers: Record = { 'Content-Type': 'application/json' } + if (token) headers.Authorization = `Bearer ${token}` + + const res = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body ?? {}), + }) + + if (res.status === 401 && path !== '/account/refresh') { + const newToken = await tryRefresh() + if (newToken) { + headers.Authorization = `Bearer ${newToken}` + const retryRes = await fetch(`${API_BASE}${path}`, { + method: 'POST', headers, body: JSON.stringify(body ?? {}), + }) + return handleResponse(retryRes, path) + } + } + + return handleResponse(res, path) +} + +export async function postForm(path: string, body: FormData, options?: { authRequired?: boolean }): Promise { + const auth = useAuthStore() + const token = auth.token + + if (options?.authRequired && !token) { + throw new ApiError('需要先登录(缺少 token)', 401) + } + + const headers: Record = {} + if (token) headers.Authorization = `Bearer ${token}` + + const res = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers, + body, + }) + + if (res.status === 401 && path !== '/account/refresh') { + const newToken = await tryRefresh() + if (newToken) { + headers.Authorization = `Bearer ${newToken}` + const retryRes = await fetch(`${API_BASE}${path}`, { + method: 'POST', headers, body, + }) + return handleResponse(retryRes, path) + } + } + + return handleResponse(res, path) +} + +async function handleResponse(res: Response, path: string): Promise { + const auth = useAuthStore() + 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.clearTokens() + const msg = data && typeof data === 'object' && (data as ApiErrorBody).error + ? String((data as ApiErrorBody).error) + : `请求失败 (${res.status})` + const apiErr = new ApiError(msg, res.status, data) + reportError(apiErr, { path, status: res.status }) + throw apiErr + } + + return data as T +} diff --git a/frontend/src/api/comment.ts b/frontend/src/api/comment.ts index 798c3d5..3003f63 100644 --- a/frontend/src/api/comment.ts +++ b/frontend/src/api/comment.ts @@ -1,16 +1,16 @@ -import { postJson } from './client' -import { normalizeCommentList } from './normalize' -import type { Comment, MessageResponse } from './types' - -export async function listAll(videoId: number) { - const comments = await postJson('/comment/listAll', { video_id: videoId }) - return normalizeCommentList(comments) -} - -export function publish(videoId: number, content: string) { - return postJson('/comment/publish', { video_id: videoId, content }, { authRequired: true }) -} - -export function remove(commentId: number) { - return postJson('/comment/delete', { comment_id: commentId }, { authRequired: true }) -} +import { postJson } from './client' +import { normalizeCommentList } from './normalize' +import type { Comment, MessageResponse } from './types' + +export async function listAll(videoId: number) { + const comments = await postJson('/comment/listAll', { video_id: videoId }) + return normalizeCommentList(comments) +} + +export function publish(videoId: number, content: string) { + return postJson('/comment/publish', { video_id: videoId, content }, { authRequired: true }) +} + +export function remove(commentId: number) { + return postJson('/comment/delete', { comment_id: commentId }, { authRequired: true }) +} diff --git a/frontend/src/api/feed.ts b/frontend/src/api/feed.ts index a7c8ad4..5ace2b1 100644 --- a/frontend/src/api/feed.ts +++ b/frontend/src/api/feed.ts @@ -1,28 +1,28 @@ -import { postJson } from './client' -import { normalizeFeedVideoList } from './normalize' -import type { ListByFollowingResponse, ListByPopularityResponse, ListLatestResponse, ListLikesCountResponse } from './types' - -export async function listLatest(input: { limit: number; latest_time: number }) { - const res = await postJson('/feed/listLatest', input) - return { ...res, video_list: normalizeFeedVideoList(res.video_list) } -} - -export async function listLikesCount(input: { limit: number; likes_count_before?: number; id_before?: number }) { - const body: Record = { 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 - } - const res = await postJson('/feed/listLikesCount', body) - return { ...res, video_list: normalizeFeedVideoList(res.video_list) } -} - -export async function listByPopularity(input: { limit: number; as_of: number; offset: number }) { - const res = await postJson('/feed/listByPopularity', input) - return { ...res, video_list: normalizeFeedVideoList(res.video_list) } -} - -export async function listByFollowing(input: { limit: number; latest_time: number }) { - const res = await postJson('/feed/listByFollowing', input, { authRequired: true }) - return { ...res, video_list: normalizeFeedVideoList(res.video_list) } -} +import { postJson } from './client' +import { normalizeFeedVideoList } from './normalize' +import type { ListByFollowingResponse, ListByPopularityResponse, ListLatestResponse, ListLikesCountResponse } from './types' + +export async function listLatest(input: { limit: number; latest_time: number }) { + const res = await postJson('/feed/listLatest', input) + return { ...res, video_list: normalizeFeedVideoList(res.video_list) } +} + +export async function listLikesCount(input: { limit: number; likes_count_before?: number; id_before?: number }) { + const body: Record = { 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 + } + const res = await postJson('/feed/listLikesCount', body) + return { ...res, video_list: normalizeFeedVideoList(res.video_list) } +} + +export async function listByPopularity(input: { limit: number; as_of: number; offset: number }) { + const res = await postJson('/feed/listByPopularity', input) + return { ...res, video_list: normalizeFeedVideoList(res.video_list) } +} + +export async function listByFollowing(input: { limit: number; latest_time: number }) { + const res = await postJson('/feed/listByFollowing', input, { authRequired: true }) + return { ...res, video_list: normalizeFeedVideoList(res.video_list) } +} diff --git a/frontend/src/api/like.ts b/frontend/src/api/like.ts index ce80cc0..25bd29a 100644 --- a/frontend/src/api/like.ts +++ b/frontend/src/api/like.ts @@ -1,18 +1,18 @@ -import { postJson } from './client' -import type { IsLikedResponse, MessageResponse, Video } from './types' - -export function like(videoId: number) { - return postJson('/like/like', { video_id: videoId }, { authRequired: true }) -} - -export function unlike(videoId: number) { - return postJson('/like/unlike', { video_id: videoId }, { authRequired: true }) -} - -export function isLiked(videoId: number) { - return postJson('/like/isLiked', { video_id: videoId }, { authRequired: true }) -} - -export function listMyLikedVideos() { - return postJson('/like/listMyLikedVideos', {}, { authRequired: true }) -} +import { postJson } from './client' +import type { IsLikedResponse, MessageResponse, Video } from './types' + +export function like(videoId: number) { + return postJson('/like/like', { video_id: videoId }, { authRequired: true }) +} + +export function unlike(videoId: number) { + return postJson('/like/unlike', { video_id: videoId }, { authRequired: true }) +} + +export function isLiked(videoId: number) { + return postJson('/like/isLiked', { video_id: videoId }, { authRequired: true }) +} + +export function listMyLikedVideos() { + return postJson('/like/listMyLikedVideos', {}, { authRequired: true }) +} diff --git a/frontend/src/api/normalize.ts b/frontend/src/api/normalize.ts index 1b06a34..1e58444 100644 --- a/frontend/src/api/normalize.ts +++ b/frontend/src/api/normalize.ts @@ -1,57 +1,57 @@ -import type { Account, Comment, FeedAuthor, FeedVideoItem, Video } from './types' - -export function listOrEmpty(value: T[] | null | undefined): T[] { - return Array.isArray(value) ? value : [] -} - -export function normalizeAccount(value: Account | null | undefined): Account { - return { - id: Number(value?.id ?? 0), - username: value?.username || '匿名用户', - } -} - -function normalizeAuthor(value: FeedAuthor | null | undefined): FeedAuthor { - return { - id: Number(value?.id ?? 0), - username: value?.username || '匿名用户', - } -} - -export function normalizeFeedVideoItem(value: FeedVideoItem): FeedVideoItem { - return { - ...value, - author: normalizeAuthor(value.author), - title: value.title || '未命名视频', - description: value.description || '', - play_url: value.play_url || '', - cover_url: value.cover_url || '', - create_time: Number(value.create_time ?? 0), - likes_count: Number(value.likes_count ?? 0), - is_liked: Boolean(value.is_liked), - } -} - -export function normalizeFeedVideoList(value: FeedVideoItem[] | null | undefined): FeedVideoItem[] { - return listOrEmpty(value).map(normalizeFeedVideoItem) -} - -export function normalizeVideoList(value: Video[] | null | undefined): Video[] { - return listOrEmpty(value).map((video) => ({ - ...video, - username: video.username || '匿名用户', - title: video.title || '未命名视频', - description: video.description || '', - play_url: video.play_url || '', - cover_url: video.cover_url || '', - likes_count: Number(video.likes_count ?? 0), - })) -} - -export function normalizeCommentList(value: Comment[] | null | undefined): Comment[] { - return listOrEmpty(value).map((comment) => ({ - ...comment, - username: comment.username || '匿名用户', - content: comment.content || '', - })) -} +import type { Account, Comment, FeedAuthor, FeedVideoItem, Video } from './types' + +export function listOrEmpty(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : [] +} + +export function normalizeAccount(value: Account | null | undefined): Account { + return { + id: Number(value?.id ?? 0), + username: value?.username || '匿名用户', + } +} + +function normalizeAuthor(value: FeedAuthor | null | undefined): FeedAuthor { + return { + id: Number(value?.id ?? 0), + username: value?.username || '匿名用户', + } +} + +export function normalizeFeedVideoItem(value: FeedVideoItem): FeedVideoItem { + return { + ...value, + author: normalizeAuthor(value.author), + title: value.title || '未命名视频', + description: value.description || '', + play_url: value.play_url || '', + cover_url: value.cover_url || '', + create_time: Number(value.create_time ?? 0), + likes_count: Number(value.likes_count ?? 0), + is_liked: Boolean(value.is_liked), + } +} + +export function normalizeFeedVideoList(value: FeedVideoItem[] | null | undefined): FeedVideoItem[] { + return listOrEmpty(value).map(normalizeFeedVideoItem) +} + +export function normalizeVideoList(value: Video[] | null | undefined): Video[] { + return listOrEmpty(value).map((video) => ({ + ...video, + username: video.username || '匿名用户', + title: video.title || '未命名视频', + description: video.description || '', + play_url: video.play_url || '', + cover_url: video.cover_url || '', + likes_count: Number(video.likes_count ?? 0), + })) +} + +export function normalizeCommentList(value: Comment[] | null | undefined): Comment[] { + return listOrEmpty(value).map((comment) => ({ + ...comment, + username: comment.username || '匿名用户', + content: comment.content || '', + })) +} diff --git a/frontend/src/api/social.ts b/frontend/src/api/social.ts index 6af442e..514e7c2 100644 --- a/frontend/src/api/social.ts +++ b/frontend/src/api/social.ts @@ -1,29 +1,29 @@ -import { postJson } from './client' -import { listOrEmpty, normalizeAccount } from './normalize' -import type { GetAllFollowersResponse, GetAllVloggersResponse, MessageResponse } from './types' - -export function follow(vloggerId: number) { - return postJson('/social/follow', { vlogger_id: vloggerId }, { authRequired: true }) -} - -export function unfollow(vloggerId: number) { - return postJson('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true }) -} - -export async function getAllFollowers(vloggerId?: number) { - const res = await postJson( - '/social/getAllFollowers', - vloggerId ? { vlogger_id: vloggerId } : {}, - { authRequired: true }, - ) - return { ...res, followers: listOrEmpty(res.followers).map(normalizeAccount) } -} - -export async function getAllVloggers(followerId?: number) { - const res = await postJson( - '/social/getAllVloggers', - followerId ? { follower_id: followerId } : {}, - { authRequired: true }, - ) - return { ...res, vloggers: listOrEmpty(res.vloggers).map(normalizeAccount) } -} +import { postJson } from './client' +import { listOrEmpty, normalizeAccount } from './normalize' +import type { GetAllFollowersResponse, GetAllVloggersResponse, MessageResponse } from './types' + +export function follow(vloggerId: number) { + return postJson('/social/follow', { vlogger_id: vloggerId }, { authRequired: true }) +} + +export function unfollow(vloggerId: number) { + return postJson('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true }) +} + +export async function getAllFollowers(vloggerId?: number) { + const res = await postJson( + '/social/getAllFollowers', + vloggerId ? { vlogger_id: vloggerId } : {}, + { authRequired: true }, + ) + return { ...res, followers: listOrEmpty(res.followers).map(normalizeAccount) } +} + +export async function getAllVloggers(followerId?: number) { + const res = await postJson( + '/social/getAllVloggers', + followerId ? { follower_id: followerId } : {}, + { authRequired: true }, + ) + return { ...res, vloggers: listOrEmpty(res.vloggers).map(normalizeAccount) } +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 99a4766..0311488 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,87 +1,89 @@ -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 ListByPopularityResponse = { - video_list: FeedVideoItem[] - as_of: number - next_offset: number - has_more: boolean - next_latest_popularity?: number - next_latest_before?: string - next_latest_id_before?: number -} - -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[] -} +export type MessageResponse = { message: string } + +export type TokenResponse = { token: string; refresh_token?: string; account_id?: number; username?: string } + +export type Account = { + id: number + username: string + avatar_url?: string + bio?: 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 ListByPopularityResponse = { + video_list: FeedVideoItem[] + as_of: number + next_offset: number + has_more: boolean + next_latest_popularity?: number + next_latest_before?: string + next_latest_id_before?: number +} + +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[] +} diff --git a/frontend/src/api/video.ts b/frontend/src/api/video.ts index 21b2727..6eee891 100644 --- a/frontend/src/api/video.ts +++ b/frontend/src/api/video.ts @@ -1,30 +1,30 @@ -import { postForm, postJson } from './client' -import { normalizeVideoList } from './normalize' -import type { Video } from './types' - -export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) { - return postJson('/video/publish', input, { authRequired: true }) -} - -export type UploadResponse = { url: string; play_url?: string; cover_url?: string } - -export function uploadVideo(file: File) { - const fd = new FormData() - fd.append('file', file) - return postForm('/video/uploadVideo', fd, { authRequired: true }) -} - -export function uploadCover(file: File) { - const fd = new FormData() - fd.append('file', file) - return postForm('/video/uploadCover', fd, { authRequired: true }) -} - -export async function listByAuthorId(authorId: number) { - const videos = await postJson('/video/listByAuthorID', { author_id: authorId }) - return normalizeVideoList(videos) -} - -export function getDetail(id: number) { - return postJson('/video/getDetail', { id }) -} +import { postForm, postJson } from './client' +import { normalizeVideoList } from './normalize' +import type { Video } from './types' + +export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) { + return postJson('/video/publish', input, { authRequired: true }) +} + +export type UploadResponse = { url: string; play_url?: string; cover_url?: string } + +export function uploadVideo(file: File) { + const fd = new FormData() + fd.append('file', file) + return postForm('/video/uploadVideo', fd, { authRequired: true }) +} + +export function uploadCover(file: File) { + const fd = new FormData() + fd.append('file', file) + return postForm('/video/uploadCover', fd, { authRequired: true }) +} + +export async function listByAuthorId(authorId: number) { + const videos = await postJson('/video/listByAuthorID', { author_id: authorId }) + return normalizeVideoList(videos) +} + +export function getDetail(id: number) { + return postJson('/video/getDetail', { id }) +} diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index b7d1830..1e0042f 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -1,322 +1,322 @@ - - - - - - - - - - {{ route.name }} - - - - - 搜索 - - - - + 发布视频 - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + {{ route.name }} + + + + + 搜索 + + + + + 发布视频 + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/FeedVideoCard.vue b/frontend/src/components/FeedVideoCard.vue index 85575f1..0f6d0a0 100644 --- a/frontend/src/components/FeedVideoCard.vue +++ b/frontend/src/components/FeedVideoCard.vue @@ -1,89 +1,89 @@ - - - - - - - - - - - - {{ item.title }} - - - 作者:{{ item.author.username }} (#{{ item.author.id }}) · 创建时间:{{ new Date(item.create_time * 1000).toLocaleString() }} - - - - ❤️ {{ item.likes_count }} - - {{ item.is_liked ? '已赞' : '点赞' }} - - - - {{ item.description }} - - 播放地址 - 查看详情 / 评论 - - - - - - + + + + + + + + + + + + {{ item.title }} + + + 作者:{{ item.author.username }} (#{{ item.author.id }}) · 创建时间:{{ new Date(item.create_time * 1000).toLocaleString() }} + + + + ❤️ {{ item.likes_count }} + + {{ item.is_liked ? '已赞' : '点赞' }} + + + + {{ item.description }} + + 播放地址 + 查看详情 / 评论 + + + + + + diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue index b58e52b..a3281aa 100644 --- a/frontend/src/components/HelloWorld.vue +++ b/frontend/src/components/HelloWorld.vue @@ -1,41 +1,41 @@ - - - - {{ msg }} - - - count is {{ count }} - - Edit - components/HelloWorld.vue to test HMR - - - - - Check out - create-vue, the official Vue + Vite starter - - - Learn more about IDE Support for Vue in the - Vue Docs Scaling up Guide. - - Click on the Vite and Vue logos to learn more - - - + + + + {{ msg }} + + + count is {{ count }} + + Edit + components/HelloWorld.vue to test HMR + + + + + Check out + create-vue, the official Vue + Vite starter + + + Learn more about IDE Support for Vue in the + Vue Docs Scaling up Guide. + + Click on the Vite and Vue logos to learn more + + + diff --git a/frontend/src/components/JsonBox.vue b/frontend/src/components/JsonBox.vue index 6437725..daa38ca 100644 --- a/frontend/src/components/JsonBox.vue +++ b/frontend/src/components/JsonBox.vue @@ -1,17 +1,17 @@ - - - - {{ text }} - + + + + {{ text }} + diff --git a/frontend/src/components/Toaster.vue b/frontend/src/components/Toaster.vue index 836663f..0df7754 100644 --- a/frontend/src/components/Toaster.vue +++ b/frontend/src/components/Toaster.vue @@ -1,73 +1,73 @@ - - - - - - {{ t.message }} - × - - - - - + + + + + + {{ t.message }} + × + + + + + diff --git a/frontend/src/components/UserAvatar.vue b/frontend/src/components/UserAvatar.vue index d57cbcb..2088dc9 100644 --- a/frontend/src/components/UserAvatar.vue +++ b/frontend/src/components/UserAvatar.vue @@ -1,54 +1,57 @@ - - - - - {{ initial }} - - - - - + + + + + + {{ initial }} + + + + + diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 4f54e32..0100e67 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,45 +1,48 @@ -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(readToken()) - - const isLoggedIn = computed(() => !!token.value) - const claims = computed(() => (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 } -}) +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import { decodeJwtPayload, type JwtPayload } from '../utils/jwt' + +const ACCESS_KEY = 'access_token' +const REFRESH_KEY = 'refresh_token' + +function readStored(key: string): string | null { + try { return localStorage.getItem(key) } catch { return null } +} + +function writeStored(key: string, value: string) { + localStorage.setItem(key, value) +} + +function removeStored(key: string) { + localStorage.removeItem(key) +} + +export const useAuthStore = defineStore('auth', () => { + const token = ref(readStored(ACCESS_KEY)) + const refreshToken = ref(readStored(REFRESH_KEY)) + + const isLoggedIn = computed(() => !!token.value) + const claims = computed(() => (token.value ? decodeJwtPayload(token.value) : null)) + + function setToken(newToken: string) { + token.value = newToken + writeStored(ACCESS_KEY, newToken) + } + + function setTokens(access: string, refresh: string) { + token.value = access + refreshToken.value = refresh + writeStored(ACCESS_KEY, access) + writeStored(REFRESH_KEY, refresh) + } + + function clearTokens() { + token.value = null + refreshToken.value = null + removeStored(ACCESS_KEY) + removeStored(REFRESH_KEY) + } + + return { token, refreshToken, isLoggedIn, claims, setToken, setTokens, clearTokens } +}) diff --git a/frontend/src/stores/social.ts b/frontend/src/stores/social.ts index 6b6ff13..8378467 100644 --- a/frontend/src/stores/social.ts +++ b/frontend/src/stores/social.ts @@ -1,109 +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([]) - const vloggers = ref([]) - - 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, - } -}) - +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([]) + const vloggers = ref([]) + + 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, + } +}) + diff --git a/frontend/src/stores/toast.ts b/frontend/src/stores/toast.ts index 07abebd..d2a2d48 100644 --- a/frontend/src/stores/toast.ts +++ b/frontend/src/stores/toast.ts @@ -1,40 +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([]) - - 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 } -}) +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([]) + + 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 } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css index ed94b73..3fa9f7a 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,224 +1,224 @@ -: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; -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -/* Hide scrollbars globally (still scrollable). */ -* { - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE/Edge legacy */ -} - -*::-webkit-scrollbar { - width: 0; - height: 0; -} - -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; -} +: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; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Hide scrollbars globally (still scrollable). */ +* { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE/Edge legacy */ +} + +*::-webkit-scrollbar { + width: 0; + height: 0; +} + +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; +} diff --git a/frontend/src/utils/jwt.ts b/frontend/src/utils/jwt.ts index b1c38dc..109d9c8 100644 --- a/frontend/src/utils/jwt.ts +++ b/frontend/src/utils/jwt.ts @@ -1,35 +1,35 @@ -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) -} - -function base64ToUtf8String(base64: string) { - const binary = atob(base64) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i) - return new TextDecoder().decode(bytes) -} - -export function decodeJwtPayload(token: string): JwtPayload | null { - const [, payload] = token.split('.') - if (!payload) return null - - try { - const json = base64ToUtf8String(base64UrlToBase64(payload)) - const parsed = JSON.parse(json) - if (!parsed || typeof parsed !== 'object') return null - return parsed as JwtPayload - } catch { - return null - } -} +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) +} + +function base64ToUtf8String(base64: string) { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i) + return new TextDecoder().decode(bytes) +} + +export function decodeJwtPayload(token: string): JwtPayload | null { + const [, payload] = token.split('.') + if (!payload) return null + + try { + const json = base64ToUtf8String(base64UrlToBase64(payload)) + const parsed = JSON.parse(json) + if (!parsed || typeof parsed !== 'object') return null + return parsed as JwtPayload + } catch { + return null + } +} diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index 2c0095a..177ce25 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -1,568 +1,568 @@ - - - - - - - 登录 - - - username - - - - password - - - 登录 - - - - 注册账号 - 修改密码 - - - - - - - - - - - @{{ me.username }} - #{{ me.id }} - - - - - 设置 - - - - - - {{ social.followersLoading ? '…' : social.followerCount }} - 粉丝 - - - {{ social.vloggersLoading ? '…' : social.followingCount }} - 关注 - - - {{ myVideos.loading ? '…' : myVideos.items.length }} - 作品 - - - {{ likedVideos.loading ? '…' : likedVideos.loaded ? likedVideos.items.length : '—' }} - 点赞 - - 社交信息加载失败:{{ socialErrorHint }} - - - - - - {{ videoTab === 'works' ? '作品' : '点赞视频' }} - 点击封面进入播放页 - - - - 加载中… - {{ myVideos.error }} - 暂无作品 - - - - - - {{ v.title }} - ❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }} - - - - - - 加载中… - {{ likedVideos.error }} - 暂无点赞视频 - - - - - - {{ v.title }} - ❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }} - - - - - - - - - - - {{ listTitle }} - × - - - 加载中… - {{ drawerError }} - 暂无 - - - - - @{{ u.username }} - #{{ u.id }} - - - - - - - - - + + + + + + + 登录 + + + username + + + + password + + + 登录 + + + + 注册账号 + 修改密码 + + + + + + + + + + + @{{ me.username }} + #{{ me.id }} + + + + + 设置 + + + + + + {{ social.followersLoading ? '…' : social.followerCount }} + 粉丝 + + + {{ social.vloggersLoading ? '…' : social.followingCount }} + 关注 + + + {{ myVideos.loading ? '…' : myVideos.items.length }} + 作品 + + + {{ likedVideos.loading ? '…' : likedVideos.loaded ? likedVideos.items.length : '—' }} + 点赞 + + 社交信息加载失败:{{ socialErrorHint }} + + + + + + {{ videoTab === 'works' ? '作品' : '点赞视频' }} + 点击封面进入播放页 + + + + 加载中… + {{ myVideos.error }} + 暂无作品 + + + + + + {{ v.title }} + ❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }} + + + + + + 加载中… + {{ likedVideos.error }} + 暂无点赞视频 + + + + + + {{ v.title }} + ❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }} + + + + + + + + + + + {{ listTitle }} + × + + + 加载中… + {{ drawerError }} + 暂无 + + + + + @{{ u.username }} + #{{ u.id }} + + + + + + + + + diff --git a/frontend/src/views/ChangePasswordView.vue b/frontend/src/views/ChangePasswordView.vue index df72b18..692ba3c 100644 --- a/frontend/src/views/ChangePasswordView.vue +++ b/frontend/src/views/ChangePasswordView.vue @@ -1,72 +1,72 @@ - - - - - - - 修改密码 - 不需要登录(对应后端 `/account/changePassword`)。 - - - username - - - - old_password - - - - new_password - - - - 提交 - - - - - - 提示 - 改密成功后后端会让旧 token 失效;请在「账号」页重新登录。 - - - - - + + + + + + + 修改密码 + 不需要登录(对应后端 `/account/changePassword`)。 + + + username + + + + old_password + + + + new_password + + + + 提交 + + + + + + 提示 + 改密成功后后端会让旧 token 失效;请在「账号」页重新登录。 + + + + + diff --git a/frontend/src/views/FeedView.vue b/frontend/src/views/FeedView.vue index d773316..05320d4 100644 --- a/frontend/src/views/FeedView.vue +++ b/frontend/src/views/FeedView.vue @@ -1,262 +1,262 @@ - - - - - - - Feed - `/feed/listLatest` 与 `/feed/listLikesCount` 支持匿名(可选 JWT);`/feed/listByFollowing` 需要 JWT。 - - - - - 最新流(listLatest) - limit:{{ latest.limit }} · next_time:{{ latest.next_time }} · has_more:{{ latest.has_more }} - - - limit - - 刷新 - 加载更多 - - - 错误:{{ latest.error }} - - - - - - - - - 点赞数流(listLikesCount) - - limit:{{ likesCount.limit }} · next=(likes={{ likesCount.next_likes_count_before }}, id={{ likesCount.next_id_before }}) - · has_more:{{ likesCount.has_more }} - - - - limit - - 刷新 - - 加载更多 - - - - 错误:{{ likesCount.error }} - - - - - - - - - 关注流(listByFollowing,JWT) - - limit:{{ following.limit }} · next_time:{{ following.next_time }} · has_more:{{ following.has_more }} - - - - limit - - 刷新 - 加载更多 - - - 未登录:无法访问关注流 - 错误:{{ following.error }} - - - - - - - - 动作输出(点赞等) - - 动作:{{ action.name || '-' }} - 请求中… - 错误:{{ action.error }} - - - - - - + + + + + + + Feed + `/feed/listLatest` 与 `/feed/listLikesCount` 支持匿名(可选 JWT);`/feed/listByFollowing` 需要 JWT。 + + + + + 最新流(listLatest) + limit:{{ latest.limit }} · next_time:{{ latest.next_time }} · has_more:{{ latest.has_more }} + + + limit + + 刷新 + 加载更多 + + + 错误:{{ latest.error }} + + + + + + + + + 点赞数流(listLikesCount) + + limit:{{ likesCount.limit }} · next=(likes={{ likesCount.next_likes_count_before }}, id={{ likesCount.next_id_before }}) + · has_more:{{ likesCount.has_more }} + + + + limit + + 刷新 + + 加载更多 + + + + 错误:{{ likesCount.error }} + + + + + + + + + 关注流(listByFollowing,JWT) + + limit:{{ following.limit }} · next_time:{{ following.next_time }} · has_more:{{ following.has_more }} + + + + limit + + 刷新 + 加载更多 + + + 未登录:无法访问关注流 + 错误:{{ following.error }} + + + + + + + + 动作输出(点赞等) + + 动作:{{ action.name || '-' }} + 请求中… + 错误:{{ action.error }} + + + + + + diff --git a/frontend/src/views/HotView.vue b/frontend/src/views/HotView.vue index 09be8e5..915db0b 100644 --- a/frontend/src/views/HotView.vue +++ b/frontend/src/views/HotView.vue @@ -1,157 +1,157 @@ - - - - - - - - 热榜 - 按热度排序(/feed/listByPopularity) - - - - limit - - 刷新 - 加载更多 - - - - 错误:{{ state.error }} - 加载中… - 暂无内容 - - - - {{ idx + 1 }} - - - - - - - - + + + + + + + + 热榜 + 按热度排序(/feed/listByPopularity) + + + + limit + + 刷新 + 加载更多 + + + + 错误:{{ state.error }} + 加载中… + 暂无内容 + + + + {{ idx + 1 }} + + + + + + + + diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue index 268e881..14c6231 100644 --- a/frontend/src/views/RegisterView.vue +++ b/frontend/src/views/RegisterView.vue @@ -1,67 +1,67 @@ - - - - - - - 注册 - 创建新账号(对应后端 `/account/register`)。 - - - username - - - - password - - - - 注册 - - - - - - 提示 - 注册成功后会跳回「账号」页进行登录。 - - - - - + + + + + + + 注册 + 创建新账号(对应后端 `/account/register`)。 + + + username + + + + password + + + + 注册 + + + + + + 提示 + 注册成功后会跳回「账号」页进行登录。 + + + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 2958f97..e0b9778 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -1,168 +1,168 @@ - - - - - - - 设置 - 需要先登录后才能进行改名/退出等操作。 - - 去登录 - - - - 提示 - 登录入口在「账号」页。 - - - - - - - - - - @{{ me.username }} - #{{ me.id }} - - - - - - - 账号设置 - 改名 - - - - - new_username - - - - 取消 - 提交 - - - - - - 账号安全 - - 修改密码 - 退出登录 - - - - - - 说明 - - 改名后会返回新 token,旧 token 立即失效 - 退出登录会清空本地 token - 修改密码无需登录,但成功后会让旧 token 失效 - - - - - - - - + + + + + + + 设置 + 需要先登录后才能进行改名/退出等操作。 + + 去登录 + + + + 提示 + 登录入口在「账号」页。 + + + + + + + + + + @{{ me.username }} + #{{ me.id }} + + + + + + + 账号设置 + 改名 + + + + + new_username + + + + 取消 + 提交 + + + + + + 账号安全 + + 修改密码 + 退出登录 + + + + + + 说明 + + 改名后会返回新 token,旧 token 立即失效 + 退出登录会清空本地 token + 修改密码无需登录,但成功后会让旧 token 失效 + + + + + + + + diff --git a/frontend/src/views/UserProfileView.vue b/frontend/src/views/UserProfileView.vue index 03645b1..b294715 100644 --- a/frontend/src/views/UserProfileView.vue +++ b/frontend/src/views/UserProfileView.vue @@ -1,455 +1,455 @@ - - - - - - - - - - @{{ state.user?.username ?? '-' }} - #{{ state.user?.id ?? userId }} - - - - - 我的账号 - - {{ isFollowing ? '已关注' : '关注' }} - - - - - 加载中… - {{ state.error }} - - - - {{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.followers.length) : '—' }} - 粉丝 - - - {{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.vloggers.length) : '—' }} - 关注 - - - {{ state.videos.length }} - 作品 - - 登录后可查看粉丝/关注列表 - 社交信息加载失败:{{ state.socialError }} - - - - - - 作品 - 点击封面进入播放页 - - - 暂无作品 - - - - - - {{ v.title }} - ❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }} - - - - - - - - - {{ listTitle }} - × - - - 加载中… - {{ state.socialError }} - 暂无 - - - - - @{{ u.username }} - #{{ u.id }} - - - - - - - - - - + + + + + + + + + + @{{ state.user?.username ?? '-' }} + #{{ state.user?.id ?? userId }} + + + + + 我的账号 + + {{ isFollowing ? '已关注' : '关注' }} + + + + + 加载中… + {{ state.error }} + + + + {{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.followers.length) : '—' }} + 粉丝 + + + {{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.vloggers.length) : '—' }} + 关注 + + + {{ state.videos.length }} + 作品 + + 登录后可查看粉丝/关注列表 + 社交信息加载失败:{{ state.socialError }} + + + + + + 作品 + 点击封面进入播放页 + + + 暂无作品 + + + + + + {{ v.title }} + ❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }} + + + + + + + + + {{ listTitle }} + × + + + 加载中… + {{ state.socialError }} + 暂无 + + + + + @{{ u.username }} + #{{ u.id }} + + + + + + + + + + diff --git a/frontend/src/views/VideoDetailView.vue b/frontend/src/views/VideoDetailView.vue index d24cfcc..8e81905 100644 --- a/frontend/src/views/VideoDetailView.vue +++ b/frontend/src/views/VideoDetailView.vue @@ -1,686 +1,686 @@ - - - - - - - - ← 返回推荐 - - - {{ muted ? '静音' : '有声' }} - - - - - 加载中… - {{ state.error }} - - - - - - - - - @{{ state.video.username }} - - {{ state.video.title }} - {{ state.video.description }} - - play_url - cover_url - - - - - - ♥ - {{ state.video.likes_count }} - - - - 💬 - 评论 - - - - + - {{ social.isFollowing(state.video.author_id) ? '已关注' : '关注' }} - - - - ↗ - 分享 - - - - - 点击 暂停/播放 - 双击 点赞 - - - - - - - - 评论 - × - - - - 加载中… - {{ drawer.error }} - 暂无评论 - - - - {{ c.username }} - - #{{ c.id }} · {{ new Date(c.created_at).toLocaleString() }} - - - {{ c.content }} - - - 删除 - - - - - - - - - 刷新 - - 发送 - - - - - - - - - - + + + + + + + + ← 返回推荐 + + + {{ muted ? '静音' : '有声' }} + + + + + 加载中… + {{ state.error }} + + + + + + + + + @{{ state.video.username }} + + {{ state.video.title }} + {{ state.video.description }} + + play_url + cover_url + + + + + + ♥ + {{ state.video.likes_count }} + + + + 💬 + 评论 + + + + + + {{ social.isFollowing(state.video.author_id) ? '已关注' : '关注' }} + + + + ↗ + 分享 + + + + + 点击 暂停/播放 + 双击 点赞 + + + + + + + + 评论 + × + + + + 加载中… + {{ drawer.error }} + 暂无评论 + + + + {{ c.username }} + + #{{ c.id }} · {{ new Date(c.created_at).toLocaleString() }} + + + {{ c.content }} + + + 删除 + + + + + + + + + 刷新 + + 发送 + + + + + + + + + + diff --git a/frontend/src/views/VideoView.vue b/frontend/src/views/VideoView.vue index 5cfae1b..b787fd9 100644 --- a/frontend/src/views/VideoView.vue +++ b/frontend/src/views/VideoView.vue @@ -1,346 +1,346 @@ - - - - - - - - 发布视频 - 进行中:{{ stage || '…' }} - - 选择视频文件与封面图片,上传到本机后自动生成 URL,再写入 `/video/publish`。 - - - - title - - - - description - - - - - video (.mp4) - - - 选择视频 - - {{ publishForm.video ? publishForm.video.name : '未选择文件' }} - - 清除 - - - 已选择:{{ publishForm.video.name }}({{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB) - - - - cover (jpg/png/webp) - - - 选择封面 - - {{ publishForm.cover ? publishForm.cover.name : '未选择文件' }} - - 清除 - - 已选择:{{ publishForm.cover.name }} - - - - - - 封面预览 - - - - 视频预览 - - - - - - 发布 - - - - - 已发布 - - - {{ published.title }} - #{{ published.id }} - - - 去播放 - play_url - cover_url - - - - - - - - - + + + + + + + + 发布视频 + 进行中:{{ stage || '…' }} + + 选择视频文件与封面图片,上传到本机后自动生成 URL,再写入 `/video/publish`。 + + + + title + + + + description + + + + + video (.mp4) + + + 选择视频 + + {{ publishForm.video ? publishForm.video.name : '未选择文件' }} + + 清除 + + + 已选择:{{ publishForm.video.name }}({{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB) + + + + cover (jpg/png/webp) + + + 选择封面 + + {{ publishForm.cover ? publishForm.cover.name : '未选择文件' }} + + 清除 + + 已选择:{{ publishForm.cover.name }} + + + + + + 封面预览 + + + + 视频预览 + + + + + + 发布 + + + + + 已发布 + + + {{ published.title }} + #{{ published.id }} + + + 去播放 + play_url + cover_url + + + + + + + + +
- Edit - components/HelloWorld.vue to test HMR -
components/HelloWorld.vue
- Check out - create-vue, the official Vue + Vite starter -
- Learn more about IDE Support for Vue in the - Vue Docs Scaling up Guide. -
Click on the Vite and Vue logos to learn more
+ Edit + components/HelloWorld.vue to test HMR +
+ Check out + create-vue, the official Vue + Vite starter +
+ Learn more about IDE Support for Vue in the + Vue Docs Scaling up Guide. +
{{ text }}
登录
{{ videoTab === 'works' ? '作品' : '点赞视频' }}
修改密码
不需要登录(对应后端 `/account/changePassword`)。
提示
改密成功后后端会让旧 token 失效;请在「账号」页重新登录。
Feed
`/feed/listLatest` 与 `/feed/listLikesCount` 支持匿名(可选 JWT);`/feed/listByFollowing` 需要 JWT。
最新流(listLatest)
点赞数流(listLikesCount)
关注流(listByFollowing,JWT)
动作输出(点赞等)
热榜
按热度排序(/feed/listByPopularity)
注册
创建新账号(对应后端 `/account/register`)。
注册成功后会跳回「账号」页进行登录。
设置
需要先登录后才能进行改名/退出等操作。
登录入口在「账号」页。
账号设置
账号安全
说明
作品
发布视频
选择视频文件与封面图片,上传到本机后自动生成 URL,再写入 `/video/publish`。
已发布