feat: add telegram-style resource stream

This commit is contained in:
TerryM
2026-05-25 05:25:57 +08:00
parent aaebd7ccd1
commit a784f159fe
45 changed files with 3201 additions and 1160 deletions

View File

@@ -0,0 +1,581 @@
---
title: "ARK Library Frontend — Backend API Requirements"
type: api-requirements
date: 2026-05-25
audience: backend
status: draft
---
# ARK Library Frontend — Backend API Requirements
这份文档列出前端接下来需要后端提供的**全部接口**。重点是新的 Telegram-style 资料流;旧 `resources` 接口可作为过渡,但最终建议统一到 `posts` 模型。
## 0. 通用约定
- API base前端通过 `VITE_API_URL` 指向后端;本地可同源 `/api`
- 上传文件可通过 `/uploads/...` 或完整 URL 返回;前端会用 `assetUrl()` 处理相对路径。
- 所有时间字段使用 ISO 8601 字符串,例如 `2026-05-24T14:42:00.000Z`
- 语言字段:`zh-TW` / `zh-CN` / `en`
- 错误格式:非 2xx + text/plain 或 JSON 均可;前端会显示错误文本。
- Admin 接口需要 `Authorization: Bearer <token>`
## 1. 核心数据模型
### 1.1 Category
```ts
type Category = {
id: number;
slug: string; // 用于 /category/:slug 和 GET /api/posts?category=<slug>
name: string; // 已按 lang 返回本地化名称
description?: string;
iconKey: string; // folder/calendar/megaphone/video/image 等,前端已有 icon map
sortOrder: number;
};
```
### 1.2 Post新资料流核心
```ts
type AttachmentKind = "image" | "video" | "document";
type Attachment = {
id: string;
kind: AttachmentKind;
url: string; // 原始文件或可访问文件 URL
mime: string; // image/jpeg, application/pdf, video/mp4, ...
filename: string;
sizeBytes: number;
width?: number; // image/video 建议提供
height?: number;
durationSec?: number; // video 建议提供
posterUrl?: string; // video preview
thumbnailUrl?: string; // image/document preview
};
type Post = {
id: string;
categoryId: number;
categorySlug: string;
language: "zh-TW" | "zh-CN" | "en";
text?: string;
attachments: Attachment[];
isRecommended: boolean;
publishedAt: string;
updatedAt: string;
};
type PostListResponse = {
items: Post[];
nextCursor?: string;
};
```
### 1.3 Post 显示规则(后端必须按这个模型返回)
- 纯文字/链接:`attachments: []``text` 非空。
- 单张图片:`attachments.length === 1``kind: "image"`
- 图片 + 文字:`kind: "image"` + `text`
- 视频:`kind: "video"`,建议提供 `posterUrl` / `durationSec`
- 文件:`kind: "document"`,前端显示下载卡。
- 图片当文件上传:`kind: "document"``mime` 是 image前端会显示缩略图 + 下载按钮。
- 多图:
- 2 / 3 / 4 张图:前端会独立纵向显示每张图,同一个 Post 只显示一次时间。
- 超过 4 张图:前端显示 2×2 合并格,第 4 格模糊并显示 `+N`
## 2. Public API前台用户
### 2.1 分类列表
```http
GET /api/categories?lang=zh-CN
```
Response:
```ts
Category[]
```
用途Home 资料分类、CategoryPage 标题、Admin 表单分类选择。
---
### 2.2 全部资料 / 分类资料流
```http
GET /api/posts?lang=zh-CN&limit=20&cursor=<cursor>&type=all&language=&category=<slug>
```
Query:
| 参数 | 必填 | 说明 |
|---|---:|---|
| `lang` | 是 | UI 语言 |
| `limit` | 否 | 默认 20最大建议 50 |
| `cursor` | 否 | 后端返回的不透明 cursor |
| `category` | 否 | 不传 = 全部资料;传 slug = 单分类 |
| `type` | 否 | `all/image/video/ppt/pdf/text/link/archive` |
| `language` | 否 | 资料语言:`zh-TW/zh-CN/en` |
Response:
```ts
PostListResponse
```
排序:`publishedAt DESC`
用途:
- `/browse`:不传 `category`
- `/category/:slug`:传 `category=<slug>`
---
### 2.3 Home 推荐资料
```http
GET /api/posts/recommended?lang=zh-CN&limit=12
```
Response:
```ts
{ items: Post[] }
```
用途Home「官方推荐」section。按 `sortOrder ASC` + `publishedAt DESC` 或后端自定义推荐顺序。
> 过渡期:当前前端 Home 仍可接受旧 `/api/resources/recommended`,但建议后端新做 `posts/recommended` 后前端再切换。
---
### 2.4 Home 最新资料
```http
GET /api/posts/latest?lang=zh-CN&limit=8
```
Response:
```ts
{ items: Post[] }
```
用途Home「最新更新」section。按 `publishedAt DESC`
> 过渡期:当前前端 Home 仍可接受旧 `/api/resources/latest`。
---
### 2.5 单条 Post旧链接落地
```http
GET /api/posts/:id
```
Response:
```ts
Post
```
用途:旧 `/resource/:id` 前端重定向:拿 `categorySlug` 后跳到 `/category/<slug>#post-<id>`
---
### 2.6 搜索
建议新接口:
```http
GET /api/posts/search?q=<keyword>&lang=zh-CN&type=all&language=&cursor=&limit=20
```
Response:
```ts
PostListResponse
```
搜索范围建议:`text``filename``categoryName`、tags。
过渡期当前前端仍使用:
```http
GET /api/resources?q=<keyword>&lang=&type=&language=&limit=50
```
Response:
```ts
{ items: Resource[] }
```
---
### 2.7 搜索日志
```http
POST /api/search-log
Content-Type: application/json
{ "query": "ARK" }
```
Response204 或 `{ ok: true }`
用途:记录用户搜索词;失败不阻断用户体验。
---
### 2.8 下载统计(可选)
文件下载目前前端可直接打开 `Attachment.url`。如果后端需要统计下载,提供:
```http
POST /api/posts/:postId/attachments/:attachmentId/download
```
Response204 或 `{ ok: true }`
> 过渡期旧 Home 推荐卡还可能调用 `POST /api/resources/:id/download`。
## 3. Filter 语义
`GET /api/posts``type` 参数建议按以下规则命中:
| type | 命中条件 |
|---|---|
| `all` | 全部 |
| `image` | 任一 attachment `kind === "image"` |
| `video` | 任一 attachment `kind === "video"``mime.startsWith("video/")` |
| `pdf` | 任一 attachment 扩展名 `pdf``mime === "application/pdf"` |
| `ppt` | 任一 attachment 扩展名 `ppt/pptx/key` 或 mime 含 `presentation` |
| `archive` | 任一 attachment 扩展名 `zip/rar/7z/tar/gz` |
| `text` | `text` 非空 |
| `link` | `text` 中包含 `https?://` |
## 4. Wallet Auth API
### 4.1 取得签名 nonce/message
```http
POST /api/auth/wallet/nonce
Content-Type: application/json
{ "address": "0x..." }
```
Response:
```ts
{ message: string }
```
### 4.2 验证签名并签发 token
```http
POST /api/auth/wallet/verify
Content-Type: application/json
{
"address": "0x...",
"message": "...",
"signature": "0x..."
}
```
Response:
```ts
{ token: string }
```
### 4.3 验证当前 wallet session
```http
GET /api/auth/wallet/me
Authorization: Bearer <wallet-token>
```
Response:
```ts
{ wallet: string }
```
## 5. Admin API
### 5.1 Admin 登录
```http
POST /api/admin/login
Content-Type: application/json
{ "username": "...", "password": "..." }
```
Response:
```ts
{ token: string }
```
---
### 5.2 Admin dashboard
```http
GET /api/admin/dashboard
Authorization: Bearer <admin-token>
```
Response:
```ts
type AdminDashboard = {
totalResources: number; // 若迁移到 Post可理解为 totalPosts
published: number;
todayNew: number;
totalViews: number;
totalDownloads: number;
totalFavorites: number; // 收藏下线后可返回 0避免旧 admin UI 崩
totalShares: number;
hotResources: {
id: string;
title: string;
downloads: number;
favorites: number; // 可返回 0
views: number;
}[];
};
```
---
### 5.3 文件上传
```http
POST /api/admin/upload
Authorization: Bearer <admin-token>
Content-Type: multipart/form-data
file=<File>
```
最低 Response:
```ts
{ url: string }
```
建议 Response更方便前端自动建 Attachment
```ts
{
url: string;
mime: string;
filename: string;
sizeBytes: number;
width?: number;
height?: number;
durationSec?: number;
thumbnailUrl?: string;
posterUrl?: string;
}
```
---
### 5.4 Admin Post 列表
```http
GET /api/admin/posts?limit=25&page=1&status=&category=&type=&q=
Authorization: Bearer <admin-token>
```
Response:
```ts
{
items: AdminPost[];
total: number;
}
type AdminPost = Post & {
isPublic: boolean;
sortOrder: number;
status: "draft" | "published" | "archived";
viewCount?: number;
downloadCount?: number;
};
```
---
### 5.5 Admin Post 详情
```http
GET /api/admin/posts/:id
Authorization: Bearer <admin-token>
```
Response`AdminPost`
---
### 5.6 创建 Post
```http
POST /api/admin/posts
Authorization: Bearer <admin-token>
Content-Type: application/json
```
Request:
```ts
type UpsertPostPayload = {
categoryId: number;
language: "zh-TW" | "zh-CN" | "en";
text?: string;
attachments: Attachment[];
isPublic: boolean;
isRecommended: boolean;
sortOrder: number;
status: "draft" | "published" | "archived";
publishedAt?: string;
tags?: string[];
};
```
Response`AdminPost`
---
### 5.7 更新 Post
```http
PUT /api/admin/posts/:id
Authorization: Bearer <admin-token>
Content-Type: application/json
```
Request`UpsertPostPayload`
Response`AdminPost`
---
### 5.8 删除 Post
```http
DELETE /api/admin/posts/:id
Authorization: Bearer <admin-token>
```
Response204 或 `{ ok: true }`
---
### 5.9 Admin 搜索日志
```http
GET /api/admin/search-logs?limit=300
Authorization: Bearer <admin-token>
```
Response:
```ts
{
items: {
id: string;
query: string;
createdAt: string;
count?: number;
}[];
}
```
## 6. 过渡期旧 Resource API如果 admin 尚未迁移)
当前部分前端/admin 代码仍可能使用旧接口。后端可以短期保留,或前端后续再统一切到 Post
```http
GET /api/resources?lang=&limit=&page=&sort=&q=&type=&language=&tag=
GET /api/resources/recommended?lang=&limit=
GET /api/resources/latest?lang=&limit=
POST /api/resources/:id/download
GET /api/admin/resources?limit=&page=
GET /api/admin/resources/:id
POST /api/admin/resources
PUT /api/admin/resources/:id
```
`Resource` shape
```ts
type Resource = {
id: string;
title: string;
description?: string;
type: string;
language: string;
categoryId: number;
categorySlug: string;
categoryName: string;
coverImage?: string;
fileUrl?: string;
previewUrl?: string;
externalUrl?: string;
bodyText?: string;
badgeLabel?: string;
isDownloadable: boolean;
isRecommended: boolean;
publishedAt?: string;
updatedAt: string;
tags?: string[];
};
```
## 7. 已下线 / 不需要实现
- 用户收藏:`/favorites` 页面已移除。
- `POST /api/resources/:id/favorite` 不需要。
- Reaction / 点赞 / 评论不需要。
- Telegram 管理员标签、头像、群组名不需要。
## 8. 前后端切换计划
1. 后端先实现 `/api/posts``/api/posts/:id``/api/categories`
2. 前端 staging 设置:`VITE_USE_MOCK_POSTS=false`
3. 确认 `/browse``/category/:slug` 正常拉真数据。
4. 再实现 `/api/posts/recommended``/api/posts/latest`,前端把 Home 从旧 resources 切到 posts。
5. 最后迁移 Admin 从 `/api/admin/resources``/api/admin/posts`
## 9. 最小可上线优先级
### P0前台资料流必需
- `GET /api/categories`
- `GET /api/posts`
- `GET /api/posts/:id`
- `POST /api/admin/upload`
- `POST /api/admin/posts`
- `PUT /api/admin/posts/:id`
- `GET /api/admin/posts`
- `GET /api/admin/posts/:id`
### P1首页/搜索/统计)
- `GET /api/posts/recommended`
- `GET /api/posts/latest`
- `GET /api/posts/search`
- `POST /api/search-log`
- `GET /api/admin/dashboard`
- `GET /api/admin/search-logs`
### P2账户
- Wallet nonce / verify / me

View File

@@ -0,0 +1,175 @@
---
title: "Posts API Contract (for backend)"
type: api-contract
date: 2026-05-25
audience: backend
status: draft
---
# Posts API Contract
> 这份文档是从 `2026-05-25-telegram-style-resource-stream-design.md` §1§2 抽出,供后端实现使用。前端已用 mock data 完成视觉;上线时把 `VITE_USE_MOCK_POSTS=false` 即可切真接口。
## 1. 数据模型
```ts
type AttachmentKind = "image" | "video" | "document";
type Attachment = {
id: string; // 唯一 id
kind: AttachmentKind; // 三大类,前端按此分支渲染
url: string; // 原始文件地址
mime: string; // image/jpeg, application/pdf, video/mp4, ...
filename: string; // 显示用文件名,含扩展名
sizeBytes: number; // 字节数;前端格式化为 "3.5 MB"
width?: number; // image/video 用于占位比例CLS 优化)
height?: number;
durationSec?: number; // video 专用
posterUrl?: string; // video 海报缩略图
thumbnailUrl?: string; // image 缩略,列表用减少流量
};
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 8601用于排序 + 日期分组
updatedAt: string;
};
type PostListResponse = {
items: Post[];
nextCursor?: string; // 不透明 cursorundefined = 没有下一页
};
```
### 关键约定
- **图片当文档**(在前端显示为「文件下载卡」):`kind === "document"``mime.startsWith("image/")`。Admin 上传时通过开关决定走 image 还是 document 通道。
- **图片当图片**(前端显示为图片预览):`kind === "image"`
- **多图相册**:一个 Post 带多个 `kind === "image"` 的 attachments。前端会在 2-4 grid 中渲染attachments.length > 4 时第 4 格模糊 + `+N`
- **图片 + 文字**Post 同时有 `text` 与 attachments。
- **纯文本 / 链接**Post 仅有 `text``attachments: []`
- **视频**`kind === "video"` 单 attachment。`posterUrl` 用于预览,`durationSec` 用于角标。
- Attachment 内不携带任何「上传者头像 / 管理员标签」等社交字段(前端已下线)。
## 2. Endpoints
### 2.1 列表(核心)
```
GET /api/posts
```
Query 参数:
| 参数 | 必填 | 说明 |
|---|---|---|
| `lang` | 是 | UI 语言;后端可据此选择不同语言版本的 `text` |
| `category` | 否 | category slug不传 = 全部分类 |
| `type` | 否 | `all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`;语义见 §3 |
| `language` | 否 | 资源语言:`zh-TW` / `zh-CN` / `en` |
| `cursor` | 否 | 上一次返回的 `nextCursor`;不传 = 第一页 |
| `limit` | 否 | 默认 20最大 50 |
返回:`PostListResponse`
排序:`publishedAt DESC`
### 2.2 Home 用聚合接口(可选,沿用现状)
```
GET /api/posts/recommended?lang=&limit=
GET /api/posts/latest?lang=&limit=
```
返回:`{ items: Post[] }`(不分页)
### 2.3 单条(用于老链接 301 落地)
```
GET /api/posts/:id
```
返回:`Post`(或 404
前端 `/resource/:id` 现在是轻量重定向:拿到 `categorySlug``/category/<slug>#post-<id>` 锚点滚动。
### 2.4 分类(不变)
```
GET /api/categories?lang=
```
返回:现有 `Category[]`
### 2.5 Admin CRUD
```
POST /api/admin/posts
PUT /api/admin/posts/:id
DELETE /api/admin/posts/:id
GET /api/admin/posts?... (含未发布草稿)
```
需求:
- 支持多附件上传(一次 multipart 或先 `POST /api/admin/upload` 拿到 url 再创建 Post
- Admin UI 需要一个开关:「图片以图片形式呈现 / 以文档形式呈现」,对应 attachment.kind 的 image vs document。
- 支持发布/隐藏、置顶/官方推荐。
> Admin UI 改造单独建 spec / plan本契约仅说明后端必须支持这些字段。
## 3. `type` 参数语义
一个 Post 命中某个 `type`,规则:
| type | 命中条件 |
|---|---|
| `all` | 全部 |
| `image` | `attachments` 中至少一个 `kind === "image"``mime.startsWith("image/")` |
| `video` | 至少一个 `kind === "video"``mime.startsWith("video/")` |
| `pdf` | 至少一个 `mime === "application/pdf"` 或扩展名为 `pdf` |
| `ppt` | 至少一个扩展名为 `ppt` / `pptx` / `key` 或 mime 含 `presentation` |
| `archive` | 至少一个扩展名为 `zip` / `rar` / `7z` / `tar` / `gz` |
| `text` | `text` 非空 |
| `link` | `text` 非空且匹配 `https?://` |
前端 mock 已按此规则过滤,便于切真接口时口径一致。
## 4. 删除 / 废弃
| 项 | 处理 |
|---|---|
| `POST /api/resources/:id/favorite` | 删除 |
| `GET /api/favorites` / 收藏列表 | 删除(前端 `/favorites` 路由已移除) |
| `/r/:id` 老前端路由 | 已合并到 `/resource/:id` 重定向 |
| 老 `/api/resources*` 系列 | 后端可保留过渡期。建议提供数据迁移脚本:每个老 Resource → 一个 Post带 1 个 attachment 或 text-only`isRecommended` / `language` / `categorySlug` 字段迁移;`favorite count` 字段丢弃。 |
| Resource.coverImage 与 Resource.fileUrl 二选一 | 转为 attachments[0]kind 由后端判断 image vs document|
## 5. Search
`GET /api/resources?q=...` 当前仍被 SearchPage 使用(在新 schema 上线前过渡)。后端可视情况:
- 短期:保留旧接口
- 长期:新增 `GET /api/posts/search?q=...` 返回 `PostListResponse`,前端再切
## 6. 错误格式
沿用现状HTTP 状态码 + 文本 body。前端 `getJSON` 会把非 2xx 当作 `Error(text)` 抛出,`MessageStream` 显示红色横幅 + 重试按钮。
## 7. 兼容性 / 灰度
后端 ready 时步骤:
1. 把示例数据导入到 Posts 表
2. `/api/posts` 在 staging 通过
3. 前端 staging 部署设 `VITE_USE_MOCK_POSTS=false`,跑通后再 prod
4. 前端代码层面:删除 `src/mocks/mockPosts.ts``usePostStream.ts` 中的 mock 分支,或保留 mock 用于本地离线开发
## 8. 联系
前端Terry。Spec 主文档:`.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md`

View File

@@ -0,0 +1,327 @@
---
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
- 桌面端多列布局