16 KiB
媒体库与 /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 + '/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 |
「媒体」壳:标题、子导航(资源库 / 孤儿清理)、<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:
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:
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
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 等):
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
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:
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
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 保持与项目风格一致):
<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
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},类型与 APIpayload一致 -
模板:顶部
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
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
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(及注释「桌面端:下拉内…」)中 插入:
{ 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
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 顺序开始写代码」 即可。