# 媒体库与 `/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
媒体
上传与管理图片;孤儿资源请在「孤儿清理」中处理。
{{ t.label }}
```
若项目里 `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 为 `` `` ``;成功/失败 `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 顺序开始写代码」** 即可。