1 changed files with 472 additions and 0 deletions
@ -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`** 包裹 **`<NuxtPage />`**,子路由 **`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` | 「媒体」壳:标题、子导航(资源库 / 孤儿清理)、`<NuxtPage />` | |
|||
| `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 |
|||
<script setup lang="ts"> |
|||
const route = useRoute() |
|||
|
|||
const tabs = [ |
|||
{ label: '资源库', to: '/me/media' }, |
|||
{ label: '孤儿清理', to: '/me/media/orphans' }, |
|||
] as const |
|||
|
|||
function tabActive(to: string) { |
|||
if (to === '/me/media') { |
|||
return route.path === '/me/media' || route.path === '/me/media/' |
|||
} |
|||
return route.path === to || route.path.startsWith(`${to}/`) |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<UContainer class="py-8 space-y-6"> |
|||
<div> |
|||
<h1 class="text-2xl font-semibold"> |
|||
媒体 |
|||
</h1> |
|||
<p class="text-sm text-muted mt-1"> |
|||
上传与管理图片;孤儿资源请在「孤儿清理」中处理。 |
|||
</p> |
|||
</div> |
|||
|
|||
<div class="flex flex-wrap gap-2 border-b border-default pb-3"> |
|||
<UButton |
|||
v-for="t in tabs" |
|||
:key="t.to" |
|||
:to="t.to" |
|||
size="sm" |
|||
:variant="tabActive(t.to) ? 'solid' : 'ghost'" |
|||
:color="tabActive(t.to) ? 'primary' : 'neutral'" |
|||
> |
|||
{{ t.label }} |
|||
</UButton> |
|||
</div> |
|||
|
|||
<NuxtPage /> |
|||
</UContainer> |
|||
</template> |
|||
``` |
|||
|
|||
若项目里 `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`** 或 `<input type="file" multiple accept="image/png,image/jpeg,image/jpg,image/webp">` + 上传按钮;`FormData` 字段名 **`file`**(与 `upload.post.ts` 的 `upload.array('file', 10)` 一致),`POST /api/file/upload` |
|||
- 网格:`grid gap-4 sm:grid-cols-2 md:grid-cols-3` 等;每张卡片 `<img :src="item.publicPath" :alt="item.storageKey" class="..." loading="lazy">`(`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 顺序开始写代码」** 即可。 |
|||
Loading…
Reference in new issue