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
+119
View File
@@ -0,0 +1,119 @@
# BiliDownloader
哔哩哔哩视频下载器 —— **Python 零依赖本地服务 + shadcn/ui 界面 + Edge 应用模式窗口**
> 这是**弃用 WinUI3 后的重构版**。旧版(C# / WinUI3,位于 `E:\deepseek\BiliDownloader`)能编译能跑,
> 但需要 MSIX 打包且本机存在 WinUI3 启动限制;这一版改成浏览器界面,双击即用、无运行时依赖。
## 启动
双击 **`start.cmd`** —— 它会起本地服务并打开一个 Edge 应用模式窗口(无地址栏、无标签页,看起来就是独立应用)。
停止:双击 `stop.cmd`。
也可以手动:
```powershell
python server.py # 起服务并自动开窗口
python server.py --no-open # 只起服务,自己用浏览器打开 http://127.0.0.1:8799/
```
## 功能
- **站内搜索**:WBI 签名的官方搜索接口,卡片列表展示封面 / 标题 / UP主 / 时长 / 播放量
- **粘贴链接或 BV 号**直接解析
- **清晰度可选**:按**真正能下到的流**列出(不是标称支持),默认最高
- **多线程加速下载**:文件切段并发下载(默认 16,可调 1~64),断线按分片续传,支持跨进程断点续传
- **实时进度**:SSE 推送,总体 + 视频流 + 音频流三条进度、实时速度、速度曲线、ETA
- **ffmpeg 无损封装**为 mp4,产物文件名带清晰度
- 深浅色主题(默认深色)并记忆
## 架构
```
BiliDownloaderWeb/
├─ start.cmd / stop.cmd 双击启动 / 停止
├─ server.py 本地 HTTP 服务(标准库 http.server,只监听 127.0.0.1)
│ JSON API + SSE 进度推送 + 托管前端产物
├─ engine.py 下载引擎(已实测验证的核心逻辑)
│ ├─ WBI 签名的站内搜索
│ ├─ 视频详情 / 分P / 清晰度
│ ├─ 多线程分段下载(Range 分片 + 每片重试 + 旁路状态文件续传)
│ └─ ffmpeg 封装
├─ config.json sessdata / outputDir / threads / preferAvc / keepParts
├─ ui/ React 19 + Tailwind 4 + Vite 7 + shadcn/ui(new-york)
│ └─ src/{App.tsx,components/,lib/}
└─ tools/ 验收脚本
├─ smoke_api.py 后端 API 冒烟
├─ e2e_download.py 端到端下载验收(含 ffprobe 与全片解码校验)
└─ ui-check.mjs 浏览器端验收(Edge 无头 + CDP,真的驱动一次搜索)
```
为什么是"本地薄服务 + 浏览器界面":前端需要跨域访问 B 站 API、需要写本地磁盘、需要用 ffmpeg,
浏览器自己做不到,所以后端必须存在;而这层后端用 Python 标准库就够,**零第三方依赖**,
`engine.py` 里那套逻辑也已在上一轮里逐项实测过。
## 验收结果(都是实测,非推断)
**后端 API**(`tools/smoke_api.py`)
| 项 | 结果 |
|---|---|
| 站内搜索 | `numResults=1000`,返回 20 条,封面 URL 正常 |
| 视频详情 | 标题/UP主/时长/分P 全部正确 |
| 清晰度列表 | 按 `dash.video` 实际流构建 |
**端到端下载**(`tools/e2e_download.py`)—— **13/13 通过**
```
产物: [4K]当你用《航拍中国》的方式打开崂山育才——航拍育才 [480P标清].mp4
大小: 11.2 MB 流: h264 852x480 + aac
时长: 90.0s(源 91s) 全片解码: 无错误
速度: 峰值 15.9 MB/s,16 线程 耗时: 2.0s
中间文件: 已清理
```
**浏览器界面**(`tools/ui-check.mjs`,Edge 无头 + CDP)—— **18/18 通过**
其中包含**真的驱动界面**:填入关键词 → 触发提交 → 等结果渲染(21 个卡片)→ 点选第一个
→ 等清晰度解析 → 断言「开始下载」可点。另有:React 挂载、深色主题生效、无横向溢出、零脚本错误。
## 两个必须知道的坑(都踩过)
### 1. 清晰度列表必须取 `dash.video`,不能取 `accept_quality`
`accept_quality` / `support_formats` 只反映视频**标称**支持什么,与当前账号能否下载无关。
实测:未登录时 `accept_quality` 仍列出 `120(4K)`,但 `dash.video` 里只有 `32(480P)`。
按前者建列表会让用户选到根本拿不到的清晰度,点下载才报错。
### 2. 产物文件名必须带清晰度
否则同一视频下不同清晰度会互相覆盖 —— 实测踩过:480P 的验收下载把之前下好的 4K 文件直接盖掉。
## 配置
`config.json`(与 `server.py` 同目录):
```json
{
"sessdata": "...",
"outputDir": "E:\\deepseek\\downloads",
"threads": 16,
"preferAvc": true,
"keepParts": false
}
```
`threads` 实测:16 线程约 4.1 MB/s,32 线程约 6.8 MB/s;过高可能触发 CDN 限流。
> ⚠️ `sessdata` 等同于账号登录凭据,明文存在本机。**别把这个目录分享给他人。**
> 未登录/失效时 B 站只放出 480P 及以下,界面会明确提示。
## 重新构建前端
```powershell
cd ui
npm install # 首次
npm run build # 产物到 ui/dist,由 server.py 托管
npm run dev # 开发模式(热更新,代理 /api 到 8799)
```
+7
View File
@@ -0,0 +1,7 @@
{
"sessdata": "12892060%2C1804723781%2Caa6de%2A91CjBKyjjjtXUBib-hcvUvs7CT7YUiP5j-0qIMAlKTi3BHY_jNPgE6Ze49_LBruJQGivwSVk9NeEh3RXBBajc3TXJlbWRqYlQ0SzJjRzVsSE51dmtuSXV4VF9ZSkZwSHY3d1paaTBUSGtxdWRXbHVYRG9JVmk5bmRhR0s3QUEwVHZZRlV1aG5WMUR3IIEC",
"outputDir": "E:\\deepseek\\downloads",
"threads": 16,
"preferAvc": true,
"keepParts": false
}
+513
View File
@@ -0,0 +1,513 @@
# -*- coding: utf-8 -*-
"""
engine.py —— BiliDownloader 下载引擎(被 server.py 导入)
这里的核心逻辑全部来自已经实测跑通的 bili_download.py,行为保持一致:
* WBI 签名的站内搜索(老的无签名接口已被 B 站封禁)
* 视频详情 / 分P / 清晰度列表(playurl qn=127 拿全部可用清晰度)
* 多线程分段加速下载:Range 分片 + 并发 + 每片独立重试续传 + 旁路状态文件跨进程续传
* ffmpeg 无损封装为 mp4
只依赖 Python 标准库;ffmpeg 需在 PATH 或常见安装位置。
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import threading
import time
import urllib.parse
import urllib.request
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
API_VIEW = "https://api.bilibili.com/x/web-interface/view?bvid={bvid}"
API_NAV = "https://api.bilibili.com/x/web-interface/nav"
API_SEARCH = "https://api.bilibili.com/x/web-interface/wbi/search/type"
API_PLAYURL = ("https://api.bilibili.com/x/player/playurl?bvid={bvid}&cid={cid}"
"&qn=127&fnver=0&fnval=4048&fourk=1")
QN_NAME = {
127: "8K 超高清", 126: "杜比视界", 125: "HDR 真彩", 120: "4K 超清",
116: "1080P60", 112: "1080P 高码率", 80: "1080P 高清", 74: "720P60",
64: "720P 高清", 32: "480P 清晰", 16: "360P 流畅", 6: "240P 极速",
}
# WBI 签名置换表(与 bilibili-API-collect 公开算法一致)
MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,
26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
20, 34, 44, 52,
]
CHUNK = 1 << 18 # 256KB
SEG_MIN = 2 << 20 # 小于 2MB 不值得分片
class BiliError(RuntimeError):
"""带 B 站错误码的异常。"""
def __init__(self, code: int, message: str):
super().__init__(f"B 站接口错误 {code}: {message}")
self.code = code
self.message = message
def human(n: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.1f}{unit}" if unit != "B" else f"{int(n)}B"
n /= 1024
return f"{n:.1f}GB"
# ------------------------------------------------------------------ HTTP
def _headers(referer: str, cookie: str | None, extra: dict | None = None) -> dict:
h = {"User-Agent": UA, "Referer": referer, "Accept": "*/*",
"Accept-Encoding": "identity", "Connection": "keep-alive"}
if cookie:
h["Cookie"] = cookie
if extra:
h.update(extra)
return h
def _get(url: str, referer: str = "https://www.bilibili.com",
cookie: str | None = None, timeout: int = 30) -> bytes:
req = urllib.request.Request(url, headers=_headers(referer, cookie))
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read()
def _api(url: str, cookie: str | None = None, referer: str = "https://www.bilibili.com") -> dict:
raw = _get(url, referer, cookie)
try:
payload = json.loads(raw.decode("utf-8"))
except Exception as exc:
raise BiliError(-1, "B 站返回了非 JSON 内容(可能触发了风控)") from exc
if payload.get("code") != 0:
raise BiliError(payload.get("code", -1), payload.get("message", ""))
return payload["data"]
# ------------------------------------------------------------------ WBI 签名
def _mixin_key(img_url: str, sub_url: str) -> str:
img = os.path.basename(urllib.parse.urlparse(img_url).path).split(".")[0]
sub = os.path.basename(urllib.parse.urlparse(sub_url).path).split(".")[0]
raw = img + sub
return "".join(raw[i] for i in MIXIN_KEY_ENC_TAB)[:32]
def _sign(params: dict, mixin_key: str) -> str:
params = dict(params)
params["wts"] = int(time.time())
items = sorted(params.items())
query = urllib.parse.urlencode(
[(k, "".join(c for c in str(v) if c not in "!'()*")) for k, v in items])
w_rid = hashlib.md5((query + mixin_key).encode()).hexdigest()
return f"{query}&w_rid={w_rid}"
# ------------------------------------------------------------------ 业务接口
def login_status(cookie: str | None) -> dict:
"""未登录时 nav 返回 code=-101,但 data.wbi_img 仍然可用,所以这里不抛异常。"""
try:
raw = _get(API_NAV, cookie=cookie)
payload = json.loads(raw.decode("utf-8"))
except Exception as exc:
return {"isLogin": False, "uname": "", "error": str(exc)}
data = payload.get("data") or {}
return {"isLogin": bool(data.get("isLogin")), "uname": data.get("uname") or "",
"code": payload.get("code")}
def search(keyword: str, page: int = 1, page_size: int = 20,
cookie: str | None = None) -> dict:
"""站内搜索。必须走 WBI 签名接口 —— 老的无签名接口已被封(返回非 JSON)。"""
nav = json.loads(_get(API_NAV, cookie=cookie).decode("utf-8"))
wbi = (nav.get("data") or {}).get("wbi_img") or {}
img_url, sub_url = wbi.get("img_url"), wbi.get("sub_url")
if not img_url or not sub_url:
raise BiliError(-1, "无法获取 WBI 密钥")
query = _sign({"search_type": "video", "keyword": keyword,
"page": page, "page_size": page_size}, _mixin_key(img_url, sub_url))
payload = json.loads(_get(f"{API_SEARCH}?{query}", cookie=cookie).decode("utf-8"))
if payload.get("code") != 0:
raise BiliError(payload.get("code", -1), payload.get("message", ""))
data = payload.get("data") or {}
results = []
for item in (data.get("result") or []):
title = re.sub(r"<em[^>]*>|</em>", "", item.get("title") or "")
pic = item.get("pic") or ""
if pic.startswith("//"):
pic = "https:" + pic
results.append({
"bvid": item.get("bvid"),
"title": title,
"author": item.get("author"),
"duration": item.get("duration"), # 字符串,如 "2:28"
"play": item.get("play"),
"danmaku": item.get("danmaku"),
"pic": pic,
"description": item.get("description") or item.get("desc") or "",
})
return {"items": results, "page": page,
"numResults": data.get("numResults"),
"hasMore": len(results) >= page_size}
def video_detail(bvid: str, cookie: str | None = None) -> dict:
data = _api(API_VIEW.format(bvid=bvid), cookie)
pic = data.get("pic") or ""
if pic.startswith("//"):
pic = "https:" + pic
return {
"bvid": data.get("bvid"),
"title": data.get("title"),
"pic": pic,
"duration": data.get("duration"), # int 秒
"desc": data.get("desc") or "",
"owner": (data.get("owner") or {}).get("name"),
"stat": data.get("stat") or {},
"pages": [{"cid": p.get("cid"), "page": p.get("page"),
"part": p.get("part"), "duration": p.get("duration")}
for p in (data.get("pages") or [])],
}
def play_url(bvid: str, cid: int, cookie: str | None = None) -> dict:
"""qn=127 请求最高清晰度,让服务端把可用的清晰度都返回。
★ 关键:清晰度列表必须按 `dash.video` 里**真正拿到的流**来建,不能用
`accept_quality` / `support_formats` —— 那两个只反映视频"标称"支持什么,
与当前账号能否下载无关。实测:未登录时 accept_quality 仍列出 120(4K),
但 dash.video 里只有 32(480P)。若按 accept_quality 建列表,用户会选到根本拿不到的清晰度,
点下载才报错。
"""
data = _api(API_PLAYURL.format(bvid=bvid, cid=cid), cookie)
accept = data.get("accept_quality") or []
formats = {f.get("quality"): f for f in (data.get("support_formats") or [])}
dash = data.get("dash") or {}
videos = [{"quality": s.get("id"), "baseUrl": s.get("baseUrl"),
"backupUrl": s.get("backupUrl") or [], "codecs": s.get("codecs") or "",
"width": s.get("width"), "height": s.get("height"),
"bandwidth": s.get("bandwidth")}
for s in (dash.get("video") or []) if s.get("baseUrl")]
audios = [{"baseUrl": s.get("baseUrl"), "backupUrl": s.get("backupUrl") or [],
"codecs": s.get("codecs") or "", "bandwidth": s.get("bandwidth")}
for s in (dash.get("audio") or []) if s.get("baseUrl")]
# 同一清晰度可能有多种编码,取码率最高的一条来展示尺寸
best: dict[int, dict] = {}
for s in videos:
q = s["quality"]
if q is None:
continue
if q not in best or (s["bandwidth"] or 0) > (best[q]["bandwidth"] or 0):
best[q] = s
qualities = []
for q in sorted(best, reverse=True):
s = best[q]
f = formats.get(q) or {}
qualities.append({
"quality": q,
"label": f.get("new_description") or QN_NAME.get(q, str(q)),
"width": s.get("width"), "height": s.get("height"),
"codecs": s.get("codecs"),
"bandwidth": s.get("bandwidth"),
})
return {"qualities": qualities, "video": videos, "audio": audios,
# 实际可下载的最高清晰度(用它判断是否被"未登录"限制到 480P)
"maxQuality": max(best) if best else 0,
# 视频标称支持的清晰度,仅作参考展示
"advertisedQuality": max(accept) if accept else 0,
"duration": dash.get("duration") or 0}
# ------------------------------------------------------------------ 下载
def _state_path(dest: str) -> str:
return dest + ".parts.json"
def _resource_key(url: str) -> str:
"""取 URL 中标识资源的稳定部分。
baseUrl 带 e= 时效签名,重跑时签名字段与 CDN 域名都会变;只有 path
(含 avid/cid/清晰度)保持不变,所以续传状态必须以 path 为键。
"""
return urllib.parse.urlparse(url).path
def _load_state(dest: str, url: str, total: int, n: int) -> list[int]:
if not os.path.exists(dest) or os.path.getsize(dest) != total:
return [0] * n
try:
with open(_state_path(dest), "r", encoding="utf-8") as f:
st = json.load(f)
if st.get("key") != _resource_key(url) or st.get("total") != total:
return [0] * n
offs = [int(x) for x in st["offsets"]]
if len(offs) != n:
return [0] * n
return [max(0, min(o, total)) for o in offs]
except Exception:
return [0] * n
def _save_state(dest: str, url: str, total: int, offsets: list[int]) -> None:
tmp = _state_path(dest) + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"key": _resource_key(url), "total": total, "offsets": offsets}, f)
os.replace(tmp, _state_path(dest))
except Exception:
pass
def probe(url: str, referer: str, cookie: str | None) -> tuple[int, bool]:
"""探测总长度与是否支持 Range 分片。"""
req = urllib.request.Request(
url, headers=_headers(referer, cookie, {"Range": "bytes=0-0"}))
with urllib.request.urlopen(req, timeout=30) as r:
if r.status == 206:
cr = r.headers.get("Content-Range", "")
return (int(cr.split("/")[-1]) if "/" in cr else 0), True
return int(r.headers.get("Content-Length") or 0), False
def _download_range(url: str, fd, referer: str, cookie: str | None,
start: int, end: int, on_bytes, cancel, retries: int = 24,
on_chunk=None) -> None:
cur = start
attempt = 0
while cur <= end:
if cancel is not None and cancel.is_set():
raise InterruptedError("已取消")
try:
req = urllib.request.Request(
url, headers=_headers(referer, cookie, {"Range": f"bytes={cur}-{end}"}))
with urllib.request.urlopen(req, timeout=45) as r:
if r.status != 206:
raise IOError(f"服务器未返回 206 (got {r.status})")
os.lseek(fd, cur, os.SEEK_SET)
while True:
chunk = r.read(CHUNK)
if not chunk:
break
os.write(fd, chunk)
cur += len(chunk)
on_bytes(len(chunk))
if on_chunk:
on_chunk(cur)
if cur <= end:
raise IOError("连接提前结束")
attempt = 0
except InterruptedError:
raise
except Exception as e:
attempt += 1
if attempt > retries:
raise RuntimeError(f"分片重试 {retries} 次仍失败: {e}")
time.sleep(min(0.5 * attempt, 5))
def download_fast(url: str, dest: str, referer: str, cookie: str | None,
threads: int = 16, on_progress=None, cancel=None,
label: str = "视频") -> dict:
"""多线程分段加速下载,支持跨进程断点续传。返回统计信息。"""
total, ranges_ok = probe(url, referer, cookie)
state = {"done": 0, "total": total, "t0": time.time(), "resumed": 0}
def report():
if not on_progress:
return
el = max(time.time() - state["t0"], 1e-6)
on_progress({
"label": label,
"downloaded": state["done"],
"total": total,
"percent": (state["done"] * 100 / total) if total else 0,
"speed": state["done"] / el,
"threads": 1 if not ranges_ok else max(1, min(threads, 64)),
"resumed": state["resumed"],
})
lock = threading.Lock()
def on_bytes(n):
with lock:
state["done"] += n
if not ranges_ok or total < SEG_MIN or threads <= 1:
have = os.path.getsize(dest) if os.path.exists(dest) else 0
if have and have < total:
state["done"] = have
state["resumed"] = have
report()
mode = "ab" if have else "wb"
req_headers = _headers(referer, cookie,
{"Range": f"bytes={have}-"} if have else None)
with urllib.request.urlopen(urllib.request.Request(url, headers=req_headers),
timeout=45) as r:
if have and r.status != 206:
have = 0
mode = "wb"
state["done"] = 0
with open(dest, mode) as f:
while True:
if cancel is not None and cancel.is_set():
raise InterruptedError("已取消")
chunk = r.read(CHUNK)
if not chunk:
break
f.write(chunk)
on_bytes(len(chunk))
report()
return {"total": total, "threads": 1, "resumed": state["resumed"]}
n = max(1, min(threads, 64))
seg = total // n
spans = [(i * seg, total - 1 if i == n - 1 else (i + 1) * seg - 1) for i in range(n)]
offsets = _load_state(dest, url, total, n)
if not os.path.exists(dest) or os.path.getsize(dest) != total:
with open(dest, "wb") as f:
f.truncate(total)
offsets = [0] * n
resume_done = sum(offsets)
if resume_done:
state["done"] = resume_done
state["resumed"] = resume_done
report()
last_flush = [time.time()]
errors: list[Exception] = []
state_lock = threading.Lock()
def flush(force=False):
with state_lock:
now = time.time()
if not force and now - last_flush[0] < 2.0:
return
last_flush[0] = now
_save_state(dest, url, total, offsets)
def worker(i, s, e):
try:
fd = os.open(dest, os.O_RDWR | getattr(os, "O_BINARY", 0))
try:
def on_chunk(cur_abs, i=i, s=s):
with state_lock:
offsets[i] = cur_abs - s + 1
flush()
_download_range(url, fd, referer, cookie, s + offsets[i], e,
on_bytes, cancel, on_chunk=on_chunk)
finally:
os.close(fd)
with state_lock:
offsets[i] = e - s + 1
except InterruptedError as ex:
errors.append(ex)
except Exception as ex:
errors.append(ex)
t0 = time.time()
reporter_stop = threading.Event()
def reporter():
while not reporter_stop.wait(0.4):
report()
threading.Thread(target=reporter, daemon=True).start()
ts = [threading.Thread(target=worker, args=(i, s, e), daemon=True)
for i, (s, e) in enumerate(spans)]
for t in ts:
t.start()
for t in ts:
t.join()
reporter_stop.set()
if any(isinstance(e, InterruptedError) for e in errors):
flush(force=True)
raise InterruptedError("已取消")
if errors:
flush(force=True)
raise RuntimeError(f"下载失败(进度已保存, 重跑可续传): {errors[0]}")
if os.path.getsize(dest) != total:
raise RuntimeError(f"大小不符: {os.path.getsize(dest)} != {total}")
if os.path.exists(_state_path(dest)):
os.remove(_state_path(dest))
report()
return {"total": total, "threads": n, "resumed": resume_done,
"elapsed": time.time() - t0}
# ------------------------------------------------------------------ ffmpeg
def find_ffmpeg() -> str | None:
exe = shutil.which("ffmpeg")
if exe:
return exe
for p in (r"C:\ffmpeg\bin\ffmpeg.exe",
r"C:\Users\Administrator\miniconda3\Library\bin\ffmpeg.exe"):
if os.path.exists(p):
return p
return None
def merge(video: str, audio: str | None, out: str) -> None:
ff = find_ffmpeg()
if not ff:
raise RuntimeError("找不到 ffmpeg")
cmd = [ff, "-y", "-hide_banner", "-loglevel", "error", "-i", video]
if audio:
cmd += ["-i", audio, "-map", "0:v:0", "-map", "1:a:0", "-c", "copy"]
else:
cmd += ["-c", "copy"]
cmd += ["-movflags", "+faststart", out]
subprocess.run(cmd, check=True, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
# ------------------------------------------------------------------ 工具
def safe_name(name: str) -> str:
name = re.sub(r'[\\/:*?"<>|\r\n\t]', "_", name).strip(" .")
return (name or "video")[:120]
def pick_streams(play: dict, quality: int, prefer_avc: bool = True):
"""在指定清晰度下选码流:优先 H.264(兼容性最好),音频取码率最大。"""
pool = [s for s in play["video"] if s["quality"] == quality]
if not pool:
return None, None
if prefer_avc:
avc = [s for s in pool if s["codecs"].startswith(("avc", "av01"))]
pool = avc or pool
video = max(pool, key=lambda s: s["bandwidth"])
if not play["audio"]:
return video, None
audio = max(play["audio"], key=lambda s: s["bandwidth"])
return video, audio
+438
View File
@@ -0,0 +1,438 @@
# -*- coding: utf-8 -*-
"""
server.py —— BiliDownloader 本地服务(零第三方依赖)
只监听 127.0.0.1。职责:
* 托管前端构建产物 (ui/dist)
* 提供 JSON API(搜索 / 详情 / 清晰度 / 开始下载 / 取消 / 配置)
* 用 Server-Sent Events 把下载进度实时推给浏览器
用法:
python server.py [--port 8799] [--no-open]
"""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import queue
import subprocess
import sys
import threading
import time
import traceback
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import engine
BASE = os.path.dirname(os.path.abspath(__file__))
DIST = os.path.join(BASE, "ui", "dist")
CONFIG_PATH = os.path.join(BASE, "config.json")
DEFAULT_CONFIG = {
"sessdata": "",
"outputDir": os.path.join(os.path.expanduser("~"), "Videos", "BiliDownloader"),
"threads": 16,
"preferAvc": True,
"keepParts": False,
}
_config_lock = threading.Lock()
_job_lock = threading.Lock()
_job: dict | None = None # 当前/最近一次下载任务
_cancel: threading.Event | None = None
_streams: dict[str, dict] = {} # (bvid,cid) -> playurl 结果
_subscribers: list[queue.Queue] = []
_play_cache: dict[str, dict] = {}
# ------------------------------------------------------------------ 配置
def load_config() -> dict:
cfg = dict(DEFAULT_CONFIG)
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
cfg.update(json.load(f))
except Exception:
pass
cfg["threads"] = max(1, min(64, int(cfg.get("threads") or 16)))
return cfg
def save_config(cfg: dict) -> dict:
merged = load_config()
merged.update({k: v for k, v in cfg.items() if k in DEFAULT_CONFIG})
merged["threads"] = max(1, min(64, int(merged.get("threads") or 16)))
with _config_lock:
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(merged, f, ensure_ascii=False, indent=2)
os.replace(tmp, CONFIG_PATH)
return merged
def cookie_of(cfg: dict) -> str | None:
s = (cfg.get("sessdata") or "").strip()
return f"SESSDATA={s}" if s else None
# ------------------------------------------------------------------ 事件广播
def broadcast(event: str, data: dict) -> None:
payload = f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
dead = []
for q in list(_subscribers):
try:
q.put_nowait(payload)
except Exception:
dead.append(q)
for q in dead:
try:
_subscribers.remove(q)
except ValueError:
pass
# ------------------------------------------------------------------ 下载任务
def start_download(bvid: str, cid: int, quality: int, title: str) -> dict:
global _job, _cancel
with _job_lock:
if _job and _job.get("state") == "running":
raise RuntimeError("已有下载任务在进行中")
cfg = load_config()
key = f"{bvid}:{cid}"
play = _streams.get(key)
if not play:
raise RuntimeError("播放地址不存在或已过期,请重新选择视频")
video, audio = engine.pick_streams(play, quality, cfg.get("preferAvc", True))
if video is None:
raise RuntimeError("当前清晰度没有可用的视频流")
out_dir = cfg["outputDir"]
os.makedirs(out_dir, exist_ok=True)
base = engine.safe_name(title)
# 文件名必须带上清晰度:否则同一视频下不同清晰度会互相覆盖
# (实测踩过:480P 的验收下载把之前下好的 4K 文件直接盖掉了)
q_label = next((q["label"] for q in play.get("qualities", [])
if q["quality"] == quality), str(quality))
q_tag = engine.safe_name(q_label).replace(" ", "")
stamp = time.strftime("%Y%m%d%H%M%S")
final_path = os.path.join(out_dir, f"{base} [{q_tag}].mp4")
tmp_v = os.path.join(out_dir, f".{base}.{cid}.{stamp}.video.m4s")
tmp_a = os.path.join(out_dir, f".{base}.{cid}.{stamp}.audio.m4s")
_cancel = threading.Event()
_job = {
"state": "running", "stage": "下载视频流", "bvid": bvid, "cid": cid,
"title": title, "quality": quality, "outputPath": final_path,
"startedAt": time.time(), "video": None, "audio": None,
"progress": {"video": None, "audio": None}, "error": None,
"resumed": 0, "threads": cfg["threads"],
}
broadcast("job", dict(_job))
threading.Thread(target=_run_job, args=(video, audio, tmp_v, tmp_a, final_path,
cfg, bvid, cid), daemon=True).start()
return dict(_job)
def _run_job(video, audio, tmp_v, tmp_a, final_path, cfg, bvid, cid) -> None:
global _job
cookie = cookie_of(cfg)
referer = f"https://www.bilibili.com/video/{bvid}"
threads = cfg["threads"]
def make_on_progress(which):
def on_progress(info):
_job["progress"][which] = info
broadcast("progress", {"which": which, **info, "stage": _job["stage"]})
return on_progress
try:
_job["stage"] = "下载视频流"
broadcast("stage", {"stage": _job["stage"]})
vres = engine.download_fast(video["baseUrl"], tmp_v, referer, cookie,
threads, make_on_progress("video"), _cancel, "视频")
_job["resumed"] = vres.get("resumed", 0)
if audio:
_job["stage"] = "下载音频流"
broadcast("stage", {"stage": _job["stage"]})
engine.download_fast(audio["baseUrl"], tmp_a, referer, cookie,
max(1, min(8, threads)), make_on_progress("audio"),
_cancel, "音频")
_job["stage"] = "ffmpeg 封装"
broadcast("stage", {"stage": _job["stage"]})
engine.merge(tmp_v, tmp_a if audio else None, final_path)
if not cfg.get("keepParts"):
for p in (tmp_v, tmp_a):
try:
if os.path.exists(p):
os.remove(p)
except OSError:
pass
_job["state"] = "done"
_job["stage"] = "完成"
_job["size"] = os.path.getsize(final_path)
_job["elapsed"] = time.time() - _job["startedAt"]
broadcast("job", dict(_job))
except InterruptedError:
for p in (final_path,):
try:
if os.path.exists(p):
os.remove(p)
except OSError:
pass
if not cfg.get("keepParts"):
for p in (tmp_v, tmp_a):
try:
if os.path.exists(p):
os.remove(p)
except OSError:
pass
_job["state"] = "cancelled"
_job["stage"] = "已取消,未完成的文件已清理"
broadcast("job", dict(_job))
except Exception as exc:
for p in (final_path,):
try:
if os.path.exists(p):
os.remove(p)
except OSError:
pass
_job["state"] = "error"
_job["error"] = str(exc)
broadcast("job", dict(_job))
traceback.print_exc()
# ------------------------------------------------------------------ HTTP
class Handler(BaseHTTPRequestHandler):
server_version = "BiliDownloader"
def log_message(self, fmt, *args):
pass # 静音访问日志
# ---------- helpers
def _json(self, obj, status=200):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _error(self, message, status=400):
self._json({"ok": False, "error": str(message)}, status)
def _query(self) -> dict:
parsed = urllib.parse.urlparse(self.path)
return {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}, parsed.path
def _body(self) -> dict:
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
return json.loads(self.rfile.read(length).decode("utf-8"))
# ---------- routes
def do_GET(self):
params, path = self._query()
try:
if path == "/api/config":
cfg = load_config()
cfg["configPath"] = CONFIG_PATH
cfg["ffmpeg"] = engine.find_ffmpeg()
return self._json(cfg)
if path == "/api/login":
cfg = load_config()
return self._json(engine.login_status(cookie_of(cfg)))
if path == "/api/search":
cfg = load_config()
return self._json(engine.search(
params.get("q", ""), int(params.get("page", 1)), 20, cookie_of(cfg)))
if path == "/api/video":
cfg = load_config()
return self._json(engine.video_detail(params["bvid"], cookie_of(cfg)))
if path == "/api/playurl":
cfg = load_config()
bvid, cid = params["bvid"], int(params["cid"])
play = engine.play_url(bvid, cid, cookie_of(cfg))
_streams[f"{bvid}:{cid}"] = play
return self._json(play)
if path == "/api/job":
return self._json(_job or {})
if path == "/api/events":
return self._sse()
if path.startswith("/api/"):
return self._error("未知接口", 404)
return self._static(path)
except engine.BiliError as exc:
return self._error(f"{exc.message}(code {exc.code})", 502)
except Exception as exc:
traceback.print_exc()
return self._error(exc, 500)
def do_POST(self):
_, path = self._query()
try:
if path == "/api/config":
return self._json(save_config(self._body()))
if path == "/api/download":
data = self._body()
job = start_download(data["bvid"], int(data["cid"]),
int(data["quality"]), data.get("title") or "video")
return self._json(job)
if path == "/api/cancel":
if _cancel:
_cancel.set()
return self._json({"ok": True})
if path == "/api/open-folder":
target = (self._body() or {}).get("path") or load_config()["outputDir"]
if os.path.exists(target):
subprocess.Popen(["explorer", "/select,", os.path.normpath(target)])
else:
os.makedirs(target, exist_ok=True)
subprocess.Popen(["explorer", os.path.normpath(target)])
return self._json({"ok": True})
return self._error("未知接口", 404)
except Exception as exc:
traceback.print_exc()
return self._error(exc, 500)
# ---------- SSE
def _sse(self):
q: queue.Queue = queue.Queue()
_subscribers.append(q)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "keep-alive")
self.end_headers()
try:
hello = f"event: job\ndata: {json.dumps(_job or {}, ensure_ascii=False)}\n\n"
self.wfile.write(hello.encode("utf-8"))
self.wfile.flush()
while True:
try:
payload = q.get(timeout=15)
self.wfile.write(payload.encode("utf-8"))
except queue.Empty:
self.wfile.write(b": ping\n\n") # 心跳,防代理断连
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
pass
finally:
try:
_subscribers.remove(q)
except ValueError:
pass
# ---------- 静态文件
def _static(self, path: str):
if not os.path.isdir(DIST):
body = ("<!doctype html><meta charset=utf-8>"
"<h1>前端还没构建</h1>"
"<p>请在 ui 目录执行 <code>npm run build</code>,"
"或直接用 <code>npm run dev</code> 起开发服务器。</p>").encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
rel = urllib.parse.unquote(path.lstrip("/")) or "index.html"
target = os.path.normpath(os.path.join(DIST, rel))
# 防目录穿越
if not target.startswith(os.path.normpath(DIST)):
return self._error("非法路径", 403)
if not os.path.isfile(target):
target = os.path.join(DIST, "index.html") # SPA 回退
if not os.path.isfile(target):
return self._error("Not found", 404)
ctype = mimetypes.guess_type(target)[0] or "application/octet-stream"
if ctype.startswith("text/") or ctype in ("application/javascript", "application/json"):
ctype += "; charset=utf-8"
with open(target, "rb") as f:
body = f.read()
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store" if target.endswith(".html") else "public, max-age=3600")
self.end_headers()
self.wfile.write(body)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=8799)
ap.add_argument("--no-open", action="store_true")
args = ap.parse_args()
cfg = load_config()
if not os.path.exists(CONFIG_PATH):
save_config(cfg)
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
url = f"http://127.0.0.1:{args.port}/"
print(f"BiliDownloader 服务已启动: {url}", flush=True)
print(f"配置: {CONFIG_PATH}", flush=True)
print(f"ffmpeg: {engine.find_ffmpeg() or '未找到(合并会失败)'}", flush=True)
if not args.no_open:
threading.Thread(target=_open_browser, args=(url,), daemon=True).start()
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
return 0
def _open_browser(url: str) -> None:
time.sleep(0.8)
try:
edge = None
for p in (r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"):
if os.path.exists(p):
edge = p
break
if edge:
# 应用模式窗口:无地址栏、无标签页,看起来就是一个独立应用
subprocess.Popen([edge, f"--app={url}", "--window-size=1280,860"])
else:
subprocess.Popen(["cmd", "/c", "start", "", url], shell=False)
except Exception:
pass
if __name__ == "__main__":
sys.exit(main())
+21
View File
@@ -0,0 +1,21 @@
@echo off
rem BiliDownloader 启动脚本:起本地服务 + 开 Edge 应用模式窗口
setlocal
cd /d "%~dp0"
set "PYTHONIOENCODING=utf-8"
rem 已经有实例在跑,就直接把窗口拉起来,不要再起一个服务
powershell -NoProfile -Command "if (Get-NetTCPConnection -LocalPort 8799 -State Listen -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }" >nul 2>nul
if %errorlevel%==0 (
start "" "msedge" --app=http://127.0.0.1:8799/ --window-size=1280,860
exit /b 0
)
rem 优先用 pythonw(无控制台窗口),没有就退回 python
where pythonw >nul 2>nul
if %errorlevel%==0 (
start "" pythonw "%~dp0server.py" --port 8799
) else (
start "" /min python "%~dp0server.py" --port 8799
)
exit /b 0
+8
View File
@@ -0,0 +1,8 @@
@echo off
rem 停止 BiliDownloader 本地服务
setlocal
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8799" ^| findstr LISTENING') do (
taskkill /PID %%a /F >nul 2>nul
)
echo 已停止 BiliDownloader 服务。
exit /b 0
+37
View File
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""诊断:nav 为何报未登录 + dash.video 实际内容。"""
import json
import sys
import urllib.request
sys.path.insert(0, r"E:\deepseek\BiliDownloaderWeb")
import engine
cfg = json.load(open(r"E:\deepseek\BiliDownloaderWeb\config.json", encoding="utf-8"))
sess = cfg["sessdata"]
print("config 里 sessdata 长度 =", len(sess))
print("前 24 字符 =", sess[:24])
print("后 24 字符 =", sess[-24:])
print("\n--- 直接请求 nav,看原始返回 ---")
req = urllib.request.Request(
engine.API_NAV,
headers={"User-Agent": engine.UA, "Referer": "https://www.bilibili.com",
"Cookie": "SESSDATA=" + sess})
with urllib.request.urlopen(req, timeout=30) as r:
raw = json.loads(r.read().decode("utf-8"))
print("code =", raw.get("code"), " message =", raw.get("message"))
d = raw.get("data") or {}
print("isLogin =", d.get("isLogin"), " uname =", d.get("uname"))
print("有 wbi_img =", bool(d.get("wbi_img")))
print("\n--- dash.video 实际内容(qn=127)---")
vid = engine.video_detail("BV1HVBAYLEAo", "SESSDATA=" + sess)
cid = vid["pages"][0]["cid"]
p = engine.play_url("BV1HVBAYLEAo", cid, "SESSDATA=" + sess)
print("maxQuality =", p["maxQuality"])
for s in p["video"]:
print(f" 视频流 qn={s['quality']:>4} {s['width']}x{s['height']} codec={s['codecs']} {s['bandwidth']//1000}kbps")
for s in p["audio"]:
print(f" 音频流 {s['codecs']} {s['bandwidth']//1000}kbps")
print("qualities 的 width/height 字段示例:", p["qualities"][0])
+165
View File
@@ -0,0 +1,165 @@
# -*- coding: utf-8 -*-
"""
e2e_download.py —— 端到端验收:通过服务的 HTTP 接口真的下一次,并校验产物。
流程:playurl -> POST /api/download -> 订阅 SSE 收进度 -> 等完成 -> ffprobe 校验
"""
import json
import os
import subprocess
import sys
import threading
import time
import urllib.request
BASE = "http://127.0.0.1:8799"
BVID = sys.argv[1] if len(sys.argv) > 1 else "BV1HVBAYLEAo"
ok_count = 0
fail_count = 0
def check(name, ok, detail=""):
global ok_count, fail_count
if ok:
ok_count += 1
print(f" [OK] {name}" + (f" — {detail}" if detail else ""))
else:
fail_count += 1
print(f" [XX] {name}" + (f" — {detail}" if detail else ""))
def get(path):
with urllib.request.urlopen(BASE + path, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
def post(path, payload):
req = urllib.request.Request(
BASE + path, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
print("=" * 62)
print(f"端到端下载验收: {BVID}")
print("=" * 62)
video = get(f"/api/video?bvid={BVID}")
cid = video["pages"][0]["cid"]
print(f"\n视频: {video['title']}")
print(f"UP主: {video['owner']} 时长: {video['duration']}s")
play = get(f"/api/playurl?bvid={BVID}&cid={cid}")
check("拿到清晰度列表", len(play["qualities"]) > 0,
" / ".join(q["label"] for q in play["qualities"]))
quality = play["qualities"][0]["quality"]
print(f"选择清晰度: {quality} ({play['qualities'][0]['label']})")
# 先确认没有正在跑的任务
job0 = get("/api/job")
check("初始无进行中的任务", job0.get("state") != "running", str(job0.get("state")))
# 订阅 SSE
events = []
prog_samples = []
stop = threading.Event()
def listen():
try:
with urllib.request.urlopen(BASE + "/api/events", timeout=600) as r:
event = None
while not stop.is_set():
line = r.readline().decode("utf-8", "replace").strip()
if line.startswith("event:"):
event = line.split(":", 1)[1].strip()
elif line.startswith("data:"):
data = json.loads(line.split(":", 1)[1].strip())
events.append((event, data))
if event == "progress":
prog_samples.append(data)
except Exception as exc:
if not stop.is_set():
print(" SSE 读取结束:", exc)
t = threading.Thread(target=listen, daemon=True)
t.start()
time.sleep(0.8)
print("\n--- 开始下载 ---")
t0 = time.time()
job = post("/api/download", {"bvid": BVID, "cid": cid, "quality": quality,
"title": video["title"]})
check("已受理下载任务", job.get("state") == "running", str(job.get("state")))
deadline = time.time() + 300
last_state = None
while time.time() < deadline:
time.sleep(1)
cur = get("/api/job")
if cur.get("state") != last_state:
last_state = cur.get("state")
print(f" [{time.time() - t0:5.1f}s] state={last_state} stage={cur.get('stage')}")
if cur.get("state") in ("done", "error", "cancelled"):
break
elapsed = time.time() - t0
stop.set()
final = get("/api/job")
check("任务正常结束", final.get("state") == "done", str(final.get("state") or final.get("error")))
check("收到了进度事件", len(prog_samples) > 0, f"{len(prog_samples)} 条")
check("同时收到 job/stage 事件", len(events) > 0, f"{len(events)} 条")
out = final.get("outputPath") or ""
check("产物文件存在", bool(out) and os.path.exists(out), out)
if out and os.path.exists(out):
size = os.path.getsize(out)
check("产物大小合理", size > 100_000, f"{size / 1024 / 1024:.1f} MB")
ffprobe = None
import shutil
ffprobe = shutil.which("ffprobe") or r"C:\Users\Administrator\miniconda3\Library\bin\ffprobe.exe"
cp = subprocess.run(
[ffprobe, "-v", "error", "-show_entries",
"format=duration,size:stream=codec_type,codec_name,width,height",
"-of", "json", out],
capture_output=True, text=True)
if cp.returncode == 0:
info = json.loads(cp.stdout)
streams = info.get("streams", [])
kinds = {s["codec_type"]: s for s in streams}
check("产物含视频流", "video" in kinds,
f"{kinds.get('video', {}).get('codec_name')} "
f"{kinds.get('video', {}).get('width')}x{kinds.get('video', {}).get('height')}")
check("产物含音频流", "audio" in kinds, str(kinds.get("audio", {}).get("codec_name")))
dur = float(info["format"]["duration"])
check("时长与源基本一致", abs(dur - video["duration"]) < 5, f"{dur:.1f}s vs {video['duration']}s")
else:
check("ffprobe 可解析产物", False, cp.stderr.strip()[:120])
# 全片解码校验
ffmpeg = shutil.which("ffmpeg") or r"C:\Users\Administrator\miniconda3\Library\bin\ffmpeg.exe"
cp2 = subprocess.run([ffmpeg, "-v", "error", "-i", out, "-f", "null", "-"],
capture_output=True, text=True)
check("全片解码无错误", cp2.returncode == 0 and not cp2.stderr.strip(),
cp2.stderr.strip()[:160] or "clean")
# 临时文件应已清理
tmp_left = [f for f in os.listdir(os.path.dirname(out)) if f.endswith(".m4s")]
check("中间文件已清理", len(tmp_left) == 0, f"残留 {len(tmp_left)} 个" if tmp_left else "无残留")
# 并发保护:任务已结束,再来一次应能受理(说明状态机复位)
print(f"\n耗时 {elapsed:.1f}s")
print("\n进度采样(每 10 条取 1):")
for s in prog_samples[::max(1, len(prog_samples) // 10)][:10]:
print(f" {s['label']} {s['percent']:5.1f}% {s['downloaded']/1024/1024:6.1f}MB"
f"/{s['total']/1024/1024:.1f}MB {s['speed']/1024/1024:.2f}MB/s {s['threads']}线程")
print("\n" + "=" * 62)
print(f"结果: {ok_count} 通过 / {fail_count} 失败")
print("=" * 62)
sys.exit(1 if fail_count else 0)
+44
View File
@@ -0,0 +1,44 @@
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class WinEnum {
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lParam);
[DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
}
"@
$found = New-Object System.Collections.ArrayList
$cb = [WinEnum+EnumProc]{
param($h, $l)
$len = [WinEnum]::GetWindowTextLength($h)
if ($len -gt 0 -and [WinEnum]::IsWindowVisible($h)) {
$sb = New-Object System.Text.StringBuilder ($len + 2)
[void][WinEnum]::GetWindowText($h, $sb, $sb.Capacity)
$t = $sb.ToString()
$r = New-Object WinEnum+RECT
[void][WinEnum]::GetWindowRect($h, [ref]$r)
[void]$found.Add([pscustomobject]@{
Title = $t
W = $r.R - $r.L
H = $r.B - $r.T
X = $r.L
Y = $r.T
})
}
return $true
}
[void][WinEnum]::EnumWindows($cb, [IntPtr]::Zero)
Write-Output "=== 可见顶层窗口中标题含 BiliDownloader 的 ==="
$found | Where-Object { $_.Title -like "*BiliDownloader*" } |
Select-Object Title, W, H, X, Y | Format-Table -AutoSize | Out-String
Write-Output "=== 前 12 个可见窗口(对照)==="
$found | Sort-Object -Property W -Descending | Select-Object -First 12 Title, W, H |
Format-Table -AutoSize | Out-String
+52
View File
@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""后端 API 冒烟测试。"""
import json
import urllib.parse
import urllib.request
BASE = "http://127.0.0.1:8799"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
def post(path, payload):
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(BASE + path, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
print("--- /api/config ---")
cfg = get("/api/config")
print(f"threads={cfg['threads']} outputDir={cfg['outputDir']}")
print(f"ffmpeg={cfg['ffmpeg']}")
print(f"configPath={cfg['configPath']}")
print("\n--- /api/login ---")
print(get("/api/login"))
print("\n--- /api/search?q=航拍中国 ---")
s = get("/api/search?q=" + urllib.parse.quote("航拍中国"))
print(f"numResults={s['numResults']} hasMore={s['hasMore']} 条数={len(s['items'])}")
for it in s["items"][:5]:
print(f" {it['bvid']} {it['title'][:34]} up={it['author']} dur={it['duration']} play={it['play']}")
print(f" pic = {s['items'][0]['pic']}")
print("\n--- /api/video?bvid=BV1HVBAYLEAo ---")
v = get("/api/video?bvid=BV1HVBAYLEAo")
print(f"title={v['title']}")
print(f"owner={v['owner']} duration={v['duration']}s pages={len(v['pages'])}")
cid = v["pages"][0]["cid"]
print(f"cid={cid}")
print("\n--- /api/playurl ---")
p = get(f"/api/playurl?bvid=BV1HVBAYLEAo&cid={cid}")
print(f"maxQuality={p['maxQuality']} video流={len(p['video'])} audio流={len(p['audio'])}")
for q in p["qualities"]:
print(f" qn={q['quality']:>4} {q['label']} {q.get('width')}x{q.get('height')}")
print(f" 4K 候选: " + ", ".join(
f"{s['quality']}/{s['codecs']}/{s['bandwidth']//1000}kbps" for s in p["video"] if s["quality"] == 120))
+196
View File
@@ -0,0 +1,196 @@
/**
* ui-check.mjs —— 浏览器端验收(Edge 无头 + CDP)
*
* 不只是"页面能打开":真的驱动界面做一次搜索、点选结果、等待清晰度解析,
* 并读取渲染后的 DOM 断言每一处关键内容。
*
* 用法: node tools/ui-check.mjs [baseUrl]
*/
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import WebSocket from '../ui/node_modules/ws/index.js';
const BASE = process.argv[2] || 'http://127.0.0.1:8799/';
const PORT = 9333;
const EDGE_CANDIDATES = [
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
function check(name, ok, detail = '') {
results.push({ name, ok, detail });
console.log(`${ok ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
async function main() {
const fs = await import('node:fs');
const edge = EDGE_CANDIDATES.find((p) => fs.existsSync(p));
if (!edge) throw new Error('找不到 Edge');
const profile = mkdtempSync(join(tmpdir(), 'bd-ui-'));
const child = spawn(
edge,
[
'--headless=new',
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
`--remote-debugging-port=${PORT}`,
`--user-data-dir=${profile}`,
'--window-size=1280,860',
BASE,
],
{ stdio: 'ignore' },
);
let target = null;
for (let i = 0; i < 40 && !target; i++) {
await sleep(500);
try {
const res = await fetch(`http://127.0.0.1:${PORT}/json/list`);
const list = await res.json();
target = list.find((t) => t.type === 'page' && t.url.startsWith('http'));
} catch {
/* 还没起来 */
}
}
if (!target) throw new Error('CDP 目标未就绪');
const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
await new Promise((res, rej) => {
ws.once('open', res);
ws.once('error', rej);
});
let id = 0;
const pending = new Map();
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id && pending.has(msg.id)) {
pending.get(msg.id)(msg);
pending.delete(msg.id);
}
});
const send = (method, params = {}) =>
new Promise((res) => {
const myId = ++id;
pending.set(myId, res);
ws.send(JSON.stringify({ id: myId, method, params }));
});
const evaluate = async (expression) => {
const r = await send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
});
if (r.result?.exceptionDetails) {
throw new Error(r.result.exceptionDetails.exception?.description || 'JS 异常');
}
return r.result?.result?.value;
};
await send('Runtime.enable');
await sleep(3500); // 等 React 挂载 + 首屏取配置/登录状态
console.log(`\n目标: ${BASE}\n`);
// ---------- 1. 首屏渲染 ----------
const text = await evaluate('document.body.innerText');
check('React 已挂载并渲染', !!text && text.length > 60, `${text?.length ?? 0} 字符`);
for (const key of ['BiliDownloader', '搜索站内视频', '未登录', '还没有选择视频', '清晰度', '开始下载', '日志']) {
check(`首屏含「${key}」`, text.includes(key));
}
// ---------- 2. 暗色主题 ----------
const isDark = await evaluate("document.documentElement.classList.contains('dark')");
check('默认深色主题生效', isDark === true);
const bg = await evaluate(
"getComputedStyle(document.body).backgroundColor",
);
check('背景取自 shadcn token(非默认白)', bg !== 'rgb(255, 255, 255)' && bg !== 'rgba(0, 0, 0, 0)', bg);
// ---------- 3. 无横向溢出 ----------
const overflow = await evaluate(
'document.documentElement.scrollWidth - document.documentElement.clientWidth',
);
check('无横向溢出', overflow <= 0, `diff=${overflow}`);
// ---------- 4. 真的搜一次 ----------
console.log('\n —— 驱动一次真实搜索 ——');
await evaluate(`(() => {
const input = document.querySelector('input');
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, '航拍中国');
input.dispatchEvent(new Event('input', { bubbles: true }));
const form = input.closest('form');
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
return true;
})()`);
await sleep(6000);
const afterSearch = await evaluate('document.body.innerText');
const cardCount = await evaluate(
"document.querySelectorAll('button[type=button]').length",
);
check('搜索结果已渲染出卡片', cardCount >= 10, `${cardCount} 个可点卡片`);
// ---------- 5. 点选一个结果 → 清晰度解析 ----------
console.log('\n —— 点选第一个结果,等待清晰度解析 ——');
await evaluate(`(() => {
const btn = document.querySelector('button[type=button]');
btn.click();
return true;
})()`);
await sleep(9000);
const afterPick = await evaluate('document.body.innerText');
check('已进入待下载状态(出现「保存到」)', afterPick.includes('保存到'));
check('详情面板显示 BV 号', /BV[0-9A-Za-z]{10}/.test(afterPick));
check('清晰度已解析(出现「可用清晰度」日志或档位)', /可用清晰度|480P|360P|1080P|4K/.test(afterPick));
const hasStartEnabled = await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(x => x.textContent.includes('开始下载'));
return b ? !b.disabled : null;
})()`);
check('「开始下载」按钮可点', hasStartEnabled === true);
// ---------- 6. 控制台报错 ----------
const errs = await evaluate('window.__bdErrors ? window.__bdErrors.length : 0');
check('页面无致命脚本错误', errs === 0, `${errs} 个`);
console.log('\n —— 渲染文本采样 ——');
console.log(
afterPick
.split('\n')
.filter((l) => l.trim())
.slice(0, 26)
.map((l) => ' ' + l.slice(0, 96))
.join('\n'),
);
ws.close();
child.kill();
try {
rmSync(profile, { recursive: true, force: true });
} catch {}
const failed = results.filter((r) => !r.ok);
console.log(`\n结果: ${results.length - failed.length}/${results.length} 通过`);
if (failed.length) {
console.log('失败项:');
for (const f of failed) console.log(` - ${f.name} ${f.detail}`);
process.exit(1);
}
}
main().catch((e) => {
console.error('验收脚本异常:', e.message);
process.exit(1);
});
+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' },
},
});