# 媒体库与 `/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 + '/static/media/' + 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: `/static/media/${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 顺序开始写代码」** 即可。