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 |
根据 origin 与 publicSlug 生成与站内 /@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
- 以管理员账号登录(
role为admin)。 - 在浏览器开发者工具中请求
GET /api/admin/users(或curl带上有效会话 Cookie)。 - 确认 JSON 中每个
users[]元素含postCount、timelineEventCount、rssFeedCount,且为非负整数;与库内实际行数一致(可任选一名用户用 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: number、timelineEventCount: number、rssFeedCount: 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.postCount、u.timelineEventCount、u.rssFeedCount。 -
publicSlug有值(建议u.publicSlug?.trim()):NuxtLink的to为`/@${u.publicSlug.trim()}`;旁加小号UButton(variant="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)
- Spec 覆盖: §4 三个字段 → Task 2;§5 表格、打开、复制、
URL语义、无 slug 占位、复制失败提示 → Task 1 + Task 3;§2 全量计数不按 visibility → Task 2 SQL。无缺口。 - 占位符扫描: 无 TBD/TODO。
- 命名一致: API 使用
postCount/timelineEventCount/rssFeedCount;前后端与 spec 表格一致。
执行交接
计划已保存到 docs/superpowers/plans/2026-04-18-admin-user-stats-implementation-plan.md。
两种执行方式:
- Subagent-Driven(推荐) — 每个 Task 派生子代理,Task 之间做简短审查,迭代快。
- Inline Execution — 本会话内按 Task 顺序执行,使用 executing-plans 式检查点。
你更倾向哪一种?