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.
 
 
 
 

7.9 KiB

Admin 用户列表统计与公开页链接 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: 在管理员用户列表接口与页面中,为每位用户展示文章 / 时间线 / RSS 源数量,并在存在 publicSlug 时提供站内打开与复制完整公开页 URL。

Architecture: 在单一 GET /api/admin/users 响应中通过 Drizzle + SQLite 为每行附加三个计数(相关子查询 COUNT(*),不按 visibility 过滤);前端在用户管理表格增加列与操作,复制 URL 使用 URL 构造函数与可单测的小工具函数生成,失败时用 Nuxt UI useToast 提示。

Tech Stack: Nuxt 4 / Nitro、Vue 3、Drizzle ORM 0.45、SQLite(drizzle-pkg)、Bun test、@nuxt/ui 4.6。

Spec: docs/superpowers/specs/2026-04-18-admin-user-stats-design.md


文件结构(将创建 / 修改)

路径 职责
server/api/admin/users.get.ts 在现有分页查询的 select 中增加三个计数字段并保持 requireAdmin
app/utils/public-profile-url.ts 根据 originpublicSlug 生成与站内 /@slug 一致的绝对 URL(供复制与单测)
app/utils/public-profile-url.test.ts 对 URL 构造函数的回归测试
app/pages/me/admin/users/index.vue 表格列、打开链接、useToast + 剪贴板复制

Task 1: 公开页绝对 URL 小工具(TDD)

Files:

  • Create: app/utils/public-profile-url.ts

  • Create: app/utils/public-profile-url.test.ts

  • Step 1: 编写失败测试

app/utils/public-profile-url.test.ts

import { describe, expect, test } from "bun:test";
import { buildPublicProfileAbsoluteUrl } from "./public-profile-url";

describe("buildPublicProfileAbsoluteUrl", () => {
  test("joins origin with /@slug path", () => {
    expect(buildPublicProfileAbsoluteUrl("https://example.com", "my-slug")).toBe(
      "https://example.com/@my-slug",
    );
  });

  test("normalizes origin without trailing slash", () => {
    expect(buildPublicProfileAbsoluteUrl("https://example.com/", "ab")).toBe("https://example.com/@ab");
  });
});
  • Step 2: 运行测试确认失败

Run:

cd /home/dash/projects/person-panel && bun test app/utils/public-profile-url.test.ts

Expected: FAIL(模块或函数不存在)。

  • Step 3: 最小实现

app/utils/public-profile-url.ts

/** 与站内路由 `/@${publicSlug}` 对应的绝对 URL(用于复制到剪贴板)。 */
export function buildPublicProfileAbsoluteUrl(origin: string, publicSlug: string): string {
  const base = origin.replace(/\/+$/, "");
  const path = `/@${publicSlug}`;
  return new URL(path, `${base}/`).href;
}
  • Step 4: 运行测试确认通过

Run:

cd /home/dash/projects/person-panel && bun test app/utils/public-profile-url.test.ts

Expected: PASS。

  • Step 5: Commit
cd /home/dash/projects/person-panel
git add app/utils/public-profile-url.ts app/utils/public-profile-url.test.ts
git commit -m "feat(admin-ui): add public profile absolute URL helper"

Task 2: 扩展 GET /api/admin/users 返回三个计数

Files:

  • Modify: server/api/admin/users.get.ts

  • Step 1: 在 select 中加入相关子查询计数

在文件顶部增加导入:

import { sql } from "drizzle-orm";
import { posts, timelineEvents } from "drizzle-pkg/lib/schema/content";
import { rssFeeds } from "drizzle-pkg/lib/schema/rss";

dbGlobal.select({ ... }) 的对象扩展为在现有字段之外增加(字段名与 spec 一致):

postCount: sql<number>`(select count(*) from ${posts} where ${posts.userId} = ${users.id})`.mapWith(
  Number,
),
timelineEventCount: sql<number>`(select count(*) from ${timelineEvents} where ${timelineEvents.userId} = ${users.id})`.mapWith(
  Number,
),
rssFeedCount: sql<number>`(select count(*) from ${rssFeeds} where ${rssFeeds.userId} = ${users.id})`.mapWith(
  Number,
),

保持 from(users)orderBy(desc(users.id))limit/offset 不变。若本地运行时报错(与 Drizzle 生成 SQL 有关),可改为「三个 groupBy(userId) 子查询 leftJoin」的等价写法,但须保持响应字段名不变。

  • Step 2: 手工验证 API
  1. 以管理员账号登录(roleadmin)。
  2. 在浏览器开发者工具中请求 GET /api/admin/users(或 curl 带上有效会话 Cookie)。
  3. 确认 JSON 中每个 users[] 元素含 postCounttimelineEventCountrssFeedCount,且为非负整数;与库内实际行数一致(可任选一名用户用 DB 客户端 select count(*) 对照)。
  • Step 3: 回归测试

Run:

cd /home/dash/projects/person-panel && bun test

Expected: 全部 PASS(含 Task 1 新增测试)。

  • Step 4: Commit
cd /home/dash/projects/person-panel
git add server/api/admin/users.get.ts
git commit -m "feat(admin): include per-user content counts in users list API"

Task 3: 用户管理页表格与复制链接

Files:

  • Modify: app/pages/me/admin/users/index.vue

  • Step 1: 扩展行类型与导入

<script setup> 顶部增加:

import { buildPublicProfileAbsoluteUrl } from '../../../../utils/public-profile-url'

rows 的 ref 类型中每条记录扩展为包含 postCount: numbertimelineEventCount: numberrssFeedCount: number(与 API 对齐)。

  • Step 2: 增加 useToast 与复制函数
const toast = useToast()

async function copyPublicUrl(publicSlug: string) {
  const href = buildPublicProfileAbsoluteUrl(window.location.origin, publicSlug)
  try {
    await navigator.clipboard.writeText(href)
    toast.add({ title: '已复制公开页链接', color: 'success' })
  } catch {
    toast.add({ title: '复制失败', color: 'error' })
  }
}
  • Step 3: 更新表头与表体

<thead>状态 列之后增加列头:文章时间线RSS 源公开页(或更短文案,与布局一致)。

<tbody> 对应 <tr> 中:

  • 三列分别输出 u.postCountu.timelineEventCountu.rssFeedCount

  • publicSlug 有值(建议 u.publicSlug?.trim()NuxtLinkto`/@${u.publicSlug.trim()}`;旁加小号 UButtonvariant="soft")触发 copyPublicUrl(u.publicSlug.trim())

  • publicSlug:该格显示 ,不提供链接与复制按钮。

  • Step 4: 手工验证 UI

以管理员访问 /me/admin/users:确认三列数字与 API 一致;有 slug 用户可打开 /@slug;复制后粘贴得到与 URL('/@slug', origin).href 一致的地址;无 slug 用户无按钮;故意在浏览器拒绝剪贴板权限时能看到错误 toast(可选)。

  • Step 5: Commit
cd /home/dash/projects/person-panel
git add app/pages/me/admin/users/index.vue
git commit -m "feat(admin-ui): show per-user stats and public profile actions"

计划自检(对照 spec)

  1. Spec 覆盖: §4 三个字段 → Task 2;§5 表格、打开、复制、URL 语义、无 slug 占位、复制失败提示 → Task 1 + Task 3;§2 全量计数不按 visibility → Task 2 SQL。无缺口。
  2. 占位符扫描: 无 TBD/TODO。
  3. 命名一致: API 使用 postCount / timelineEventCount / rssFeedCount;前后端与 spec 表格一致。

执行交接

计划已保存到 docs/superpowers/plans/2026-04-18-admin-user-stats-implementation-plan.md

两种执行方式:

  1. Subagent-Driven(推荐) — 每个 Task 派生子代理,Task 之间做简短审查,迭代快。
  2. Inline Execution — 本会话内按 Task 顺序执行,使用 executing-plans 式检查点。

你更倾向哪一种?