feat: add telegram-style resource stream
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
---
|
||||
title: "Telegram-style Resource Stream — Implementation Plan"
|
||||
type: plan
|
||||
date: 2026-05-25
|
||||
workbranch: feat/telegram-stream
|
||||
specs:
|
||||
- .unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md
|
||||
---
|
||||
|
||||
# Telegram-style Resource Stream — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
实现 `/browse` 与 `/category/:slug` 的 Telegram-style 消息流重构。前端先用 mock data 完成全部视觉与交互,等后端 `/api/posts` 系列接口 ready 后切换。同时收尾删除收藏功能与 `ResourceDetail` 详情页。
|
||||
|
||||
分支:`feat/telegram-stream`(在当前目录新建,不走 worktree)。完成后由 Terry 显式确认才 merge。
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
Task 1 (基础类型 + mock + utils)
|
||||
↓
|
||||
Task 2 (hooks) ─────────────────┐
|
||||
│
|
||||
Task 3 (overlays) ─────┐ │
|
||||
↓ │
|
||||
Task 4 (bubbles)
|
||||
↓
|
||||
Task 5 (stream 容器)
|
||||
↓
|
||||
Task 6 (页面改写)
|
||||
|
||||
Task 7 (清理收藏 / 详情页) ── 可与 1-5 并行,但合并到 Task 6 之前完成
|
||||
Task 8 (验证 + 文档 + API 契约) ── 最后
|
||||
```
|
||||
|
||||
依赖关键路径:1 → 2/3 → 4 → 5 → 6 → 8。Task 7 独立,建议早做以减少 imports 残留。
|
||||
|
||||
## Tasks
|
||||
|
||||
- unstarted: Task 0 — 创建分支
|
||||
- Description: 在当前目录创建并切到 `feat/telegram-stream` 分支
|
||||
- Dependencies: 无
|
||||
- Acceptance Criteria: `git branch --show-current` 输出 `feat/telegram-stream`;`git status` 干净
|
||||
- Steps:
|
||||
1. `git status --short --branch` 确认无未提交改动
|
||||
2. `git checkout -b feat/telegram-stream`
|
||||
3. 确认 `.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md` 与 `.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md` 已在该分支
|
||||
|
||||
- unstarted: Task 1 — 类型定义 + Mock data + 纯函数 utils
|
||||
- Description: 建立 Post / Attachment 类型,写覆盖 7 种 bubble 的 mock 样本,写 3 个无依赖纯函数 util
|
||||
- Dependencies: Task 0
|
||||
- Acceptance Criteria:
|
||||
- `src/types/post.ts` 导出 `Post`、`Attachment`、`PostListResponse`、`PostScope`
|
||||
- `src/mocks/mockPosts.ts` 至少 12 条 Post 覆盖:2 图片当文档 + 2 PDF/AI + 2 纯文本+链接 + 1 视频 + 2 单图 + 1 图+文 + 1 三图相册 + 1 七图相册;publishedAt 跨 ≥3 个不同日期
|
||||
- `formatBytes(3549239)` → `"3.4 MB"`(unit tests pass)
|
||||
- `autolink("点 https://x.com 看")` 返回 React 节点数组,链接处为 `<a target="_blank" rel="noopener noreferrer">`(unit tests pass)
|
||||
- `fileIcon({ mime, filename })` 返回 `{ Icon, color }`,PDF 红、AI 橙、PPT 红、图片走 thumbnail 不返回 Icon
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `src/types/post.ts`
|
||||
2. 写 `src/components/messageStream/utils/formatBytes.ts` + `.test.ts`
|
||||
3. 写 `src/components/messageStream/utils/autolink.tsx` + `.test.tsx`
|
||||
4. 写 `src/components/messageStream/utils/fileIcon.ts`
|
||||
5. 写 `src/mocks/mockPosts.ts`(图片用 picsum.photos 占位,视频用公开 sample mp4 + 一张占位 poster)
|
||||
6. 跑 `npm test` 与 `npx tsc --noEmit`
|
||||
|
||||
- unstarted: Task 2 — Stream hooks(usePostStream + useGroupedByDay)
|
||||
- Description: 数据层抽象,mock/real 双模式 + 按日期分组
|
||||
- Dependencies: Task 1
|
||||
- Acceptance Criteria:
|
||||
- `usePostStream({ scope, type, language })` 在 `VITE_USE_MOCK_POSTS !== "false"` 时从 `MOCK_POSTS` 过滤 + 倒序 + cursor 切片(每页 20)+ 200ms 假延迟
|
||||
- 真接口分支调用 `getJSON</api/posts?...>`(占位即可,后端未 ready 时不会走到)
|
||||
- 返回 `{ items, isLoading, error, hasMore, loadMore, reset }`
|
||||
- `useGroupedByDay(posts, lang)` 返回 `Array<{ dayLabel: string; items: Post[] }>`,按本地时区日期分组,dayLabel 通过 `Intl.DateTimeFormat` 按 lang 切换(zh-TW/zh-CN/en)
|
||||
- 单元测试:useGroupedByDay 在跨天数据上分出正确组数
|
||||
- Steps:
|
||||
1. 写 `src/components/messageStream/hooks/usePostStream.ts`
|
||||
2. 写 `src/components/messageStream/hooks/useGroupedByDay.ts` + `.test.ts`
|
||||
3. 在 `.env.example` 加 `VITE_USE_MOCK_POSTS=true` 注释
|
||||
4. 跑 `npm test` 与 `npx tsc --noEmit`
|
||||
|
||||
- unstarted: Task 3 — Overlay 基础设施(ImageLightbox + VideoPlayer)
|
||||
- Description: 全屏画廊与视频播放器,portal 渲染,单一入口 context
|
||||
- Dependencies: Task 1
|
||||
- Acceptance Criteria:
|
||||
- `<ImageLightboxProvider>` 包在 App 根,暴露 `useLightbox()` → `openLightbox(images, startIndex)`
|
||||
- `ImageLightbox` 支持:左右切换(按钮 + 键盘 ← → + 触屏左右滑)、ESC/点遮罩关闭、右上角下载按钮
|
||||
- `VideoPlayer` 支持:全屏遮罩 + `<video controls autoPlay>`、接 `currentTime` 参数避免重新加载、ESC/点遮罩关闭
|
||||
- 两个 overlay 在手机端不溢出、不被底部 nav 遮挡
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `src/components/messageStream/overlays/ImageLightbox.tsx` + Provider/Context
|
||||
2. 写 `src/components/messageStream/overlays/VideoPlayer.tsx`
|
||||
3. 在 `App.tsx` / 根布局挂 Provider
|
||||
4. 手动验证:在 dev console 临时调用 `openLightbox` 看是否正确呈现
|
||||
|
||||
- unstarted: Task 4 — Bubble 子组件(6 个 + 分发器)
|
||||
- Description: 按截图实现 6 种气泡 + `MessageBubble` 分发
|
||||
- Dependencies: Task 1, Task 3
|
||||
- Acceptance Criteria:
|
||||
- `FileDocBubble` 处理 `kind: "document"`:
|
||||
- 若 `mime.startsWith("image/")` → 左侧用 `thumbnailUrl` 缩略 + ↓ 覆盖;右侧 filename.ext + size(截图 1)
|
||||
- 否则 → 左侧蓝圆 ↓ 图标(按 mime 取色)+ 右侧 filename + size(截图 2)
|
||||
- `TextBubble` 渲染 `text`,调 `autolink`(截图 3)
|
||||
- `VideoBubble` 初始显示 posterUrl + ▶️ + 时长,第一次点 inline `<video controls autoPlay>`,第二次点(已播放)调 `openVideoPlayer`(截图 4)
|
||||
- `ImageBubble` 单张图,点击调 `openLightbox([att], 0)`(截图 5)
|
||||
- `ImageWithTextBubble` 单图 + 下方文本(autolink)(截图 6)
|
||||
- `AlbumBubble` 2-4 格 grid,间距 2px;attachments.length > 4 时第 4 格 `bg-black/45 backdrop-blur-sm` 覆盖 `+N`;点任一格调 `openLightbox(images, index)`(截图 7)
|
||||
- `MessageBubble` 实现 spec §4 的 `pickBubble` 分发,右下角绝对定位时间戳 `text-[11px] text-neutral-500`
|
||||
- 所有 bubble 容器:`rounded-2xl bg-ark-panel p-3`(文本气泡 `px-4 py-2.5`),左对齐,无头像
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `MessageBubble.tsx`(含 pickBubble)
|
||||
2. 写 `bubbles/TextBubble.tsx`
|
||||
3. 写 `bubbles/FileDocBubble.tsx`
|
||||
4. 写 `bubbles/ImageBubble.tsx`
|
||||
5. 写 `bubbles/ImageWithTextBubble.tsx`
|
||||
6. 写 `bubbles/AlbumBubble.tsx`
|
||||
7. 写 `bubbles/VideoBubble.tsx`
|
||||
8. 在 dev 中临时挂一个 demo route 跑 MOCK_POSTS 全量渲染,目视检查 7 张截图对照
|
||||
|
||||
- unstarted: Task 5 — Stream 容器 + FilterChips + DaySeparator
|
||||
- Description: 顶层组件接管 fetch、分组、无限滚动、sticky 筛选
|
||||
- Dependencies: Task 2, Task 4
|
||||
- Acceptance Criteria:
|
||||
- `FilterChips`:sticky top,横向滚动 `overflow-x-auto whitespace-nowrap`,类型 chips(all/image/video/ppt/pdf/text/link/archive,沿用 `typeFilterLabel`)+ 语言 chips(all/zh-TW/zh-CN/en),改变时 reset cursor 并同步 URL `?type=&language=`
|
||||
- `DaySeparator`:胶囊样式,居中
|
||||
- `MessageStream`:
|
||||
- 接 `scope: { kind: "all" } | { kind: "category", slug: string }`
|
||||
- 调用 `usePostStream` + `useGroupedByDay`
|
||||
- 用 `IntersectionObserver` 监听底部 sentinel,触发 `loadMore`(loadingRef 守护避免重复)
|
||||
- 容器:手机 `max-w-full px-3 mx-auto`,md+ `max-w-[640px] mx-auto`
|
||||
- 空状态用 `t("noResults")`,错误用红色 inline 横幅 + 重试按钮
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `FilterChips.tsx`
|
||||
2. 写 `DaySeparator.tsx`
|
||||
3. 写 `MessageStream.tsx`
|
||||
4. 在 dev demo route 挂 `<MessageStream scope={{ kind: "all" }} />` 验证滚动 + 筛选 + 分组
|
||||
|
||||
- unstarted: Task 6 — 重写 CategoryPage 与 Browse
|
||||
- Description: 两个页面瘦身为单一组件调用
|
||||
- Dependencies: Task 5, Task 7
|
||||
- Acceptance Criteria:
|
||||
- `src/pages/CategoryPage.tsx`:仅渲染分类标题 + `<MessageStream scope={{ kind: "category", slug }} />`,行数 < 30
|
||||
- `src/pages/Browse.tsx`:仅渲染页面标题 + `<MessageStream scope={{ kind: "all" }} />`,行数 < 30;不再读 `sort` / `tag` / `page` 参数
|
||||
- 排序 tabs 全部去掉
|
||||
- 移除对 `ResourceCard` / `ResourceListFooter` 的 import
|
||||
- `App.tsx` 路由保持 `/browse` 与 `/category/:slug` 不变
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 改写 `CategoryPage.tsx`
|
||||
2. 改写 `Browse.tsx`
|
||||
3. 跑 dev server 在 `/browse` 与 `/category/<slug>` 验证
|
||||
|
||||
- unstarted: Task 7 — 移除收藏功能 + ResourceDetail
|
||||
- Description: 整套清理
|
||||
- Dependencies: Task 0(可与 Task 1-5 并行)
|
||||
- Acceptance Criteria:
|
||||
- 删除文件:`src/pages/FavoritesPage.tsx`、`src/pages/ResourceDetail.tsx`、`src/components/ResourceCard.tsx`、`src/components/ResourceListFooter.tsx`
|
||||
- `App.tsx` 移除 `/favorites` 路由;`/r/:id` 改为新组件 `PostRedirect`(mock 模式下从 `MOCK_POSTS` 找 slug;找不到 → navigate `/browse`)
|
||||
- `src/api.ts` 移除 `postFavoriteDelta`
|
||||
- 全代码无 `postFavoriteDelta` / `FavoritesPage` / `ResourceDetail` / `ResourceCard` / `ResourceListFooter` 引用
|
||||
- Home 中的 `/favorites` 入口(如有)移除
|
||||
- `src/i18n.tsx` 移除 `favorites` / `addFavorite` / `removeFavorite` 等收藏 key(三语言同步)
|
||||
- `npx tsc --noEmit` 通过(无 unused / 未引用错误)
|
||||
- Steps:
|
||||
1. `grep -rn "postFavoriteDelta\|FavoritesPage\|ResourceDetail\|ResourceCard\|ResourceListFooter\|/favorites\|/r/:id" src/` 列出所有引用点
|
||||
2. 写 `src/pages/PostRedirect.tsx`,挂到 `/r/:id` 路由
|
||||
3. 删除 4 个文件 + 修改 `App.tsx` + `api.ts` + `i18n.tsx`
|
||||
4. 再 grep 一次确认清零
|
||||
5. `npx tsc --noEmit`
|
||||
|
||||
- unstarted: Task 8 — 验证 + 文档 + API 契约抽出
|
||||
- Description: 全量验证 + 给后端的接口文档
|
||||
- Dependencies: Task 6, Task 7
|
||||
- Acceptance Criteria:
|
||||
- `npx tsc --noEmit` 通过
|
||||
- `npm run format:check` 通过(若不通过先 `npm run format`)
|
||||
- `npm test` 全绿
|
||||
- `npm run build` 成功
|
||||
- 手动视觉验证:Chrome DevTools iPhone 14 Pro 视口逐一对照 7 张参考截图
|
||||
- 新增 `.unipi/docs/specs/2026-05-25-posts-api-contract.md`:从主 spec §1-§2 抽出后端需要的所有内容(Post/Attachment schema、6 个 endpoint、删除清单、迁移要求)
|
||||
- 更新 `README.md` 增加 `VITE_USE_MOCK_POSTS` 段落
|
||||
- 不 commit、不 push(等 Terry 显式确认)
|
||||
- Steps:
|
||||
1. 跑全套检查命令
|
||||
2. dev server 手机视口检查
|
||||
3. 写 API 契约文档
|
||||
4. 改 README
|
||||
5. 报告完成,等 Terry 审阅与 commit 指令
|
||||
|
||||
## Risks
|
||||
|
||||
- **mock 数据视觉与真实数据偏差**:mock 用 picsum 占位图,可能掩盖真实图片不同宽高比的边界情况。缓解:mockPosts 中包含横图 / 竖图 / 接近正方形三种比例样本。
|
||||
- **video poster 在 mock 模式不易获取**:用一张本地 SVG 占位即可,避免依赖外部链接。
|
||||
- **i18n 删除收藏 key 后未使用的引用**:tsconfig 的 `noUnusedLocals` 不覆盖 i18n key 的字符串引用,需手动 grep。
|
||||
- **`PostRedirect` 在真接口模式下的实现**:当前先写 mock 分支,真接口分支 TODO 注释标明等 `/api/posts/:id` ready 后补。
|
||||
- **infinite scroll + URL 同步**:用户改 filter chip 时既要 reset cursor 又要更新 URL,注意避免 `setSearchParams` 触发额外 effect 循环。
|
||||
- **后端最终 schema 与本 spec 偏差**:如有偏差,必须先回 spec 改契约,再调前端类型,避免散点修改。
|
||||
|
||||
## Out of Scope(本 plan 不涵盖,遵循 spec)
|
||||
|
||||
- Home 页布局调整
|
||||
- Admin 后台 Post 编辑器
|
||||
- 真实后端 API 实现 + 数据迁移
|
||||
- 长按菜单 / 评论 / Reaction
|
||||
- 桌面端多列布局
|
||||
- SEO 优化
|
||||
Reference in New Issue
Block a user