feat: add telegram-style resource stream
This commit is contained in:
@@ -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 节点数组,链接处为 `<a target="_blank" rel="noopener noreferrer">`(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</api/posts?...>`(占位即可,后端未 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:
|
||||
- `<ImageLightboxProvider>` 包在 App 根,暴露 `useLightbox()` → `openLightbox(images, startIndex)`
|
||||
- `ImageLightbox` 支持:左右切换(按钮 + 键盘 ← → + 触屏左右滑)、ESC/点遮罩关闭、右上角下载按钮
|
||||
- `VideoPlayer` 支持:全屏遮罩 + `<video controls autoPlay>`、接 `currentTime` 参数避免重新加载、ESC/点遮罩关闭
|
||||
- 两个 overlay 在手机端不溢出、不被底部 nav 遮挡
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `src/components/messageStream/overlays/ImageLightbox.tsx` + Provider/Context
|
||||
2. 写 `src/components/messageStream/overlays/VideoPlayer.tsx`
|
||||
3. 在 `App.tsx` / 根布局挂 Provider
|
||||
4. 手动验证:在 dev console 临时调用 `openLightbox` 看是否正确呈现
|
||||
|
||||
- unstarted: Task 4 — Bubble 子组件(6 个 + 分发器)
|
||||
- Description: 按截图实现 6 种气泡 + `MessageBubble` 分发
|
||||
- Dependencies: Task 1, Task 3
|
||||
- Acceptance Criteria:
|
||||
- `FileDocBubble` 处理 `kind: "document"`:
|
||||
- 若 `mime.startsWith("image/")` → 左侧用 `thumbnailUrl` 缩略 + ↓ 覆盖;右侧 filename.ext + size(截图 1)
|
||||
- 否则 → 左侧蓝圆 ↓ 图标(按 mime 取色)+ 右侧 filename + size(截图 2)
|
||||
- `TextBubble` 渲染 `text`,调 `autolink`(截图 3)
|
||||
- `VideoBubble` 初始显示 posterUrl + ▶️ + 时长,第一次点 inline `<video controls autoPlay>`,第二次点(已播放)调 `openVideoPlayer`(截图 4)
|
||||
- `ImageBubble` 单张图,点击调 `openLightbox([att], 0)`(截图 5)
|
||||
- `ImageWithTextBubble` 单图 + 下方文本(autolink)(截图 6)
|
||||
- `AlbumBubble` 2-4 格 grid,间距 2px;attachments.length > 4 时第 4 格 `bg-black/45 backdrop-blur-sm` 覆盖 `+N`;点任一格调 `openLightbox(images, index)`(截图 7)
|
||||
- `MessageBubble` 实现 spec §4 的 `pickBubble` 分发,右下角绝对定位时间戳 `text-[11px] text-neutral-500`
|
||||
- 所有 bubble 容器:`rounded-2xl bg-ark-panel p-3`(文本气泡 `px-4 py-2.5`),左对齐,无头像
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `MessageBubble.tsx`(含 pickBubble)
|
||||
2. 写 `bubbles/TextBubble.tsx`
|
||||
3. 写 `bubbles/FileDocBubble.tsx`
|
||||
4. 写 `bubbles/ImageBubble.tsx`
|
||||
5. 写 `bubbles/ImageWithTextBubble.tsx`
|
||||
6. 写 `bubbles/AlbumBubble.tsx`
|
||||
7. 写 `bubbles/VideoBubble.tsx`
|
||||
8. 在 dev 中临时挂一个 demo route 跑 MOCK_POSTS 全量渲染,目视检查 7 张截图对照
|
||||
|
||||
- unstarted: Task 5 — Stream 容器 + FilterChips + DaySeparator
|
||||
- Description: 顶层组件接管 fetch、分组、无限滚动、sticky 筛选
|
||||
- Dependencies: Task 2, Task 4
|
||||
- Acceptance Criteria:
|
||||
- `FilterChips`:sticky top,横向滚动 `overflow-x-auto whitespace-nowrap`,类型 chips(all/image/video/ppt/pdf/text/link/archive,沿用 `typeFilterLabel`)+ 语言 chips(all/zh-TW/zh-CN/en),改变时 reset cursor 并同步 URL `?type=&language=`
|
||||
- `DaySeparator`:胶囊样式,居中
|
||||
- `MessageStream`:
|
||||
- 接 `scope: { kind: "all" } | { kind: "category", slug: string }`
|
||||
- 调用 `usePostStream` + `useGroupedByDay`
|
||||
- 用 `IntersectionObserver` 监听底部 sentinel,触发 `loadMore`(loadingRef 守护避免重复)
|
||||
- 容器:手机 `max-w-full px-3 mx-auto`,md+ `max-w-[640px] mx-auto`
|
||||
- 空状态用 `t("noResults")`,错误用红色 inline 横幅 + 重试按钮
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 写 `FilterChips.tsx`
|
||||
2. 写 `DaySeparator.tsx`
|
||||
3. 写 `MessageStream.tsx`
|
||||
4. 在 dev demo route 挂 `<MessageStream scope={{ kind: "all" }} />` 验证滚动 + 筛选 + 分组
|
||||
|
||||
- unstarted: Task 6 — 重写 CategoryPage 与 Browse
|
||||
- Description: 两个页面瘦身为单一组件调用
|
||||
- Dependencies: Task 5, Task 7
|
||||
- Acceptance Criteria:
|
||||
- `src/pages/CategoryPage.tsx`:仅渲染分类标题 + `<MessageStream scope={{ kind: "category", slug }} />`,行数 < 30
|
||||
- `src/pages/Browse.tsx`:仅渲染页面标题 + `<MessageStream scope={{ kind: "all" }} />`,行数 < 30;不再读 `sort` / `tag` / `page` 参数
|
||||
- 排序 tabs 全部去掉
|
||||
- 移除对 `ResourceCard` / `ResourceListFooter` 的 import
|
||||
- `App.tsx` 路由保持 `/browse` 与 `/category/:slug` 不变
|
||||
- `npx tsc --noEmit` 通过
|
||||
- Steps:
|
||||
1. 改写 `CategoryPage.tsx`
|
||||
2. 改写 `Browse.tsx`
|
||||
3. 跑 dev server 在 `/browse` 与 `/category/<slug>` 验证
|
||||
|
||||
- unstarted: Task 7 — 移除收藏功能 + ResourceDetail
|
||||
- Description: 整套清理
|
||||
- Dependencies: Task 0(可与 Task 1-5 并行)
|
||||
- Acceptance Criteria:
|
||||
- 删除文件:`src/pages/FavoritesPage.tsx`、`src/pages/ResourceDetail.tsx`、`src/components/ResourceCard.tsx`、`src/components/ResourceListFooter.tsx`
|
||||
- `App.tsx` 移除 `/favorites` 路由;`/r/:id` 改为新组件 `PostRedirect`(mock 模式下从 `MOCK_POSTS` 找 slug;找不到 → navigate `/browse`)
|
||||
- `src/api.ts` 移除 `postFavoriteDelta`
|
||||
- 全代码无 `postFavoriteDelta` / `FavoritesPage` / `ResourceDetail` / `ResourceCard` / `ResourceListFooter` 引用
|
||||
- Home 中的 `/favorites` 入口(如有)移除
|
||||
- `src/i18n.tsx` 移除 `favorites` / `addFavorite` / `removeFavorite` 等收藏 key(三语言同步)
|
||||
- `npx tsc --noEmit` 通过(无 unused / 未引用错误)
|
||||
- Steps:
|
||||
1. `grep -rn "postFavoriteDelta\|FavoritesPage\|ResourceDetail\|ResourceCard\|ResourceListFooter\|/favorites\|/r/:id" src/` 列出所有引用点
|
||||
2. 写 `src/pages/PostRedirect.tsx`,挂到 `/r/:id` 路由
|
||||
3. 删除 4 个文件 + 修改 `App.tsx` + `api.ts` + `i18n.tsx`
|
||||
4. 再 grep 一次确认清零
|
||||
5. `npx tsc --noEmit`
|
||||
|
||||
- unstarted: Task 8 — 验证 + 文档 + API 契约抽出
|
||||
- Description: 全量验证 + 给后端的接口文档
|
||||
- Dependencies: Task 6, Task 7
|
||||
- Acceptance Criteria:
|
||||
- `npx tsc --noEmit` 通过
|
||||
- `npm run format:check` 通过(若不通过先 `npm run format`)
|
||||
- `npm test` 全绿
|
||||
- `npm run build` 成功
|
||||
- 手动视觉验证:Chrome DevTools iPhone 14 Pro 视口逐一对照 7 张参考截图
|
||||
- 新增 `.unipi/docs/specs/2026-05-25-posts-api-contract.md`:从主 spec §1-§2 抽出后端需要的所有内容(Post/Attachment schema、6 个 endpoint、删除清单、迁移要求)
|
||||
- 更新 `README.md` 增加 `VITE_USE_MOCK_POSTS` 段落
|
||||
- 不 commit、不 push(等 Terry 显式确认)
|
||||
- Steps:
|
||||
1. 跑全套检查命令
|
||||
2. dev server 手机视口检查
|
||||
3. 写 API 契约文档
|
||||
4. 改 README
|
||||
5. 报告完成,等 Terry 审阅与 commit 指令
|
||||
|
||||
## Risks
|
||||
|
||||
- **mock 数据视觉与真实数据偏差**:mock 用 picsum 占位图,可能掩盖真实图片不同宽高比的边界情况。缓解:mockPosts 中包含横图 / 竖图 / 接近正方形三种比例样本。
|
||||
- **video poster 在 mock 模式不易获取**:用一张本地 SVG 占位即可,避免依赖外部链接。
|
||||
- **i18n 删除收藏 key 后未使用的引用**:tsconfig 的 `noUnusedLocals` 不覆盖 i18n key 的字符串引用,需手动 grep。
|
||||
- **`PostRedirect` 在真接口模式下的实现**:当前先写 mock 分支,真接口分支 TODO 注释标明等 `/api/posts/:id` ready 后补。
|
||||
- **infinite scroll + URL 同步**:用户改 filter chip 时既要 reset cursor 又要更新 URL,注意避免 `setSearchParams` 触发额外 effect 循环。
|
||||
- **后端最终 schema 与本 spec 偏差**:如有偏差,必须先回 spec 改契约,再调前端类型,避免散点修改。
|
||||
|
||||
## Out of Scope(本 plan 不涵盖,遵循 spec)
|
||||
|
||||
- Home 页布局调整
|
||||
- Admin 后台 Post 编辑器
|
||||
- 真实后端 API 实现 + 数据迁移
|
||||
- 长按菜单 / 评论 / Reaction
|
||||
- 桌面端多列布局
|
||||
- SEO 优化
|
||||
@@ -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" }
|
||||
```
|
||||
|
||||
Response:204 或 `{ ok: true }`。
|
||||
|
||||
用途:记录用户搜索词;失败不阻断用户体验。
|
||||
|
||||
---
|
||||
|
||||
### 2.8 下载统计(可选)
|
||||
|
||||
文件下载目前前端可直接打开 `Attachment.url`。如果后端需要统计下载,提供:
|
||||
|
||||
```http
|
||||
POST /api/posts/:postId/attachments/:attachmentId/download
|
||||
```
|
||||
|
||||
Response:204 或 `{ 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>
|
||||
```
|
||||
|
||||
Response:204 或 `{ 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
|
||||
175
.unipi/docs/specs/2026-05-25-posts-api-contract.md
Normal file
175
.unipi/docs/specs/2026-05-25-posts-api-contract.md
Normal 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~N;text-only post 时为 []
|
||||
isRecommended: boolean;
|
||||
publishedAt: string; // ISO 8601;用于排序 + 日期分组
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type PostListResponse = {
|
||||
items: Post[];
|
||||
nextCursor?: string; // 不透明 cursor;undefined = 没有下一页
|
||||
};
|
||||
```
|
||||
|
||||
### 关键约定
|
||||
|
||||
- **图片当文档**(在前端显示为「文件下载卡」):`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`。
|
||||
@@ -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~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
|
||||
- 桌面端多列布局
|
||||
Reference in New Issue
Block a user