refactor: 企业级目录重构与播放/关于页体验升级

- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件
- 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分
- 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈
- 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口
- 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题
- 语言按本地偏好与 IP 识别,国旗改为内联 SVG
- 清理未使用的旧站点文件,补充 ESLint 配置
This commit is contained in:
Zhou Weidong committed 2026-11-12 12:32:29 +08:00
1 parent 8dd5283892
commit ecfbd3b8b5
90 files changed
+4535 -886

No files matched your search

@@ -0,0 +1,211 @@
# YMB Site 企业级重构方案
> 目标:在不改变现有视觉与交互的前提下,把「演示工程」重构成可按业务域扩展的前端工程。
> 约束:纯重构,功能与文案不变;每个阶段结束必须 `pnpm build` + `pnpm lint` 通过;剧集 ID 保持字符串(19 位长整型)。
## 一、现状诊断
### 1. 目录没有业务边界
当前 `src/` 按技术类型平铺:`pages/`、`components/`、`services/`、`hooks/`、`lib/`、`data/`。
- 首页、Shorts、播放页、搜索页共享同一批 `services` 与 `components`,无法知道改一个页面会影响谁。
- 页面与接口调用直接耦合:`HomePage`、`SearchPage`、`ListingPage` 各自写一遍 `normalizePage(...).list.map(normalizeDrama)`。
- `data/catalog.js` 是演示兜底数据,和真实 API 层混在同一层级。
### 2. 请求层只有 24 行,没有封装
`src/services/httpClient.js` 现状:
- header、超时、JSON 解析、业务码判断全部内联在一个 `request()` 里;
- 错误统一 `throw new Error(payload.message)`,丢失 HTTP 状态、业务码、请求路径,超时/断网/业务失败无法区分;
- 没有拦截器链,后续加 token、埋点、错误上报只能继续往里堆 if;
- `appApi.js` 用字符串拼 query(`?pageIndex=1&pageSize=20`),16 个端点挤在一个对象里。
### 3. 数据与状态层薄弱
- `useApi` 只有 `active` 标志,没有 `AbortController`、没有 `refetch`、没有重试;错误只有一个 `message` 字符串。
- 点赞/收藏在 `ShortsFeedPage` 和 `WatchPage` 各写了一遍(含 try/catch + console.error),没有 mutation 抽象。
- `store.jsx` 把 locale / 搜索词 / 侧栏状态塞进一个 Context,搜索框每次输入都会让整棵树重渲染。
- `normalizers.js` 同时承担媒体 URL 解析、分页解包、播放进度模型,职责混杂。
### 4. 组件没有抽离
| 现状 | 问题 |
| --- | --- |
| `layouts/AppShell.jsx` 134 行 | 顶栏 + 搜索 + 创建菜单 + 侧栏 + 底部导航 + 上传弹窗状态全在一个组件 |
| `pages/ShortsFeedPage.jsx` 237 行 | `ShortSlide`、点赞/收藏、分页游标、滚动监听、键盘事件混在一起 |
| `pages/WatchPage.jsx` 235 行 | 播放源、滑动手势、剧集列表、统计、评论、分享混在一起 |
| 各页面 | loading / error / empty 三态重复手写 |
| `components/Skeletons.jsx` 104 行 | 6 个骨架屏塞在一个文件 |
### 5. 样式与路由
- `src/styles.css`(旧版全站样式,含 `@font-face`)、`enterprise.css`、`youtube-overrides.css` 三层叠加,没有 tokens 与层级约定。
- `src/app/App.jsx` 单行全量 import,无懒加载;构建主 chunk 907KB,Vite 已提示超限。
## 二、目标目录结构
```text
src/
├── app/ # 应用装配层
│ ├── App.jsx # 只做 Provider + Router 组装
│ ├── router.jsx # 路由表(React.lazy + Suspense)
│ └── providers/
│ ├── AppProviders.jsx
│ ├── LocaleProvider.jsx # 语言 + SEO
│ └── UiProvider.jsx # 侧栏 / 搜索词 / toast
├── shared/ # 与业务无关的通用能力
│ ├── api/
│ │ ├── config.js # baseURL、超时、平台常量
│ │ ├── errors.js # ApiError / TimeoutError / NetworkError
│ │ ├── client.js # fetch 核心 + 拦截器管线
│ │ ├── interceptors.js # 设备头 / JSON / 业务码 / 错误归一
│ │ └── index.js
│ ├── hooks/
│ │ ├── useRequest.js # 查询:取消、重试、refetch
│ │ ├── useMutation.js # 写操作:pending、错误、乐观更新
│ │ └── useToast.js
│ ├── components/
│ │ ├── AsyncBoundary.jsx # 统一 loading / error / empty / retry
│ │ ├── VideoGrid.jsx
│ │ ├── EmptyState.jsx
│ │ ├── ErrorState.jsx
│ │ ├── Toast.jsx
│ │ └── skeletons/index.jsx
│ ├── lib/ # format / seo / media
│ └── styles/ # tokens / base / layout / 组件样式
├── entities/ # 领域模型与数据转换
│ └── drama/
│ ├── model.js # normalizeDrama / normalizeProgress
│ └── api.js # 剧集、分集、播放、统计
├── features/ # 业务域(页面 + 域内组件 + 域内 hook)
│ ├── home/ HomePage.jsx / CategoryBar.jsx / useHomeFeed.js
│ ├── shorts/ ShortsFeedPage.jsx / ShortSlide.jsx / useShortsFeed.js
│ ├── watch/ WatchPage.jsx / EpisodeRail.jsx / useEpisodePlayback.js
│ ├── search/ SearchPage.jsx / useSearchResults.js
│ ├── history/ HistoryPage.jsx
│ ├── listing/ ListingPage.jsx
│ └── about/ AboutPage.jsx + i18n/about.js
├── layouts/
│ ├── AppShell.jsx # 只做骨架拼装
│ └── components/
│ ├── TopBar.jsx
│ ├── SearchBox.jsx
│ ├── CreateMenu.jsx
│ ├── Sidebar.jsx
│ └── BottomNav.jsx
├── i18n/
│ ├── index.js # 聚合 + getLocale
│ └── locales/{id,en,zh}.js
└── main.jsx
```
依赖方向固定为:`features → entities → shared`,`layouts → shared`;禁止 `shared` 反向引用 `features`,禁止页面直接调用 `fetch`。
## 三、请求层设计(重点)
### 1. 统一错误类型
```js
// shared/api/errors.js
export class ApiError extends Error {
constructor(message, { status = 0, code = null, path = '', cause } = {}) { ... }
}
export class TimeoutError extends ApiError {}
export class NetworkError extends ApiError {}
```
页面只判断 `error instanceof TimeoutError` / `error.status`,不再解析字符串。
### 2. 拦截器管线
```js
// shared/api/client.js
const client = createHttpClient({ baseURL, timeout: 12000 });
client.useRequest((config) => ({
...config,
headers: { 'X-Device-Id': getDeviceId(), 'X-Client-Platform': 'web', ...config.headers },
}));
client.useResponse((response) => response); // 预留:解包、埋点
client.useError((error) => { /* 归一化 + 401 处理入口 */ throw error; });
client.get('/app/home/feed');
client.post('/app/likes/toggle', { targetType, targetId });
```
- 请求拦截器:设备 ID、平台头、Content-Type、语言头(预留)。
- 响应拦截器:`code === 0/200` 时解包 `data`;否则抛 `ApiError(code, message)`。
- 错误拦截器:`AbortError` → 取消(静默);超时 → `TimeoutError`;`fetch` 异常 → `NetworkError`;HTTP 非 2xx → `ApiError(status)`。
- 所有查询共用 `AbortSignal`,组件卸载即取消,避免切页后 setState。
### 3. 端点按域拆分
```text
entities/drama/api.js getDrama / getEpisodes / getPlayback / getStats
features/home/api.js getHomeFeed / getCategories / getCategoryDramas
features/search/api.js search
features/history/api.js getRecent / clearHistory / reportProgress
features/shared? likes / favorites / comments → shared/api/engagement.js
```
`src/services/appApi.js` 在迁移期保留为兼容门面,阶段 6 删除。
## 四、组件抽离清单
| 抽离项 | 来源 | 去向 |
| --- | --- | --- |
| `TopBar` / `SearchBox` / `CreateMenu` / `Sidebar` / `BottomNav` | `AppShell.jsx` | `layouts/components/` |
| `ShortSlide` | `ShortsFeedPage.jsx` 内的私有组件 | `features/shorts/components/ShortSlide.jsx` |
| `EpisodeRail` | `WatchPage.jsx` 右侧剧集列表 | `features/watch/components/EpisodeRail.jsx` |
| `AsyncBoundary` | 4 个页面重复的三态 | `shared/components/AsyncBoundary.jsx` |
| `VideoGrid` | `HomePage` / `ListingPage` / `SearchPage` 重复网格 | `shared/components/VideoGrid.jsx` |
| `EmptyState` / `ErrorState` | 各页面 `api-note` / `search-empty` | `shared/components/` |
| `Toast` | Shorts / Watch 各自实现 | `shared/components/Toast.jsx` + `useToast` |
| 骨架屏 | `Skeletons.jsx` | `shared/components/skeletons/`(按页面一个文件 + index 聚合) |
| 点赞/收藏交互 | Shorts 与 Watch 重复 | `shared/hooks/useEngagement.js` |
`AsyncBoundary` 使用方式:
```jsx
<AsyncBoundary
loading={request.loading}
error={request.error}
empty={!items.length}
skeleton={<VideoGridSkeleton />}
emptyText={locale.empty}
onRetry={request.refetch}
>
<VideoGrid items={items} />
</AsyncBoundary>
```
## 五、分阶段实施
| 阶段 | 内容 | 验收 |
| --- | --- | --- |
| P0 | 记录基线:`pnpm build` / `pnpm lint` 结果,页面清单截图 | 基线存档 |
| P1 | `shared/api`:错误类型、client、拦截器、config;`appApi` 改为基于 client | build + lint,接口行为不变 |
| P2 | `entities/drama`:模型归一化、分页、媒体 URL 收敛;页面改用 | 首页 / 列表 / 搜索数据一致 |
| P3 | `useRequest` / `useMutation` / `useToast` / `useEngagement`;替换 `useApi` 与重复逻辑 | 点赞、收藏、评论、上传弹窗回归 |
| P4 | 拆 `AppShell` 为 TopBar/SearchBox/CreateMenu/Sidebar/BottomNav;路由懒加载 | 侧栏收起、移动端抽屉、搜索跳转回归 |
| P5 | 页面迁入 `features/`,抽 ShortSlide、EpisodeRail、VideoGrid、AsyncBoundary、骨架屏 | 首页 / Shorts / 播放 / 搜索 / 历史 / 关于全量回归 |
| P6 | 样式分层(tokens/base/legacy 分离),删除旧兼容层与 `services/appApi.js` | 视觉 diff 无变化,build + lint 通过 |
每个阶段独立提交,提交信息遵循仓库现有 `feat:` / `refactor:` 风格。
## 六、风险与对策
1. **视觉回归**:P4/P5 只调整结构,CSS 类名与覆盖顺序保持不变;阶段结束对照截图。
2. **Shorts 滑动性能**:`ShortsFeedPage` 的 IntersectionObserver、scroll-snap、键盘/滚轮逻辑整体迁移,不重写。
3. **ID 精度**:所有 episode/drama ID 继续保持 `String()`,禁止 `Number()`;在 `entities/drama/model.js` 注释固化。
4. **Context 拆分**:拆 locale 与 ui 后,确认切语言仍能刷新全站文案、搜索输入不再触发页面重渲染。
5. **接口兼容**:P1/P2 期间 `appApi` 门面保留,页面可逐步迁移,避免一次性大爆炸。
## 七、验收标准
- `pnpm build` 通过,主包因路由懒加载下降(当前 907KB)。
- `pnpm lint` 0 error。
- 路由回归:`/`、`/shorts`、`/watch/:id`、`/search`、`/history`、`/subscriptions`、`/library`、`/about`。
- 交互回归:上下滑切集、Shorts 上下滑、点赞/收藏/评论、语言切换、移动端侧栏、上传弹窗。
- 目录符合本文第二节,跨层依赖检查无违规。