---
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~N；text-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          截图 7：2-4 格 grid，4+ 时第 4 格模糊 + `+N`
  overlays/
    ImageLightbox.tsx        全屏画廊（左右滑、缩放、关闭、下载）
    VideoPlayer.tsx          全屏视频播放器
  hooks/
    usePostStream.ts         cursor 分页 + IntersectionObserver；mock/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，间距 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 内 `<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 条"图片当文档"（不同 mime：jpg、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
- 桌面端多列布局
