12 KiB
12 KiB
title, type, date, workbranch, specs
| title | type | date | workbranch | specs | |
|---|---|---|---|---|---|
| Telegram-style Resource Stream — Implementation Plan | plan | 2026-05-25 | feat/telegram-stream |
|
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:
git status --short --branch确认无未提交改动git checkout -b feat/telegram-stream- 确认
.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md与.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md已在该分支
- Description: 在当前目录创建并切到
-
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、PostScopesrc/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 不返回 Iconnpx tsc --noEmit通过
- Steps:
- 写
src/types/post.ts - 写
src/components/messageStream/utils/formatBytes.ts+.test.ts - 写
src/components/messageStream/utils/autolink.tsx+.test.tsx - 写
src/components/messageStream/utils/fileIcon.ts - 写
src/mocks/mockPosts.ts(图片用 picsum.photos 占位,视频用公开 sample mp4 + 一张占位 poster) - 跑
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:
- 写
src/components/messageStream/hooks/usePostStream.ts - 写
src/components/messageStream/hooks/useGroupedByDay.ts+.test.ts - 在
.env.example加VITE_USE_MOCK_POSTS=true注释 - 跑
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:
- 写
src/components/messageStream/overlays/ImageLightbox.tsx+ Provider/Context - 写
src/components/messageStream/overlays/VideoPlayer.tsx - 在
App.tsx/ 根布局挂 Provider - 手动验证:在 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)AlbumBubble2-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:
- 写
MessageBubble.tsx(含 pickBubble) - 写
bubbles/TextBubble.tsx - 写
bubbles/FileDocBubble.tsx - 写
bubbles/ImageBubble.tsx - 写
bubbles/ImageWithTextBubble.tsx - 写
bubbles/AlbumBubble.tsx - 写
bubbles/VideoBubble.tsx - 在 dev 中临时挂一个 demo route 跑 MOCK_POSTS 全量渲染,目视检查 7 张截图对照
- 写
- Description: 按截图实现 6 种气泡 +
-
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:
- 写
FilterChips.tsx - 写
DaySeparator.tsx - 写
MessageStream.tsx - 在 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 }} />,行数 < 30src/pages/Browse.tsx:仅渲染页面标题 +<MessageStream scope={{ kind: "all" }} />,行数 < 30;不再读sort/tag/page参数- 排序 tabs 全部去掉
- 移除对
ResourceCard/ResourceListFooter的 import App.tsx路由保持/browse与/category/:slug不变npx tsc --noEmit通过
- Steps:
- 改写
CategoryPage.tsx - 改写
Browse.tsx - 跑 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:
grep -rn "postFavoriteDelta\|FavoritesPage\|ResourceDetail\|ResourceCard\|ResourceListFooter\|/favorites\|/r/:id" src/列出所有引用点- 写
src/pages/PostRedirect.tsx,挂到/r/:id路由 - 删除 4 个文件 + 修改
App.tsx+api.ts+i18n.tsx - 再 grep 一次确认清零
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:
- 跑全套检查命令
- dev server 手机视口检查
- 写 API 契约文档
- 改 README
- 报告完成,等 Terry 审阅与 commit 指令
Risks
- mock 数据视觉与真实数据偏差:mock 用 picsum 占位图,可能掩盖真实图片不同宽高比的边界情况。缓解:mockPosts 中包含横图 / 竖图 / 接近正方形三种比例样本。
- video poster 在 mock 模式不易获取:用一张本地 SVG 占位即可,避免依赖外部链接。
- i18n 删除收藏 key 后未使用的引用:tsconfig 的
noUnusedLocals不覆盖 i18n key 的字符串引用,需手动 grep。 PostRedirect在真接口模式下的实现:当前先写 mock 分支,真接口分支 TODO 注释标明等/api/posts/:idready 后补。- infinite scroll + URL 同步:用户改 filter chip 时既要 reset cursor 又要更新 URL,注意避免
setSearchParams触发额外 effect 循环。 - 后端最终 schema 与本 spec 偏差:如有偏差,必须先回 spec 改契约,再调前端类型,避免散点修改。
Out of Scope(本 plan 不涵盖,遵循 spec)
- Home 页布局调整
- Admin 后台 Post 编辑器
- 真实后端 API 实现 + 数据迁移
- 长按菜单 / 评论 / Reaction
- 桌面端多列布局
- SEO 优化