Browse Source

docs(plan): admin user stats and public profile links

Made-with: Cursor
main
npmrun 6 hours ago
parent
commit
f391d578fc
  1. 228
      docs/superpowers/plans/2026-04-18-admin-user-stats-implementation-plan.md

228
docs/superpowers/plans/2026-04-18-admin-user-stats-implementation-plan.md

@ -0,0 +1,228 @@
# 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`
```typescript
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:
```bash
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`
```typescript
/** 与站内路由 `/@${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:
```bash
cd /home/dash/projects/person-panel && bun test app/utils/public-profile-url.test.ts
```
Expected: PASS。
- [ ] **Step 5: Commit**
```bash
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` 中加入相关子查询计数**
在文件顶部增加导入:
```typescript
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 一致):
```typescript
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. 以管理员账号登录(`role` 为 `admin`)。
2. 在浏览器开发者工具中请求 `GET /api/admin/users`(或 `curl` 带上有效会话 Cookie)。
3. 确认 JSON 中每个 `users[]` 元素含 `postCount`、`timelineEventCount`、`rssFeedCount`,且为非负整数;与库内实际行数一致(可任选一名用户用 DB 客户端 `select count(*)` 对照)。
- [ ] **Step 3: 回归测试**
Run:
```bash
cd /home/dash/projects/person-panel && bun test
```
Expected: 全部 PASS(含 Task 1 新增测试)。
- [ ] **Step 4: Commit**
```bash
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>` 顶部增加:
```typescript
import { buildPublicProfileAbsoluteUrl } from '../../../../utils/public-profile-url'
```
`rows` 的 ref 类型中每条记录扩展为包含 `postCount: number`、`timelineEventCount: number`、`rssFeedCount: number`(与 API 对齐)。
- [ ] **Step 2: 增加 `useToast` 与复制函数**
```typescript
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**
```bash
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 式检查点。
你更倾向哪一种?
Loading…
Cancel
Save