Compare commits
No commits in common. "632336de4154b6b16845376fcdd659a823678726" and "a7873891f2702668a22881498bfdf5528f9d13a8" have entirely different histories.
632336de41
...
a7873891f2
|
|
@ -27,8 +27,3 @@ AGENTS.md
|
|||
DESIGN.md
|
||||
README.md
|
||||
.prettierrc
|
||||
|
||||
# 会话/审查产物
|
||||
.superpowers
|
||||
.docs
|
||||
docs/superpowers
|
||||
|
|
|
|||
|
|
@ -12,12 +12,8 @@ TURSO_DATABASE_URL=file:local.db
|
|||
# 媒体挂载凭据加密密钥:64 位 hex(openssl rand -hex 32)
|
||||
MEDIA_SECRET_KEY=
|
||||
|
||||
# 找回密码令牌交付:配置后以 POST JSON { email, username, token, resetPath } 推送到该地址
|
||||
# (可对接邮件/企业微信/ bark 等任意通知服务);未配置时令牌写入服务日志
|
||||
PASSWORD_RESET_WEBHOOK_URL=
|
||||
|
||||
# 弹弹play 开放弹幕网络(https://doc.dandanplay.com/open)
|
||||
# AppId/AppSecret 推荐在「账户设置」页配置(存库、即时生效,仅管理员);此处为可选的服务端兜底
|
||||
# 未配置 AppId/AppSecret 时,弹幕接口返回 source=none「未配置开放网络」
|
||||
OPEN_DANMAKU_APP_ID=
|
||||
OPEN_DANMAKU_APP_SECRET=
|
||||
# 可选:自定义开放弹幕 API 地址(默认 https://api.dandanplay.net)
|
||||
|
|
|
|||
|
|
@ -30,13 +30,3 @@ storybook-static/
|
|||
.superpowers/
|
||||
.worktrees/
|
||||
worktrees/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# vitepress cache
|
||||
app/docs/.vitepress/cache/
|
||||
# local screenshots / fixtures
|
||||
ui-audit/
|
||||
tmp-e2e-dav/
|
||||
generated-*.png
|
||||
tmp-*.png
|
||||
|
|
|
|||
33
AGENTS.md
|
|
@ -1,10 +1,10 @@
|
|||
# AGENTS.md — 帆幕(Fanmu)总览
|
||||
# AGENTS.md — app-template 总览
|
||||
|
||||
面向 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。
|
||||
|
||||
|
|
@ -13,34 +13,28 @@
|
|||
| 包 | 职责 | 依赖 |
|
||||
|----|------|------|
|
||||
| `@app/types` | Zod 入参 schema + 共享输出类型 | zod |
|
||||
| `@app/danmaku` | 弹幕开放协议纯函数(签名/hash/XML/匹配/过滤;`./browser` 无 node:crypto) | — |
|
||||
| `@app/models` | Drizzle 表定义(唯一 schema 源) | drizzle-orm |
|
||||
| `@app/db` | libSQL/Turso 客户端单例 + drizzle-kit | models |
|
||||
| `@app/dao` | 数据访问层(`userDao` / `mountDao` / `mediaItemDao` 等) | db, models |
|
||||
| `@app/trpc` | tRPC 路由 + service 业务逻辑(调用 DAO) | types, dao, danmaku |
|
||||
| `@app/web` | Astro 前端 + React islands + 本地 tRPC HTTP 适配 | trpc, types, danmaku(`./browser`), design-tokens, seo-geo, i18n, ui |
|
||||
| `@app/dao` | 数据访问层(`userDao` 等) | db, models |
|
||||
| `@app/trpc` | tRPC 路由 + service 业务逻辑(调用 DAO) | types, dao |
|
||||
| `@app/web` | Astro 前端 + React islands + 本地 tRPC HTTP 适配 | trpc, types, design-tokens, seo-geo, i18n, ui |
|
||||
| `@app/ui` | shadcn 风格基础 UI 组件(源码直出 + Storybook) | design-tokens, react (peer) |
|
||||
| `@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/i18n` | 文案与 locale 纯函数(zh-CN / en、t()、Accept-Language) | — |
|
||||
| `@app/adsense` | AdSense 脚本加载 + `AdSlot` React 组件 | react (peer) |
|
||||
| `@app/docs` | 产品文档站(VitePress,中文根路径 + 英文 `/en`) | —(仅 vitepress) |
|
||||
|
||||
依赖方向(只允许向下):
|
||||
|
||||
```
|
||||
web ──► trpc ──► dao ──► db ──► models
|
||||
│ │ │ │
|
||||
│ │ └──► danmaku │
|
||||
│ │ │
|
||||
│ └─────────────────┴──► types
|
||||
├──► danmaku(浏览器侧走 ./browser)
|
||||
├──► design-tokens
|
||||
├──► seo-geo
|
||||
├──► i18n
|
||||
├──► adsense
|
||||
└──► ui
|
||||
|
||||
docs(独立,不依赖上述业务包)
|
||||
```
|
||||
|
||||
禁止循环依赖。跨包只通过 `package.json` 的 `exports` 入口引用,不要 deep import。
|
||||
|
|
@ -49,30 +43,22 @@ docs(独立,不依赖上述业务包)
|
|||
|
||||
- 源码在各包 `src/`;构建产物在 `dist/`(git 忽略,勿手改)。
|
||||
- 环境变量模板见 `.env.example`:`TURSO_DATABASE_URL`、`TURSO_AUTH_TOKEN`、`PUBLIC_SITE_URL`。
|
||||
- 根 `.env` 由 `packages/db/drizzle.config.ts` 与 `packages/db/scripts/seed.ts` 手动加载(monorepo 下工具不会自动读根 env)。
|
||||
- **分层不跳层**:业务实体按 types → models → dao → trpc → web 落地;领域逻辑进 service,SQL 进 dao。
|
||||
- `e2e/`:Playwright 端到端测试(配置在根 `playwright.config.ts`),不是 workspace 包。
|
||||
- `app/docs/`:VitePress 文档站源码(`.vitepress/` 配置 + `guide/` 中文 + `en/guide/` 英文)。
|
||||
- 根 `.env` 由 `packages/db/drizzle.config.ts` 手动加载(drizzle-kit 在 monorepo 中不会自动读根 env)。
|
||||
- **模板不掺杂业务逻辑**:禁止把产品领域实体/页面写进本仓库;下游业务仓库再扩展。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
yarn test # 各包 vitest(turbo run test)
|
||||
yarn typecheck # 全仓 typecheck
|
||||
yarn lint # Biome
|
||||
yarn build # 全仓 build(依赖拓扑)
|
||||
yarn build:web # 仅 @app/web + 上游依赖闭包(日常/CI 更快)
|
||||
yarn dev:web # Astro :4321
|
||||
yarn dev:api # 独立 tRPC :4000(web 内嵌 /api/trpc 时通常不需要)
|
||||
yarn docs:dev # VitePress 文档站
|
||||
yarn docs:build
|
||||
yarn test:e2e # Playwright(自动起 webServer)
|
||||
yarn db:generate # Drizzle 迁移生成
|
||||
yarn db:push # 推 schema
|
||||
yarn db:seed # 初始化默认管理员(幂等)
|
||||
yarn db:studio
|
||||
yarn storybook # @app/ui Storybook :6006
|
||||
```
|
||||
|
||||
改某个包时可先 `yarn workspace @app/<name> typecheck`。
|
||||
|
|
@ -87,7 +73,7 @@ yarn storybook # @app/ui Storybook :6006
|
|||
6. **UI**:设计令牌从 `@app/design-tokens` 引入;组件优先用 `@app/ui`(shadcn 风格,源码在 `packages/ui`)+ `cn()`。视觉遵循 `DESIGN.md`。
|
||||
7. **构建顺序**:改 `types`/`models` 后需先 build 上游,再让下游 typecheck(Turbo `dependsOn: ["^build"]` 已配置)。
|
||||
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/adsense/AGENTS.md](packages/adsense/AGENTS.md)
|
||||
- [packages/ui/AGENTS.md](packages/ui/AGENTS.md)
|
||||
- [app/docs/AGENTS.md](app/docs/AGENTS.md)
|
||||
|
|
|
|||
10
DESIGN.md
|
|
@ -1,6 +1,6 @@
|
|||
# 帆幕(Fanmu)— 设计方向
|
||||
# App Template — 设计方向
|
||||
|
||||
产品视觉基线:**shadcn/ui Dashboard**(媒体五页侧栏/顶栏导航、卡片网格、中性灰阶);模板深空工业 token 仍可用于非媒体页,但 **`@app/design-tokens` 色板 token 路径约定不变**(改值可以,改结构需同步全局)。
|
||||
脚手架视觉基线:**深空工业中控终端**。不绑定具体产品叙事;下游可在此 palette / type 上换肤,但 **`@app/design-tokens` 色板 token 路径约定不变**(改值可以,改结构需同步全局)。
|
||||
|
||||
## Style anchor
|
||||
深色工业 HUD / 控制台:近黑底、信号黄强调、发丝线框、等宽铭牌。
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
| 墨色 | `#F2F2F2` |
|
||||
| 弱化文字 | `#999999` |
|
||||
| 更弱文字 | `#666666` |
|
||||
| 强调(品牌金黄) | `#FFB300` |
|
||||
| 强调(信号黄) | `#FFFA00` |
|
||||
| 信号绿 | `#00FFA2` |
|
||||
| 信号品红 | `#FF1AAC` |
|
||||
| 亮色背景(对照) | `#FAFAFA` |
|
||||
|
|
@ -36,8 +36,8 @@
|
|||
## Interaction
|
||||
- 主题:系统 / 亮 / 暗(`app-theme`)
|
||||
- 语言:cookie `app-lang` + Accept-Language
|
||||
- **薄 SSR**:服务端输出布局 / head / 导航;页面主体(`components/pages/*Body`)`client:load` 在浏览器渲染
|
||||
- 动效只在浏览器 effect 内绑定;尊重 `prefers-reduced-motion`
|
||||
- SSR 输出结构与内容;动效只在浏览器 effect 内绑定
|
||||
- 尊重 `prefers-reduced-motion`
|
||||
|
||||
## 范围
|
||||
`tokens.css` · `globals.css` · `AppLayout.astro` · `ThemeSwitch` · `UserMenu` · `home` / `dashboard` 视图 · `ui/*`
|
||||
|
|
|
|||
16
Dockerfile
|
|
@ -1,3 +1,4 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# 与 .node-version 对齐;可用 build-arg 覆盖
|
||||
ARG NODE_VERSION=26.7.0
|
||||
|
||||
|
|
@ -13,11 +14,9 @@ RUN npm install -g corepack && corepack enable
|
|||
FROM base AS deps
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
COPY app/web/package.json app/web/package.json
|
||||
# workspaces: app/* + packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链
|
||||
COPY app/docs/package.json app/docs/package.json
|
||||
# workspaces: packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链
|
||||
COPY packages/adsense/package.json packages/adsense/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/design-tokens/package.json packages/design-tokens/package.json
|
||||
COPY packages/i18n/package.json packages/i18n/package.json
|
||||
|
|
@ -56,14 +55,7 @@ ENV NODE_ENV=production \
|
|||
HOSTNAME=0.0.0.0 \
|
||||
PORT=4321 \
|
||||
# file: 库与 session 的可写位置(/app 属主为 root,node 用户不可写)
|
||||
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=
|
||||
TMPDIR=/tmp
|
||||
|
||||
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
|
||||
|
|
@ -76,6 +68,4 @@ RUN mkdir -p /app/data && chown node:node /app/data
|
|||
|
||||
USER node
|
||||
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"]
|
||||
|
|
|
|||
29
README.md
|
|
@ -1,8 +1,6 @@
|
|||
# 帆幕 — 私有影音库 · 弹幕观影 · 网盘挂载
|
||||
# App Template — Astro + tRPC + Drizzle Monorepo
|
||||
|
||||
**帆幕(Fanmu)** 是自建私有影音库:WebDAV/OpenList 挂载片源、媒体库扫描与 Bangumi 刮削、ArtPlayer 播放与弹幕(开放网络 + 本地 XML),多用户进度与权限。
|
||||
|
||||
基于 monorepo 分层(types → models → db → dao → trpc → web),本仓库按**产品仓**维护。
|
||||
可复用全栈脚手架:**不绑定产品业务**,只保留鉴权 + `user` 垂直示例,演示完整分层接线。
|
||||
|
||||
```
|
||||
.
|
||||
|
|
@ -49,25 +47,4 @@ yarn dev:web # Astro :4321
|
|||
|
||||
`web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。
|
||||
|
||||
新增业务实体请按既有分层接线:`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
|
||||
# 然后走上面「首次运行配置」
|
||||
```
|
||||
新增业务实体请复制 `user` 垂直切片:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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.**
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# 账号和进度
|
||||
|
||||
帆幕支持多个人用。
|
||||
|
||||
## 一人一个账号
|
||||
|
||||
- 注册自己的账号
|
||||
- 各人有自己的媒体库、挂载(看部署怎么配)、观看进度
|
||||
- 互不干扰
|
||||
|
||||
管理员(`db:seed` 建的那个)可以管用户。
|
||||
|
||||
## 进度跟人走
|
||||
|
||||
张三看到第 12 分钟,李四打开同一个视频,还是从头开始。
|
||||
|
||||
换设备也一样:手机看一半,电脑上接着看(只要登的是同一个账号)。
|
||||
|
||||
## 忘了密码
|
||||
|
||||
登录页有「忘记密码」流程。如果邮件功能没配好,就让管理员帮你重置。
|
||||
|
||||
## 默认账号
|
||||
|
||||
本地开发 `db:seed` 会建:
|
||||
|
||||
| 账号 | 密码 |
|
||||
|------|------|
|
||||
| admin | admin123 |
|
||||
|
||||
**这个密码是公开的,开到公网前一定要改。**
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
# 找视频
|
||||
|
||||
挂好网盘之后,就可以翻文件夹了。
|
||||
|
||||
## 怎么找
|
||||
|
||||
1. 打开侧栏 **浏览**
|
||||
2. 选一个挂载
|
||||
3. 一层层点进去,像用文件管理器一样
|
||||
|
||||
每行文件可以直接 **播放**,也可以 **加入媒体库**。
|
||||
|
||||
## 还是找不到?
|
||||
|
||||
- **文件夹是空的**:先去[挂载](/guide/mount)看看连接通不通
|
||||
- **视频格式播不了**:浏览器认 mp4、webm 比较稳;mkv 有的浏览器能播有的不行。播不了的话,可以先把文件转一下,或者换个浏览器试试
|
||||
- **名字乱七八糟**:不影响播。想要好看的海报和标题,去[媒体库](/guide/library)扫描,或[手动认剧](/guide/scrape)
|
||||
|
||||
## 和媒体库什么关系
|
||||
|
||||
| | 浏览 | 媒体库 |
|
||||
|--|------|--------|
|
||||
| 看的是什么 | 网盘真实文件夹 | 整理过的片单 |
|
||||
| 适合 | 随便翻、临时看一眼 | 常看的剧、接着看 |
|
||||
| 来源 | 挂载里的文件 | 扫描进来的 |
|
||||
|
||||
两条路都能播。浏览是「文件视角」,媒体库是「剧集视角」。
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
# 看片和弹幕
|
||||
|
||||
点开一个视频,就是播放页。
|
||||
|
||||
## 播放
|
||||
|
||||
播放器是网页里的,不用装东西。基本操作(播放/暂停、进度、音量、全屏)都有。
|
||||
|
||||
看一半关掉不要紧,进度记住了,下次接着看。
|
||||
|
||||
## 弹幕从哪来
|
||||
|
||||
和弹弹play一个思路:**同一集,用同一份弹幕**。
|
||||
|
||||
流程是这样:
|
||||
|
||||
1. 你打开一个视频
|
||||
2. 系统猜这是哪部剧第几集(或者你之前已经认过了)
|
||||
3. 拿这个「身份」去弹幕网络要弹幕
|
||||
4. 弹幕飘在视频上
|
||||
|
||||
所以字幕组 A 的 720P 和字幕组 B 的 1080P,只要认成同一集,弹幕就是同一份。
|
||||
|
||||
## 没有弹幕怎么办
|
||||
|
||||
- **识别失败**:先去[认剧](/guide/scrape)手动认一下
|
||||
- **弹幕网络挂了**:照样能看视频,只是没弹幕。过会儿再试
|
||||
- **就是想要本地弹幕**:导入 XML 文件
|
||||
|
||||
### 导入 XML 弹幕
|
||||
|
||||
播放页或[弹幕设置](#弹幕设置)里可以导入 `.xml` 弹幕文件(B 站导出的那种)。
|
||||
|
||||
同名的视频和弹幕文件放在一个文件夹里,也认得出来。
|
||||
|
||||
## 弹幕设置
|
||||
|
||||
侧栏 **设置** 页面的弹幕区块可以调:
|
||||
|
||||
- 默认开还是关
|
||||
- 透明度(怕挡画面就调淡一点)
|
||||
- 密度(一屏幕最多飘多少条)
|
||||
|
||||
播放的时候也能随时开关。
|
||||
|
||||
## 弹幕礼仪
|
||||
|
||||
弹幕内容来自公开弹幕库。看到了不舒服的,关掉弹幕就好。帆幕不存储、不修改弹幕内容。
|
||||
|
|
@ -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,附上:
|
||||
|
||||
- 你干了什么
|
||||
- 期望发生什么
|
||||
- 实际发生什么
|
||||
- 截图或报错文字
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
# 媒体库
|
||||
|
||||
不想每次都翻文件夹?把片子扫进媒体库,以后按剧找。
|
||||
|
||||
## 它长什么样
|
||||
|
||||
媒体库是一排海报卡片,写着剧名。点进去是这一部的分集列表,看过一半的会显示进度,点一下就能接着看。
|
||||
|
||||
## 怎么进来
|
||||
|
||||
两种方式:
|
||||
|
||||
1. **浏览页里点「加入库」**:加单个文件
|
||||
2. **挂载页里点「扫描」**:把这个挂载下视频都扫一遍
|
||||
|
||||
扫描会做这些事:
|
||||
|
||||
- 把视频文件记下来
|
||||
- 猜这是哪部剧第几集
|
||||
- 去 Bangumi 查剧名、海报
|
||||
- 查不到就标成「未识别」,不会丢,也不会挡住你播
|
||||
|
||||
## 扫完之后
|
||||
|
||||
| 你会看到 | 说明 |
|
||||
|----------|------|
|
||||
| 海报、标题 | 识别成功的话会自动填 |
|
||||
| 未识别 | 文件名猜不出来,或 Bangumi 查不到 |
|
||||
| 继续播放 | 有进度的条目,点进去接着看 |
|
||||
|
||||
## 认错了怎么办
|
||||
|
||||
去[认剧](/guide/scrape)手动改绑。
|
||||
|
||||
## 进度怎么记
|
||||
|
||||
每看到一处,会自动记住看到哪了。下次打开同一个视频,接着来。
|
||||
|
||||
进度跟人走:张三和李四各记各的,互不影响。见[账号](/guide/account)。
|
||||
|
|
@ -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)
|
||||
|
|
@ -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` | 检查代码风格 |
|
||||
|
|
@ -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 / 剧场版 | 集数可能对不上,手动调 |
|
||||
|
|
@ -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)
|
||||
|
||||
先把「挂载 → 找片 → 播放 → 弹幕 → 进度」这条主路走顺。
|
||||
|
|
@ -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)。
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
- 语言:`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。
|
||||
- 裸 `/`:`src/middleware.ts` 按 cookie `app-lang` → `Accept-Language` 跳 `/zh-CN` 或 `/en`;响应 `Vary: Accept-Language`;非法/大小写 `zh-cn` 规范化。
|
||||
- 手动切换:header 语言链接写 cookie 后跳转(布局内联脚本)。
|
||||
|
|
@ -20,28 +20,17 @@
|
|||
```
|
||||
src/
|
||||
pages/
|
||||
[locale]/
|
||||
index.astro | dashboard.astro | library.astro | browse.astro
|
||||
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
|
||||
[locale]/index.astro | dashboard.astro | auth/* | settings.astro
|
||||
api/trpc/[...trpc].ts | api/users.ts
|
||||
sitemap.xml.ts | robots.txt.ts | llms.txt.ts | llms-full.txt.ts
|
||||
403.astro | 404.astro | 500.astro
|
||||
views/ # SSR 壳 + seo/nav(每页一个)
|
||||
home.astro | dashboard.astro | library.astro | browse.astro
|
||||
watch.astro | mounts.astro | auth.astro | error.astro
|
||||
middleware.ts # 裸 / 语言检测跳转 + locale 规范化
|
||||
views/
|
||||
home.astro | dashboard.astro | auth.astro | error.astro
|
||||
middleware.ts # 裸 / 语言检测跳转 + locale 规范化
|
||||
layouts/AppLayout.astro
|
||||
components/
|
||||
pages/ # 薄 SSR 主体(client:load)
|
||||
HomeBody.tsx | DashboardBody.tsx | LibraryBody.tsx | BrowseBody.tsx
|
||||
WatchBody.tsx | MountsBody.tsx | DanmakuSettingsBody.tsx(settings 页弹幕区块)
|
||||
AuthBody.tsx | ErrorBody.tsx
|
||||
UserList.tsx | UserMenu.tsx | ThemeSwitch.tsx | AuthForm.tsx | errors/*
|
||||
lib/
|
||||
trpc.ts | seo.ts(getSiteSeo(locale))| nav.ts | theme.ts
|
||||
danmaku-render.ts | errors.ts | utils.ts(cn re-export)
|
||||
trpc.ts | seo.ts(getSiteSeo(locale))| theme.ts | utils.ts(cn re-export)
|
||||
styles/globals.css
|
||||
astro.config.ts
|
||||
```
|
||||
|
|
@ -53,28 +42,24 @@ astro.config.ts
|
|||
- `@app/ui` → `packages/ui/src`(源码直出)
|
||||
- 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`。
|
||||
- 站点 SEO:`lib/seo.ts` 的 `getSiteSeo(locale)`;sitemap 双语 URL + hreflang `alternates`。
|
||||
- 新公开页面:登记 `PUBLIC_PATHS` / views,并同步 `getSiteSeo` 的 `llms.pages` 与 sitemap;主体写成 `*Body.tsx`。
|
||||
- 导航项集中在 `lib/nav.ts`,改菜单只动这一处。
|
||||
- 新公开页面:登记 `PUBLIC_PATHS` / views,并同步 `getSiteSeo` 的 `llms.pages` 与 sitemap。
|
||||
|
||||
## React islands
|
||||
|
||||
- 页面主体默认 `client:load`(薄 SSR);局部岛仍可用 `client:visible`。
|
||||
- 需要交互才加 `client:load` / `client:visible`。
|
||||
- 文案:`import { t } from "@app/i18n"` + `locale` prop;不要绑 i18next Provider。
|
||||
- tRPC:组件内 `QueryClientProvider` + trpc Provider(参考 `UserList.tsx`)。
|
||||
- UI:`@app/ui`(通用基础组件)+ `cn()`(`~/lib/utils` re-export);颜色用 CSS 变量。
|
||||
- 播放页弹幕渲染逻辑在 `lib/danmaku-render.ts`,与 `WatchBody` 解耦。
|
||||
|
||||
## 修改指南
|
||||
|
||||
- **加文案**:先改 `packages/i18n/src/messages/zh-CN.ts`,再补 `en.ts`。
|
||||
- **加业务页面**:沿用 `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`。
|
||||
- Astro 保留字:组件 props 避免 `slot`。
|
||||
|
||||
|
|
@ -86,5 +71,4 @@ yarn workspace @app/web lint
|
|||
yarn workspace @app/web dev # :4321
|
||||
yarn workspace @app/web build
|
||||
yarn dev:web
|
||||
yarn test:e2e # Playwright(e2e/ 目录)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@ export default defineConfig({
|
|||
},
|
||||
ssr: {
|
||||
noExternal: ["@app/trpc", "@app/db", "@app/i18n", "@app/ui"],
|
||||
// webdav 必须走 Node 原生 stream,不能被 SSR 打包掏空
|
||||
external: ["webdav"],
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
"lint": "biome check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/danmaku": "workspace:*",
|
||||
"@app/design-tokens": "workspace:*",
|
||||
"@app/i18n": "workspace:*",
|
||||
"@app/seo-geo": "workspace:*",
|
||||
|
|
@ -26,7 +25,6 @@
|
|||
"@trpc/client": "^11.19.0",
|
||||
"@trpc/react-query": "^11.19.0",
|
||||
"artplayer": "^5.2.2",
|
||||
"artplayer-plugin-danmuku": "^5.3.0",
|
||||
"astro": "^7.3.3",
|
||||
"gsap": "^3.15.0",
|
||||
"lenis": "^1.3.26",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
/**
|
||||
* 认证表单岛:登录 / 注册 / 找回 / 重置 / 改密,按 `mode` 切换。
|
||||
* 默认登录方式:用户名 + 密码。
|
||||
* Provider 包在组件内部。
|
||||
* Provider 包在组件内部(与 UserList 同模式)。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { trpc, createTRPCReactClient } from "../lib/trpc";
|
||||
import { go } from "../lib/nav";
|
||||
import type { Locale } from "@app/i18n";
|
||||
import { t } from "@app/i18n";
|
||||
import {
|
||||
|
|
@ -50,7 +48,6 @@ export default function AuthForm(props: AuthFormProps) {
|
|||
}
|
||||
|
||||
function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
|
@ -62,7 +59,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
const login = trpc.auth.login.useMutation({
|
||||
onSuccess: () => {
|
||||
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")),
|
||||
});
|
||||
|
|
@ -70,7 +67,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
const register = trpc.auth.register.useMutation({
|
||||
onSuccess: () => {
|
||||
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")),
|
||||
});
|
||||
|
|
@ -89,7 +86,7 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
const resetPassword = trpc.auth.resetPassword.useMutation({
|
||||
onSuccess: () => {
|
||||
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")),
|
||||
});
|
||||
|
|
@ -114,14 +111,9 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
e.preventDefault();
|
||||
if (busy) return;
|
||||
if (mode === "login") {
|
||||
login.mutate({ username, password });
|
||||
login.mutate({ email, password });
|
||||
} else if (mode === "register") {
|
||||
register.mutate({
|
||||
username,
|
||||
password,
|
||||
name: name.trim() || undefined,
|
||||
email: email.trim() || undefined,
|
||||
});
|
||||
register.mutate({ name, email, password });
|
||||
} else if (mode === "forgot") {
|
||||
requestReset.mutate({ email });
|
||||
} else if (mode === "reset") {
|
||||
|
|
@ -157,29 +149,13 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
const homeBase = home(locale);
|
||||
|
||||
return (
|
||||
<Card className={`w-full ${mode === "change" ? "max-w-2xl" : "max-w-md"}`}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{titles[mode].title}</CardTitle>
|
||||
<CardDescription>{titles[mode].desc}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{(mode === "login" || mode === "register") && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t(locale, "auth.field.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "register" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t(locale, "auth.field.name")}</Label>
|
||||
|
|
@ -187,25 +163,22 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
id="name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(mode === "register" || mode === "forgot") && (
|
||||
{(mode === "login" || mode === "register" || mode === "forgot") && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
{mode === "forgot"
|
||||
? t(locale, "auth.forgot.emailLabel")
|
||||
: t(locale, "auth.field.email")}
|
||||
</Label>
|
||||
<Label htmlFor="email">{t(locale, "auth.field.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required={mode === "forgot"}
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
|
|
@ -240,9 +213,6 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "auth.passwordHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -276,9 +246,6 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "auth.passwordHint")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -309,9 +276,6 @@ function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "auth.passwordHint")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,9 +26,8 @@ const trpcClient = createTRPCReactClient();
|
|||
|
||||
export interface UserMenuProps {
|
||||
locale?: Locale | undefined;
|
||||
username?: string | undefined;
|
||||
name?: string | undefined;
|
||||
email?: string | null | undefined;
|
||||
email?: string | undefined;
|
||||
avatarUrl?: string | undefined;
|
||||
}
|
||||
|
||||
|
|
@ -100,9 +99,7 @@ function UserMenuInner({ locale, avatarUrl }: { locale: Locale; avatarUrl?: stri
|
|||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{name}</span>
|
||||
<span className="u-mono text-[11px] opacity-70">
|
||||
{user.email ?? user.username ?? ""}
|
||||
</span>
|
||||
<span className="u-mono text-[11px] opacity-70">{user.email}</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import type { Locale } from "@app/i18n";
|
||||
import { t } from "@app/i18n";
|
||||
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 { useEffect, useState } from "react";
|
||||
import { go, queryParam, setQueryParams } from "~/lib/nav";
|
||||
import { useState } from "react";
|
||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||
|
||||
function pathOf(segments: string[]): string {
|
||||
|
|
@ -18,51 +17,13 @@ function fileRelPath(dirPath: string, basename: string): string {
|
|||
function BrowseInner(props: { locale: Locale }) {
|
||||
const { locale } = props;
|
||||
const mounts = trpc.media.mountList.useQuery();
|
||||
const [mountId, setMountId] = useState(() => queryParam("mount") ?? "");
|
||||
const [mountId, setMountId] = useState("");
|
||||
const [path, setPath] = useState("/");
|
||||
const [crumbs, setCrumbs] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryParams({ mount: mountId || null });
|
||||
}, [mountId]);
|
||||
|
||||
// 挂载列表返回后:URL 里的 mount 有效则保留;否则自动选中第一个已挂载目录
|
||||
useEffect(() => {
|
||||
const list = mounts.data?.mounts ?? [];
|
||||
if (list.length === 0) return;
|
||||
if (mountId && list.some((m) => m.id === mountId)) return;
|
||||
const first = list[0];
|
||||
if (first) setMountId(first.id);
|
||||
}, [mounts.data, mountId]);
|
||||
|
||||
const dir = trpc.media.mountListDir.useQuery({ mountId, path }, { enabled: Boolean(mountId) });
|
||||
const scan = trpc.media.libraryScan.useMutation();
|
||||
const ensure = trpc.media.libraryEnsurePath.useMutation();
|
||||
const utils = trpc.useUtils();
|
||||
const batchScrape = trpc.media.scrapeRescrapePath.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(t(locale, "browse.batchDone", { message: data.message }));
|
||||
void utils.media.libraryList.invalidate();
|
||||
void utils.media.libraryGroups.invalidate();
|
||||
},
|
||||
onError: () => toast.error(t(locale, "browse.batchError")),
|
||||
});
|
||||
const bindPath = trpc.media.scrapeBindPath.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(t(locale, "browse.batchDone", { message: data.message }));
|
||||
setBindOpen(false);
|
||||
setBindQ("");
|
||||
void utils.media.libraryList.invalidate();
|
||||
void utils.media.libraryGroups.invalidate();
|
||||
},
|
||||
onError: () => toast.error(t(locale, "browse.batchError")),
|
||||
});
|
||||
const [bindOpen, setBindOpen] = useState(false);
|
||||
const [bindQ, setBindQ] = useState("");
|
||||
const bindSearch = trpc.media.scrapeSearch.useQuery(
|
||||
{ q: bindQ },
|
||||
{ enabled: bindOpen && bindQ.length > 1 },
|
||||
);
|
||||
|
||||
function goRoot() {
|
||||
setCrumbs([]);
|
||||
|
|
@ -91,7 +52,7 @@ function BrowseInner(props: { locale: Locale }) {
|
|||
if (!mountId) return;
|
||||
try {
|
||||
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 {
|
||||
// error surfaced via ensure.error
|
||||
}
|
||||
|
|
@ -136,37 +97,11 @@ function BrowseInner(props: { locale: Locale }) {
|
|||
{t(locale, "browse.addLibrary")}
|
||||
</Button>
|
||||
)}
|
||||
{mountId && path !== "/" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={batchScrape.isPending}
|
||||
onClick={() => batchScrape.mutate({ mountId, path })}
|
||||
>
|
||||
{t(locale, "browse.batchScrape")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setBindOpen(true)}>
|
||||
{t(locale, "browse.batchBind")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(batchScrape.data || bindPath.data) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(locale, "browse.batchDone", {
|
||||
message: batchScrape.data?.message ?? bindPath.data?.message ?? "",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{scan.data && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(locale, "browse.scanDone", { count: scan.data.scanned })}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => go(`/${locale}/library`)}>
|
||||
{t(locale, "browse.goLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(locale, "browse.scanDone", { count: scan.data.scanned })}
|
||||
</p>
|
||||
)}
|
||||
{scan.isError && (
|
||||
<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>
|
||||
)}
|
||||
{mountId && dir.isError && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm text-red-600">
|
||||
{t(locale, "browse.loadError")}
|
||||
{dir.error instanceof Error && dir.error.message
|
||||
? `:${dir.error.message}`
|
||||
: ""}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => void dir.refetch()}>
|
||||
{t(locale, "error.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-red-600">{t(locale, "browse.loadError")}</p>
|
||||
)}
|
||||
{ensure.isError && (
|
||||
<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 && (
|
||||
<li className="p-0">
|
||||
<div className="u-empty border-0 bg-transparent">
|
||||
<p>{t(locale, "browse.empty")}</p>
|
||||
</div>
|
||||
<li className="p-4 text-sm text-muted-foreground">
|
||||
{t(locale, "browse.empty")}
|
||||
</li>
|
||||
)}
|
||||
{!mountId && mountList.length === 0 && (
|
||||
<li className="p-0">
|
||||
<div className="u-empty border-0 bg-transparent">
|
||||
<p className="u-empty__title">{t(locale, "browse.mountsEmpty")}</p>
|
||||
<div className="u-empty__actions">
|
||||
<Button size="sm" onClick={() => go(`/${locale}/mounts`)}>
|
||||
{t(locale, "browse.addMount")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{!mountId && mountList.length > 0 && (
|
||||
<li className="p-0">
|
||||
<div className="u-empty border-0 bg-transparent">
|
||||
<p>{t(locale, "browse.loading")}</p>
|
||||
</div>
|
||||
{!mountId && (
|
||||
<li className="p-4 text-sm text-muted-foreground">
|
||||
{t(locale, "browse.empty")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{bindOpen && mountId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 space-y-3">
|
||||
<h2 className="font-medium">{t(locale, "browse.bindFolderTitle")}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "browse.bindFolderHint")}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{path}</p>
|
||||
<Input
|
||||
placeholder={t(locale, "library.searchWork")}
|
||||
value={bindQ}
|
||||
onChange={(e) => setBindQ(e.target.value)}
|
||||
/>
|
||||
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
|
||||
{(bindSearch.data ?? []).map((hit) => (
|
||||
<li
|
||||
key={hit.id}
|
||||
className="flex items-center justify-between gap-2 p-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{hit.nameCn || hit.name}</div>
|
||||
{hit.nameCn && hit.name && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{hit.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={bindPath.isPending}
|
||||
onClick={() =>
|
||||
bindPath.mutate({
|
||||
mountId,
|
||||
path,
|
||||
bangumiId: hit.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(locale, "library.bind")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button variant="outline" onClick={() => setBindOpen(false)}>
|
||||
{t(locale, "library.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,265 +1,96 @@
|
|||
import type { Locale, MessageKey } from "@app/i18n";
|
||||
import type { Locale } 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 { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||
|
||||
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
|
||||
|
||||
type BlockType = "scroll" | "top" | "bottom";
|
||||
type DanmakuSettings = { enabled: boolean; opacity: number; density: number };
|
||||
|
||||
type DanmakuSettings = {
|
||||
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));
|
||||
}
|
||||
const FALLBACK: DanmakuSettings = { enabled: true, opacity: 0.8, density: 1 };
|
||||
|
||||
function readSettings(): DanmakuSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
|
||||
if (!raw) return { ...FALLBACK };
|
||||
if (!raw) return FALLBACK;
|
||||
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 enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled;
|
||||
const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity;
|
||||
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 {
|
||||
enabled,
|
||||
opacity: Math.min(1, Math.max(0, opacityRaw)),
|
||||
density: Math.min(1, Math.max(0.1, densityRaw)),
|
||||
blockKeywords: blockKeywords.slice(0, 100),
|
||||
blockTypes: parseBlockTypes(rec["blockTypes"]),
|
||||
};
|
||||
} catch {
|
||||
return { ...FALLBACK };
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(next: DanmakuSettings) {
|
||||
try {
|
||||
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// localStorage 不可用时静默;服务端已持久化
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
function Inner(props: { locale: Locale }) {
|
||||
const { locale } = props;
|
||||
const prefsQuery = trpc.media.danmakuGetSettings.useQuery();
|
||||
const save = trpc.media.danmakuSaveSettings.useMutation();
|
||||
const filledFromServer = useRef(false);
|
||||
|
||||
// SSR 与水合首帧共用 FALLBACK,避免 localStorage 差异导致 hydration 不一致
|
||||
const [enabled, setEnabled] = useState(FALLBACK.enabled);
|
||||
const [opacity, setOpacity] = useState(FALLBACK.opacity);
|
||||
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(() => {
|
||||
const data = prefsQuery.data;
|
||||
if (!data || filledFromServer.current) return;
|
||||
filledFromServer.current = true;
|
||||
setEnabled(data.enabled);
|
||||
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 prefs = readSettings();
|
||||
setEnabled(prefs.enabled);
|
||||
setOpacity(prefs.opacity);
|
||||
setDensity(prefs.density);
|
||||
}, []);
|
||||
|
||||
const onSave = () => {
|
||||
const next: DanmakuSettings = { enabled, opacity, density, blockKeywords, blockTypes };
|
||||
writeSettings(next);
|
||||
save.mutate(next, {
|
||||
onSuccess: () => {
|
||||
toast.success(t(locale, "danmaku.saved"));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(locale, "danmaku.saveFailed"));
|
||||
},
|
||||
});
|
||||
const next = { enabled, opacity, density };
|
||||
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
|
||||
save.mutate(next);
|
||||
};
|
||||
|
||||
const openNetworkMissing = prefsQuery.data?.openNetworkConfigured === false;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{openNetworkMissing && (
|
||||
<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">
|
||||
{t(locale, "danmaku.openNetworkNotConfigured")}
|
||||
</div>
|
||||
<div className="max-w-md space-y-4">
|
||||
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
{t(locale, "danmaku.enabled")}
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t(locale, "danmaku.opacityValue", { value: (opacity * 100).toFixed(0) })}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t(locale, "danmaku.densityValue", { value: density })}
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={density}
|
||||
onChange={(e) => setDensity(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<Button onClick={onSave}>{t(locale, "danmaku.save")}</Button>
|
||||
{save.isSuccess && (
|
||||
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
|
||||
)}
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
{t(locale, "danmaku.enabled")}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span>{t(locale, "danmaku.opacity")}</span>
|
||||
<span className="u-mono text-xs text-muted-foreground">
|
||||
{(opacity * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span>{t(locale, "danmaku.density")}</span>
|
||||
<span className="u-mono text-xs text-muted-foreground">
|
||||
{(density * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={density}
|
||||
onChange={(e) => setDensity(Number(e.target.value))}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button type="button" onClick={onSave} disabled={save.isPending}>
|
||||
{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>
|
||||
<p className="text-xs text-muted-foreground">{t(locale, "danmaku.localImportHint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,447 +1,130 @@
|
|||
import type { Locale } from "@app/i18n";
|
||||
import { t } from "@app/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
toast,
|
||||
} from "@app/ui";
|
||||
import { Button, Input } from "@app/ui";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { go } from "~/lib/nav";
|
||||
import { useState } from "react";
|
||||
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 }) {
|
||||
const { locale } = props;
|
||||
const utils = trpc.useUtils();
|
||||
const list = trpc.media.mountList.useQuery();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const create = trpc.media.mountCreate.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(t(locale, "mounts.saved"));
|
||||
void utils.media.mountList.invalidate();
|
||||
setForm(EMPTY_FORM);
|
||||
setFieldErrors({});
|
||||
setCreatedMountId(data.mount.id);
|
||||
},
|
||||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.saveError"))),
|
||||
});
|
||||
const update = trpc.media.mountUpdate.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(t(locale, "mounts.updated"));
|
||||
void utils.media.mountList.invalidate();
|
||||
setMode("create");
|
||||
setEditId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFieldErrors({});
|
||||
},
|
||||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.saveError"))),
|
||||
onSuccess: () => utils.media.mountList.invalidate(),
|
||||
});
|
||||
const test = trpc.media.mountTest.useMutation();
|
||||
const del = trpc.media.mountDelete.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
data.removedItems > 0
|
||||
? t(locale, "mounts.deletedN", { n: data.removedItems })
|
||||
: t(locale, "mounts.deleted"),
|
||||
);
|
||||
void utils.media.mountList.invalidate();
|
||||
if (editId) {
|
||||
setMode("create");
|
||||
setEditId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "mounts.deleteError"))),
|
||||
onSuccess: () => utils.media.mountList.invalidate(),
|
||||
});
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
password: "",
|
||||
rootPath: "/",
|
||||
});
|
||||
|
||||
const [mode, setMode] = useState<FormMode>("create");
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [createdMountId, setCreatedMountId] = useState<string | null>(null);
|
||||
|
||||
const saving = create.isPending || update.isPending;
|
||||
const mountList = list.data?.mounts ?? [];
|
||||
|
||||
function startEdit(mount: {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
username: string | null;
|
||||
rootPath: string;
|
||||
}) {
|
||||
setMode("edit");
|
||||
setEditId(mount.id);
|
||||
setCreatedMountId(null);
|
||||
test.reset();
|
||||
setForm({
|
||||
name: mount.name,
|
||||
baseUrl: mount.baseUrl,
|
||||
username: mount.username ?? "",
|
||||
password: "",
|
||||
rootPath: mount.rootPath || "/",
|
||||
});
|
||||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
formRef.current?.querySelector<HTMLInputElement>("input")?.focus();
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setMode("create");
|
||||
setEditId(null);
|
||||
test.reset();
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "edit") return;
|
||||
if (mountList.length > 0) setCreatedMountId(null);
|
||||
}, [mountList.length, mode]);
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const errs = validateForm(form);
|
||||
setFieldErrors(errs);
|
||||
if (errs.name || errs.baseUrl) {
|
||||
const firstKey = errs.name ? "mount-name" : "mount-url";
|
||||
formRef.current?.querySelector<HTMLInputElement>(`#${firstKey}`)?.focus();
|
||||
return;
|
||||
}
|
||||
const base = {
|
||||
name: form.name.trim(),
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
username: form.username.trim() || undefined,
|
||||
rootPath: form.rootPath.trim() || "/",
|
||||
};
|
||||
if (mode === "edit" && editId) {
|
||||
// 密码留空 = 不修改原凭据
|
||||
update.mutate({
|
||||
id: editId,
|
||||
...base,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
});
|
||||
} else {
|
||||
create.mutate({
|
||||
...base,
|
||||
type: "webdav",
|
||||
password: form.password || undefined,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onTest() {
|
||||
test.mutate({
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
username: form.username.trim() || undefined,
|
||||
password: form.password || undefined,
|
||||
rootPath: form.rootPath.trim() || "/",
|
||||
// 编辑留空密码时由服务端复用已存密文
|
||||
mountId: mode === "edit" ? (editId ?? undefined) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold">{t(locale, "mounts.title")}</h1>
|
||||
<span className="u-mono text-xs text-muted-foreground">
|
||||
{String(mountList.length).padStart(2, "0")}
|
||||
</span>
|
||||
{mode === "edit" ? (
|
||||
<Button size="sm" variant="outline" className="ml-auto" onClick={cancelEdit}>
|
||||
{t(locale, "mounts.cancelEdit")}
|
||||
<h1 className="text-xl font-semibold">{t(locale, "mounts.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(locale, "mounts.openlistHint")} {t(locale, "mounts.credentialHint")}
|
||||
</p>
|
||||
<form
|
||||
className="grid gap-3 rounded-lg border border-border bg-card p-4 md:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
create.mutate({
|
||||
name: form.name,
|
||||
type: "webdav",
|
||||
baseUrl: form.baseUrl,
|
||||
username: form.username || undefined,
|
||||
password: form.password || undefined,
|
||||
rootPath: form.rootPath || "/",
|
||||
enabled: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder={t(locale, "mounts.namePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder={t(locale, "mounts.baseUrlPlaceholder")}
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder={t(locale, "mounts.usernamePlaceholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t(locale, "mounts.passwordPlaceholder")}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t(locale, "mounts.rootPathPlaceholder")}
|
||||
value={form.rootPath}
|
||||
onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
{t(locale, "mounts.save")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
formRef.current?.querySelector<HTMLInputElement>("input")?.focus();
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
test.mutate({
|
||||
baseUrl: form.baseUrl,
|
||||
username: form.username || undefined,
|
||||
password: form.password || undefined,
|
||||
rootPath: form.rootPath || "/",
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(locale, "mounts.add")}
|
||||
{t(locale, "mounts.test")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form ref={formRef} className="space-y-4" onSubmit={onSubmit}>
|
||||
<Card className={cn("space-y-4 p-5", mode === "edit" && "border-primary/50")}>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{mode === "edit"
|
||||
? t(locale, "mounts.editHeading")
|
||||
: t(locale, "mounts.add")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(locale, "mounts.credentialHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mount-name">{t(locale, "mounts.fieldName")}</Label>
|
||||
<Input
|
||||
id="mount-name"
|
||||
aria-label={t(locale, "mounts.namePlaceholder")}
|
||||
placeholder={t(locale, "mounts.namePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, name: e.target.value });
|
||||
if (fieldErrors.name)
|
||||
setFieldErrors({ ...fieldErrors, name: undefined });
|
||||
}}
|
||||
/>
|
||||
{fieldErrors.name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t(locale, "mounts.errNameRequired")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mount-url">{t(locale, "mounts.fieldUrl")}</Label>
|
||||
<Input
|
||||
id="mount-url"
|
||||
aria-label={t(locale, "mounts.baseUrlPlaceholder")}
|
||||
placeholder={t(locale, "mounts.baseUrlPlaceholder")}
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, baseUrl: e.target.value });
|
||||
if (fieldErrors.baseUrl) {
|
||||
setFieldErrors({ ...fieldErrors, baseUrl: undefined });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{fieldErrors.baseUrl && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t(locale, "mounts.errUrlInvalid")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mount-user">{t(locale, "mounts.fieldUser")}</Label>
|
||||
<Input
|
||||
id="mount-user"
|
||||
aria-label={t(locale, "mounts.usernamePlaceholder")}
|
||||
placeholder={t(locale, "mounts.usernamePlaceholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mount-pass">{t(locale, "mounts.fieldPass")}</Label>
|
||||
<Input
|
||||
id="mount-pass"
|
||||
type="password"
|
||||
aria-label={t(locale, "mounts.passwordPlaceholder")}
|
||||
placeholder={
|
||||
mode === "edit"
|
||||
? t(locale, "mounts.passwordKeepHint")
|
||||
: t(locale, "mounts.passwordPlaceholder")
|
||||
}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mount-root">{t(locale, "mounts.fieldRoot")}</Label>
|
||||
<Input
|
||||
id="mount-root"
|
||||
aria-label={t(locale, "mounts.rootPathPlaceholder")}
|
||||
placeholder={t(locale, "mounts.rootPathPlaceholder")}
|
||||
value={form.rootPath}
|
||||
onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{mode === "edit"
|
||||
? t(locale, "mounts.saveEdit")
|
||||
: t(locale, "mounts.save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={test.isPending || !form.baseUrl.trim()}
|
||||
onClick={onTest}
|
||||
>
|
||||
{t(locale, "mounts.test")}
|
||||
</Button>
|
||||
{mode === "edit" && (
|
||||
<Button type="button" variant="ghost" onClick={cancelEdit}>
|
||||
{t(locale, "mounts.cancelEdit")}
|
||||
</Button>
|
||||
)}
|
||||
{test.data && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm",
|
||||
test.data.ok ? "text-green-600" : "text-red-600",
|
||||
)}
|
||||
>
|
||||
{test.data.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(locale, "mounts.openlistHint")}
|
||||
</div>
|
||||
{test.data && (
|
||||
<p className={test.data.ok ? "text-green-600" : "text-red-600"}>
|
||||
{test.data.message}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{createdMountId && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-primary/40 bg-primary/5 p-4">
|
||||
<p className="text-sm">{t(locale, "mounts.afterCreateHint")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
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.rootPath && m.rootPath !== "/" ? ` · ${m.rootPath}` : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2 pt-0">
|
||||
<Button size="sm" variant="secondary" onClick={() => startEdit(m)}>
|
||||
{t(locale, "mounts.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => go(`/${locale}/browse?mount=${m.id}`)}
|
||||
>
|
||||
{t(locale, "mounts.browseThis")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
if (confirmDeleteId === m.id) {
|
||||
setConfirmDeleteId(null);
|
||||
del.mutate({ id: m.id });
|
||||
} else {
|
||||
setConfirmDeleteId(m.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{confirmDeleteId === m.id
|
||||
? t(locale, "mounts.confirmDelete")
|
||||
: t(locale, "mounts.delete")}
|
||||
</Button>
|
||||
{confirmDeleteId === m.id && (
|
||||
<span className="self-center text-xs text-destructive">
|
||||
{t(locale, "mounts.deleteHint")}
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{list.isSuccess && mountList.length === 0 && (
|
||||
<div className="u-empty col-span-full">
|
||||
<p className="u-empty__title">{t(locale, "mounts.empty")}</p>
|
||||
<p>{t(locale, "mounts.openlistHint")}</p>
|
||||
<div className="u-empty__actions">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
formRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
formRef.current
|
||||
?.querySelector<HTMLInputElement>("input")
|
||||
?.focus();
|
||||
}}
|
||||
>
|
||||
{t(locale, "mounts.add")}
|
||||
</Button>
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{(list.data?.mounts ?? []).map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between gap-3 p-3">
|
||||
<div>
|
||||
<div className="font-medium">{m.name}</div>
|
||||
<div className="text-xs text-muted-foreground break-all">
|
||||
{m.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => del.mutate({ id: m.id })}
|
||||
>
|
||||
{t(locale, "mounts.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{list.data?.mounts.length === 0 && (
|
||||
<li className="p-4 text-sm text-muted-foreground">
|
||||
{t(locale, "mounts.empty")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
---
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
import "../styles/globals.css";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import { Toaster } from "@app/ui";
|
||||
import ThemeSwitch from "~/components/ThemeSwitch";
|
||||
import LocaleSwitch from "~/components/LocaleSwitch";
|
||||
import {
|
||||
buildDefaultJsonLd,
|
||||
buildHeadTags,
|
||||
|
|
@ -12,36 +9,34 @@ import {
|
|||
toJsonLdScript,
|
||||
type PageSeoInput,
|
||||
} from "@app/seo-geo";
|
||||
import {
|
||||
alternatePath,
|
||||
COOKIE_LANG,
|
||||
COOKIE_LANG_MAX_AGE,
|
||||
LOCALES,
|
||||
type Locale,
|
||||
localePath,
|
||||
t,
|
||||
} from "@app/i18n";
|
||||
import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, t } from "@app/i18n";
|
||||
import { getSiteSeo } from "~/lib/seo";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
locale: Locale;
|
||||
brand?: string;
|
||||
/** 当前页,用于侧栏高亮 */
|
||||
active?: "home" | "library" | "browse" | "mounts" | "settings";
|
||||
nav?: { label: string; href: string; n: string }[];
|
||||
switchHref?: string;
|
||||
switchLabel?: string;
|
||||
seo?: PageSeoInput;
|
||||
/** 播放页隐藏模板页脚 */
|
||||
hideFooter?: boolean;
|
||||
/** 媒体页只保留 AppShell 侧栏时隐藏模板 side-rail(含左让位) */
|
||||
hideSideRail?: boolean;
|
||||
}
|
||||
|
||||
const { title, locale, brand, active, seo, hideFooter = false } = Astro.props;
|
||||
|
||||
/** 语言下拉:各语言在当前页的 URL */
|
||||
const localeHrefs = Object.fromEntries(
|
||||
LOCALES.map((code) => [code, alternatePath(Astro.url.pathname, code)]),
|
||||
) as Record<Locale, string>;
|
||||
const {
|
||||
title,
|
||||
locale,
|
||||
brand,
|
||||
nav: navProp,
|
||||
switchHref,
|
||||
switchLabel,
|
||||
seo,
|
||||
hideSideRail = false,
|
||||
} = Astro.props;
|
||||
|
||||
const brandName = brand ?? t(locale, "meta.brand");
|
||||
const nav = navProp ?? [];
|
||||
|
||||
const siteSeo = getSiteSeo(locale);
|
||||
const pageSeoInput: PageSeoInput = {
|
||||
|
|
@ -54,18 +49,9 @@ const headHtml = renderHeadTagsHtml(headTags);
|
|||
const jsonLdHtml = toJsonLdScript(buildDefaultJsonLd(siteSeo, pageSeoInput));
|
||||
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 brandInitial = brandName.charAt(0) || "弹";
|
||||
|
||||
/** 全站统一侧栏导航 */
|
||||
const nav = [
|
||||
{ id: "home", n: "00", label: t(locale, "nav.home"), href: homeHref },
|
||||
{ id: "library", n: "01", label: t(locale, "nav.library"), href: localePath(locale, "/library") },
|
||||
{ id: "browse", n: "02", label: t(locale, "nav.browse"), href: localePath(locale, "/browse") },
|
||||
{ id: "mounts", n: "03", label: t(locale, "nav.mounts"), href: localePath(locale, "/mounts") },
|
||||
{ id: "settings", n: "04", label: t(locale, "nav.settings"), href: localePath(locale, "/settings") },
|
||||
] as const;
|
||||
const brandInitial = locale === "zh-CN" ? "模" : "A";
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
|
|
@ -87,11 +73,6 @@ const nav = [
|
|||
<title set:html={pageTitle} />
|
||||
<Fragment set:html={headHtml} />
|
||||
<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" }}>
|
||||
(function () {
|
||||
function apply(mode) {
|
||||
|
|
@ -103,46 +84,36 @@ const nav = [
|
|||
document.documentElement.classList.toggle("light", !dark);
|
||||
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
||||
}
|
||||
function applyFromStorage() {
|
||||
var mode = "system";
|
||||
try {
|
||||
var t = localStorage.getItem("app-theme");
|
||||
if (t === "light" || t === "dark" || t === "system") mode = t;
|
||||
} catch (e) {}
|
||||
apply(mode);
|
||||
}
|
||||
applyFromStorage();
|
||||
|
||||
// ClientRouter 软导航后 <html> class 会被新页覆盖,需按偏好重放主题
|
||||
document.addEventListener("astro:after-swap", applyFromStorage);
|
||||
|
||||
if (!window.__fanmuChromeInit) {
|
||||
window.__fanmuChromeInit = true;
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", function () {
|
||||
try {
|
||||
if ((localStorage.getItem("app-theme") || "system") === "system") {
|
||||
apply("system");
|
||||
}
|
||||
} catch (e) {
|
||||
var mode = "system";
|
||||
try {
|
||||
var t = localStorage.getItem("app-theme");
|
||||
if (t === "light" || t === "dark" || t === "system") mode = t;
|
||||
} catch (e) {}
|
||||
apply(mode);
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", function () {
|
||||
try {
|
||||
if ((localStorage.getItem("app-theme") || "system") === "system") {
|
||||
apply("system");
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
apply("system");
|
||||
}
|
||||
});
|
||||
|
||||
// 语言切换:写偏好 cookie 后由 <a> 跳转(捕获阶段)
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
var el = e.target && e.target.closest ? e.target.closest("[data-switch-locale]") : null;
|
||||
if (!el) return;
|
||||
var target = el.getAttribute("data-switch-locale") || nextLocale;
|
||||
document.cookie =
|
||||
cookieName + "=" + target + "; path=/; max-age=" + maxAge + "; SameSite=Lax";
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
// 语言切换:写偏好 cookie 后由 <a> 跳转(捕获阶段)
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
var el = e.target && e.target.closest ? e.target.closest("[data-switch-locale]") : null;
|
||||
if (!el) return;
|
||||
var target = el.getAttribute("data-switch-locale") || nextLocale;
|
||||
document.cookie =
|
||||
cookieName + "=" + target + "; path=/; max-age=" + maxAge + "; SameSite=Lax";
|
||||
},
|
||||
true,
|
||||
);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
|
@ -158,40 +129,42 @@ const nav = [
|
|||
<div aria-hidden="true" class="bg-vignette pointer-events-none fixed inset-0 z-0"></div>
|
||||
|
||||
{
|
||||
<aside class="side-rail" aria-label={t(locale, "nav.primary")}>
|
||||
<a href={brandHref} class="side-rail__brand" aria-label={brandName}>
|
||||
<span
|
||||
class="brand-signal flex size-8 items-center justify-center text-[12px] font-bold"
|
||||
style="background: var(--edge); color: #101110;"
|
||||
>
|
||||
{brandInitial}
|
||||
</span>
|
||||
<span class="side-rail__brand-text">{brandName}</span>
|
||||
</a>
|
||||
<div class="side-rail__rule" aria-hidden="true"></div>
|
||||
<nav class="side-rail__nav">
|
||||
{nav.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={["side-rail__link", { "is-active": active === item.id }]}
|
||||
aria-current={active === item.id ? "page" : undefined}
|
||||
!hideSideRail && (
|
||||
<aside class="side-rail" aria-label={t(locale, "nav.primary")}>
|
||||
<a href={brandHref} class="side-rail__brand" aria-label={brandName}>
|
||||
<span
|
||||
class="brand-signal flex size-8 items-center justify-center text-[12px] font-bold"
|
||||
style="background: var(--edge); color: #101110;"
|
||||
>
|
||||
<span class="u-mono">{item.n}</span>
|
||||
<span>{item.label}</span>
|
||||
{brandInitial}
|
||||
</span>
|
||||
<span class="side-rail__brand-text">{brandName}</span>
|
||||
</a>
|
||||
<div class="side-rail__rule" aria-hidden="true"></div>
|
||||
<nav class="side-rail__nav">
|
||||
<a href={brandHref} class="side-rail__link is-home">
|
||||
<span class="u-mono">00</span>
|
||||
<span>{t(locale, "nav.home")}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div class="side-rail__rule" aria-hidden="true"></div>
|
||||
<div class="side-rail__foot">
|
||||
<ThemeSwitch client:only="react" />
|
||||
</div>
|
||||
</aside>
|
||||
{nav.map((item) => (
|
||||
<a href={item.href} class="side-rail__link">
|
||||
<span class="u-mono">{item.n}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div class="side-rail__rule" aria-hidden="true"></div>
|
||||
<div class="side-rail__foot">
|
||||
<ThemeSwitch client:visible />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="site-shell">
|
||||
<div class:list={["site-shell", { "site-shell--no-rail": hideSideRail }]}>
|
||||
<header class="site-header">
|
||||
<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
|
||||
class="brand-signal flex size-7 items-center justify-center text-[11px] font-bold"
|
||||
style="background: var(--edge); color: #101110;"
|
||||
|
|
@ -203,11 +176,7 @@ const nav = [
|
|||
<nav class="site-header__nav" aria-label={t(locale, "nav.primary")}>
|
||||
{
|
||||
nav.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={["u-linelink", { "is-active": active === item.id }]}
|
||||
aria-current={active === item.id ? "page" : undefined}
|
||||
>
|
||||
<a href={item.href} class="u-linelink">
|
||||
<span class="u-mono">{item.n}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
|
|
@ -215,11 +184,23 @@ const nav = [
|
|||
}
|
||||
</nav>
|
||||
<div class="site-header__actions">
|
||||
<LocaleSwitch client:only="react" locale={locale} hrefs={localeHrefs} />
|
||||
<span class="lg:hidden">
|
||||
<ThemeSwitch client:only="react" />
|
||||
{
|
||||
switchHref && (
|
||||
<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>
|
||||
<UserMenu client:only="react" locale={locale} />
|
||||
<UserMenu client:visible locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-header__dash" aria-hidden="true"></div>
|
||||
|
|
@ -229,16 +210,13 @@ const nav = [
|
|||
<slot />
|
||||
</main>
|
||||
|
||||
{!hideFooter && (
|
||||
<footer class="site-footer">
|
||||
<div class="u-dotline" aria-hidden="true"></div>
|
||||
<div class="site-footer__inner">
|
||||
<p class="u-mono">{brandName}</p>
|
||||
<p class="u-label">{t(locale, "home.footer")}</p>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
<footer class="site-footer">
|
||||
<div class="u-dotline" aria-hidden="true"></div>
|
||||
<div class="site-footer__inner">
|
||||
<p class="u-mono">{t(locale, "meta.brand")}</p>
|
||||
<p class="u-label">{t(locale, "home.footer")}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<Toaster client:load />
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,48 +1,95 @@
|
|||
import {
|
||||
filterComments,
|
||||
parseDanmakuXml,
|
||||
serializeDanmakuXml,
|
||||
type DanmakuComment,
|
||||
type FilterOptions,
|
||||
} from "@app/danmaku/browser";
|
||||
export type DanmakuItem = { time: number; text: string; mode: number };
|
||||
|
||||
export { filterComments, parseDanmakuXml, serializeDanmakuXml };
|
||||
export type { DanmakuComment, FilterOptions };
|
||||
export function parseDanmakuXml(xml: string): DanmakuItem[] {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||
const nodes = Array.from(doc.querySelectorAll("d"));
|
||||
const out: DanmakuItem[] = [];
|
||||
for (const n of nodes) {
|
||||
const p = n.getAttribute("p") ?? "";
|
||||
const [t, mode] = p.split(",");
|
||||
const 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 DanmakuItem = {
|
||||
time: number;
|
||||
text: string;
|
||||
mode: 0 | 1 | 2;
|
||||
color?: string | undefined;
|
||||
export type DanmakuLoopOptions = {
|
||||
opacity?: number | undefined;
|
||||
/** 每秒横向位移 px */
|
||||
speed?: number | undefined;
|
||||
};
|
||||
|
||||
/** B 站 p 属性 mode → artplayer 插件 mode */
|
||||
function toPluginMode(raw: number): 0 | 1 | 2 {
|
||||
// 1/2/3 滚动,4 底部,5 顶部
|
||||
if (raw === 4) return 2;
|
||||
if (raw === 5) return 1;
|
||||
return 0;
|
||||
}
|
||||
export function startDanmakuLoop(
|
||||
container: HTMLElement,
|
||||
getItems: () => DanmakuItem[],
|
||||
getCurrentTime: () => number,
|
||||
options?: DanmakuLoopOptions,
|
||||
): () => void {
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "absolute inset-0 overflow-hidden pointer-events-none";
|
||||
layer.style.position = "absolute";
|
||||
layer.style.zIndex = "5";
|
||||
const opacity = options?.opacity;
|
||||
if (typeof opacity === "number") {
|
||||
layer.style.opacity = String(Math.min(1, Math.max(0, opacity)));
|
||||
}
|
||||
container.style.position = container.style.position || "relative";
|
||||
container.appendChild(layer);
|
||||
|
||||
function toHexColor(decimal: number): string | undefined {
|
||||
if (!Number.isFinite(decimal) || decimal < 0) return undefined;
|
||||
return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`;
|
||||
}
|
||||
const active: { el: HTMLDivElement; start: number }[] = [];
|
||||
const speed = options?.speed ?? 160;
|
||||
let raf = 0;
|
||||
let cursor = 0;
|
||||
let lastTime = 0;
|
||||
|
||||
/** DanmakuComment(含颜色/模式)→ 插件条目 */
|
||||
export function toDanmukuItems(list: DanmakuComment[]): DanmakuItem[] {
|
||||
return list.map((c) => ({
|
||||
time: c.time,
|
||||
text: c.text,
|
||||
mode: toPluginMode(c.mode),
|
||||
color: toHexColor(c.color),
|
||||
}));
|
||||
}
|
||||
const clearActive = () => {
|
||||
for (const a of active) a.el.remove();
|
||||
active.length = 0;
|
||||
};
|
||||
|
||||
/** density 0.1–1:按时间均匀抽稀 */
|
||||
export function thinDanmaku(list: DanmakuComment[], density: number): DanmakuComment[] {
|
||||
if (density >= 1) return list;
|
||||
const keepEvery = Math.max(1, Math.round(1 / density));
|
||||
return list.filter((_, i) => i % keepEvery === 0);
|
||||
const tick = () => {
|
||||
const now = getCurrentTime();
|
||||
const items = getItems();
|
||||
// 进度回跳时重置游标,避免一次性涌出全部历史弹幕
|
||||
if (now + 0.5 < lastTime) {
|
||||
cursor = 0;
|
||||
clearActive();
|
||||
}
|
||||
lastTime = now;
|
||||
while (cursor < items.length) {
|
||||
const it = items[cursor];
|
||||
if (!it || it.time > now) break;
|
||||
cursor += 1;
|
||||
// 快进/加载导致的过期弹幕直接丢弃,防止一帧内刷屏
|
||||
if (it.time < now - 2) continue;
|
||||
const el = document.createElement("div");
|
||||
el.textContent = it.text;
|
||||
el.style.cssText =
|
||||
"position:absolute;left:100%;white-space:nowrap;color:#fff;font-size:22px;text-shadow:0 1px 2px #000;will-change:transform;";
|
||||
const maxY = Math.max(0, container.clientHeight - 40);
|
||||
el.style.top = `${Math.random() * maxY}px`;
|
||||
layer.appendChild(el);
|
||||
active.push({ el, start: performance.now() });
|
||||
}
|
||||
const t = performance.now();
|
||||
for (let i = active.length - 1; i >= 0; i -= 1) {
|
||||
const a = active[i];
|
||||
if (!a) continue;
|
||||
const elapsed = (t - a.start) / 1000;
|
||||
const x = container.clientWidth - elapsed * speed;
|
||||
a.el.style.transform = `translateX(${x - container.clientWidth}px)`;
|
||||
if (x < -a.el.offsetWidth) {
|
||||
a.el.remove();
|
||||
active.splice(i, 1);
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearActive();
|
||||
layer.remove();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@ export function getSiteSeo(locale: Locale): SiteSeoConfig {
|
|||
const name = t(locale, "meta.brand");
|
||||
const isZh = locale === "zh-CN";
|
||||
const description = isZh
|
||||
? "挂载 WebDAV/OpenList 片源的 Web 媒体库:扫描、Bangumi 刮削、ArtPlayer 播放与在线弹幕。"
|
||||
: "Web media library for WebDAV/OpenList: scan, Bangumi scrape, ArtPlayer playback and online danmaku.";
|
||||
? "Astro + tRPC + Drizzle monorepo 应用脚手架,开箱即用的分层基线。"
|
||||
: "Astro + tRPC + Drizzle monorepo application scaffold with a ready vertical baseline.";
|
||||
|
||||
return {
|
||||
siteUrl,
|
||||
|
|
@ -29,15 +29,15 @@ export function getSiteSeo(locale: Locale): SiteSeoConfig {
|
|||
organization: { name, url: siteUrl },
|
||||
llms: {
|
||||
summary: isZh
|
||||
? "帆幕:私有影音库与弹幕观影。WebDAV 挂载、媒体库扫描、Bangumi 刮削、ArtPlayer 播放与弹弹play 开放弹幕网络中继,本地 XML 兜底。"
|
||||
: "Fanmu: private media library with danmaku. WebDAV mounts, library scan, Bangumi scraping, ArtPlayer playback, dandanplay open danmaku relay with local XML fallback.",
|
||||
? "这是可复用的应用脚手架模板:Astro SSR + React islands + tRPC + Drizzle/Turso,附带鉴权与用户垂直示例。"
|
||||
: "Reusable application scaffold: Astro SSR + React islands + tRPC + Drizzle/Turso, with auth and a user vertical example.",
|
||||
details: [
|
||||
isZh
|
||||
? "分层:types → models → db → dao → trpc → web。媒体域按业务分层扩展。"
|
||||
: "Layers: types → models → db → dao → trpc → web. Extend the media domain as a vertical slice.",
|
||||
? "分层:types → models → db → dao → trpc → web。业务实体按 user 切片扩展,禁止跨层。"
|
||||
: "Layers: types → models → db → dao → trpc → web. Extend domains via the user slice; never skip layers.",
|
||||
isZh
|
||||
? "挂载凭据仅存服务端加密;浏览器不直连网盘密码。"
|
||||
: "Mount credentials are encrypted server-side; the browser never sees remote passwords.",
|
||||
? "不绑定具体产品业务;works 等领域逻辑应在业务仓库中添加。"
|
||||
: "No product domain baked in; add domains such as works in your product repo.",
|
||||
],
|
||||
pages: PUBLIC_PATHS.map((basePath) => ({
|
||||
path: pathForLocale(locale, basePath),
|
||||
|
|
@ -45,9 +45,9 @@ export function getSiteSeo(locale: Locale): SiteSeoConfig {
|
|||
description,
|
||||
})),
|
||||
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",
|
||||
"/api/* responses",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -8,26 +8,6 @@ import type { AppRouter } from "@app/trpc";
|
|||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
|
||||
/** 网关/框架可能对 /api/trpc 返回 HTML 错误页:转换成 JSON 错误,避免客户端 JSON.parse 抛原始异常给用户 */
|
||||
async function safeFetch(input: RequestInfo | URL, init?: unknown): Promise<Response> {
|
||||
const res = await fetch(input, init as RequestInit | undefined);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!res.ok && !contentType.includes("application/json")) {
|
||||
const message = res.status >= 500 ? "服务暂时不可用,请稍后重试" : "请求失败,请重试";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
code: -32603,
|
||||
data: { code: "INTERNAL_SERVER_ERROR", httpStatus: res.status },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function createTRPCReactClient() {
|
||||
return trpc.createClient({
|
||||
links: [
|
||||
|
|
@ -35,7 +15,6 @@ export function createTRPCReactClient() {
|
|||
url: "/api/trpc",
|
||||
// 便于服务端日志区分 island 流量
|
||||
headers: () => ({ "x-trpc-source": "react-island" }),
|
||||
fetch: safeFetch,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const PROTECTED_BASES = [
|
|||
"/browse",
|
||||
"/watch",
|
||||
"/mounts",
|
||||
"/danmaku",
|
||||
] as const;
|
||||
|
||||
const LOCALE_SEGMENTS = new Set(["zh-CN", "en"]);
|
||||
|
|
@ -68,8 +69,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||
// 小写 zh-cn → 规范 zh-CN(保留 rest)
|
||||
const seg = path.split("/").filter(Boolean)[0];
|
||||
if (seg && seg.toLowerCase() === "zh-cn" && seg !== "zh-CN") {
|
||||
// 去掉 "/seg" 本身,剩余为 "/rest"(off-by-one 会产生 /zh-CNn)
|
||||
const rest = path.slice(seg.length + 1) || "/";
|
||||
const rest = path.slice(seg.length) || "/";
|
||||
return context.redirect(`/zh-CN${rest === "/" ? "" : rest}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
// Astro 全局 404:优先跟随 URL 的 locale 段(/zh-CN/* 显示中文),否则按 cookie / Accept-Language
|
||||
const seg = Astro.url.pathname.split("/")[1];
|
||||
const pathLocale = seg && isLocale(seg) ? seg : null;
|
||||
const locale =
|
||||
pathLocale ??
|
||||
resolvePreferredLocale(
|
||||
Astro.cookies.get(COOKIE_LANG)?.value,
|
||||
Astro.request.headers.get("accept-language"),
|
||||
);
|
||||
// Astro 全局 404:按 cookie / Accept-Language 选 locale(无 [locale] 段)
|
||||
const locale = resolvePreferredLocale(
|
||||
Astro.cookies.get(COOKIE_LANG)?.value,
|
||||
Astro.request.headers.get("accept-language"),
|
||||
);
|
||||
Astro.response.status = 404;
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
|
||||
import Danmaku from "~/views/danmaku.astro";
|
||||
|
||||
const raw = Astro.params["locale"];
|
||||
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
|
||||
|
|
@ -9,10 +10,10 @@ if (!normalized || !isLocale(normalized)) {
|
|||
Astro.cookies.get(COOKIE_LANG)?.value,
|
||||
Astro.request.headers.get("accept-language"),
|
||||
);
|
||||
return Astro.redirect(localePath(preferred, "/settings"));
|
||||
return Astro.redirect(localePath(preferred, "/danmaku"));
|
||||
}
|
||||
|
||||
const locale: Locale = normalized;
|
||||
// 弹幕设置已整合进 settings 页,旧地址保留重定向
|
||||
return Astro.redirect(localePath(locale, "/settings"));
|
||||
---
|
||||
|
||||
<Danmaku locale={locale} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
|
||||
import Dashboard from "~/views/dashboard.astro";
|
||||
|
||||
const raw = Astro.params["locale"];
|
||||
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
|
||||
|
|
@ -13,8 +14,6 @@ if (!normalized || !isLocale(normalized)) {
|
|||
}
|
||||
|
||||
const locale: Locale = normalized;
|
||||
|
||||
// /dashboard 是旧路由:登录态(middleware 已鉴权)在页面渲染前重定向到媒体库。
|
||||
// 必须在页面层 redirect —— 放在子组件里会与流式渲染冲突抛 ResponseSentError。
|
||||
return Astro.redirect(localePath(locale, "/library"));
|
||||
---
|
||||
|
||||
<Dashboard locale={locale} />
|
||||
|
|
|
|||
|
|
@ -1,16 +1,6 @@
|
|||
---
|
||||
import {
|
||||
COOKIE_LANG,
|
||||
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";
|
||||
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
|
||||
import AuthView from "~/views/auth.astro";
|
||||
|
||||
const raw = Astro.params["locale"];
|
||||
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
|
||||
|
|
@ -24,20 +14,6 @@ if (!normalized || !isLocale(normalized)) {
|
|||
}
|
||||
|
||||
const locale: Locale = normalized;
|
||||
const pageTitle = `${t(locale, "settings.title")} · ${t(locale, "meta.brand")}`;
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
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>
|
||||
<AuthView locale={locale} mode="change" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/** 认证后的 Range 流代理:鉴权在本路由,webdav 细节封装在 @app/trpc 的 openRemoteFileStream。 */
|
||||
import type { APIRoute } from "astro";
|
||||
import { COOKIE_SESSION } from "@app/types";
|
||||
import { authService, openRemoteFileStream, StreamSourceError } from "@app/trpc";
|
||||
import { authService, openRemoteFileStream } from "@app/trpc";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
|
|
@ -25,9 +25,6 @@ export const GET: APIRoute = async ({ request, cookies, url }) => {
|
|||
headers: result.headers,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof StreamSourceError) {
|
||||
return new Response(e.message, { status: e.status });
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : "stream failed";
|
||||
return new Response(msg, { status: 502 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ export const prerender = false;
|
|||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
// 内部转发到 tRPC handler(同进程内,避免跨网络)
|
||||
// tRPC v11 的 query 走 GET,input 放 query 参数
|
||||
const url = new URL(request.url);
|
||||
url.pathname = "/api/trpc/user.list";
|
||||
url.search = `?input=${encodeURIComponent(JSON.stringify({ limit: 20, offset: 0 }))}`;
|
||||
const req = new Request(url.toString(), { method: "GET" });
|
||||
const req = new Request(url.toString(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ json: { limit: 20, offset: 0 } }),
|
||||
});
|
||||
|
||||
// 调用同一个 Astro 进程的 tRPC endpoint
|
||||
const res = await fetch(req);
|
||||
|
|
|
|||
|
|
@ -15,16 +15,16 @@ function buildFor(locale: Locale): string {
|
|||
}
|
||||
|
||||
return buildLlmsFullTxt(siteSeo, {
|
||||
title: `${t(locale, "meta.brand")} · product guide`,
|
||||
title: `${t(locale, "meta.brand")} · scaffold index`,
|
||||
sections: [
|
||||
{
|
||||
heading: t(locale, "home.stackTitle"),
|
||||
path: "/zh-CN",
|
||||
summary: t(locale, "home.stackBody"),
|
||||
points: [
|
||||
"WebDAV / OpenList 挂载代理(凭据服务端加密)",
|
||||
"扫描入库 + Bangumi 刮削 + ArtPlayer 播放",
|
||||
"弹弹play 开放弹幕网络中继,本地 XML 兜底",
|
||||
"Astro 7 SSR + React 19 islands",
|
||||
"tRPC v11 + Drizzle ORM + Turso/libSQL",
|
||||
"Tailwind CSS 4 + Biome + Yarn 4 workspaces + Turbo",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -32,19 +32,19 @@ function buildFor(locale: Locale): string {
|
|||
path: "/zh-CN",
|
||||
summary: t(locale, "home.layerBody"),
|
||||
points: [
|
||||
"多用户:每人独立媒体库与播放进度",
|
||||
"统一左侧边栏导航:首页 / 媒体库 / 文件浏览 / 挂载管理 / 弹幕设置",
|
||||
"types → models → db → dao → trpc → web",
|
||||
"user / auth 为垂直示例,可替换为业务实体",
|
||||
],
|
||||
},
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
question: "如何接入 OpenList?",
|
||||
answer: "在挂载管理填写 OpenList 暴露的 WebDAV 地址(如 https://host/dav)与账号,测试连接后保存。",
|
||||
question: "这个模板包含业务逻辑吗?",
|
||||
answer: "不包含产品业务。仅保留鉴权与 user 垂直示例,用于演示分层接线;具体业务应在下游仓库添加。",
|
||||
},
|
||||
{
|
||||
question: "弹幕从哪里来?",
|
||||
answer: "优先走弹弹play 开放弹幕网络(需配置 AppId/AppSecret);失败或未配置时可在播放页导入本地 XML。",
|
||||
question: "如何新增业务实体?",
|
||||
answer: "按 user 切片扩展:@app/types 加 schema → @app/models 建表 → @app/dao 加查询 → @app/trpc 加 service/router → @app/web 挂页面。",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
@import "tailwindcss";
|
||||
@import "@app/design-tokens/tokens.css";
|
||||
|
||||
/* 扫描 monorepo 里的 UI 包,避免 dropdown 等组件类未生成 */
|
||||
@source "../../../../packages/ui/src";
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
|
|
@ -11,126 +8,6 @@
|
|||
border-color: var(--line);
|
||||
}
|
||||
|
||||
/* 原生控件跟品牌色,避免浏览器默认蓝 */
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
accent-color: var(--edge);
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
|
||||
linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 16px) calc(50% + 2px),
|
||||
calc(100% - 11px) calc(50% + 2px);
|
||||
background-size:
|
||||
5px 5px,
|
||||
5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--line);
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -5px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--edge);
|
||||
box-shadow: 0 1px 3px color-mix(in srgb, #000 30%, transparent);
|
||||
}
|
||||
input[type="range"]::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--line);
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--edge);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--edge);
|
||||
/* Chrome 部分主题下忽略 accent-color,强制品牌色勾选框 */
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--panel);
|
||||
display: inline-grid;
|
||||
place-content: center;
|
||||
}
|
||||
input[type="checkbox"]::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
transform: scale(0);
|
||||
transition: transform 0.12s ease;
|
||||
background: #101110;
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
input[type="checkbox"]:checked {
|
||||
background: var(--edge);
|
||||
border-color: var(--edge);
|
||||
}
|
||||
input[type="checkbox"]:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* 空态:居中卡片,避免大片死白 */
|
||||
.u-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 220px;
|
||||
padding: 2.5rem 1.5rem;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel) 55%, transparent);
|
||||
color: var(--ink-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
.u-empty__title {
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.u-empty__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
scroll-behavior: smooth;
|
||||
|
|
@ -186,17 +63,6 @@ select {
|
|||
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 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
|
|
@ -257,9 +123,7 @@ html.has-reveal .u-reveal.is-in {
|
|||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
/* island 内部不依赖 JS 加类:入场后直接显示,避免 hydration 冲突。
|
||||
两条规则需按源序覆盖上一条的初始隐藏态,故不做特异性排序。 */
|
||||
/* biome-ignore lint/style/noDescendingSpecificity: 源序覆盖是刻意的级联依赖 */
|
||||
/* island 内部不依赖 JS 加类:入场后直接显示,避免 hydration 冲突 */
|
||||
astro-island .u-reveal,
|
||||
html.has-reveal astro-island .u-reveal {
|
||||
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);
|
||||
}
|
||||
.u-linelink:hover,
|
||||
.u-linelink:focus-visible,
|
||||
.u-linelink.is-active {
|
||||
.u-linelink:focus-visible {
|
||||
color: var(--ink);
|
||||
}
|
||||
.u-linelink:hover::after,
|
||||
.u-linelink:focus-visible::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
.u-linelink.is-active::after {
|
||||
transform: scaleX(1);
|
||||
height: 2px;
|
||||
}
|
||||
.u-linelink .u-mono {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.u-linelink:hover .u-mono,
|
||||
.u-linelink.is-active .u-mono {
|
||||
.u-linelink:hover .u-mono {
|
||||
color: var(--edge-as-text);
|
||||
}
|
||||
|
||||
|
|
@ -444,8 +302,7 @@ html.has-reveal astro-island .u-reveal {
|
|||
|
||||
/* —— 卡片:5px 圆角 + 轻阴影 —— */
|
||||
.u-card {
|
||||
position: relative;
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--ink) 2%);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 流水线读出条 */
|
||||
.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 {
|
||||
display: none;
|
||||
|
|
@ -582,29 +424,20 @@ html.has-reveal astro-island .u-reveal {
|
|||
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:focus-visible {
|
||||
background: color-mix(in srgb, var(--edge) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--edge) 30%, var(--line));
|
||||
background: color-mix(in srgb, var(--edge) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--edge) 35%, var(--line));
|
||||
color: var(--ink);
|
||||
}
|
||||
.side-rail__link.is-active {
|
||||
background: color-mix(in srgb, var(--edge) 14%, var(--sink));
|
||||
border-color: color-mix(in srgb, var(--edge) 45%, var(--line));
|
||||
color: var(--ink);
|
||||
box-shadow: inset 2px 0 0 var(--edge);
|
||||
}
|
||||
.side-rail__link:hover .u-mono,
|
||||
.side-rail__link:focus-visible .u-mono {
|
||||
.side-rail__link:hover .u-mono {
|
||||
color: var(--edge-as-text);
|
||||
}
|
||||
.side-rail__link.is-active .u-mono {
|
||||
color: var(--edge-as-text);
|
||||
}
|
||||
.light .side-rail__link.is-active {
|
||||
background: color-mix(in srgb, var(--edge) 12%, var(--panel));
|
||||
box-shadow: inset 2px 0 0 var(--edge-deep);
|
||||
}
|
||||
.side-rail__foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -638,12 +471,18 @@ html.has-reveal astro-island .u-reveal {
|
|||
padding-left: 8.5rem;
|
||||
padding-right: 3.5rem;
|
||||
}
|
||||
.site-shell--no-rail {
|
||||
padding-left: 3.5rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.site-shell {
|
||||
padding-left: 10.5rem;
|
||||
padding-right: 5rem;
|
||||
}
|
||||
.site-shell--no-rail {
|
||||
padding-left: 5rem;
|
||||
}
|
||||
}
|
||||
.site-shell--page {
|
||||
height: 100dvh;
|
||||
|
|
@ -702,25 +541,18 @@ html.has-reveal astro-island .u-reveal {
|
|||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
/* 桌面已有侧栏品牌,顶栏品牌隐藏(Tailwind lg:hidden 会被上面的 display:flex 压掉) */
|
||||
@media (min-width: 1024px) {
|
||||
.site-header__brand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.site-header__nav {
|
||||
display: flex;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 1.35rem;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
min-width: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.site-header__nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
@media (min-width: 768px) {
|
||||
.site-header__nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
/* 桌面已有侧栏时,顶栏隐藏重复分类导航,仅保留品牌/操作 */
|
||||
/* 桌面已有侧栏时,顶栏可隐藏重复分类导航,仅保留品牌/操作 */
|
||||
@media (min-width: 1024px) {
|
||||
.site-header__nav {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import { type Locale, t } from "@app/i18n";
|
||||
import AuthBody from "~/components/pages/AuthBody";
|
||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||
import { Toaster } from "@app/ui";
|
||||
import AuthForm from "~/components/AuthForm";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
export interface Props {
|
||||
|
|
@ -11,6 +12,7 @@ export interface Props {
|
|||
}
|
||||
|
||||
const { locale, mode, next, token } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
|
||||
const titleKey =
|
||||
mode === "login"
|
||||
|
|
@ -22,14 +24,18 @@ const titleKey =
|
|||
: mode === "reset"
|
||||
? "auth.reset.title"
|
||||
: "auth.settings.title";
|
||||
|
||||
const pageTitle = `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`;
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={pageTitle}
|
||||
title={`${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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 AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -8,18 +10,32 @@ export interface Props {
|
|||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const labels: Record<string, string> = {
|
||||
"meta.brand": t(locale, "meta.brand"),
|
||||
"nav.library": t(locale, "nav.library"),
|
||||
"nav.browse": t(locale, "nav.browse"),
|
||||
"nav.mounts": t(locale, "nav.mounts"),
|
||||
"nav.danmaku": t(locale, "nav.danmaku"),
|
||||
};
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={t(locale, "browse.title")}
|
||||
locale={locale}
|
||||
active="browse"
|
||||
brand={t(locale, "meta.product")}
|
||||
nav={[]}
|
||||
hideSideRail
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
seo={{
|
||||
title: `${t(locale, "browse.title")} · ${t(locale, "meta.brand")}`,
|
||||
path: Astro.url.pathname,
|
||||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<BrowseBody locale={locale} client:load />
|
||||
<AppShell locale={locale} active="browse" labels={labels}>
|
||||
<BrowseBody locale={locale} client:load />
|
||||
</AppShell>
|
||||
<Toaster client:load />
|
||||
</AppLayout>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import { type Locale, t } from "@app/i18n";
|
||||
import ErrorBody from "~/components/pages/ErrorBody";
|
||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||
import { ErrorView } from "~/components/errors/ErrorView";
|
||||
import { createAppError, type AppErrorStatus } from "~/lib/errors";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -10,7 +10,9 @@ export interface Props {
|
|||
}
|
||||
|
||||
const { locale, status } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const error = createAppError(status);
|
||||
const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
|
||||
const titleKey = `error.${error.kind}.title` as never;
|
||||
const pageTitle = t(locale, titleKey);
|
||||
---
|
||||
|
|
@ -18,11 +20,16 @@ const pageTitle = t(locale, titleKey);
|
|||
<AppLayout
|
||||
title={`${pageTitle} · ${t(locale, "meta.brand")}`}
|
||||
locale={locale}
|
||||
nav={[]}
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
seo={{
|
||||
title: `${pageTitle} · ${t(locale, "meta.brand")}`,
|
||||
path: Astro.url.pathname,
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
import { type Locale, t } from "@app/i18n";
|
||||
import HomeBody from "~/components/pages/HomeBody";
|
||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||
import AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
export interface Props {
|
||||
|
|
@ -8,12 +7,49 @@ export interface 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
|
||||
title={t(locale, "home.title")}
|
||||
title={tr("home.title")}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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 AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -8,18 +10,31 @@ export interface Props {
|
|||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const labels: Record<string, string> = {
|
||||
"meta.brand": t(locale, "meta.brand"),
|
||||
"nav.library": t(locale, "nav.library"),
|
||||
"nav.browse": t(locale, "nav.browse"),
|
||||
"nav.mounts": t(locale, "nav.mounts"),
|
||||
"nav.danmaku": t(locale, "nav.danmaku"),
|
||||
};
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={t(locale, "library.title")}
|
||||
locale={locale}
|
||||
active="library"
|
||||
brand={t(locale, "meta.product")}
|
||||
nav={[]}
|
||||
hideSideRail
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
seo={{
|
||||
title: `${t(locale, "library.title")} · ${t(locale, "meta.brand")}`,
|
||||
path: Astro.url.pathname,
|
||||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<LibraryBody locale={locale} client:load />
|
||||
<AppShell locale={locale} active="library" labels={labels}>
|
||||
<LibraryBody locale={locale} client:load />
|
||||
</AppShell>
|
||||
<Toaster client:load />
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -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 AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -8,18 +10,31 @@ export interface Props {
|
|||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const labels: Record<string, string> = {
|
||||
"meta.brand": t(locale, "meta.brand"),
|
||||
"nav.library": t(locale, "nav.library"),
|
||||
"nav.browse": t(locale, "nav.browse"),
|
||||
"nav.mounts": t(locale, "nav.mounts"),
|
||||
"nav.danmaku": t(locale, "nav.danmaku"),
|
||||
};
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={t(locale, "mounts.title")}
|
||||
locale={locale}
|
||||
active="mounts"
|
||||
brand={t(locale, "meta.product")}
|
||||
nav={[]}
|
||||
hideSideRail
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
seo={{
|
||||
title: `${t(locale, "mounts.title")} · ${t(locale, "meta.brand")}`,
|
||||
path: Astro.url.pathname,
|
||||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<MountsBody locale={locale} client:load />
|
||||
<AppShell locale={locale} active="mounts" labels={labels}>
|
||||
<MountsBody locale={locale} client:load />
|
||||
</AppShell>
|
||||
<Toaster client:load />
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -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 AppLayout from "~/layouts/AppLayout.astro";
|
||||
|
||||
|
|
@ -9,18 +11,31 @@ export interface Props {
|
|||
}
|
||||
|
||||
const { locale, mediaItemId } = Astro.props;
|
||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||
const labels: Record<string, string> = {
|
||||
"meta.brand": t(locale, "meta.brand"),
|
||||
"nav.library": t(locale, "nav.library"),
|
||||
"nav.browse": t(locale, "nav.browse"),
|
||||
"nav.mounts": t(locale, "nav.mounts"),
|
||||
"nav.danmaku": t(locale, "nav.danmaku"),
|
||||
};
|
||||
---
|
||||
|
||||
<AppLayout
|
||||
title={t(locale, "watch.title")}
|
||||
locale={locale}
|
||||
brand={t(locale, "meta.product")}
|
||||
hideFooter
|
||||
nav={[]}
|
||||
hideSideRail
|
||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||
switchLabel={t(other, `locale.${other}` as never)}
|
||||
seo={{
|
||||
title: `${t(locale, "watch.title")} · ${t(locale, "meta.brand")}`,
|
||||
path: Astro.url.pathname,
|
||||
robots: ["noindex", "nofollow"],
|
||||
}}
|
||||
>
|
||||
<WatchBody locale={locale} mediaItemId={mediaItemId} client:load />
|
||||
<AppShell locale={locale} active="watch" labels={labels}>
|
||||
<WatchBody locale={locale} mediaItemId={mediaItemId} client:load />
|
||||
</AppShell>
|
||||
<Toaster client:load />
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 619 KiB |
|
Before Width: | Height: | Size: 665 KiB |
|
|
@ -35,8 +35,6 @@
|
|||
"includes": [
|
||||
"**",
|
||||
"!**/storybook-static",
|
||||
"!**/.vitepress/cache",
|
||||
"!**/.vitepress/dist",
|
||||
"!**/dist",
|
||||
"!**/.turbo",
|
||||
"!**/node_modules",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> **面向 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 代理。
|
||||
|
||||
|
|
@ -1848,7 +1848,7 @@ export function AppShell(props: {
|
|||
<div className="flex min-h-screen bg-background text-foreground">
|
||||
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4">
|
||||
<div className="mb-6 px-2 text-sm font-semibold tracking-wide">
|
||||
{props.labels["meta.brand"] ?? "帆幕"}
|
||||
{props.labels["meta.brand"] ?? "弹弹 Web"}
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1" aria-label="sidebar">
|
||||
{NAV.map((item) => (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
- **日期**:2026-09-25
|
||||
- **状态**:已获设计批准,待实现计划(writing-plans)
|
||||
- **仓库**:本产品仓(按**产品仓**维护,业务进入现有分层)
|
||||
- **仓库**:`app-template`(按**产品仓**维护,业务进入现有分层)
|
||||
|
||||
## 1. 背景与调研结论
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| 验收口径 | 真实链路可跑通 |
|
||||
| 范围 | 协议/数据兼容 + 核心四块全选 |
|
||||
|
|
@ -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` 配置)。
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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("私有影音库");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "fanmu",
|
||||
"name": "app-template",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
|
|
@ -16,12 +16,7 @@
|
|||
"db:generate": "turbo run db:generate",
|
||||
"db:push": "turbo run db:push",
|
||||
"db:studio": "turbo run db:studio",
|
||||
"db:seed": "yarn workspace @app/db seed",
|
||||
"docs:dev": "yarn workspace @app/docs dev",
|
||||
"docs:build": "yarn workspace @app/docs build",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "turbo run lint",
|
||||
"clean": "turbo run clean",
|
||||
"format": "turbo run format",
|
||||
|
|
@ -38,7 +33,6 @@
|
|||
"@commitlint/cli": "^21.2.3",
|
||||
"@commitlint/config-conventional": "^21.2.3",
|
||||
"@emnapi/runtime": "^1.11.3",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@storybook/addon-a11y": "^9.1.10",
|
||||
"@storybook/addon-docs": "^9.1.10",
|
||||
"@storybook/react": "^9.1.10",
|
||||
|
|
|
|||