You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

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 / mediaRefsdefineWrappedResponseHandlerR.successevent.context.auth.requireUser()、Bun test(bun:test)、Nuxt UI(UContainerUCardUPaginationUButtonUFileUpload 或隐藏 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 解析 pagepageSize(仅允许 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 一致,已存在则复用 countdesceqinArray 等):

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 导入 countdesceqinArray(若无 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 === 0data.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;导航用 NuxtLinkUButton 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>

若项目里 UButtontovariant 组合有既定用法,以对齐 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 对齐模式:useToastuseClientApiuseAuthSession 按需):

  • definePageMeta({ title: '媒体库' })

  • state:pagepageSizeitemstotalloadinguploading

  • 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.tsupload.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」)

  • UPaginationtotalpage-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 返回字段名 publicPathrefCountcreatedAt(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 顺序开始写代码」 即可。