diff --git a/.env.example b/.env.example index 7f8943e..18592e9 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,7 @@ VITE_ADMIN_ONLY=false # Optional admin UI base path. Leave empty to use default app behavior. VITE_ADMIN_UI_PREFIX= + +# Use mock Post data (Telegram-style resource stream) only when explicitly enabled. +# Default production/staging behavior should hit the real /api/posts API. +VITE_USE_MOCK_POSTS=false diff --git a/.gitignore b/.gitignore index dd1dd85..99e8b48 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,9 @@ pnpm-debug.log* coverage/ .cache/ .vite/ + +# Agent local state / workflow noise +.oh-my-opencode-pi-* +.omc/ +.unipi/ralph/ +.unipi/logs/ diff --git a/.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md b/.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md new file mode 100644 index 0000000..1947089 --- /dev/null +++ b/.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md @@ -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 节点数组,链接处为 ``(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`(占位即可,后端未 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: + - `` 包在 App 根,暴露 `useLightbox()` → `openLightbox(images, startIndex)` + - `ImageLightbox` 支持:左右切换(按钮 + 键盘 ← → + 触屏左右滑)、ESC/点遮罩关闭、右上角下载按钮 + - `VideoPlayer` 支持:全屏遮罩 + ` + fileIcon.ts 按 mime/扩展名返回图标 + 颜色 + formatBytes.ts 3,549,239 → "3.5 MB" + +src/mocks/ + mockPosts.ts 覆盖 7 种 bubble 类型的样本数据(图片用 picsum 占位或本地) +``` + +### 4. Bubble 分发逻辑 + +```ts +function pickBubble(post: Post) { + const a = post.attachments; + if (a.length === 0) return TextBubble; + if (a.length >= 2 && a.every(x => x.kind === "image")) return AlbumBubble; + const only = a[0]; + if (only.kind === "video") return VideoBubble; + if (only.kind === "image") { + return post.text ? ImageWithTextBubble : ImageBubble; + } + return FileDocBubble; // document(含图片当文档:内部用 thumbnail 替代蓝圆图标) +} +``` + +### 5. 移动端布局规范 + +- 容器宽度:手机 `max-w-full px-3`;md+ `max-w-[640px] mx-auto`。桌面端不做多列,保持单列聊天流(左右大留白)。 +- 气泡:`rounded-2xl bg-ark-panel`,左对齐,无头像,内边距 `p-3`(文本 `px-4 py-2.5`)。 +- 时间戳:右下角 `text-[11px] text-neutral-500`,绝对定位。 +- 文档下载按钮:圆形 36×36,金色 `bg-ark-gold` + 黑色 ↓。 +- Day separator:胶囊 `rounded-full bg-ark-panel/70 backdrop-blur px-3 py-1 text-xs text-neutral-400`,居中、sticky 在 FilterChips 下。 +- 多图 grid:宽度 100%,2×2,间距 2px;4+ 时第 4 格 `relative` 叠 `bg-black/45 backdrop-blur-sm` + `+N` 居中文字。 +- FilterChips 容器:`sticky top-0 z-10 bg-ark-bg/90 backdrop-blur` + 横向滚动 `overflow-x-auto whitespace-nowrap`。 + +### 6. 交互 + +| 交互 | 行为 | +|---|---| +| 点击文档下载按钮 | `window.open(attachment.url, "_blank")` 触发浏览器下载 | +| 点击单张图片 | 打开 `ImageLightbox`(单图) | +| 点击相册任一图 / `+N` | 打开 `ImageLightbox`,可左右切换 | +| 点击视频海报 | 第一次:bubble 内 `
`。 +- 老路由 `/r/:id` 改为一个轻量重定向组件:fetch `/api/posts/:id` 拿到 `categorySlug` → `navigate(/category/${slug}#post-${id}, { replace: true })` → `scrollIntoView`。 +- Mock 模式下从 `MOCK_POSTS` 找。 + +### 9. 移除清单 + +文件: +- `src/pages/ResourceDetail.tsx` +- `src/pages/FavoritesPage.tsx` +- `src/components/ResourceCard.tsx` +- `src/components/ResourceListFooter.tsx` + +代码: +- `postFavoriteDelta` 及所有调用点 +- i18n keys:`favorites`, `addFavorite`, `removeFavorite` 等收藏相关 +- Home 中的 `/favorites` 入口 + +路由: +- `/favorites`:删除 +- `/r/:id`:保留为轻量重定向 + +### 10. 测试 / 验证策略 + +- 视觉验证:本地 `npm run dev`,手机模拟器(Chrome DevTools iPhone 14 Pro 视口)逐一对照 7 张截图。 +- 单元测试:`pickBubble` 分发逻辑、`autolink` 正则、`formatBytes`、`useGroupedByDay`。 +- 类型检查:`npx tsc --noEmit`(项目 strict)。 +- 格式化:`npm run format`。 +- 删除后回归:确认 `/favorites` 与 `/r/:id` 老链接不报 404 而是合理跳转或 410。 + +### 11. 风险与缓解 + +- **真接口 schema 与 mock 不一致**:spec 是合同;后端实现时若需偏离,必须先回来改 spec。前端 hook 内 `Post` 类型从 `src/types/post.ts` 单一来源导入。 +- **`+N` 相册和单图 lightbox 的状态管理混乱**:用 React Context(``)暴露 `openLightbox(images, startIndex)` 单一入口,所有 bubble 调它。 +- **视频 inline → 全屏切换的状态丢失**:全屏 overlay 接 `currentTime` 参数,避免重新加载。 +- **scroll restoration**:cursor 分页页内来回滑动时 IntersectionObserver 容易重复触发;用 `loadingRef` 守护。 + +## Implementation Checklist + +> 全部项已被 `.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md` 覆盖。 + +- [x] 定义 `src/types/post.ts`:`Post`、`Attachment`、`PostListResponse` +- [x] 创建 `src/mocks/mockPosts.ts`:覆盖 7 种 bubble 类型 + 跨日期样本 +- [x] 创建 `src/components/messageStream/hooks/usePostStream.ts`(mock + real 双模式 + cursor 分页 + IntersectionObserver) +- [x] 创建 `src/components/messageStream/hooks/useGroupedByDay.ts` +- [x] 创建 `src/components/messageStream/utils/autolink.tsx` +- [x] 创建 `src/components/messageStream/utils/fileIcon.ts` +- [x] 创建 `src/components/messageStream/utils/formatBytes.ts` +- [x] 创建 `FilterChips.tsx`(sticky + 横向滚动) +- [x] 创建 `DaySeparator.tsx` +- [x] 创建 `MessageBubble.tsx`(含 `pickBubble` 分发) +- [x] 创建 `bubbles/FileDocBubble.tsx`(图片当文档 + pdf/ai/ppt) +- [x] 创建 `bubbles/TextBubble.tsx`(autolink) +- [x] 创建 `bubbles/VideoBubble.tsx`(inline 播放 + 全屏触发) +- [x] 创建 `bubbles/ImageBubble.tsx` +- [x] 创建 `bubbles/ImageWithTextBubble.tsx` +- [x] 创建 `bubbles/AlbumBubble.tsx`(2-4 grid + `+N`) +- [x] 创建 `overlays/ImageLightbox.tsx` + `ImageLightboxProvider` context +- [x] 创建 `overlays/VideoPlayer.tsx` +- [x] 创建 `MessageStream.tsx` 顶层组件 +- [x] 重写 `src/pages/CategoryPage.tsx` 为 `` +- [x] 重写 `src/pages/Browse.tsx` 为 `` +- [x] 删除 `src/pages/ResourceDetail.tsx`,将 `/r/:id` 改为重定向组件(mock 模式下从 `MOCK_POSTS` 查) +- [x] 删除 `src/pages/FavoritesPage.tsx`、`src/components/ResourceCard.tsx`、`src/components/ResourceListFooter.tsx` +- [x] 移除 `postFavoriteDelta` 及全部调用点 +- [x] 移除 `App.tsx` 中 `/favorites` 路由 + Home 入口 +- [x] 清理 i18n favorites 相关 keys +- [x] 单元测试:`pickBubble`、`autolink`、`formatBytes`、`useGroupedByDay` +- [x] 视觉对照 7 张参考截图(iPhone 14 Pro 视口) +- [x] 运行 `npx tsc --noEmit && npm run format:check && npm test` +- [x] 文档:在 README 注明 `VITE_USE_MOCK_POSTS` 用法 +- [x] 交付后端 API 契约文档(本 spec 的 §2 部分单独抽出 markdown 给后端) + +## Open Questions + +- **长按 / 右键菜单**:是否需要"复制链接"、"举报"、"分享"?v2 决定。 +- **`type` 筛选语义边界**:一个 Post 含多种 attachment 时(图+视频混合,目前 mock 不出现),`type=video` 命中规则由后端定,前端按返回展示即可。 +- **空状态文案**:消息流为空时显示什么?目前沿用 `t("noResults")`。 +- **错误重试**:网络失败时是否提供"重试"按钮?建议下方加一个 inline 重试条。 +- **视频自动暂停**:滚出视口时是否自动暂停?建议做,体验更顺。 +- **i18n 时间戳格式**:是否需要适配繁体/简体/英文不同的日期分组格式?沿用 `Intl.DateTimeFormat` 按 `lang` 切换。 +- **SEO**:删除 `/r/:id` 详情页后,搜索引擎抓取深度受影响吗?目前站点未做强 SEO,可忽略;如需保留可让 `/r/:id` 渲染服务端可解析的 `