From 9b86f2325292477d71c87342f602bddac60e01f5 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 19 Apr 2026 10:49:43 +0800 Subject: [PATCH] docs: add media library implementation plan Made-with: Cursor --- ...2026-04-19-media-library-implementation-plan.md | 472 +++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md diff --git a/docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md b/docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md new file mode 100644 index 0000000..666577b --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md @@ -0,0 +1,472 @@ +# 媒体库与 `/me/media` 壳 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 按 `docs/superpowers/specs/2026-04-19-media-library-design.md` 实现 **`GET /api/me/media/assets`**、**`/me/media` 父壳 + 资源库子页**、将 **孤儿页** 纳入同一壳;更新 **`AppShell`** 与 **`/me` 控制台首页** 导航。 + +**Architecture:** 列表数据在 `server/service/media` 中新增「按用户分页列出 `media_assets` + 批量聚合 `media_refs` 计数」函数(两阶段查询,避免复杂 join)。Nuxt 使用 **`app/pages/me/media.vue`** 包裹 **``**,子路由 **`media/index.vue`**(资源库)、**`media/orphans.vue`**(现有页面逻辑迁移路径不变)。前端复制使用 **`window.location.origin + '/public/assets/' + storageKey`**。上传复用 **`POST /api/file/upload`** 与资料页相同的 `fetchData` + `FormData` 模式。 + +**Tech Stack:** Nuxt 4.4、Nitro、`#server/service/media`(Drizzle + `dbGlobal`)、`mediaAssets` / `mediaRefs`、`defineWrappedResponseHandler`、`R.success`、`event.context.auth.requireUser()`、Bun test(`bun:test`)、Nuxt UI(`UContainer`、`UCard`、`UPagination`、`UButton`、`UFileUpload` 或隐藏 file input,与现有页面风格一致)。 + +**Spec:** `docs/superpowers/specs/2026-04-19-media-library-design.md` + +**验证:** 每任务完成后执行 `bun run build`;单测用 `bun test <路径>`。无全站 HTTP 集成测试框架,**401 / 用户隔离**以代码审查 + 手测登录态为主。 + +--- + +## 文件结构(将创建 / 修改) + +| 路径 | 职责 | +|------|------| +| `server/utils/me-media-assets-query.ts` | 解析 `page`、`pageSize`(仅允许 10 / 20 / 50);供 API 与单测使用 | +| `server/utils/me-media-assets-query.test.ts` | 上述解析函数的单元测试 | +| `server/service/media/index.ts` | 新增 `listUserMediaAssetsPage(userId, page, pageSize)`:总数 + 当前页行 + 每行 `refCount` | +| `server/api/me/media/assets.get.ts` | 鉴权、query 解析、调用 service、返回 `R.success({ items, total })` | +| `app/pages/me/media.vue` | 「媒体」壳:标题、子导航(资源库 / 孤儿清理)、`` | +| `app/pages/me/media/index.vue` | 资源库:上传、网格、分页、复制 URL / Markdown、空态与错误 toast | +| `app/pages/me/media/orphans.vue` | **仅必要时**调整 `definePageMeta` / 布局相关(业务与 API 尽量不变) | +| `app/components/AppShell.vue` | 控制台子导航增加「媒体」;更新「四个子链接」类注释 | +| `app/pages/me/index.vue` | 合并「文章媒体清理」为单一「媒体」卡片,指向 `/me/media` | + +--- + +### Task 1: 解析 `page` / `pageSize` 工具与单测 + +**Files:** + +- Create: `server/utils/me-media-assets-query.ts` +- Create: `server/utils/me-media-assets-query.test.ts` + +- [ ] **Step 1: 写失败单测(期望函数尚不存在)** + +在 `server/utils/me-media-assets-query.test.ts`: + +```typescript +import { describe, expect, test } from "bun:test"; +import { parseMeMediaAssetsQuery } from "./me-media-assets-query"; + +describe("parseMeMediaAssetsQuery", () => { + test("defaults page to 1 and pageSize to 20", () => { + expect(parseMeMediaAssetsQuery({})).toEqual({ page: 1, pageSize: 20 }); + expect(parseMeMediaAssetsQuery({ page: undefined, pageSize: undefined })).toEqual({ + page: 1, + pageSize: 20, + }); + }); + + test("accepts pageSize 10 20 50", () => { + expect(parseMeMediaAssetsQuery({ pageSize: "10" })).toEqual({ page: 1, pageSize: 10 }); + expect(parseMeMediaAssetsQuery({ pageSize: "50" })).toEqual({ page: 1, pageSize: 50 }); + }); + + test("throws 400 statusMessage when pageSize is not allowed", () => { + expect(() => parseMeMediaAssetsQuery({ pageSize: "15" })).toThrow(); + try { + parseMeMediaAssetsQuery({ pageSize: "99" }); + } catch (e) { + expect(e).toMatchObject({ statusCode: 400 }); + } + }); +}); +``` + +- [ ] **Step 2: 运行单测确认失败** + +Run: `bun test server/utils/me-media-assets-query.test.ts` + +Expected: FAIL(模块或导出不存在) + +- [ ] **Step 3: 实现 `parseMeMediaAssetsQuery`** + +新建 `server/utils/me-media-assets-query.ts`: + +```typescript +const ALLOWED_PAGE_SIZES = new Set([10, 20, 50]); + +function parsePositiveInt(raw: string | undefined, fallback: number, label: string): number { + if (raw === undefined || raw === "") { + return fallback; + } + const n = Number(raw); + if (!Number.isInteger(n) || n < 1) { + throw createError({ statusCode: 400, statusMessage: `${label} 须为正整数` }); + } + return n; +} + +export type MeMediaAssetsQuery = { + page: number; + pageSize: number; +}; + +/** 从 `getQuery(event)` 的字符串值解析;pageSize 仅允许 10 / 20 / 50。 */ +export function parseMeMediaAssetsQuery(q: { + page?: string; + pageSize?: string; +}): MeMediaAssetsQuery { + const page = parsePositiveInt(q.page, 1, "page"); + const pageSizeRaw = parsePositiveInt(q.pageSize, 20, "pageSize"); + if (!ALLOWED_PAGE_SIZES.has(pageSizeRaw)) { + throw createError({ + statusCode: 400, + statusMessage: "pageSize 须为 10、20 或 50", + }); + } + return { page, pageSize: pageSizeRaw }; +} +``` + +- [ ] **Step 4: 运行单测确认通过** + +Run: `bun test server/utils/me-media-assets-query.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/utils/me-media-assets-query.ts server/utils/me-media-assets-query.test.ts +git commit -m "feat(media): add me media assets query parser and tests" +``` + +--- + +### Task 2: Service `listUserMediaAssetsPage` + +**Files:** + +- Modify: `server/service/media/index.ts` + +- [ ] **Step 1: 在 `server/service/media/index.ts` 追加导出函数** + +在文件末尾(或与其他 list 函数邻近)新增(注意与现有 `import` 一致,已存在则复用 `count`、`desc`、`eq`、`inArray` 等): + +```typescript +export type UserMediaAssetListRow = { + id: number; + storageKey: string; + mime: string; + sizeBytes: number; + createdAt: Date; + refCount: number; +}; + +export async function listUserMediaAssetsPage( + userId: number, + page: number, + pageSize: number, +): Promise<{ items: UserMediaAssetListRow[]; total: number }> { + const offset = (page - 1) * pageSize; + + const [{ total }] = await dbGlobal + .select({ total: count() }) + .from(mediaAssets) + .where(eq(mediaAssets.userId, userId)); + + const rows = await dbGlobal + .select({ + id: mediaAssets.id, + storageKey: mediaAssets.storageKey, + mime: mediaAssets.mime, + sizeBytes: mediaAssets.sizeBytes, + createdAt: mediaAssets.createdAt, + }) + .from(mediaAssets) + .where(eq(mediaAssets.userId, userId)) + .orderBy(desc(mediaAssets.createdAt)) + .limit(pageSize) + .offset(offset); + + if (rows.length === 0) { + return { items: [], total }; + } + + const ids = rows.map((r) => r.id); + const refRows = await dbGlobal + .select({ + assetId: mediaRefs.assetId, + c: count(), + }) + .from(mediaRefs) + .where(inArray(mediaRefs.assetId, ids)) + .groupBy(mediaRefs.assetId); + + const refMap = new Map(refRows.map((r) => [r.assetId, r.c])); + + const items: UserMediaAssetListRow[] = rows.map((r) => ({ + id: r.id, + storageKey: r.storageKey, + mime: r.mime, + sizeBytes: r.sizeBytes, + createdAt: r.createdAt, + refCount: refMap.get(r.id) ?? 0, + })); + + return { items, total }; +} +``` + +确保文件顶部已从 `drizzle-orm` 导入 `count`、`desc`、`eq`、`inArray`(若无 `inArray` 则追加)。 + +- [ ] **Step 2: Commit** + +```bash +git add server/service/media/index.ts +git commit -m "feat(media): add listUserMediaAssetsPage with ref counts" +``` + +--- + +### Task 3: API `GET /api/me/media/assets` + +**Files:** + +- Create: `server/api/me/media/assets.get.ts` + +- [ ] **Step 1: 实现 handler** + +`server/api/me/media/assets.get.ts`: + +```typescript +import { listUserMediaAssetsPage } from "#server/service/media"; +import { parseMeMediaAssetsQuery } from "#server/utils/me-media-assets-query"; + +export default defineWrappedResponseHandler(async (event) => { + const user = await event.context.auth.requireUser(); + const q = getQuery(event); + const { page, pageSize } = parseMeMediaAssetsQuery({ + page: typeof q.page === "string" ? q.page : undefined, + pageSize: typeof q.pageSize === "string" ? q.pageSize : undefined, + }); + + const { items, total } = await listUserMediaAssetsPage(user.id, page, pageSize); + + const payload = { + items: items.map((r) => ({ + id: r.id, + storageKey: r.storageKey, + publicPath: `/public/assets/${r.storageKey}`, + mime: r.mime, + sizeBytes: r.sizeBytes, + createdAt: r.createdAt.toISOString(), + refCount: r.refCount, + })), + total, + }; + + return R.success(payload); +}); +``` + +- [ ] **Step 2: 手测(开发服务器)** + +Run: `bun run dev`,登录后请求 `GET /api/me/media/assets?page=1&pageSize=20`(浏览器或 curl 带 cookie)。 + +Expected: `code === 0`,`data.items` 为数组;未登录应 **401**(与现有 `/api/me/*` 一致)。 + +- [ ] **Step 3: Commit** + +```bash +git add server/api/me/media/assets.get.ts +git commit -m "feat(api): add GET /api/me/media/assets for media library" +``` + +--- + +### Task 4: 父壳 `app/pages/me/media.vue` + +**Files:** + +- Create: `app/pages/me/media.vue` + +- [ ] **Step 1: 实现壳组件** + +`app/pages/me/media.vue`(子页使用 `definePageMeta` 设标题;壳可用 `useRoute` 高亮 tab;导航用 `NuxtLink` 或 `UButton` `to` 保持与项目风格一致): + +```vue + + + +``` + +若项目里 `UButton` 的 `to` 与 `variant` 组合有既定用法,以对齐 **`orphans.vue` / `AppShell`** 为准微调。 + +- [ ] **Step 2: `bun run build`** + +Expected: 构建成功。 + +- [ ] **Step 3: Commit** + +```bash +git add app/pages/me/media.vue +git commit -m "feat(ui): add /me/media parent shell with sub nav" +``` + +--- + +### Task 5: 资源库页 `app/pages/me/media/index.vue` + +**Files:** + +- Create: `app/pages/me/media/index.vue` + +- [ ] **Step 1: 实现页面** + +要点(与 `app/pages/me/media/orphans.vue` 对齐模式:`useToast`、`useClientApi`、`useAuthSession` 按需): + +- `definePageMeta({ title: '媒体库' })` +- state:`page`、`pageSize`、`items`、`total`、`loading`、`uploading` +- `load()`:`fetchData` 请求 **`/api/me/media/assets?page=${page}&pageSize=${pageSize}`**,类型与 API `payload` 一致 +- 模板:顶部 **`UFileUpload`** 或 `` + 上传按钮;`FormData` 字段名 **`file`**(与 `upload.post.ts` 的 `upload.array('file', 10)` 一致),`POST /api/file/upload` +- 网格:`grid gap-4 sm:grid-cols-2 md:grid-cols-3` 等;每张卡片 ``(`publicPath` 为相对路径即可用于 `img src`) +- 文案:`formatBytes` / `formatDt` 可从 `orphans.vue` 复制小函数,避免新依赖 +- **复制**:`const absoluteUrl = `${window.location.origin}${item.publicPath}`;`navigator.clipboard.writeText`;Markdown 为 `` `![](${absoluteUrl})` ``;成功/失败 `toast.add` +- 展示 **`refCount`**(小字「引用数:n」) +- **`UPagination`**:`total`、`page-size` 绑定;`pageSize` 变更时重置 `page` 为 1 并 `load` +- 空列表:说明 + 上传引导 + +- [ ] **Step 2: `bun run build`** + +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add app/pages/me/media/index.vue +git commit -m "feat(ui): add media library page at /me/media" +``` + +--- + +### Task 6: 孤儿页与壳的协同 + +**Files:** + +- Modify: `app/pages/me/media/orphans.vue`(按需) + +- [ ] **Step 1: 检查布局重复** + +打开 `/me/media/orphans`:若孤儿页根节点仍有 **`UContainer` + 大标题**,会与父壳重复。若存在: + +- 去掉孤儿页外层 **`UContainer` / 重复标题**(保留筛选、表格、弹窗等业务块),或改为仅 **卡片内** 标题为「图片孤儿审查」。 + +**不要**改动孤儿列表 API 路径与删除逻辑。 + +- [ ] **Step 2: `bun run build` + 手测两条路由** + +`/me/media`、`/me/media/orphans` 导航与高亮正确。 + +- [ ] **Step 3: Commit** + +```bash +git add app/pages/me/media/orphans.vue +git commit -m "fix(ui): avoid duplicate chrome on media orphans under shell" +``` + +若 Step 1 确认无需修改,可跳过 commit,在计划中勾选并注明「无重复容器,跳过」。 + +--- + +### Task 7: `AppShell` 与 `/me` 首页 + +**Files:** + +- Modify: `app/components/AppShell.vue` +- Modify: `app/pages/me/index.vue` + +- [ ] **Step 1: `AppShell.vue`** + +在 `consoleSubNav`(及注释「桌面端:下拉内…」)中 **插入**: + +```typescript +{ label: '媒体', to: '/me/media', icon: 'i-lucide-images' }, +``` + +位置建议:在「RSS」之后或「文章」之后,与产品一致即可。同步 **`mobileMenuItems`** 里控制台分组数组。 + +- [ ] **Step 2: `app/pages/me/index.vue`** + +将原「文章媒体清理」卡片 **替换** 为一块 **「媒体」**: + +- 标题:**媒体** +- 描述:**资源库上传与复制链接;孤儿图片审查与清理。** +- 按钮:`to="/me/media"`,文案 **进入** + +删除单独指向 **`/me/media/orphans`** 的首页卡片(spec:单一入口)。 + +- [ ] **Step 3: `bun run build`** + +- [ ] **Step 4: Commit** + +```bash +git add app/components/AppShell.vue app/pages/me/index.vue +git commit -m "feat(nav): add media console link and merge dashboard card" +``` + +--- + +## Spec 对照(自检) + +| Spec 章节 | 对应任务 | +|-----------|----------| +| 路由 `/me/media`、`/me/media/orphans` + 父壳 | Task 4、Task 6 | +| 资源库列表、分页、排序、refCount | Task 2、Task 5 | +| 上传 `POST /api/file/upload` | Task 5 | +| 复制 URL + Markdown(绝对 origin) | Task 5 | +| `GET /api/me/media/assets` 鉴权与 query | Task 1、Task 3 | +| AppShell + `/me` 卡片 | Task 7 | +| 首版不做库内删除、不做编辑器集成 | 计划中无对应任务(符合) | + +**占位符扫描:** 无 TBD。 + +**类型一致性:** API 返回字段名 `publicPath`、`refCount`、`createdAt`(ISO 字符串)与 Task 5 前端类型一致。 + +--- + +**Plan complete and saved to `docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md`. Two execution options:** + +**1. Subagent-Driven(推荐)** — 每个 Task 派生子代理、任务间复核,迭代快 + +**2. Inline Execution** — 本会话内按 Task 执行,使用 executing-plans、批量推进并设检查点 + +**你更倾向哪一种?** 若不需要子代理流程,直接回复 **「在本会话实现」** 或 **「按 Task 顺序开始写代码」** 即可。