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