Files
Arkie-Library-Frontend/.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md
2026-05-25 05:25:57 +08:00

328 lines
17 KiB
Markdown
Raw 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.
---
title: "Telegram-style Resource Stream资料分类查看全部 UI 重构)"
type: brainstorm
date: 2026-05-25
---
# Telegram-style Resource Stream
## Problem Statement
当前"查看全部"打开的资料列表(`/browse``/category/:slug`)使用统一的卡片网格,无法表达"admin 上传的内容本质是不同类型的消息"(一张图、一段文字+链接、一个视频、一个 PDF、4+ 张图相册等)。手机端用户体验偏弱,缺少 Telegram 那种按类型差异化呈现的直观感受。
本次重构目标:把 `/browse``/category/:slug` 改成**单列、按时间倒序、按日期分组、按上传类型差异化渲染**的 Telegram-style 消息流,手机优先,保留 ARK 既有色系(深底 + 金色高亮)。
> 后端 endpoint 尚未实现。本次前端先用 mock data 完成视觉与交互,验收后再交接 API 契约给后端。
## Context
### 当前实现
- `src/pages/Home.tsx`:分类 section 头部"查看全部" → `/browse`;分类卡片 → `/category/<slug>`
- `src/pages/Browse.tsx`221 行):含排序 tabs最新/推荐/热门/发布)+ 类型 chips + 语言 chips + tag 过滤 + 分页(每页 24
- `src/pages/CategoryPage.tsx`156 行):含类型 chips + 语言 chips + 分页。
- `src/pages/ResourceDetail.tsx`229 行):`/r/:id` 资源详情独立路由。
- `src/pages/FavoritesPage.tsx` + `postFavoriteDelta`:收藏功能。
- `src/components/ResourceCard.tsx`:统一卡片,不区分资源类型。
- `Resource` schema`src/api.ts`扁平1 个 resource = 1 个文件(`coverImage` / `fileUrl`),无 attachments 数组。
### 设计参考(用户提供 7 张 Telegram 截图)
1. 图片当文档上传:缩略图 + ↓ + filename.ext + size + 右下时间,无头像、无 reaction。
2. PDF / AI / PPT 等文档:蓝圆 ↓ + filename + size。
3. 纯文本 + 链接:`https://...` 自动识别为可点链接。
4. 视频:海报 + ▶️,第一次点 inline 播放预览,第二次点全屏;下方可有 admin 写的说明文字。
5. 单张图片:直接显示,点全屏。
6. 图片 + 文字:图片上方/下方文字,文字内链接 autolink。
7. 4+ 张图相册1-4 格 grid第 4 张模糊 + `+N`;点开后全屏画廊。
## Chosen Approach
**方案 A自建 `MessageStream` + 多态 `MessageBubble` 家族 + Mock-data layer**
- 新建一个 `MessageStream` 容器组件,被 `Browse.tsx``CategoryPage.tsx` 共用,差异通过 `scope` props 注入。
- `MessageBubble` 内根据 `Post.attachments` 的形状分发到 6 个子组件FileDoc / Text / Video / Image / ImageWithText / Album
- 全屏交互(图片画廊 / 视频全屏)走 portal overlay。
- 数据层用 `usePostStream` hook 抽象,默认走 `src/mocks/mockPosts.ts`(受 `VITE_USE_MOCK_POSTS` 控制),后端 ready 后切换为真 API。
- 同时**收尾**收藏功能与详情页:删除 `/favorites``/r/:id`、heart 按钮、`postFavoriteDelta`
## Why This Approach
### 拒绝方案 B聊天 UI 库 `@chatscope/chat-ui-kit-react` 等)
- 默认主题与 ARK 深底+金色严重冲突,改主题成本 ≈ 自己写。
- 不支持"4+ 图相册 +N 模糊"自定义。
- 增加包体积与灰盒 bug 风险。
### 拒绝方案 C重样 `ResourceCard`
- 当前卡片统一渲染,无法满足"按类型差异化"(视频内嵌播放器 vs 多图相册 vs 文本+链接)。
- 改动表面但不达 Telegram 风格。
### 拒绝其他子选项
- **保留排序 tabs**Telegram 流天然按时间倒序,多余 tabs 破坏隐喻。Home 页仍保留"官方推荐 / 最新更新" section 作为入口。
- **保留收藏功能在列表/详情页**:用户明确要求"不需要 reaction",且收藏与 Telegram 隐喻不符;整体下线最干净。
- **保留 `ResourceDetail` 作 fallback**:所有交互(下载 / 全屏 / 链接)都能就地完成,独立详情页冗余;老 `/r/:id` 改 301 重定向到 `/category/<slug>#post-<id>` 锚点。
- **`kind` 枚举铺开**:后端枚举膨胀难维护;前端按 `mime` / 文件后缀做细分图标更灵活。
## Design
### 1. 数据模型(前端使用 + 后端接口契约)
```ts
type Post = {
id: string;
categoryId: number;
categorySlug: string;
language: string; // zh-TW | zh-CN | en
text?: string; // 可选;纯文本/图说,前端自动识别 https 链接
attachments: Attachment[]; // 0~Ntext-only post 时为 []
isRecommended: boolean;
publishedAt: string; // ISO用于排序 + 日期分组
updatedAt: string;
};
type Attachment = {
id: string;
kind: "image" | "video" | "document";
url: string;
mime: string; // image/jpeg | application/pdf | video/mp4 | ...
filename: string; // "ARK项目一图读懂-01.jpg"
sizeBytes: number;
width?: number;
height?: number;
durationSec?: number; // video 专用
posterUrl?: string; // video 海报
thumbnailUrl?: string; // image 缩略
};
```
关键约定:
- `kind: "document" + mime.startsWith("image/")` = 图片当文档上传(截图 1
- `kind: "image"` = 图片当图片呈现(截图 5、6、7。该开关在 admin 上传 UI 决定,传到后端落库。
- 多图相册 = 一个 Post 带多个 `kind: "image"` 的 attachments。
- 图片+文字 = Post 同时有 `text` 与 attachments。
- 纯文本+链接 = Post 仅有 `text``attachments: []`
### 2. 后端 API 契约(移交给后端)
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | `/api/posts?category=<slug>&lang=&type=&language=&cursor=&limit=20` | 分类内消息流 |
| GET | `/api/posts?lang=&type=&language=&cursor=&limit=20` | 全部消息流(`/browse` |
| GET | `/api/posts/recommended?lang=&limit=` | Home 推荐 section |
| GET | `/api/posts/latest?lang=&limit=` | Home 最新 section |
| GET | `/api/posts/:id` | 单条(用于老 `/r/:id` 301 重定向落地,前端拿到 `categorySlug` 后跳锚点) |
| GET | `/api/categories` | 不变 |
| POST/PUT/DELETE | `/api/admin/posts/...` | Admin CRUD支持多附件 + 文本 + "图片是否以文档呈现"开关 |
废弃:
- `/api/resources/:id/favorite`
-`/api/resources*` 系列保留过渡期,由后端写迁移脚本:每个老 Resource → 一个 Post。
返回结构:`{ items: Post[], nextCursor?: string }`cursor 由后端不透明字符串提供。
`type` 参数语义:`all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`。一个 Post 命中条件 = `attachments[*].mime``text` 满足;具体由后端定义。
### 3. 组件结构
```
src/pages/
CategoryPage.tsx ← 重写:<MessageStream scope={{ kind:'category', slug }} />
Browse.tsx ← 重写:<MessageStream scope={{ kind:'all' }} />
src/components/messageStream/
MessageStream.tsx 顶层fetch + 无限滚动 + 日期分组 + sticky filter chips
FilterChips.tsx 类型 + 语言 chips横向滚动sticky top
DaySeparator.tsx "2 月 27 日" 胶囊
MessageBubble.tsx 单条 Post 容器:决定子组件 + 右下角时间戳
bubbles/
FileDocBubble.tsx 截图 1 + 2文档图片当文档 / pdf / ai / ppt / docx
TextBubble.tsx 截图 3纯文本 + autolink
VideoBubble.tsx 截图 4海报 + ▶️,先 inline 后全屏
ImageBubble.tsx 截图 5单张图片
ImageWithTextBubble.tsx 截图 6图片 + 文本 + autolink
AlbumBubble.tsx 截图 72-4 格 grid4+ 时第 4 格模糊 + `+N`
overlays/
ImageLightbox.tsx 全屏画廊(左右滑、缩放、关闭、下载)
VideoPlayer.tsx 全屏视频播放器
hooks/
usePostStream.ts cursor 分页 + IntersectionObservermock/real 切换
useGroupedByDay.ts 按 publishedAt 本地日期分组
utils/
autolink.tsx 文本中 https?://... → <a target="_blank" rel="noopener">
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间距 2px4+ 时第 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 内 `<video controls autoPlay>` inline 播放 |
| 点击播放中的视频 | 打开 `VideoPlayer` 全屏 overlay |
| 文本中的链接 | `target="_blank" rel="noopener noreferrer"` 新标签打开 |
| 滚动到底部 | IntersectionObserver 触发下一页 cursor 拉取 |
| 筛选 chips 变化 | 重置 cursor重新拉取同步 URL `?type=&language=` |
| 长按气泡 | 暂不做,列入 Open Questions |
### 7. Mock data 层
`src/mocks/mockPosts.ts` 导出 `MOCK_POSTS: Post[]`,至少包含:
- 2 条"图片当文档"(不同 mimejpg、png
- 2 条文档pdf、ai
- 2 条纯文本+链接(含中文 + 多链接 + emoji
- 1 条视频(带 posterUrl + duration
- 2 条单图(不同宽高比)
- 1 条图+文字
- 1 条 3 图相册
- 1 条 7 图相册(验证 `+N` 行为)
- 跨多天的 `publishedAt`,验证 DaySeparator
`usePostStream` 行为:
```ts
const useMock = import.meta.env.VITE_USE_MOCK_POSTS !== "false";
if (useMock) {
// 1. 按 scope.slug / type / language 过滤 MOCK_POSTS
// 2. 按 publishedAt 倒序
// 3. 按 cursor数字 offset 字符串)切 20 条
// 4. setTimeout 200ms 模拟延迟
// 5. 返回 nextCursor = offset+20 或 undefined
} else {
// fetch /api/posts?... 真接口
}
```
切真接口时只需在部署环境设 `VITE_USE_MOCK_POSTS=false`(或干脆删 mock 分支)。
### 8. 锚点 + 分享
- 每个 bubble 渲染为 `<article id="post-${post.id}">`
- 老路由 `/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`<ImageLightboxProvider>`)暴露 `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``<MessageStream scope={{ kind:'category', slug }} />`
- [x] 重写 `src/pages/Browse.tsx``<MessageStream scope={{ kind:'all' }} />`
- [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` 渲染服务端可解析的 `<noscript>` 摘要后再 JS 重定向。
- **Admin 上传 UI 改造**本次只覆盖前台浏览端admin 端 Post 编辑器(多附件 + 文本 + 图片呈现方式开关)需要单独的 spec / 任务。
## Out of Scope
- Home 页面布局调整(分类卡片网格、推荐/最新 section 保持不变)
- Admin 后台 UI 改造(单独 spec
- 真实 API 实现(后端工作)
- 后端数据迁移脚本
- 长按菜单、举报、分享等社交功能
- 评论 / Reaction
- 离线缓存 / Service Worker
- 桌面端多列布局