Initial commit: BiliDownloader Web 版:Python 零依赖后端 + shadcn/ui 前端
This commit is contained in:
@@ -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>
|
||||
Generated
+3620
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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%)`;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user