1
0

add: video labelling page

This commit is contained in:
alikia2x (寒寒) 2025-12-01 01:10:13 +08:00
parent 92f3d807ab
commit 423e602da5
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG Key ID: 56209E0CCD8420C6
20 changed files with 588 additions and 127 deletions

View File

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

View File

@ -52,6 +52,8 @@ export const BiliVideoSchema = z.object({
coverUrl: z.string().nullable()
});
export type BiliVideoType = z.infer<typeof BiliVideoSchema>;
export const SongSchema = z.object({
duration: z.number().nullable(),
name: z.string().nullable(),

View File

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

View File

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

View File

@ -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<z.infer<typeof videoSchema>>(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()
})
}
);

View File

@ -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");

View File

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

View File

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

View File

@ -7,5 +7,5 @@ export default defineConfig({
dbCredentials: {
url: process.env.DATABASE_URL_MAIN!
},
schemaFilter: ["public", "credentials"]
schemaFilter: ["public", "credentials", "internal"]
});

View File

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

View File

@ -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"`),
]);

View File

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

View File

@ -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 (
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl 2xl:w-3xl mb-20">
<main className="w-full max-sm:mx-3 sm:px-10 pt-14 xl:pt-8 md:w-3xl xl:w-4xl mb-20">
<div className="flex items-center justify-between">
<a href="/">
<h1 className="text-3xl mb-5">V档案馆</h1>
@ -13,13 +13,22 @@ export function Layout({ children }: { children?: React.ReactNode }) {
<LoginOrLogout />
</div>
</div>
<Search />
{children}
</main>
</div>
);
}
export function Layout({ children }: { children?: React.ReactNode }) {
return (
<LayoutWithoutSearch>
<Search />
{children}
</LayoutWithoutSearch>
);
}
const LoginOrLogout = () => {
const session = localStorage.getItem("sessionID");
if (session) {
@ -37,22 +46,3 @@ const LoginOrLogout = () => {
return <a href="/login"></a>;
}
};
export function LayoutWithoutSearch({ children }: { children?: React.ReactNode }) {
return (
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl 2xl:w-3xl mb-20">
<div className="flex items-center justify-between">
<a href="/">
<h1 className="text-3xl mb-5">V档案馆</h1>
</a>
<div className="h-8">
<LoginOrLogout />
</div>
</div>
{children}
</main>
</div>
);
}

View File

@ -33,7 +33,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
{children}
<ScrollRestoration />
<Scripts />
<Toaster />
<Toaster position="top-center" />
</body>
</html>
);

View File

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

View File

@ -102,7 +102,7 @@ export const MilestoneVideos: React.FC = () => {
if (isLoading && milestoneData.length === 0) {
return (
<ScrollArea className="h-140 w-full">
<ScrollArea className="h-140 xl:h-180 w-full">
<div className="h-[0.1px]"></div>
{[1, 2, 3].map((i) => (
<div
@ -137,7 +137,7 @@ export const MilestoneVideos: React.FC = () => {
return (
<div className="space-y-4">
<ScrollArea className="h-140 w-full" ref={scrollContainer} onScroll={handleScroll}>
<ScrollArea className="h-140 xl:h-180 w-full" ref={scrollContainer} onScroll={handleScroll}>
{milestoneData.map((video) => (
<MilestoneVideoCard
key={video.bilibili_metadata.aid}

View File

@ -3,79 +3,117 @@ import { Progress } from "@/components/ui/progress";
import { formatDateTime } from "@/components/SearchResults";
import { addHoursToNow, formatHours } from "../song/[id]/info";
import { milestoneConfig, type CloseMilestoneInfo, type MilestoneType } from "./Milestone";
import { DAY, HOUR, MINUTE, SECOND } from "@core/lib";
function timeAgo(timeStamp: Date | number, now: Date | number = Date.now()): string {
const pastTime = typeof timeStamp === "number" ? timeStamp : timeStamp.getTime();
const currentTime = typeof now === "number" ? now : now.getTime();
const diffMs = Math.abs(currentTime - pastTime);
if (diffMs < MINUTE) {
const seconds = Math.floor(diffMs / SECOND);
return `${seconds}`;
}
if (diffMs < HOUR) {
const minutes = Math.floor(diffMs / MINUTE);
if (diffMs % MINUTE === 0) {
return `${minutes} 分钟`;
}
return `${minutes} 分钟`;
}
if (diffMs < DAY) {
const hours = Math.floor(diffMs / HOUR);
const minutes = Math.floor((diffMs % HOUR) / MINUTE);
return `${hours}${minutes}`;
}
if (diffMs >= 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<CloseMilestoneInfo>[number];
milestoneType: MilestoneType;
video: NonNullable<CloseMilestoneInfo>[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 (
<Card className="px-3 max-md:py-3 md:px-4 my-4 gap-0">
<div className="w-full flex items-start space-x-4 mb-4">
{video.bilibili_metadata.coverUrl && (
<img
src={video.bilibili_metadata.coverUrl}
alt="视频封面"
className="h-25 w-40 rounded-sm object-cover shrink-0"
referrerPolicy="no-referrer"
loading="lazy"
/>
)}
<div className="flex flex-col w-full justify-between">
<h3 className="text-sm sm:text-lg font-medium line-clamp-2 text-wrap mb-2">
<a href={`/song/av${video.bilibili_metadata.aid}/info`} className="hover:underline">
{video.bilibili_metadata.title}
</a>
</h3>
return (
<Card className="px-3 max-md:py-3 md:px-4 my-4 gap-0">
<div className="w-full flex items-start space-x-4 mb-4">
{video.bilibili_metadata.coverUrl && (
<img
src={video.bilibili_metadata.coverUrl}
alt="视频封面"
className="h-25 w-40 rounded-sm object-cover shrink-0"
referrerPolicy="no-referrer"
loading="lazy"
/>
)}
<div className="flex flex-col w-full justify-between">
<h3 className="text-sm sm:text-lg font-medium line-clamp-2 text-wrap mb-2">
<a href={`/song/av${video.bilibili_metadata.aid}/info`} className="hover:underline">
{video.bilibili_metadata.title}
</a>
</h3>
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>: {video.eta.currentViews.toLocaleString()}</span>
<span>: {config.target.toLocaleString()}</span>
</div>
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>: {video.eta.currentViews.toLocaleString()}</span>
<span>: {config.target.toLocaleString()}</span>
</div>
<Progress value={progressPercentage} />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-5 text-xs text-muted-foreground mb-2">
<div>
<p>: {remainingViews.toLocaleString()}</p>
<p>: {formatHours(video.eta.eta)}</p>
</div>
<div>
<p>: {Math.round(video.eta.speed)}/</p>
<p>: {addHoursToNow(video.eta.eta)}</p>
</div>
</div>
<Progress value={progressPercentage} />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-5 text-xs text-muted-foreground mb-2">
<div>
<p>: {remainingViews.toLocaleString()}</p>
<p>: {formatHours(video.eta.eta)}</p>
</div>
<div>
<p>: {Math.round(video.eta.speed)}/</p>
<p>: {addHoursToNow(video.eta.eta)}</p>
</div>
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
{video.bilibili_metadata.publishedAt && (
<span className="stat-num">
{formatDateTime(new Date(video.bilibili_metadata.publishedAt))}
</span>
)}
<a
href={`https://www.bilibili.com/video/av${video.bilibili_metadata.aid}`}
target="_blank"
rel="noopener noreferrer"
className="text-pink-400 text-xs hover:underline"
>
</a>
<a
href={`/song/av${video.bilibili_metadata.aid}/info`}
className="text-xs text-secondary-foreground hover:underline"
>
</a>
</div>
</Card>
);
<div className="flex gap-4 text-xs text-muted-foreground">
{video.bilibili_metadata.publishedAt && (
<span className="stat-num">
{formatDateTime(new Date(video.bilibili_metadata.publishedAt))}
</span>
)}
<span>
{timeAgo(new Date(video.eta.updatedAt))}
</span>
<a
href={`https://www.bilibili.com/video/av${video.bilibili_metadata.aid}`}
target="_blank"
rel="noopener noreferrer"
className="text-pink-400 text-xs hover:underline"
>
</a>
<a
href={`/song/av${video.bilibili_metadata.aid}/info`}
className="text-xs text-secondary-foreground hover:underline"
>
</a>
</div>
</Card>
);
};

View File

@ -14,16 +14,24 @@ export default function Home() {
return (
<Layout>
<h2 className="text-2xl font-medium mt-8 mb-4"></h2>
<div className="flex max-sm:flex-col sm:items-center gap-7 mb-8">
<a href="/time-calculator">
<Button></Button>
</a>
<div className="flex max-sm:flex-col sm:items-center justify-between gap-7 mb-8">
<div className="flex gap-3">
<a href="/time-calculator">
<Button></Button>
</a>
<a href="/labelling">
<Button></Button>
</a>
</div>
<div className="flex sm:w-96 gap-3">
<Input placeholder="输入 BV 号或 av 号" value={input} onChange={(e) => setInput(e.target.value)} />
<a href={`/song/${input}/add`}>
<Button></Button>
</a>
<a href={`/video/${input}/info`}>
<Button></Button>
</a>
</div>
</div>

View File

@ -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<App>(import.meta.env.VITE_API_URL!);
type VideosResponse = Awaited<ReturnType<Awaited<typeof app.videos.unlabelled>["get"]>>["data"];
export default function Home() {
const [videos, setVideos] = useState<Exclude<VideosResponse, null>>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<any>(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<boolean> => {
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 (
<Layout>
<Title title="视频打标工具" />
<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>
);
}

View File

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