Initial commit: BiliDownloader Web 版:Python 零依赖后端 + shadcn/ui 前端

This commit is contained in:
WpyQwq
2026-09-19 11:49:36 +08:00
commit f42cf11831
41 changed files with 7576 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BiliDownloader</title>
<meta name="color-scheme" content="light dark" />
<script>
// 主题记忆(默认深色):尽早设置,避免首屏闪白
try {
const saved = localStorage.getItem('bd.theme');
const dark = saved ? saved === 'dark' : true;
document.documentElement.classList.toggle('dark', dark);
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3620
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "wpywmail-ui",
"private": true,
"version": "0.2.0",
"type": "module",
"description": "WpywMail 客户端界面(shadcn/ui + Tailwind + Vite)",
"scripts": {
"dev": "vite",
"build": "tsc -b --noCheck && vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.548.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.16",
"@types/node": "^24.9.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.4",
"tailwindcss": "^4.1.16",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.1.12",
"ws": "^8.21.3"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0a0a0a"/>
<rect x="6" y="9" width="20" height="14" rx="3" fill="none" stroke="#fafafa" stroke-width="2"/>
<path d="M7 11.5l9 6.5 9-6.5" fill="none" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

+342
View File
@@ -0,0 +1,342 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CloudDownload, Loader2, Moon, Search, Settings2, Sun, UserRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { SearchResults } from '@/components/SearchResults';
import { DownloadPanel } from '@/components/DownloadPanel';
import { SettingsDialog } from '@/components/SettingsDialog';
import { formatBytes } from '@/lib/format';
import {
api,
extractBvid,
subscribe,
type AppConfig,
type Job,
type LoginStatus,
type PlayUrl,
type SearchItem,
type VideoDetail,
} from '@/lib/api';
const stamp = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
export default function App() {
const [config, setConfig] = useState<AppConfig | null>(null);
const [login, setLogin] = useState<LoginStatus | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [dark, setDark] = useState(() => localStorage.getItem('bd.theme') !== 'light');
const [query, setQuery] = useState('');
const [items, setItems] = useState<SearchItem[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [searching, setSearching] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [searched, setSearched] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [video, setVideo] = useState<VideoDetail | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const [play, setPlay] = useState<PlayUrl | null>(null);
const [loadingPlay, setLoadingPlay] = useState(false);
const [quality, setQuality] = useState<number | null>(null);
const [job, setJob] = useState<Job | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [speedHistory, setSpeedHistory] = useState<number[]>([]);
const lastMilestone = useRef(0);
const jobRef = useRef<Job | null>(null);
jobRef.current = job;
const log = useCallback((msg: string) => {
setLogs((prev) => [...prev.slice(-299), `[${stamp()}] ${msg}`]);
}, []);
// ---------------- 主题 ----------------
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem('bd.theme', dark ? 'dark' : 'light');
}, [dark]);
// ---------------- 启动:配置 + 登录状态 + 当前任务 ----------------
useEffect(() => {
api.config().then(setConfig).catch((e) => log(`读取配置失败:${e.message}`));
api
.login()
.then((s) => {
setLogin(s);
log(s.isLogin ? `已登录:${s.uname}` : '未登录(B 站只放出 480P 及以下)');
})
.catch(() => setLogin({ isLogin: false, uname: '' }));
api
.job()
.then((j) => {
if (j?.state) setJob(j);
})
.catch(() => {});
}, [log]);
// ---------------- 服务端事件 ----------------
useEffect(() => {
return subscribe({
onJob: (j) => {
setJob(j);
if (j.state === 'done') {
log(`✔ 完成:${j.outputPath}(${formatBytes(j.size)},用时 ${Math.round(j.elapsed ?? 0)}s)`);
setSpeedHistory([]);
} else if (j.state === 'error') {
log(`✖ 失败:${j.error}`);
} else if (j.state === 'cancelled') {
log('已取消,未完成的文件已清理');
setSpeedHistory([]);
}
},
onStage: (s) => log(`▶ ${s.stage}`),
onProgress: (p) => {
const prog = jobRef.current?.progress;
const otherSpeed = p.which === 'video' ? (prog?.audio?.speed ?? 0) : (prog?.video?.speed ?? 0);
setSpeedHistory((prev) => [...prev.slice(-59), p.speed + otherSpeed]);
const mark = Math.floor(p.percent / 10) * 10;
if (mark > lastMilestone.current && mark < 100) {
lastMilestone.current = mark;
log(`${p.label} ${mark}%(${formatBytes(p.downloaded)}/${formatBytes(p.total)})`);
}
},
});
}, [log]);
// ---------------- 清晰度 ----------------
const loadPlay = useCallback(
async (bvid: string, cid: number) => {
setLoadingPlay(true);
setPlay(null);
setQuality(null);
try {
const p = await api.playUrl(bvid, cid);
setPlay(p);
if (p.qualities.length) {
setQuality(p.qualities[0].quality);
log(
`可用清晰度 ${p.qualities.length} 档:` +
p.qualities.map((q) => q.label).join(' / ') +
(p.maxQuality < 80 ? '(未登录,被限制在 480P 及以下)' : ''),
);
} else {
log('该视频没有返回可用流');
}
} catch (e) {
log(`解析清晰度失败:${(e as Error).message}`);
} finally {
setLoadingPlay(false);
}
},
[log],
);
// ---------------- 搜索 / 解析 ----------------
const runSearch = useCallback(
async (raw: string, nextPage = 1) => {
const text = raw.trim();
if (!text) return;
const bvid = extractBvid(text);
if (bvid) {
setSearchError(null);
setSearching(true);
try {
const detail = await api.video(bvid);
setVideo(detail);
setPageIndex(0);
log(`解析视频:${detail.title}`);
await loadPlay(bvid, detail.pages[0].cid);
} catch (e) {
setSearchError(`解析失败:${(e as Error).message}`);
} finally {
setSearching(false);
}
return;
}
setSearching(true);
setSearchError(null);
try {
const res = await api.search(text, nextPage);
setItems((prev) => (nextPage === 1 ? res.items : [...prev, ...res.items]));
setPage(res.page);
setHasMore(res.hasMore);
setSearched(true);
if (nextPage === 1) log(`搜索「${text}」:${res.items.length} 条结果`);
} catch (e) {
setSearchError((e as Error).message);
} finally {
setSearching(false);
}
},
[log, loadPlay],
);
const pickItem = useCallback(
async (item: SearchItem) => {
try {
const detail = await api.video(item.bvid);
setVideo(detail);
setPageIndex(0);
log(`选中:${detail.title}`);
await loadPlay(item.bvid, detail.pages[0].cid);
} catch (e) {
log(`加载视频失败:${(e as Error).message}`);
}
},
[log, loadPlay],
);
const changePage = useCallback(
async (i: number) => {
if (!video) return;
setPageIndex(i);
await loadPlay(video.bvid, video.pages[i].cid);
},
[video, loadPlay],
);
// ---------------- 下载 ----------------
const startDownload = useCallback(async () => {
if (!video || quality == null) return;
lastMilestone.current = 0;
setSpeedHistory([]);
try {
const j = await api.download({
bvid: video.bvid,
cid: video.pages[pageIndex].cid,
quality,
title: video.title,
});
setJob(j);
log(`开始下载:${video.title}`);
} catch (e) {
const msg = (e as Error).message;
log(`无法开始下载:${msg}`);
setJob({ state: 'error', error: msg });
}
}, [video, quality, pageIndex, log]);
const cancel = useCallback(async () => {
await api.cancel().catch(() => {});
}, []);
const openFolder = useCallback(
(path?: string) => {
api.openFolder(path ?? config?.outputDir).catch(() => {});
},
[config],
);
return (
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
{/* ---------------- 顶栏 ---------------- */}
<header className="flex h-14 shrink-0 items-center gap-4 border-b px-5">
<div className="flex shrink-0 items-center gap-2.5">
<div className="bg-primary text-primary-foreground flex size-7 items-center justify-center rounded-md">
<CloudDownload className="size-4" />
</div>
<span className="text-sm font-semibold tracking-tight">BiliDownloader</span>
</div>
<form
className="relative ml-2 max-w-[620px] flex-1"
onSubmit={(e) => {
e.preventDefault();
void runSearch(query, 1);
}}
>
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索站内视频,或粘贴视频链接 / BV 号"
className="pl-8"
/>
{searching && (
<Loader2 className="text-muted-foreground absolute top-1/2 right-2.5 size-4 -translate-y-1/2 animate-spin" />
)}
</form>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<Badge variant={login?.isLogin ? 'secondary' : 'outline'} className="gap-1.5 font-normal">
<UserRound className="size-3" />
{login?.isLogin ? login.uname : '未登录'}
</Badge>
<Button
variant="ghost"
size="icon"
title={dark ? '切换到浅色' : '切换到深色'}
onClick={() => setDark((d) => !d)}
>
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
<Button variant="ghost" size="icon" title="设置" onClick={() => setSettingsOpen(true)}>
<Settings2 className="size-4" />
</Button>
</div>
</header>
{/* ---------------- 主体 ---------------- */}
<main className="grid min-h-0 flex-1 grid-cols-[minmax(400px,0.95fr)_minmax(520px,1.05fr)]">
<section className="min-h-0 border-r p-4">
<SearchResults
items={items}
loading={searching && items.length === 0}
loadingMore={loadingMore}
hasMore={hasMore}
searched={searched}
error={searchError}
selectedBvid={video?.bvid ?? null}
onPick={pickItem}
onLoadMore={async () => {
setLoadingMore(true);
await runSearch(query, page + 1);
setLoadingMore(false);
}}
/>
</section>
<section className="min-h-0 p-4">
<DownloadPanel
video={video}
play={play}
loadingPlay={loadingPlay}
quality={quality}
onQualityChange={setQuality}
pageIndex={pageIndex}
onPageChange={changePage}
job={job}
logs={logs}
speedHistory={speedHistory}
loggedIn={!!login?.isLogin}
outputDir={config?.outputDir ?? ''}
onStart={startDownload}
onCancel={cancel}
onOpenFolder={openFolder}
onOpenSettings={() => setSettingsOpen(true)}
/>
</section>
</main>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
config={config}
onSaved={(c) => {
setConfig(c);
log('配置已保存');
}}
onLoginChanged={(s) => {
setLogin(s);
log(s.isLogin ? `已登录:${s.uname}` : '未登录 / SESSDATA 无效');
}}
/>
</div>
);
}
+359
View File
@@ -0,0 +1,359 @@
import { useEffect, useRef, useState } from 'react';
import {
AlertCircle,
CheckCircle2,
FolderOpen,
Gauge,
Loader2,
LockKeyhole,
Play,
Square,
Timer,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { formatBytes, formatCount, formatDuration, formatEta, formatSpeed, shortPath } from '@/lib/format';
import type { Job, PlayUrl, Quality, StreamProgress, VideoDetail } from '@/lib/api';
type Props = {
video: VideoDetail | null;
play: PlayUrl | null;
loadingPlay: boolean;
quality: number | null;
onQualityChange: (q: number) => void;
pageIndex: number;
onPageChange: (i: number) => void;
job: Job | null;
logs: string[];
speedHistory: number[];
loggedIn: boolean;
outputDir: string;
onStart: () => void;
onCancel: () => void;
onOpenFolder: (path?: string) => void;
onOpenSettings: () => void;
};
function Sparkline({ data }: { data: number[] }) {
const W = 240;
const H = 34;
if (data.length < 2) {
return <div className="text-muted-foreground/60 h-[34px] text-[11px] leading-[34px]">速度曲线(下载中显示)</div>;
}
const max = Math.max(...data, 1);
const pts = data.map((v, i) => {
const x = (i / (data.length - 1)) * W;
const y = H - (v / max) * (H - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const area = `0,${H} ${pts.join(' ')} ${W},${H}`;
return (
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="h-[34px] w-full" aria-hidden>
<polygon points={area} className="fill-primary/15" />
<polyline
points={pts.join(' ')}
fill="none"
className="stroke-primary"
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
function StreamBar({ label, info }: { label: string; info: StreamProgress | null | undefined }) {
const percent = info?.percent ?? 0;
return (
<div className="grid grid-cols-[52px_1fr_auto] items-center gap-3">
<span className="text-muted-foreground text-xs">{label}</span>
<Progress value={percent} className="h-1.5" />
<span className="text-muted-foreground w-[190px] text-right font-mono text-[11px] tabular-nums">
{info
? `${percent.toFixed(1)}% ${formatBytes(info.downloaded)}/${formatBytes(info.total)} ${formatSpeed(info.speed)}`
: '—'}
</span>
</div>
);
}
export function DownloadPanel({
video,
play,
loadingPlay,
quality,
onQualityChange,
pageIndex,
onPageChange,
job,
logs,
speedHistory,
loggedIn,
outputDir,
onStart,
onCancel,
onOpenFolder,
onOpenSettings,
}: Props) {
const logRef = useRef<HTMLDivElement>(null);
const running = job?.state === 'running';
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logs]);
const videoP = job?.progress?.video;
const audioP = job?.progress?.audio;
const totalDown = (videoP?.downloaded ?? 0) + (audioP?.downloaded ?? 0);
const totalAll = (videoP?.total ?? 0) + (audioP?.total ?? 0);
const totalPercent = totalAll > 0 ? (totalDown * 100) / totalAll : 0;
const totalSpeed = (videoP?.speed ?? 0) + (audioP?.speed ?? 0);
const eta = formatEta({ downloaded: totalDown, total: totalAll, speed: totalSpeed });
const limitedByLogin = !!play && play.maxQuality > 0 && play.maxQuality < 80;
const currentQ: Quality | undefined = play?.qualities.find((q) => q.quality === quality);
return (
<div className="flex h-full flex-col gap-4">
{/* ---------------- 已选视频 ---------------- */}
<div className="shrink-0">
{!video ? (
<div className="text-muted-foreground flex h-[104px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-sm">
<span>还没有选择视频</span>
<span className="text-xs opacity-70">在左侧点选一个结果,播放器地址会自动解析</span>
</div>
) : (
<div className="flex gap-3">
<div className="bg-muted aspect-video w-[112px] shrink-0 overflow-hidden rounded-md border">
{video.pic && (
<img
src={video.pic}
alt={video.title}
referrerPolicy="no-referrer"
className="size-full object-cover"
/>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="line-clamp-2 text-sm leading-snug font-medium">{video.title}</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
<span>{video.owner}</span>
<span className="opacity-40">·</span>
<span className="tabular-nums">{formatDuration(video.duration)}</span>
{video.stat?.view != null && (
<>
<span className="opacity-40">·</span>
<span className="tabular-nums">{formatCount(video.stat.view)}播放</span>
</>
)}
<span className="opacity-40">·</span>
<span className="font-mono">{video.bvid}</span>
</div>
{video.pages.length > 1 && (
<div className="mt-1 flex items-center gap-2">
<span className="text-muted-foreground text-xs">分P</span>
<Select value={String(pageIndex)} onValueChange={(v) => onPageChange(Number(v))}>
<SelectTrigger size="sm" className="w-[260px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{video.pages.map((p, i) => (
<SelectItem key={p.cid} value={String(i)}>
P{p.page} · {p.part}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
)}
</div>
<Separator />
{/* ---------------- 参数 + 动作 ---------------- */}
<div className="shrink-0 space-y-3">
<div className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2">
<span className="text-muted-foreground text-xs">清晰度</span>
<div className="flex items-center gap-2">
{loadingPlay ? (
<div className="text-muted-foreground flex h-9 items-center gap-2 text-xs">
<Loader2 className="size-3.5 animate-spin" /> 正在解析可用清晰度…
</div>
) : (
<Select
value={quality != null ? String(quality) : undefined}
onValueChange={(v) => onQualityChange(Number(v))}
disabled={!play || play.qualities.length === 0 || running}
>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="选择清晰度" />
</SelectTrigger>
<SelectContent>
{play?.qualities.map((q) => (
<SelectItem key={q.quality} value={String(q.quality)}>
<span className="flex w-full items-center gap-2">
<span>{q.label}</span>
{q.width && q.height && (
<span className="text-muted-foreground font-mono text-[11px] tabular-nums">
{q.width}×{q.height}
</span>
)}
{q.codecs && (
<span className="text-muted-foreground font-mono text-[11px]">
{q.codecs.startsWith('avc') ? 'H.264' : q.codecs.split('.')[0].toUpperCase()}
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
{currentQ?.bandwidth ? (
<span className="text-muted-foreground font-mono text-[11px] tabular-nums">
{Math.round(currentQ.bandwidth / 1000)} kbps
</span>
) : null}
</div>
<span className="text-muted-foreground text-xs">保存到</span>
<div className="flex min-w-0 items-center gap-2">
<span className="text-muted-foreground truncate font-mono text-[11px]" title={outputDir}>
{shortPath(outputDir)}
</span>
<Button variant="ghost" size="sm" className="shrink-0" onClick={onOpenSettings}>
更改
</Button>
</div>
</div>
{limitedByLogin && (
<div className="border-chart-4/40 bg-chart-4/5 text-foreground/90 flex items-start gap-2 rounded-lg border px-3 py-2 text-xs">
<LockKeyhole className="text-chart-4 mt-0.5 size-3.5 shrink-0" />
<div className="leading-relaxed">
当前未登录(或 SESSDATA 已失效),B 站只放出 <b>{play?.qualities[0]?.label ?? '480P'}</b> 及以下。
该视频标称支持到 <b>{play?.advertisedQuality === 120 ? '4K' : `${play?.advertisedQuality}P`}</b>。
想下高清请在
<button className="text-primary mx-1 underline underline-offset-2" onClick={onOpenSettings}>
设置
</button>
里填一个有效的 SESSDATA。
</div>
</div>
)}
<div className="flex items-center gap-2">
{running ? (
<Button variant="destructive" onClick={onCancel} className="min-w-[132px]">
<Square className="size-3.5" />
取消下载
</Button>
) : (
<Button
onClick={onStart}
disabled={!video || !play || quality == null || play.qualities.length === 0}
className="min-w-[132px]"
>
<Play className="size-3.5" />
开始下载
</Button>
)}
{job?.state === 'done' && (
<Button variant="outline" onClick={() => onOpenFolder(job.outputPath)}>
<FolderOpen className="size-3.5" />
打开文件夹
</Button>
)}
<div className="text-muted-foreground ml-auto flex items-center gap-3 font-mono text-[11px] tabular-nums">
<span className="flex items-center gap-1">
<Gauge className="size-3" />
{job?.threads ?? 16} 线程
</span>
{(videoP?.threads ?? 0) > 0 && (
<span className="flex items-center gap-1">
<Timer className="size-3" />
ETA {eta}
</span>
)}
</div>
</div>
</div>
<Separator />
{/* ---------------- 进度 ---------------- */}
<div className="shrink-0 space-y-3">
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<span className="text-xs font-medium">{job?.stage ?? '等待开始'}</span>
<span className="font-mono text-sm tabular-nums">{totalPercent.toFixed(1)}%</span>
</div>
<Progress value={totalPercent} className="h-2.5" />
<div className="text-muted-foreground flex justify-between font-mono text-[11px] tabular-nums">
<span>
{formatBytes(totalDown)} / {formatBytes(totalAll)}
</span>
<span>{formatSpeed(totalSpeed)}</span>
</div>
</div>
<div className="space-y-2">
<StreamBar label="视频流" info={videoP} />
<StreamBar label="音频流" info={audioP} />
</div>
<Sparkline data={speedHistory} />
</div>
{/* ---------------- 结果提示 ---------------- */}
{job?.state === 'done' && (
<div className="border-primary/30 bg-primary/5 flex items-start gap-2 rounded-lg border px-3 py-2 text-xs">
<CheckCircle2 className="text-primary mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0">
<div className="font-medium">
下载完成 · {formatBytes(job.size)} · 用时 {formatDuration(job.elapsed)}
</div>
<div className="text-muted-foreground truncate font-mono text-[11px]" title={job.outputPath}>
{job.outputPath}
</div>
</div>
</div>
)}
{job?.state === 'error' && (
<div className="border-destructive/40 bg-destructive/5 flex items-start gap-2 rounded-lg border px-3 py-2 text-xs">
<AlertCircle className="text-destructive mt-0.5 size-3.5 shrink-0" />
<div className="leading-relaxed break-all">{job.error}</div>
</div>
)}
{job?.state === 'cancelled' && (
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border px-3 py-2 text-xs">
<X className="size-3.5" />
已取消,未完成的文件已清理
</div>
)}
{/* ---------------- 日志 ---------------- */}
<div className="flex min-h-0 flex-1 flex-col gap-1.5">
<span className="text-muted-foreground text-xs">日志</span>
<div
ref={logRef}
className={cn(
'bg-muted/40 min-h-[72px] flex-1 overflow-auto rounded-lg border p-2.5',
'font-mono text-[11px] leading-relaxed whitespace-pre-wrap',
)}
>
{logs.length ? logs.join('\n') : <span className="text-muted-foreground/60">(暂无)</span>}
</div>
</div>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react';
import { Download, Loader2, Play, SearchX } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { formatCount } from '@/lib/format';
import type { SearchItem } from '@/lib/api';
type Props = {
items: SearchItem[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
searched: boolean;
error: string | null;
selectedBvid: string | null;
onPick: (item: SearchItem) => void;
onLoadMore: () => void;
};
function Cover({ src, alt }: { src: string; alt: string }) {
const [failed, setFailed] = useState(false);
return (
<div className="bg-muted relative aspect-video w-[124px] shrink-0 overflow-hidden rounded-md border">
{src && !failed ? (
<img
src={src}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
className="size-full object-cover"
/>
) : (
<div className="text-muted-foreground flex size-full items-center justify-center">
<Play className="size-5 opacity-40" />
</div>
)}
</div>
);
}
export function SearchResults({
items,
loading,
loadingMore,
hasMore,
searched,
error,
selectedBvid,
onPick,
onLoadMore,
}: Props) {
if (loading) {
return (
<div className="space-y-3 p-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="aspect-video w-[124px] rounded-md" />
<div className="flex-1 space-y-2 py-1">
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-3 w-[45%]" />
<Skeleton className="h-3 w-[65%]" />
</div>
</div>
))}
</div>
);
}
if (error) {
return (
<div className="text-destructive flex flex-col items-center gap-2 py-16 text-center text-sm">
<SearchX className="size-6 opacity-70" />
<div className="max-w-[46ch] px-4 leading-relaxed">{error}</div>
</div>
);
}
if (!items.length) {
return (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 py-16 text-center">
<SearchX className="size-7 opacity-30" />
<div className="text-sm">{searched ? '没有找到相关视频' : '搜索站内视频,或直接粘贴视频链接 / BV 号'}</div>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="space-y-1 pr-2">
{items.map((item) => {
const active = item.bvid === selectedBvid;
return (
<button
key={item.bvid}
type="button"
onClick={() => onPick(item)}
className={cn(
'group hover:bg-accent/60 focus-visible:ring-ring/50 flex w-full items-start gap-3 rounded-lg border border-transparent p-2 text-left transition-colors outline-none focus-visible:ring-[3px]',
active && 'bg-accent border-border',
)}
>
<Cover src={item.pic} alt={item.title} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="line-clamp-2 text-[13px] leading-snug font-medium">{item.title}</div>
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<span className="max-w-[12ch] truncate">{item.author}</span>
<span className="opacity-40">·</span>
<span className="tabular-nums">{item.duration}</span>
<span className="opacity-40">·</span>
<span className="tabular-nums">{formatCount(item.play)}播放</span>
</div>
{item.description && (
<div className="text-muted-foreground/70 line-clamp-1 text-xs">{item.description}</div>
)}
</div>
<Button
size="sm"
variant={active ? 'default' : 'secondary'}
className={cn('mt-1 shrink-0 opacity-0 transition-opacity', 'group-hover:opacity-100', active && 'opacity-100')}
onClick={(e) => {
e.stopPropagation();
onPick(item);
}}
>
<Download className="size-3.5" />
下载
</Button>
</button>
);
})}
{hasMore && (
<div className="flex justify-center py-3">
<Button variant="ghost" size="sm" disabled={loadingMore} onClick={onLoadMore}>
{loadingMore && <Loader2 className="size-3.5 animate-spin" />}
加载更多
</Button>
</div>
)}
</div>
</ScrollArea>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useState } from 'react';
import { CheckCircle2, FolderOpen, Loader2, ShieldAlert, XCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { api, type AppConfig, type LoginStatus } from '@/lib/api';
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
config: AppConfig | null;
onSaved: (cfg: AppConfig) => void;
onLoginChanged: (s: LoginStatus) => void;
};
export function SettingsDialog({ open, onOpenChange, config, onSaved, onLoginChanged }: Props) {
const [draft, setDraft] = useState<Partial<AppConfig>>({});
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [login, setLogin] = useState<LoginStatus | null>(null);
const [note, setNote] = useState<string | null>(null);
useEffect(() => {
if (open && config) {
setDraft({
sessdata: config.sessdata,
outputDir: config.outputDir,
threads: config.threads,
preferAvc: config.preferAvc,
keepParts: config.keepParts,
});
setNote(null);
setLogin(null);
}
}, [open, config]);
const save = async () => {
setSaving(true);
setNote(null);
try {
const saved = await api.saveConfig(draft);
onSaved(saved);
setNote('已保存');
} catch (e) {
setNote(`保存失败:${(e as Error).message}`);
} finally {
setSaving(false);
}
};
const testLogin = async () => {
setTesting(true);
try {
await api.saveConfig(draft); // 先落盘,再测
const s = await api.login();
setLogin(s);
onLoginChanged(s);
} catch (e) {
setLogin({ isLogin: false, uname: '', code: -1 });
setNote(`测试失败:${(e as Error).message}`);
} finally {
setTesting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>设置</DialogTitle>
<DialogDescription>
保存后立刻写回 <span className="font-mono text-[11px]">{config?.configPath}</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-1">
<div className="space-y-2">
<Label htmlFor="sessdata">SESSDATA</Label>
<Input
id="sessdata"
value={draft.sessdata ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, sessdata: e.target.value }))}
placeholder="浏览器登录 B 站后从 Cookie 里取出 SESSDATA 的值"
className="font-mono text-xs"
/>
<div className="text-muted-foreground flex items-start gap-1.5 text-[11px] leading-relaxed">
<ShieldAlert className="mt-0.5 size-3 shrink-0" />
<span>
未登录时 B 站只放出 480P 及以下;填了有效 SESSDATA 才能下 1080P / 4K。
该值等同于账号登录凭据,保存在本机 <span className="font-mono">config.json</span> 里,别分享给他人。
</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={testLogin} disabled={testing}>
{testing ? <Loader2 className="size-3.5 animate-spin" /> : null}
测试登录状态
</Button>
{login &&
(login.isLogin ? (
<span className="flex items-center gap-1 text-xs text-[--chart-2]">
<CheckCircle2 className="size-3.5" />
已登录:{login.uname}
</span>
) : (
<span className="text-destructive flex items-center gap-1 text-xs">
<XCircle className="size-3.5" />
未登录 / SESSDATA 无效(只能下 480P)
</span>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="outdir">输出目录</Label>
<div className="flex gap-2">
<Input
id="outdir"
value={draft.outputDir ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, outputDir: e.target.value }))}
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
title="在资源管理器中打开"
onClick={() => api.openFolder(draft.outputDir)}
>
<FolderOpen className="size-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="threads">并发线程数</Label>
<Input
id="threads"
type="number"
min={1}
max={64}
value={draft.threads ?? 16}
onChange={(e) => setDraft((d) => ({ ...d, threads: Number(e.target.value) }))}
className="w-[120px] font-mono"
/>
<div className="text-muted-foreground text-[11px]">
建议 16;实测 16 线程约 4.1MB/s,32 线程约 6.8MB/s。过高可能触发 CDN 限流。
</div>
</div>
<Separator />
<div className="space-y-2.5">
{(
[
['preferAvc', '优先 H.264(AVC)编码', '兼容性最好;关掉则可能选到更高码率的 HEVC/AV1'],
['keepParts', '保留中间文件', '保留下载的 .video.m4s / .audio.m4s,便于排查问题'],
] as const
).map(([key, label, hint]) => (
<label key={key} className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={Boolean(draft[key])}
onChange={(e) => setDraft((d) => ({ ...d, [key]: e.target.checked }))}
className="accent-primary mt-0.5 size-4"
/>
<span className="space-y-0.5">
<span className="block text-sm">{label}</span>
<span className="text-muted-foreground block text-[11px]">{hint}</span>
</span>
</label>
))}
</div>
<div className="text-muted-foreground font-mono text-[11px]">
ffmpeg:{config?.ffmpeg || '未找到(合并会失败,请把 ffmpeg 加入 PATH)'}
</div>
</div>
<DialogFooter className="items-center">
{note && <span className="text-muted-foreground mr-auto text-xs">{note}</span>}
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="size-3.5 animate-spin" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+31
View File
@@ -0,0 +1,31 @@
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image data-slot="avatar-image" className={cn('aspect-square size-full', className)} {...props} />
);
}
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn('bg-muted flex size-full items-center justify-center rounded-full text-xs', className)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
+31
View File
@@ -0,0 +1,31 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-white',
outline: 'text-foreground',
},
},
defaultVariants: { variant: 'default' },
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+45
View File
@@ -0,0 +1,45 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
+34
View File
@@ -0,0 +1,34 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', className)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-header" className={cn('flex flex-col gap-1.5 px-6', className)} {...props} />;
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-description" className={cn('text-muted-foreground text-sm', className)} {...props} />;
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-footer" className={cn('flex items-center px-6', className)} {...props} />;
}
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
+103
View File
@@ -0,0 +1,103 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger(props: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal(props: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-[1px]',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:size-4"
>
<XIcon />
<span className="sr-only">关闭</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />;
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return <DialogPrimitive.Title data-slot="dialog-title" className={cn('text-lg leading-none font-semibold', className)} {...props} />;
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+187
View File
@@ -0,0 +1,187 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function DropdownMenu(props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuTrigger(props: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean; variant?: 'default' | 'destructive' }) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup(props: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
);
}
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
function DropdownMenuSub(props: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+20
View File
@@ -0,0 +1,20 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };
+20
View File
@@ -0,0 +1,20 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none',
'group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50',
'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
);
}
export { Label };
+26
View File
@@ -0,0 +1,26 @@
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
function Progress({
className,
value,
indicatorClassName,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root> & { indicatorClassName?: string }) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn('bg-primary h-full w-full flex-1 transition-transform duration-300 ease-out', indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
+45
View File
@@ -0,0 +1,45 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn('relative', className)} {...props}>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px]"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
+127
View File
@@ -0,0 +1,127 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Select(props: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectValue(props: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { size?: 'sm' | 'default' }) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground",
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
'dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent',
'px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none',
'focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
"data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin)',
'overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground",
'relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
"[&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue };
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);
}
export { Separator };
+7
View File
@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="skeleton" className={cn('bg-accent animate-pulse rounded-md', className)} {...props} />;
}
export { Skeleton };
+17
View File
@@ -0,0 +1,17 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
);
}
export { Textarea };
+41
View File
@@ -0,0 +1,41 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
function TooltipProvider({ delayDuration = 200, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
}
function Tooltip(props: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger(props: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 4,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+141
View File
@@ -0,0 +1,141 @@
/** 与本地 Python 服务通信的类型化客户端。 */
export type SearchItem = {
bvid: string;
title: string;
author: string;
duration: string;
play: number;
danmaku: number;
pic: string;
description: string;
};
export type SearchResult = {
items: SearchItem[];
page: number;
numResults: number;
hasMore: boolean;
};
export type VideoPage = { cid: number; page: number; part: string; duration: number };
export type VideoDetail = {
bvid: string;
title: string;
pic: string;
duration: number;
desc: string;
owner: string;
stat: { view?: number; danmaku?: number; like?: number; favorite?: number };
pages: VideoPage[];
};
export type Quality = {
quality: number;
label: string;
width: number | null;
height: number | null;
codecs: string | null;
bandwidth: number | null;
};
export type PlayUrl = {
qualities: Quality[];
maxQuality: number;
advertisedQuality: number;
duration: number;
};
export type StreamProgress = {
label: string;
downloaded: number;
total: number;
percent: number;
speed: number;
threads: number;
resumed: number;
};
export type Job = {
state?: 'running' | 'done' | 'cancelled' | 'error';
stage?: string;
title?: string;
quality?: number;
outputPath?: string;
size?: number;
elapsed?: number;
threads?: number;
resumed?: number;
error?: string | null;
progress?: { video: StreamProgress | null; audio: StreamProgress | null };
};
export type AppConfig = {
sessdata: string;
outputDir: string;
threads: number;
preferAvc: boolean;
keepParts: boolean;
configPath?: string;
ffmpeg?: string | null;
};
export type LoginStatus = { isLogin: boolean; uname: string; code?: number };
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
});
const text = await res.text();
let payload: unknown;
try {
payload = text ? JSON.parse(text) : {};
} catch {
throw new Error(`服务返回了非 JSON 内容(HTTP ${res.status})`);
}
const obj = payload as { ok?: boolean; error?: string };
if (!res.ok || obj?.error) throw new Error(obj?.error || `HTTP ${res.status}`);
return payload as T;
}
export const api = {
config: () => request<AppConfig>('/api/config'),
saveConfig: (cfg: Partial<AppConfig>) =>
request<AppConfig>('/api/config', { method: 'POST', body: JSON.stringify(cfg) }),
login: () => request<LoginStatus>('/api/login'),
search: (q: string, page = 1) =>
request<SearchResult>(`/api/search?q=${encodeURIComponent(q)}&page=${page}`),
video: (bvid: string) => request<VideoDetail>(`/api/video?bvid=${encodeURIComponent(bvid)}`),
playUrl: (bvid: string, cid: number) =>
request<PlayUrl>(`/api/playurl?bvid=${encodeURIComponent(bvid)}&cid=${cid}`),
job: () => request<Job>('/api/job'),
download: (payload: { bvid: string; cid: number; quality: number; title: string }) =>
request<Job>('/api/download', { method: 'POST', body: JSON.stringify(payload) }),
cancel: () => request<{ ok: boolean }>('/api/cancel', { method: 'POST', body: '{}' }),
openFolder: (path?: string) =>
request<{ ok: boolean }>('/api/open-folder', { method: 'POST', body: JSON.stringify({ path }) }),
};
/** 订阅服务端事件。返回取消订阅函数。 */
export function subscribe(handlers: {
onJob?: (job: Job) => void;
onProgress?: (p: StreamProgress & { which: 'video' | 'audio'; stage: string }) => void;
onStage?: (s: { stage: string }) => void;
}): () => void {
const es = new EventSource('/api/events');
es.addEventListener('job', (e) => handlers.onJob?.(JSON.parse((e as MessageEvent).data)));
es.addEventListener('progress', (e) => handlers.onProgress?.(JSON.parse((e as MessageEvent).data)));
es.addEventListener('stage', (e) => handlers.onStage?.(JSON.parse((e as MessageEvent).data)));
es.onerror = () => {
/* EventSource 会自动重连 */
};
return () => es.close();
}
/** 从任意输入里抠出 BV 号(粘贴链接或直接粘 BV 号都行)。 */
export function extractBvid(input: string): string | null {
const m = input.match(/BV[0-9A-Za-z]{10}/);
return m ? m[0] : null;
}
+52
View File
@@ -0,0 +1,52 @@
/** 统一格式化。数字一律用等宽 + tabular-nums 呈现,保证读数对齐。 */
export function formatBytes(n?: number | null): string {
if (!n || n <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
export function formatSpeed(bytesPerSec?: number | null): string {
if (!bytesPerSec || bytesPerSec <= 0) return '—';
return `${formatBytes(bytesPerSec)}/s`;
}
/** 秒 -> 1:23:45 / 12:34 */
export function formatDuration(seconds?: number | null): string {
if (!seconds || seconds <= 0) return '—';
const s = Math.floor(seconds % 60);
const m = Math.floor((seconds / 60) % 60);
const h = Math.floor(seconds / 3600);
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
/** 播放量:1.2万 / 3.4亿 */
export function formatCount(n?: number | null): string {
if (!n || n <= 0) return '0';
if (n >= 100000000) return `${(n / 100000000).toFixed(1)}亿`;
if (n >= 10000) return `${(n / 10000).toFixed(1)}万`;
return String(n);
}
export function formatEta(info?: { downloaded: number; total: number; speed: number } | null): string {
if (!info || !info.speed || info.speed <= 0) return '—';
const remain = Math.max(0, info.total - info.downloaded) / info.speed;
if (!Number.isFinite(remain) || remain < 0) return '—';
return formatDuration(remain);
}
export function shortPath(p?: string, max = 46): string {
if (!p) return '';
const normalized = p.replace(/\\/g, '/');
if (normalized.length <= max) return p;
const parts = normalized.split('/');
const tail = parts.slice(-2).join('/');
return `…/${tail.length > max ? tail.slice(-max) : tail}`;
}
+48
View File
@@ -0,0 +1,48 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** 相对时间:今天显示时分,今年显示月日,更早显示年月日 */
export function formatDate(raw?: string | null): string {
if (!raw) return '';
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return String(raw).slice(0, 16);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
if (d.toDateString() === now.toDateString()) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`;
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
}
export function formatFullDate(raw?: string | null): string {
if (!raw) return '';
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return String(raw);
return d.toLocaleString('zh-CN', {
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
export function formatSize(bytes?: number): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/** 取显示名首字,用于头像占位 */
export function initials(name: string, address: string): string {
const source = (name || address || '?').trim();
const first = source.replace(/["'<>]/g, '').trim()[0];
return (first || '?').toUpperCase();
}
/** 由地址生成稳定的头像底色(色相散开,饱和度/亮度固定,避免花哨) */
export function avatarHue(seed: string): string {
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360;
return `hsl(${h} 45% 42%)`;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { TooltipProvider } from '@/components/ui/tooltip';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TooltipProvider>
<App />
</TooltipProvider>
</StrictMode>,
);
+179
View File
@@ -0,0 +1,179 @@
@import "tailwindcss";
@import "tw-animate-css";
/* shadcn/ui 默认(new-york / neutral)主题 token,原样实例化。
light 与 dark 两套都保留;默认深色(这个项目的主人偏好深色),可切换并记忆。 */
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--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);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 16%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* 中文界面用系统字体栈(中文字库太大,不引入 webfont);
数字/速度/日志一律走等宽,保证读数纵向对齐。 */
--font-sans: "Segoe UI", "Microsoft YaHei UI", "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
--font-mono: ui-monospace, "Cascadia Mono", "Consolas", "Microsoft YaHei Mono", monospace;
}
@layer base {
* {
border-color: var(--border);
}
html,
body,
#root {
height: 100%;
/* 桌面客户端不该出现页面级滚动条:滚动只发生在三栏内部 */
overflow: hidden;
}
body {
margin: 0;
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
/* 界面全是读数:默认开启等宽数字,避免进度跳动时宽度抖动 */
font-variant-numeric: tabular-nums;
}
/* 日志与所有读数 */
.log-pane {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
}
/* 滚动条(桌面客户端观感)+ 三栏内部的滚动容器 */
@layer utilities {
.scroll-pane {
overflow-y: auto;
overflow-x: hidden;
/* 关键:flex 子项默认 min-height:auto 会按内容撑高,导致「列表没滚、整页滚」 */
min-height: 0;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.scrollbar-thin::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: color-mix(in oklab, var(--foreground) 18%, transparent);
border-radius: 9999px;
border: 3px solid transparent;
background-clip: content-box;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in oklab, var(--foreground) 30%, transparent);
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'node:path';
// 构建产物交给本地 Node 服务托管(server/index.js 会优先发 ui/dist),
// 所以 base 用相对路径,避免路径耦合。
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
build: {
outDir: 'dist',
emptyOutDir: true,
sourcemap: false,
chunkSizeWarningLimit: 1200,
},
server: {
port: 5174,
// 开发时把 API 代理到本地邮件服务,便于热更新调试
proxy: { '/api': 'http://127.0.0.1:8788' },
},
});