17 KiB
title, type, date
| title | type | date |
|---|---|---|
| Telegram-style Resource Stream(资料分类查看全部 UI 重构) | brainstorm | 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:统一卡片,不区分资源类型。Resourceschema(src/api.ts)扁平:1 个 resource = 1 个文件(coverImage/fileUrl),无 attachments 数组。
设计参考(用户提供 7 张 Telegram 截图)
- 图片当文档上传:缩略图 + ↓ + filename.ext + size + 右下时间,无头像、无 reaction。
- PDF / AI / PPT 等文档:蓝圆 ↓ + filename + size。
- 纯文本 + 链接:
https://...自动识别为可点链接。 - 视频:海报 + ▶️,第一次点 inline 播放预览,第二次点全屏;下方可有 admin 写的说明文字。
- 单张图片:直接显示,点全屏。
- 图片 + 文字:图片上方/下方文字,文字内链接 autolink。
- 4+ 张图相册:1-4 格 grid,第 4 张模糊 +
+N;点开后全屏画廊。
Chosen Approach
方案 A:自建 MessageStream + 多态 MessageBubble 家族 + Mock-data layer
- 新建一个
MessageStream容器组件,被Browse.tsx和CategoryPage.tsx共用,差异通过scopeprops 注入。 MessageBubble内根据Post.attachments的形状分发到 6 个子组件(FileDoc / Text / Video / Image / ImageWithText / Album)。- 全屏交互(图片画廊 / 视频全屏)走 portal overlay。
- 数据层用
usePostStreamhook 抽象,默认走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. 数据模型(前端使用 + 后端接口契约)
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 分发逻辑
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 行为:
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.tsxsrc/pages/FavoritesPage.tsxsrc/components/ResourceCard.tsxsrc/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覆盖。
- 定义
src/types/post.ts:Post、Attachment、PostListResponse - 创建
src/mocks/mockPosts.ts:覆盖 7 种 bubble 类型 + 跨日期样本 - 创建
src/components/messageStream/hooks/usePostStream.ts(mock + real 双模式 + cursor 分页 + IntersectionObserver) - 创建
src/components/messageStream/hooks/useGroupedByDay.ts - 创建
src/components/messageStream/utils/autolink.tsx - 创建
src/components/messageStream/utils/fileIcon.ts - 创建
src/components/messageStream/utils/formatBytes.ts - 创建
FilterChips.tsx(sticky + 横向滚动) - 创建
DaySeparator.tsx - 创建
MessageBubble.tsx(含pickBubble分发) - 创建
bubbles/FileDocBubble.tsx(图片当文档 + pdf/ai/ppt) - 创建
bubbles/TextBubble.tsx(autolink) - 创建
bubbles/VideoBubble.tsx(inline 播放 + 全屏触发) - 创建
bubbles/ImageBubble.tsx - 创建
bubbles/ImageWithTextBubble.tsx - 创建
bubbles/AlbumBubble.tsx(2-4 grid ++N) - 创建
overlays/ImageLightbox.tsx+ImageLightboxProvidercontext - 创建
overlays/VideoPlayer.tsx - 创建
MessageStream.tsx顶层组件 - 重写
src/pages/CategoryPage.tsx为<MessageStream scope={{ kind:'category', slug }} /> - 重写
src/pages/Browse.tsx为<MessageStream scope={{ kind:'all' }} /> - 删除
src/pages/ResourceDetail.tsx,将/r/:id改为重定向组件(mock 模式下从MOCK_POSTS查) - 删除
src/pages/FavoritesPage.tsx、src/components/ResourceCard.tsx、src/components/ResourceListFooter.tsx - 移除
postFavoriteDelta及全部调用点 - 移除
App.tsx中/favorites路由 + Home 入口 - 清理 i18n favorites 相关 keys
- 单元测试:
pickBubble、autolink、formatBytes、useGroupedByDay - 视觉对照 7 张参考截图(iPhone 14 Pro 视口)
- 运行
npx tsc --noEmit && npm run format:check && npm test - 文档:在 README 注明
VITE_USE_MOCK_POSTS用法 - 交付后端 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
- 桌面端多列布局