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

212 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 上下滑、点赞/收藏/评论、语言切换、移动端侧栏、上传弹窗。
- 目录符合本文第二节,跨层依赖检查无违规。