feat(P1): 前端双 Token 适配 + 401 自动刷新 + UserAvatar src 支持 + Account API 扩展

This commit is contained in:
Sisyphus
2026-04-25 18:54:22 +08:00
parent 9b20df4bf3
commit 2322383036
30 changed files with 4284 additions and 4236 deletions

View File

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

View File

@@ -1,34 +1,48 @@
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 })
}
import { postForm, 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 })
}
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<MessageResponse>('/account/updateProfile', data, { authRequired: true })
}
export function refresh(refreshToken: string) {
return postJson<TokenResponse>('/account/refresh', { refresh_token: refreshToken })
}

View File

@@ -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<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})`
const apiErr = new ApiError(msg, res.status, data)
reportError(apiErr, { path, status: res.status })
throw apiErr
}
return data as T
}
export async function postForm<T>(path: string, body: FormData, 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> = {}
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<string | null> | null = null
async function tryRefresh(): Promise<string | null> {
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<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 ?? {}),
})
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<T>(retryRes, path)
}
}
return handleResponse<T>(res, path)
}
export async function postForm<T>(path: string, body: FormData, 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> = {}
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<T>(retryRes, path)
}
}
return handleResponse<T>(res, path)
}
async function handleResponse<T>(res: Response, path: string): Promise<T> {
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
}

View File

@@ -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[] | null>('/comment/listAll', { video_id: videoId })
return normalizeCommentList(comments)
}
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 })
}
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[] | null>('/comment/listAll', { video_id: videoId })
return normalizeCommentList(comments)
}
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 })
}

View File

@@ -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<ListLatestResponse>('/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<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
}
const res = await postJson<ListLikesCountResponse>('/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<ListByPopularityResponse>('/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<ListByFollowingResponse>('/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<ListLatestResponse>('/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<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
}
const res = await postJson<ListLikesCountResponse>('/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<ListByPopularityResponse>('/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<ListByFollowingResponse>('/feed/listByFollowing', input, { authRequired: true })
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
}

View File

@@ -1,18 +1,18 @@
import { postJson } from './client'
import type { IsLikedResponse, MessageResponse, Video } 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 })
}
export function listMyLikedVideos() {
return postJson<Video[]>('/like/listMyLikedVideos', {}, { authRequired: true })
}
import { postJson } from './client'
import type { IsLikedResponse, MessageResponse, Video } 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 })
}
export function listMyLikedVideos() {
return postJson<Video[]>('/like/listMyLikedVideos', {}, { authRequired: true })
}

View File

@@ -1,57 +1,57 @@
import type { Account, Comment, FeedAuthor, FeedVideoItem, Video } from './types'
export function listOrEmpty<T>(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<T>(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 || '',
}))
}

View File

@@ -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<MessageResponse>('/social/follow', { vlogger_id: vloggerId }, { authRequired: true })
}
export function unfollow(vloggerId: number) {
return postJson<MessageResponse>('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true })
}
export async function getAllFollowers(vloggerId?: number) {
const res = await postJson<GetAllFollowersResponse>(
'/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<GetAllVloggersResponse>(
'/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<MessageResponse>('/social/follow', { vlogger_id: vloggerId }, { authRequired: true })
}
export function unfollow(vloggerId: number) {
return postJson<MessageResponse>('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true })
}
export async function getAllFollowers(vloggerId?: number) {
const res = await postJson<GetAllFollowersResponse>(
'/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<GetAllVloggersResponse>(
'/social/getAllVloggers',
followerId ? { follower_id: followerId } : {},
{ authRequired: true },
)
return { ...res, vloggers: listOrEmpty(res.vloggers).map(normalizeAccount) }
}

View File

@@ -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[]
}

View File

@@ -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>('/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<UploadResponse>('/video/uploadVideo', fd, { authRequired: true })
}
export function uploadCover(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<UploadResponse>('/video/uploadCover', fd, { authRequired: true })
}
export async function listByAuthorId(authorId: number) {
const videos = await postJson<Video[] | null>('/video/listByAuthorID', { author_id: authorId })
return normalizeVideoList(videos)
}
export function getDetail(id: number) {
return postJson<Video>('/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>('/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<UploadResponse>('/video/uploadVideo', fd, { authRequired: true })
}
export function uploadCover(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<UploadResponse>('/video/uploadCover', fd, { authRequired: true })
}
export async function listByAuthorId(authorId: number) {
const videos = await postJson<Video[] | null>('/video/listByAuthorID', { author_id: authorId })
return normalizeVideoList(videos)
}
export function getDetail(id: number) {
return postJson<Video>('/video/getDetail', { id })
}

View File

@@ -1,322 +1,322 @@
<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="/hot">热榜</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>
<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="/hot">热榜</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

@@ -1,89 +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>
<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

@@ -1,41 +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>
<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

@@ -1,17 +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>
<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

@@ -1,73 +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>
<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

@@ -1,54 +1,57 @@
<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>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
username: string
id?: number
size?: number
src?: string
}>()
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>
<img v-if="src" :src="src" class="avatar" :style="{ width: sizePx, height: sizePx }" alt="" />
<div v-else 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;
object-fit: cover;
}
</style>

View File

@@ -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<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 }
})
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<string | null>(readStored(ACCESS_KEY))
const refreshToken = ref<string | null>(readStored(REFRESH_KEY))
const isLoggedIn = computed(() => !!token.value)
const claims = computed<JwtPayload | null>(() => (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 }
})

View File

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

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

View File

@@ -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;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +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>
<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

@@ -1,262 +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>
<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

@@ -1,157 +1,157 @@
<script setup lang="ts">
import { computed, onMounted, reactive } from '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 AppShell from '../components/AppShell.vue'
import FeedVideoCard from '../components/FeedVideoCard.vue'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
const auth = useAuthStore()
const toast = useToastStore()
const canLike = computed(() => auth.isLoggedIn)
const state = reactive({
loading: false,
error: '',
items: [] as FeedVideoItem[],
hasMore: false,
limit: 10,
asOf: 0,
nextOffset: 0,
})
const likeBusy = reactive<Record<string, boolean>>({})
async function loadHot(reset: boolean) {
if (state.loading) return
state.loading = true
state.error = ''
try {
const res = await feedApi.listByPopularity({
limit: state.limit,
as_of: reset ? 0 : state.asOf,
offset: reset ? 0 : state.nextOffset,
})
state.hasMore = res.has_more
state.asOf = res.as_of
state.nextOffset = res.next_offset
state.items = reset ? res.video_list : state.items.concat(res.video_list)
} catch (e) {
state.error = e instanceof ApiError ? e.message : String(e)
} finally {
state.loading = false
}
}
async function toggleLike(item: FeedVideoItem) {
if (!auth.isLoggedIn) {
toast.error('请先登录')
return
}
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
}
}
onMounted(async () => {
await loadHot(true)
})
</script>
<template>
<AppShell>
<div class="card">
<div class="row" style="justify-content: space-between; align-items: baseline">
<div>
<p class="title" style="margin: 0">热榜</p>
<p class="subtle" style="margin: 6px 0 0">按热度排序/feed/listByPopularity</p>
</div>
<div class="row">
<label class="subtle" style="margin: 0">limit</label>
<input v-model.number="state.limit" type="number" min="1" max="50" style="width: 90px" :disabled="state.loading" />
<button class="primary" type="button" :disabled="state.loading" @click="loadHot(true)">刷新</button>
<button type="button" :disabled="state.loading || !state.hasMore" @click="loadHot(false)">加载更多</button>
</div>
</div>
<div v-if="state.error" class="pill bad" style="margin-top: 12px">错误{{ state.error }}</div>
<div v-else-if="state.loading && state.items.length === 0" class="subtle" style="margin-top: 12px">加载中</div>
<div v-else-if="state.items.length === 0" class="subtle" style="margin-top: 12px">暂无内容</div>
<div v-if="state.items.length" class="rank-list" style="margin-top: 14px">
<div v-for="(item, idx) in state.items" :key="`hot-${item.id}`" class="rank-row">
<div class="rank-num" :class="idx < 3 ? 'top' : ''">{{ idx + 1 }}</div>
<FeedVideoCard
:item="item"
:can-like="canLike"
:busy="!!likeBusy[String(item.id)]"
@toggle-like="toggleLike"
/>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.rank-list {
display: grid;
gap: 12px;
}
.rank-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.rank-num {
height: 44px;
width: 44px;
border-radius: 16px;
display: grid;
place-items: center;
font-weight: 900;
letter-spacing: 0.2px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.86);
user-select: none;
}
.rank-num.top {
border-color: rgba(254, 44, 85, 0.55);
background: rgba(254, 44, 85, 0.18);
color: rgba(255, 255, 255, 0.96);
}
@media (max-width: 900px) {
.rank-row {
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
}
.rank-num {
height: 38px;
width: 38px;
border-radius: 14px;
}
}
</style>
<script setup lang="ts">
import { computed, onMounted, reactive } from '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 AppShell from '../components/AppShell.vue'
import FeedVideoCard from '../components/FeedVideoCard.vue'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
const auth = useAuthStore()
const toast = useToastStore()
const canLike = computed(() => auth.isLoggedIn)
const state = reactive({
loading: false,
error: '',
items: [] as FeedVideoItem[],
hasMore: false,
limit: 10,
asOf: 0,
nextOffset: 0,
})
const likeBusy = reactive<Record<string, boolean>>({})
async function loadHot(reset: boolean) {
if (state.loading) return
state.loading = true
state.error = ''
try {
const res = await feedApi.listByPopularity({
limit: state.limit,
as_of: reset ? 0 : state.asOf,
offset: reset ? 0 : state.nextOffset,
})
state.hasMore = res.has_more
state.asOf = res.as_of
state.nextOffset = res.next_offset
state.items = reset ? res.video_list : state.items.concat(res.video_list)
} catch (e) {
state.error = e instanceof ApiError ? e.message : String(e)
} finally {
state.loading = false
}
}
async function toggleLike(item: FeedVideoItem) {
if (!auth.isLoggedIn) {
toast.error('请先登录')
return
}
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
}
}
onMounted(async () => {
await loadHot(true)
})
</script>
<template>
<AppShell>
<div class="card">
<div class="row" style="justify-content: space-between; align-items: baseline">
<div>
<p class="title" style="margin: 0">热榜</p>
<p class="subtle" style="margin: 6px 0 0">按热度排序/feed/listByPopularity</p>
</div>
<div class="row">
<label class="subtle" style="margin: 0">limit</label>
<input v-model.number="state.limit" type="number" min="1" max="50" style="width: 90px" :disabled="state.loading" />
<button class="primary" type="button" :disabled="state.loading" @click="loadHot(true)">刷新</button>
<button type="button" :disabled="state.loading || !state.hasMore" @click="loadHot(false)">加载更多</button>
</div>
</div>
<div v-if="state.error" class="pill bad" style="margin-top: 12px">错误{{ state.error }}</div>
<div v-else-if="state.loading && state.items.length === 0" class="subtle" style="margin-top: 12px">加载中</div>
<div v-else-if="state.items.length === 0" class="subtle" style="margin-top: 12px">暂无内容</div>
<div v-if="state.items.length" class="rank-list" style="margin-top: 14px">
<div v-for="(item, idx) in state.items" :key="`hot-${item.id}`" class="rank-row">
<div class="rank-num" :class="idx < 3 ? 'top' : ''">{{ idx + 1 }}</div>
<FeedVideoCard
:item="item"
:can-like="canLike"
:busy="!!likeBusy[String(item.id)]"
@toggle-like="toggleLike"
/>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.rank-list {
display: grid;
gap: 12px;
}
.rank-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.rank-num {
height: 44px;
width: 44px;
border-radius: 16px;
display: grid;
place-items: center;
font-weight: 900;
letter-spacing: 0.2px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.86);
user-select: none;
}
.rank-num.top {
border-color: rgba(254, 44, 85, 0.55);
background: rgba(254, 44, 85, 0.18);
color: rgba(255, 255, 255, 0.96);
}
@media (max-width: 900px) {
.rank-row {
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
}
.rank-num {
height: 38px;
width: 38px;
border-radius: 14px;
}
}
</style>

View File

@@ -1,67 +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>
<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

@@ -1,168 +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>
<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.clearTokens()
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

@@ -1,455 +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>
<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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,346 +1,346 @@
<script setup lang="ts">
import { onUnmounted, reactive, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import { ApiError } from '../api/client'
import * as videoApi from '../api/video'
import type { Video } from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
const router = useRouter()
const auth = useAuthStore()
const toast = useToastStore()
const busy = ref(false)
const stage = ref('')
const published = ref<Video | null>(null)
const videoInput = ref<HTMLInputElement | null>(null)
const coverInput = ref<HTMLInputElement | null>(null)
const publishForm = reactive({
title: '',
description: '',
video: null as File | null,
cover: null as File | null,
})
const preview = reactive({
videoUrl: '',
coverUrl: '',
})
function setPreviewVideo(file: File | null) {
if (preview.videoUrl) URL.revokeObjectURL(preview.videoUrl)
preview.videoUrl = file ? URL.createObjectURL(file) : ''
}
function setPreviewCover(file: File | null) {
if (preview.coverUrl) URL.revokeObjectURL(preview.coverUrl)
preview.coverUrl = file ? URL.createObjectURL(file) : ''
}
watch(
() => publishForm.video,
(f) => setPreviewVideo(f),
)
watch(
() => publishForm.cover,
(f) => setPreviewCover(f),
)
onUnmounted(() => {
setPreviewVideo(null)
setPreviewCover(null)
})
function pickVideo(e: Event) {
const input = e.target as HTMLInputElement
publishForm.video = input.files?.[0] ?? null
}
function pickCover(e: Event) {
const input = e.target as HTMLInputElement
publishForm.cover = input.files?.[0] ?? null
}
function openVideoPicker() {
videoInput.value?.click()
}
function openCoverPicker() {
coverInput.value?.click()
}
function clearVideo() {
publishForm.video = null
if (videoInput.value) videoInput.value.value = ''
}
function clearCover() {
publishForm.cover = null
if (coverInput.value) coverInput.value.value = ''
}
async function onPublish() {
if (busy.value) return
if (!auth.isLoggedIn) {
toast.error('请先登录')
await router.push('/account')
return
}
const title = publishForm.title.trim()
const description = publishForm.description.trim()
if (!title) {
toast.error('请输入 title')
return
}
if (!publishForm.video) {
toast.error('请选择视频文件(.mp4')
return
}
if (!publishForm.cover) {
toast.error('请选择封面图片jpg/png/webp')
return
}
busy.value = true
stage.value = ''
published.value = null
try {
stage.value = '上传封面'
const coverRes = await videoApi.uploadCover(publishForm.cover!)
stage.value = '上传视频'
const videoRes = await videoApi.uploadVideo(publishForm.video!)
const coverUrl = coverRes.url || coverRes.cover_url || ''
const playUrl = videoRes.url || videoRes.play_url || ''
if (!coverUrl || !playUrl) {
toast.error('上传成功但缺少 url')
return
}
stage.value = '发布视频'
const res = await videoApi.publishVideo({ title, description, play_url: playUrl, cover_url: coverUrl })
published.value = res
toast.success('已发布')
publishForm.title = ''
publishForm.description = ''
clearVideo()
clearCover()
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
stage.value = ''
}
}
</script>
<template>
<AppShell>
<div class="publish-wrap">
<div class="card publish-card">
<div class="row" style="justify-content: space-between; align-items: baseline">
<p class="title" style="margin: 0">发布视频</p>
<div v-if="busy" class="pill">进行中{{ stage || '' }}</div>
</div>
<p class="subtle" style="margin-top: 10px">选择视频文件与封面图片上传到本机后自动生成 URL再写入 `/video/publish`</p>
<div class="grid form-grid" style="margin-top: 16px">
<div>
<label>title</label>
<input v-model.trim="publishForm.title" class="big-input" :disabled="busy" />
</div>
<div>
<label>description</label>
<textarea v-model.trim="publishForm.description" class="big-input" :disabled="busy" />
</div>
<div class="grid two">
<div>
<label>video (.mp4)</label>
<input ref="videoInput" class="file-native" type="file" accept="video/mp4" :disabled="busy" @change="pickVideo" />
<div class="file-box">
<button type="button" :disabled="busy" @click="openVideoPicker">选择视频</button>
<div class="file-name" :class="publishForm.video ? '' : 'muted'">
{{ publishForm.video ? publishForm.video.name : '未选择文件' }}
</div>
<button v-if="publishForm.video" type="button" :disabled="busy" @click="clearVideo">清除</button>
</div>
<div v-if="publishForm.video" class="subtle" style="margin-top: 6px">
已选择{{ publishForm.video.name }}{{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB
</div>
</div>
<div>
<label>cover (jpg/png/webp)</label>
<input
ref="coverInput"
class="file-native"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="busy"
@change="pickCover"
/>
<div class="file-box">
<button type="button" :disabled="busy" @click="openCoverPicker">选择封面</button>
<div class="file-name" :class="publishForm.cover ? '' : 'muted'">
{{ publishForm.cover ? publishForm.cover.name : '未选择文件' }}
</div>
<button v-if="publishForm.cover" type="button" :disabled="busy" @click="clearCover">清除</button>
</div>
<div v-if="publishForm.cover" class="subtle" style="margin-top: 6px">已选择{{ publishForm.cover.name }}</div>
</div>
</div>
<div v-if="preview.coverUrl || preview.videoUrl" class="grid two">
<div v-if="preview.coverUrl" class="preview-card">
<div class="subtle">封面预览</div>
<img class="cover" :src="preview.coverUrl" alt="cover preview" />
</div>
<div v-if="preview.videoUrl" class="preview-card">
<div class="subtle">视频预览</div>
<video class="video" :src="preview.videoUrl" controls playsinline preload="metadata" />
</div>
</div>
<div class="row" style="justify-content: flex-end; margin-top: 8px">
<button class="primary big-btn" type="button" :disabled="busy" @click="onPublish">发布</button>
</div>
</div>
<div v-if="published" class="card" style="margin-top: 14px">
<p class="title">已发布</p>
<div class="row" style="justify-content: space-between">
<div>
<div class="title" style="margin: 0">{{ published.title }}</div>
<div class="subtle mono">#{{ published.id }}</div>
</div>
<div class="row">
<RouterLink class="pill" :to="`/video/${published.id}`">去播放</RouterLink>
<a class="pill mono" :href="published.play_url" target="_blank" rel="noreferrer">play_url</a>
<a class="pill mono" :href="published.cover_url" target="_blank" rel="noreferrer">cover_url</a>
</div>
</div>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.publish-wrap {
display: grid;
justify-items: center;
}
.publish-card {
width: min(980px, 100%);
padding: 22px;
}
.form-grid {
gap: 16px;
}
.form-grid .grid.two {
gap: 20px;
}
.form-grid .grid.two > * {
min-width: 0;
}
.form-grid input[type='file'] {
max-width: 100%;
}
.file-native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.file-box {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
border-radius: 14px;
min-height: 46px;
}
.file-box button {
padding: 8px 10px;
border-radius: 12px;
}
.file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: rgba(255, 255, 255, 0.88);
}
.muted {
color: rgba(255, 255, 255, 0.55);
}
.big-input {
box-sizing: border-box;
width: 100%;
max-width: 100%;
padding: 12px 14px;
font-size: 14px;
border-radius: 14px;
}
.big-btn {
padding: 12px 18px;
font-size: 14px;
border-radius: 14px;
}
.preview-card {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 12px;
display: grid;
gap: 10px;
}
.cover {
width: 100%;
aspect-ratio: 9/12;
object-fit: cover;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.35);
}
.video {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.35);
}
</style>
<script setup lang="ts">
import { onUnmounted, reactive, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import { ApiError } from '../api/client'
import * as videoApi from '../api/video'
import type { Video } from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
const router = useRouter()
const auth = useAuthStore()
const toast = useToastStore()
const busy = ref(false)
const stage = ref('')
const published = ref<Video | null>(null)
const videoInput = ref<HTMLInputElement | null>(null)
const coverInput = ref<HTMLInputElement | null>(null)
const publishForm = reactive({
title: '',
description: '',
video: null as File | null,
cover: null as File | null,
})
const preview = reactive({
videoUrl: '',
coverUrl: '',
})
function setPreviewVideo(file: File | null) {
if (preview.videoUrl) URL.revokeObjectURL(preview.videoUrl)
preview.videoUrl = file ? URL.createObjectURL(file) : ''
}
function setPreviewCover(file: File | null) {
if (preview.coverUrl) URL.revokeObjectURL(preview.coverUrl)
preview.coverUrl = file ? URL.createObjectURL(file) : ''
}
watch(
() => publishForm.video,
(f) => setPreviewVideo(f),
)
watch(
() => publishForm.cover,
(f) => setPreviewCover(f),
)
onUnmounted(() => {
setPreviewVideo(null)
setPreviewCover(null)
})
function pickVideo(e: Event) {
const input = e.target as HTMLInputElement
publishForm.video = input.files?.[0] ?? null
}
function pickCover(e: Event) {
const input = e.target as HTMLInputElement
publishForm.cover = input.files?.[0] ?? null
}
function openVideoPicker() {
videoInput.value?.click()
}
function openCoverPicker() {
coverInput.value?.click()
}
function clearVideo() {
publishForm.video = null
if (videoInput.value) videoInput.value.value = ''
}
function clearCover() {
publishForm.cover = null
if (coverInput.value) coverInput.value.value = ''
}
async function onPublish() {
if (busy.value) return
if (!auth.isLoggedIn) {
toast.error('请先登录')
await router.push('/account')
return
}
const title = publishForm.title.trim()
const description = publishForm.description.trim()
if (!title) {
toast.error('请输入 title')
return
}
if (!publishForm.video) {
toast.error('请选择视频文件(.mp4')
return
}
if (!publishForm.cover) {
toast.error('请选择封面图片jpg/png/webp')
return
}
busy.value = true
stage.value = ''
published.value = null
try {
stage.value = '上传封面'
const coverRes = await videoApi.uploadCover(publishForm.cover!)
stage.value = '上传视频'
const videoRes = await videoApi.uploadVideo(publishForm.video!)
const coverUrl = coverRes.url || coverRes.cover_url || ''
const playUrl = videoRes.url || videoRes.play_url || ''
if (!coverUrl || !playUrl) {
toast.error('上传成功但缺少 url')
return
}
stage.value = '发布视频'
const res = await videoApi.publishVideo({ title, description, play_url: playUrl, cover_url: coverUrl })
published.value = res
toast.success('已发布')
publishForm.title = ''
publishForm.description = ''
clearVideo()
clearCover()
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
stage.value = ''
}
}
</script>
<template>
<AppShell>
<div class="publish-wrap">
<div class="card publish-card">
<div class="row" style="justify-content: space-between; align-items: baseline">
<p class="title" style="margin: 0">发布视频</p>
<div v-if="busy" class="pill">进行中{{ stage || '' }}</div>
</div>
<p class="subtle" style="margin-top: 10px">选择视频文件与封面图片上传到本机后自动生成 URL再写入 `/video/publish`</p>
<div class="grid form-grid" style="margin-top: 16px">
<div>
<label>title</label>
<input v-model.trim="publishForm.title" class="big-input" :disabled="busy" />
</div>
<div>
<label>description</label>
<textarea v-model.trim="publishForm.description" class="big-input" :disabled="busy" />
</div>
<div class="grid two">
<div>
<label>video (.mp4)</label>
<input ref="videoInput" class="file-native" type="file" accept="video/mp4" :disabled="busy" @change="pickVideo" />
<div class="file-box">
<button type="button" :disabled="busy" @click="openVideoPicker">选择视频</button>
<div class="file-name" :class="publishForm.video ? '' : 'muted'">
{{ publishForm.video ? publishForm.video.name : '未选择文件' }}
</div>
<button v-if="publishForm.video" type="button" :disabled="busy" @click="clearVideo">清除</button>
</div>
<div v-if="publishForm.video" class="subtle" style="margin-top: 6px">
已选择{{ publishForm.video.name }}{{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB
</div>
</div>
<div>
<label>cover (jpg/png/webp)</label>
<input
ref="coverInput"
class="file-native"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="busy"
@change="pickCover"
/>
<div class="file-box">
<button type="button" :disabled="busy" @click="openCoverPicker">选择封面</button>
<div class="file-name" :class="publishForm.cover ? '' : 'muted'">
{{ publishForm.cover ? publishForm.cover.name : '未选择文件' }}
</div>
<button v-if="publishForm.cover" type="button" :disabled="busy" @click="clearCover">清除</button>
</div>
<div v-if="publishForm.cover" class="subtle" style="margin-top: 6px">已选择{{ publishForm.cover.name }}</div>
</div>
</div>
<div v-if="preview.coverUrl || preview.videoUrl" class="grid two">
<div v-if="preview.coverUrl" class="preview-card">
<div class="subtle">封面预览</div>
<img class="cover" :src="preview.coverUrl" alt="cover preview" />
</div>
<div v-if="preview.videoUrl" class="preview-card">
<div class="subtle">视频预览</div>
<video class="video" :src="preview.videoUrl" controls playsinline preload="metadata" />
</div>
</div>
<div class="row" style="justify-content: flex-end; margin-top: 8px">
<button class="primary big-btn" type="button" :disabled="busy" @click="onPublish">发布</button>
</div>
</div>
<div v-if="published" class="card" style="margin-top: 14px">
<p class="title">已发布</p>
<div class="row" style="justify-content: space-between">
<div>
<div class="title" style="margin: 0">{{ published.title }}</div>
<div class="subtle mono">#{{ published.id }}</div>
</div>
<div class="row">
<RouterLink class="pill" :to="`/video/${published.id}`">去播放</RouterLink>
<a class="pill mono" :href="published.play_url" target="_blank" rel="noreferrer">play_url</a>
<a class="pill mono" :href="published.cover_url" target="_blank" rel="noreferrer">cover_url</a>
</div>
</div>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.publish-wrap {
display: grid;
justify-items: center;
}
.publish-card {
width: min(980px, 100%);
padding: 22px;
}
.form-grid {
gap: 16px;
}
.form-grid .grid.two {
gap: 20px;
}
.form-grid .grid.two > * {
min-width: 0;
}
.form-grid input[type='file'] {
max-width: 100%;
}
.file-native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.file-box {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
border-radius: 14px;
min-height: 46px;
}
.file-box button {
padding: 8px 10px;
border-radius: 12px;
}
.file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: rgba(255, 255, 255, 0.88);
}
.muted {
color: rgba(255, 255, 255, 0.55);
}
.big-input {
box-sizing: border-box;
width: 100%;
max-width: 100%;
padding: 12px 14px;
font-size: 14px;
border-radius: 14px;
}
.big-btn {
padding: 12px 18px;
font-size: 14px;
border-radius: 14px;
}
.preview-card {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 12px;
display: grid;
gap: 10px;
}
.cover {
width: 100%;
aspect-ratio: 9/12;
object-fit: cover;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.35);
}
.video {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.35);
}
</style>