chore(product): checkpoint working tree before dandanplay alignment

Includes product polish (auth/dashboard/error pages, i18n, stream/playback fixes), docs site, e2e suites, and related tooling. Excludes local screenshots and caches.
This commit is contained in:
noelorin 2026-09-26 18:36:16 +08:00
parent 289f197a2b
commit 63e760ba36
126 changed files with 7142 additions and 1197 deletions

View File

@ -12,6 +12,10 @@ TURSO_DATABASE_URL=file:local.db
# 媒体挂载凭据加密密钥:64 位 hex(openssl rand -hex 32)
MEDIA_SECRET_KEY=
# 找回密码令牌交付:配置后以 POST JSON { email, username, token, resetPath } 推送到该地址
# (可对接邮件/企业微信/ bark 等任意通知服务);未配置时令牌写入服务日志
PASSWORD_RESET_WEBHOOK_URL=
# 弹弹play 开放弹幕网络(https://doc.dandanplay.com/open)
# 未配置 AppId/AppSecret 时,弹幕接口返回 source=none「未配置开放网络」
OPEN_DANMAKU_APP_ID=

10
.gitignore vendored
View File

@ -30,3 +30,13 @@ storybook-static/
.superpowers/
.worktrees/
worktrees/
playwright-report/
test-results/
# vitepress cache
app/docs/.vitepress/cache/
# local screenshots / fixtures
ui-audit/
tmp-e2e-dav/
generated-*.png
tmp-*.png

View File

@ -1,10 +1,10 @@
# AGENTS.md — app-template 总览
# AGENTS.md — 帆幕(Fanmu)总览
面向 AI 编码代理的仓库导览。修改任何代码前,先读本文件与目标子包的 `AGENTS.md`。
## 项目是什么
**可复用全栈脚手架 / 开发模板**(不绑定产品业务)。只保留鉴权 + `user` 垂直示例,演示完整分层接线。视觉基线见 `DESIGN.md`。
**帆幕(Fanmu)**:私有影音库 · 弹幕观影 · 网盘挂载。本仓库为**产品仓**,业务代码进入既有分层。视觉基线见 `DESIGN.md`。
技术栈:Astro 7(SSR)+ React 19 islands · tRPC v11 · Drizzle ORM + Turso/libSQL · Tailwind CSS 4 · TypeScript 7 · Biome · Yarn 4 workspaces + Turbo。
@ -15,7 +15,7 @@
| `@app/types` | Zod 入参 schema + 共享输出类型 | zod |
| `@app/models` | Drizzle 表定义(唯一 schema 源) | drizzle-orm |
| `@app/db` | libSQL/Turso 客户端单例 + drizzle-kit | models |
| `@app/dao` | 数据访问层(`userDao` 等) | db, models |
| `@app/dao` | 数据访问层(`userDao` / `mountDao` / `mediaItemDao` 等) | db, models |
| `@app/trpc` | tRPC 路由 + service 业务逻辑(调用 DAO) | types, dao |
| `@app/web` | Astro 前端 + React islands + 本地 tRPC HTTP 适配 | trpc, types, design-tokens, seo-geo, i18n, ui |
| `@app/ui` | shadcn 风格基础 UI 组件(源码直出 + Storybook) | design-tokens, react (peer) |
@ -23,6 +23,7 @@
| `@app/seo-geo` | SEO/GEO 纯函数(meta、sitemap、JSON-D、llms.txt) | zod, schema-dts, escape-html, fast-xml-builder, serialize-javascript, mdast-util-to-markdown |
| `@app/i18n` | 文案与 locale 纯函数(zh-CN / en、t()、Accept-Language) | — |
| `@app/adsense` | AdSense 脚本加载 + `AdSlot` React 组件 | react (peer) |
| `@app/docs` | 产品文档站(VitePress,中文根路径 + 英文 `/en`) | —(仅 vitepress) |
依赖方向(只允许向下):
@ -35,6 +36,8 @@ web ──► trpc ──► dao ──► db ──► models
├──► i18n
├──► adsense
└──► ui
docs(独立,不依赖上述业务包)
```
禁止循环依赖。跨包只通过 `package.json` 的 `exports` 入口引用,不要 deep import。
@ -43,8 +46,10 @@ web ──► trpc ──► dao ──► db ──► models
- 源码在各包 `src/`;构建产物在 `dist/`(git 忽略,勿手改)。
- 环境变量模板见 `.env.example`:`TURSO_DATABASE_URL`、`TURSO_AUTH_TOKEN`、`PUBLIC_SITE_URL`。
- 根 `.env` 由 `packages/db/drizzle.config.ts` 手动加载(drizzle-kit 在 monorepo 中不会自动读根 env)。
- **模板不掺杂业务逻辑**:禁止把产品领域实体/页面写进本仓库;下游业务仓库再扩展。
- 根 `.env` 由 `packages/db/drizzle.config.ts` 与 `packages/db/scripts/seed.ts` 手动加载(monorepo 下工具不会自动读根 env)。
- **分层不跳层**:业务实体按 types → models → dao → trpc → web 落地;领域逻辑进 service,SQL 进 dao。
- `e2e/`:Playwright 端到端测试(配置在根 `playwright.config.ts`),不是 workspace 包。
- `app/docs/`:VitePress 文档站源码(`.vitepress/` 配置 + `guide/` 中文 + `en/guide/` 英文)。
## 常用命令
@ -56,9 +61,14 @@ yarn build # 全仓 build(依赖拓扑)
yarn build:web # 仅 @app/web + 上游依赖闭包(日常/CI 更快)
yarn dev:web # Astro :4321
yarn dev:api # 独立 tRPC :4000(web 内嵌 /api/trpc 时通常不需要)
yarn docs:dev # VitePress 文档站
yarn docs:build
yarn test:e2e # Playwright(自动起 webServer)
yarn db:generate # Drizzle 迁移生成
yarn db:push # 推 schema
yarn db:seed # 初始化默认管理员(幂等)
yarn db:studio
yarn storybook # @app/ui Storybook :6006
```
改某个包时可先 `yarn workspace @app/<name> typecheck`。
@ -73,7 +83,7 @@ yarn db:studio
6. **UI**:设计令牌从 `@app/design-tokens` 引入;组件优先用 `@app/ui`(shadcn 风格,源码在 `packages/ui`)+ `cn()`。视觉遵循 `DESIGN.md`。
7. **构建顺序**:改 `types`/`models` 后需先 build 上游,再让下游 typecheck(Turbo `dependsOn: ["^build"]` 已配置)。
8. **注释**:只写非显而易见的 WHY;默认不写注释。
9. **新增业务实体**:复制 `user` 垂直切片(types → models → dao → trpc → web),不要跳层。
9. **新增业务实体**:按 types → models → dao → trpc → web 分层落地,不要跳层。
## 各包入口
@ -90,3 +100,4 @@ yarn db:studio
- [packages/i18n/AGENTS.md](packages/i18n/AGENTS.md)
- [packages/adsense/AGENTS.md](packages/adsense/AGENTS.md)
- [packages/ui/AGENTS.md](packages/ui/AGENTS.md)
- [app/docs/AGENTS.md](app/docs/AGENTS.md)

View File

@ -1,4 +1,4 @@
# 弹弹 Web — 设计方向
# 帆幕(Fanmu)— 设计方向
产品视觉基线:**shadcn/ui Dashboard**(媒体五页侧栏/顶栏导航、卡片网格、中性灰阶);模板深空工业 token 仍可用于非媒体页,但 **`@app/design-tokens` 色板 token 路径约定不变**(改值可以,改结构需同步全局)。
@ -16,7 +16,7 @@
| 墨色 | `#F2F2F2` |
| 弱化文字 | `#999999` |
| 更弱文字 | `#666666` |
| 强调(信号黄) | `#FFFA00` |
| 强调(品牌金黄) | `#FFB300` |
| 信号绿 | `#00FFA2` |
| 信号品红 | `#FF1AAC` |
| 亮色背景(对照) | `#FAFAFA` |

View File

@ -14,7 +14,8 @@ RUN npm install -g corepack && corepack enable
FROM base AS deps
COPY package.json yarn.lock .yarnrc.yml ./
COPY app/web/package.json app/web/package.json
# workspaces: packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链
# workspaces: app/* + packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链
COPY app/docs/package.json app/docs/package.json
COPY packages/adsense/package.json packages/adsense/package.json
COPY packages/dao/package.json packages/dao/package.json
COPY packages/db/package.json packages/db/package.json

View File

@ -1,6 +1,6 @@
# 弹弹 Web — 本地片源 · 在线弹幕
# 帆幕 — 私有影音库 · 弹幕观影 · 网盘挂载
多用户 Web 媒体中心:WebDAV/OpenList 挂载、媒体库扫描与 Bangumi 刮削、ArtPlayer 播放与弹幕(开放网络 + 本地 XML)。
**帆幕(Fanmu)** 是自建私有影音库:WebDAV/OpenList 挂载片源、媒体库扫描与 Bangumi 刮削、ArtPlayer 播放与弹幕(开放网络 + 本地 XML),多用户进度与权限。
基于 monorepo 分层(types → models → db → dao → trpc → web),本仓库按**产品仓**维护。
@ -49,4 +49,4 @@ yarn dev:web # Astro :4321
`web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。
新增业务实体请复制 `user` 垂直切片:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。
新增业务实体请按既有分层接线:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。

View File

@ -0,0 +1,160 @@
import { defineConfig } from "vitepress";
const zhNav = [
{ text: "是什么", link: "/guide/what-is" },
{ text: "功能", link: "/guide/library" },
{ text: "上手", link: "/guide/quick-start" },
{ text: "问题", link: "/guide/faq" },
];
const zhSidebar = [
{
text: "了解帆幕",
items: [
{ text: "帆幕是什么", link: "/guide/what-is" },
{ text: "快速上手", link: "/guide/quick-start" },
],
},
{
text: "怎么用",
items: [
{ text: "连网盘", link: "/guide/mount" },
{ text: "找视频", link: "/guide/browse" },
{ text: "媒体库", link: "/guide/library" },
{ text: "看片和弹幕", link: "/guide/danmaku" },
{ text: "认出是哪部剧", link: "/guide/scrape" },
{ text: "账号和进度", link: "/guide/account" },
],
},
{
text: "帮助",
items: [{ text: "常见问题", link: "/guide/faq" }],
},
];
const enNav = [
{ text: "About", link: "/en/guide/what-is" },
{ text: "Features", link: "/en/guide/library" },
{ text: "Get started", link: "/en/guide/quick-start" },
{ text: "FAQ", link: "/en/guide/faq" },
];
const enSidebar = [
{
text: "About Fanmu",
items: [
{ text: "What is Fanmu", link: "/en/guide/what-is" },
{ text: "Get started", link: "/en/guide/quick-start" },
],
},
{
text: "How to use",
items: [
{ text: "Mount a drive", link: "/en/guide/mount" },
{ text: "Find a video", link: "/en/guide/browse" },
{ text: "Library", link: "/en/guide/library" },
{ text: "Watch and danmaku", link: "/en/guide/danmaku" },
{ text: "Identify the show", link: "/en/guide/scrape" },
{ text: "Accounts and progress", link: "/en/guide/account" },
],
},
{
text: "Help",
items: [{ text: "FAQ", link: "/en/guide/faq" }],
},
];
// 文档站:VitePress;产品说明文档,中文在根路径,英文在 /en
export default defineConfig({
cleanUrls: true,
lastUpdated: true,
head: [["link", { rel: "icon", href: "/favicon.svg" }]],
locales: {
root: {
label: "简体中文",
lang: "zh-CN",
link: "/",
title: "帆幕文档",
description: "帆幕 — 自己的影音库,看片有弹幕。挂网盘、播视频、飘弹幕。",
themeConfig: {
nav: zhNav,
sidebar: zhSidebar,
outline: { level: [2, 3], label: "本页目录" },
search: {
provider: "local",
options: {
translations: {
button: { buttonText: "搜索", buttonAriaLabel: "搜索文档" },
modal: {
noResultsText: "未找到结果",
resetButtonTitle: "清除查询",
footer: {
selectText: "选择",
navigateText: "切换",
closeText: "关闭",
},
},
},
},
},
docFooter: { prev: "上一篇", next: "下一篇" },
darkModeSwitchLabel: "主题",
sidebarMenuLabel: "菜单",
returnToTopLabel: "回到顶部",
langMenuLabel: "切换语言",
lastUpdatedText: "更新于",
notFound: {
title: "页面不存在",
quote: "这里什么都没有",
linkLabel: "回首页",
linkText: "回首页",
},
},
},
en: {
label: "English",
lang: "en",
link: "/en/",
title: "Fanmu Docs",
description:
"Fanmu — your own media library with danmaku. Mount a drive, play video, watch comments fly.",
themeConfig: {
nav: enNav,
sidebar: enSidebar,
outline: { level: [2, 3], label: "On this page" },
search: {
provider: "local",
options: {
translations: {
button: { buttonText: "Search", buttonAriaLabel: "Search docs" },
modal: {
noResultsText: "No results",
resetButtonTitle: "Clear query",
footer: {
selectText: "Select",
navigateText: "Navigate",
closeText: "Close",
},
},
},
},
},
docFooter: { prev: "Previous", next: "Next" },
darkModeSwitchLabel: "Theme",
sidebarMenuLabel: "Menu",
returnToTopLabel: "Back to top",
langMenuLabel: "Change language",
lastUpdatedText: "Updated",
notFound: {
title: "Page not found",
quote: "Nothing lives at this address",
linkLabel: "Go home",
linkText: "Go home",
},
},
},
},
markdown: {
lineNumbers: true,
},
});

View File

@ -0,0 +1,11 @@
/**
* VitePress 主题:使用默认品牌色与明暗切换。
* 仅保留排版微调,不覆盖 --vp-c-* 品牌语义变量。
*/
:root {
--vp-font-family-base:
"Space Grotesk", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--vp-font-family-mono:
"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

View File

@ -0,0 +1,18 @@
import DefaultTheme from "vitepress/theme";
import type { Theme } from "vitepress";
// @ts-expect-error vitepress 会处理 css 导入
import "./custom.css";
/** 无本地偏好时默认暗色(与主应用一致),仍可手动切换亮/暗。 */
const initTheme = `try { const k='vitepress-theme-appearance'; if(!localStorage.getItem(k)) localStorage.setItem(k,'dark'); } catch {}`;
export default {
extends: DefaultTheme,
enhanceApp() {
if (typeof document !== "undefined") {
const s = document.createElement("script");
s.textContent = initTheme;
document.head.prepend(s);
}
},
} satisfies Theme;

48
app/docs/AGENTS.md Normal file
View File

@ -0,0 +1,48 @@
# AGENTS.md — @app/docs
## 职责
**产品文档站**(VitePress):面向用户的使用说明,中文在根路径、英文在 `/en`。与 `@app/web` 产品页分离——这里是文档,不是应用路由。
不依赖 `@app/*` 业务包;纯 Markdown + VitePress 配置,无 tRPC / DB。
## 结构
```
.vitepress/
config.mts # locales(root=zh-CN, en)、nav、sidebar、搜索文案
theme/index.ts # 扩展默认主题;无本地偏好时默认暗色(与主应用一致)
theme/custom.css # 自定义样式
guide/ # 中文文档(权威源)
what-is.md | quick-start.md | mount.md | browse.md
library.md | danmaku.md | scrape.md | account.md | faq.md
en/guide/ # 英文文档(与 guide/ 同构,逐篇对应)
index.md # 中文首页
en/index.md # 英文首页
public/ # 静态资源(favicon 等)
```
## 约定
- **双语同构**:`guide/*.md` 与 `en/guide/*.md` 文件名一一对应;新增页面两边同时加,并在 `config.mts` 的 `zhSidebar` / `enSidebar` 登记。
- 路径用 `cleanUrls: true`(链接不带 `.html`)。
- 文案语气与产品一致(「帆幕」/「Fanmu」);中文文档用简体中文,不混繁体。
- 搜索为 VitePress local provider,UI 文案在 `config.mts` 的 `themeConfig.search` 内联翻译,不另建 i18n 包。
- 主题默认暗色:`theme/index.ts` 在无 `vitepress-theme-appearance` 时写入 `dark`,与 web 的 `app-theme` 偏好独立。
## 修改指南
- **加一页文档**:`guide/<slug>.md` + `en/guide/<slug>.md` → 更新 `config.mts` 两侧 sidebar。
- **改导航顺序**:只改 `config.mts` 的 `zhNav`/`zhSidebar`/`enNav`/`enSidebar`,保持中英结构对称。
- **加静态资源**:放 `public/`,引用用绝对路径 `/xxx`。
- 不要引入 Vue 组件或自定义布局(当前只用默认主题 + CSS);确需扩展时走 `theme/index.ts`。
- 构建产物 `.vitepress/dist/` 与缓存 `.vitepress/cache/` 勿手改。
## 命令
```bash
yarn docs:dev # 本地预览
yarn docs:build # 构建到 .vitepress/dist/
yarn workspace @app/docs typecheck
yarn workspace @app/docs lint
```

View File

@ -0,0 +1,31 @@
# Accounts and progress
Fanmu supports more than one person.
## One account each
- Register your own account
- Each person has their own library and watch progress
- Nobody sees the other's list
The admin (from `db:seed`) can manage users.
## Progress follows the person
Zhang San is 12 minutes in. Li Si opens the same file and starts from zero.
Switching devices is fine too: watch on the phone, continue on the desktop, same account.
## Forgot password
Use "Forgot password" on the login page. If mail is not configured, ask the admin to reset it.
## Default account
Local `db:seed` creates:
| User | Password |
|------|----------|
| admin | admin123 |
**That password is public. Change it before the app faces the internet.**

View File

@ -0,0 +1,27 @@
# Find a video
Once the drive is mounted, just browse.
## How
1. Open **Browse**
2. Pick a mount
3. Open folders like a file manager
Each file can be **played** directly or **added to the library**.
## Cannot find anything?
- **Empty folder**: check the [mount](/en/guide/mount) connection
- **Will not play**: browsers handle mp4 and webm best. mkv depends on the browser. Convert the file or try another browser
- **Ugly file names**: fine for playback. For posters and titles, [scan into the library](/en/guide/library) or [identify the show](/en/guide/scrape)
## Browse vs library
| | Browse | Library |
|--|--------|---------|
| What you see | Real folders on the drive | A tidy show list |
| Good for | Quick look | Regular shows, resume |
| Source | Mounted files | Scanned items |
Both can play. Browse is the file view; library is the show view.

View File

@ -0,0 +1,46 @@
# Watch and danmaku
Open a file and you are on the player page.
## Playback
It is a web player. Play/pause, seek, volume, fullscreen are all there.
Leave halfway and progress is kept.
## Where danmaku comes from
Same idea as DanDanPlay: **one episode, one danmaku pool**.
1. You open a video
2. The system knows which show and episode it is (or you identified it earlier)
3. It asks the danmaku network for that episode
4. Danmaku floats over the video
So a 720P from one group and a 1080P from another, once matched to the same episode, share the same danmaku.
## No danmaku?
- **Unmatched**: [identify the show](/en/guide/scrape) first
- **Network down**: video still plays, just silent of danmaku. Retry later
- **Prefer a local file**: import an XML
### Import XML danmaku
On the player or the [Danmaku settings](#danmaku-settings) page, import a `.xml` danmaku file (the kind exported from Bilibili).
A video and a same-named danmaku file in one folder is also picked up.
## Danmaku settings
The **Danmaku** page lets you set:
- On or off by default
- Opacity (lower it if it blocks the picture)
- Density (how many at once on screen)
You can also toggle while watching.
## A note on content
Danmaku comes from a public pool. If something bothers you, turn it off. Fanmu does not edit danmaku text.

56
app/docs/en/guide/faq.md Normal file
View File

@ -0,0 +1,56 @@
# FAQ
## Playback
**Black screen / will not play?**
Browsers do not all handle mkv or ts. Try another browser, or convert to mp4. It may also be a broken mount — test the connection.
**Seeking? Quality switching?**
You can seek. There is no quality switch: the file on the drive is what plays.
**Progress not saved?**
A flaky network can drop progress reports. Drag the slider if a spot is missing.
## Danmaku
**None at all?**
Work through this:
1. Was the video identified? If not, [identify it](/en/guide/scrape)
2. Is the match correct? A wrong match pulls the wrong pool
3. Is the danmaku network up?
4. Still nothing? [Import XML](/en/guide/danmaku)
**Too dense?**
Lower opacity and density, or turn it off.
**Can I send danmaku?**
Yes. It goes to the public pool.
## Mounts
**Test connection fails?**
- Use the WebDAV root, not a web page URL
- For OpenList, use the WebDAV endpoint
- Check username, password, and the HTTPS certificate
**Why not SMB / Emby?**
v1 is WebDAV only. More later if there is demand.
## Accounts
**Is email required?**
Depends on your deployment. Locally, the seeded admin is enough.
**Can I see what others watched?**
No. Progress is private.

View File

@ -0,0 +1,37 @@
# Library
Do not want to dig through folders every time? Scan shows into the library.
## What it looks like
A grid of posters with titles. Open one and you get the episode list. Watched episodes show progress; one click resumes.
## How items get in
1. **Add to library** on a file in Browse
2. **Scan** on a mount — takes every video under it
A scan will:
- Record the video files
- Guess show and episode from the name
- Look up title and poster on Bangumi
- Mark anything it cannot place as "unmatched" — it will not block playback
## After the scan
| You see | Meaning |
|---------|---------|
| Poster and title | Match worked |
| Unmatched | Name was unreadable, or Bangumi had no hit |
| Continue | Resume from saved progress |
## Wrong match?
Fix it in [Identify the show](/en/guide/scrape).
## Progress
Playback position is saved as you watch. Next time, continue.
Progress is per person. Zhang San and Li Si do not share it. See [Accounts](/en/guide/account).

View File

@ -0,0 +1,41 @@
# Mount a drive
Videos on a cloud drive? Mount it into Fanmu first.
## What is supported
**WebDAV**.
OpenList (formerly AList) works through its WebDAV endpoint — there is no separate OpenList API.
SMB, Emby, and OneDrive are not supported yet.
## How
1. Open **Mounts** in the sidebar
2. Add a mount and fill in:
| Field | Meaning |
|-------|---------|
| Name | Anything, e.g. "My NAS" |
| URL | WebDAV root, like `https://nas.example.com/dav` |
| Username | Drive account (optional) |
| Password | Drive password (optional) |
| Root path | Only look under this folder (empty = whole root) |
3. Click **Test connection**, then save
## Is the password safe
The password is **encrypted on the server**. The browser never sees the plaintext. Streams also go through the server, so your browser does not talk to the drive with your password.
## If it fails
- Cannot connect: check that the URL is the WebDAV root, and that the account and HTTPS cert are fine
- Connects but empty: the root path may be too deep
- Do not need it anymore: disable or delete the mount
## Next
- [Find a video](/en/guide/browse)
- [Scan into the library](/en/guide/library)

View File

@ -0,0 +1,75 @@
# Get started
Goal: a running app and a login screen in a few minutes.
## What you need
- Node.js 22 or newer
- Yarn 4 (enable via Corepack)
- No database server; local SQLite is the default
## Four steps
### 1. Install
```bash
corepack enable
yarn install
```
### 2. Config
```bash
cp .env.example .env
```
Local defaults are fine. `TURSO_DATABASE_URL` already points at `file:local.db`.
To mount a drive you also need a secret (used to encrypt saved drive passwords):
```bash
openssl rand -hex 32
```
Put it in `.env` as `MEDIA_SECRET_KEY`.
### 3. Database and admin
```bash
yarn db:push
yarn db:seed
```
`db:seed` creates **admin / admin123**. Change that password before going public.
### 4. Run
```bash
yarn dev:web
```
Open `http://localhost:4321`. English is at `/en`, Chinese at `/zh-CN`.
After login you should see:
- **Library** — your shows
- **Browse** — folders on the drive
- **Mounts** — drive connections
- **Danmaku** — on/off and style
- **Watch** — the player page
## Next
1. [Mount a drive](/en/guide/mount)
2. [Play something](/en/guide/browse)
3. [Scan into the library](/en/guide/library)
## Commands
| Command | Does |
|---------|------|
| `yarn dev:web` | Dev server on port 4321 |
| `yarn build:web` | Build the web app |
| `yarn docs:dev` | Preview these docs |
| `yarn typecheck` | Type check |
| `yarn lint` | Lint |

View File

@ -0,0 +1,43 @@
# Identify the show
Danmaku and posters both depend on knowing **what this is**. Get this right and the rest is easy.
## Automatic
On scan or add-to-library, the file name is used to guess:
- Which show
- Which episode
Then Fanmu looks it up on **Bangumi** for title, poster, and episode number.
## If the guess is wrong
The item is marked **unmatched**.
Nothing is deleted. Playback still works. You can leave it, or bind it by hand.
## Fix it by hand
Open the item:
1. Search for the show on Bangumi
2. Pick the right one
3. Confirm
You can re-scrape after binding to refresh posters and metadata.
## Why Bangumi
Bangumi has solid anime data and a real API. Not Douban, not TMDB.
If a niche title is missing there, you can only hand-edit the name, and danmaku may not match. Use [local XML danmaku](/en/guide/danmaku) as fallback.
## Common cases
| Case | What to do |
|------|------------|
| Name like `VCB-Studio][BanG Dream! Ave Mujica][01][1080p][x265_FLAC].mkv` | Usually auto-matched |
| Name like `video.mp4` | Search manually |
| Matched the wrong show | Rebind |
| OVA / special / movie | Episode number may be off; fix it |

View File

@ -0,0 +1,45 @@
# What is Fanmu
Fanmu is a **self-hosted media library**.
Your videos live on a WebDAV drive (OpenList included). Mount that drive in Fanmu and you can play in a browser, with danmaku on top.
## Why this exists
Watching a local or cloud file has one old problem: **no danmaku**.
DanDanPlay solved this for desktop: play a file from disk, get matching online danmaku. Fanmu does the same idea in a browser:
- Nothing to install on every machine
- Phone, tablet, TV browser all work
- Files stay in one place on the drive
## What it can do
- **Mount WebDAV** (OpenList uses its WebDAV endpoint)
- **Play video** in the browser; progress is saved
- **Danmaku**: matched from the open danmaku network; local XML as fallback
- **Library**: scan in files, posters, resume where you left off
- **Accounts**: each person has their own list and progress
## How it relates to DanDanPlay
| | DanDanPlay | Fanmu |
|--|------------|-------|
| Where it runs | Desktop app | In the browser |
| Where files live | Local disk | WebDAV drive |
| Danmaku | Open danmaku network | Same network |
| Install client | Yes | No |
Fanmu is not a plugin and does not need DanDanPlay running on your PC. It stands on its own: **video from your drive, danmaku from the web**.
## Not in v1
Present in DanDanPlay, not here yet:
- BT download / RSS
- AI subtitles and summaries
- Cast, frame interpolation
- Emby / Jellyfin / Plex (WebDAV only for now)
The main path first: mount → find → play → danmaku → progress.

55
app/docs/en/index.md Normal file
View File

@ -0,0 +1,55 @@
---
layout: home
hero:
name: Fanmu
text: Your own media library, with danmaku
tagline: Mount a cloud drive, play video in the browser, and get online danmaku on top. No desktop client needed.
actions:
- theme: brand
text: What it does
link: /en/guide/what-is
- theme: alt
text: Get started
link: /en/guide/quick-start
features:
- title: Mount and play
details: Hook up a WebDAV drive and play right in the browser. The password stays on the server.
- title: Video with danmaku
details: Danmaku is matched automatically, the same way DanDanPlay does for local files. You can also import a local XML file.
- title: Knows what you are watching
details: Scans figure out the show and episode, then pull in posters and metadata. Fix a wrong match by hand if needed.
- title: Built for households
details: Multiple accounts. Everyone gets their own library and watch progress.
---
## What is this
**Fanmu** is a media library you host yourself.
Your videos live on a cloud drive (WebDAV, including OpenList's WebDAV endpoint). Mount it in Fanmu and play in the browser, with danmaku.
The idea is close to DanDanPlay: local video plus online danmaku. Fanmu just runs in the browser, so you do not install a player on every machine.
## What you can do
| Task | Guide |
|------|-------|
| [Mount a drive](/en/guide/mount) | Connect WebDAV |
| [Find a video](/en/guide/browse) | Browse folders and play |
| [Library](/en/guide/library) | Scan in shows, posters, resume |
| [Watch and danmaku](/en/guide/danmaku) | Playback, danmaku, styling |
| [Identify the show](/en/guide/scrape) | Auto match, or fix it yourself |
| [Accounts](/en/guide/account) | Sign in, private progress |
## Run it
```bash
yarn install
cp .env.example .env
yarn db:push && yarn db:seed
yarn dev:web
```
Open `http://localhost:4321` — English lives at `/en`. More detail in [Get started](/en/guide/quick-start).

31
app/docs/guide/account.md Normal file
View File

@ -0,0 +1,31 @@
# 账号和进度
帆幕支持多个人用。
## 一人一个账号
- 注册自己的账号
- 各人有自己的媒体库、挂载(看部署怎么配)、观看进度
- 互不干扰
管理员(`db:seed` 建的那个)可以管用户。
## 进度跟人走
张三看到第 12 分钟,李四打开同一个视频,还是从头开始。
换设备也一样:手机看一半,电脑上接着看(只要登的是同一个账号)。
## 忘了密码
登录页有「忘记密码」流程。如果邮件功能没配好,就让管理员帮你重置。
## 默认账号
本地开发 `db:seed` 会建:
| 账号 | 密码 |
|------|------|
| admin | admin123 |
**这个密码是公开的,开到公网前一定要改。**

27
app/docs/guide/browse.md Normal file
View File

@ -0,0 +1,27 @@
# 找视频
挂好网盘之后,就可以翻文件夹了。
## 怎么找
1. 打开侧栏 **浏览**
2. 选一个挂载
3. 一层层点进去,像用文件管理器一样
每行文件可以直接 **播放**,也可以 **加入媒体库**。
## 还是找不到?
- **文件夹是空的**:先去[挂载](/guide/mount)看看连接通不通
- **视频格式播不了**:浏览器认 mp4、webm 比较稳;mkv 有的浏览器能播有的不行。播不了的话,可以先把文件转一下,或者换个浏览器试试
- **名字乱七八糟**:不影响播。想要好看的海报和标题,去[媒体库](/guide/library)扫描,或[手动认剧](/guide/scrape)
## 和媒体库什么关系
| | 浏览 | 媒体库 |
|--|------|--------|
| 看的是什么 | 网盘真实文件夹 | 整理过的片单 |
| 适合 | 随便翻、临时看一眼 | 常看的剧、接着看 |
| 来源 | 挂载里的文件 | 扫描进来的 |
两条路都能播。浏览是「文件视角」,媒体库是「剧集视角」。

48
app/docs/guide/danmaku.md Normal file
View File

@ -0,0 +1,48 @@
# 看片和弹幕
点开一个视频,就是播放页。
## 播放
播放器是网页里的,不用装东西。基本操作(播放/暂停、进度、音量、全屏)都有。
看一半关掉不要紧,进度记住了,下次接着看。
## 弹幕从哪来
和弹弹play一个思路:**同一集,用同一份弹幕**。
流程是这样:
1. 你打开一个视频
2. 系统猜这是哪部剧第几集(或者你之前已经认过了)
3. 拿这个「身份」去弹幕网络要弹幕
4. 弹幕飘在视频上
所以字幕组 A 的 720P 和字幕组 B 的 1080P,只要认成同一集,弹幕就是同一份。
## 没有弹幕怎么办
- **识别失败**:先去[认剧](/guide/scrape)手动认一下
- **弹幕网络挂了**:照样能看视频,只是没弹幕。过会儿再试
- **就是想要本地弹幕**:导入 XML 文件
### 导入 XML 弹幕
播放页或[弹幕设置](#弹幕设置)里可以导入 `.xml` 弹幕文件(B 站导出的那种)。
同名的视频和弹幕文件放在一个文件夹里,也认得出来。
## 弹幕设置
侧栏 **弹幕** 页面可以调:
- 默认开还是关
- 透明度(怕挡画面就调淡一点)
- 密度(一屏幕最多飘多少条)
播放的时候也能随时开关。
## 弹幕礼仪
弹幕内容来自公开弹幕库。看到了不舒服的,关掉弹幕就好。帆幕不存储、不修改弹幕内容。

66
app/docs/guide/faq.md Normal file
View File

@ -0,0 +1,66 @@
# 常见问题
## 播放相关
**视频点开是黑的 / 播不了?**
浏览器不一定认得 mkv、ts 这些格式。换个浏览器试试,或者把视频转成 mp4。也可能是网盘断了,去挂载页测一下连接。
**能快进吗?能选清晰度吗?**
能快进。清晰度跟文件走——你网盘里是什么文件,就播什么,没有「切换清晰度」这一说。
**进度没记上?**
网络不好的时候进度上报会失败。正常看一般没事;如果某次没记上,手动拖一下就好。
## 弹幕相关
**完全没有弹幕?**
按这个顺序查:
1. 这个视频认成剧了吗?没认成就[手动认](/guide/scrape)
2. 认对了吗?认错了弹幕就是别家的
3. 弹幕网络通不通?挂了就等一会儿
4. 实在没有,[导入 XML 弹幕](/guide/danmaku)
**弹幕太多挡脸?**
弹幕设置里调透明度和密度,或者直接关掉。
**能发弹幕吗?**
能,发出去的是公共弹幕库,大家都能看到。
## 挂载相关
**测试连接失败?**
- 地址要写 WebDAV 根,不是网页地址
- OpenList 用它的 WebDAV 端点
- 用户名密码要对
- HTTPS 证书要有效
**为什么不是 SMB / Emby?**
第一版只做 WebDAV。其他以后看需求。
## 账号相关
**注册要邮箱吗?**
看部署怎么配的。本地开发用 seed 的管理员就行。
**能看到别人看了什么吗?**
不能。各人进度各人自己看。
## 还有别的问题
到仓库提 issue,附上:
- 你干了什么
- 期望发生什么
- 实际发生什么
- 截图或报错文字

39
app/docs/guide/library.md Normal file
View File

@ -0,0 +1,39 @@
# 媒体库
不想每次都翻文件夹?把片子扫进媒体库,以后按剧找。
## 它长什么样
媒体库是一排海报卡片,写着剧名。点进去是这一部的分集列表,看过一半的会显示进度,点一下就能接着看。
## 怎么进来
两种方式:
1. **浏览页里点「加入库」**:加单个文件
2. **挂载页里点「扫描」**:把这个挂载下视频都扫一遍
扫描会做这些事:
- 把视频文件记下来
- 猜这是哪部剧第几集
- 去 Bangumi 查剧名、海报
- 查不到就标成「未识别」,不会丢,也不会挡住你播
## 扫完之后
| 你会看到 | 说明 |
|----------|------|
| 海报、标题 | 识别成功的话会自动填 |
| 未识别 | 文件名猜不出来,或 Bangumi 查不到 |
| 继续播放 | 有进度的条目,点进去接着看 |
## 认错了怎么办
去[认剧](/guide/scrape)手动改绑。
## 进度怎么记
每看到一处,会自动记住看到哪了。下次打开同一个视频,接着来。
进度跟人走:张三和李四各记各的,互不影响。见[账号](/guide/account)。

41
app/docs/guide/mount.md Normal file
View File

@ -0,0 +1,41 @@
# 连网盘
想看的片子在网盘里?先把它「挂」到帆幕上。
## 目前支持什么
**WebDAV**。
OpenList(以前的 AList)也用它的 WebDAV 地址,不要去找什么「OpenList 专用接口」。
其他(SMB、Emby、OneDrive 直连)暂时不支持。
## 怎么挂
1. 打开侧栏 **挂载**
2. 点添加,填这些:
| 填什么 | 说明 |
|--------|------|
| 名称 | 随便起,比如「我的NAS」 |
| 地址 | WebDAV 根地址,形如 `https://nas.example.com/dav` |
| 用户名 | 网盘账号(可空) |
| 密码 | 网盘密码(可空) |
| 根路径 | 只看这个目录下面(可空,表示整个根) |
3. 先点 **测试连接**,通了再保存
## 密码安全吗
密码发到服务器后会**加密存起来**,浏览器拿不到明文。每次去网盘拉文件,也是服务器出面,你的浏览器不直接碰网盘密码。
## 挂错了怎么办
- 连不上:检查地址是不是 WebDAV 根,用户名密码对不对,HTTPS 证书有没有问题
- 能连上但没文件:看看「根路径」是不是填太深了
- 不想用了:挂载页里可以停用或删掉
## 下一步
- [在网盘里找视频](/guide/browse)
- [扫一遍进媒体库](/guide/library)

View File

@ -0,0 +1,75 @@
# 快速上手
目标:几分钟内跑起来,登录进去看到界面。
## 你需要什么
- Node.js 22 以上
- Yarn 4(用 Corepack 打开就行)
- 不用装数据库软件,默认用本地 SQLite 文件
## 四步跑起来
### 1. 装依赖
```bash
corepack enable
yarn install
```
### 2. 准备配置
```bash
cp .env.example .env
```
本地开发一般不用改什么。`TURSO_DATABASE_URL` 默认就是 `file:local.db`,数据存在当前目录。
如果要挂网盘,还需要一个密钥(用来加密你保存的网盘密码):
```bash
openssl rand -hex 32
```
把结果填进 `.env` 的 `MEDIA_SECRET_KEY`。
### 3. 建库、建管理员
```bash
yarn db:push
yarn db:seed
```
`db:seed` 会建一个管理员:**admin / admin123**。上线前记得改密码。
### 4. 启动
```bash
yarn dev:web
```
打开 `http://localhost:4321`,走中文入口在 `/zh-CN`,英文在 `/en`。
登录后你应该能看到这些页面:
- **媒体库**:你的片子
- **浏览**:网盘里的文件夹
- **挂载**:管理网盘连接
- **弹幕**:弹幕开关和样式
- **播放**:点开视频后的页面
## 接下来干什么
1. [把网盘挂上来](/guide/mount)
2. [找个视频播一下](/guide/browse)
3. 想要整齐的片单就[扫进媒体库](/guide/library)
## 常用命令
| 命令 | 干什么 |
|------|--------|
| `yarn dev:web` | 开发模式跑主应用(4321 端口) |
| `yarn build:web` | 打包主应用 |
| `yarn docs:dev` | 本地预览这份文档 |
| `yarn typecheck` | 检查类型 |
| `yarn lint` | 检查代码风格 |

46
app/docs/guide/scrape.md Normal file
View File

@ -0,0 +1,46 @@
# 认出是哪部剧
弹幕和海报,都靠「认出这是什么」。这一步做对了,后面都省事。
## 自动认
扫描或加入媒体库时,会看文件名,猜:
- 这是哪部作品
- 这是第几集
然后去 **Bangumi** 查资料,把标题、海报、剧集号填上。
## 猜不准会怎样
标成 **未识别**。
不会删文件,不会不让你播,只是先不配弹幕、不配海报。你可以:
- 留着不管,直接播
- 手动绑一下(见下)
## 手动改
打开这个条目的详情:
1. 搜索 Bangumi 上的剧名
2. 选中正确的那一部
3. 确认绑定
绑完可以再刮一次(重新拉海报和信息)。
## 为什么用 Bangumi
Bangumi 的动画资料比较全,而且有 API。不是豆瓣,也不是 TMDB。
如果某部冷门作品 Bangumi 上没有,那就只能手动填名字,弹幕也匹配不到——这种情况下用[本地 XML 弹幕](/guide/danmaku)兜底。
## 常见情况
| 情况 | 该怎么办 |
|------|----------|
| 文件名是 `VCB-Studio][BanG Dream! Ave Mujica][01][1080p][x265_FLAC].mkv` | 一般能自动认出来 |
| 文件名是 `视频.mp4` | 认不出来,手动搜 |
| 认成了别的剧 | 手动改绑 |
| 特典 / OVA / 剧场版 | 集数可能对不上,手动调 |

45
app/docs/guide/what-is.md Normal file
View File

@ -0,0 +1,45 @@
# 帆幕是什么
帆幕是一个**自己架的影音库**。
你的视频存在网盘里(比如 OpenList、支持 WebDAV 的网盘)。挂载到帆幕之后,打开浏览器就能播,而且能飘弹幕。
## 为什么要这个东西
看本地片或网盘片有个老问题:**没有弹幕**。
弹弹play 解决过这个问题——播放硬盘里的视频,自动配上网络弹幕。帆幕的想法差不多,只是跑在浏览器里:
- 不用在每台电脑上装客户端
- 手机、平板、电视浏览器都能开
- 片子集中存在网盘,不用到处拷
## 能干什么
- **连网盘**:支持 WebDAV(OpenList 也走它的 WebDAV)
- **看视频**:浏览器里直接播,进度自动记住
- **飘弹幕**:自动去弹幕库匹配;匹配不上就导入本地 XML 弹幕
- **媒体库**:扫一遍入库,有海报、有剧集信息,能「继续看」
- **多账号**:一人一个片单和进度
## 跟弹弹play 什么关系
| | 弹弹play | 帆幕 |
|--|----------|------|
| 在哪跑 | 电脑上的软件 | 浏览器网页 |
| 片子在哪 | 本机硬盘 | WebDAV 网盘 |
| 弹幕 | 在线弹幕库 | 同一套开放弹幕网络 |
| 装客户端 | 要 | 不用 |
帆幕不是弹弹play 的插件,也不用先开着电脑上的弹弹play。它是独立的一套东西,能力对齐:**本地/网盘视频 + 在线弹幕**。
## 还没有的东西
第一版先不做这些(弹弹play 里有的):
- BT 下载、RSS 追新
- AI 字幕、AI 总结
- 投屏、插帧
- Emby / Jellyfin / Plex 直连(目前只认 WebDAV)
先把「挂载 → 找片 → 播放 → 弹幕 → 进度」这条主路走顺。

53
app/docs/index.md Normal file
View File

@ -0,0 +1,53 @@
---
layout: home
hero:
name: 帆幕
text: 自己的影音库,看片有弹幕
tagline: 挂上网盘播视频,自动配上网上弹幕。不用装客户端,打开浏览器就能看。
actions:
- theme: brand
text: 看看能干什么
link: /guide/what-is
- theme: alt
text: 马上用起来
link: /guide/quick-start
features:
- title: 连上网盘就看
details: 把 WebDAV 网盘挂进来,在浏览器里直接播。账号密码留在服务器,不进浏览器。
- title: 看片有弹幕
details: 播视频时自动去找对应的弹幕,跟在弹弹play里看本地片一样。没有的话也能导入本地弹幕文件。
- title: 认得出是哪部剧
details: 扫一扫就知道这是哪部作品第几集,自动配上海报和信息。认错了也能手动改。
- title: 一家人都能用
details: 多账号,各人有各人的片单和进度,互不干扰。
---
## 这是啥
**帆幕** 就是你自己架的一个影音库。
片子放在网盘里,用浏览器打开帆幕就能看,而且能飘弹幕。体验上参考了弹弹play:本地视频也能配上网上弹幕,不过帆幕整个跑在浏览器里,不用在电脑上装播放器。
## 能干啥
| 事情 | 说明 |
|------|------|
| [连网盘](/guide/mount) | 把 WebDAV 网盘挂上来 |
| [找视频](/guide/browse) | 在网盘里翻文件夹,点开就播 |
| [媒体库](/guide/library) | 扫一遍入库,有海报、能接着看 |
| [看片和弹幕](/guide/danmaku) | 播放、发弹幕、调弹幕样式 |
| [认剧](/guide/scrape) | 自动认出哪部剧第几集,认错能改 |
| [账号](/guide/account) | 注册登录,各人看各人的 |
## 怎么跑起来
```bash
yarn install
cp .env.example .env
yarn db:push && yarn db:seed
yarn dev:web
```
浏览器打开 `http://localhost:4321`,用 `admin` / `admin123` 登录。更细的步骤见[快速上手](/guide/quick-start)。

18
app/docs/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "@app/docs",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview",
"typecheck": "tsc --noEmit",
"lint": "biome check ."
},
"devDependencies": {
"@biomejs/biome": "^2.1.1",
"typescript": "^7.0.2",
"vitepress": "^1.6.4"
}
}

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" role="img" aria-label="App Template">
<title>App Template</title>
<rect width="32" height="32" rx="6" fill="#0E0F0E"/>
<path d="M8 22V10h4.2c3.2 0 5.2 1.7 5.2 4.4 0 1.7-.9 3-2.4 3.6L18.8 22h-3.3l-3.2-3.6H11V22H8zm3-6.2h1.3c1.5 0 2.4-.7 2.4-1.9S13.8 12 12.3 12H11v3.8z" fill="#FFFA00"/>
<path d="M20 10h2.8v12H20z" fill="#00FFA2"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

11
app/docs/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"lib": ["ES2022", "DOM"],
"types": ["node"],
"moduleResolution": "bundler",
"allowArbitraryExtensions": true
},
"include": [".vitepress/**/*.ts", ".vitepress/**/*.mts"]
}

View File

@ -2,14 +2,14 @@
## 职责
Astro SSR 前端(Node standalone):页面、布局、React islands、tRPC HTTP 适配、SEO 文本端点、**i18n 路由**。
**薄 SSR** Astro 前端(Node standalone):布局 / head / SEO / 中间件服务端渲染;页面主体为 React 客户端组件(`client:load`)。另含 tRPC HTTP 适配、SEO 文本端点、**i18n 路由**。
脚手架基线页面:首页、鉴权、仪表盘(user 列表示例)、设置。视觉遵循仓库根 `DESIGN.md` 与 `@app/design-tokens`。
产品页面:首页、鉴权、仪表盘、媒体库 / 浏览 / 播放 / 挂载 / 弹幕设置与错误页。视觉遵循仓库根 `DESIGN.md` 与 `@app/design-tokens`。
## i18n
- 语言:`zh-CN`(`/zh-CN/...`)、`en`(`/en/...`)。**中文也带前缀**,不把中文留在裸 `/`。
- **单套路由**:`src/pages/[locale]/index.astro`、`[locale]/dashboard.astro` + `src/views/*`;禁止再复制 `pages/zh-CN`、`pages/en` 目录。
- **单套路由**:`src/pages/[locale]/...` + `src/views/*`;禁止再复制 `pages/zh-CN`、`pages/en` 目录。
- 文案:`@app/i18n` 的 `t(locale, key)`;React island 传 `locale` prop,不引入 i18next。
- 裸 `/`:`src/middleware.ts` 按 cookie `app-lang` → `Accept-Language` 跳 `/zh-CN` 或 `/en`;响应 `Vary: Accept-Language`;非法/大小写 `zh-cn` 规范化。
- 手动切换:header 语言链接写 cookie 后跳转(布局内联脚本)。
@ -20,17 +20,28 @@ Astro SSR 前端(Node standalone):页面、布局、React islands、tRPC H
```
src/
pages/
[locale]/index.astro | dashboard.astro | auth/* | settings.astro
api/trpc/[...trpc].ts | api/users.ts
[locale]/
index.astro | dashboard.astro | library.astro | browse.astro
watch.astro | mounts.astro | danmaku.astro | settings.astro
auth/login.astro | auth/register.astro
auth/forgot-password.astro | auth/reset-password.astro
api/trpc/[...trpc].ts | api/stream.ts | api/users.ts
sitemap.xml.ts | robots.txt.ts | llms.txt.ts | llms-full.txt.ts
views/
home.astro | dashboard.astro | auth.astro | error.astro
403.astro | 404.astro | 500.astro
views/ # SSR 壳 + seo/nav(每页一个)
home.astro | dashboard.astro | library.astro | browse.astro
watch.astro | mounts.astro | danmaku.astro | auth.astro | error.astro
middleware.ts # 裸 / 语言检测跳转 + locale 规范化
layouts/AppLayout.astro
components/
pages/ # 薄 SSR 主体(client:load)
HomeBody.tsx | DashboardBody.tsx | LibraryBody.tsx | BrowseBody.tsx
WatchBody.tsx | MountsBody.tsx | DanmakuSettingsBody.tsx
AuthBody.tsx | ErrorBody.tsx
UserList.tsx | UserMenu.tsx | ThemeSwitch.tsx | AuthForm.tsx | errors/*
lib/
trpc.ts | seo.ts(getSiteSeo(locale))| theme.ts | utils.ts(cn re-export)
trpc.ts | seo.ts(getSiteSeo(locale))| nav.ts | theme.ts
danmaku-render.ts | errors.ts | utils.ts(cn re-export)
styles/globals.css
astro.config.ts
```
@ -42,24 +53,28 @@ astro.config.ts
- `@app/ui` → `packages/ui/src`(源码直出)
- typecheck 时 tsconfig paths 指向各包 `src`——**改上游后先 build 再 dev/build web**
## 页面与 SEO
## 页面与 SEO(薄 SSR)
- **服务端**:`AppLayout.astro`(head、导航、脚注)、`seo` prop、middleware(locale / 鉴权跳转)、API / sitemap 等端点。
- **客户端**:`views/*` 只保留壳,主体为 `components/pages/*Body.tsx`,统一 `client:load`。
- 布局统一走 `AppLayout.astro`,必传 `locale`,通过 `seo` prop 传 `PageSeoInput`。
- 站点 SEO:`lib/seo.ts` 的 `getSiteSeo(locale)`;sitemap 双语 URL + hreflang `alternates`。
- 新公开页面:登记 `PUBLIC_PATHS` / views,并同步 `getSiteSeo` 的 `llms.pages` 与 sitemap。
- 新公开页面:登记 `PUBLIC_PATHS` / views,并同步 `getSiteSeo` 的 `llms.pages` 与 sitemap;主体写成 `*Body.tsx`。
- 导航项集中在 `lib/nav.ts`,改菜单只动这一处。
## React islands
- 需要交互才加 `client:load` / `client:visible`。
- 页面主体默认 `client:load`(薄 SSR);局部岛仍可用 `client:visible`。
- 文案:`import { t } from "@app/i18n"` + `locale` prop;不要绑 i18next Provider。
- tRPC:组件内 `QueryClientProvider` + trpc Provider(参考 `UserList.tsx`)。
- UI:`@app/ui`(通用基础组件)+ `cn()`(`~/lib/utils` re-export);颜色用 CSS 变量。
- 播放页弹幕渲染逻辑在 `lib/danmaku-render.ts`,与 `WatchBody` 解耦。
## 修改指南
- **加文案**:先改 `packages/i18n/src/messages/zh-CN.ts`,再补 `en.ts`。
- **加业务页面**:在下游仓库扩展,勿把产品业务写回模板。
- **调 API**:优先 tRPC;`api/users.ts` 仅演示。
- **加业务页面**:沿用 `views/*` + `components/pages/*Body` 薄 SSR 模式扩展。
- **调 API**:优先 tRPC(`trpc.media.*`);`api/users.ts` 仅演示;`api/stream.ts` 为受保护的媒体流。
- **主题**:`localStorage` key `app-theme`;语言 cookie `app-lang`。
- Astro 保留字:组件 props 避免 `slot`。
@ -71,4 +86,5 @@ yarn workspace @app/web lint
yarn workspace @app/web dev # :4321
yarn workspace @app/web build
yarn dev:web
yarn test:e2e # Playwright(e2e/ 目录)
```

View File

@ -45,6 +45,8 @@ export default defineConfig({
},
ssr: {
noExternal: ["@app/trpc", "@app/db", "@app/i18n", "@app/ui"],
// webdav 必须走 Node 原生 stream,不能被 SSR 打包掏空
external: ["webdav"],
},
},
server: {

View File

@ -25,6 +25,7 @@
"@trpc/client": "^11.19.0",
"@trpc/react-query": "^11.19.0",
"artplayer": "^5.2.2",
"artplayer-plugin-danmuku": "^5.3.0",
"astro": "^7.3.3",
"gsap": "^3.15.0",
"lenis": "^1.3.26",

View File

@ -1,79 +0,0 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
const NAV = [
{ hrefKey: "nav.library", href: "library", id: "library" },
{ hrefKey: "nav.browse", href: "browse", id: "browse" },
{ hrefKey: "nav.mounts", href: "mounts", id: "mounts" },
{ hrefKey: "nav.danmaku", href: "danmaku", id: "danmaku" },
] as const;
export function AppShell(props: {
locale: string;
active: (typeof NAV)[number]["id"] | "watch";
labels: Record<string, string>;
children: ReactNode;
}) {
const base = `/${props.locale}`;
const product = props.labels["meta.product"] ?? props.labels["meta.brand"] ?? "弹弹 Web";
return (
<div className="flex min-h-screen flex-col bg-background text-foreground md:flex-row">
{/* 窄屏:顶栏品牌 + 横向导航(md 以下侧栏隐藏时的唯一入口) */}
<div className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur md:hidden">
<div className="flex items-center gap-3 px-3 pt-3">
<a href={`${base}/library`} className="text-sm font-semibold tracking-wide">
{product}
</a>
</div>
<nav
className="flex gap-1 overflow-x-auto px-2 pb-2 pt-2"
aria-label="media-nav-mobile"
>
{NAV.map((item) => (
<a
key={item.id}
href={`${base}/${item.href}`}
className={cn(
"shrink-0 rounded-full px-3 py-1.5 text-sm transition-colors",
props.active === item.id
? "bg-primary font-medium text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
>
{props.labels[item.hrefKey] ?? item.href}
</a>
))}
</nav>
</div>
{/* 宽屏:侧栏 */}
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4">
<a
href={`${base}/library`}
className="mb-6 px-2 text-sm font-semibold tracking-wide"
>
{product}
</a>
<nav className="flex flex-col gap-1" aria-label="sidebar">
{NAV.map((item) => (
<a
key={item.id}
href={`${base}/${item.href}`}
className={cn(
"rounded-md px-3 py-2 text-sm transition-colors",
props.active === item.id
? "bg-background font-medium shadow-sm"
: "text-muted-foreground hover:bg-background/60",
)}
>
{props.labels[item.hrefKey] ?? item.href}
</a>
))}
</nav>
</aside>
<main className="flex min-w-0 flex-1 flex-col p-4 md:p-6">{props.children}</main>
</div>
);
}

View File

@ -1,10 +1,12 @@
/**
* 认证表单岛:登录 / 注册 / 找回 / 重置 / 改密,按 `mode` 切换。
* Provider 包在组件内部(与 UserList 同模式)。
* 默认登录方式:用户名 + 密码。
* Provider 包在组件内部。
*/
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { trpc, createTRPCReactClient } from "../lib/trpc";
import { go } from "../lib/nav";
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import {
@ -48,6 +50,7 @@ export default function AuthForm(props: AuthFormProps) {
}
function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
const [username, setUsername] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@ -59,7 +62,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
const login = trpc.auth.login.useMutation({
onSuccess: () => {
toast.success(t(locale, "auth.msg.loginOk"));
window.location.href = next?.startsWith("/") ? next : home(locale);
go(next?.startsWith("/") ? next : home(locale));
},
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
});
@ -67,7 +70,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
const register = trpc.auth.register.useMutation({
onSuccess: () => {
toast.success(t(locale, "auth.msg.registerOk"));
window.location.href = next?.startsWith("/") ? next : home(locale);
go(next?.startsWith("/") ? next : home(locale));
},
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
});
@ -86,7 +89,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
const resetPassword = trpc.auth.resetPassword.useMutation({
onSuccess: () => {
toast.success(t(locale, "auth.msg.resetOk"));
window.location.href = `${home(locale)}/auth/login`;
go(`${home(locale)}/auth/login`);
},
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
});
@ -111,9 +114,14 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
e.preventDefault();
if (busy) return;
if (mode === "login") {
login.mutate({ email, password });
login.mutate({ username, password });
} else if (mode === "register") {
register.mutate({ name, email, password });
register.mutate({
username,
password,
name: name.trim() || undefined,
email: email.trim() || undefined,
});
} else if (mode === "forgot") {
requestReset.mutate({ email });
} else if (mode === "reset") {
@ -156,6 +164,22 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
{(mode === "login" || mode === "register") && (
<div className="space-y-2">
<Label htmlFor="username">{t(locale, "auth.field.username")}</Label>
<Input
id="username"
name="username"
autoComplete="username"
required
minLength={3}
maxLength={32}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
)}
{mode === "register" && (
<div className="space-y-2">
<Label htmlFor="name">{t(locale, "auth.field.name")}</Label>
@ -163,22 +187,25 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
id="name"
name="name"
autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
)}
{(mode === "login" || mode === "register" || mode === "forgot") && (
{(mode === "register" || mode === "forgot") && (
<div className="space-y-2">
<Label htmlFor="email">{t(locale, "auth.field.email")}</Label>
<Label htmlFor="email">
{mode === "forgot"
? t(locale, "auth.forgot.emailLabel")
: t(locale, "auth.field.email")}
</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
required={mode === "forgot"}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
@ -213,6 +240,9 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t(locale, "auth.passwordHint")}
</p>
</div>
)}
@ -246,6 +276,9 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t(locale, "auth.passwordHint")}
</p>
</div>
</>
)}
@ -276,6 +309,9 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t(locale, "auth.passwordHint")}
</p>
</div>
</>
)}

View File

@ -1,183 +0,0 @@
/**
* 用户 CRUD 演示岛:tRPC + React Query。
* Provider 包在组件内部,便于 Astro 以 client:visible 挂载而不污染全局。
*/
import type { RouterOutputs } from "@app/trpc";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Separator,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
toast,
} from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createTRPCReactClient, trpc } from "../lib/trpc";
const queryClient = new QueryClient();
const trpcClient = createTRPCReactClient();
type User = RouterOutputs["user"]["list"]["users"][number];
export default function UserList() {
return (
<QueryClientProvider client={queryClient}>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<UserListInner />
</trpc.Provider>
</QueryClientProvider>
);
}
function UserListInner() {
const users = trpc.user.list.useQuery({ limit: 20, offset: 0 });
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
users.refetch();
toast.success("用户创建成功");
},
onError: (err) => {
toast.error(`创建失败: ${err.message}`);
},
});
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !email || !password) return;
await createUser.mutateAsync({ name, email, password });
setName("");
setEmail("");
setPassword("");
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>用户列表</CardTitle>
<CardDescription>当前共 {users.data?.total ?? "—"} 位用户</CardDescription>
</CardHeader>
<CardContent className="p-0">
{users.isPending ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-muted-foreground)]">
加载中…
</div>
) : users.isError ? (
<div className="p-6 text-sm text-[var(--color-destructive)]">
加载失败: {users.error.message}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[120px]">ID</TableHead>
<TableHead>名称</TableHead>
<TableHead>邮箱</TableHead>
<TableHead>状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.data?.users.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-[var(--color-muted-foreground)]"
>
暂无数据
</TableCell>
</TableRow>
) : (
users.data?.users.map((u: User) => (
<TableRow key={u.id}>
<TableCell className="font-mono text-xs">
{u.id.slice(0, 8)}…
</TableCell>
<TableCell>{u.name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">活跃</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">新建用户</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">名称</Label>
<Input
id="name"
placeholder="输入名称"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">邮箱</Label>
<Input
id="email"
type="email"
placeholder="input@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
placeholder="至少 8 位"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</div>
<Separator />
<div className="flex gap-2">
<Button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? "创建中…" : "创建用户"}
</Button>
{createUser.isError && (
<span className="text-sm text-[var(--color-destructive)] self-center">
{createUser.error.message}
</span>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -26,8 +26,9 @@ const trpcClient = createTRPCReactClient();
export interface UserMenuProps {
locale?: Locale | undefined;
username?: string | undefined;
name?: string | undefined;
email?: string | undefined;
email?: string | null | undefined;
avatarUrl?: string | undefined;
}
@ -99,7 +100,9 @@ function UserMenuInner({ locale, avatarUrl }: { locale: Locale; avatarUrl?: stri
<DropdownMenuLabel>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{name}</span>
<span className="u-mono text-[11px] opacity-70">{user.email}</span>
<span className="u-mono text-[11px] opacity-70">
{user.email ?? user.username ?? ""}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />

View File

@ -0,0 +1,24 @@
/**
* 鉴权页主体:薄 SSR — 外层容器 + AuthForm 在浏览器渲染。
*/
import type { Locale } from "@app/i18n";
import { Toaster } from "@app/ui";
import AuthForm, { type AuthFormMode } from "~/components/AuthForm";
export interface AuthBodyProps {
locale: Locale;
mode: AuthFormMode;
next?: string | undefined;
token?: string | undefined;
}
export default function AuthBody({ locale, mode, next, token }: AuthBodyProps) {
return (
<>
<div className="flex min-h-[60vh] items-center justify-center px-4 py-16">
<AuthForm locale={locale} mode={mode} next={next} defaultToken={token} />
</div>
<Toaster />
</>
);
}

View File

@ -1,9 +1,10 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { isVideoFilename } from "@app/types";
import { Button } from "@app/ui";
import { Button, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { go, queryParam, setQueryParams } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
function pathOf(segments: string[]): string {
@ -17,13 +18,51 @@ function fileRelPath(dirPath: string, basename: string): string {
function BrowseInner(props: { locale: Locale }) {
const { locale } = props;
const mounts = trpc.media.mountList.useQuery();
const [mountId, setMountId] = useState("");
const [mountId, setMountId] = useState(() => queryParam("mount") ?? "");
const [path, setPath] = useState("/");
const [crumbs, setCrumbs] = useState<string[]>([]);
useEffect(() => {
setQueryParams({ mount: mountId || null });
}, [mountId]);
// 挂载列表返回后:URL 里的 mount 有效则保留;否则自动选中第一个已挂载目录
useEffect(() => {
const list = mounts.data?.mounts ?? [];
if (list.length === 0) return;
if (mountId && list.some((m) => m.id === mountId)) return;
const first = list[0];
if (first) setMountId(first.id);
}, [mounts.data, mountId]);
const dir = trpc.media.mountListDir.useQuery({ mountId, path }, { enabled: Boolean(mountId) });
const scan = trpc.media.libraryScan.useMutation();
const ensure = trpc.media.libraryEnsurePath.useMutation();
const utils = trpc.useUtils();
const batchScrape = trpc.media.scrapeRescrapePath.useMutation({
onSuccess: (data) => {
toast.success(t(locale, "browse.batchDone", { message: data.message }));
void utils.media.libraryList.invalidate();
void utils.media.libraryGroups.invalidate();
},
onError: () => toast.error(t(locale, "browse.batchError")),
});
const bindPath = trpc.media.scrapeBindPath.useMutation({
onSuccess: (data) => {
toast.success(t(locale, "browse.batchDone", { message: data.message }));
setBindOpen(false);
setBindQ("");
void utils.media.libraryList.invalidate();
void utils.media.libraryGroups.invalidate();
},
onError: () => toast.error(t(locale, "browse.batchError")),
});
const [bindOpen, setBindOpen] = useState(false);
const [bindQ, setBindQ] = useState("");
const bindSearch = trpc.media.scrapeSearch.useQuery(
{ q: bindQ },
{ enabled: bindOpen && bindQ.length > 1 },
);
function goRoot() {
setCrumbs([]);
@ -52,7 +91,7 @@ function BrowseInner(props: { locale: Locale }) {
if (!mountId) return;
try {
const item = await ensure.mutateAsync({ mountId, path: fileRelPath(path, basename) });
window.location.href = `/${locale}/watch?id=${item.id}`;
go(`/${locale}/watch?id=${item.id}`);
} catch {
// error surfaced via ensure.error
}
@ -97,11 +136,37 @@ function BrowseInner(props: { locale: Locale }) {
{t(locale, "browse.addLibrary")}
</Button>
)}
{mountId && path !== "/" && (
<>
<Button
size="sm"
disabled={batchScrape.isPending}
onClick={() => batchScrape.mutate({ mountId, path })}
>
{t(locale, "browse.batchScrape")}
</Button>
<Button size="sm" variant="secondary" onClick={() => setBindOpen(true)}>
{t(locale, "browse.batchBind")}
</Button>
</>
)}
</div>
{(batchScrape.data || bindPath.data) && (
<p className="text-sm text-muted-foreground">
{t(locale, "browse.batchDone", {
message: batchScrape.data?.message ?? bindPath.data?.message ?? "",
})}
</p>
)}
{scan.data && (
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-muted-foreground">
{t(locale, "browse.scanDone", { count: scan.data.scanned })}
</p>
<Button size="sm" onClick={() => go(`/${locale}/library`)}>
{t(locale, "browse.goLibrary")}
</Button>
</div>
)}
{scan.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.scanError")}</p>
@ -128,7 +193,17 @@ function BrowseInner(props: { locale: Locale }) {
<p className="text-sm text-muted-foreground">{t(locale, "browse.loading")}</p>
)}
{mountId && dir.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.loadError")}</p>
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-red-600">
{t(locale, "browse.loadError")}
{dir.error instanceof Error && dir.error.message
? `:${dir.error.message}`
: ""}
</p>
<Button size="sm" variant="outline" onClick={() => void dir.refetch()}>
{t(locale, "error.retry")}
</Button>
</div>
)}
{ensure.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.playError")}</p>
@ -168,24 +243,82 @@ function BrowseInner(props: { locale: Locale }) {
);
})}
{mountId && dir.data?.entries.length === 0 && (
<li className="p-4 text-sm text-muted-foreground">
{t(locale, "browse.empty")}
<li className="p-0">
<div className="u-empty border-0 bg-transparent">
<p>{t(locale, "browse.empty")}</p>
</div>
</li>
)}
{!mountId && (
<li className="flex flex-col items-start gap-3 p-4 text-sm text-muted-foreground">
<span>{t(locale, "browse.empty")}</span>
<Button
size="sm"
onClick={() => {
window.location.href = `/${locale}/mounts`;
}}
>
{!mountId && mountList.length === 0 && (
<li className="p-0">
<div className="u-empty border-0 bg-transparent">
<p className="u-empty__title">{t(locale, "browse.mountsEmpty")}</p>
<div className="u-empty__actions">
<Button size="sm" onClick={() => go(`/${locale}/mounts`)}>
{t(locale, "browse.addMount")}
</Button>
</div>
</div>
</li>
)}
{!mountId && mountList.length > 0 && (
<li className="p-0">
<div className="u-empty border-0 bg-transparent">
<p>{t(locale, "browse.loading")}</p>
</div>
</li>
)}
</ul>
{bindOpen && mountId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 space-y-3">
<h2 className="font-medium">{t(locale, "browse.bindFolderTitle")}</h2>
<p className="text-xs text-muted-foreground">
{t(locale, "browse.bindFolderHint")}
</p>
<p className="font-mono text-xs text-muted-foreground">{path}</p>
<Input
placeholder={t(locale, "library.searchWork")}
value={bindQ}
onChange={(e) => setBindQ(e.target.value)}
/>
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
{(bindSearch.data ?? []).map((hit) => (
<li
key={hit.id}
className="flex items-center justify-between gap-2 p-2 text-sm"
>
<div className="min-w-0">
<div className="truncate">{hit.nameCn || hit.name}</div>
{hit.nameCn && hit.name && (
<div className="truncate text-xs text-muted-foreground">
{hit.name}
</div>
)}
</div>
<Button
size="sm"
disabled={bindPath.isPending}
onClick={() =>
bindPath.mutate({
mountId,
path,
bangumiId: hit.id,
})
}
>
{t(locale, "library.bind")}
</Button>
</li>
))}
</ul>
<Button variant="outline" onClick={() => setBindOpen(false)}>
{t(locale, "library.cancel")}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@ -1,8 +1,9 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, toast } from "@app/ui";
import { Button, Card, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { go } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
@ -59,9 +60,10 @@ function Inner(props: { locale: Locale }) {
};
return (
<div className="max-w-md space-y-4">
<div className="space-y-6">
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
<label className="flex items-center gap-2 text-sm">
<Card className="max-w-md space-y-5 p-6">
<label className="flex items-center gap-2.5 text-sm font-medium">
<input
type="checkbox"
checked={enabled}
@ -69,8 +71,13 @@ function Inner(props: { locale: Locale }) {
/>
{t(locale, "danmaku.enabled")}
</label>
<label className="block text-sm">
{t(locale, "danmaku.opacityValue", { value: (opacity * 100).toFixed(0) })}
<div className="space-y-2">
<div className="flex items-baseline justify-between text-sm">
<span>{t(locale, "danmaku.opacity")}</span>
<span className="u-mono text-xs text-muted-foreground">
{(opacity * 100).toFixed(0)}%
</span>
</div>
<input
type="range"
min={0}
@ -80,11 +87,14 @@ function Inner(props: { locale: Locale }) {
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full"
/>
</label>
<label className="block text-sm">
{t(locale, "danmaku.densityValue", {
value: (density * 100).toFixed(0),
})}
</div>
<div className="space-y-2">
<div className="flex items-baseline justify-between text-sm">
<span>{t(locale, "danmaku.density")}</span>
<span className="u-mono text-xs text-muted-foreground">
{(density * 100).toFixed(0)}%
</span>
</div>
<input
type="range"
min={0.1}
@ -94,14 +104,37 @@ function Inner(props: { locale: Locale }) {
onChange={(e) => setDensity(Number(e.target.value))}
className="w-full"
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button type="button" onClick={onSave}>
{t(locale, "danmaku.save")}
</Button>
{save.isSuccess && (
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
)}
<p className="text-xs text-muted-foreground">{t(locale, "danmaku.localImportHint")}</p>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t(locale, "danmaku.localImportHint")}
</p>
</Card>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => go(`/${locale}/library`)}
>
{t(locale, "nav.library")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => go(`/${locale}/browse`)}
>
{t(locale, "nav.browse")}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
/**
* 错误页主体:薄 SSR — ErrorView 在浏览器渲染。
*/
import type { Locale } from "@app/i18n";
import { ErrorView } from "~/components/errors/ErrorView";
import { createAppError, type AppErrorStatus } from "~/lib/errors";
export interface ErrorBodyProps {
locale: Locale;
status: AppErrorStatus;
}
export default function ErrorBody({ locale, status }: ErrorBodyProps) {
const error = createAppError(status);
const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
return (
<div className="flex min-h-[55vh] items-center justify-center px-4 py-16">
<ErrorView error={error} locale={locale} homeHref={homeHref} />
</div>
);
}

View File

@ -2,15 +2,52 @@
* 首页主体:薄 SSR — Astro 壳负责 head/导航,本组件在浏览器渲染。
*/
import { t, type Locale } from "@app/i18n";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { go } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
export interface HomeBodyProps {
locale: Locale;
}
export default function HomeBody({ locale }: HomeBodyProps) {
function ContinueStrip(props: { locale: Locale }) {
const { locale } = props;
const list = trpc.media.playbackContinue.useQuery({ limit: 4 }, { retry: false });
const items = list.data ?? [];
if (items.length === 0) return null;
return (
<section className="u-card u-reveal p-6">
<p className="u-label">{t(locale, "library.continueTitle")}</p>
<div className="mt-3 flex flex-wrap gap-2">
{items.map((item) => (
<button
key={item.mediaItemId}
type="button"
className="u-btn u-btn--ghost"
onClick={() => go(`/${locale}/watch?id=${item.mediaItemId}`)}
>
{item.title}
</button>
))}
</div>
</section>
);
}
function HomeInner({ locale }: HomeBodyProps) {
const tr = (key: Parameters<typeof t>[1]) => t(locale, key);
const libraryHref = locale === "zh-CN" ? "/zh-CN/library" : "/en/library";
const loginHref = locale === "zh-CN" ? "/zh-CN/auth/login" : "/en/auth/login";
const me = trpc.auth.me.useQuery(undefined, { retry: false });
const loggedIn = me.data?.user != null;
const steps = [
{ key: "nav.mounts", href: `/${locale}/mounts` },
{ key: "nav.browse", href: `/${locale}/browse` },
{ key: "nav.library", href: `/${locale}/library` },
{ key: "nav.danmaku", href: `/${locale}/danmaku` },
] as const;
return (
<section className="space-y-10 py-10">
@ -32,14 +69,18 @@ export default function HomeBody({ locale }: HomeBodyProps) {
<a href={libraryHref} className="u-btn u-btn--edge">
{tr("home.ctaDashboard")}
</a>
{!loggedIn && (
<a href={loginHref} className="u-btn u-btn--ghost">
{tr("home.ctaLogin")}
</a>
)}
</div>
</div>
<ContinueStrip locale={locale} />
<div className="grid gap-6 md:grid-cols-2">
<section className="u-card p-6 u-reveal">
<section className="u-card flex flex-col p-6 u-reveal">
<p className="u-label">{tr("home.stackTitle")}</p>
<p
className="mt-3 text-[14px] leading-relaxed"
@ -48,7 +89,7 @@ export default function HomeBody({ locale }: HomeBodyProps) {
{tr("home.stackBody")}
</p>
</section>
<section className="u-card p-6 u-reveal">
<section className="u-card flex flex-col p-6 u-reveal">
<p className="u-label">{tr("home.layerTitle")}</p>
<p
className="mt-3 text-[14px] leading-relaxed"
@ -56,8 +97,29 @@ export default function HomeBody({ locale }: HomeBodyProps) {
>
{tr("home.layerBody")}
</p>
<div className="mt-auto flex flex-wrap gap-2 pt-4">
{steps.map((s, i) => (
<a key={s.key} href={s.href} className="u-btn u-btn--ghost">
<span className="u-mono">{String(i + 1).padStart(2, "0")}</span>
{tr(s.key)}
</a>
))}
</div>
</section>
</div>
</section>
);
}
const trpcClient = createTRPCReactClient();
export default function HomeBody({ locale }: HomeBodyProps) {
const [qc] = useState(() => new QueryClient());
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={trpcClient} queryClient={qc}>
<HomeInner locale={locale} />
</trpc.Provider>
</QueryClientProvider>
);
}

View File

@ -1,59 +1,458 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, Input } from "@app/ui";
import type { LibraryGroup } from "@app/types";
import { Button, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils";
import { go, queryParam, setQueryParams } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
function LibraryInner(props: { locale: Locale }) {
const { locale } = props;
const [filter, setFilter] = useState<"" | "unmatched" | "ok">("");
const [q, setQ] = useState("");
const [offset, setOffset] = useState(0);
const pageLimit = 24;
const list = trpc.media.libraryList.useQuery({
limit: pageLimit,
offset,
scrapeStatus: filter || undefined,
q: q || undefined,
});
const progressList = list.data?.rows ?? [];
const total = list.data?.total ?? 0;
const hasMore = offset + progressList.length < total;
const resetPaging = () => setOffset(0);
const [matchId, setMatchId] = useState<string | null>(null);
const [searchQ, setSearchQ] = useState("");
const search = trpc.media.scrapeSearch.useQuery(
{ q: searchQ },
{ enabled: searchQ.length > 1 && matchId !== null },
type ViewMode = "wall" | "list";
const PAGE_SIZE = 24;
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
function PosterBox(props: { posterUrl: string | null; locale: Locale; className?: string }) {
return (
<div
className={cn(
"flex aspect-[3/4] items-center justify-center bg-muted text-xs text-muted-foreground",
props.className,
)}
>
{props.posterUrl ? (
<img
src={props.posterUrl}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
t(props.locale, "library.noPoster")
)}
</div>
);
const bind = trpc.media.scrapeBind.useMutation({
onSuccess: () => {
setMatchId(null);
list.refetch();
},
}
function MountTabs(props: {
mounts: { id: string; name: string }[];
value: string;
onChange: (mountId: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1 rounded-md border border-border p-0.5" role="tablist">
{props.mounts.map((m) => (
<button
key={m.id}
type="button"
role="tab"
aria-selected={m.id === props.value}
className={cn(
"rounded px-3 py-1.5 text-sm transition-colors",
m.id === props.value
? "bg-primary font-medium text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => props.onChange(m.id)}
>
{m.name}
</button>
))}
</div>
);
}
/** 作品详情:剧集按需从 librarySeries 拉取(限当前挂载) */
function GroupDetail(props: {
bangumiId: number;
mountId: string;
locale: Locale;
onBack: () => void;
onPlay: (mediaItemId: string, bangumiId: number) => void;
onMatch?: ((mediaItemId: string) => void) | undefined;
}) {
const { locale } = props;
const seriesQuery = trpc.media.librarySeries.useQuery(
{ bangumiId: props.bangumiId, mountId: props.mountId || undefined },
{ enabled: Boolean(props.mountId) },
);
const group = seriesQuery.data;
return (
<div className="space-y-4">
<Button variant="outline" size="sm" onClick={props.onBack}>
← {t(locale, "library.backToList")}
</Button>
{seriesQuery.isPending && (
<p className="text-sm text-muted-foreground">{t(locale, "browse.loading")}</p>
)}
{seriesQuery.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.loadError")}</p>
)}
{group && (
<div className="flex flex-col gap-4 sm:flex-row">
<div className="w-full max-w-40 overflow-hidden rounded-lg border border-border sm:w-40">
<PosterBox posterUrl={group.posterUrl} locale={locale} />
</div>
<div className="min-w-0 flex-1 space-y-2">
<h2 className="truncate text-lg font-semibold" title={group.title}>
{group.title}
</h2>
<p className="text-sm text-muted-foreground">
{t(locale, "library.episodeCount", { count: group.episodes.length })}
</p>
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t(locale, "library.episodes")}
</div>
<ul className="divide-y divide-border rounded-lg border border-border">
{group.episodes.map((ep) => (
<li
key={ep.id}
className="flex items-center gap-3 px-3 py-2 text-sm"
>
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground">
{ep.epNumber != null ? `EP${ep.epNumber}` : "—"}
</span>
<span
className="min-w-0 flex-1 truncate"
title={ep.rawName}
>
{ep.rawName}
</span>
<Button
size="sm"
variant="secondary"
onClick={() => props.onPlay(ep.id, props.bangumiId)}
>
{t(locale, "library.play")}
</Button>
{props.onMatch && (
<Button
size="sm"
variant="outline"
onClick={() => props.onMatch?.(ep.id)}
>
{t(locale, "library.match")}
</Button>
)}
</li>
))}
</ul>
</div>
</div>
</div>
)}
</div>
);
}
function ContinueRow(props: { locale: Locale }) {
const { locale } = props;
const list = trpc.media.playbackContinue.useQuery({ limit: 6 });
const items = list.data ?? [];
if (list.isPending) return null;
if (items.length === 0) return null;
return (
<section className="space-y-2">
<div className="flex items-center gap-2">
<p className="u-label">{t(locale, "library.continueTitle")}</p>
<span className="u-dotline flex-1" aria-hidden="true" />
</div>
<div className="flex gap-3 overflow-x-auto pb-1">
{items.map((item) => {
const pct =
item.durationMs > 0
? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100))
: 0;
return (
<button
key={item.mediaItemId}
type="button"
className="w-40 shrink-0 overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60"
onClick={() => go(`/${locale}/watch?id=${item.mediaItemId}`)}
>
<PosterBox posterUrl={item.posterUrl} locale={locale} />
<div className="p-2">
<div className="truncate text-sm font-medium" title={item.title}>
{item.title}
</div>
<div className="mt-1 h-1 w-full overflow-hidden rounded bg-muted">
<div
className="h-full bg-primary"
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-1 font-mono text-[11px] text-muted-foreground">
{t(locale, "library.continue")} · {pct}%
</div>
</div>
</button>
);
})}
</div>
</section>
);
}
/**
* 「加载更多」分页累积:按 offset 缓存已取页。
* reqKey/refreshKey 变更时丢弃旧累积,避免 mutation 后旧页与新页混排。
*/
type PagedAcc<T> = {
key: string;
total: number;
pages: Record<number, T[]>;
};
function usePagedAccumulator<T>(params: {
reqKey: string;
refreshKey: number;
data: { total: number; items: T[] } | undefined;
offset: number;
}): { items: T[]; total: number; hasMore: boolean } {
const { reqKey, refreshKey, data, offset } = params;
const [acc, setAcc] = useState<PagedAcc<T> | null>(null);
const fullKey = `${reqKey}|r${refreshKey}`;
const [prevFullKey, setPrevFullKey] = useState(fullKey);
// 渲染期重置:条件或 refreshKey 变化后丢弃旧页
if (prevFullKey !== fullKey) {
setPrevFullKey(fullKey);
setAcc(null);
}
useEffect(() => {
if (!data) return;
setAcc((prev) => {
if (!prev || prev.key !== fullKey || offset === 0) {
return { key: fullKey, total: data.total, pages: { [offset]: data.items } };
}
return {
key: prev.key,
total: data.total,
pages: { ...prev.pages, [offset]: data.items },
};
});
}, [data, fullKey, offset]);
const items: T[] = [];
let total = 0;
if (acc && acc.key === fullKey) {
total = acc.total;
for (const pageOffset of Object.keys(acc.pages)
.map(Number)
.sort((a, b) => a - b)) {
const page = acc.pages[pageOffset];
if (page) items.push(...page);
}
}
return { items, total, hasMore: items.length < total };
}
function WallView(props: {
locale: Locale;
mountId: string;
q: string;
refreshKey: number;
selectedBangumiId: number | null;
onSelect: (bangumiId: number | null) => void;
onPlay: (mediaItemId: string, bangumiId: number) => void;
onMatch?: ((mediaItemId: string) => void) | undefined;
onClearSearch?: (() => void) | undefined;
hideEmpty?: boolean;
mountsEmpty?: boolean;
}) {
const { locale, mountId, q } = props;
const [offset, setOffset] = useState(0);
const reqKey = `${mountId}|${q}`;
const [prevReqKey, setPrevReqKey] = useState(reqKey);
if (prevReqKey !== reqKey) {
setPrevReqKey(reqKey);
setOffset(0);
}
const [prevRefresh, setPrevRefresh] = useState(props.refreshKey);
if (prevRefresh !== props.refreshKey) {
setPrevRefresh(props.refreshKey);
setOffset(0);
}
const groupsQuery = trpc.media.libraryGroups.useQuery(
{ mountId, q: q || undefined, limit: PAGE_SIZE, offset },
{ enabled: Boolean(mountId) },
);
const paged = usePagedAccumulator<LibraryGroup>({
reqKey,
refreshKey: props.refreshKey,
data: groupsQuery.data
? { total: groupsQuery.data.total, items: groupsQuery.data.groups }
: undefined,
offset,
});
const groups = paged.items;
const hasMore = paged.hasMore;
if (props.selectedBangumiId != null) {
return (
<GroupDetail
bangumiId={props.selectedBangumiId}
mountId={mountId}
locale={locale}
onBack={() => props.onSelect(null)}
onPlay={props.onPlay}
onMatch={props.onMatch}
/>
);
}
const showEmpty =
!props.hideEmpty && (props.mountsEmpty || (groups.length === 0 && !groupsQuery.isPending));
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">{t(locale, "library.title")}</h1>
<div className="ml-auto flex gap-2">
<Input
className="w-40"
placeholder={t(locale, "library.searchPlaceholder")}
value={q}
onChange={(e) => {
setQ(e.target.value);
resetPaging();
}}
/>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
{groups.map((group) => (
<button
key={group.bangumiId}
type="button"
className="overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60"
onClick={() => props.onSelect(group.bangumiId)}
>
<PosterBox posterUrl={group.posterUrl} locale={locale} />
<div className="p-2">
<div className="truncate text-sm font-medium" title={group.title}>
{group.title}
</div>
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
{t(locale, "library.episodeCount", { count: group.episodeCount })}
</div>
</div>
</button>
))}
{showEmpty && (
<div className="u-empty col-span-full">
<p className="u-empty__title">{t(locale, "library.title")}</p>
<p>
{!props.mountsEmpty && q
? t(locale, "library.noResults", { q })
: t(locale, "library.groupsEmpty")}
</p>
<div className="u-empty__actions">
{!props.mountsEmpty && q && props.onClearSearch ? (
<Button size="sm" variant="outline" onClick={props.onClearSearch}>
{t(locale, "library.clearSearch")}
</Button>
) : (
<>
<Button size="sm" onClick={() => go(`/${locale}/browse`)}>
{t(locale, "library.ctaBrowse")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => go(`/${locale}/mounts`)}
>
{t(locale, "library.ctaMounts")}
</Button>
</>
)}
</div>
</div>
)}
</div>
{hasMore && (
<div className="flex justify-center">
<Button
variant="outline"
disabled={groupsQuery.isPending}
onClick={() => setOffset(groups.length)}
>
{t(locale, "library.loadMore")}
</Button>
</div>
)}
</div>
);
}
function ListView(props: {
locale: Locale;
mountId: string;
q: string;
filter: "" | "unmatched" | "ok";
refreshKey: number;
onFilterChange: (f: "" | "unmatched" | "ok") => void;
onMatch: (mediaItemId: string) => void;
onPlay: (mediaItemId: string, bangumiId?: number | null) => void;
onRescrape?: ((mediaItemId: string) => void) | undefined;
onClearSearch?: (() => void) | undefined;
hideEmpty?: boolean;
mountsEmpty?: boolean;
}) {
const { locale, mountId, q, filter } = props;
const [offset, setOffset] = useState(0);
const pageLimit = PAGE_SIZE;
const reqKey = `${mountId}|${q}|${filter}`;
const [prevReqKey, setPrevReqKey] = useState(reqKey);
if (prevReqKey !== reqKey) {
setPrevReqKey(reqKey);
setOffset(0);
}
const [prevRefresh, setPrevRefresh] = useState(props.refreshKey);
if (prevRefresh !== props.refreshKey) {
setPrevRefresh(props.refreshKey);
setOffset(0);
}
const list = trpc.media.libraryList.useQuery({
limit: pageLimit,
offset,
mountId: mountId || undefined,
scrapeStatus: filter || undefined,
q: q || undefined,
});
// 与海报墙同一套累积逻辑:加载更多追加而不是顶掉上一页
type MediaListItem = {
id: string;
title: string;
posterUrl: string | null;
bangumiId: number | null;
scrapeStatus: string;
};
const paged = usePagedAccumulator<MediaListItem>({
reqKey,
refreshKey: props.refreshKey,
data: list.data
? {
total: list.data.total,
items: list.data.rows.map((r) => ({
id: r.id,
title: r.title,
posterUrl: r.posterUrl,
bangumiId: r.bangumiId,
scrapeStatus: r.scrapeStatus,
})),
}
: undefined,
offset,
});
const rows = paged.items;
const hasMore = paged.hasMore;
return (
<div className="space-y-4">
<div className="flex justify-end">
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={filter}
onChange={(e) => {
setFilter(e.target.value as typeof filter);
resetPaging();
props.onFilterChange(e.target.value as typeof filter);
setOffset(0);
}}
>
<option value="">{t(locale, "library.filterAll")}</option>
@ -61,25 +460,14 @@ function LibraryInner(props: { locale: Locale }) {
<option value="unmatched">{t(locale, "library.unmatched")}</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
{progressList.map((item) => (
{rows.map((item) => (
<div
key={item.id}
className="overflow-hidden rounded-lg border border-border bg-card"
>
<div className="aspect-[3/4] bg-muted flex items-center justify-center text-xs text-muted-foreground">
{item.posterUrl ? (
<img
src={item.posterUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
t(locale, "library.noPoster")
)}
</div>
<PosterBox posterUrl={item.posterUrl} locale={locale} />
<div className="p-2">
<div className="truncate text-sm font-medium" title={item.title}>
{item.title}
@ -88,46 +476,70 @@ function LibraryInner(props: { locale: Locale }) {
<Button
size="sm"
variant="secondary"
onClick={() => {
window.location.href = `/${locale}/watch?id=${item.id}`;
}}
onClick={() => props.onPlay(item.id, item.bangumiId)}
>
{t(locale, "library.play")}
</Button>
{item.scrapeStatus !== "ok" && (
<Button
size="sm"
variant="outline"
onClick={() => setMatchId(item.id)}
onClick={() => props.onMatch(item.id)}
>
{t(locale, "library.match")}
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => props.onRescrape?.(item.id)}
>
{t(locale, "library.reScrape")}
</Button>
</div>
</div>
</div>
))}
{progressList.length === 0 && !list.isPending && (
<div className="col-span-full flex flex-col items-start gap-3 rounded-lg border border-border bg-card p-6">
<p className="text-sm text-muted-foreground">
{t(locale, "library.empty")}
{!props.hideEmpty &&
(props.mountsEmpty || (rows.length === 0 && !list.isPending)) && (
<div className="u-empty col-span-full">
<p className="u-empty__title">{t(locale, "library.title")}</p>
<p>
{!props.mountsEmpty && q
? t(locale, "library.noResults", { q })
: !props.mountsEmpty && filter
? t(locale, "library.filterEmpty")
: t(locale, "library.empty")}
</p>
<div className="flex flex-wrap gap-2">
<div className="u-empty__actions">
{!props.mountsEmpty && q && props.onClearSearch ? (
<Button
onClick={() => {
window.location.href = `/${locale}/browse`;
}}
size="sm"
variant="outline"
onClick={props.onClearSearch}
>
{t(locale, "library.clearSearch")}
</Button>
) : !props.mountsEmpty && filter ? (
<Button
size="sm"
variant="outline"
onClick={() => props.onFilterChange("")}
>
{t(locale, "library.filterAll")}
</Button>
) : (
<>
<Button size="sm" onClick={() => go(`/${locale}/browse`)}>
{t(locale, "library.ctaBrowse")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
window.location.href = `/${locale}/mounts`;
}}
onClick={() => go(`/${locale}/mounts`)}
>
{t(locale, "library.ctaMounts")}
</Button>
</>
)}
</div>
</div>
)}
@ -143,42 +555,275 @@ function LibraryInner(props: { locale: Locale }) {
</Button>
</div>
)}
</div>
);
}
function LibraryInner(props: { locale: Locale }) {
const { locale } = props;
const [view, setView] = useState<ViewMode>(() => {
try {
return localStorage.getItem("media-library-view") === "list" ? "list" : "wall";
} catch {
return "wall";
}
});
const changeView = (v: ViewMode) => {
setView(v);
try {
localStorage.setItem("media-library-view", v);
} catch {
/* noop */
}
};
const [filter, setFilter] = useState<"" | "unmatched" | "ok">("");
const [q, setQ] = useState("");
const debouncedQ = useDebouncedValue(q, 300);
const [matchId, setMatchId] = useState<string | null>(null);
const [searchQ, setSearchQ] = useState("");
const [epInput, setEpInput] = useState("");
const [mountId, setMountId] = useState(() => queryParam("mount") ?? "");
const [selectedBangumiId, setSelectedBangumiId] = useState<number | null>(() => {
const raw = queryParam("g");
const parsed = raw ? Number(raw) : Number.NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
});
const search = trpc.media.scrapeSearch.useQuery(
{ q: searchQ },
{ enabled: searchQ.length > 1 && matchId !== null },
);
const utils = trpc.useUtils();
const [refreshKey, setRefreshKey] = useState(0);
const bumpRefresh = () => setRefreshKey((k) => k + 1);
const bind = trpc.media.scrapeBind.useMutation({
onSuccess: () => {
setMatchId(null);
toast.success(t(locale, "library.matched"));
bumpRefresh();
void utils.media.libraryList.invalidate();
void utils.media.libraryGroups.invalidate();
void utils.media.librarySeries.invalidate();
void utils.media.playbackContinue.invalidate();
},
});
const rescrape = trpc.media.scrapeRescrape.useMutation({
onSuccess: () => {
toast.success(t(locale, "library.reScrape"));
bumpRefresh();
void utils.media.libraryList.invalidate();
void utils.media.libraryGroups.invalidate();
void utils.media.librarySeries.invalidate();
},
});
const bindWithEp = (bangumiId: number) => {
if (!matchId) return;
const epNum = epInput.trim() === "" ? undefined : Number(epInput);
bind.mutate({
mediaItemId: matchId,
bangumiId,
...(epNum != null && Number.isFinite(epNum) ? { epNumber: epNum } : {}),
});
};
const mounts = trpc.media.mountList.useQuery();
const probe = trpc.media.libraryList.useQuery({ limit: 1, offset: 0 });
const autoScan = trpc.media.libraryScan.useMutation();
const autoScanStarted = useRef(false);
const [autoScanning, setAutoScanning] = useState(false);
const mountList = mounts.data?.mounts ?? [];
const mountsEmpty = !mounts.isPending && mountList.length === 0;
useEffect(() => {
setQueryParams({ mount: mountId || null });
}, [mountId]);
// 挂载列表返回后:URL 里的 mount 有效则保留;否则自动选中第一个
useEffect(() => {
const list = mounts.data?.mounts ?? [];
if (list.length === 0) return;
if (mountId && list.some((m) => m.id === mountId)) return;
const first = list[0];
if (first) setMountId(first.id);
}, [mounts.data, mountId]);
// 空库且已有挂载时,自动把已挂载目录扫进媒体库(仅触发一次;顺序扫描避免并发打满 WebDAV)
useEffect(() => {
if (autoScanStarted.current) return;
if (mounts.isPending || probe.isPending) return;
const list = mounts.data?.mounts ?? [];
if (list.length === 0) return;
if ((probe.data?.total ?? 0) > 0) return;
autoScanStarted.current = true;
setAutoScanning(true);
void (async () => {
for (const m of list) {
try {
await autoScan.mutateAsync({ mountId: m.id });
} catch {
// 单个挂载失败不阻断其余挂载
}
}
setAutoScanning(false);
setRefreshKey((k) => k + 1);
void utils.media.libraryList.invalidate();
void utils.media.libraryGroups.invalidate();
})();
}, [mounts.data, mounts.isPending, probe.data, probe.isPending, autoScan, utils]);
useEffect(() => {
setQueryParams({ g: selectedBangumiId != null ? String(selectedBangumiId) : null });
}, [selectedBangumiId]);
const play = (id: string, bangumiId?: number | null) => {
const g = bangumiId ?? selectedBangumiId;
const back = g != null ? `&from=series&g=${g}` : "";
go(`/${locale}/watch?id=${id}${back}`);
};
return (
<div className="space-y-6">
<ContinueRow locale={locale} />
{autoScanning && (
<p className="text-sm text-muted-foreground">{t(locale, "library.autoScanning")}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">{t(locale, "library.title")}</h1>
<div className="ml-auto flex flex-wrap items-center gap-2">
<div className="flex rounded-md border border-border p-0.5">
<button
type="button"
className={cn(
"rounded px-3 py-1.5 text-sm transition-colors",
view === "wall"
? "bg-primary font-medium text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => {
// 海报墙只展示已刮削分组,切过去时列表对齐同一集合
changeView("wall");
}}
>
{t(locale, "library.viewWall")}
</button>
<button
type="button"
className={cn(
"rounded px-3 py-1.5 text-sm transition-colors",
view === "list"
? "bg-primary font-medium text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => {
changeView("list");
if (filter === "") setFilter("ok");
}}
>
{t(locale, "library.viewList")}
</button>
</div>
<Input
className="w-40"
placeholder={t(locale, "library.searchPlaceholder")}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
</div>
{mountList.length > 0 && (
<MountTabs
mounts={mountList}
value={mountId}
onChange={(id) => {
setMountId(id);
setSelectedBangumiId(null);
}}
/>
)}
{view === "wall" ? (
<WallView
locale={locale}
mountId={mountId}
q={debouncedQ}
refreshKey={refreshKey}
selectedBangumiId={selectedBangumiId}
onSelect={setSelectedBangumiId}
onPlay={play}
onMatch={setMatchId}
onClearSearch={() => setQ("")}
hideEmpty={autoScanning}
mountsEmpty={mountsEmpty}
/>
) : (
<ListView
locale={locale}
mountId={mountId}
q={debouncedQ}
filter={filter}
refreshKey={refreshKey}
onFilterChange={setFilter}
onMatch={setMatchId}
onPlay={play}
onRescrape={(id) => rescrape.mutate({ mediaItemId: id })}
onClearSearch={() => setQ("")}
hideEmpty={autoScanning}
mountsEmpty={mountsEmpty}
/>
)}
{matchId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 space-y-3">
<h2 className="font-medium">{t(locale, "library.matchTitle")}</h2>
<h2 className="font-medium">{t(locale, "library.manualTitle")}</h2>
<Input
placeholder={t(locale, "library.searchWork")}
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
/>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">
{t(locale, "library.epLabel")}
</span>
<Input
className="w-28"
inputMode="numeric"
placeholder={t(locale, "library.epPlaceholder")}
value={epInput}
onChange={(e) => setEpInput(e.target.value)}
/>
</div>
<p className="text-xs text-muted-foreground">
{t(locale, "library.candidates")}
</p>
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
{(search.data ?? []).map((hit) => (
<li
key={hit.id}
className="flex items-center justify-between p-2 text-sm"
>
<span>{hit.nameCn || hit.name}</span>
<Button
size="sm"
onClick={() =>
bind.mutate({
mediaItemId: matchId,
bangumiId: hit.id,
})
}
className="flex items-center justify-between gap-2 p-2 text-sm"
>
<div className="min-w-0">
<div className="truncate">{hit.nameCn || hit.name}</div>
{hit.nameCn && hit.name && (
<div className="truncate text-xs text-muted-foreground">
{hit.name}
</div>
)}
</div>
<Button size="sm" onClick={() => bindWithEp(hit.id)}>
{t(locale, "library.bind")}
</Button>
</li>
))}
</ul>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setMatchId(null)}>
{t(locale, "library.cancel")}
</Button>
</div>
</div>
</div>
)}
</div>
);

View File

@ -1,135 +1,445 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, Input } from "@app/ui";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
toast,
} from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { go } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
import { formatTrpcError } from "~/lib/trpc-error";
import { cn } from "~/lib/utils";
function MountsInner(props: { locale: Locale }) {
const { locale } = props;
const utils = trpc.useUtils();
const list = trpc.media.mountList.useQuery();
const create = trpc.media.mountCreate.useMutation({
onSuccess: () => utils.media.mountList.invalidate(),
});
const test = trpc.media.mountTest.useMutation();
const del = trpc.media.mountDelete.useMutation({
onSuccess: () => utils.media.mountList.invalidate(),
});
const [form, setForm] = useState({
type FormMode = "create" | "edit";
type FormState = {
name: string;
baseUrl: string;
username: string;
password: string;
rootPath: string;
};
type FieldErrors = Partial<Record<"name" | "baseUrl", string | undefined>>;
function validateForm(form: FormState): FieldErrors {
const errs: FieldErrors = {};
if (!form.name.trim()) errs.name = "name";
const url = form.baseUrl.trim();
if (!url) {
errs.baseUrl = "required";
} else {
try {
const u = new URL(url);
if (u.protocol !== "http:" && u.protocol !== "https:") errs.baseUrl = "protocol";
} catch {
errs.baseUrl = "invalid";
}
}
return errs;
}
const EMPTY_FORM: FormState = {
name: "",
baseUrl: "",
username: "",
password: "",
rootPath: "/",
};
function MountsInner(props: { locale: Locale }) {
const { locale } = props;
const utils = trpc.useUtils();
const list = trpc.media.mountList.useQuery();
const formRef = useRef<HTMLFormElement>(null);
const create = trpc.media.mountCreate.useMutation({
onSuccess: (data) => {
toast.success(t(locale, "mounts.saved"));
void utils.media.mountList.invalidate();
setForm(EMPTY_FORM);
setFieldErrors({});
setCreatedMountId(data.mount.id);
},
onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.saveError"))),
});
const update = trpc.media.mountUpdate.useMutation({
onSuccess: () => {
toast.success(t(locale, "mounts.updated"));
void utils.media.mountList.invalidate();
setMode("create");
setEditId(null);
setForm(EMPTY_FORM);
setFieldErrors({});
},
onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.saveError"))),
});
const test = trpc.media.mountTest.useMutation();
const del = trpc.media.mountDelete.useMutation({
onSuccess: (data) => {
toast.success(
data.removedItems > 0
? t(locale, "mounts.deletedN", { n: data.removedItems })
: t(locale, "mounts.deleted"),
);
void utils.media.mountList.invalidate();
if (editId) {
setMode("create");
setEditId(null);
setForm(EMPTY_FORM);
}
},
onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.deleteError"))),
});
const [mode, setMode] = useState<FormMode>("create");
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [createdMountId, setCreatedMountId] = useState<string | null>(null);
const saving = create.isPending || update.isPending;
const mountList = list.data?.mounts ?? [];
function startEdit(mount: {
id: string;
name: string;
baseUrl: string;
username: string | null;
rootPath: string;
}) {
setMode("edit");
setEditId(mount.id);
setCreatedMountId(null);
test.reset();
setForm({
name: mount.name,
baseUrl: mount.baseUrl,
username: mount.username ?? "",
password: "",
rootPath: mount.rootPath || "/",
});
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
formRef.current?.querySelector<HTMLInputElement>("input")?.focus();
}
function cancelEdit() {
setMode("create");
setEditId(null);
test.reset();
setForm(EMPTY_FORM);
}
useEffect(() => {
if (mode === "edit") return;
if (mountList.length > 0) setCreatedMountId(null);
}, [mountList.length, mode]);
function onSubmit(e: React.FormEvent) {
e.preventDefault();
const errs = validateForm(form);
setFieldErrors(errs);
if (errs.name || errs.baseUrl) {
const firstKey = errs.name ? "mount-name" : "mount-url";
formRef.current?.querySelector<HTMLInputElement>(`#${firstKey}`)?.focus();
return;
}
const base = {
name: form.name.trim(),
baseUrl: form.baseUrl.trim(),
username: form.username.trim() || undefined,
rootPath: form.rootPath.trim() || "/",
};
if (mode === "edit" && editId) {
// 密码留空 = 不修改原凭据
update.mutate({
id: editId,
...base,
...(form.password ? { password: form.password } : {}),
});
} else {
create.mutate({
...base,
type: "webdav",
password: form.password || undefined,
enabled: true,
});
}
}
function onTest() {
test.mutate({
baseUrl: form.baseUrl.trim(),
username: form.username.trim() || undefined,
password: form.password || undefined,
rootPath: form.rootPath.trim() || "/",
});
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl font-semibold">{t(locale, "mounts.title")}</h1>
<p className="text-sm text-muted-foreground">
{t(locale, "mounts.openlistHint")} {t(locale, "mounts.credentialHint")}
</p>
<form
className="grid gap-3 rounded-lg border border-border bg-card p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
create.mutate({
name: form.name,
type: "webdav",
baseUrl: form.baseUrl,
username: form.username || undefined,
password: form.password || undefined,
rootPath: form.rootPath || "/",
enabled: true,
});
<span className="u-mono text-xs text-muted-foreground">
{String(mountList.length).padStart(2, "0")}
</span>
{mode === "edit" ? (
<Button size="sm" variant="outline" className="ml-auto" onClick={cancelEdit}>
{t(locale, "mounts.cancelEdit")}
</Button>
) : (
<Button
size="sm"
className="ml-auto"
onClick={() => {
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
formRef.current?.querySelector<HTMLInputElement>("input")?.focus();
}}
>
{t(locale, "mounts.add")}
</Button>
)}
</div>
<form ref={formRef} className="space-y-4" onSubmit={onSubmit}>
<Card className={cn("space-y-4 p-5", mode === "edit" && "border-primary/50")}>
<div>
<p className="font-medium">
{mode === "edit"
? t(locale, "mounts.editHeading")
: t(locale, "mounts.add")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t(locale, "mounts.credentialHint")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="mount-name">{t(locale, "mounts.fieldName")}</Label>
<Input
id="mount-name"
aria-label={t(locale, "mounts.namePlaceholder")}
placeholder={t(locale, "mounts.namePlaceholder")}
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
onChange={(e) => {
setForm({ ...form, name: e.target.value });
if (fieldErrors.name)
setFieldErrors({ ...fieldErrors, name: undefined });
}}
/>
{fieldErrors.name && (
<p className="text-xs text-destructive">
{t(locale, "mounts.errNameRequired")}
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="mount-url">{t(locale, "mounts.fieldUrl")}</Label>
<Input
id="mount-url"
aria-label={t(locale, "mounts.baseUrlPlaceholder")}
placeholder={t(locale, "mounts.baseUrlPlaceholder")}
value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
required
onChange={(e) => {
setForm({ ...form, baseUrl: e.target.value });
if (fieldErrors.baseUrl) {
setFieldErrors({ ...fieldErrors, baseUrl: undefined });
}
}}
/>
{fieldErrors.baseUrl && (
<p className="text-xs text-destructive">
{t(locale, "mounts.errUrlInvalid")}
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="mount-user">{t(locale, "mounts.fieldUser")}</Label>
<Input
id="mount-user"
aria-label={t(locale, "mounts.usernamePlaceholder")}
placeholder={t(locale, "mounts.usernamePlaceholder")}
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="mount-pass">{t(locale, "mounts.fieldPass")}</Label>
<Input
id="mount-pass"
type="password"
aria-label={t(locale, "mounts.passwordPlaceholder")}
placeholder={t(locale, "mounts.passwordPlaceholder")}
placeholder={
mode === "edit"
? t(locale, "mounts.passwordKeepHint")
: t(locale, "mounts.passwordPlaceholder")
}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="mount-root">{t(locale, "mounts.fieldRoot")}</Label>
<Input
id="mount-root"
aria-label={t(locale, "mounts.rootPathPlaceholder")}
placeholder={t(locale, "mounts.rootPathPlaceholder")}
value={form.rootPath}
onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
/>
<div className="flex gap-2">
<Button type="submit" disabled={create.isPending}>
{t(locale, "mounts.save")}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="submit" disabled={saving}>
{mode === "edit"
? t(locale, "mounts.saveEdit")
: t(locale, "mounts.save")}
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
test.mutate({
baseUrl: form.baseUrl,
username: form.username || undefined,
password: form.password || undefined,
rootPath: form.rootPath || "/",
})
}
disabled={test.isPending || !form.baseUrl.trim()}
onClick={onTest}
>
{t(locale, "mounts.test")}
</Button>
</div>
{mode === "edit" && (
<Button type="button" variant="ghost" onClick={cancelEdit}>
{t(locale, "mounts.cancelEdit")}
</Button>
)}
{test.data && (
<p className={test.data.ok ? "text-green-600" : "text-red-600"}>
<p
className={cn(
"text-sm",
test.data.ok ? "text-green-600" : "text-red-600",
)}
>
{test.data.message}
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{t(locale, "mounts.openlistHint")}
</p>
</Card>
</form>
<ul className="divide-y divide-border rounded-lg border border-border">
{(list.data?.mounts ?? []).map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 p-3">
<div>
<div className="font-medium">{m.name}</div>
<div className="text-xs text-muted-foreground break-all">
{m.baseUrl}
</div>
</div>
<div className="flex gap-2">
{createdMountId && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-primary/40 bg-primary/5 p-4">
<p className="text-sm">{t(locale, "mounts.afterCreateHint")}</p>
<Button
variant="destructive"
size="sm"
onClick={() => del.mutate({ id: m.id })}
className="ml-auto"
onClick={() => go(`/${locale}/browse?mount=${createdMountId}`)}
>
{t(locale, "mounts.delete")}
{t(locale, "mounts.toBrowse")}
</Button>
</div>
</li>
))}
{list.data?.mounts.length === 0 && (
<li className="p-4 text-sm text-muted-foreground">
{t(locale, "mounts.empty")}
</li>
)}
</ul>
<section className="space-y-3">
<p className="u-label">{t(locale, "mounts.listTitle")}</p>
<div className="grid gap-3 md:grid-cols-2">
{mountList.map((m) => (
<Card
key={m.id}
className={cn(
"transition-colors",
editId === m.id && "border-primary/60",
)}
>
<CardHeader className="pb-2">
<div className="flex items-start gap-2">
<CardTitle className="text-base">{m.name}</CardTitle>
<div className="ml-auto flex gap-1.5">
{editId === m.id && (
<Badge className="border-primary/50 text-primary">
{t(locale, "mounts.editing")}
</Badge>
)}
{!m.enabled && (
<Badge variant="outline">
{t(locale, "mounts.disabled")}
</Badge>
)}
</div>
</div>
<CardDescription className="break-all">
{m.baseUrl}
{m.rootPath && m.rootPath !== "/" ? ` · ${m.rootPath}` : ""}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 pt-0">
<Button size="sm" variant="secondary" onClick={() => startEdit(m)}>
{t(locale, "mounts.edit")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => go(`/${locale}/browse?mount=${m.id}`)}
>
{t(locale, "mounts.browseThis")}
</Button>
<Button
size="sm"
variant="destructive"
disabled={del.isPending}
onClick={() => {
if (confirmDeleteId === m.id) {
setConfirmDeleteId(null);
del.mutate({ id: m.id });
} else {
setConfirmDeleteId(m.id);
}
}}
>
{confirmDeleteId === m.id
? t(locale, "mounts.confirmDelete")
: t(locale, "mounts.delete")}
</Button>
{confirmDeleteId === m.id && (
<span className="self-center text-xs text-destructive">
{t(locale, "mounts.deleteHint")}
</span>
)}
</CardContent>
</Card>
))}
{list.isSuccess && mountList.length === 0 && (
<div className="u-empty col-span-full">
<p className="u-empty__title">{t(locale, "mounts.empty")}</p>
<p>{t(locale, "mounts.openlistHint")}</p>
<div className="u-empty__actions">
<Button
size="sm"
onClick={() => {
formRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
formRef.current
?.querySelector<HTMLInputElement>("input")
?.focus();
}}
>
{t(locale, "mounts.add")}
</Button>
</div>
</div>
)}
</div>
</section>
</div>
);
}

View File

@ -1,16 +1,32 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button } from "@app/ui";
import { Button, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Artplayer from "artplayer";
import artplayerPluginDanmuku from "artplayer-plugin-danmuku";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parseDanmakuXml, startDanmakuLoop } from "~/lib/danmaku-render";
import { parseDanmakuXml, thinDanmaku } from "~/lib/danmaku-render";
import { go, queryParam } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
import { cn } from "~/lib/utils";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
type DanmakuPrefs = { enabled: boolean; opacity: number; density: number };
type DanmukuPlugin = {
load: (danmuku: unknown) => Promise<unknown> | unknown;
show: () => unknown;
hide: () => unknown;
};
function danmukuPlugin(art: Artplayer): DanmukuPlugin | null {
const plugins = art.plugins as Record<string, unknown>;
const dm = plugins["artplayerPluginDanmuku"];
if (!dm || typeof dm !== "object") return null;
return dm as DanmukuPlugin;
}
type ProgressInput = {
mediaItemId: string;
positionMs: number;
@ -38,6 +54,35 @@ function readDanmakuPrefs(): DanmakuPrefs {
}
}
function writeDanmakuPrefs(prefs: DanmakuPrefs) {
try {
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(prefs));
} catch {
/* noop */
}
}
const DANMAKU_XML_PREFIX = "media-danmaku-xml:";
/** 单条弹幕 XML 本地持久化上限(localStorage 常规配额约 5MB,留余量) */
const DANMAKU_XML_MAX_BYTES = 2 * 1024 * 1024;
function readLocalXml(mediaItemId: string): string {
try {
return localStorage.getItem(DANMAKU_XML_PREFIX + mediaItemId) ?? "";
} catch {
return "";
}
}
function writeLocalXml(mediaItemId: string, xml: string) {
try {
if (new Blob([xml]).size > DANMAKU_XML_MAX_BYTES) return;
localStorage.setItem(DANMAKU_XML_PREFIX + mediaItemId, xml);
} catch {
/* 配额不足时仅内存生效 */
}
}
/** 页面卸载时 fetch 可能被取消,用 keepalive 走与 httpBatchLink 相同的批格式兜底 */
function sendProgressBeacon(input: ProgressInput) {
const url = "/api/trpc/media.playbackReport?batch=1";
@ -65,14 +110,22 @@ function sendProgressBeacon(input: ProgressInput) {
function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const { locale, mediaItemId } = props;
const wrapperRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<HTMLDivElement>(null);
const artRef = useRef<Artplayer | null>(null);
const progressStartRef = useRef(0);
const prefsRef = useRef<DanmakuPrefs>(readDanmakuPrefs());
const [localXml, setLocalXml] = useState("");
const [danmakuOn, setDanmakuOn] = useState(true);
const [danmakuOpacity, setDanmakuOpacity] = useState(0.8);
const [danmakuDensity, setDanmakuDensity] = useState(1);
const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled);
const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density);
const [ended, setEnded] = useState(false);
const [playError, setPlayError] = useState(false);
// 进入/切换条目时恢复本地导入的弹幕
useEffect(() => {
setLocalXml(readLocalXml(mediaItemId));
setPlayError(false);
setEnded(false);
}, [mediaItemId]);
const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId });
const progress = trpc.media.playbackGet.useQuery({ mediaItemId });
@ -84,10 +137,37 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
reportRef.current = report;
});
const bangumiId = detail.data?.bangumiId ?? null;
const series = trpc.media.librarySeries.useQuery(
{ bangumiId: bangumiId ?? 0 },
{ enabled: bangumiId != null && bangumiId > 0 },
);
const episodes = useMemo(() => {
const list = series.data?.episodes ?? [];
return [...list].sort((a, b) => (a.epNumber ?? 0) - (b.epNumber ?? 0));
}, [series.data]);
const epIndex = episodes.findIndex((ep) => ep.id === mediaItemId);
const prevEp = epIndex > 0 ? episodes[epIndex - 1] : null;
const nextEp = epIndex >= 0 && epIndex < episodes.length - 1 ? episodes[epIndex + 1] : null;
const openEpisode = useCallback(
(id: string) => {
const g = bangumiId != null ? `&from=series&g=${bangumiId}` : "";
go(`/${locale}/watch?id=${id}${g}`);
},
[locale, bangumiId],
);
const goSeries = useCallback(() => {
const g = queryParam("g") ?? (bangumiId != null ? String(bangumiId) : null);
if (g != null) go(`/${locale}/library?g=${g}`);
else go(`/${locale}/library`);
}, [locale, bangumiId]);
useEffect(() => {
const prefs = readDanmakuPrefs();
prefsRef.current = prefs;
setDanmakuOn(prefs.enabled);
setDanmakuOpacity(prefs.opacity);
setDanmakuDensity(prefs.density);
}, []);
@ -101,6 +181,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
useEffect(() => {
const mountEl = playerRef.current;
if (!mountEl || !detailId) return;
const prefs = prefsRef.current;
const art = new Artplayer({
container: mountEl,
url: `/api/stream?id=${encodeURIComponent(mediaItemId)}`,
@ -111,10 +192,56 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
playbackRate: true,
aspectRatio: true,
fullscreen: true,
theme: "#3b82f6",
theme: "#FB7299",
plugins: [
artplayerPluginDanmuku({
danmuku: [],
opacity: prefs.opacity,
fontSize: 25,
speed: 5,
margin: [10, "25%"],
color: "#FFFFFF",
mode: 0,
antiOverlap: true,
visible: prefs.enabled,
emitter: true,
}),
],
});
artRef.current = art;
// 常用快捷键(ArtPlayer 默认不含 f/m,补注册)
art.hotkey.add("KeyF", () => {
art.fullscreen = !art.fullscreen;
});
art.hotkey.add("KeyM", () => {
art.muted = !art.muted;
});
// 播放失败 → 业务级错误卡(含重试);加载成功后清除
const onVideoError = () => {
setPlayError(true);
};
const onVideoReady = () => {
setPlayError(false);
};
art.on("video:error", onVideoError);
art.on("video:loadeddata", onVideoReady);
art.on("playing", onVideoReady);
// 播放器内弹幕设置变更 → 回写本地默认偏好
art.on("artplayerPluginDanmuku:config", (...args: unknown[]) => {
const option = (args[0] ?? {}) as { opacity?: number; visible?: boolean };
const prev = prefsRef.current;
const next: DanmakuPrefs = {
enabled: option.visible ?? prev.enabled,
opacity: typeof option.opacity === "number" ? option.opacity : prev.opacity,
density: prev.density,
};
prefsRef.current = next;
writeDanmakuPrefs(next);
});
const seekToStart = () => {
const target = progressStartRef.current / 1000;
if (target > 0 && art.duration > target) {
@ -146,11 +273,13 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
};
let timer: ReturnType<typeof setInterval> | null = null;
art.on("play", () => {
// video:play / video:pause 是原生事件转发:覆盖 UI 按钮与系统媒体键/程序化暂停
art.on("video:play", () => {
setEnded(false);
if (timer) clearInterval(timer);
timer = setInterval(reportNow, 5000);
});
art.on("pause", () => {
art.on("video:pause", () => {
if (timer) {
clearInterval(timer);
timer = null;
@ -163,6 +292,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
timer = null;
}
reportNow();
setEnded(true);
});
const onVisibility = () => {
@ -194,24 +324,28 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : "";
const items = useMemo(() => {
const all = xmlSource ? parseDanmakuXml(xmlSource) : [];
// density < 1 时按比例抽稀,避免同屏弹幕过密
if (danmakuDensity >= 1) return all;
const keepEvery = Math.max(1, Math.round(1 / danmakuDensity));
return all.filter((_, i) => i % keepEvery === 0);
return thinDanmaku(all, danmakuDensity);
}, [xmlSource, danmakuDensity]);
const danmakuCount = items.length;
// 弹幕数据变化时交给官方插件渲染
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || items.length === 0) return;
const stop = startDanmakuLoop(
wrapper,
() => items,
() => artRef.current?.currentTime ?? 0,
{ opacity: danmakuOpacity },
);
return stop;
}, [items, danmakuOpacity]);
const art = artRef.current;
if (!art) return;
const dm = danmukuPlugin(art);
if (!dm) return;
void dm.load(items);
}, [items]);
// 外部开关同步到插件显隐
useEffect(() => {
const art = artRef.current;
if (!art) return;
const dm = danmukuPlugin(art);
if (!dm) return;
if (danmakuOn) dm.show();
else dm.hide();
}, [danmakuOn]);
const onImportXml = useCallback(
(file: File) => {
@ -219,15 +353,37 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
reader.onload = () => {
const text = String(reader.result ?? "");
setLocalXml(text);
importMeta.mutate({ mediaItemId, byteSize: file.size });
writeLocalXml(mediaItemId, text);
importMeta.mutate(
{ mediaItemId, byteSize: file.size },
{
onSuccess: () => toast.success(t(locale, "watch.importOk")),
onError: () => toast.error(t(locale, "watch.importFail")),
},
);
};
reader.readAsText(file);
},
[importMeta, mediaItemId],
[importMeta, mediaItemId, locale],
);
const replay = useCallback(() => {
const art = artRef.current;
if (!art) return;
setEnded(false);
art.currentTime = 0;
void art.play();
}, []);
const retryStream = useCallback(() => {
const art = artRef.current;
if (!art) return;
setPlayError(false);
art.switchUrl(`/api/stream?id=${encodeURIComponent(mediaItemId)}`);
}, [mediaItemId]);
const goLibrary = useCallback(() => {
window.location.href = `/${locale}/library`;
go(`/${locale}/library`);
}, [locale]);
if (detail.isError) {
@ -257,18 +413,22 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
);
}
return (
<div className="space-y-3">
<h1 className="text-xl font-semibold">{t(locale, "watch.notFound")}</h1>
<p className="text-sm text-muted-foreground">{t(locale, "watch.notFoundHint")}</p>
<Button size="sm" variant="ghost" onClick={goLibrary}>
<div className="u-empty">
<p className="u-empty__title">{t(locale, "watch.notFound")}</p>
<p>{t(locale, "watch.notFoundHint")}</p>
<div className="u-empty__actions">
<Button size="sm" onClick={goLibrary}>
{t(locale, "watch.backToLibrary")}
</Button>
</div>
</div>
);
}
const sourceLabel = !danmakuOn
? t(locale, "watch.danmakuOff")
? localXml
? t(locale, "watch.danmakuDisabledHint")
: t(locale, "watch.danmakuOff")
: localXml
? t(locale, "watch.danmakuCount", { count: danmakuCount })
: openDanmaku.data?.source === "none"
@ -283,14 +443,12 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">{detail.data.title}</h1>
<div className="ml-auto flex gap-2">
<Button
size="sm"
variant={danmakuOn ? "default" : "outline"}
onClick={() => setDanmakuOn((v) => !v)}
>
{danmakuOn ? t(locale, "watch.danmakuOn") : t(locale, "watch.danmakuOff")}
{bangumiId != null && (
<Button size="sm" variant="ghost" onClick={goSeries}>
← {t(locale, "watch.backToSeries")}
</Button>
)}
<div className="ml-auto flex flex-wrap gap-2">
<label className="inline-flex h-9 cursor-pointer items-center rounded-md border border-border px-3 text-sm">
{t(locale, "watch.importXml")}
<input
@ -309,16 +467,113 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
</Button>
</div>
</div>
<div
ref={wrapperRef}
className="relative aspect-video w-full overflow-hidden rounded-lg bg-black"
>
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
<div ref={playerRef} className="absolute inset-0" />
{playError && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/80 p-6 text-center">
<p className="text-base font-medium text-white">
{t(locale, "watch.playFailed")}
</p>
<p className="max-w-md text-sm text-white/70">
{t(locale, "watch.playFailedHint")}
</p>
<div className="flex gap-2">
<Button size="sm" onClick={retryStream}>
{t(locale, "error.retry")}
</Button>
<Button size="sm" variant="outline" onClick={goLibrary}>
{t(locale, "watch.backToLibrary")}
</Button>
</div>
<div className="flex justify-between text-sm text-muted-foreground">
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{sourceLabel}</span>
<span>{t(locale, "watch.progressHint")}</span>
</div>
{episodes.length > 0 && (
<section className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="u-label">{t(locale, "watch.episodes")}</p>
<span className="u-dotline flex-1" aria-hidden="true" />
<Button
size="sm"
variant="outline"
disabled={!prevEp}
onClick={() => prevEp && openEpisode(prevEp.id)}
>
← {t(locale, "watch.prevEpisode")}
</Button>
<Button
size="sm"
variant="outline"
disabled={!nextEp}
onClick={() => nextEp && openEpisode(nextEp.id)}
>
{t(locale, "watch.nextEpisode")} →
</Button>
</div>
<ul className="flex flex-wrap gap-2">
{episodes.map((ep) => {
const current = ep.id === mediaItemId;
return (
<li key={ep.id}>
<button
type="button"
className={cn(
"rounded-md border px-3 py-1.5 text-sm transition-colors",
current
? "border-primary bg-primary/10 font-medium text-primary"
: "border-border text-muted-foreground hover:border-primary/60 hover:text-foreground",
)}
onClick={() => !current && openEpisode(ep.id)}
>
{ep.epNumber != null
? t(locale, "watch.episodeN", { n: ep.epNumber })
: ep.rawName}
{current && (
<span className="ml-2 text-[11px]">
{t(locale, "watch.currentEpisode")}
</span>
)}
</button>
</li>
);
})}
</ul>
</section>
)}
{ended && nextEp && (
<div className="flex items-center gap-3 rounded-lg border border-primary/40 bg-primary/5 p-4">
<p className="text-sm">
{t(locale, "watch.episodeN", {
n: nextEp.epNumber ?? episodes.indexOf(nextEp) + 1,
})}
</p>
<Button size="sm" className="ml-auto" onClick={() => openEpisode(nextEp.id)}>
{t(locale, "watch.playNextCta")}
</Button>
</div>
)}
{ended && !nextEp && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-primary/40 bg-primary/5 p-4">
<p className="text-sm">{t(locale, "watch.playbackFinished")}</p>
<div className="ml-auto flex gap-2">
<Button size="sm" variant="outline" onClick={replay}>
{t(locale, "watch.replay")}
</Button>
<Button size="sm" onClick={bangumiId != null ? goSeries : goLibrary}>
{bangumiId != null
? t(locale, "watch.backToSeries")
: t(locale, "watch.backToLibrary")}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@ -1,4 +1,5 @@
---
import { ClientRouter } from "astro:transitions";
import "../styles/globals.css";
import UserMenu from "~/components/UserMenu";
import ThemeSwitch from "~/components/ThemeSwitch";
@ -9,20 +10,19 @@ import {
toJsonLdScript,
type PageSeoInput,
} from "@app/seo-geo";
import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, t } from "@app/i18n";
import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, localePath, t } from "@app/i18n";
import { getSiteSeo } from "~/lib/seo";
export interface Props {
title: string;
locale: Locale;
brand?: string;
nav?: { label: string; href: string; n: string }[];
/** 当前页,用于侧栏高亮 */
active?: "home" | "library" | "browse" | "mounts" | "danmaku";
switchHref?: string;
switchLabel?: string;
seo?: PageSeoInput;
/** 媒体页只保留 AppShell 侧栏时隐藏模板 side-rail(含左让位) */
hideSideRail?: boolean;
/** 媒体页隐藏模板页脚 */
/** 播放页隐藏模板页脚 */
hideFooter?: boolean;
}
@ -30,16 +30,14 @@ const {
title,
locale,
brand,
nav: navProp,
active,
switchHref,
switchLabel,
seo,
hideSideRail = false,
hideFooter = false,
} = Astro.props;
const brandName = brand ?? t(locale, "meta.brand");
const nav = navProp ?? [];
const siteSeo = getSiteSeo(locale);
const pageSeoInput: PageSeoInput = {
@ -52,10 +50,18 @@ const headHtml = renderHeadTagsHtml(headTags);
const jsonLdHtml = toJsonLdScript(buildDefaultJsonLd(siteSeo, pageSeoInput));
const pageTitle = headTags.find((tTag) => tTag.key === "og:title")?.value ?? title;
const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
const libraryHref = locale === "zh-CN" ? "/zh-CN/library" : "/en/library";
const brandHref = hideSideRail ? libraryHref : homeHref;
const homeHref = localePath(locale, "/");
const brandHref = homeHref;
const brandInitial = brandName.charAt(0) || "弹";
/** 全站统一侧栏导航 */
const nav = [
{ id: "home", n: "00", label: t(locale, "nav.home"), href: homeHref },
{ id: "library", n: "01", label: t(locale, "nav.library"), href: localePath(locale, "/library") },
{ id: "browse", n: "02", label: t(locale, "nav.browse"), href: localePath(locale, "/browse") },
{ id: "mounts", n: "03", label: t(locale, "nav.mounts"), href: localePath(locale, "/mounts") },
{ id: "danmaku", n: "04", label: t(locale, "nav.danmaku"), href: localePath(locale, "/danmaku") },
] as const;
---
<!doctype html>
@ -77,6 +83,7 @@ const brandInitial = brandName.charAt(0) || "弹";
<title set:html={pageTitle} />
<Fragment set:html={headHtml} />
<Fragment set:html={jsonLdHtml} />
<ClientRouter />
<script is:inline define:vars={{ cookieName: COOKIE_LANG, maxAge: COOKIE_LANG_MAX_AGE, nextLocale: locale === "zh-CN" ? "en" : "zh-CN" }}>
(function () {
function apply(mode) {
@ -88,12 +95,21 @@ const brandInitial = brandName.charAt(0) || "弹";
document.documentElement.classList.toggle("light", !dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
}
function applyFromStorage() {
var mode = "system";
try {
var t = localStorage.getItem("app-theme");
if (t === "light" || t === "dark" || t === "system") mode = t;
} catch (e) {}
apply(mode);
}
applyFromStorage();
// ClientRouter 软导航后 <html> class 会被新页覆盖,需按偏好重放主题
document.addEventListener("astro:after-swap", applyFromStorage);
if (!window.__fanmuChromeInit) {
window.__fanmuChromeInit = true;
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", function () {
@ -118,6 +134,7 @@ const brandInitial = brandName.charAt(0) || "弹";
},
true,
);
}
})();
</script>
</head>
@ -133,7 +150,6 @@ const brandInitial = brandName.charAt(0) || "弹";
<div aria-hidden="true" class="bg-vignette pointer-events-none fixed inset-0 z-0"></div>
{
!hideSideRail && (
<aside class="side-rail" aria-label={t(locale, "nav.primary")}>
<a href={brandHref} class="side-rail__brand" aria-label={brandName}>
<span
@ -146,12 +162,12 @@ const brandInitial = brandName.charAt(0) || "弹";
</a>
<div class="side-rail__rule" aria-hidden="true"></div>
<nav class="side-rail__nav">
<a href={brandHref} class="side-rail__link is-home">
<span class="u-mono">00</span>
<span>{t(locale, "nav.home")}</span>
</a>
{nav.map((item) => (
<a href={item.href} class="side-rail__link">
<a
href={item.href}
class:list={["side-rail__link", { "is-active": active === item.id }]}
aria-current={active === item.id ? "page" : undefined}
>
<span class="u-mono">{item.n}</span>
<span>{item.label}</span>
</a>
@ -159,16 +175,15 @@ const brandInitial = brandName.charAt(0) || "弹";
</nav>
<div class="side-rail__rule" aria-hidden="true"></div>
<div class="side-rail__foot">
<ThemeSwitch client:visible />
<ThemeSwitch client:only="react" />
</div>
</aside>
)
}
<div class:list={["site-shell", { "site-shell--no-rail": hideSideRail }]}>
<div class="site-shell">
<header class="site-header">
<div class="site-header__inner">
<a href={brandHref} class="site-header__brand md:hidden" aria-label={brandName}>
<a href={brandHref} class="site-header__brand lg:hidden" aria-label={brandName}>
<span
class="brand-signal flex size-7 items-center justify-center text-[11px] font-bold"
style="background: var(--edge); color: #101110;"
@ -180,7 +195,11 @@ const brandInitial = brandName.charAt(0) || "弹";
<nav class="site-header__nav" aria-label={t(locale, "nav.primary")}>
{
nav.map((item) => (
<a href={item.href} class="u-linelink">
<a
href={item.href}
class:list={["u-linelink", { "is-active": active === item.id }]}
aria-current={active === item.id ? "page" : undefined}
>
<span class="u-mono">{item.n}</span>
<span>{item.label}</span>
</a>
@ -201,10 +220,10 @@ const brandInitial = brandName.charAt(0) || "弹";
</a>
)
}
<span class={hideSideRail ? "" : "md:hidden"}>
<ThemeSwitch client:visible />
<span class="lg:hidden">
<ThemeSwitch client:only="react" />
</span>
<UserMenu client:visible locale={locale} />
<UserMenu client:only="react" locale={locale} />
</div>
</div>
<div class="site-header__dash" aria-hidden="true"></div>

View File

@ -1,95 +1,49 @@
export type DanmakuItem = { time: number; text: string; mode: number };
export type DanmakuItem = {
time: number;
text: string;
/** 0 滚动,1 顶部,2 底部(artplayer-plugin-danmuku) */
mode: 0 | 1 | 2;
color?: string | undefined;
};
/** B 站 p 属性 mode → artplayer 插件 mode */
function toPluginMode(raw: number): 0 | 1 | 2 {
// 1/2/3 滚动,4 底部,5 顶部
if (raw === 4) return 2;
if (raw === 5) return 1;
return 0;
}
function toHexColor(decimal: number): string | undefined {
if (!Number.isFinite(decimal) || decimal < 0) return undefined;
return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`;
}
/** 解析 B 站弹幕 XML(含颜色/模式),供 artplayer-plugin-danmuku 使用 */
export function parseDanmakuXml(xml: string): DanmakuItem[] {
const doc = new DOMParser().parseFromString(xml, "application/xml");
const nodes = Array.from(doc.querySelectorAll("d"));
const out: DanmakuItem[] = [];
for (const n of nodes) {
const p = n.getAttribute("p") ?? "";
const [t, mode] = p.split(",");
const [t, modeRaw, , colorRaw] = p.split(",");
const time = Number(t);
if (!Number.isFinite(time)) continue;
out.push({ time, text: n.textContent ?? "", mode: Number(mode) || 1 });
const text = (n.textContent ?? "").trim();
if (!text) continue;
out.push({
time,
text,
mode: toPluginMode(Number(modeRaw) || 1),
color: toHexColor(Number(colorRaw)),
});
}
return out.sort((a, b) => a.time - b.time);
}
export type DanmakuLoopOptions = {
opacity?: number | undefined;
/** 每秒横向位移 px */
speed?: number | undefined;
};
export function startDanmakuLoop(
container: HTMLElement,
getItems: () => DanmakuItem[],
getCurrentTime: () => number,
options?: DanmakuLoopOptions,
): () => void {
const layer = document.createElement("div");
layer.className = "absolute inset-0 overflow-hidden pointer-events-none";
layer.style.position = "absolute";
layer.style.zIndex = "5";
const opacity = options?.opacity;
if (typeof opacity === "number") {
layer.style.opacity = String(Math.min(1, Math.max(0, opacity)));
}
container.style.position = container.style.position || "relative";
container.appendChild(layer);
const active: { el: HTMLDivElement; start: number }[] = [];
const speed = options?.speed ?? 160;
let raf = 0;
let cursor = 0;
let lastTime = 0;
const clearActive = () => {
for (const a of active) a.el.remove();
active.length = 0;
};
const tick = () => {
const now = getCurrentTime();
const items = getItems();
// 进度回跳时重置游标,避免一次性涌出全部历史弹幕
if (now + 0.5 < lastTime) {
cursor = 0;
clearActive();
}
lastTime = now;
while (cursor < items.length) {
const it = items[cursor];
if (!it || it.time > now) break;
cursor += 1;
// 快进/加载导致的过期弹幕直接丢弃,防止一帧内刷屏
if (it.time < now - 2) continue;
const el = document.createElement("div");
el.textContent = it.text;
el.style.cssText =
"position:absolute;left:100%;white-space:nowrap;color:#fff;font-size:22px;text-shadow:0 1px 2px #000;will-change:transform;";
const maxY = Math.max(0, container.clientHeight - 40);
el.style.top = `${Math.random() * maxY}px`;
layer.appendChild(el);
active.push({ el, start: performance.now() });
}
const t = performance.now();
for (let i = active.length - 1; i >= 0; i -= 1) {
const a = active[i];
if (!a) continue;
const elapsed = (t - a.start) / 1000;
const x = container.clientWidth - elapsed * speed;
a.el.style.transform = `translateX(${x - container.clientWidth}px)`;
if (x < -a.el.offsetWidth) {
a.el.remove();
active.splice(i, 1);
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
clearActive();
layer.remove();
};
/** density < 1 时按比例抽稀,避免同屏过密 */
export function thinDanmaku(items: DanmakuItem[], density: number): DanmakuItem[] {
if (density >= 1) return items;
const keepEvery = Math.max(1, Math.round(1 / density));
return items.filter((_, i) => i % keepEvery === 0);
}

23
app/web/src/lib/nav.ts Normal file
View File

@ -0,0 +1,23 @@
import { navigate } from "astro:transitions/client";
/** 站内软导航(ClientRouter),避免整页 GET。 */
export function go(href: string): void {
void navigate(href);
}
/** 读取当前 URL 查询参数(浏览器侧)。 */
export function queryParam(name: string): string | null {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get(name);
}
/** 更新查询参数(不刷新,便于刷新/分享后停留在同一选集)。 */
export function setQueryParams(params: Record<string, string | null>): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
for (const [key, value] of Object.entries(params)) {
if (value == null) url.searchParams.delete(key);
else url.searchParams.set(key, value);
}
window.history.replaceState(null, "", url.toString());
}

View File

@ -29,11 +29,11 @@ export function getSiteSeo(locale: Locale): SiteSeoConfig {
organization: { name, url: siteUrl },
llms: {
summary: isZh
? "弹弹 Web:多用户 Web 媒体中心。WebDAV 挂载、媒体库扫描、Bangumi 刮削、ArtPlayer 播放与弹弹play 开放弹幕网络中继,本地 XML 兜底。"
: "DanDan Web: multi-user web media hub. WebDAV mounts, library scan, Bangumi scraping, ArtPlayer playback, dandanplay open danmaku relay with local XML fallback.",
? "帆幕:私有影音库与弹幕观影。WebDAV 挂载、媒体库扫描、Bangumi 刮削、ArtPlayer 播放与弹弹play 开放弹幕网络中继,本地 XML 兜底。"
: "Fanmu: private media library with danmaku. WebDAV mounts, library scan, Bangumi scraping, ArtPlayer playback, dandanplay open danmaku relay with local XML fallback.",
details: [
isZh
? "分层:types → models → db → dao → trpc → web。媒体域按垂直切片扩展。"
? "分层:types → models → db → dao → trpc → web。媒体域按业务分层扩展。"
: "Layers: types → models → db → dao → trpc → web. Extend the media domain as a vertical slice.",
isZh
? "挂载凭据仅存服务端加密;浏览器不直连网盘密码。"

View File

@ -0,0 +1,26 @@
/** tRPC 错误 → 用户可读文案:提取 zod 数组消息、兜底 HTML/网络异常。 */
export function formatTrpcError(err: unknown, fallback: string): string {
const raw = err instanceof Error ? err.message : typeof err === "string" ? err : "";
if (!raw) return fallback;
if (raw.startsWith("<") || raw.includes("<!DOCTYPE")) {
return "服务暂时不可用,请稍后重试";
}
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
const msgs = parsed
.map((e) =>
e && typeof e === "object" ? (e as { message?: unknown }).message : null,
)
.filter((m): m is string => typeof m === "string" && m.length > 0);
if (msgs.length > 0) return msgs.join(";");
}
if (parsed && typeof parsed === "object" && "message" in parsed) {
const m = (parsed as { message?: unknown }).message;
if (typeof m === "string" && m) return m;
}
} catch {
/* 非 JSON,按原文处理 */
}
return raw;
}

View File

@ -8,6 +8,26 @@ import type { AppRouter } from "@app/trpc";
export const trpc = createTRPCReact<AppRouter>();
/** 网关/框架可能对 /api/trpc 返回 HTML 错误页:转换成 JSON 错误,避免客户端 JSON.parse 抛原始异常给用户 */
async function safeFetch(input: RequestInfo | URL, init?: unknown): Promise<Response> {
const res = await fetch(input, init as RequestInit | undefined);
const contentType = res.headers.get("content-type") ?? "";
if (!res.ok && !contentType.includes("application/json")) {
const message = res.status >= 500 ? "服务暂时不可用,请稍后重试" : "请求失败,请重试";
return new Response(
JSON.stringify({
error: {
message,
code: -32603,
data: { code: "INTERNAL_SERVER_ERROR", httpStatus: res.status },
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return res;
}
export function createTRPCReactClient() {
return trpc.createClient({
links: [
@ -15,6 +35,7 @@ export function createTRPCReactClient() {
url: "/api/trpc",
// 便于服务端日志区分 island 流量
headers: () => ({ "x-trpc-source": "react-island" }),
fetch: safeFetch,
}),
],
});

View File

@ -69,7 +69,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
// 小写 zh-cn → 规范 zh-CN(保留 rest)
const seg = path.split("/").filter(Boolean)[0];
if (seg && seg.toLowerCase() === "zh-cn" && seg !== "zh-CN") {
const rest = path.slice(seg.length) || "/";
// 去掉 "/seg" 本身,剩余为 "/rest"(off-by-one 会产生 /zh-CNn)
const rest = path.slice(seg.length + 1) || "/";
return context.redirect(`/zh-CN${rest === "/" ? "" : rest}`);
}

View File

@ -1,12 +1,16 @@
---
import { COOKIE_LANG, resolvePreferredLocale } from "@app/i18n";
import { COOKIE_LANG, isLocale, resolvePreferredLocale } from "@app/i18n";
import ErrorViewPage from "~/views/error.astro";
// Astro 全局 404:按 cookie / Accept-Language 选 locale(无 [locale] 段)
const locale = resolvePreferredLocale(
// Astro 全局 404:优先跟随 URL 的 locale 段(/zh-CN/* 显示中文),否则按 cookie / Accept-Language
const seg = Astro.url.pathname.split("/")[1];
const pathLocale = seg && isLocale(seg) ? seg : null;
const locale =
pathLocale ??
resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
);
Astro.response.status = 404;
---

View File

@ -1,6 +1,5 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Dashboard from "~/views/dashboard.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
@ -14,6 +13,8 @@ if (!normalized || !isLocale(normalized)) {
}
const locale: Locale = normalized;
---
<Dashboard locale={locale} />
// /dashboard 是旧路由:登录态(middleware 已鉴权)在页面渲染前重定向到媒体库。
// 必须在页面层 redirect —— 放在子组件里会与流式渲染冲突抛 ResponseSentError。
return Astro.redirect(localePath(locale, "/library"));
---

View File

@ -1,7 +1,7 @@
/** 认证后的 Range 流代理:鉴权在本路由,webdav 细节封装在 @app/trpc 的 openRemoteFileStream。 */
import type { APIRoute } from "astro";
import { COOKIE_SESSION } from "@app/types";
import { authService, openRemoteFileStream } from "@app/trpc";
import { authService, openRemoteFileStream, StreamSourceError } from "@app/trpc";
export const prerender = false;
@ -25,6 +25,9 @@ export const GET: APIRoute = async ({ request, cookies, url }) => {
headers: result.headers,
});
} catch (e) {
if (e instanceof StreamSourceError) {
return new Response(e.message, { status: e.status });
}
const msg = e instanceof Error ? e.message : "stream failed";
return new Response(msg, { status: 502 });
}

View File

@ -5,13 +5,11 @@ export const prerender = false;
export const GET: APIRoute = async ({ request }) => {
// 内部转发到 tRPC handler(同进程内,避免跨网络)
// tRPC v11 的 query 走 GET,input 放 query 参数
const url = new URL(request.url);
url.pathname = "/api/trpc/user.list";
const req = new Request(url.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ json: { limit: 20, offset: 0 } }),
});
url.search = `?input=${encodeURIComponent(JSON.stringify({ limit: 20, offset: 0 }))}`;
const req = new Request(url.toString(), { method: "GET" });
// 调用同一个 Astro 进程的 tRPC endpoint
const res = await fetch(req);

View File

@ -33,7 +33,7 @@ function buildFor(locale: Locale): string {
summary: t(locale, "home.layerBody"),
points: [
"多用户:每人独立媒体库与播放进度",
"媒体页 shadcn Dashboard 导航:媒体库 / 浏览 / 挂载 / 弹幕设置",
"统一左侧边栏导航:首页 / 媒体库 / 文件浏览 / 挂载管理 / 弹幕设置",
],
},
],

View File

@ -1,6 +1,9 @@
@import "tailwindcss";
@import "@app/design-tokens/tokens.css";
/* 扫描 monorepo 里的 UI 包,避免 dropdown 等组件类未生成 */
@source "../../../../packages/ui/src";
*,
*::before,
*::after {
@ -8,6 +11,126 @@
border-color: var(--line);
}
/* 原生控件跟品牌色,避免浏览器默认蓝 */
input,
select,
textarea,
button {
font: inherit;
accent-color: var(--edge);
}
select {
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
background-position:
calc(100% - 16px) calc(50% + 2px),
calc(100% - 11px) calc(50% + 2px);
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
padding-right: 2rem;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 22px;
cursor: pointer;
background: transparent;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: var(--line);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
margin-top: -5px;
border: none;
border-radius: 50%;
background: var(--edge);
box-shadow: 0 1px 3px color-mix(in srgb, #000 30%, transparent);
}
input[type="range"]::-moz-range-track {
height: 6px;
border-radius: 3px;
background: var(--line);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background: var(--edge);
}
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--edge);
/* Chrome 部分主题下忽略 accent-color,强制品牌色勾选框 */
appearance: none;
-webkit-appearance: none;
border: 1px solid var(--line-strong);
border-radius: 4px;
background: var(--panel);
display: inline-grid;
place-content: center;
}
input[type="checkbox"]::before {
content: "";
width: 10px;
height: 10px;
transform: scale(0);
transition: transform 0.12s ease;
background: #101110;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
input[type="checkbox"]:checked {
background: var(--edge);
border-color: var(--edge);
}
input[type="checkbox"]:checked::before {
transform: scale(1);
}
/* 空态:居中卡片,避免大片死白 */
.u-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-height: 220px;
padding: 2.5rem 1.5rem;
text-align: center;
border: 1px dashed var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 55%, transparent);
color: var(--ink-2);
font-size: 14px;
}
.u-empty__title {
color: var(--ink);
font-size: 15px;
font-weight: 600;
}
.u-empty__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
margin-top: 0.35rem;
}
html {
font-family: var(--font-sans);
scroll-behavior: smooth;
@ -241,19 +364,25 @@ html.has-reveal astro-island .u-reveal {
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.u-linelink:hover,
.u-linelink:focus-visible {
.u-linelink:focus-visible,
.u-linelink.is-active {
color: var(--ink);
}
.u-linelink:hover::after,
.u-linelink:focus-visible::after {
transform: scaleX(1);
}
.u-linelink.is-active::after {
transform: scaleX(1);
height: 2px;
}
.u-linelink .u-mono {
font-size: 10px;
letter-spacing: 0.12em;
color: var(--ink-3);
}
.u-linelink:hover .u-mono {
.u-linelink:hover .u-mono,
.u-linelink.is-active .u-mono {
color: var(--edge-as-text);
}
@ -302,7 +431,8 @@ html.has-reveal astro-island .u-reveal {
/* —— 卡片:5px 圆角 + 轻阴影 —— */
.u-card {
background: var(--panel);
position: relative;
background: color-mix(in srgb, var(--panel) 92%, var(--ink) 2%);
border: 1px solid var(--line);
border-radius: 5px;
box-shadow: 0 7px 12px color-mix(in srgb, #000 18%, transparent);
@ -356,6 +486,24 @@ html.has-reveal astro-island .u-reveal {
letter-spacing: 0.08em;
}
/* 流水线读出条 */
.u-hud-strip {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.65rem 0.9rem;
border: 1px solid var(--line);
border-radius: 5px;
background: color-mix(in srgb, var(--panel) 70%, transparent);
color: var(--ink-3);
font-size: 10px;
letter-spacing: 0.18em;
}
.u-hud-strip .u-mono {
flex-shrink: 0;
}
/* —— 左侧固定竖栏 —— */
.side-rail {
display: none;
@ -431,13 +579,27 @@ html.has-reveal astro-island .u-reveal {
}
.side-rail__link:hover,
.side-rail__link:focus-visible {
background: color-mix(in srgb, var(--edge) 10%, transparent);
border-color: color-mix(in srgb, var(--edge) 35%, var(--line));
background: color-mix(in srgb, var(--edge) 8%, transparent);
border-color: color-mix(in srgb, var(--edge) 30%, var(--line));
color: var(--ink);
}
.side-rail__link:hover .u-mono {
.side-rail__link.is-active {
background: color-mix(in srgb, var(--edge) 14%, var(--sink));
border-color: color-mix(in srgb, var(--edge) 45%, var(--line));
color: var(--ink);
box-shadow: inset 2px 0 0 var(--edge);
}
.side-rail__link:hover .u-mono,
.side-rail__link:focus-visible .u-mono {
color: var(--edge-as-text);
}
.side-rail__link.is-active .u-mono {
color: var(--edge-as-text);
}
.light .side-rail__link.is-active {
background: color-mix(in srgb, var(--edge) 12%, var(--panel));
box-shadow: inset 2px 0 0 var(--edge-deep);
}
.side-rail__foot {
display: flex;
flex-direction: column;
@ -471,18 +633,12 @@ html.has-reveal astro-island .u-reveal {
padding-left: 8.5rem;
padding-right: 3.5rem;
}
.site-shell--no-rail {
padding-left: 3.5rem;
}
}
@media (min-width: 1280px) {
.site-shell {
padding-left: 10.5rem;
padding-right: 5rem;
}
.site-shell--no-rail {
padding-left: 5rem;
}
}
.site-shell--page {
height: 100dvh;
@ -541,18 +697,25 @@ html.has-reveal astro-island .u-reveal {
text-decoration: none;
color: var(--ink);
}
.site-header__nav {
/* 桌面已有侧栏品牌,顶栏品牌隐藏(Tailwind lg:hidden 会被上面的 display:flex 压掉) */
@media (min-width: 1024px) {
.site-header__brand {
display: none;
}
}
.site-header__nav {
display: flex;
align-items: center;
gap: 1.35rem;
font-size: 13px;
overflow-x: auto;
min-width: 0;
scrollbar-width: none;
}
@media (min-width: 768px) {
.site-header__nav {
display: flex;
}
.site-header__nav::-webkit-scrollbar {
display: none;
}
/* 桌面已有侧栏时,顶栏可隐藏重复分类导航,仅保留品牌/操作 */
/* 桌面已有侧栏时,顶栏隐藏重复分类导航,仅保留品牌/操作 */
@media (min-width: 1024px) {
.site-header__nav {
display: none;

View File

@ -1,7 +1,6 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import AuthForm from "~/components/AuthForm";
import AuthBody from "~/components/pages/AuthBody";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
@ -24,18 +23,16 @@ const titleKey =
: mode === "reset"
? "auth.reset.title"
: "auth.settings.title";
const pageTitle = `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`;
---
<AppLayout
title={`${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`}
title={pageTitle}
locale={locale}
nav={[]}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{ title: `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }}
seo={{ title: pageTitle, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }}
>
<div class="flex min-h-[60vh] items-center justify-center px-4 py-16">
<AuthForm client:load locale={locale} mode={mode} next={next} defaultToken={token} />
</div>
<Toaster client:load />
<AuthBody client:load locale={locale} mode={mode} next={next} token={token} />
</AppLayout>

View File

@ -1,7 +1,6 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { BrowseBody } from "~/components/pages/BrowseBody";
import AppLayout from "~/layouts/AppLayout.astro";
@ -11,22 +10,12 @@ export interface Props {
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"meta.product": t(locale, "meta.product"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "browse.title")}
locale={locale}
nav={[]}
hideSideRail
hideFooter
active="browse"
brand={t(locale, "meta.product")}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
@ -36,9 +25,6 @@ const labels: Record<string, string> = {
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="browse" labels={labels}>
<BrowseBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -1,7 +1,6 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { DanmakuSettingsBody } from "~/components/pages/DanmakuSettingsBody";
import AppLayout from "~/layouts/AppLayout.astro";
@ -11,22 +10,12 @@ export interface Props {
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"meta.product": t(locale, "meta.product"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "danmaku.title")}
locale={locale}
nav={[]}
hideSideRail
hideFooter
active="danmaku"
brand={t(locale, "meta.product")}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
@ -36,8 +25,6 @@ const labels: Record<string, string> = {
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="danmaku" labels={labels}>
<DanmakuSettingsBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -1,29 +0,0 @@
---
import { alternatePath, type Locale, localePath, t } from "@app/i18n";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
// 产品:/dashboard 即媒体库(保留路由以免旧链接与 e2e 404)
return Astro.redirect(localePath(locale, "/library"));
---
<AppLayout
title={t(locale, "dashboard.title")}
locale={locale}
nav={[]}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "dashboard.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<p class="py-10 text-sm text-muted-foreground">{t(locale, "dashboard.desc")}</p>
</AppLayout>

View File

@ -1,6 +1,6 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { ErrorView } from "~/components/errors/ErrorView";
import ErrorBody from "~/components/pages/ErrorBody";
import { createAppError, type AppErrorStatus } from "~/lib/errors";
import AppLayout from "~/layouts/AppLayout.astro";
@ -12,7 +12,6 @@ export interface Props {
const { locale, status } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const error = createAppError(status);
const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
const titleKey = `error.${error.kind}.title` as never;
const pageTitle = t(locale, titleKey);
---
@ -20,7 +19,6 @@ const pageTitle = t(locale, titleKey);
<AppLayout
title={`${pageTitle} · ${t(locale, "meta.brand")}`}
locale={locale}
nav={[]}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
@ -29,7 +27,5 @@ const pageTitle = t(locale, titleKey);
robots: ["noindex", "nofollow"],
}}
>
<div class="flex min-h-[55vh] items-center justify-center px-4 py-16">
<ErrorView error={error} locale={locale} homeHref={homeHref} />
</div>
<ErrorBody client:load locale={locale} status={status} />
</AppLayout>

View File

@ -15,7 +15,7 @@ const switchHref = alternatePath(Astro.url.pathname, otherLocale);
<AppLayout
title={t(locale, "home.title")}
locale={locale}
nav={[]}
active="home"
switchHref={switchHref}
switchLabel={t(otherLocale, `locale.${otherLocale}` as never)}
>

View File

@ -1,7 +1,6 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { LibraryBody } from "~/components/pages/LibraryBody";
import AppLayout from "~/layouts/AppLayout.astro";
@ -11,22 +10,12 @@ export interface Props {
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"meta.product": t(locale, "meta.product"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "library.title")}
locale={locale}
nav={[]}
hideSideRail
hideFooter
active="library"
brand={t(locale, "meta.product")}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
@ -36,8 +25,6 @@ const labels: Record<string, string> = {
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="library" labels={labels}>
<LibraryBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -1,7 +1,6 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { MountsBody } from "~/components/pages/MountsBody";
import AppLayout from "~/layouts/AppLayout.astro";
@ -11,22 +10,12 @@ export interface Props {
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"meta.product": t(locale, "meta.product"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "mounts.title")}
locale={locale}
nav={[]}
hideSideRail
hideFooter
active="mounts"
brand={t(locale, "meta.product")}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
@ -36,8 +25,6 @@ const labels: Record<string, string> = {
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="mounts" labels={labels}>
<MountsBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -1,7 +1,6 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { WatchBody } from "~/components/pages/WatchBody";
import AppLayout from "~/layouts/AppLayout.astro";
@ -12,23 +11,13 @@ export interface Props {
const { locale, mediaItemId } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"meta.product": t(locale, "meta.product"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "watch.title")}
locale={locale}
nav={[]}
hideSideRail
hideFooter
brand={t(locale, "meta.product")}
hideFooter
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
@ -37,8 +26,6 @@ const labels: Record<string, string> = {
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="watch" labels={labels}>
<WatchBody locale={locale} mediaItemId={mediaItemId} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

BIN
assets/brand/fanmu-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

View File

@ -35,6 +35,8 @@
"includes": [
"**",
"!**/storybook-static",
"!**/.vitepress/cache",
"!**/.vitepress/dist",
"!**/dist",
"!**/.turbo",
"!**/node_modules",

View File

@ -2,7 +2,7 @@
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 在 `app-template` 产品化全栈上实现多用户 Web 媒体中心:WebDAV 挂载(含 OpenList)→ 扫描/Bangumi 刮削 → ArtPlayer 播放 + 弹幕(开放网络中继/本地 XML)→ 进度与 shadcn 风格 dashboard UI。
**目标:** 在 本产品仓 产品化全栈上实现多用户 Web 媒体中心:WebDAV 挂载(含 OpenList)→ 扫描/Bangumi 刮削 → ArtPlayer 播放 + 弹幕(开放网络中继/本地 XML)→ 进度与 shadcn 风格 dashboard UI。
**架构:** 方案 A 紧凑垂直切片 `media`:types Zod → models 表 → dao → trpc service/router(挂载代理、stream、刮削、弹幕、库、进度)→ `@app/web` 薄 SSR + React islands。浏览器不持网盘密码;流经 `/api/stream` Range 代理。
@ -1848,7 +1848,7 @@ export function AppShell(props: {
<div className="flex min-h-screen bg-background text-foreground">
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4">
<div className="mb-6 px-2 text-sm font-semibold tracking-wide">
{props.labels["meta.brand"] ?? "弹弹 Web"}
{props.labels["meta.brand"] ?? "帆幕"}
</div>
<nav className="flex flex-col gap-1" aria-label="sidebar">
{NAV.map((item) => (

View File

@ -2,7 +2,7 @@
- **日期**:2026-09-25
- **状态**:已获设计批准,待实现计划(writing-plans)
- **仓库**:`app-template`(按**产品仓**维护,业务进入现有分层)
- **仓库**:本产品仓(按**产品仓**维护,业务进入现有分层)
## 1. 背景与调研结论

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

25
e2e/api.spec.ts Normal file
View File

@ -0,0 +1,25 @@
import { expect, test } from "@playwright/test";
test("GET /api/users 转发 tRPC user.list 返回用户列表", async ({ request }) => {
const res = await request.get("/api/users");
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(Array.isArray(body?.result?.data?.users)).toBe(true);
// 安全:任何对外出口不得泄露 passwordHash
expect(JSON.stringify(body)).not.toContain("passwordHash");
});
test("tRPC user.list 直接调用不返回 passwordHash", async ({ request }) => {
const input = encodeURIComponent(JSON.stringify({ limit: 20, offset: 0 }));
const res = await request.get(`/api/trpc/user.list?input=${input}`);
expect(res.ok()).toBeTruthy();
expect(JSON.stringify(await res.json())).not.toContain("passwordHash");
});
test("tRPC HTTP 适配可调用", async ({ request }) => {
const res = await request.get("/api/trpc/user.list");
// 未登录/无输入时允许 200 或 4xx,但服务必须可达且返回 JSON
expect(res.headers()["content-type"]).toContain("application/json");
const body = await res.text();
expect(body.length).toBeGreaterThan(0);
});

25
e2e/auth.spec.ts Normal file
View File

@ -0,0 +1,25 @@
import { expect, test } from "@playwright/test";
test("未登录访问 /zh-CN/dashboard 跳转登录并带 next", async ({ page }) => {
await page.goto("/zh-CN/dashboard");
await expect(page).toHaveURL(/\/zh-CN\/auth\/login\?next=/);
await expect(page.getByRole("heading", { name: "登录" })).toBeVisible();
});
test("未登录访问 /en/library 跳转英文登录页", async ({ page }) => {
await page.goto("/en/library");
await expect(page).toHaveURL(/\/en\/auth\/login\?next=/);
});
test("登录页渲染用户名/密码表单", async ({ page }) => {
await page.goto("/zh-CN/auth/login");
await expect(page.getByLabel("用户名")).toBeVisible();
await expect(page.getByLabel("密码")).toBeVisible();
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
});
test("注册页可达", async ({ page }) => {
const res = await page.goto("/zh-CN/auth/register");
expect(res?.status()).toBe(200);
await expect(page.getByLabel("邮箱")).toBeVisible();
});

15
e2e/errors.spec.ts Normal file
View File

@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test";
test("未知路径返回 404 页面", async ({ page }) => {
const res = await page.goto("/zh-CN/definitely-not-a-page");
expect(res?.status()).toBe(404);
});
test("裸非法路径视为非法 locale 前缀,302 到本地化首页", async ({ request }) => {
const res = await request.get("/no-such-page", {
headers: { "accept-language": "zh-CN" },
maxRedirects: 0,
});
expect(res.status()).toBe(302);
expect(res.headers()["location"]).toBe("/zh-CN");
});

View File

@ -3,8 +3,8 @@ import { expect, test } from "@playwright/test";
test("中文首页渲染 hero 与 CTA", async ({ page }) => {
await page.goto("/zh-CN");
const h1 = page.getByRole("heading", { level: 1 });
await expect(h1).toContainText("本地片源");
await expect(h1).toContainText("在线弹幕");
await expect(h1).toContainText("私有影音库");
await expect(h1).toContainText("弹幕观影");
await expect(page.getByRole("link", { name: "登录 / 注册" })).toBeVisible();
await expect(page.getByRole("link", { name: "打开媒体库" })).toBeVisible();
});

51
e2e/i18n.spec.ts Normal file
View File

@ -0,0 +1,51 @@
import { expect, test } from "@playwright/test";
test.describe("裸 / 重定向", () => {
test("默认 Accept-Language (en-US) 跳 /en", async ({ request }) => {
const res = await request.get("/", {
headers: { "accept-language": "en-US" },
maxRedirects: 0,
});
expect(res.status()).toBeGreaterThanOrEqual(300);
expect(res.status()).toBeLessThan(400);
expect(res.headers()["location"]).toContain("/en");
});
test("Accept-Language zh-CN 跳 /zh-CN", async ({ request }) => {
const res = await request.get("/", {
headers: { "accept-language": "zh-CN,zh;q=0.9" },
maxRedirects: 0,
});
expect(res.status()).toBeGreaterThanOrEqual(300);
expect(res.status()).toBeLessThan(400);
expect(res.headers()["location"]).toContain("/zh-CN");
});
test("cookie app-lang 优先于 Accept-Language", async ({ request }) => {
const res = await request.get("/", {
headers: {
cookie: "app-lang=zh-CN",
"accept-language": "en-US",
},
maxRedirects: 0,
});
expect(res.headers()["location"]).toContain("/zh-CN");
});
});
test.describe("locale 规范化", () => {
test("小写 /zh-cn 重定向到 /zh-CN", async ({ request }) => {
const res = await request.get("/zh-cn", { maxRedirects: 0 });
expect(res.status()).toBeGreaterThanOrEqual(300);
expect(res.status()).toBeLessThan(400);
expect(res.headers()["location"]).toBe("/zh-CN");
});
});
test("header 语言切换链接跳到另一语言", async ({ page }) => {
await page.goto("/zh-CN");
const switchLink = page.locator('[data-switch-locale="en"]');
await expect(switchLink).toBeVisible();
await switchLink.click();
await expect(page).toHaveURL(/\/en$/);
});

View File

@ -1,5 +1,5 @@
{
"name": "app-template",
"name": "fanmu",
"private": true,
"workspaces": [
"packages/*",
@ -16,7 +16,11 @@
"db:generate": "turbo run db:generate",
"db:push": "turbo run db:push",
"db:studio": "turbo run db:studio",
"db:seed": "yarn workspace @app/db seed",
"docs:dev": "yarn workspace @app/docs dev",
"docs:build": "yarn workspace @app/docs build",
"typecheck": "turbo run typecheck",
"test:e2e": "playwright test",
"lint": "turbo run lint",
"clean": "turbo run clean",
"format": "turbo run format",
@ -33,6 +37,7 @@
"@commitlint/cli": "^21.2.3",
"@commitlint/config-conventional": "^21.2.3",
"@emnapi/runtime": "^1.11.3",
"@playwright/test": "^1.63.0",
"@storybook/addon-a11y": "^9.1.10",
"@storybook/addon-docs": "^9.1.10",
"@storybook/react": "^9.1.10",

View File

@ -2,7 +2,7 @@
## 职责
**数据访问层(DAO)**:封装 Drizzle 查询(`userDao` / `sessionDao` / `passwordResetDao`)。service 只调本包,不直接 `db.select`。不含业务规则、HTTP/tRPC。
**数据访问层(DAO)**:封装 Drizzle 查询。service 只调本包,不直接 `db.select`。不含业务规则、HTTP/tRPC。
## 结构
@ -12,6 +12,10 @@ src/
users.ts # userDao
sessions.ts # sessionDao
password-resets.ts # passwordResetDao
mounts.ts # mountDao
media-items.ts # mediaItemDao
playback-progress.ts # playbackProgressDao
danmaku-cache.ts # danmakuCacheDao
```
## 依赖
@ -21,15 +25,17 @@ src/
## 约定
- 一表一个 dao 对象;方法名 `list` / `getById` / `create` / `update` / `delete`。
- 一表一个 dao 对象;方法名 `list` / `getById` / `create` / `update` / `delete`(媒体域可加 `getByPath`、`upsert` 等,但保持动词清晰)。
- `exactOptionalPropertyTypes`:update 入参用 `Partial<Omit<Row, "id" | "createdAt" | "updatedAt">>`。
- `noUncheckedIndexedAccess`:`returning()[0]` 判空后抛错或 `?? null`。
- 不读 env、不建 client;环境变量校验在 `@app/db`。
- 分页/过滤等查询参数类型一并从本包导出(如 `ListUsersParams`)。
## 修改指南
- 加查询:新建或扩展对应 `*.ts`,在 `index.ts` 导出。
- 新表:先改 `@app/models` → `yarn db:generate`/`db:push` → 本包加 dao。
- 媒体域查询注意 `mediaItems` 唯一键含 `mountId`,按路径查找时必须带挂载维度。
## 命令

View File

@ -1,5 +1,5 @@
import { and, desc, eq, like, or, sql } from "drizzle-orm";
import { mediaItems, type MediaItem } from "@app/models";
import { and, asc, desc, eq, inArray, isNotNull, like, or, sql } from "drizzle-orm";
import { mediaItems, mounts, type MediaItem } from "@app/models";
import { db } from "@app/db";
export type MediaItemRow = MediaItem;
@ -10,11 +10,13 @@ export const mediaItemDao = {
params: {
limit: number;
offset: number;
mountId?: string | undefined;
scrapeStatus?: string | undefined;
q?: string | undefined;
},
): Promise<{ rows: MediaItemRow[]; total: number }> {
const conds = [eq(mediaItems.userId, userId)];
if (params.mountId) conds.push(eq(mediaItems.mountId, params.mountId));
if (params.scrapeStatus) conds.push(eq(mediaItems.scrapeStatus, params.scrapeStatus));
if (params.q) {
const likeQ = `%${params.q}%`;
@ -35,6 +37,143 @@ export const mediaItemDao = {
.where(where);
return { rows, total: Number(countRow?.["count"] ?? 0) };
},
/**
* 海报墙分组分页:指定挂载下已刮削条目按 bangumiId 分组。
* q 命中任一集即收入该组,但 episodeCount 始终为该组全量集数(与列表筛选「已刮削」对得上)。
*/
async listScrapedGroups(
userId: string,
params: {
mountId: string;
q?: string | undefined;
limit: number;
offset: number;
},
): Promise<{
rows: {
bangumiId: number;
title: string;
posterUrl: string | null;
episodeCount: number;
updatedAt: Date;
}[];
total: number;
}> {
const baseConds = [
eq(mediaItems.userId, userId),
eq(mediaItems.mountId, params.mountId),
eq(mediaItems.scrapeStatus, "ok"),
isNotNull(mediaItems.bangumiId),
];
const fullWhere = and(...baseConds);
// 先找出 q 命中的作品 id;命中集合决定「有哪些组」,不缩小组内集数
let matchIds: number[] | null = null;
if (params.q) {
const likeQ = `%${params.q}%`;
const qCond = or(like(mediaItems.title, likeQ), like(mediaItems.rawName, likeQ));
const matched = await db
.select({ bangumiId: mediaItems.bangumiId })
.from(mediaItems)
.where(and(...baseConds, qCond))
.groupBy(mediaItems.bangumiId);
matchIds = matched.map((r) => r.bangumiId).filter((x): x is number => x != null);
if (matchIds.length === 0) return { rows: [], total: 0 };
}
const scopeWhere =
matchIds == null ? fullWhere : and(fullWhere, inArray(mediaItems.bangumiId, matchIds));
// rn=1 即每组 updated_at 最新的一行,其 title/posterUrl 就代表该组
const ranked = db
.select({
bangumiId: mediaItems.bangumiId,
title: mediaItems.title,
posterUrl: mediaItems.posterUrl,
updatedAt: mediaItems.updatedAt,
rn: sql<number>`row_number() over (partition by ${mediaItems.bangumiId} order by ${mediaItems.updatedAt} desc, ${mediaItems.id} desc)`.as(
"rn",
),
episodeCount: sql<number>`count(*) over (partition by ${mediaItems.bangumiId})`.as(
"episode_count",
),
})
.from(mediaItems)
.where(scopeWhere)
.as("ranked");
const pageRows = await db
.select({
bangumiId: ranked.bangumiId,
title: ranked.title,
posterUrl: ranked.posterUrl,
episodeCount: ranked.episodeCount,
updatedAt: ranked.updatedAt,
})
.from(ranked)
.where(eq(ranked.rn, 1))
.orderBy(desc(ranked.updatedAt), desc(ranked.bangumiId))
.limit(params.limit)
.offset(params.offset);
const total =
matchIds != null
? matchIds.length
: await this.countScrapedSeries(userId, params.mountId);
const rows: {
bangumiId: number;
title: string;
posterUrl: string | null;
episodeCount: number;
updatedAt: Date;
}[] = [];
for (const row of pageRows) {
const bid = row.bangumiId;
if (bid == null) continue;
rows.push({
bangumiId: bid,
title: row.title,
posterUrl: row.posterUrl,
episodeCount: Number(row.episodeCount),
updatedAt: row.updatedAt,
});
}
return { rows, total };
},
async countScrapedSeries(userId: string, mountId: string): Promise<number> {
const [countRow] = await db
.select({ count: sql<number>`count(distinct ${mediaItems.bangumiId})` })
.from(mediaItems)
.where(
and(
eq(mediaItems.userId, userId),
eq(mediaItems.mountId, mountId),
eq(mediaItems.scrapeStatus, "ok"),
isNotNull(mediaItems.bangumiId),
),
);
return Number(countRow?.["count"] ?? 0);
},
/** 路径前缀下的条目(文件夹级批量刮削/指定用);精确目录边界,避免 /Foo 误吞 /FooBar */
async listByPathPrefix(userId: string, mountId: string, path: string): Promise<MediaItemRow[]> {
const normPrefix = path.replace(/\/+$/, "") || "/";
const base = [eq(mediaItems.userId, userId), eq(mediaItems.mountId, mountId)];
if (normPrefix === "/") {
return db
.select()
.from(mediaItems)
.where(and(...base))
.orderBy(asc(mediaItems.path));
}
return db
.select()
.from(mediaItems)
.where(
and(
...base,
or(eq(mediaItems.path, normPrefix), like(mediaItems.path, `${normPrefix}/%`)),
),
)
.orderBy(asc(mediaItems.path));
},
async getByIdForUser(id: string, userId: string): Promise<MediaItemRow | null> {
const [row] = await db
.select()
@ -43,6 +182,20 @@ export const mediaItemDao = {
.limit(1);
return row ?? null;
},
/** 同作品(bangumiId)剧集,按集数升序;mountId 可选,库内详情只看该挂载 */
async listByBangumiId(
userId: string,
bangumiId: number,
mountId?: string | undefined,
): Promise<MediaItemRow[]> {
const conds = [eq(mediaItems.userId, userId), eq(mediaItems.bangumiId, bangumiId)];
if (mountId) conds.push(eq(mediaItems.mountId, mountId));
return db
.select()
.from(mediaItems)
.where(and(...conds))
.orderBy(asc(mediaItems.epNumber), asc(mediaItems.rawName));
},
async getByUserPath(
userId: string,
mountId: string,
@ -107,6 +260,8 @@ export const mediaItemDao = {
userId: string,
data: {
bangumiId?: number | null;
dandanplayEpisodeId?: number | null;
matchedHash?: string | null;
epNumber?: number | null;
scrapeStatus: string;
scrapedAt?: Date | null;
@ -121,4 +276,37 @@ export const mediaItemDao = {
.returning();
return row ?? null;
},
/** 弹幕识别身份落库(owner 校验):episodeId + 识别用文件指纹,后续播放免重 match */
async setDanmakuMatch(
id: string,
userId: string,
data: { episodeId: number; matchedHash?: string | null },
): Promise<MediaItemRow | null> {
const [row] = await db
.update(mediaItems)
.set({
dandanplayEpisodeId: data.episodeId,
matchedHash: data.matchedHash ?? null,
updatedAt: new Date(),
})
.where(and(eq(mediaItems.id, id), eq(mediaItems.userId, userId)))
.returning();
return row ?? null;
},
/** 删除挂载下的全部条目(挂载删除级联),返回被删条目 id 供清理进度 */
async deleteByMount(mountId: string, userId: string): Promise<string[]> {
const rows = await db
.delete(mediaItems)
.where(and(eq(mediaItems.mountId, mountId), eq(mediaItems.userId, userId)))
.returning({ id: mediaItems.id });
return rows.map((r) => r.id);
},
/** 清理挂载已不存在的孤儿条目(历史脏数据兜底),返回删除数量 */
async cleanupOrphans(): Promise<number> {
const rows = await db
.delete(mediaItems)
.where(sql`${mediaItems.mountId} not in (select id from ${mounts})`)
.returning({ id: mediaItems.id });
return rows.length;
},
};

View File

@ -1,7 +1,9 @@
import { and, eq } from "drizzle-orm";
import { playbackProgress, type PlaybackProgress } from "@app/models";
import { and, desc, eq, gt, inArray, lt, or, sql } from "drizzle-orm";
import { mediaItems, playbackProgress, type PlaybackProgress } from "@app/models";
import { db } from "@app/db";
export type PlaybackProgressRow = PlaybackProgress;
export const playbackProgressDao = {
async get(userId: string, mediaItemId: string): Promise<PlaybackProgress | null> {
const [row] = await db
@ -16,6 +18,24 @@ export const playbackProgressDao = {
.limit(1);
return row ?? null;
},
/** 未看完的最近进度(positionMs>0 且未接近结尾),按更新时间倒序 */
async listUnfinished(userId: string, limit: number): Promise<PlaybackProgress[]> {
return db
.select()
.from(playbackProgress)
.where(
and(
eq(playbackProgress.userId, userId),
gt(playbackProgress.positionMs, 0),
or(
eq(playbackProgress.durationMs, 0),
lt(playbackProgress.positionMs, playbackProgress.durationMs),
),
),
)
.orderBy(desc(playbackProgress.updatedAt))
.limit(limit);
},
async upsert(
userId: string,
mediaItemId: string,
@ -40,4 +60,17 @@ export const playbackProgressDao = {
if (!row) throw new Error("Failed to insert progress");
return row;
},
/** 删除指定媒体条目的进度(挂载/条目删除级联) */
async deleteByMediaItems(mediaItemIds: string[]): Promise<void> {
if (mediaItemIds.length === 0) return;
await db
.delete(playbackProgress)
.where(inArray(playbackProgress.mediaItemId, mediaItemIds));
},
/** 清理媒体条目已不存在的孤儿进度(历史脏数据兜底) */
async cleanupOrphans(): Promise<void> {
await db
.delete(playbackProgress)
.where(sql`${playbackProgress.mediaItemId} not in (select id from ${mediaItems})`);
},
};

View File

@ -26,6 +26,15 @@ export const userDao = {
return row ?? null;
},
async getByUsername(username: string): Promise<UserRow | null> {
const [row] = await db
.select()
.from(users)
.where(eq(users.username, username.toLowerCase()))
.limit(1);
return row ?? null;
},
async getByEmail(email: string): Promise<UserRow | null> {
const [row] = await db
.select()
@ -36,9 +45,17 @@ export const userDao = {
},
async create(
data: Omit<UserRow, "id" | "createdAt" | "updatedAt"> & { id?: string },
data: Omit<UserRow, "id" | "createdAt" | "updatedAt" | "username" | "email"> & {
id?: string;
username: string;
email?: string | null;
},
): Promise<UserRow> {
const payload = { ...data, email: data.email.toLowerCase() };
const payload = {
...data,
username: data.username.toLowerCase(),
email: data.email ? data.email.toLowerCase() : null,
};
const rows = await db.insert(users).values(payload).returning();
const row = rows[0];
if (!row) throw new Error("Failed to create user");
@ -50,7 +67,10 @@ export const userDao = {
data: Partial<Omit<UserRow, "id" | "createdAt" | "updatedAt">>,
): Promise<UserRow | null> {
const payload: Partial<Omit<UserRow, "id" | "createdAt" | "updatedAt">> = { ...data };
if (payload.email !== undefined) payload.email = payload.email.toLowerCase();
if (payload.username !== undefined) payload.username = payload.username.toLowerCase();
if (payload.email !== undefined) {
payload.email = payload.email ? payload.email.toLowerCase() : null;
}
const [row] = await db
.update(users)
.set({ ...payload, updatedAt: new Date() })

View File

@ -2,7 +2,7 @@
## 职责
创建并导出 **Drizzle + libSQL 客户端单例**,以及 drizzle-kit 配置。查询在 `@app/dao`,业务在 `@app/trpc` service。
创建并导出 **Drizzle + libSQL 客户端单例**,以及 drizzle-kit 配置与种子脚本。查询在 `@app/dao`,业务在 `@app/trpc` service。
## 结构
@ -10,6 +10,8 @@
src/
client.ts # export const db = drizzle(client, { schema })
index.ts # 对外 re-export
scripts/
seed.ts # 幂等初始化默认管理员(yarn db:seed)
drizzle.config.ts # dialect: turso;schema 指向 ../models/dist/index.js
```
@ -18,8 +20,14 @@ drizzle.config.ts # dialect: turso;schema 指向 ../models/dist/index.js
- 读环境变量:`TURSO_DATABASE_URL`(必填,缺失则启动抛错)、`TURSO_AUTH_TOKEN`(可选)。
- 本地开发常用 `TURSO_DATABASE_URL=file:local.db`。
- `schema` 来自 `@app/models`——**模型定义请去 models 改**,本包只聚合导入。
- **不写查询**;SQL/Drizzle query 在 `@app/dao`。
- `drizzle.config.ts` 会手动解析根目录 `.env`(monorepo 下 drizzle-kit 不自动加载)。改 env 路径时两处保持一致。
- **不写业务查询**;SQL/Drizzle query 在 `@app/dao`。
- `drizzle.config.ts` 与 `scripts/seed.ts` 都会手动解析根目录 `.env`(monorepo 下 drizzle-kit / tsx 不自动加载)。改 env 路径时保持一致。
## seed 脚本
- `yarn db:seed`:创建默认管理员(用户名 + 密码),已存在同名用户则跳过(幂等)。
- 密码哈希格式与 `@app/trpc` 的 `password.ts` 一致(scrypt `salt:hash` hex)——改哈希算法时两处同步。
- 脚本可直接依赖 `@app/models` 与 `@libsql/client`,但不要在 `src/` 里塞业务逻辑。
## 修改指南
@ -34,5 +42,6 @@ yarn workspace @app/db typecheck
yarn workspace @app/db build
yarn db:generate
yarn db:push
yarn db:seed
yarn db:studio
```

View File

@ -20,7 +20,8 @@
"lint": "biome check .",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"seed": "tsx scripts/seed.ts"
},
"dependencies": {
"@app/models": "workspace:*",
@ -30,6 +31,7 @@
"devDependencies": {
"@types/node": "^22.15.29",
"drizzle-kit": "^0.30.2",
"tsx": "^4.19.4",
"typescript": "^7.0.2"
}
}

View File

@ -0,0 +1,77 @@
/**
* Seed:初始化默认管理员(用户名 + 密码登录)。
* 用法:`yarn db:seed`(幂等:已存在同名用户则跳过)。
* 密码哈希格式与 `@app/trpc` 的 `password.ts` 一致(scrypt `salt:hash` hex)。
*/
import { randomBytes, scryptSync } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { eq } from "drizzle-orm";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "@app/models";
// monorepo 根 .env(tsx/node 不会自动加载)
try {
const rootEnv = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env");
const env = readFileSync(rootEnv, "utf-8");
for (const line of env.split("\n")) {
const [key, ...vals] = line.split("=");
if (key && !key.startsWith("#")) {
process.env[key.trim()] ??= vals.join("=").trim();
}
}
} catch {}
const url = process.env["TURSO_DATABASE_URL"];
if (!url) throw new Error("TURSO_DATABASE_URL is required");
const clientConfig: { url: string; authToken?: string } = { url };
const token = process.env["TURSO_AUTH_TOKEN"];
if (token) clientConfig.authToken = token;
const client = createClient(clientConfig);
const db = drizzle(client, { schema });
function hashPassword(password: string): string {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
const ADMIN_USERNAME = (process.env["SEED_ADMIN_USERNAME"] ?? "admin").toLowerCase();
const ADMIN_PASSWORD = process.env["SEED_ADMIN_PASSWORD"] ?? "admin123";
const ADMIN_NAME = process.env["SEED_ADMIN_NAME"] ?? "Admin";
const ADMIN_EMAIL = process.env["SEED_ADMIN_EMAIL"] ?? "admin@example.com";
async function seed(): Promise<void> {
const { users } = schema;
const existing = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users.username, ADMIN_USERNAME))
.limit(1);
if (existing.length > 0) {
console.log(`[seed] admin 已存在(username=${ADMIN_USERNAME}),跳过创建`);
return;
}
await db.insert(users).values({
username: ADMIN_USERNAME,
name: ADMIN_NAME,
email: ADMIN_EMAIL.toLowerCase(),
passwordHash: hashPassword(ADMIN_PASSWORD),
});
console.log(`[seed] 已创建管理员用户 username=${ADMIN_USERNAME} password=${ADMIN_PASSWORD}`);
console.log("[seed] 生产环境请通过 SEED_ADMIN_PASSWORD 覆盖默认密码,并尽快登录后修改");
}
seed()
.then(() => process.exit(0))
.catch((err) => {
console.error("[seed] 失败:", err);
process.exit(1);
});

View File

@ -7,5 +7,5 @@
"@app/models": ["../models/src/index.ts"]
}
},
"include": ["src"]
"include": ["src", "scripts"]
}

View File

@ -6,6 +6,27 @@
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
/* shadcn 工具类映射(bg-card / bg-popover / text-muted-foreground …) */
--color-background: var(--paper);
--color-foreground: var(--ink);
--color-card: var(--panel);
--color-card-foreground: var(--ink);
--color-popover: var(--panel);
--color-popover-foreground: var(--ink);
--color-primary: var(--edge);
--color-primary-foreground: #101110;
--color-muted: var(--sink);
--color-muted-foreground: var(--ink-2);
--color-border: var(--line);
--color-input: var(--line);
--color-ring: var(--edge);
--color-accent: var(--sink);
--color-accent-foreground: var(--ink);
--color-secondary: var(--line-strong);
--color-secondary-foreground: var(--ink);
--color-destructive: var(--err);
--color-destructive-foreground: #f2f2f2;
}
/* 终末地设计语义
@ -28,11 +49,11 @@
--ink-2: #a3a6a1;
--ink-3: #7a7e78;
--edge: #fffa00;
--edge-rgb: 255, 250, 0;
--edge-deep: #e6e100;
--edge-on-paper: #fffa00;
--edge-as-text: #fffa00;
--edge: #ffb300;
--edge-rgb: 255, 179, 0;
--edge-deep: #e09a00;
--edge-on-paper: #ffb300;
--edge-as-text: #ffb300;
--sig-green: #00ffa2;
--sig-magenta: #ff1aac;
@ -45,17 +66,20 @@
--warn: #ffb700;
--hazard: repeating-linear-gradient(-45deg, #191919 0 8px, #0e0f0e 8px 16px);
--hazard-edge: repeating-linear-gradient(-45deg, #fffa00 0 8px, #0e0f0e 8px 16px);
--hazard-edge: repeating-linear-gradient(-45deg, #ffb300 0 8px, #0e0f0e 8px 16px);
--color-background: var(--paper);
--color-foreground: var(--ink);
--color-card: var(--panel);
--color-card-foreground: var(--ink);
--color-popover: var(--panel);
--color-popover-foreground: var(--ink);
--color-primary: var(--edge);
--color-primary-foreground: #101110;
--color-muted: var(--sink);
--color-muted-foreground: var(--ink-2);
--color-border: var(--line);
--color-input: var(--line);
--color-ring: var(--edge);
--color-accent: var(--sink);
--color-accent-foreground: var(--ink);
@ -78,11 +102,11 @@
--ink-2: #4a4d48;
--ink-3: #5c5f5a;
--edge: #fffa00;
--edge-rgb: 255, 250, 0;
--edge-deep: #e6e100;
--edge-on-paper: #d4cf00;
--edge-as-text: #6b6500;
--edge: #ffb300;
--edge-rgb: 255, 179, 0;
--edge-deep: #e09a00;
--edge-on-paper: #b88600;
--edge-as-text: #7a5c00;
--sig-green: #00c982;
--sig-magenta: #e01090;
@ -95,17 +119,20 @@
--warn: #d9822b;
--hazard: repeating-linear-gradient(-45deg, #e5e5e5 0 8px, #f2f2f2 8px 16px);
--hazard-edge: repeating-linear-gradient(-45deg, #fffa00 0 8px, #191919 8px 16px);
--hazard-edge: repeating-linear-gradient(-45deg, #ffb300 0 8px, #191919 8px 16px);
--color-background: var(--paper);
--color-foreground: var(--ink);
--color-card: var(--panel);
--color-card-foreground: var(--ink);
--color-popover: var(--panel);
--color-popover-foreground: var(--ink);
--color-primary: var(--edge);
--color-primary-foreground: #101110;
--color-muted: var(--sink);
--color-muted-foreground: var(--ink-2);
--color-border: var(--line);
--color-input: var(--line);
--color-ring: var(--edge);
--color-accent: var(--sink);
--color-accent-foreground: var(--ink);

View File

@ -19,6 +19,8 @@ src/
index.ts
```
key 按域分组(`<domain>.<name>`):`nav` / `home` / `dashboard` / `auth` / `library` / `browse` / `watch` / `mounts` / `danmaku` / `error` / `meta` / `locale`。
## 约定
- 中文 `/zh-CN`、英文 `/en` 前缀;裸 `/` 仅由 middleware 检测后 302,不挂页面。

View File

@ -2,8 +2,8 @@ import type { MessageKey } from "./zh-CN.js";
/** 与 zh-CN 同构;类型强制 key 对齐。 */
export const en: Record<MessageKey, string> = {
"meta.brand": "DanDan Web",
"meta.product": "DanDan Web",
"meta.brand": "Fanmu",
"meta.product": "Fanmu",
"meta.productTagline": "Local files · online danmaku",
"meta.tagline": "Local video · online danmaku",
@ -17,7 +17,7 @@ export const en: Record<MessageKey, string> = {
"nav.mounts": "Mounts",
"nav.danmaku": "Danmaku settings",
"home.title": "DanDan Web",
"home.title": "Fanmu",
"home.subtitle":
"Mount WebDAV / OpenList libraries, scan into your media library, play with ArtPlayer and online danmaku.",
"home.heroLine1": "Local files",
@ -30,7 +30,7 @@ export const en: Record<MessageKey, string> = {
"home.layerTitle": "Get started",
"home.layerBody":
"Register → add a WebDAV mount → scan in Browse → play from the Library with danmaku.",
"home.footer": "DANDAN WEB · SSR",
"home.footer": "Private library · Danmaku",
"dashboard.title": "Console",
"dashboard.heading": "Media hub",
@ -56,6 +56,34 @@ export const en: Record<MessageKey, string> = {
"library.searchWork": "Search by title",
"library.bind": "Bind",
"library.cancel": "Cancel",
"library.viewWall": "Poster wall",
"library.viewList": "List",
"library.episodeCount": "{count} eps",
"library.groupsEmpty":
"No scraped titles yet. Scan a mount or match manually to see them here.",
"library.episodes": "Episodes",
"library.expand": "Show episodes",
"library.collapse": "Collapse",
"library.epLabel": "Episode",
"library.epPlaceholder": "Optional, e.g. 12",
"library.reScrape": "Re-scrape",
"library.manualTitle": "Manual match",
"library.candidates": "Candidates (click to bind)",
"browse.batchScrape": "Batch scrape folder",
"browse.batchBind": "Bind folder as title",
"browse.batchDone": "{message}",
"browse.batchError": "Batch action failed",
"browse.folderActions": "Folder actions",
"browse.bindFolderTitle": "Bind folder to a title",
"browse.bindFolderHint":
"Search and pick a title; all videos under this folder will bind to it, episodes parsed from filenames.",
"library.backToList": "Back to library",
"library.continueTitle": "Continue watching",
"library.matched": "Matched",
"library.autoScanning": "Auto-scanning mounted directories into the library…",
"library.noResults": "No results matching “{q}”",
"library.filterEmpty": "Nothing under the current filter — try another filter",
"library.clearSearch": "Clear search",
"browse.title": "File browser",
"browse.play": "Play",
@ -71,6 +99,7 @@ export const en: Record<MessageKey, string> = {
"browse.playError": "Failed to add to library",
"browse.scanDone": "Scan complete: {count} files",
"browse.scanError": "Scan failed. Check the mount configuration.",
"browse.goLibrary": "Go to library",
"mounts.title": "Mounts",
"mounts.add": "Add WebDAV",
@ -83,8 +112,34 @@ export const en: Record<MessageKey, string> = {
"mounts.passwordPlaceholder": "Password / token",
"mounts.rootPathPlaceholder": "Root path /",
"mounts.save": "Save",
"mounts.saveEdit": "Save changes",
"mounts.saved": "Mount saved",
"mounts.updated": "Mount updated",
"mounts.saveError": "Save failed — try again",
"mounts.delete": "Delete",
"mounts.deleted": "Mount deleted",
"mounts.deleteError": "Delete failed",
"mounts.edit": "Edit",
"mounts.editing": "Editing",
"mounts.editHeading": "Edit mount",
"mounts.cancelEdit": "Cancel edit",
"mounts.passwordKeepHint": "Password / token (leave blank to keep)",
"mounts.fieldName": "Name",
"mounts.fieldUrl": "WebDAV URL",
"mounts.fieldUser": "Username",
"mounts.fieldPass": "Password / token",
"mounts.fieldRoot": "Root path",
"mounts.listTitle": "Mounted",
"mounts.disabled": "Disabled",
"mounts.empty": "No mounts yet",
"mounts.browseThis": "Browse files",
"mounts.afterCreateHint": "Mount saved — browse its videos now.",
"mounts.toBrowse": "Browse",
"mounts.errNameRequired": "Enter a mount name",
"mounts.errUrlInvalid": "Enter a valid http(s) URL, e.g. https://host/dav",
"mounts.confirmDelete": "Confirm delete?",
"mounts.deleteHint": "Media items scanned from this mount will also be removed",
"mounts.deletedN": "Mount deleted ({n} media items removed)",
"watch.title": "Watch",
"watch.loading": "Loading…",
@ -103,6 +158,23 @@ export const en: Record<MessageKey, string> = {
"watch.loadErrorHint": "Could not load this media. Check the link or try again later.",
"watch.notFound": "Media not found",
"watch.notFoundHint": "This media may have been removed, or you do not have access.",
"watch.episodes": "Episodes",
"watch.prevEpisode": "Prev",
"watch.nextEpisode": "Next",
"watch.currentEpisode": "Now playing",
"watch.playNextCta": "Play next",
"watch.backToSeries": "Back to series",
"watch.danmakuSettings": "Danmaku settings",
"watch.episodeN": "EP {n}",
"watch.importOk": "Local danmaku imported",
"watch.importFail": "Danmaku import failed",
"watch.playFailed": "Playback source failed",
"watch.playFailedHint":
"Cannot read the video stream — the mount may be removed or the source unavailable.",
"watch.replay": "Replay",
"watch.playbackFinished": "Episode finished",
"watch.danmakuDisabledHint":
"Danmaku is off — enable it via the button above or in danmaku settings",
"danmaku.title": "Danmaku settings",
"danmaku.opacity": "Opacity",
@ -161,7 +233,10 @@ export const en: Record<MessageKey, string> = {
"auth.msg.logoutOk": "Signed out",
"auth.msg.passwordOk": "Password updated",
"auth.msg.resetOk": "Password reset — please sign in",
"auth.msg.resetRequested": "If the email exists, the reset request was accepted",
"auth.msg.resetRequested":
"If the email exists, the reset request was accepted; a reset link will be sent to the mailbox (if no mail service is configured, contact the admin or check server logs)",
"auth.passwordHint": "At least 8 characters",
"auth.forgot.emailLabel": "Registered email",
"auth.err.generic": "Something went wrong — try again",
"locale.switch": "Language",

Some files were not shown because too many files have changed in this diff Show More