feat: 前端界面
This commit is contained in:
@@ -56,3 +56,44 @@ export async function postJson<T>(path: string, body: unknown, options?: { authR
|
|||||||
|
|
||||||
return data as T
|
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})`
|
||||||
|
throw new ApiError(msg, res.status, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { postJson } from './client'
|
import { postJson } from './client'
|
||||||
import type { IsLikedResponse, MessageResponse } from './types'
|
import type { IsLikedResponse, MessageResponse, Video } from './types'
|
||||||
|
|
||||||
export function like(videoId: number) {
|
export function like(videoId: number) {
|
||||||
return postJson<MessageResponse>('/like/like', { video_id: videoId }, { authRequired: true })
|
return postJson<MessageResponse>('/like/like', { video_id: videoId }, { authRequired: true })
|
||||||
@@ -12,3 +12,7 @@ export function unlike(videoId: number) {
|
|||||||
export function isLiked(videoId: number) {
|
export function isLiked(videoId: number) {
|
||||||
return postJson<IsLikedResponse>('/like/isLiked', { video_id: videoId }, { authRequired: true })
|
return postJson<IsLikedResponse>('/like/isLiked', { video_id: videoId }, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listMyLikedVideos() {
|
||||||
|
return postJson<Video[]>('/like/listMyLikedVideos', {}, { authRequired: true })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { postJson } from './client'
|
import { postForm, postJson } from './client'
|
||||||
import type { Video } from './types'
|
import type { Video } from './types'
|
||||||
|
|
||||||
export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) {
|
export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) {
|
||||||
return postJson<Video>('/video/publish', input, { authRequired: true })
|
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 function listByAuthorId(authorId: number) {
|
export function listByAuthorId(authorId: number) {
|
||||||
return postJson<Video[]>('/video/listByAuthorID', { author_id: authorId })
|
return postJson<Video[]>('/video/listByAuthorID', { author_id: authorId })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -13,12 +13,19 @@ function base64UrlToBase64(input: string) {
|
|||||||
return pad === 0 ? base64 : base64 + '='.repeat(4 - pad)
|
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 {
|
export function decodeJwtPayload(token: string): JwtPayload | null {
|
||||||
const [, payload] = token.split('.')
|
const [, payload] = token.split('.')
|
||||||
if (!payload) return null
|
if (!payload) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const json = atob(base64UrlToBase64(payload))
|
const json = base64ToUtf8String(base64UrlToBase64(payload))
|
||||||
const parsed = JSON.parse(json)
|
const parsed = JSON.parse(json)
|
||||||
if (!parsed || typeof parsed !== 'object') return null
|
if (!parsed || typeof parsed !== 'object') return null
|
||||||
return parsed as JwtPayload
|
return parsed as JwtPayload
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import AppShell from '../components/AppShell.vue'
|
|||||||
import UserAvatar from '../components/UserAvatar.vue'
|
import UserAvatar from '../components/UserAvatar.vue'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import * as accountApi from '../api/account'
|
import * as accountApi from '../api/account'
|
||||||
|
import * as likeApi from '../api/like'
|
||||||
import type { Video } from '../api/types'
|
import type { Video } from '../api/types'
|
||||||
import * as videoApi from '../api/video'
|
import * as videoApi from '../api/video'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
@@ -31,6 +32,9 @@ const myVideos = reactive({
|
|||||||
items: [] as Video[],
|
items: [] as Video[],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type VideoTab = 'works' | 'likes'
|
||||||
|
const videoTab = ref<VideoTab>('works')
|
||||||
|
|
||||||
let myVideosReq = 0
|
let myVideosReq = 0
|
||||||
async function loadMyVideos() {
|
async function loadMyVideos() {
|
||||||
const id = me.value.id
|
const id = me.value.id
|
||||||
@@ -58,10 +62,57 @@ async function loadMyVideos() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const likedVideos = reactive({
|
||||||
|
loading: false,
|
||||||
|
loaded: false,
|
||||||
|
error: '',
|
||||||
|
items: [] as Video[],
|
||||||
|
})
|
||||||
|
|
||||||
|
let likedVideosReq = 0
|
||||||
|
async function loadLikedVideos() {
|
||||||
|
if (!auth.isLoggedIn || !me.value.id) {
|
||||||
|
likedVideosReq += 1
|
||||||
|
likedVideos.loading = false
|
||||||
|
likedVideos.loaded = false
|
||||||
|
likedVideos.error = ''
|
||||||
|
likedVideos.items = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (likedVideos.loading) return
|
||||||
|
|
||||||
|
const req = ++likedVideosReq
|
||||||
|
likedVideos.loading = true
|
||||||
|
likedVideos.error = ''
|
||||||
|
try {
|
||||||
|
const vids = await likeApi.listMyLikedVideos()
|
||||||
|
if (req !== likedVideosReq) return
|
||||||
|
likedVideos.items = vids
|
||||||
|
likedVideos.loaded = true
|
||||||
|
} catch (e) {
|
||||||
|
if (req !== likedVideosReq) return
|
||||||
|
likedVideos.error = e instanceof ApiError ? e.message : String(e)
|
||||||
|
likedVideos.items = []
|
||||||
|
likedVideos.loaded = true
|
||||||
|
} finally {
|
||||||
|
if (req === likedVideosReq) likedVideos.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function goVideo(id: number) {
|
async function goVideo(id: number) {
|
||||||
await router.push(`/video/${id}`)
|
await router.push(`/video/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openWorksVideos() {
|
||||||
|
videoTab.value = 'works'
|
||||||
|
void loadMyVideos()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLikedVideos() {
|
||||||
|
videoTab.value = 'likes'
|
||||||
|
void loadLikedVideos()
|
||||||
|
}
|
||||||
|
|
||||||
async function onLogin() {
|
async function onLogin() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
const username = loginForm.username.trim()
|
const username = loginForm.username.trim()
|
||||||
@@ -135,8 +186,17 @@ watch(
|
|||||||
if (!v) {
|
if (!v) {
|
||||||
drawer.open = false
|
drawer.open = false
|
||||||
myVideosReq += 1
|
myVideosReq += 1
|
||||||
|
myVideos.loading = false
|
||||||
myVideos.items = []
|
myVideos.items = []
|
||||||
myVideos.error = ''
|
myVideos.error = ''
|
||||||
|
|
||||||
|
likedVideosReq += 1
|
||||||
|
likedVideos.loading = false
|
||||||
|
likedVideos.loaded = false
|
||||||
|
likedVideos.items = []
|
||||||
|
likedVideos.error = ''
|
||||||
|
|
||||||
|
videoTab.value = 'works'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -144,7 +204,10 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => me.value.id,
|
() => me.value.id,
|
||||||
(id) => {
|
(id) => {
|
||||||
if (auth.isLoggedIn && id) void loadMyVideos()
|
if (auth.isLoggedIn && id) {
|
||||||
|
void loadMyVideos()
|
||||||
|
if (videoTab.value === 'likes') void loadLikedVideos()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -199,33 +262,54 @@ watch(
|
|||||||
<div class="metric-num">{{ social.vloggersLoading ? '…' : social.followingCount }}</div>
|
<div class="metric-num">{{ social.vloggersLoading ? '…' : social.followingCount }}</div>
|
||||||
<div class="metric-label">关注</div>
|
<div class="metric-label">关注</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="metric static">
|
<button class="metric" type="button" :class="{ active: videoTab === 'works' }" @click="openWorksVideos">
|
||||||
<div class="metric-num">{{ myVideos.loading ? '…' : myVideos.items.length }}</div>
|
<div class="metric-num">{{ myVideos.loading ? '…' : myVideos.items.length }}</div>
|
||||||
<div class="metric-label">作品</div>
|
<div class="metric-label">作品</div>
|
||||||
</div>
|
</button>
|
||||||
|
<button class="metric" type="button" :class="{ active: videoTab === 'likes' }" @click="openLikedVideos">
|
||||||
|
<div class="metric-num">{{ likedVideos.loading ? '…' : likedVideos.loaded ? likedVideos.items.length : '—' }}</div>
|
||||||
|
<div class="metric-label">点赞</div>
|
||||||
|
</button>
|
||||||
<div v-if="socialErrorHint" class="subtle" style="margin-left: 8px">社交信息加载失败:{{ socialErrorHint }}</div>
|
<div v-if="socialErrorHint" class="subtle" style="margin-left: 8px">社交信息加载失败:{{ socialErrorHint }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top: 14px">
|
<div class="card" style="margin-top: 14px">
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
<p class="title" style="margin: 0">作品</p>
|
<p class="title" style="margin: 0">{{ videoTab === 'works' ? '作品' : '点赞视频' }}</p>
|
||||||
<div class="subtle">点击封面进入播放页</div>
|
<div class="subtle">点击封面进入播放页</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="myVideos.loading" class="hint" style="margin-top: 12px">加载中…</div>
|
<template v-if="videoTab === 'works'">
|
||||||
<div v-else-if="myVideos.error" class="hint bad" style="margin-top: 12px">{{ myVideos.error }}</div>
|
<div v-if="myVideos.loading" class="hint" style="margin-top: 12px">加载中…</div>
|
||||||
<div v-else-if="myVideos.items.length === 0" class="hint" style="margin-top: 12px">暂无作品</div>
|
<div v-else-if="myVideos.error" class="hint bad" style="margin-top: 12px">{{ myVideos.error }}</div>
|
||||||
|
<div v-else-if="myVideos.items.length === 0" class="hint" style="margin-top: 12px">暂无作品</div>
|
||||||
|
|
||||||
<div v-else class="video-grid" style="margin-top: 12px">
|
<div v-else class="video-grid" style="margin-top: 12px">
|
||||||
<button v-for="v in myVideos.items" :key="v.id" class="video-card" type="button" @click="goVideo(v.id)">
|
<button v-for="v in myVideos.items" :key="v.id" class="video-card" type="button" @click="goVideo(v.id)">
|
||||||
<img class="video-cover" :src="v.cover_url" :alt="v.title" loading="lazy" />
|
<img class="video-cover" :src="v.cover_url" :alt="v.title" loading="lazy" />
|
||||||
<div class="video-meta">
|
<div class="video-meta">
|
||||||
<div class="video-title">{{ v.title }}</div>
|
<div class="video-title">{{ v.title }}</div>
|
||||||
<div class="video-sub subtle">❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }}</div>
|
<div class="video-sub subtle">❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="likedVideos.loading" class="hint" style="margin-top: 12px">加载中…</div>
|
||||||
|
<div v-else-if="likedVideos.error" class="hint bad" style="margin-top: 12px">{{ likedVideos.error }}</div>
|
||||||
|
<div v-else-if="likedVideos.items.length === 0" class="hint" style="margin-top: 12px">暂无点赞视频</div>
|
||||||
|
|
||||||
|
<div v-else class="video-grid" style="margin-top: 12px">
|
||||||
|
<button v-for="v in likedVideos.items" :key="v.id" class="video-card" type="button" @click="goVideo(v.id)">
|
||||||
|
<img class="video-cover" :src="v.cover_url" :alt="v.title" loading="lazy" />
|
||||||
|
<div class="video-meta">
|
||||||
|
<div class="video-title">{{ v.title }}</div>
|
||||||
|
<div class="video-sub subtle">❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -294,6 +378,11 @@ watch(
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric.active {
|
||||||
|
background: rgba(254, 44, 85, 0.14);
|
||||||
|
border-color: rgba(254, 44, 85, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
.metric.static {
|
.metric.static {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,225 +1,346 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AppShell from '../components/AppShell.vue'
|
import AppShell from '../components/AppShell.vue'
|
||||||
import JsonBox from '../components/JsonBox.vue'
|
|
||||||
import UserAvatar from '../components/UserAvatar.vue'
|
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import * as videoApi from '../api/video'
|
import * as videoApi from '../api/video'
|
||||||
import type { Video } from '../api/types'
|
import type { Video } from '../api/types'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useSocialStore } from '../stores/social'
|
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const social = useSocialStore()
|
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const myId = computed(() => auth.claims?.account_id ?? 0)
|
|
||||||
|
|
||||||
const last = reactive<{ action: string; loading: boolean; data: unknown }>({
|
const busy = ref(false)
|
||||||
action: '',
|
const stage = ref('')
|
||||||
loading: false,
|
const published = ref<Video | null>(null)
|
||||||
data: 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function exec(action: string, fn: () => Promise<unknown>) {
|
const preview = reactive({
|
||||||
last.action = action
|
videoUrl: '',
|
||||||
last.loading = true
|
coverUrl: '',
|
||||||
last.data = null
|
})
|
||||||
try {
|
|
||||||
const res = await fn()
|
function setPreviewVideo(file: File | null) {
|
||||||
last.data = res
|
if (preview.videoUrl) URL.revokeObjectURL(preview.videoUrl)
|
||||||
return res
|
preview.videoUrl = file ? URL.createObjectURL(file) : ''
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof ApiError ? e.message : String(e)
|
|
||||||
toast.error(msg)
|
|
||||||
last.data = e instanceof ApiError ? e.payload : null
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
last.loading = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishForm = reactive({ title: '', description: '', play_url: '', cover_url: '' })
|
function setPreviewCover(file: File | null) {
|
||||||
const listAuthorId = ref<number>(1)
|
if (preview.coverUrl) URL.revokeObjectURL(preview.coverUrl)
|
||||||
const detailId = ref<number>(1)
|
preview.coverUrl = file ? URL.createObjectURL(file) : ''
|
||||||
|
}
|
||||||
|
|
||||||
const listResult = ref<Video[] | null>(null)
|
watch(
|
||||||
const followBusy = reactive<Record<string, boolean>>({})
|
() => 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() {
|
async function onPublish() {
|
||||||
const res = await exec('发布视频', () => videoApi.publishVideo(publishForm))
|
if (busy.value) return
|
||||||
if (res) toast.success('已发布')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onListByAuthor() {
|
|
||||||
await exec('按作者列出视频', async () => {
|
|
||||||
const res = await videoApi.listByAuthorId(listAuthorId.value)
|
|
||||||
listResult.value = res
|
|
||||||
return res
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onGetDetail() {
|
|
||||||
await exec('获取视频详情', () => videoApi.getDetail(detailId.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleFollow(authorId: number) {
|
|
||||||
if (!auth.isLoggedIn) {
|
if (!auth.isLoggedIn) {
|
||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
|
await router.push('/account')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (myId.value && myId.value === authorId) return
|
|
||||||
|
|
||||||
const key = String(authorId)
|
const title = publishForm.title.trim()
|
||||||
if (followBusy[key]) return
|
const description = publishForm.description.trim()
|
||||||
followBusy[key] = true
|
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 {
|
try {
|
||||||
if (social.isFollowing(authorId)) {
|
stage.value = '上传封面'
|
||||||
await social.unfollow(authorId)
|
const coverRes = await videoApi.uploadCover(publishForm.cover!)
|
||||||
toast.info('已取关')
|
|
||||||
} else {
|
stage.value = '上传视频'
|
||||||
await social.follow(authorId)
|
const videoRes = await videoApi.uploadVideo(publishForm.video!)
|
||||||
toast.success('已关注')
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
const msg = e instanceof ApiError ? e.message : String(e)
|
const msg = e instanceof ApiError ? e.message : String(e)
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
followBusy[key] = false
|
busy.value = false
|
||||||
|
stage.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div class="grid two">
|
<div class="publish-wrap">
|
||||||
<div class="card">
|
<div class="card publish-card">
|
||||||
<p class="title">视频</p>
|
<div class="row" style="justify-content: space-between; align-items: baseline">
|
||||||
<p class="subtle">发布需要 JWT;列表/详情无需 JWT。</p>
|
<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="card" style="margin-top: 12px">
|
<div class="grid form-grid" style="margin-top: 16px">
|
||||||
<p class="title">发布视频(JWT)</p>
|
<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 class="grid two">
|
||||||
<div>
|
<div>
|
||||||
<label>title</label>
|
<label>video (.mp4)</label>
|
||||||
<input v-model.trim="publishForm.title" />
|
<input ref="videoInput" class="file-native" type="file" accept="video/mp4" :disabled="busy" @change="pickVideo" />
|
||||||
</div>
|
<div class="file-box">
|
||||||
<div>
|
<button type="button" :disabled="busy" @click="openVideoPicker">选择视频</button>
|
||||||
<label>description</label>
|
<div class="file-name" :class="publishForm.video ? '' : 'muted'">
|
||||||
<input v-model.trim="publishForm.description" />
|
{{ publishForm.video ? publishForm.video.name : '未选择文件' }}
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>play_url</label>
|
|
||||||
<input v-model.trim="publishForm.play_url" placeholder="http://..." />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>cover_url</label>
|
|
||||||
<input v-model.trim="publishForm.cover_url" placeholder="http://..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row" style="margin-top: 10px">
|
|
||||||
<button class="primary" type="button" :disabled="last.loading" @click="onPublish">发布</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid two" style="margin-top: 12px">
|
|
||||||
<div class="card">
|
|
||||||
<p class="title">按作者列出</p>
|
|
||||||
<div class="grid">
|
|
||||||
<div>
|
|
||||||
<label>author_id</label>
|
|
||||||
<input v-model.number="listAuthorId" type="number" min="1" />
|
|
||||||
</div>
|
|
||||||
<button class="primary" type="button" :disabled="last.loading" @click="onListByAuthor">查询</button>
|
|
||||||
<div v-if="listResult" class="subtle">共 {{ listResult.length }} 条</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<p class="title">详情</p>
|
|
||||||
<div class="grid">
|
|
||||||
<div>
|
|
||||||
<label>id</label>
|
|
||||||
<input v-model.number="detailId" type="number" min="1" />
|
|
||||||
</div>
|
|
||||||
<button class="primary" type="button" :disabled="last.loading" @click="onGetDetail">获取</button>
|
|
||||||
<RouterLink class="pill" :to="`/video/${detailId}`">打开详情页(含评论/点赞)</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="listResult" class="card" style="margin-top: 12px">
|
|
||||||
<p class="title">列表结果</p>
|
|
||||||
<div class="grid" style="gap: 10px">
|
|
||||||
<div v-for="v in listResult" :key="v.id" class="card" style="background: rgba(255, 255, 255, 0.05)">
|
|
||||||
<div class="row" style="justify-content: space-between">
|
|
||||||
<div>
|
|
||||||
<div class="title">
|
|
||||||
<RouterLink :to="`/video/${v.id}`">{{ v.title }}</RouterLink>
|
|
||||||
</div>
|
|
||||||
<div class="row" style="gap: 10px; margin-top: 6px">
|
|
||||||
<RouterLink class="author-link" :to="`/u/${v.author_id}`">
|
|
||||||
<UserAvatar :username="v.username" :id="v.author_id" :size="28" />
|
|
||||||
<span class="author-name">@{{ v.username }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
<span class="subtle mono">#{{ v.author_id }}</span>
|
|
||||||
<span class="subtle">❤️ {{ v.likes_count }}</span>
|
|
||||||
<span class="subtle">{{ new Date(v.create_time).toLocaleString() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<button v-if="publishForm.video" type="button" :disabled="busy" @click="clearVideo">清除</button>
|
||||||
<button
|
</div>
|
||||||
v-if="auth.isLoggedIn && (!myId || myId !== v.author_id)"
|
<div v-if="publishForm.video" class="subtle" style="margin-top: 6px">
|
||||||
class="primary"
|
已选择:{{ publishForm.video.name }}({{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB)
|
||||||
type="button"
|
</div>
|
||||||
style="padding: 8px 10px"
|
</div>
|
||||||
:disabled="!!followBusy[String(v.author_id)]"
|
<div>
|
||||||
@click.stop="toggleFollow(v.author_id)"
|
<label>cover (jpg/png/webp)</label>
|
||||||
>
|
<input
|
||||||
{{ social.isFollowing(v.author_id) ? '已关注' : '关注' }}
|
ref="coverInput"
|
||||||
</button>
|
class="file-native"
|
||||||
<RouterLink class="pill" :to="`/video/${v.id}`">详情</RouterLink>
|
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>
|
</div>
|
||||||
|
<button v-if="publishForm.cover" type="button" :disabled="busy" @click="clearCover">清除</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top: 10px">
|
<div v-if="publishForm.cover" class="subtle" style="margin-top: 6px">已选择:{{ publishForm.cover.name }}</div>
|
||||||
<a class="pill mono" :href="v.play_url" target="_blank" rel="noreferrer">play_url</a>
|
</div>
|
||||||
<a class="pill mono" :href="v.cover_url" target="_blank" rel="noreferrer">cover_url</a>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<p class="title">最近响应</p>
|
|
||||||
<div class="row" style="margin-bottom: 10px">
|
|
||||||
<span class="pill">动作:{{ last.action || '-' }}</span>
|
|
||||||
<span v-if="last.loading" class="pill">请求中…</span>
|
|
||||||
</div>
|
|
||||||
<JsonBox :value="last.data" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.author-link {
|
.publish-wrap {
|
||||||
display: inline-flex;
|
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;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
text-decoration: none;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.author-link:hover {
|
.file-box button {
|
||||||
text-decoration: none;
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.author-name {
|
.file-name {
|
||||||
font-weight: 800;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.muted {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
// Force IPv4 to avoid Windows resolving `localhost` -> `::1` (IPv6) and causing ECONNREFUSED
|
||||||
|
target: 'http://127.0.0.1:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user