Compare commits

..

No commits in common. "632336de4154b6b16845376fcdd659a823678726" and "a7873891f2702668a22881498bfdf5528f9d13a8" have entirely different histories.

181 changed files with 1534 additions and 15484 deletions

View File

@ -27,8 +27,3 @@ AGENTS.md
DESIGN.md DESIGN.md
README.md README.md
.prettierrc .prettierrc
# 会话/审查产物
.superpowers
.docs
docs/superpowers

View File

@ -12,12 +12,8 @@ TURSO_DATABASE_URL=file:local.db
# 媒体挂载凭据加密密钥:64 位 hex(openssl rand -hex 32) # 媒体挂载凭据加密密钥:64 位 hex(openssl rand -hex 32)
MEDIA_SECRET_KEY= MEDIA_SECRET_KEY=
# 找回密码令牌交付:配置后以 POST JSON { email, username, token, resetPath } 推送到该地址
# (可对接邮件/企业微信/ bark 等任意通知服务);未配置时令牌写入服务日志
PASSWORD_RESET_WEBHOOK_URL=
# 弹弹play 开放弹幕网络(https://doc.dandanplay.com/open) # 弹弹play 开放弹幕网络(https://doc.dandanplay.com/open)
# AppId/AppSecret 推荐在「账户设置」页配置(存库、即时生效,仅管理员);此处为可选的服务端兜底 # 未配置 AppId/AppSecret 时,弹幕接口返回 source=none「未配置开放网络」
OPEN_DANMAKU_APP_ID= OPEN_DANMAKU_APP_ID=
OPEN_DANMAKU_APP_SECRET= OPEN_DANMAKU_APP_SECRET=
# 可选:自定义开放弹幕 API 地址(默认 https://api.dandanplay.net) # 可选:自定义开放弹幕 API 地址(默认 https://api.dandanplay.net)

10
.gitignore vendored
View File

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

View File

@ -1,6 +1,6 @@
# 帆幕(Fanmu)— 设计方向 # App Template — 设计方向
产品视觉基线:**shadcn/ui Dashboard**(媒体五页侧栏/顶栏导航、卡片网格、中性灰阶);模板深空工业 token 仍可用于非媒体页,但 **`@app/design-tokens` 色板 token 路径约定不变**(改值可以,改结构需同步全局)。 脚手架视觉基线:**深空工业中控终端**。不绑定具体产品叙事;下游可在此 palette / type 上换肤,但 **`@app/design-tokens` 色板 token 路径约定不变**(改值可以,改结构需同步全局)。
## Style anchor ## Style anchor
深色工业 HUD / 控制台:近黑底、信号黄强调、发丝线框、等宽铭牌。 深色工业 HUD / 控制台:近黑底、信号黄强调、发丝线框、等宽铭牌。
@ -16,7 +16,7 @@
| 墨色 | `#F2F2F2` | | 墨色 | `#F2F2F2` |
| 弱化文字 | `#999999` | | 弱化文字 | `#999999` |
| 更弱文字 | `#666666` | | 更弱文字 | `#666666` |
| 强调(品牌金黄) | `#FFB300` | | 强调(信号黄) | `#FFFA00` |
| 信号绿 | `#00FFA2` | | 信号绿 | `#00FFA2` |
| 信号品红 | `#FF1AAC` | | 信号品红 | `#FF1AAC` |
| 亮色背景(对照) | `#FAFAFA` | | 亮色背景(对照) | `#FAFAFA` |
@ -36,8 +36,8 @@
## Interaction ## Interaction
- 主题:系统 / 亮 / 暗(`app-theme`) - 主题:系统 / 亮 / 暗(`app-theme`)
- 语言:cookie `app-lang` + Accept-Language - 语言:cookie `app-lang` + Accept-Language
- **薄 SSR**:服务端输出布局 / head / 导航;页面主体(`components/pages/*Body`)`client:load` 在浏览器渲染 - SSR 输出结构与内容;动效只在浏览器 effect 内绑定
- 动效只在浏览器 effect 内绑定;尊重 `prefers-reduced-motion` - 尊重 `prefers-reduced-motion`
## 范围 ## 范围
`tokens.css` · `globals.css` · `AppLayout.astro` · `ThemeSwitch` · `UserMenu` · `home` / `dashboard` 视图 · `ui/*` `tokens.css` · `globals.css` · `AppLayout.astro` · `ThemeSwitch` · `UserMenu` · `home` / `dashboard` 视图 · `ui/*`

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# 与 .node-version 对齐;可用 build-arg 覆盖 # 与 .node-version 对齐;可用 build-arg 覆盖
ARG NODE_VERSION=26.7.0 ARG NODE_VERSION=26.7.0
@ -13,11 +14,9 @@ RUN npm install -g corepack && corepack enable
FROM base AS deps FROM base AS deps
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY app/web/package.json app/web/package.json COPY app/web/package.json app/web/package.json
# workspaces: app/* + packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链 # workspaces: packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链
COPY app/docs/package.json app/docs/package.json
COPY packages/adsense/package.json packages/adsense/package.json COPY packages/adsense/package.json packages/adsense/package.json
COPY packages/dao/package.json packages/dao/package.json COPY packages/dao/package.json packages/dao/package.json
COPY packages/danmaku/package.json packages/danmaku/package.json
COPY packages/db/package.json packages/db/package.json COPY packages/db/package.json packages/db/package.json
COPY packages/design-tokens/package.json packages/design-tokens/package.json COPY packages/design-tokens/package.json packages/design-tokens/package.json
COPY packages/i18n/package.json packages/i18n/package.json COPY packages/i18n/package.json packages/i18n/package.json
@ -56,14 +55,7 @@ ENV NODE_ENV=production \
HOSTNAME=0.0.0.0 \ HOSTNAME=0.0.0.0 \
PORT=4321 \ PORT=4321 \
# file: 库与 session 的可写位置(/app 属主为 root,node 用户不可写) # file: 库与 session 的可写位置(/app 属主为 root,node 用户不可写)
TMPDIR=/tmp \ TMPDIR=/tmp
# 默认本地 sqlite;生产可 -e TURSO_DATABASE_URL=libsql://... 覆盖
TURSO_DATABASE_URL=file:/app/data/local.db \
# 挂载凭据加密(64 hex):运行时必须 -e MEDIA_SECRET_KEY=...
MEDIA_SECRET_KEY= \
# 可选:开放弹幕 AppId/Secret、Bangumi/Open API 地址见 .env.example
OPEN_DANMAKU_APP_ID= \
OPEN_DANMAKU_APP_SECRET=
COPY --from=build --chown=node:node /app/package.json /app/yarn.lock /app/.yarnrc.yml ./ COPY --from=build --chown=node:node /app/package.json /app/yarn.lock /app/.yarnrc.yml ./
COPY --from=build --chown=node:node /app/app/web/package.json app/web/package.json COPY --from=build --chown=node:node /app/app/web/package.json app/web/package.json
@ -76,6 +68,4 @@ RUN mkdir -p /app/data && chown node:node /app/data
USER node USER node
EXPOSE 4321 EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:4321/" || exit 1
CMD ["node", "app/web/dist/server/entry.mjs"] CMD ["node", "app/web/dist/server/entry.mjs"]

View File

@ -1,8 +1,6 @@
# 帆幕 — 私有影音库 · 弹幕观影 · 网盘挂载 # App Template — Astro + tRPC + Drizzle Monorepo
**帆幕(Fanmu)** 是自建私有影音库:WebDAV/OpenList 挂载片源、媒体库扫描与 Bangumi 刮削、ArtPlayer 播放与弹幕(开放网络 + 本地 XML),多用户进度与权限。 可复用全栈脚手架:**不绑定产品业务**,只保留鉴权 + `user` 垂直示例,演示完整分层接线。
基于 monorepo 分层(types → models → db → dao → trpc → web),本仓库按**产品仓**维护。
``` ```
. .
@ -49,25 +47,4 @@ yarn dev:web # Astro :4321
`web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。 `web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。
新增业务实体请按既有分层接线:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。 新增业务实体请复制 `user` 垂直切片:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。
## 首次运行配置(产品闭环)
1. 注册第一个账号(自动成为**管理员**)
2. **设置 → 开放弹幕网络**:填写弹弹play DevCenter 申请的 `AppId` / `AppSecret`([dev.dandanplay.com](https://dev.dandanplay.com))
3. **设置 → 弹弹play 账号**:登录弹弹play 账号(站点共用会话,用于拉在线弹幕)
4. 挂载 WebDAV → 扫描/入库 → 播放页自动匹配或手选作品/集数
未完成 2/3 时播放页会提示「去设置」;本地 XML 导入不受影响。
### Docker
```bash
docker build -t fanmu:local .
docker run -d -p 4330:4321 \
-e MEDIA_SECRET_KEY=<64-hex> \
-v $PWD/packages/db/local.db:/app/data/local.db \
fanmu:local
# 然后走上面「首次运行配置」
```

View File

@ -1,160 +0,0 @@
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

@ -1,11 +0,0 @@
/**
* 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

@ -1,18 +0,0 @@
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;

View File

@ -1,48 +0,0 @@
# 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

@ -1,31 +0,0 @@
# 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

@ -1,27 +0,0 @@
# 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

@ -1,46 +0,0 @@
# 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 **Settings** page's Danmaku section 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.

View File

@ -1,56 +0,0 @@
# 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

@ -1,37 +0,0 @@
# 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

@ -1,41 +0,0 @@
# 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

@ -1,75 +0,0 @@
# 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

@ -1,43 +0,0 @@
# 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

@ -1,45 +0,0 @@
# 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.

View File

@ -1,55 +0,0 @@
---
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).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,41 +0,0 @@
# 连网盘
想看的片子在网盘里?先把它「挂」到帆幕上。
## 目前支持什么
**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

@ -1,75 +0,0 @@
# 快速上手
目标:几分钟内跑起来,登录进去看到界面。
## 你需要什么
- 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` | 检查代码风格 |

View File

@ -1,46 +0,0 @@
# 认出是哪部剧
弹幕和海报,都靠「认出这是什么」。这一步做对了,后面都省事。
## 自动认
扫描或加入媒体库时,会看文件名,猜:
- 这是哪部作品
- 这是第几集
然后去 **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 / 剧场版 | 集数可能对不上,手动调 |

View File

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

View File

@ -1,53 +0,0 @@
---
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)。

View File

@ -1,18 +0,0 @@
{
"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

@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 418 B

View File

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

View File

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

View File

@ -12,7 +12,6 @@
"lint": "biome check ." "lint": "biome check ."
}, },
"dependencies": { "dependencies": {
"@app/danmaku": "workspace:*",
"@app/design-tokens": "workspace:*", "@app/design-tokens": "workspace:*",
"@app/i18n": "workspace:*", "@app/i18n": "workspace:*",
"@app/seo-geo": "workspace:*", "@app/seo-geo": "workspace:*",
@ -26,7 +25,6 @@
"@trpc/client": "^11.19.0", "@trpc/client": "^11.19.0",
"@trpc/react-query": "^11.19.0", "@trpc/react-query": "^11.19.0",
"artplayer": "^5.2.2", "artplayer": "^5.2.2",
"artplayer-plugin-danmuku": "^5.3.0",
"astro": "^7.3.3", "astro": "^7.3.3",
"gsap": "^3.15.0", "gsap": "^3.15.0",
"lenis": "^1.3.26", "lenis": "^1.3.26",

View File

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

View File

@ -1,55 +0,0 @@
/**
* 顶栏语言下拉:trigger 显示当前语言,菜单项来自 `LOCALES`(后续扩展只需改 `@app/i18n`)。
* 菜单项是带 `data-switch-locale` 的 `<a>`,由 AppLayout 内联脚本写 `app-lang` cookie 后跳转。
*/
import { LOCALES, type Locale, t } from "@app/i18n";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/ui";
export interface LocaleSwitchProps {
locale: Locale;
/** 各语言在当前页的 URL(AppLayout 按 pathname 预先算好) */
hrefs: Record<Locale, string>;
}
export default function LocaleSwitch({ locale, hrefs }: LocaleSwitchProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="u-linelink"
aria-label={t(locale, "locale.switch")}
>
{t(locale, `locale.${locale}`)}
<span aria-hidden="true" className="text-[9px] opacity-70">
▾
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{LOCALES.map((code) => (
<DropdownMenuItem key={code} asChild>
<a
data-switch-locale={code}
href={hrefs[code]}
aria-current={code === locale ? "true" : undefined}
className={
code === locale
? "flex w-full items-center gap-2 font-medium text-inherit no-underline"
: "flex w-full items-center gap-2 text-inherit no-underline"
}
>
<span>{t(code, `locale.${code}`)}</span>
{code === locale && (
<span aria-hidden="true" className="ml-auto text-[11px]">
✓
</span>
)}
</a>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

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

View File

@ -1,22 +0,0 @@
/**
* 鉴权页主体:薄 SSR — 外层容器 + AuthForm 在浏览器渲染。
*/
import type { Locale } from "@app/i18n";
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>
</>
);
}

View File

@ -1,10 +1,9 @@
import type { Locale } from "@app/i18n"; import type { Locale } from "@app/i18n";
import { t } from "@app/i18n"; import { t } from "@app/i18n";
import { isVideoFilename } from "@app/types"; import { isVideoFilename } from "@app/types";
import { Button, Input, toast } from "@app/ui"; import { Button } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useState } from "react";
import { go, queryParam, setQueryParams } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { createTRPCReactClient, trpc } from "~/lib/trpc";
function pathOf(segments: string[]): string { function pathOf(segments: string[]): string {
@ -18,51 +17,13 @@ function fileRelPath(dirPath: string, basename: string): string {
function BrowseInner(props: { locale: Locale }) { function BrowseInner(props: { locale: Locale }) {
const { locale } = props; const { locale } = props;
const mounts = trpc.media.mountList.useQuery(); const mounts = trpc.media.mountList.useQuery();
const [mountId, setMountId] = useState(() => queryParam("mount") ?? ""); const [mountId, setMountId] = useState("");
const [path, setPath] = useState("/"); const [path, setPath] = useState("/");
const [crumbs, setCrumbs] = useState<string[]>([]); 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 dir = trpc.media.mountListDir.useQuery({ mountId, path }, { enabled: Boolean(mountId) });
const scan = trpc.media.libraryScan.useMutation(); const scan = trpc.media.libraryScan.useMutation();
const ensure = trpc.media.libraryEnsurePath.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() { function goRoot() {
setCrumbs([]); setCrumbs([]);
@ -91,7 +52,7 @@ function BrowseInner(props: { locale: Locale }) {
if (!mountId) return; if (!mountId) return;
try { try {
const item = await ensure.mutateAsync({ mountId, path: fileRelPath(path, basename) }); const item = await ensure.mutateAsync({ mountId, path: fileRelPath(path, basename) });
go(`/${locale}/watch?id=${item.id}`); window.location.href = `/${locale}/watch?id=${item.id}`;
} catch { } catch {
// error surfaced via ensure.error // error surfaced via ensure.error
} }
@ -136,37 +97,11 @@ function BrowseInner(props: { locale: Locale }) {
{t(locale, "browse.addLibrary")} {t(locale, "browse.addLibrary")}
</Button> </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> </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 && ( {scan.data && (
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t(locale, "browse.scanDone", { count: scan.data.scanned })} {t(locale, "browse.scanDone", { count: scan.data.scanned })}
</p> </p>
<Button size="sm" onClick={() => go(`/${locale}/library`)}>
{t(locale, "browse.goLibrary")}
</Button>
</div>
)} )}
{scan.isError && ( {scan.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.scanError")}</p> <p className="text-sm text-red-600">{t(locale, "browse.scanError")}</p>
@ -193,17 +128,7 @@ function BrowseInner(props: { locale: Locale }) {
<p className="text-sm text-muted-foreground">{t(locale, "browse.loading")}</p> <p className="text-sm text-muted-foreground">{t(locale, "browse.loading")}</p>
)} )}
{mountId && dir.isError && ( {mountId && dir.isError && (
<div className="flex flex-wrap items-center gap-3"> <p className="text-sm text-red-600">{t(locale, "browse.loadError")}</p>
<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 && ( {ensure.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.playError")}</p> <p className="text-sm text-red-600">{t(locale, "browse.playError")}</p>
@ -243,82 +168,16 @@ function BrowseInner(props: { locale: Locale }) {
); );
})} })}
{mountId && dir.data?.entries.length === 0 && ( {mountId && dir.data?.entries.length === 0 && (
<li className="p-0"> <li className="p-4 text-sm text-muted-foreground">
<div className="u-empty border-0 bg-transparent"> {t(locale, "browse.empty")}
<p>{t(locale, "browse.empty")}</p>
</div>
</li> </li>
)} )}
{!mountId && mountList.length === 0 && ( {!mountId && (
<li className="p-0"> <li className="p-4 text-sm text-muted-foreground">
<div className="u-empty border-0 bg-transparent"> {t(locale, "browse.empty")}
<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> </li>
)} )}
</ul> </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> </div>
); );
} }

View File

@ -1,159 +1,60 @@
import type { Locale, MessageKey } from "@app/i18n"; import type { Locale } from "@app/i18n";
import { t } from "@app/i18n"; import { t } from "@app/i18n";
import { Button, Card, Input, toast } from "@app/ui"; import { Button } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
type BlockType = "scroll" | "top" | "bottom"; type DanmakuSettings = { enabled: boolean; opacity: number; density: number };
type DanmakuSettings = { const FALLBACK: DanmakuSettings = { enabled: true, opacity: 0.8, density: 1 };
enabled: boolean;
opacity: number;
density: number;
blockKeywords: string[];
blockTypes: BlockType[];
};
const FALLBACK: DanmakuSettings = {
enabled: true,
opacity: 0.8,
density: 1,
blockKeywords: [],
blockTypes: [],
};
const BLOCK_TYPE_LABELS = [
["scroll", "danmaku.blockTypeScroll"],
["top", "danmaku.blockTypeTop"],
["bottom", "danmaku.blockTypeBottom"],
] as const satisfies ReadonlyArray<readonly [BlockType, MessageKey]>;
function parseBlockTypes(raw: unknown): BlockType[] {
if (!Array.isArray(raw)) return [];
const allowed: BlockType[] = ["scroll", "top", "bottom"];
return raw.filter((v): v is BlockType => allowed.includes(v as BlockType));
}
function readSettings(): DanmakuSettings { function readSettings(): DanmakuSettings {
try { try {
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY); const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
if (!raw) return { ...FALLBACK }; if (!raw) return FALLBACK;
const parsed: unknown = JSON.parse(raw); const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return { ...FALLBACK }; if (typeof parsed !== "object" || parsed === null) return FALLBACK;
const rec = parsed as Record<string, unknown>; const rec = parsed as Record<string, unknown>;
const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled; const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled;
const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity; const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity;
const densityRaw = typeof rec["density"] === "number" ? rec["density"] : FALLBACK.density; const densityRaw = typeof rec["density"] === "number" ? rec["density"] : FALLBACK.density;
const kwRaw = rec["blockKeywords"];
const blockKeywords = Array.isArray(kwRaw)
? kwRaw.filter(
(v): v is string => typeof v === "string" && v.length > 0 && v.length <= 50,
)
: [];
return { return {
enabled, enabled,
opacity: Math.min(1, Math.max(0, opacityRaw)), opacity: Math.min(1, Math.max(0, opacityRaw)),
density: Math.min(1, Math.max(0.1, densityRaw)), density: Math.min(1, Math.max(0.1, densityRaw)),
blockKeywords: blockKeywords.slice(0, 100),
blockTypes: parseBlockTypes(rec["blockTypes"]),
}; };
} catch { } catch {
return { ...FALLBACK }; return FALLBACK;
}
}
function writeSettings(next: DanmakuSettings) {
try {
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
} catch {
// localStorage 不可用时静默;服务端已持久化
} }
} }
function Inner(props: { locale: Locale }) { function Inner(props: { locale: Locale }) {
const { locale } = props; const { locale } = props;
const prefsQuery = trpc.media.danmakuGetSettings.useQuery();
const save = trpc.media.danmakuSaveSettings.useMutation(); const save = trpc.media.danmakuSaveSettings.useMutation();
const filledFromServer = useRef(false);
// SSR 与水合首帧共用 FALLBACK,避免 localStorage 差异导致 hydration 不一致
const [enabled, setEnabled] = useState(FALLBACK.enabled); const [enabled, setEnabled] = useState(FALLBACK.enabled);
const [opacity, setOpacity] = useState(FALLBACK.opacity); const [opacity, setOpacity] = useState(FALLBACK.opacity);
const [density, setDensity] = useState(FALLBACK.density); const [density, setDensity] = useState(FALLBACK.density);
const [blockKeywords, setBlockKeywords] = useState<string[]>(() => []);
const [blockTypes, setBlockTypes] = useState<BlockType[]>(() => []);
const [kwInput, setKwInput] = useState("");
// 挂载后再读 localStorage 作初值/回退(客户端 only)
useEffect(() => {
const local = readSettings();
if (filledFromServer.current) return;
setEnabled(local.enabled);
setOpacity(local.opacity);
setDensity(local.density);
setBlockKeywords(local.blockKeywords);
setBlockTypes(local.blockTypes);
}, []);
useEffect(() => { useEffect(() => {
const data = prefsQuery.data; const prefs = readSettings();
if (!data || filledFromServer.current) return; setEnabled(prefs.enabled);
filledFromServer.current = true; setOpacity(prefs.opacity);
setEnabled(data.enabled); setDensity(prefs.density);
setOpacity(data.opacity);
setDensity(data.density);
setBlockKeywords(data.blockKeywords);
setBlockTypes(data.blockTypes);
}, [prefsQuery.data]);
const addBlockKeyword = useCallback(() => {
const kw = kwInput.trim();
if (!kw || kw.length > 50) return;
setBlockKeywords((prev) => {
if (prev.includes(kw) || prev.length >= 100) return prev;
return [...prev, kw];
});
setKwInput("");
}, [kwInput]);
const removeBlockKeyword = useCallback((kw: string) => {
setBlockKeywords((prev) => prev.filter((k) => k !== kw));
}, []);
const toggleBlockType = useCallback((kind: BlockType) => {
setBlockTypes((prev) =>
prev.includes(kind) ? prev.filter((k) => k !== kind) : [...prev, kind],
);
}, []); }, []);
const onSave = () => { const onSave = () => {
const next: DanmakuSettings = { enabled, opacity, density, blockKeywords, blockTypes }; const next = { enabled, opacity, density };
writeSettings(next); localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
save.mutate(next, { save.mutate(next);
onSuccess: () => {
toast.success(t(locale, "danmaku.saved"));
},
onError: () => {
toast.error(t(locale, "danmaku.saveFailed"));
},
});
}; };
const openNetworkMissing = prefsQuery.data?.openNetworkConfigured === false;
return ( return (
<div className="space-y-4"> <div className="max-w-md space-y-4">
{openNetworkMissing && ( <h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200"> <label className="flex items-center gap-2 text-sm">
{t(locale, "danmaku.openNetworkNotConfigured")}
</div>
)}
<Card className="w-full space-y-5 p-6">
<h2 className="text-base font-semibold">{t(locale, "settings.danmakuSection")}</h2>
<label className="flex items-center gap-2.5 text-sm font-medium">
<input <input
type="checkbox" type="checkbox"
checked={enabled} checked={enabled}
@ -161,13 +62,8 @@ function Inner(props: { locale: Locale }) {
/> />
{t(locale, "danmaku.enabled")} {t(locale, "danmaku.enabled")}
</label> </label>
<div className="space-y-2"> <label className="block text-sm">
<div className="flex items-baseline justify-between text-sm"> {t(locale, "danmaku.opacityValue", { value: (opacity * 100).toFixed(0) })}
<span>{t(locale, "danmaku.opacity")}</span>
<span className="u-mono text-xs text-muted-foreground">
{(opacity * 100).toFixed(0)}%
</span>
</div>
<input <input
type="range" type="range"
min={0} min={0}
@ -177,14 +73,9 @@ function Inner(props: { locale: Locale }) {
onChange={(e) => setOpacity(Number(e.target.value))} onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full" className="w-full"
/> />
</div> </label>
<div className="space-y-2"> <label className="block text-sm">
<div className="flex items-baseline justify-between text-sm"> {t(locale, "danmaku.densityValue", { value: density })}
<span>{t(locale, "danmaku.density")}</span>
<span className="u-mono text-xs text-muted-foreground">
{(density * 100).toFixed(0)}%
</span>
</div>
<input <input
type="range" type="range"
min={0.1} min={0.1}
@ -194,72 +85,12 @@ function Inner(props: { locale: Locale }) {
onChange={(e) => setDensity(Number(e.target.value))} onChange={(e) => setDensity(Number(e.target.value))}
className="w-full" className="w-full"
/> />
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t(locale, "danmaku.blockKeywords")}</p>
<div className="flex flex-wrap items-center gap-2">
{blockKeywords.map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-xs"
>
{kw}
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeBlockKeyword(kw)}
>
×
</button>
</span>
))}
<Input
className="h-8 w-40 text-sm"
value={kwInput}
placeholder={t(locale, "watch.blockKeywordPlaceholder")}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addBlockKeyword();
}
}}
/>
<Button type="button" size="sm" variant="outline" onClick={addBlockKeyword}>
{t(locale, "watch.blockKeywordAdd")}
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t(locale, "danmaku.blockTypes")}</p>
<div className="flex flex-wrap gap-3">
{BLOCK_TYPE_LABELS.map(([kind, labelKey]) => (
<label
key={kind}
className="flex items-center gap-2 text-sm font-medium"
>
<input
type="checkbox"
checked={blockTypes.includes(kind)}
onChange={() => toggleBlockType(kind)}
/>
{t(locale, labelKey)}
</label> </label>
))} <Button onClick={onSave}>{t(locale, "danmaku.save")}</Button>
</div> {save.isSuccess && (
</div> <p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
<div className="flex flex-wrap items-center gap-2 pt-1"> )}
<Button type="button" onClick={onSave} disabled={save.isPending}> <p className="text-xs text-muted-foreground">{t(locale, "danmaku.localImportHint")}</p>
{t(locale, "danmaku.save")}
</Button>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t(locale, "danmaku.serverPrefsHint")}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{t(locale, "danmaku.localImportHint")}
</p>
</Card>
</div> </div>
); );
} }

View File

@ -1,22 +0,0 @@
/**
* 错误页主体:薄 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

@ -1,125 +0,0 @@
/**
* 首页主体:薄 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;
}
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.settings", href: `/${locale}/settings` },
] as const;
return (
<section className="space-y-10 py-10">
<div className="u-reveal">
<p className="u-label u-label-edge">{tr("meta.tagline")}</p>
<h1 className="mt-3 text-[40px] font-black tracking-tight leading-[1.05]">
{tr("home.heroLine1")}
<br />
{tr("home.heroLine2")}
</h1>
<span className="u-edge-bar mt-5" />
<p
className="mt-5 max-w-xl text-[14px] leading-relaxed"
style={{ color: "var(--ink-2);" }}
>
{tr("home.subtitle")}
</p>
<div className="mt-8 flex flex-wrap gap-3">
<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 flex flex-col p-6 u-reveal">
<p className="u-label">{tr("home.stackTitle")}</p>
<p
className="mt-3 text-[14px] leading-relaxed"
style={{ color: "var(--ink-2);" }}
>
{tr("home.stackBody")}
</p>
</section>
<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"
style={{ color: "var(--ink-2);" }}
>
{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>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,447 +1,130 @@
import type { Locale } from "@app/i18n"; import type { Locale } from "@app/i18n";
import { t } from "@app/i18n"; import { t } from "@app/i18n";
import { import { Button, Input } from "@app/ui";
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
toast,
} from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { go } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { createTRPCReactClient, trpc } from "~/lib/trpc";
import { formatTrpcError } from "~/lib/trpc-error";
import { cn } from "~/lib/utils";
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 }) { function MountsInner(props: { locale: Locale }) {
const { locale } = props; const { locale } = props;
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const list = trpc.media.mountList.useQuery(); const list = trpc.media.mountList.useQuery();
const formRef = useRef<HTMLFormElement>(null);
const create = trpc.media.mountCreate.useMutation({ const create = trpc.media.mountCreate.useMutation({
onSuccess: (data) => { onSuccess: () => utils.media.mountList.invalidate(),
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 test = trpc.media.mountTest.useMutation();
const del = trpc.media.mountDelete.useMutation({ const del = trpc.media.mountDelete.useMutation({
onSuccess: (data) => { onSuccess: () => utils.media.mountList.invalidate(),
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 [form, setForm] = useState({
const [mode, setMode] = useState<FormMode>("create"); name: "",
const [editId, setEditId] = useState<string | null>(null); baseUrl: "",
const [form, setForm] = useState<FormState>(EMPTY_FORM); username: "",
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: "", password: "",
rootPath: mount.rootPath || "/", 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() || "/",
// 编辑留空密码时由服务端复用已存密文
mountId: mode === "edit" ? (editId ?? undefined) : undefined,
});
}
return ( return (
<div className="space-y-6"> <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> <h1 className="text-xl font-semibold">{t(locale, "mounts.title")}</h1>
<span className="u-mono text-xs text-muted-foreground"> <p className="text-sm text-muted-foreground">
{String(mountList.length).padStart(2, "0")} {t(locale, "mounts.openlistHint")} {t(locale, "mounts.credentialHint")}
</span> </p>
{mode === "edit" ? ( <form
<Button size="sm" variant="outline" className="ml-auto" onClick={cancelEdit}> className="grid gap-3 rounded-lg border border-border bg-card p-4 md:grid-cols-2"
{t(locale, "mounts.cancelEdit")} onSubmit={(e) => {
</Button> e.preventDefault();
) : ( create.mutate({
<Button name: form.name,
size="sm" type: "webdav",
className="ml-auto" baseUrl: form.baseUrl,
onClick={() => { username: form.username || undefined,
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); password: form.password || undefined,
formRef.current?.querySelector<HTMLInputElement>("input")?.focus(); rootPath: form.rootPath || "/",
enabled: true,
});
}} }}
> >
{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 <Input
id="mount-name"
aria-label={t(locale, "mounts.namePlaceholder")}
placeholder={t(locale, "mounts.namePlaceholder")} placeholder={t(locale, "mounts.namePlaceholder")}
value={form.name} value={form.name}
onChange={(e) => { onChange={(e) => setForm({ ...form, name: e.target.value })}
setForm({ ...form, name: e.target.value }); required
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 <Input
id="mount-url"
aria-label={t(locale, "mounts.baseUrlPlaceholder")}
placeholder={t(locale, "mounts.baseUrlPlaceholder")} placeholder={t(locale, "mounts.baseUrlPlaceholder")}
value={form.baseUrl} value={form.baseUrl}
onChange={(e) => { onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
setForm({ ...form, baseUrl: e.target.value }); required
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 <Input
id="mount-user"
aria-label={t(locale, "mounts.usernamePlaceholder")}
placeholder={t(locale, "mounts.usernamePlaceholder")} placeholder={t(locale, "mounts.usernamePlaceholder")}
value={form.username} value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })} 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 <Input
id="mount-pass"
type="password" 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} value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} 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 <Input
id="mount-root"
aria-label={t(locale, "mounts.rootPathPlaceholder")}
placeholder={t(locale, "mounts.rootPathPlaceholder")} placeholder={t(locale, "mounts.rootPathPlaceholder")}
value={form.rootPath} value={form.rootPath}
onChange={(e) => setForm({ ...form, rootPath: e.target.value })} onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
/> />
</div> <div className="flex gap-2">
</div> <Button type="submit" disabled={create.isPending}>
<div className="flex flex-wrap items-center gap-2"> {t(locale, "mounts.save")}
<Button type="submit" disabled={saving}>
{mode === "edit"
? t(locale, "mounts.saveEdit")
: t(locale, "mounts.save")}
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={test.isPending || !form.baseUrl.trim()} onClick={() =>
onClick={onTest} test.mutate({
baseUrl: form.baseUrl,
username: form.username || undefined,
password: form.password || undefined,
rootPath: form.rootPath || "/",
})
}
> >
{t(locale, "mounts.test")} {t(locale, "mounts.test")}
</Button> </Button>
{mode === "edit" && ( </div>
<Button type="button" variant="ghost" onClick={cancelEdit}>
{t(locale, "mounts.cancelEdit")}
</Button>
)}
{test.data && ( {test.data && (
<p <p className={test.data.ok ? "text-green-600" : "text-red-600"}>
className={cn(
"text-sm",
test.data.ok ? "text-green-600" : "text-red-600",
)}
>
{test.data.message} {test.data.message}
</p> </p>
)} )}
</div>
<p className="text-xs text-muted-foreground">
{t(locale, "mounts.openlistHint")}
</p>
</Card>
</form> </form>
{createdMountId && ( <ul className="divide-y divide-border rounded-lg border border-border">
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-primary/40 bg-primary/5 p-4"> {(list.data?.mounts ?? []).map((m) => (
<p className="text-sm">{t(locale, "mounts.afterCreateHint")}</p> <li key={m.id} className="flex items-center justify-between gap-3 p-3">
<Button <div>
size="sm" <div className="font-medium">{m.name}</div>
className="ml-auto" <div className="text-xs text-muted-foreground break-all">
onClick={() => go(`/${locale}/browse?mount=${createdMountId}`)}
>
{t(locale, "mounts.toBrowse")}
</Button>
</div>
)}
<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.baseUrl}
{m.rootPath && m.rootPath !== "/" ? ` · ${m.rootPath}` : ""} </div>
</CardDescription> </div>
</CardHeader> <div className="flex gap-2">
<CardContent className="flex flex-wrap gap-2 pt-0">
<Button size="sm" variant="secondary" onClick={() => startEdit(m)}>
{t(locale, "mounts.edit")}
</Button>
<Button <Button
size="sm"
variant="outline"
onClick={() => go(`/${locale}/browse?mount=${m.id}`)}
>
{t(locale, "mounts.browseThis")}
</Button>
<Button
size="sm"
variant="destructive" 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" size="sm"
onClick={() => { onClick={() => del.mutate({ id: m.id })}
formRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
formRef.current
?.querySelector<HTMLInputElement>("input")
?.focus();
}}
> >
{t(locale, "mounts.add")} {t(locale, "mounts.delete")}
</Button> </Button>
</div> </div>
</div> </li>
))}
{list.data?.mounts.length === 0 && (
<li className="p-4 text-sm text-muted-foreground">
{t(locale, "mounts.empty")}
</li>
)} )}
</div> </ul>
</section>
</div> </div>
); );
} }

View File

@ -1,260 +0,0 @@
import type { Locale, MessageKey } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, Card, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
import { cn } from "~/lib/utils";
function Inner(props: { locale: Locale }) {
const { locale } = props;
const me = trpc.auth.me.useQuery();
const isAdmin = me.data?.user?.role === "admin";
const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin });
const saveOpenNet = trpc.settings.saveOpenNetwork.useMutation();
const autoScrapeQ = trpc.settings.getAutoScrape.useQuery(undefined, { enabled: isAdmin });
const autoScrapeSave = trpc.settings.saveAutoScrape.useMutation();
const ddpStatus = trpc.settings.dandanplayAuthStatus.useQuery(undefined, {
enabled: Boolean(me.data?.user),
});
const ddpLogin = trpc.settings.loginDandanplay.useMutation();
const ddpLogout = trpc.settings.logoutDandanplay.useMutation();
const [ddpUser, setDdpUser] = useState("");
const [ddpPass, setDdpPass] = useState("");
const [autoScrapeOn, setAutoScrapeOn] = useState(false);
const [autoFilled, setAutoFilled] = useState(false);
const [appId, setAppId] = useState("");
const [appSecret, setAppSecret] = useState("");
const [filled, setFilled] = useState(false);
useEffect(() => {
if (autoScrapeQ.data !== undefined && !autoFilled) {
setAutoScrapeOn(autoScrapeQ.data);
setAutoFilled(true);
}
if (!openNet.data || filled) return;
setAppId(openNet.data.appId);
setAppSecret(openNet.data.appSecret);
setFilled(true);
}, [openNet.data, filled]);
if (me.isLoading) {
return <p className="text-sm text-muted-foreground">{t(locale, "watch.loading")}</p>;
}
if (!isAdmin && !me.data?.user) {
return null;
}
const label = (key: MessageKey) => t(locale, key);
return (
<Card className="space-y-4 p-6">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold">{label("settings.adminSection")}</h2>
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
{label("settings.adminOnly")}
</span>
</div>
<p className="text-sm text-muted-foreground">{label("settings.openNetworkHint")}</p>
<div className="space-y-2 rounded-md border border-border p-3">
<p className="text-sm font-medium">{label("settings.obtainCreds")}</p>
<p className="text-xs text-muted-foreground">{label("settings.obtainCredsHint")}</p>
<p className="text-xs text-muted-foreground">{label("settings.obtainSteps")}</p>
<Button
size="sm"
variant="outline"
onClick={() => {
window.open("https://dev.dandanplay.com", "_blank", "noopener,noreferrer");
}}
>
{label("settings.openDevCenter")}
</Button>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{label("settings.openAppId")}</p>
<Input
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="AppId"
autoComplete="off"
/>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{label("settings.openAppSecret")}</p>
<Input
value={appSecret}
onChange={(e) => setAppSecret(e.target.value)}
placeholder="AppSecret"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">{label("settings.secretPlainHint")}</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
{label("settings.openSource")}: {openNet.data?.source ?? "—"}
</span>
{openNet.data?.envConfigured && <span>{label("settings.envConfigured")}</span>}
</div>
<div className="flex gap-2">
<Button
size="sm"
disabled={saveOpenNet.isPending}
onClick={() =>
saveOpenNet.mutate(
{ appId: appId.trim(), appSecret },
{
onSuccess: () => toast.success(t(locale, "settings.openSaved")),
onError: () => toast.error(t(locale, "settings.openSaveFailed")),
},
)
}
>
{t(locale, "settings.save")}
</Button>
</div>
<div className="space-y-2 border-t border-border pt-3">
<p className="text-sm font-medium">{label("settings.ddpLogin")}</p>
<p className="text-xs text-muted-foreground">{label("settings.ddpLoginHint")}</p>
<p className="text-xs text-muted-foreground">
{label("settings.sharedAccountHint")}
</p>
{ddpStatus.data?.loggedIn ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm">
{t(locale, "settings.ddpLoggedInAs", {
name: ddpStatus.data.screenName ?? "",
})}
</span>
<Button
size="sm"
variant="outline"
disabled={ddpLogout.isPending}
onClick={() =>
ddpLogout.mutate(
{},
{
onSuccess: () => {
void ddpStatus.refetch();
toast.success(t(locale, "settings.ddpLoginOk"));
},
},
)
}
>
{label("settings.ddpLogoutBtn")}
</Button>
</div>
) : (
<div className="flex flex-wrap gap-2">
<Input
className="h-9 w-40"
value={ddpUser}
placeholder={label("settings.ddpUsername")}
onChange={(e) => setDdpUser(e.target.value)}
autoComplete="username"
/>
<Input
className="h-9 w-40"
type="password"
value={ddpPass}
placeholder={label("settings.ddpPassword")}
onChange={(e) => setDdpPass(e.target.value)}
autoComplete="current-password"
/>
<Button
size="sm"
disabled={ddpLogin.isPending || !ddpUser || !ddpPass}
onClick={() =>
ddpLogin.mutate(
{ username: ddpUser.trim(), password: ddpPass },
{
onSuccess: (r) => {
if (r.ok) {
setDdpPass("");
void ddpStatus.refetch();
toast.success(t(locale, "settings.ddpLoginOk"));
} else {
toast.error(
r.message ??
t(locale, "settings.ddpLoginFailed"),
);
}
},
onError: () =>
toast.error(t(locale, "settings.ddpLoginFailed")),
},
)
}
>
{label("settings.ddpLoginBtn")}
</Button>
</div>
)}
</div>
<div className="space-y-3 border-t border-border pt-3">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label("settings.autoScrape")}</p>
<span
className={cn(
"rounded px-1.5 py-0.5 text-[11px]",
autoScrapeOn
? "bg-[var(--color-primary)]/15 text-[var(--color-primary)]"
: "bg-[var(--color-muted)] text-[var(--color-muted-foreground)]",
)}
>
{autoScrapeOn
? label("settings.autoScrapeOn")
: label("settings.autoScrapeOff")}
</span>
</div>
<p className="text-xs text-muted-foreground">{label("settings.autoScrapeHint")}</p>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoScrapeOn}
onChange={(e) => setAutoScrapeOn(e.target.checked)}
className="h-4 w-4"
/>
{label("settings.autoScrapeOn")}
</label>
<Button
size="sm"
disabled={autoScrapeSave.isPending}
onClick={() =>
autoScrapeSave.mutate(
{ enabled: autoScrapeOn },
{
onSuccess: () => {
void autoScrapeQ.refetch();
toast.success(t(locale, "settings.autoScrapeSaved"));
},
onError: () => toast.error(t(locale, "settings.openSaveFailed")),
},
)
}
>
{label("settings.save")}
</Button>
</div>
</Card>
);
}
export function SettingsAdminBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
const client = createTRPCReactClient();
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<Inner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,7 @@
--- ---
import { ClientRouter } from "astro:transitions";
import "../styles/globals.css"; import "../styles/globals.css";
import UserMenu from "~/components/UserMenu"; import UserMenu from "~/components/UserMenu";
import { Toaster } from "@app/ui";
import ThemeSwitch from "~/components/ThemeSwitch"; import ThemeSwitch from "~/components/ThemeSwitch";
import LocaleSwitch from "~/components/LocaleSwitch";
import { import {
buildDefaultJsonLd, buildDefaultJsonLd,
buildHeadTags, buildHeadTags,
@ -12,36 +9,34 @@ import {
toJsonLdScript, toJsonLdScript,
type PageSeoInput, type PageSeoInput,
} from "@app/seo-geo"; } from "@app/seo-geo";
import { import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, t } from "@app/i18n";
alternatePath,
COOKIE_LANG,
COOKIE_LANG_MAX_AGE,
LOCALES,
type Locale,
localePath,
t,
} from "@app/i18n";
import { getSiteSeo } from "~/lib/seo"; import { getSiteSeo } from "~/lib/seo";
export interface Props { export interface Props {
title: string; title: string;
locale: Locale; locale: Locale;
brand?: string; brand?: string;
/** 当前页,用于侧栏高亮 */ nav?: { label: string; href: string; n: string }[];
active?: "home" | "library" | "browse" | "mounts" | "settings"; switchHref?: string;
switchLabel?: string;
seo?: PageSeoInput; seo?: PageSeoInput;
/** 播放页隐藏模板页脚 */ /** 媒体页只保留 AppShell 侧栏时隐藏模板 side-rail(含左让位) */
hideFooter?: boolean; hideSideRail?: boolean;
} }
const { title, locale, brand, active, seo, hideFooter = false } = Astro.props; const {
title,
/** 语言下拉:各语言在当前页的 URL */ locale,
const localeHrefs = Object.fromEntries( brand,
LOCALES.map((code) => [code, alternatePath(Astro.url.pathname, code)]), nav: navProp,
) as Record<Locale, string>; switchHref,
switchLabel,
seo,
hideSideRail = false,
} = Astro.props;
const brandName = brand ?? t(locale, "meta.brand"); const brandName = brand ?? t(locale, "meta.brand");
const nav = navProp ?? [];
const siteSeo = getSiteSeo(locale); const siteSeo = getSiteSeo(locale);
const pageSeoInput: PageSeoInput = { const pageSeoInput: PageSeoInput = {
@ -54,18 +49,9 @@ const headHtml = renderHeadTagsHtml(headTags);
const jsonLdHtml = toJsonLdScript(buildDefaultJsonLd(siteSeo, pageSeoInput)); const jsonLdHtml = toJsonLdScript(buildDefaultJsonLd(siteSeo, pageSeoInput));
const pageTitle = headTags.find((tTag) => tTag.key === "og:title")?.value ?? title; const pageTitle = headTags.find((tTag) => tTag.key === "og:title")?.value ?? title;
const homeHref = localePath(locale, "/"); const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
const brandHref = homeHref; const brandHref = homeHref;
const brandInitial = brandName.charAt(0) || "弹"; const brandInitial = locale === "zh-CN" ? "模" : "A";
/** 全站统一侧栏导航 */
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: "settings", n: "04", label: t(locale, "nav.settings"), href: localePath(locale, "/settings") },
] as const;
--- ---
<!doctype html> <!doctype html>
@ -87,11 +73,6 @@ const nav = [
<title set:html={pageTitle} /> <title set:html={pageTitle} />
<Fragment set:html={headHtml} /> <Fragment set:html={headHtml} />
<Fragment set:html={jsonLdHtml} /> <Fragment set:html={jsonLdHtml} />
<ClientRouter />
{/* ClientRouter onPopState 在 history.state 为 null 时会吞掉前进/后退,见 history-guard */}
<script>
import "@/lib/history-guard";
</script>
<script is:inline define:vars={{ cookieName: COOKIE_LANG, maxAge: COOKIE_LANG_MAX_AGE, nextLocale: locale === "zh-CN" ? "en" : "zh-CN" }}> <script is:inline define:vars={{ cookieName: COOKIE_LANG, maxAge: COOKIE_LANG_MAX_AGE, nextLocale: locale === "zh-CN" ? "en" : "zh-CN" }}>
(function () { (function () {
function apply(mode) { function apply(mode) {
@ -103,21 +84,12 @@ const nav = [
document.documentElement.classList.toggle("light", !dark); document.documentElement.classList.toggle("light", !dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light"; document.documentElement.style.colorScheme = dark ? "dark" : "light";
} }
function applyFromStorage() {
var mode = "system"; var mode = "system";
try { try {
var t = localStorage.getItem("app-theme"); var t = localStorage.getItem("app-theme");
if (t === "light" || t === "dark" || t === "system") mode = t; if (t === "light" || t === "dark" || t === "system") mode = t;
} catch (e) {} } catch (e) {}
apply(mode); apply(mode);
}
applyFromStorage();
// ClientRouter 软导航后 <html> class 会被新页覆盖,需按偏好重放主题
document.addEventListener("astro:after-swap", applyFromStorage);
if (!window.__fanmuChromeInit) {
window.__fanmuChromeInit = true;
window window
.matchMedia("(prefers-color-scheme: dark)") .matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", function () { .addEventListener("change", function () {
@ -142,7 +114,6 @@ const nav = [
}, },
true, true,
); );
}
})(); })();
</script> </script>
</head> </head>
@ -158,6 +129,7 @@ const nav = [
<div aria-hidden="true" class="bg-vignette pointer-events-none fixed inset-0 z-0"></div> <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")}> <aside class="side-rail" aria-label={t(locale, "nav.primary")}>
<a href={brandHref} class="side-rail__brand" aria-label={brandName}> <a href={brandHref} class="side-rail__brand" aria-label={brandName}>
<span <span
@ -170,12 +142,12 @@ const nav = [
</a> </a>
<div class="side-rail__rule" aria-hidden="true"></div> <div class="side-rail__rule" aria-hidden="true"></div>
<nav class="side-rail__nav"> <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) => ( {nav.map((item) => (
<a <a href={item.href} class="side-rail__link">
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 class="u-mono">{item.n}</span>
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
@ -183,15 +155,16 @@ const nav = [
</nav> </nav>
<div class="side-rail__rule" aria-hidden="true"></div> <div class="side-rail__rule" aria-hidden="true"></div>
<div class="side-rail__foot"> <div class="side-rail__foot">
<ThemeSwitch client:only="react" /> <ThemeSwitch client:visible />
</div> </div>
</aside> </aside>
)
} }
<div class="site-shell"> <div class:list={["site-shell", { "site-shell--no-rail": hideSideRail }]}>
<header class="site-header"> <header class="site-header">
<div class="site-header__inner"> <div class="site-header__inner">
<a href={brandHref} class="site-header__brand lg:hidden" aria-label={brandName}> <a href={brandHref} class="site-header__brand md:hidden" aria-label={brandName}>
<span <span
class="brand-signal flex size-7 items-center justify-center text-[11px] font-bold" class="brand-signal flex size-7 items-center justify-center text-[11px] font-bold"
style="background: var(--edge); color: #101110;" style="background: var(--edge); color: #101110;"
@ -203,11 +176,7 @@ const nav = [
<nav class="site-header__nav" aria-label={t(locale, "nav.primary")}> <nav class="site-header__nav" aria-label={t(locale, "nav.primary")}>
{ {
nav.map((item) => ( nav.map((item) => (
<a <a href={item.href} class="u-linelink">
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 class="u-mono">{item.n}</span>
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
@ -215,11 +184,23 @@ const nav = [
} }
</nav> </nav>
<div class="site-header__actions"> <div class="site-header__actions">
<LocaleSwitch client:only="react" locale={locale} hrefs={localeHrefs} /> {
<span class="lg:hidden"> switchHref && (
<ThemeSwitch client:only="react" /> <a
href={switchHref}
class="u-linelink"
data-switch-locale={locale === "zh-CN" ? "en" : "zh-CN"}
hreflang={locale === "zh-CN" ? "en" : "zh-CN"}
aria-label={t(locale, "locale.switch")}
>
{switchLabel ?? (locale === "zh-CN" ? "EN" : "中文")}
</a>
)
}
<span class={hideSideRail ? "" : "md:hidden"}>
<ThemeSwitch client:visible />
</span> </span>
<UserMenu client:only="react" locale={locale} /> <UserMenu client:visible locale={locale} />
</div> </div>
</div> </div>
<div class="site-header__dash" aria-hidden="true"></div> <div class="site-header__dash" aria-hidden="true"></div>
@ -229,16 +210,13 @@ const nav = [
<slot /> <slot />
</main> </main>
{!hideFooter && (
<footer class="site-footer"> <footer class="site-footer">
<div class="u-dotline" aria-hidden="true"></div> <div class="u-dotline" aria-hidden="true"></div>
<div class="site-footer__inner"> <div class="site-footer__inner">
<p class="u-mono">{brandName}</p> <p class="u-mono">{t(locale, "meta.brand")}</p>
<p class="u-label">{t(locale, "home.footer")}</p> <p class="u-label">{t(locale, "home.footer")}</p>
</div> </div>
</footer> </footer>
)}
</div> </div>
<Toaster client:load /> </body>
</body>
</html> </html>

View File

@ -1,27 +0,0 @@
import type { FilterOptions } from "~/lib/danmaku-render";
/** artplayer-plugin-danmuku 的 Danmu 形状(只取过滤所需字段) */
export type DanmuLike = {
text: string;
mode?: number | undefined;
};
export function emptyFilterOptions(): FilterOptions {
return { blockKeywords: [], blockTypes: [] };
}
/**
* 插件入队过滤(option.filter):与 @app/danmaku 的 filterComments 同语义
* (关键词 includes + 类型屏蔽),按插件 mode(0 滚动 / 1 顶部 / 2 底部)判型。
* 屏蔽在 ArtPlayer 插件内完成,React 只喂全量数据;规则经 filterRef 实时读取。
*/
export function danmukuPassesFilter(danmu: DanmuLike, opts: FilterOptions): boolean {
const kind: "scroll" | "top" | "bottom" =
danmu.mode === 1 ? "top" : danmu.mode === 2 ? "bottom" : "scroll";
if (opts.blockTypes.includes(kind)) return false;
for (const raw of opts.blockKeywords) {
const kw = raw.trim();
if (kw && danmu.text.includes(kw)) return false;
}
return true;
}

View File

@ -1,48 +1,95 @@
import { export type DanmakuItem = { time: number; text: string; mode: number };
filterComments,
parseDanmakuXml,
serializeDanmakuXml,
type DanmakuComment,
type FilterOptions,
} from "@app/danmaku/browser";
export { filterComments, parseDanmakuXml, serializeDanmakuXml }; export function parseDanmakuXml(xml: string): DanmakuItem[] {
export type { DanmakuComment, FilterOptions }; 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 time = Number(t);
if (!Number.isFinite(time)) continue;
out.push({ time, text: n.textContent ?? "", mode: Number(mode) || 1 });
}
return out.sort((a, b) => a.time - b.time);
}
/** artplayer-plugin-danmuku 条目(mode 0 滚动 / 1 顶部 / 2 底部) */ export type DanmakuLoopOptions = {
export type DanmakuItem = { opacity?: number | undefined;
time: number; /** 每秒横向位移 px */
text: string; speed?: number | undefined;
mode: 0 | 1 | 2;
color?: string | undefined;
}; };
/** B 站 p 属性 mode → artplayer 插件 mode */ export function startDanmakuLoop(
function toPluginMode(raw: number): 0 | 1 | 2 { container: HTMLElement,
// 1/2/3 滚动,4 底部,5 顶部 getItems: () => DanmakuItem[],
if (raw === 4) return 2; getCurrentTime: () => number,
if (raw === 5) return 1; options?: DanmakuLoopOptions,
return 0; ): () => 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);
function toHexColor(decimal: number): string | undefined { const active: { el: HTMLDivElement; start: number }[] = [];
if (!Number.isFinite(decimal) || decimal < 0) return undefined; const speed = options?.speed ?? 160;
return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`; let raf = 0;
} let cursor = 0;
let lastTime = 0;
/** DanmakuComment(含颜色/模式)→ 插件条目 */ const clearActive = () => {
export function toDanmukuItems(list: DanmakuComment[]): DanmakuItem[] { for (const a of active) a.el.remove();
return list.map((c) => ({ active.length = 0;
time: c.time, };
text: c.text,
mode: toPluginMode(c.mode),
color: toHexColor(c.color),
}));
}
/** density 0.1–1:按时间均匀抽稀 */ const tick = () => {
export function thinDanmaku(list: DanmakuComment[], density: number): DanmakuComment[] { const now = getCurrentTime();
if (density >= 1) return list; const items = getItems();
const keepEvery = Math.max(1, Math.round(1 / density)); // 进度回跳时重置游标,避免一次性涌出全部历史弹幕
return list.filter((_, i) => i % keepEvery === 0); 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();
};
} }

View File

@ -1,77 +0,0 @@
import { navigate } from "astro:transitions/client";
/**
* Astro ClientRouter 的 onPopState 在 `ev.state === null` 时直接 return,
* 结果是地址栏已变、页面不更新。本模块做两层兜底:
* 1. 禁止 history API 把 state 写成 null / 缺 index 的形状;
* 2. popstate 仍遇到 null state 时,按当前 URL 补一次软导航(失败则整页加载)。
*/
type HistoryStateShape = {
index?: number | undefined;
scrollX?: number | undefined;
scrollY?: number | undefined;
};
function currentState(): HistoryStateShape | null {
const s = window.history.state as HistoryStateShape | null | undefined;
return s && typeof s === "object" ? s : null;
}
function withRouterState(state: unknown): unknown {
const prev = currentState();
const index = prev?.index ?? 0;
const scrollX = prev?.scrollX ?? window.scrollX;
const scrollY = prev?.scrollY ?? window.scrollY;
if (state == null) return { index, scrollX, scrollY };
if (typeof state !== "object") return state;
const obj = state as Record<string, unknown>;
return {
...obj,
index: typeof obj["index"] === "number" ? obj["index"] : index,
scrollX: typeof obj["scrollX"] === "number" ? obj["scrollX"] : scrollX,
scrollY: typeof obj["scrollY"] === "number" ? obj["scrollY"] : scrollY,
};
}
function patchHistory(): void {
const historyProto = window.history;
const rawReplace = historyProto.replaceState.bind(historyProto);
const rawPush = historyProto.pushState.bind(historyProto);
historyProto.replaceState = (data: unknown, unused: string, url?: string | URL | null) => {
rawReplace(withRouterState(data), unused, url ?? undefined);
};
historyProto.pushState = (data: unknown, unused: string, url?: string | URL | null) => {
rawPush(withRouterState(data), unused, url ?? undefined);
};
}
function onPopState(ev: PopStateEvent): void {
if (ev.state != null) return;
// ClientRouter 已在此刻 return;补 state 后按当前 URL 软导航,恢复换页
const href = window.location.href;
rawEnsureState();
void navigate(href).catch(() => {
window.location.reload();
});
}
function rawEnsureState(): void {
const prev = currentState();
if (prev?.index != null && prev.scrollX != null && prev.scrollY != null) return;
window.history.replaceState(withRouterState(prev), "", window.location.href);
}
let installed = false;
/** 幂等安装 history 守卫(模块副作用入口)。 */
export function installHistoryGuard(): void {
if (installed || typeof window === "undefined") return;
installed = true;
patchHistory();
window.addEventListener("popstate", onPopState);
rawEnsureState();
}
installHistoryGuard();

View File

@ -1,29 +0,0 @@
import { navigate } from "astro:transitions/client";
import "@/lib/history-guard";
/** 站内软导航(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);
}
/**
* 更新查询参数(不刷新,便于刷新/分享后停留在同一选集)。
* history-guard 会保证 replaceState 不把 ClientRouter 的 state 抹成 null,
* 否则 onPopState 直接 return,浏览器前进/后退只变地址栏、不换页。
*/
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);
}
if (url.toString() === window.location.href) return;
window.history.replaceState(window.history.state, "", url.toString());
}

View File

@ -17,8 +17,8 @@ export function getSiteSeo(locale: Locale): SiteSeoConfig {
const name = t(locale, "meta.brand"); const name = t(locale, "meta.brand");
const isZh = locale === "zh-CN"; const isZh = locale === "zh-CN";
const description = isZh const description = isZh
? "挂载 WebDAV/OpenList 片源的 Web 媒体库:扫描、Bangumi 刮削、ArtPlayer 播放与在线弹幕。" ? "Astro + tRPC + Drizzle monorepo 应用脚手架,开箱即用的分层基线。"
: "Web media library for WebDAV/OpenList: scan, Bangumi scrape, ArtPlayer playback and online danmaku."; : "Astro + tRPC + Drizzle monorepo application scaffold with a ready vertical baseline.";
return { return {
siteUrl, siteUrl,
@ -29,15 +29,15 @@ export function getSiteSeo(locale: Locale): SiteSeoConfig {
organization: { name, url: siteUrl }, organization: { name, url: siteUrl },
llms: { llms: {
summary: isZh summary: isZh
? "帆幕:私有影音库与弹幕观影。WebDAV 挂载、媒体库扫描、Bangumi 刮削、ArtPlayer 播放与弹弹play 开放弹幕网络中继,本地 XML 兜底。" ? "这是可复用的应用脚手架模板:Astro SSR + React islands + tRPC + Drizzle/Turso,附带鉴权与用户垂直示例。"
: "Fanmu: private media library with danmaku. WebDAV mounts, library scan, Bangumi scraping, ArtPlayer playback, dandanplay open danmaku relay with local XML fallback.", : "Reusable application scaffold: Astro SSR + React islands + tRPC + Drizzle/Turso, with auth and a user vertical example.",
details: [ details: [
isZh isZh
? "分层:types → models → db → dao → trpc → web。媒体域按业务分层扩展。" ? "分层:types → models → db → dao → trpc → web。业务实体按 user 切片扩展,禁止跨层。"
: "Layers: types → models → db → dao → trpc → web. Extend the media domain as a vertical slice.", : "Layers: types → models → db → dao → trpc → web. Extend domains via the user slice; never skip layers.",
isZh isZh
? "挂载凭据仅存服务端加密;浏览器不直连网盘密码。" ? "不绑定具体产品业务;works 等领域逻辑应在业务仓库中添加。"
: "Mount credentials are encrypted server-side; the browser never sees remote passwords.", : "No product domain baked in; add domains such as works in your product repo.",
], ],
pages: PUBLIC_PATHS.map((basePath) => ({ pages: PUBLIC_PATHS.map((basePath) => ({
path: pathForLocale(locale, basePath), path: pathForLocale(locale, basePath),
@ -45,9 +45,9 @@ export function getSiteSeo(locale: Locale): SiteSeoConfig {
description, description,
})), })),
discouraged: isZh discouraged: isZh
? ["/library、/watch 等需登录页面", "/auth/* 等认证页", "/api/* 接口响应"] ? ["/dashboard、/settings 等需登录页面", "/auth/* 等认证页", "/api/* 接口响应"]
: [ : [
"/library, /watch and other app pages", "/dashboard, /settings and other app pages",
"/auth/* and auth pages", "/auth/* and auth pages",
"/api/* responses", "/api/* responses",
], ],

View File

@ -1,26 +0,0 @@
/** 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,26 +8,6 @@ import type { AppRouter } from "@app/trpc";
export const trpc = createTRPCReact<AppRouter>(); 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() { export function createTRPCReactClient() {
return trpc.createClient({ return trpc.createClient({
links: [ links: [
@ -35,7 +15,6 @@ export function createTRPCReactClient() {
url: "/api/trpc", url: "/api/trpc",
// 便于服务端日志区分 island 流量 // 便于服务端日志区分 island 流量
headers: () => ({ "x-trpc-source": "react-island" }), headers: () => ({ "x-trpc-source": "react-island" }),
fetch: safeFetch,
}), }),
], ],
}); });

View File

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

View File

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

View File

@ -1,5 +1,6 @@
--- ---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n"; import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Danmaku from "~/views/danmaku.astro";
const raw = Astro.params["locale"]; const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw; const normalized = raw === "zh-cn" ? "zh-CN" : raw;
@ -9,10 +10,10 @@ if (!normalized || !isLocale(normalized)) {
Astro.cookies.get(COOKIE_LANG)?.value, Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"), Astro.request.headers.get("accept-language"),
); );
return Astro.redirect(localePath(preferred, "/settings")); return Astro.redirect(localePath(preferred, "/danmaku"));
} }
const locale: Locale = normalized; const locale: Locale = normalized;
// 弹幕设置已整合进 settings 页,旧地址保留重定向
return Astro.redirect(localePath(locale, "/settings"));
--- ---
<Danmaku locale={locale} />

View File

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

View File

@ -1,16 +1,6 @@
--- ---
import { import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
COOKIE_LANG, import AuthView from "~/views/auth.astro";
isLocale,
localePath,
resolvePreferredLocale,
t,
type Locale,
} from "@app/i18n";
import AppLayout from "~/layouts/AppLayout.astro";
import AuthForm from "~/components/AuthForm";
import { DanmakuSettingsBody } from "~/components/pages/DanmakuSettingsBody";
import { SettingsAdminBody } from "~/components/pages/SettingsAdminBody";
const raw = Astro.params["locale"]; const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw; const normalized = raw === "zh-cn" ? "zh-CN" : raw;
@ -24,20 +14,6 @@ if (!normalized || !isLocale(normalized)) {
} }
const locale: Locale = normalized; const locale: Locale = normalized;
const pageTitle = `${t(locale, "settings.title")} · ${t(locale, "meta.brand")}`;
--- ---
<AppLayout <AuthView locale={locale} mode="change" />
title={pageTitle}
locale={locale}
active="settings"
seo={{ title: pageTitle, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }}
>
{/* 单列限宽:三张设置卡等宽对齐(账户 / 弹幕 / 开放网络) */}
<div class="max-w-2xl space-y-6">
<h1 class="text-xl font-semibold">{t(locale, "settings.title")}</h1>
<AuthForm locale={locale} mode="change" client:load />
<DanmakuSettingsBody locale={locale} client:load />
<SettingsAdminBody locale={locale} client:load />
</div>
</AppLayout>

View File

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

View File

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

View File

@ -15,16 +15,16 @@ function buildFor(locale: Locale): string {
} }
return buildLlmsFullTxt(siteSeo, { return buildLlmsFullTxt(siteSeo, {
title: `${t(locale, "meta.brand")} · product guide`, title: `${t(locale, "meta.brand")} · scaffold index`,
sections: [ sections: [
{ {
heading: t(locale, "home.stackTitle"), heading: t(locale, "home.stackTitle"),
path: "/zh-CN", path: "/zh-CN",
summary: t(locale, "home.stackBody"), summary: t(locale, "home.stackBody"),
points: [ points: [
"WebDAV / OpenList 挂载代理(凭据服务端加密)", "Astro 7 SSR + React 19 islands",
"扫描入库 + Bangumi 刮削 + ArtPlayer 播放", "tRPC v11 + Drizzle ORM + Turso/libSQL",
"弹弹play 开放弹幕网络中继,本地 XML 兜底", "Tailwind CSS 4 + Biome + Yarn 4 workspaces + Turbo",
], ],
}, },
{ {
@ -32,19 +32,19 @@ function buildFor(locale: Locale): string {
path: "/zh-CN", path: "/zh-CN",
summary: t(locale, "home.layerBody"), summary: t(locale, "home.layerBody"),
points: [ points: [
"多用户:每人独立媒体库与播放进度", "types → models → db → dao → trpc → web",
"统一左侧边栏导航:首页 / 媒体库 / 文件浏览 / 挂载管理 / 弹幕设置", "user / auth 为垂直示例,可替换为业务实体",
], ],
}, },
], ],
faqs: [ faqs: [
{ {
question: "如何接入 OpenList?", question: "这个模板包含业务逻辑吗?",
answer: "在挂载管理填写 OpenList 暴露的 WebDAV 地址(如 https://host/dav)与账号,测试连接后保存。", answer: "不包含产品业务。仅保留鉴权与 user 垂直示例,用于演示分层接线;具体业务应在下游仓库添加。",
}, },
{ {
question: "弹幕从哪里来?", question: "如何新增业务实体?",
answer: "优先走弹弹play 开放弹幕网络(需配置 AppId/AppSecret);失败或未配置时可在播放页导入本地 XML。", answer: "按 user 切片扩展:@app/types 加 schema → @app/models 建表 → @app/dao 加查询 → @app/trpc 加 service/router → @app/web 挂页面。",
}, },
], ],
}); });

View File

@ -1,9 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@app/design-tokens/tokens.css"; @import "@app/design-tokens/tokens.css";
/* 扫描 monorepo 里的 UI 包,避免 dropdown 等组件类未生成 */
@source "../../../../packages/ui/src";
*, *,
*::before, *::before,
*::after { *::after {
@ -11,126 +8,6 @@
border-color: var(--line); 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 { html {
font-family: var(--font-sans); font-family: var(--font-sans);
scroll-behavior: smooth; scroll-behavior: smooth;
@ -186,17 +63,6 @@ select {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* 组件内 u-mono 调整:置于 .u-linelink:hover 等更高特异性规则之前 */
.u-hud-strip .u-mono {
flex-shrink: 0;
}
.side-rail__link .u-mono {
font-size: 10px;
letter-spacing: 0.14em;
color: var(--ink-3);
}
.u-label { .u-label {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
@ -257,9 +123,7 @@ html.has-reveal .u-reveal.is-in {
opacity: 1; opacity: 1;
transform: none; transform: none;
} }
/* island 内部不依赖 JS 加类:入场后直接显示,避免 hydration 冲突。 /* island 内部不依赖 JS 加类:入场后直接显示,避免 hydration 冲突 */
两条规则需按源序覆盖上一条的初始隐藏态,故不做特异性排序。 */
/* biome-ignore lint/style/noDescendingSpecificity: 源序覆盖是刻意的级联依赖 */
astro-island .u-reveal, astro-island .u-reveal,
html.has-reveal astro-island .u-reveal { html.has-reveal astro-island .u-reveal {
opacity: 1; opacity: 1;
@ -377,25 +241,19 @@ html.has-reveal astro-island .u-reveal {
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1); transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
} }
.u-linelink:hover, .u-linelink:hover,
.u-linelink:focus-visible, .u-linelink:focus-visible {
.u-linelink.is-active {
color: var(--ink); color: var(--ink);
} }
.u-linelink:hover::after, .u-linelink:hover::after,
.u-linelink:focus-visible::after { .u-linelink:focus-visible::after {
transform: scaleX(1); transform: scaleX(1);
} }
.u-linelink.is-active::after {
transform: scaleX(1);
height: 2px;
}
.u-linelink .u-mono { .u-linelink .u-mono {
font-size: 10px; font-size: 10px;
letter-spacing: 0.12em; letter-spacing: 0.12em;
color: var(--ink-3); 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); color: var(--edge-as-text);
} }
@ -444,8 +302,7 @@ html.has-reveal astro-island .u-reveal {
/* —— 卡片:5px 圆角 + 轻阴影 —— */ /* —— 卡片:5px 圆角 + 轻阴影 —— */
.u-card { .u-card {
position: relative; background: var(--panel);
background: color-mix(in srgb, var(--panel) 92%, var(--ink) 2%);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 5px; border-radius: 5px;
box-shadow: 0 7px 12px color-mix(in srgb, #000 18%, transparent); box-shadow: 0 7px 12px color-mix(in srgb, #000 18%, transparent);
@ -499,21 +356,6 @@ html.has-reveal astro-island .u-reveal {
letter-spacing: 0.08em; 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;
}
/* —— 左侧固定竖栏 —— */ /* —— 左侧固定竖栏 —— */
.side-rail { .side-rail {
display: none; display: none;
@ -582,29 +424,20 @@ html.has-reveal astro-island .u-reveal {
color 0.2s ease, color 0.2s ease,
border-color 0.2s ease; border-color 0.2s ease;
} }
.side-rail__link .u-mono {
font-size: 10px;
letter-spacing: 0.14em;
color: var(--ink-3);
}
.side-rail__link:hover, .side-rail__link:hover,
.side-rail__link:focus-visible { .side-rail__link:focus-visible {
background: color-mix(in srgb, var(--edge) 8%, transparent); background: color-mix(in srgb, var(--edge) 10%, transparent);
border-color: color-mix(in srgb, var(--edge) 30%, var(--line)); border-color: color-mix(in srgb, var(--edge) 35%, var(--line));
color: var(--ink); color: var(--ink);
} }
.side-rail__link.is-active { .side-rail__link:hover .u-mono {
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); 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 { .side-rail__foot {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -638,12 +471,18 @@ html.has-reveal astro-island .u-reveal {
padding-left: 8.5rem; padding-left: 8.5rem;
padding-right: 3.5rem; padding-right: 3.5rem;
} }
.site-shell--no-rail {
padding-left: 3.5rem;
}
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
.site-shell { .site-shell {
padding-left: 10.5rem; padding-left: 10.5rem;
padding-right: 5rem; padding-right: 5rem;
} }
.site-shell--no-rail {
padding-left: 5rem;
}
} }
.site-shell--page { .site-shell--page {
height: 100dvh; height: 100dvh;
@ -702,25 +541,18 @@ html.has-reveal astro-island .u-reveal {
text-decoration: none; text-decoration: none;
color: var(--ink); color: var(--ink);
} }
/* 桌面已有侧栏品牌,顶栏品牌隐藏(Tailwind lg:hidden 会被上面的 display:flex 压掉) */
@media (min-width: 1024px) {
.site-header__brand {
display: none;
}
}
.site-header__nav { .site-header__nav {
display: flex; display: none;
align-items: center; align-items: center;
gap: 1.35rem; gap: 1.35rem;
font-size: 13px; font-size: 13px;
overflow-x: auto;
min-width: 0;
scrollbar-width: none;
} }
.site-header__nav::-webkit-scrollbar { @media (min-width: 768px) {
display: none; .site-header__nav {
display: flex;
}
} }
/* 桌面已有侧栏时,顶栏隐藏重复分类导航,仅保留品牌/操作 */ /* 桌面已有侧栏时,顶栏可隐藏重复分类导航,仅保留品牌/操作 */
@media (min-width: 1024px) { @media (min-width: 1024px) {
.site-header__nav { .site-header__nav {
display: none; display: none;

View File

@ -1,6 +1,7 @@
--- ---
import { type Locale, t } from "@app/i18n"; import { alternatePath, type Locale, t } from "@app/i18n";
import AuthBody from "~/components/pages/AuthBody"; import { Toaster } from "@app/ui";
import AuthForm from "~/components/AuthForm";
import AppLayout from "~/layouts/AppLayout.astro"; import AppLayout from "~/layouts/AppLayout.astro";
export interface Props { export interface Props {
@ -11,6 +12,7 @@ export interface Props {
} }
const { locale, mode, next, token } = Astro.props; const { locale, mode, next, token } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const titleKey = const titleKey =
mode === "login" mode === "login"
@ -22,14 +24,18 @@ const titleKey =
: mode === "reset" : mode === "reset"
? "auth.reset.title" ? "auth.reset.title"
: "auth.settings.title"; : "auth.settings.title";
const pageTitle = `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`;
--- ---
<AppLayout <AppLayout
title={pageTitle} title={`${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`}
locale={locale} locale={locale}
seo={{ title: pageTitle, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }} 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"] }}
> >
<AuthBody client:load locale={locale} mode={mode} next={next} token={token} /> <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 />
</AppLayout> </AppLayout>

View File

@ -1,5 +1,7 @@
--- ---
import { type Locale, t } from "@app/i18n"; 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 { BrowseBody } from "~/components/pages/BrowseBody";
import AppLayout from "~/layouts/AppLayout.astro"; import AppLayout from "~/layouts/AppLayout.astro";
@ -8,18 +10,32 @@ export interface Props {
} }
const { locale } = Astro.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"),
"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 <AppLayout
title={t(locale, "browse.title")} title={t(locale, "browse.title")}
locale={locale} locale={locale}
active="browse" nav={[]}
brand={t(locale, "meta.product")} hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{ seo={{
title: `${t(locale, "browse.title")} · ${t(locale, "meta.brand")}`, title: `${t(locale, "browse.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname, path: Astro.url.pathname,
robots: ["noindex", "nofollow"], robots: ["noindex", "nofollow"],
}} }}
> >
<AppShell locale={locale} active="browse" labels={labels}>
<BrowseBody locale={locale} client:load /> <BrowseBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout> </AppLayout>

View File

@ -0,0 +1,40 @@
---
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";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"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
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "danmaku.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="danmaku" labels={labels}>
<DanmakuSettingsBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -0,0 +1,41 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import UserList from "~/components/UserList";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
---
<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"],
}}
>
<div class="space-y-10 py-8">
<section class="u-reveal">
<p class="u-label u-label-edge">{t(locale, "dashboard.label")}</p>
<h2 class="mt-2 text-[32px] font-black tracking-tight">{t(locale, "dashboard.heading")}</h2>
<span class="u-edge-bar mt-4"></span>
<p class="mt-4 max-w-2xl text-[14px] leading-relaxed" style="color: var(--ink-2);">
{t(locale, "dashboard.desc")}
</p>
<div class="u-card mt-8 p-6">
<UserList client:visible />
</div>
</section>
</div>
<Toaster client:load />
</AppLayout>

View File

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

View File

@ -1,6 +1,5 @@
--- ---
import { type Locale, t } from "@app/i18n"; import { alternatePath, type Locale, t } from "@app/i18n";
import HomeBody from "~/components/pages/HomeBody";
import AppLayout from "~/layouts/AppLayout.astro"; import AppLayout from "~/layouts/AppLayout.astro";
export interface Props { export interface Props {
@ -8,12 +7,49 @@ export interface Props {
} }
const { locale } = Astro.props; const { locale } = Astro.props;
const tr = (key: Parameters<typeof t>[1]) => t(locale, key);
const otherLocale: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const switchHref = alternatePath(Astro.url.pathname, otherLocale);
const dashHref = locale === "zh-CN" ? "/zh-CN/dashboard" : "/en/dashboard";
const loginHref = locale === "zh-CN" ? "/zh-CN/auth/login" : "/en/auth/login";
--- ---
<AppLayout <AppLayout
title={t(locale, "home.title")} title={tr("home.title")}
locale={locale} locale={locale}
active="home" nav={[]}
switchHref={switchHref}
switchLabel={t(otherLocale, `locale.${otherLocale}` as never)}
> >
<HomeBody client:load locale={locale} /> <section class="space-y-10 py-10">
<div class="u-reveal">
<p class="u-label u-label-edge">{tr("meta.tagline")}</p>
<h1 class="mt-3 text-[40px] font-black tracking-tight leading-[1.05]">
{tr("home.heroLine1")}<br />{tr("home.heroLine2")}
</h1>
<span class="u-edge-bar mt-5"></span>
<p class="mt-5 max-w-xl text-[14px] leading-relaxed" style="color: var(--ink-2);">
{tr("home.subtitle")}
</p>
<div class="mt-8 flex flex-wrap gap-3">
<a href={dashHref} class="u-btn u-btn--edge">{tr("home.ctaDashboard")}</a>
<a href={loginHref} class="u-btn u-btn--ghost">{tr("home.ctaLogin")}</a>
</div>
</div>
<div class="grid gap-6 md:grid-cols-2">
<section class="u-card p-6 u-reveal">
<p class="u-label">{tr("home.stackTitle")}</p>
<p class="mt-3 text-[14px] leading-relaxed" style="color: var(--ink-2);">
{tr("home.stackBody")}
</p>
</section>
<section class="u-card p-6 u-reveal">
<p class="u-label">{tr("home.layerTitle")}</p>
<p class="mt-3 text-[14px] leading-relaxed" style="color: var(--ink-2);">
{tr("home.layerBody")}
</p>
</section>
</div>
</section>
</AppLayout> </AppLayout>

View File

@ -1,5 +1,7 @@
--- ---
import { type Locale, t } from "@app/i18n"; 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 { LibraryBody } from "~/components/pages/LibraryBody";
import AppLayout from "~/layouts/AppLayout.astro"; import AppLayout from "~/layouts/AppLayout.astro";
@ -8,18 +10,31 @@ export interface Props {
} }
const { locale } = Astro.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"),
"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 <AppLayout
title={t(locale, "library.title")} title={t(locale, "library.title")}
locale={locale} locale={locale}
active="library" nav={[]}
brand={t(locale, "meta.product")} hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{ seo={{
title: `${t(locale, "library.title")} · ${t(locale, "meta.brand")}`, title: `${t(locale, "library.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname, path: Astro.url.pathname,
robots: ["noindex", "nofollow"], robots: ["noindex", "nofollow"],
}} }}
> >
<AppShell locale={locale} active="library" labels={labels}>
<LibraryBody locale={locale} client:load /> <LibraryBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout> </AppLayout>

View File

@ -1,5 +1,7 @@
--- ---
import { type Locale, t } from "@app/i18n"; 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 { MountsBody } from "~/components/pages/MountsBody";
import AppLayout from "~/layouts/AppLayout.astro"; import AppLayout from "~/layouts/AppLayout.astro";
@ -8,18 +10,31 @@ export interface Props {
} }
const { locale } = Astro.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"),
"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 <AppLayout
title={t(locale, "mounts.title")} title={t(locale, "mounts.title")}
locale={locale} locale={locale}
active="mounts" nav={[]}
brand={t(locale, "meta.product")} hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{ seo={{
title: `${t(locale, "mounts.title")} · ${t(locale, "meta.brand")}`, title: `${t(locale, "mounts.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname, path: Astro.url.pathname,
robots: ["noindex", "nofollow"], robots: ["noindex", "nofollow"],
}} }}
> >
<AppShell locale={locale} active="mounts" labels={labels}>
<MountsBody locale={locale} client:load /> <MountsBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout> </AppLayout>

View File

@ -1,5 +1,7 @@
--- ---
import { type Locale, t } from "@app/i18n"; 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 { WatchBody } from "~/components/pages/WatchBody";
import AppLayout from "~/layouts/AppLayout.astro"; import AppLayout from "~/layouts/AppLayout.astro";
@ -9,18 +11,31 @@ export interface Props {
} }
const { locale, mediaItemId } = Astro.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"),
"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 <AppLayout
title={t(locale, "watch.title")} title={t(locale, "watch.title")}
locale={locale} locale={locale}
brand={t(locale, "meta.product")} nav={[]}
hideFooter hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{ seo={{
title: `${t(locale, "watch.title")} · ${t(locale, "meta.brand")}`, title: `${t(locale, "watch.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname, path: Astro.url.pathname,
robots: ["noindex", "nofollow"], robots: ["noindex", "nofollow"],
}} }}
> >
<AppShell locale={locale} active="watch" labels={labels}>
<WatchBody locale={locale} mediaItemId={mediaItemId} client:load /> <WatchBody locale={locale} mediaItemId={mediaItemId} client:load />
</AppShell>
<Toaster client:load />
</AppLayout> </AppLayout>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 665 KiB

View File

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

View File

@ -2,7 +2,7 @@
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 在 本产品仓 产品化全栈上实现多用户 Web 媒体中心:WebDAV 挂载(含 OpenList)→ 扫描/Bangumi 刮削 → ArtPlayer 播放 + 弹幕(开放网络中继/本地 XML)→ 进度与 shadcn 风格 dashboard UI。 **目标:** 在 `app-template` 产品化全栈上实现多用户 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 代理。 **架构:** 方案 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"> <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"> <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"> <div className="mb-6 px-2 text-sm font-semibold tracking-wide">
{props.labels["meta.brand"] ?? "帆幕"} {props.labels["meta.brand"] ?? "弹弹 Web"}
</div> </div>
<nav className="flex flex-col gap-1" aria-label="sidebar"> <nav className="flex flex-col gap-1" aria-label="sidebar">
{NAV.map((item) => ( {NAV.map((item) => (

View File

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

View File

@ -1,182 +0,0 @@
# 对齐弹弹play能力(核心链路 + 协议/数据格式)— 设计规格
- **日期**:2026-09-26
- **状态**:设计已获用户批准,待规格审查 → writing-plans
- **仓库**:本产品仓(业务进入现有分层 + 新增 `@app/danmaku`)
- **前置**:`2026-09-25-dandanplay-web-design.md`(Web 版弹弹play 主体)已实现主链路
## 1. 目标与范围
在已有「挂载 → 找片 → 播放 → 弹幕 → 进度」之上,把弹幕能力与弹弹play **开放弹幕网络**在体验与协议上对齐。
**In scope**
1. **协议/数据兼容**
- 开放 API:`/api/v2/match`、`/api/v2/comment/{episodeId}`(含 `withRelated`)、AppId 签名
- 数据格式:文件识别 hash(前 16MB MD5)、B 站/弹弹play 兼容弹幕 XML、匹配元数据
2. **核心链路深对齐**
- 候选集匹配与手选(不盲取第一条)
- 弹幕持久化与同目录自动关联(修 P1-3)
- 关联弹幕与渲染体验(`withRelated`、prefs 服务端同步)
- 弹幕过滤与屏蔽(关键词、类型)
**Out of scope(YAGNI)**
- 远程访问协议(弹弹play 连帆幕)
- protobuf 弹幕、多弹幕站聚合
- BT/RSS、AI 字幕、投屏、Emby/Jellyfin 直连(见产品文档「还没有」)
**验收口径**:真实链路可跑通(文件 → 匹配候选 → 手选 → 拉弹幕 → 过滤 → 持久化,刷新/多设备可恢复)。无凭证时降级路径也要可用。
**实现路径(用户选定)**:**方案 B — 独立 `@app/danmaku` 协议包**(非垂直增强、非先契约后功能)。
## 2. 架构与包边界
新增纯协议包 `@app/danmaku`:无 DB、无 tRPC;HTTP 仅用全局 `fetch`。
| 模块 | 职责 |
|------|------|
| `signature` | `base64(sha256(AppId + Timestamp + Path + AppSecret))` |
| `hash` | 文件头 16MB MD5(流式,禁止整文件缓冲) |
| `match` | `POST /api/v2/match` 入参/出参、`hashAndFileName` / `fileNameOnly`、候选排序纯函数 |
| `comment` | `GET /api/v2/comment/{episodeId}`、`withRelated`、comments 结构化 |
| `xml` | B 站/弹弹play 兼容 XML 解析与序列化(`p` 字段双向) |
| `types` | `EpisodeId`、`MatchCandidate`、`DanmakuComment` 等纯类型 |
| `filter` | `filterComments`(关键词、类型、防重叠可选) |
依赖方向(只新增向下边,禁止循环):
```
web ──► trpc ──► dao ──► db ──► models
│ │
│ ├──► types
│ └──► danmaku(协议包)
└──► danmaku(XML 解析复用)
```
- **trpc** `danmaku.service`:鉴权、落库、缓存、绑定编排 + 调 `@app/danmaku`
- **web**:`danmaku-render.ts` 格式解析改为引用 `@app/danmaku` 的 `xml`(单一解析实现)
- **包位置**:`packages/danmaku`,构建方式对齐 `@app/types`(vite + tsc dts)
## 3. 数据模型
Schema 源仍为 `@app/models`;查询进 `@app/dao`。
### `media_items` 增列
| 字段 | 说明 |
|------|------|
| `dandanEpisodeId` | 开放网络 episodeId;空 = 未绑定 |
| `danmakuMatchSource` | `auto` \| `manual` \| `none` |
| `danmakuMatchedAt` | 上次匹配时间 |
### `danmaku_prefs`(用户级,新表)
`userId · enabled · opacity · density · blockKeywords(JSON) · blockTypes(JSON) · updatedAt`
唯一 `(userId)`。blockTypes:`scroll` / `top` / `bottom`。
### `danmaku_docs`(正文持久化,新表,修 P1-3)
`id · userId · mediaItemId · source: open-network|local-xml · xml · byteSize · createdAt`
唯一 `(userId, mediaItemId, source)`(同源仅留最新)。`userId` 级隔离。
说明:`withRelated` 关联弹幕在拉取时**并入** open-network 正文,不单独落 `related` 源。
### `danmaku_cache`(保留,全局共享)
未绑定时 `matchKey = sha256(path:size)`;**绑定后**缓存键改为 `episodeId`(集身份比文件身份稳定)。
### Zod(`@app/types`)
- `danmakuMatchSelectSchema`(mediaItemId + episodeId)
- `danmakuImportSchema`(替代仅 `byteSize` 的 `importMeta`,可含 fileName)
- `danmakuPrefsSchema`(含 blockKeywords / blockTypes)
- 输出:`MatchCandidate[]`、`DanmakuFetchOutput`(source 扩展 `local-xml` 等)
## 4. 协议/数据格式对齐
| 对齐点 | 规范 |
|--------|------|
| 文件识别 | 前 16MB MD5 + 文件名;有 hash 用 `hashAndFileName`,否则 `fileNameOnly` |
| 匹配 | `POST /api/v2/match` → `matches[]`;按置信/用户选择写入 `dandanEpisodeId` |
| 弹幕 | `GET /api/v2/comment/{id}?withRelated=true` |
| 鉴权 | `X-AppId` / `X-Timestamp` / `X-Signature`(现有算法保留 + 单测向量) |
| XML | `<d p="time,mode,size,color,timestamp,pool,uid,rowid">text</d>`;round-trip 测试 |
| 降级 | 无凭证/失败 → 本地 XML 仍可用 |
环境变量沿用:`OPEN_DANMAKU_APP_ID` / `OPEN_DANMAKU_APP_SECRET` / `OPEN_DANMAKU_API_BASE`。
## 5. 页面与交互
路由不变;只增强 `/watch`、`/danmaku` 等组件。
### 候选集匹配与手选
1. 打开 `/watch`:无 `dandanEpisodeId` → 自动 match → 高置信绑定(`source=auto`)→ 拉弹幕
**高置信定义**:`matches` 仅 1 条,或最高分领先第二名 ≥ 阈值(默认 0.15,常量可调);否则进手选
2. 未匹配/低置信 → 「重新匹配」→ 候选列表(标题、集数、类型、置信)→ 点选绑定(`source=manual`)→ 立即拉弹幕
3. 已绑定可「更换匹配」;弹幕集绑定与 Bangumi 绑定**相互独立**
### 弹幕持久化与自动关联
- 导入 XML → 服务端 `danmaku_docs(source=local-xml)`(≤20MB)→ 刷新/换端可恢复
- 播放优先级:**本地已导入 > 开放网络**
- 浏览/扫描发现同目录 `同名.xml` → 登记 `local-xml`(正文按需从 WebDAV 拉取)→ 播放页提示「发现同名弹幕」
### 关联弹幕与渲染体验
- `withRelated` 并入;过滤面板可显示本集/关联计数
- 继续用 artplayer-plugin-danmuku;`danmaku_prefs` 服务端同步(开关/透明度/密度),localStorage 仅作无网络回退
- 密度 `thinDanmaku` 仍在客户端
### 弹幕过滤与屏蔽
- 播放页过滤面板:关键词、类型(滚动/顶部/底部)、防重叠(插件已有)
- 设置页:默认值 + 屏蔽词编辑 → `danmaku_prefs`
- 过滤在 XML → comments 之后、喂播放器之前执行(`filterComments` 纯函数)
## 6. 错误处理
| 场景 | 策略 |
|------|------|
| 无开放网络凭证 | 明确提示「未配置弹幕网络」;本地 XML 全功能可用 |
| match 401/签名失败 | Toast 鉴权失败;不重试风暴 |
| match 无候选/低置信 | 进手选候选面板,不静默 |
| comment 失败 | 保留绑定;可重试;本地 XML 优先 |
| XML 解析失败 | 导入报错拒绝;自动关联静默跳过 |
| prefs 失败 | 回退 localStorage 会话级;不阻断播放 |
| 权限 | docs / prefs / 绑定均强制 `userId` 隔离 |
## 7. 测试与验证
**`@app/danmaku` 单测**:签名向量、16MB MD5、XML round-trip、`filterComments`、候选排序;match/comment 用 fixture 锁字段名。
**service 测**:绑定持久化、导入落库、prefs upsert、缓存键(path:size → episodeId)切换。
**真实链路清单(验收)**
1. 配置凭证 → 未绑定视频自动匹配 → 弹幕上屏
2. 低置信 → 手选 → 绑定刷新仍有效
3. 导入 XML → 刷新/换端登录仍在
4. 同目录同名 XML 自动关联
5. 关联弹幕计入;屏蔽词/类型即时生效且持久
6. 断网/无凭证降级:可播、本地 XML 可用
**命令**:`yarn typecheck` · `yarn lint` · `yarn test` · `yarn build:web`
## 8. 风险与开放问题
1. **凭证**:当前 `.env` 的 `OPEN_DANMAKU_APP_ID` 为空。真实链路需提供 AppId/AppSecret,或先用 `OPEN_DANMAKU_API_BASE` 指向 mock 跑通再切官方。
2. **开放 API 字段**:实现时以 `doc.dandanplay.com/open` 实测为准;fixture 与适配器单点收口在 `@app/danmaku`。
3. **同目录 XML 自动关联**:避免扫描时全量拉 WebDAV 正文;按需拉取 + 缓存。
4. **协议包勿膨胀**:若未来要多端复用再抽;本规格不做远程访问协议。
## 9. 批准记录
| 节 | 结果 |
|----|------|
| §1 架构与包边界 | OK(方案 B) |
| §2 数据模型 + 协议/数据格式 | OK |
| §3 页面与交互(四块) | OK |
| §4 错误处理 + 测试验证 | OK |
| 验收口径 | 真实链路可跑通 |
| 范围 | 协议/数据兼容 + 核心四块全选 |

View File

@ -1,120 +0,0 @@
# 弹弹play 弹幕获取 + 刮削能力打通 — 设计规格
- **日期**:2026-09-26
- **状态**:已实现(与并行会话的增强实现合并;实现超出本文档处见 §7)
- **上游规格**:`2026-09-25-dandanplay-web-design.md`(开放弹幕网络中继 + Bangumi 刮削已批准)
## 1. 问题
上一版实现后能力不可用/不完整,用户反馈「没有实现接入弹弹play 的弹幕获取和刮削能力」:
1. **弹幕获取实际拿不到**:`danmaku.service.ts` 客户端已写(match/签名/16MB head MD5/24h 缓存),但
- 根 `.env` 中 `OPEN_DANMAKU_APP_ID/APP_SECRET` 为空 → 运行时恒走「未配置开放网络」降级,零弹幕;
- match 请求的 `fileName` 传了**含扩展名**的完整文件名,官方 swagger 明确要求「不包含文件夹名称和扩展名」,降低识别率;
- HTTP 非 200 / `errorCode≠0` 全部吞成 null,排障无从下手;
- match 成功得到的 `episodeId` 不落库,每次播放都重新 16MB 读流 + match。
2. **刮削没有弹弹play 源**:只有 Bangumi 文件名搜索;match 响应中的 `animeTitle/episodeTitle/imageUrl/animeId` 元数据被丢弃。
## 2. 方案选型
| 方案 | 说明 | 结论 |
|------|------|------|
| **A. 双源互补(推荐)** | dandanplay 做「文件级识别 + 弹幕身份」源,Bangumi 保留做「文件名级刮削」源;识别结果落库复用 | ✅ 采纳 |
| B. 全面换 dandanplay | 扫描期每文件要读 16MB 算 hash,大库不可行;凭据未申请时连展示元数据都没有 | ❌ |
| C. 只修弹幕、刮削维持现状 | 不满足「刮削能力」诉求 | ❌ |
**凭据配置**:维持 env-only(`OPEN_DANMAKU_APP_ID/SECRET/API_BASE`),不做 DB 存凭据的设置 UI(YAGNI,单实例自托管);弹幕设置页**明示**配置状态,消除「静默降级」体感。
## 3. 官方 API 事实依据(2026-09-26 swagger 实读,`api.dandanplay.net/swagger/v2/swagger.json`)
- 签名:`X-Signature = base64(sha256(AppId + Timestamp + Path + AppSecret))`,头 `X-AppId/X-Timestamp/X-Signature`(与现实现一致)。
- `POST /api/v2/match`:请求 `{fileName(无扩展名), fileHash(前16MB MD5,可空), fileSize, matchMode(hashAndFileName|fileNameOnly|hashOnly)}`;响应 `MatchResponseV2 = ResponseBase + {isMatched, matches: MatchResultV2[]}`;`MatchResultV2 = {episodeId, animeId, animeTitle, episodeTitle, type, typeDescription, shift, imageUrl}`;`ResponseBase = {errorCode, success, errorMessage}`。
- `GET /api/v2/comment/{episodeId}?withRelated=true`:响应 `{count, comments: [{cid, p: "time,mode,color,uid", m}]}`(与现 XML 转换兼容)。
- `GET /api/v2/bangumi/bgmtv/{bgmtvSubjectId}`:**Bangumi subjectId → 弹弹play 番剧详情**(`BangumiDetails.episodes[] = {episodeId, episodeTitle, episodeNumber(短标题可排序), airDate}`),免 hash 桥。
## 4. 设计
### 4.1 数据模型(`@app/models`)
`media_items` 新增一列:
| 字段 | 说明 |
|------|------|
| `dandanplayEpisodeId` | integer 可空;弹弹play 弹幕库编号(识别成功后落库,弹幕拉取直达) |
`matchedHash`(原预留列)开始写入:成功 hash 识别时存「前 16MB MD5」。迁移:`yarn db:generate` + `yarn db:push`。
### 4.2 服务层(`@app/trpc`)
**danmaku.service = 开放 API 客户端单点**(scrape 复用其导出):
- `matchFileName(path)`:去扩展名修 `fileName`(新增,含点文件名边界)。
- `openMatch(fileName, size, hash)` → `{ok, errorCode, errorMessage, isMatched, candidates: MatchResultV2[]}`:非 200/errorCode≠0 透出 `errorMessage`,不再吞 null。
- `openCommentXml(episodeId)` → XML(原逻辑拆出)。
- `remoteFileHash()` 导出供 scrape 用;`openHeaders` 恒带 `Content-Type`。
- `fetch()` 流程改为:
1. 无凭据 → `source:"none"` + 明确文案(含「需配置 OPEN_DANMAKU_APP_ID/SECRET」);
2. `item.dandanplayEpisodeId` 已存在 → 跳过 match 直接 comment;
3. 否则 hash + match;**精确命中(isMatched)时 `setDanmakuMatch` 落库 episodeId+matchedHash**(仅身份,不动标题/海报——改标题属刮削语义,由识别入口显式做)。
**scrape.service.matchDandanplay(userId, mediaItemId)**(新增,手动入口):
1. 无凭据 → 报错提示配置;已有 `dandanplayEpisodeId` 且未要求重识别 → 直接返回已识别信息;
2. hash + match:`isMatched` 或有候选 → 取首条,写 `dandanplayEpisodeId/matchedHash/title=animeTitle/posterUrl=imageUrl/scrapeStatus=ok`;
3. match 未中且 `bangumiId` 存在 → bgmtv 桥:`pickDandanEpisode(episodes, epNumber)` 按集数择集,只补 `dandanplayEpisodeId`;
4. 输出 `DanmakuMatchOutput {ok, matched, message?, result?}`,UI toast 展示。
`pickDandanEpisode` 为纯函数:`episodeNumber` 数值相等优先,否则同位序回退(第 N 条)。
### 4.3 tRPC 路由
- `media.scrapeDandanMatch`(mutation):入参 `{mediaItemId}`,调 `scrapeService.matchDandanplay`。
- `media.danmakuGetSettings`:响应增加 `openNetworkConfigured: boolean`(其余仍为本地偏移默认值,持久化仍走 localStorage,不在本次范围)。
- `media.danmakuFetch` 行为如 4.2,签名不变。
### 4.4 前端(`@app/web` + `@app/i18n`)
- **库页匹配弹窗**顶部加「弹弹play 文件识别」按钮:调 `scrapeDandanMatch`,成功 toast 展示 `animeTitle/episodeTitle`,失败透出 message;弹窗仍保留 Bangumi 搜索绑定。
- **弹幕设置页**顶部状态条:「开放弹幕网络:已配置 / 未配置(服务端 .env 设置 OPEN_DANMAKU_APP_ID/APP_SECRET 后重启)」。
- i18n:zh-CN 权威,en 同构新增 ~8 key。
### 4.5 错误处理
| 场景 | 策略 |
|------|------|
| 凭据未配置 | 明确文案 + 设置页状态条;不阻断播放/本地 XML |
| match/comment HTTP 错误或 errorCode≠0 | message 透传 `errorMessage`;返回 `source:"none"` |
| hash 读流失败 | 降级 fileNameOnly 匹配 |
| 识别无候选 | matched=false + 文案;Bangumi 已绑走桥接兜底 |
| 桥接无对应集数 | 只提示,不写库 |
### 4.6 测试与验证
- 纯函数单测(沿用 vitest 模式):`matchFileName`(去扩展名/点文件名)、match 响应解析(`isMatched`/候选/errorCode)、`pickDandanEpisode`(集数命中/回退)。
- 命令:`yarn workspace @app/trpc test`、`yarn typecheck`、`yarn lint`、`yarn build:web`。
- 真实弹幕需官方凭据:设置页状态条 + 文档说明申请入口,凭据就绪后播放页冒烟即可验证。
## 5. 不做(本次)
弹幕发送(POST comment)· match 候选多选 UI(并行会话已补,见 §7)· `shift` 弹幕偏移应用 · chConvert 简繁参数 · 扫描期批量 hash 识别。
## 6. 批准记录
用户提出能力缺口后未在线答复澄清(问询超时),按最佳判断采纳方案 A 推进;如与预期不符,数据模型/service 边界已隔离,可低成本调整。
## 7. 实现终态(含并行会话合并)
实现期间工作区另一会话同步推进同一能力,终态为本设计 + 以下增强(均已在工作树落地并通过 typecheck/test/build):
| 增强点 | 说明 |
|--------|------|
| `@app/danmaku` 独立包 | 开放 API 客户端/签名/hash/匹配/过滤/XML 纯函数下沉(`./browser` 子路径无 node:crypto) |
| 凭据 DB 化(超越 env-only 决策) | `app_settings` 表存 AppId/Secret,管理员设置页可改,env 兜底,运行时热更新(`settings.service`) |
| 候选手选式匹配 | match 结果 `rankCandidates` 打分;高置信自动绑,低置信在播放页手选(`danmakuMatch`/`danmakuSelectMatch`),另支持关键词指定弹幕源(`searchSource`/`sourceEpisodes`) |
| 服务端弹幕文档 | `danmaku_docs` 表持久化 local-xml/open-network 全文;同目录同名 .xml 侧车自动关联 |
| 用户弹幕偏好落库 | `danmaku_prefs` 表(enabled/opacity/density/屏蔽词/类型屏蔽),取代 localStorage |
| 匹配来源审计 | `media_items.danmaku_match_source` 列(auto/manual/none) |
本会话独有贡献:`fileName` 去扩展名修复、`setDanmakuMatch` 身份落库、`scrapeService.matchDandanplay`(刮削写标题/海报 + bgmtv 桥)、库页识别入口、match 响应解析与择集单测、`openNetworkConfigured` 状态透出。
**遗留**:WatchBody/globals.css 有 5 处 lint 违规(并行会话在改文件,未越界处理);真实弹幕需向弹弹play 官方申请 AppId/AppSecret(管理员设置页或服务端 `.env` 配置)。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@ -1,25 +0,0 @@
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);
});

View File

@ -1,25 +0,0 @@
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();
});

View File

@ -1,15 +0,0 @@
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

@ -1,26 +0,0 @@
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(page.getByRole("link", { name: "登录 / 注册" })).toBeVisible();
await expect(page.getByRole("link", { name: "打开媒体库" })).toBeVisible();
});
test("英文首页渲染英文文案", async ({ page }) => {
await page.goto("/en");
const h1 = page.getByRole("heading", { level: 1 });
await expect(h1).toBeVisible();
await expect(h1).not.toContainText("应用");
await expect(page.getByRole("link", { name: "登录 / 注册" })).toHaveCount(0);
});
test("首页无客户端控制台错误", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
await page.goto("/zh-CN");
await page.waitForLoadState("networkidle");
expect(errors).toEqual([]);
});

View File

@ -1,57 +0,0 @@
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 trigger = page.getByRole("button", { name: "语言" });
await expect(trigger).toBeVisible();
await expect(trigger).toContainText("中文");
await trigger.click();
const item = page.locator('[data-switch-locale="en"]');
await expect(item).toBeVisible();
await item.click();
await expect(page).toHaveURL(/\/en$/);
const enTrigger = page.getByRole("button", { name: "Language" });
await expect(enTrigger).toContainText("English");
});

View File

@ -1,80 +0,0 @@
import { expect, test } from "@playwright/test";
const homeH1 = (page: import("@playwright/test").Page) => page.getByRole("heading", { level: 1 });
const loginHeading = (page: import("@playwright/test").Page) =>
page.getByRole("heading", { name: "登录" });
test.describe("浏览器前进 / 后退触发页面更新", () => {
test("软导航后 goBack / goForward 换页", async ({ page }) => {
await page.goto("/zh-CN");
await expect(homeH1(page)).toContainText("私有影音库");
await page.getByRole("link", { name: "登录 / 注册" }).first().click();
await expect(page).toHaveURL(/\/zh-CN\/auth\/login/);
await expect(loginHeading(page)).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/zh-CN$/);
await expect(homeH1(page)).toContainText("私有影音库");
await page.goForward();
await expect(page).toHaveURL(/\/zh-CN\/auth\/login/);
await expect(loginHeading(page)).toBeVisible();
});
test("history.state 被抹成 null 后仍能后退换页", async ({ page }) => {
await page.goto("/zh-CN");
await expect(homeH1(page)).toContainText("私有影音库");
// 复现旧 bug:replaceState(null) 让 ClientRouter onPopState 直接 return
await page.evaluate(() => {
window.history.replaceState(null, "", window.location.href);
});
await page.locator('a[href="/zh-CN/auth/login"]').first().click();
await expect(page).toHaveURL(/\/zh-CN\/auth\/login/);
await page.goBack();
await expect(page).toHaveURL(/\/zh-CN$/);
await expect(homeH1(page)).toContainText("私有影音库");
});
test("setQueryParams 式 replaceState 后跨页后退仍换页", async ({ page }) => {
await page.goto("/zh-CN/auth/login");
await expect(loginHeading(page)).toBeVisible();
await page.evaluate(() => {
const url = new URL(window.location.href);
url.searchParams.set("next", "/zh-CN/library");
window.history.replaceState(window.history.state, "", url.toString());
});
await expect(page).toHaveURL(/next=/);
await page.locator('a[href="/zh-CN"]').first().click();
await expect(page).toHaveURL(/\/zh-CN$/);
await expect(homeH1(page)).toContainText("私有影音库");
await page.goBack();
await expect(page).toHaveURL(/\/zh-CN\/auth\/login/);
await expect(loginHeading(page)).toBeVisible();
});
test("绕过守卫写入 null state 后,popstate 兜底仍换页", async ({ page }) => {
await page.goto("/zh-CN");
await expect(homeH1(page)).toContainText("私有影音库");
// 用原生 replaceState 直接抹掉 state,验证 popstate 兜底(非补丁路径)
await page.evaluate(() => {
const proto = History.prototype as History;
proto.replaceState.call(window.history, null, "", window.location.href);
});
await page.locator('a[href="/zh-CN/auth/login"]').first().click();
await expect(page).toHaveURL(/\/zh-CN\/auth\/login/);
await page.goBack();
await expect(page).toHaveURL(/\/zh-CN$/);
await expect(homeH1(page)).toContainText("私有影音库");
});
});

View File

@ -1,5 +1,5 @@
{ {
"name": "fanmu", "name": "app-template",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
@ -16,12 +16,7 @@
"db:generate": "turbo run db:generate", "db:generate": "turbo run db:generate",
"db:push": "turbo run db:push", "db:push": "turbo run db:push",
"db:studio": "turbo run db:studio", "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", "typecheck": "turbo run typecheck",
"test": "turbo run test",
"test:e2e": "playwright test",
"lint": "turbo run lint", "lint": "turbo run lint",
"clean": "turbo run clean", "clean": "turbo run clean",
"format": "turbo run format", "format": "turbo run format",
@ -38,7 +33,6 @@
"@commitlint/cli": "^21.2.3", "@commitlint/cli": "^21.2.3",
"@commitlint/config-conventional": "^21.2.3", "@commitlint/config-conventional": "^21.2.3",
"@emnapi/runtime": "^1.11.3", "@emnapi/runtime": "^1.11.3",
"@playwright/test": "^1.63.0",
"@storybook/addon-a11y": "^9.1.10", "@storybook/addon-a11y": "^9.1.10",
"@storybook/addon-docs": "^9.1.10", "@storybook/addon-docs": "^9.1.10",
"@storybook/react": "^9.1.10", "@storybook/react": "^9.1.10",

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