From 423e602da569cd16718c8182b6dc79916955b1d0 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Mon, 1 Dec 2025 01:10:13 +0800 Subject: [PATCH] add: video labelling page --- .github/workflows/main.yml | 19 -- packages/backend/lib/schema.ts | 2 + packages/backend/routes/auth/index.ts | 6 +- packages/backend/routes/auth/user.ts | 30 ++ packages/backend/routes/video/label.ts | 95 ++++++ packages/backend/routes/video/metadata.ts | 3 +- packages/backend/routes/video/snapshots.ts | 2 +- packages/backend/src/index.ts | 3 + packages/core/drizzle.config.ts | 2 +- packages/core/drizzle/main/relations.ts | 10 +- packages/core/drizzle/main/schema.ts | 21 +- packages/temp_frontend/app/app.css | 4 +- .../temp_frontend/app/components/Layout.tsx | 34 +-- packages/temp_frontend/app/root.tsx | 2 +- packages/temp_frontend/app/routes.ts | 1 + .../app/routes/home/Milestone.tsx | 4 +- .../app/routes/home/MilestoneVideoCard.tsx | 172 ++++++----- .../temp_frontend/app/routes/home/index.tsx | 16 +- .../temp_frontend/app/routes/labelling.tsx | 284 ++++++++++++++++++ .../app/routes/video/[id]/info/index.tsx | 5 +- 20 files changed, 588 insertions(+), 127 deletions(-) delete mode 100644 .github/workflows/main.yml create mode 100644 packages/backend/routes/auth/user.ts create mode 100644 packages/backend/routes/video/label.ts create mode 100644 packages/temp_frontend/app/routes/labelling.tsx diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 0d0181d..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Deploy -on: [push] -jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - name: Deploy the backend - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.KEY }} - port: ${{ secrets.PORT }} - script: | - source ~/.bashrc - cd /web/cvsa - git pull - /home/github-actions/.bun/bin/pm2 restart cvsa-be --update-env diff --git a/packages/backend/lib/schema.ts b/packages/backend/lib/schema.ts index fd1a142..4fe2684 100644 --- a/packages/backend/lib/schema.ts +++ b/packages/backend/lib/schema.ts @@ -52,6 +52,8 @@ export const BiliVideoSchema = z.object({ coverUrl: z.string().nullable() }); +export type BiliVideoType = z.infer; + export const SongSchema = z.object({ duration: z.number().nullable(), name: z.string().nullable(), diff --git a/packages/backend/routes/auth/index.ts b/packages/backend/routes/auth/index.ts index a914bfd..c538cde 100644 --- a/packages/backend/routes/auth/index.ts +++ b/packages/backend/routes/auth/index.ts @@ -1,5 +1,9 @@ import Elysia from "elysia"; import { loginHandler } from "./login"; import { logoutHandler } from "./logout"; +import { getCurrentUserHandler } from "./user"; -export const authHandler = new Elysia().use(loginHandler).use(logoutHandler); +export const authHandler = new Elysia() + .use(loginHandler) + .use(logoutHandler) + .use(getCurrentUserHandler); diff --git a/packages/backend/routes/auth/user.ts b/packages/backend/routes/auth/user.ts new file mode 100644 index 0000000..57baf55 --- /dev/null +++ b/packages/backend/routes/auth/user.ts @@ -0,0 +1,30 @@ +import { Elysia, t } from "elysia"; +import requireAuth from "@backend/middlewares/auth"; + +export const getCurrentUserHandler = new Elysia().use(requireAuth).get( + "/user", + async ({ user, status }) => { + if (!user) { + return status(401, { message: "Unauthorized" }); + } + return { + id: user.id, + username: user.username, + nickname: user.nickname, + role: user.role + }; + }, + { + response: { + 200: t.Object({ + id: t.Integer(), + username: t.String(), + nickname: t.Union([t.String(), t.Null()]), + role: t.String() + }), + 401: t.Object({ + message: t.String() + }) + } + } +); diff --git a/packages/backend/routes/video/label.ts b/packages/backend/routes/video/label.ts new file mode 100644 index 0000000..94662ce --- /dev/null +++ b/packages/backend/routes/video/label.ts @@ -0,0 +1,95 @@ +import { Elysia, t } from "elysia"; +import { ErrorResponseSchema } from "@backend/src/schema"; +import z from "zod"; +import { BiliVideoSchema, BiliVideoType } from "@backend/lib/schema"; +import requireAuth from "@backend/middlewares/auth"; +import { sql, eq } from "drizzle-orm"; +import { bilibiliMetadata, db, videoTypeLabelInInternal } from "@core/drizzle"; +import { biliIDToAID } from "@backend/lib/bilibiliID"; + +const videoSchema = BiliVideoSchema.omit({ publishedAt: true }) + .omit({ createdAt: true }) + .omit({ coverUrl: true }) + .extend({ + views: z.number(), + username: z.string(), + uid: z.number(), + published_at: z.string(), + createdAt: z.string(), + cover_url: z.string() + }); + +export const getUnlabelledVideos = new Elysia({ prefix: "/videos" }).use(requireAuth).get( + "/unlabelled", + async () => { + const videos = await db.execute>(sql` + SELECT bm.*, ls.views, bu.username, bu.uid + FROM ( + SELECT * + FROM bilibili_metadata + TABLESAMPLE SYSTEM (0.1) + ORDER BY RANDOM() + LIMIT 20 + ) bm + JOIN latest_video_snapshot ls + ON ls.aid = bm.aid + JOIN bilibili_user bu + ON bu.uid = bm.uid + `); + return videos; + }, + { + response: { + 200: z.array(videoSchema), + 400: ErrorResponseSchema, + 500: ErrorResponseSchema + } + } +); + +export const postVideoLabel = new Elysia({ prefix: "/video" }).use(requireAuth).post( + "/:id/label", + async ({ params, body, status, user }) => { + const id = params.id; + const aid = biliIDToAID(id); + const label = body.label; + + if (!aid) { + return status(400, { + code: "MALFORMED_SLOT", + message: + "We cannot parse the video ID, or we currently do not support this format.", + errors: [] + }); + } + + const video = await db + .select() + .from(bilibiliMetadata) + .where(eq(bilibiliMetadata.aid, aid)) + .limit(1); + + if (video.length === 0) { + return status(400, { + code: "VIDEO_NOT_FOUND", + message: "Video not found", + errors: [] + }); + } + + await db.insert(videoTypeLabelInInternal).values({ + aid, + label, + user: user!.unqId + }); + + return status(201, { + message: `Labelled video av${aid} as ${label}` + }); + }, + { + body: t.Object({ + label: t.Boolean() + }) + } +); diff --git a/packages/backend/routes/video/metadata.ts b/packages/backend/routes/video/metadata.ts index 47c98a1..fda1771 100644 --- a/packages/backend/routes/video/metadata.ts +++ b/packages/backend/routes/video/metadata.ts @@ -5,6 +5,7 @@ import { getVideoInfo } from "@core/net/getVideoInfo"; import { redis } from "@core/db/redis"; import { ErrorResponseSchema } from "@backend/src/schema"; import type { VideoInfoData } from "@core/net/bilibili.d.ts"; +import { BiliAPIVideoMetadataSchema } from "@backend/lib/schema"; export async function retrieveVideoInfoFromCache(aid: number) { const cacheKey = `cvsa:videoInfo:av${aid}`; @@ -59,7 +60,7 @@ export const getVideoMetadataHandler = new Elysia({ prefix: "/video" }).get( const cachedData = await retrieveVideoInfoFromCache(aid); if (cachedData) { - return cachedData.data; + return cachedData; } const r = await getVideoInfo(aid, "getVideoInfo"); diff --git a/packages/backend/routes/video/snapshots.ts b/packages/backend/routes/video/snapshots.ts index 5f12b76..33a9edf 100644 --- a/packages/backend/routes/video/snapshots.ts +++ b/packages/backend/routes/video/snapshots.ts @@ -1,6 +1,6 @@ import { Elysia } from "elysia"; import { db, videoSnapshot } from "@core/drizzle"; -import { biliIDToAID, bv2av } from "@backend/lib/bilibiliID"; +import { biliIDToAID } from "@backend/lib/bilibiliID"; import { ErrorResponseSchema } from "@backend/src/schema"; import { eq, desc } from "drizzle-orm"; import z from "zod"; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 3e6dc99..fb1a4b8 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -18,6 +18,7 @@ import "./mq"; import pkg from "../package.json"; import * as z from "zod"; import { fromTypes } from "@elysiajs/openapi"; +import { getUnlabelledVideos, postVideoLabel } from "@backend/routes/video/label"; const [host, port] = getBindingInfo(); logStartup(host, port); @@ -67,6 +68,8 @@ const app = new Elysia({ .use(addSongHandler) .use(deleteSongHandler) .use(songEtaHandler) + .use(getUnlabelledVideos) + .use(postVideoLabel) .get( "/song/:id", ({ redirect, params }) => { diff --git a/packages/core/drizzle.config.ts b/packages/core/drizzle.config.ts index 9a901d0..56da063 100644 --- a/packages/core/drizzle.config.ts +++ b/packages/core/drizzle.config.ts @@ -7,5 +7,5 @@ export default defineConfig({ dbCredentials: { url: process.env.DATABASE_URL_MAIN! }, - schemaFilter: ["public", "credentials"] + schemaFilter: ["public", "credentials", "internal"] }); diff --git a/packages/core/drizzle/main/relations.ts b/packages/core/drizzle/main/relations.ts index d491e2d..6ec9fdc 100644 --- a/packages/core/drizzle/main/relations.ts +++ b/packages/core/drizzle/main/relations.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm/relations"; -import { usersInCredentials, history, songs, relationsProducer, singer, relationSinger } from "./schema"; +import { usersInCredentials, history, songs, relationsProducer, singer, relationSinger, videoTypeLabelInInternal } from "./schema"; export const historyRelations = relations(history, ({one}) => ({ usersInCredential: one(usersInCredentials, { @@ -10,6 +10,7 @@ export const historyRelations = relations(history, ({one}) => ({ export const usersInCredentialsRelations = relations(usersInCredentials, ({many}) => ({ histories: many(history), + videoTypeLabelInInternals: many(videoTypeLabelInInternal), })); export const relationsProducerRelations = relations(relationsProducer, ({one}) => ({ @@ -37,4 +38,11 @@ export const relationSingerRelations = relations(relationSinger, ({one}) => ({ export const singerRelations = relations(singer, ({many}) => ({ relationSingers: many(relationSinger), +})); + +export const videoTypeLabelInInternalRelations = relations(videoTypeLabelInInternal, ({one}) => ({ + usersInCredential: one(usersInCredentials, { + fields: [videoTypeLabelInInternal.user], + references: [usersInCredentials.unqId] + }), })); \ No newline at end of file diff --git a/packages/core/drizzle/main/schema.ts b/packages/core/drizzle/main/schema.ts index 0fa5d9b..8da0a25 100644 --- a/packages/core/drizzle/main/schema.ts +++ b/packages/core/drizzle/main/schema.ts @@ -2,8 +2,10 @@ import { pgTable, pgSchema, uniqueIndex, check, integer, text, timestamp, foreig import { sql } from "drizzle-orm" export const credentials = pgSchema("credentials"); +export const internal = pgSchema("internal"); export const userRoleInCredentials = credentials.enum("user_role", ['ADMIN', 'USER', 'OWNER']) +export const usersIdSeqInCredentials = credentials.sequence("users_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) export const allDataIdSeq = pgSequence("all_data_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) export const labelingResultIdSeq = pgSequence("labeling_result_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) export const relationSingerIdSeq = pgSequence("relation_singer_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) @@ -11,7 +13,6 @@ export const relationsProducerIdSeq = pgSequence("relations_producer_id_seq", { export const songsIdSeq = pgSequence("songs_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) export const videoSnapshotIdSeq = pgSequence("video_snapshot_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) export const viewsIncrementRateIdSeq = pgSequence("views_increment_rate_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "9223372036854775807", cache: "1", cycle: false }) -export const usersIdSeqInCredentials = credentials.sequence("users_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) export const usersInCredentials = credentials.table("users", { id: integer().default(sql`nextval('credentials.users_id_seq'::regclass)`).notNull(), @@ -330,3 +331,21 @@ export const bilibiliUser = pgTable("bilibili_user", { check("bilibili_user_created_at_not_null", sql`NOT NULL created_at`), check("bilibili_user_updated_at_not_null", sql`NOT NULL updated_at`), ]); + +export const videoTypeLabelInInternal = internal.table("video_type_label", { + id: serial().primaryKey().notNull(), + // You can use { mode: "bigint" } if numbers are exceeding js number limitations + aid: bigint({ mode: "number" }).notNull(), + label: boolean().notNull(), + user: text().default('i3wW8JdZ9sT3ASkk').notNull(), +}, (table) => [ + foreignKey({ + columns: [table.user], + foreignColumns: [usersInCredentials.unqId], + name: "fkey_video_type_label_user" + }), + check("video_type_label_id_not_null", sql`NOT NULL id`), + check("video_type_label_aid_not_null", sql`NOT NULL aid`), + check("video_type_label_label_not_null", sql`NOT NULL label`), + check("video_type_label_user_not_null", sql`NOT NULL "user"`), +]); diff --git a/packages/temp_frontend/app/app.css b/packages/temp_frontend/app/app.css index ebdfcb2..6d0b2e4 100644 --- a/packages/temp_frontend/app/app.css +++ b/packages/temp_frontend/app/app.css @@ -80,7 +80,7 @@ input[type="number"] { --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.92 0 0); - --secondary-foreground: oklch(0.205 0 0); + --secondary-foreground: oklch(0.305 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.95 0 0); @@ -115,7 +115,7 @@ input[type="number"] { --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); + --secondary-foreground: oklch(0.85 0 0); --muted: oklch(0.188 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); diff --git a/packages/temp_frontend/app/components/Layout.tsx b/packages/temp_frontend/app/components/Layout.tsx index 0c09435..a070efc 100644 --- a/packages/temp_frontend/app/components/Layout.tsx +++ b/packages/temp_frontend/app/components/Layout.tsx @@ -1,10 +1,10 @@ import { toast } from "sonner"; import { Search } from "./Search"; -export function Layout({ children }: { children?: React.ReactNode }) { +export function LayoutWithoutSearch({ children }: { children?: React.ReactNode }) { return (
-
+
- + {children} ); } +export function Layout({ children }: { children?: React.ReactNode }) { + return ( + + + {children} + + ); +} + const LoginOrLogout = () => { const session = localStorage.getItem("sessionID"); if (session) { @@ -37,22 +46,3 @@ const LoginOrLogout = () => { return 登录; } }; - -export function LayoutWithoutSearch({ children }: { children?: React.ReactNode }) { - return ( -
-
- - - {children} -
-
- ); -} diff --git a/packages/temp_frontend/app/root.tsx b/packages/temp_frontend/app/root.tsx index f830d85..fff7f70 100644 --- a/packages/temp_frontend/app/root.tsx +++ b/packages/temp_frontend/app/root.tsx @@ -33,7 +33,7 @@ export function Layout({ children }: { children: React.ReactNode }) { {children} - + ); diff --git a/packages/temp_frontend/app/routes.ts b/packages/temp_frontend/app/routes.ts index 218c02f..9fd1de3 100644 --- a/packages/temp_frontend/app/routes.ts +++ b/packages/temp_frontend/app/routes.ts @@ -8,4 +8,5 @@ export default [ route("login", "routes/login.tsx"), route("video/:id/info", "routes/video/[id]/info/index.tsx"), route("time-calculator", "routes/time-calculator.tsx"), + route("labelling", "routes/labelling.tsx"), ] satisfies RouteConfig; diff --git a/packages/temp_frontend/app/routes/home/Milestone.tsx b/packages/temp_frontend/app/routes/home/Milestone.tsx index 04585cd..be6d60e 100644 --- a/packages/temp_frontend/app/routes/home/Milestone.tsx +++ b/packages/temp_frontend/app/routes/home/Milestone.tsx @@ -102,7 +102,7 @@ export const MilestoneVideos: React.FC = () => { if (isLoading && milestoneData.length === 0) { return ( - +
{[1, 2, 3].map((i) => (
{ return (
- + {milestoneData.map((video) => ( = DAY) { + const days = Math.floor(diffMs / DAY); + const hours = Math.floor((diffMs % DAY) / HOUR); + return `${days} 天 ${hours} 时`; + } + + return "刚刚"; +} export const MilestoneVideoCard = ({ - video, - milestoneType, + video, + milestoneType, }: { - video: NonNullable[number]; - milestoneType: MilestoneType; + video: NonNullable[number]; + milestoneType: MilestoneType; }) => { - const config = milestoneConfig[milestoneType]; - const remainingViews = config.target - video.eta.currentViews; - const progressPercentage = (video.eta.currentViews / config.target) * 100; + const config = milestoneConfig[milestoneType]; + const remainingViews = config.target - video.eta.currentViews; + const progressPercentage = (video.eta.currentViews / config.target) * 100; - return ( - -
- {video.bilibili_metadata.coverUrl && ( - 视频封面 - )} -
-

- - {video.bilibili_metadata.title} - -

+ return ( + +
+ {video.bilibili_metadata.coverUrl && ( + 视频封面 + )} +
+

+ + {video.bilibili_metadata.title} + +

-
-
- 当前播放: {video.eta.currentViews.toLocaleString()} - 目标: {config.target.toLocaleString()} -
+
+
+ 当前播放: {video.eta.currentViews.toLocaleString()} + 目标: {config.target.toLocaleString()} +
- -
-
-
-
-
-

剩余播放: {remainingViews.toLocaleString()}

-

预计达成: {formatHours(video.eta.eta)}

-
-
-

播放速度: {Math.round(video.eta.speed)}/小时

-

达成时间: {addHoursToNow(video.eta.eta)}

-
-
+ +
+
+
+
+
+

剩余播放: {remainingViews.toLocaleString()}

+

预计达成: {formatHours(video.eta.eta)}

+
+
+

播放速度: {Math.round(video.eta.speed)}/小时

+

达成时间: {addHoursToNow(video.eta.eta)}

+
+
-
- {video.bilibili_metadata.publishedAt && ( - - 发布于 {formatDateTime(new Date(video.bilibili_metadata.publishedAt))} - - )} - - 观看视频 - - - 查看详情 - -
-
- ); +
+ {video.bilibili_metadata.publishedAt && ( + + 发布于 {formatDateTime(new Date(video.bilibili_metadata.publishedAt))} + + )} + + 数据更新于 {timeAgo(new Date(video.eta.updatedAt))}前 + + + 观看视频 + + + 查看详情 + +
+ + ); }; diff --git a/packages/temp_frontend/app/routes/home/index.tsx b/packages/temp_frontend/app/routes/home/index.tsx index 73103a2..bc3970c 100644 --- a/packages/temp_frontend/app/routes/home/index.tsx +++ b/packages/temp_frontend/app/routes/home/index.tsx @@ -14,16 +14,24 @@ export default function Home() { return (

小工具

-
- - - + diff --git a/packages/temp_frontend/app/routes/labelling.tsx b/packages/temp_frontend/app/routes/labelling.tsx new file mode 100644 index 0000000..2f4b00e --- /dev/null +++ b/packages/temp_frontend/app/routes/labelling.tsx @@ -0,0 +1,284 @@ +import { Layout } from "@/components/Layout"; +import { useCallback, useEffect, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { formatDateTime } from "@/components/SearchResults"; +import { treaty } from "@elysiajs/eden"; +import type { App } from "@backend/src"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Error } from "@/components/Error"; +import { Title } from "@/components/Title"; +import { toast } from "sonner"; +import { ChevronLeft, ChevronRight, Check, X } from "lucide-react"; + +// @ts-expect-error anyway... +const app = treaty(import.meta.env.VITE_API_URL!); + +type VideosResponse = Awaited["get"]>>["data"]; + +export default function Home() { + const [videos, setVideos] = useState>([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + + const fetchVideos = useCallback(async () => { + try { + setLoading(true); + const { data, error } = await app.videos.unlabelled.get({ + headers: { + Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`, + }, + }); + + if (error) { + setError(error); + return; + } + + if (data && data.length > 0) { + setVideos((prev) => [...prev, ...data]); + setHasMore(data.length === 20); + } else { + setHasMore(false); + } + } catch (err) { + setError({ status: 500, value: { message: "网络错误" } }); + } finally { + setLoading(false); + } + }, []); + + const loadMoreIfNeeded = useCallback(() => { + if (hasMore && videos.length - currentIndex <= 6) { + fetchVideos(); + } + }, [hasMore, videos.length, currentIndex, fetchVideos]); + + const labelVideo = async (videoId: string, label: boolean) => { + const videoKey = `${videoId}-${label}`; + + const maxRetries = 5; + let retries = 0; + + const attemptLabel = async (): Promise => { + try { + const { error } = await app.video({ id: videoId }).label.post( + { label }, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`, + }, + }, + ); + + if (error) { + throw error; + } + + toast.success(`已标记视频 ${label ? "是" : "否"}`); + return true; + } catch (err) { + retries++; + if (retries < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 1000 * retries)); + return attemptLabel(); + } + return false; + } + }; + + const success = await attemptLabel(); + + if (!success) { + toast.error(`标记失败,请稍后重试`); + } + }; + + const handleLabel = async (label: boolean) => { + const currentVideo = videos[currentIndex]; + if (!currentVideo) return; + + labelVideo(currentVideo.bvid!, label); + if (currentIndex < videos.length - 1) { + setCurrentIndex((prev) => prev + 1); + loadMoreIfNeeded(); + } else { + fetchVideos(); + if (videos.length > currentIndex + 1) { + setCurrentIndex((prev) => prev + 1); + } + } + }; + + const navigateTo = (index: number) => { + if (index >= 0 && index < videos.length) { + setCurrentIndex(index); + loadMoreIfNeeded(); + } + }; + + useEffect(() => { + fetchVideos(); + }, [fetchVideos]); + + useEffect(() => { + loadMoreIfNeeded(); + }, [currentIndex, loadMoreIfNeeded]); + + if (loading && videos.length === 0) { + return ( + + + <div className="space-y-6"> + <Skeleton className="mt-6 w-full aspect-video rounded-lg" /> + <div className="mt-6 flex justify-between items-baseline"> + <Skeleton className="w-60 h-10 rounded-sm" /> + <Skeleton className="w-25 h-10 rounded-sm" /> + </div> + <Skeleton className="w-full h-20 rounded-lg" /> + </div> + </Layout> + ); + } + + if (error && videos.length === 0) { + return <Error error={error} />; + } + + const currentVideo = videos[currentIndex]; + + return ( + <Layout> + <Title title="视频打标工具" /> + + {currentVideo ? ( + <> + <div className="mb-24"> + <p className="mt-4 mb-3"> + 该视频是否包含一首<b>中V歌曲</b>? + <Label className="text-secondary-foreground mt-1 leading-5"> + 中V歌曲意味着它是由中文虚拟歌姬演唱,或歌词中包含中文。歌曲可以是原创,也可以是非原创(如翻唱、翻调等)。 + </Label> + </p> + <div className="flex flex-col sm:flex-row sm:gap-4"> + {currentVideo.cover_url && ( + <a + href={`https://www.bilibili.com/video/${currentVideo.bvid}`} + target="_blank" + rel="noopener noreferrer" + className="min-w-full sm:min-w-60 md:min-w-80 max-w-full + sm:max-w-60 md:max-w-80 aspect-video" + > + <img + src={currentVideo.cover_url} + referrerPolicy="no-referrer" + className="w-full object-cover rounded-lg" + alt="Video cover" + /> + </a> + )} + <div> + <div className="max-sm:mt-6 flex items-center gap-2"> + <h1 className="text-2xl font-medium"> + <a + href={`https://www.bilibili.com/video/${currentVideo.bvid}`} + target="_blank" + rel="noopener noreferrer" + className="hover:underline" + > + {currentVideo.title ? currentVideo.title : "未知视频标题"} + </a> + </h1> + </div> + + <div className="flex justify-between mt-3"> + <div> + <p> + <span>{currentVideo.bvid}</span> · <span>av{currentVideo.aid}</span> + </p> + <p> + <span>发布于 {formatDateTime(new Date(currentVideo.published_at!))}</span> + </p> + <p> + <span>播放:{(currentVideo.views ?? 0).toLocaleString()}</span> + </p> + <p> + UP主: + <a + className="underline" + href={`https://space.bilibili.com/${currentVideo.uid}`} + target="_blank" + rel="noopener noreferrer" + > + {currentVideo.username} + </a> + </p> + </div> + </div> + </div> + </div> + + <div className="mt-6"> + <h3 className="font-medium text-lg mb-2">简介</h3> + <pre className="max-w-full wrap-anywhere break-all text-on-surface-variant text-sm md:text-base whitespace-pre-wrap dark:text-dark-on-surface-variant font-zh"> + {currentVideo.description || "暂无简介"} + </pre> + </div> + </div> + + <div className="fixed bottom-0 left-0 right-0 bg-background border-t p-4 shadow-lg"> + <div className="max-w-4xl mx-auto flex items-center justify-between gap-4"> + <Button + variant="outline" + onClick={() => navigateTo(currentIndex - 1)} + disabled={currentIndex === 0} + className="flex items-center gap-2" + > + <ChevronLeft className="h-4 w-4" /> + 上一个 + </Button> + + <div className="flex gap-4"> + <Button + variant="destructive" + onClick={() => handleLabel(false)} + className="flex items-center gap-2" + > + <X className="h-4 w-4" />否 + </Button> + <Button + variant="default" + onClick={() => handleLabel(true)} + className="flex items-center gap-2 bg-green-600 hover:bg-green-700" + > + <Check className="h-4 w-4" />是 + </Button> + </div> + + <Button + variant="outline" + onClick={() => navigateTo(currentIndex + 1)} + disabled={currentIndex === videos.length - 1 && !hasMore} + className="flex items-center gap-2" + > + 下一个 + <ChevronRight className="h-4 w-4" /> + </Button> + </div> + </div> + </> + ) : ( + <div className="text-center py-12"> + <p className="text-lg">没有更多视频需要打标</p> + <Button onClick={fetchVideos} className="mt-4" disabled={loading}> + {loading ? "加载中..." : "重新加载"} + </Button> + </div> + )} + </Layout> + ); +} diff --git a/packages/temp_frontend/app/routes/video/[id]/info/index.tsx b/packages/temp_frontend/app/routes/video/[id]/info/index.tsx index f0dc123..6838d90 100644 --- a/packages/temp_frontend/app/routes/video/[id]/info/index.tsx +++ b/packages/temp_frontend/app/routes/video/[id]/info/index.tsx @@ -3,14 +3,10 @@ import { treaty } from "@elysiajs/eden"; import type { App } from "@backend/src"; import { useEffect, useState } from "react"; import { Skeleton } from "@/components/ui/skeleton"; -import { TriangleAlert } from "lucide-react"; import { Title } from "@/components/Title"; -import { toast } from "sonner"; import { Error } from "@/components/Error"; import { Layout } from "@/components/Layout"; import { formatDateTime } from "@/components/SearchResults"; -import { Button } from "@/components/ui/button"; -import { av2bv } from "@backend/lib/bilibiliID"; // @ts-ignore idk const app = treaty<App>(import.meta.env.VITE_API_URL!); @@ -109,6 +105,7 @@ export default function VideoInfo({ loaderData }: Route.ComponentProps) { </> )} </p> + <p>UP主:<a className="underline" href={`https://space.bilibili.com/${videoInfo!.owner.mid}`}>{videoInfo!.owner.name}</a></p> </div> </div>