1 changed files with 228 additions and 0 deletions
@ -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…
Reference in new issue