1 changed files with 524 additions and 0 deletions
@ -0,0 +1,524 @@ |
|||
# 发现页与资料「出现在发现中」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:** 为登录用户提供 `/discover` 用户名录(仅含自愿曝光且具备公开 slug 的活跃用户)、在 `AppShell` 增加「发现」入口,并在 `/me/profile` 配置发现开关与地址展示;数据经 Drizzle 迁移与受鉴权保护的列表 API 提供。 |
|||
|
|||
**Architecture:** 在 SQLite `users` 表增加三个发现相关列;`server/service/discover` 封装分页列表查询与 DTO 映射(头像规则与 `about.get` 一致:`avatarVisibility === "public"` 才返回 URL);`GET /api/discover/users` 走现有默认 API 鉴权;前端发现页用 `useClientApi` 拉取列表,资料页扩展表单字段与 `PUT /api/me/profile`。 |
|||
|
|||
**Tech Stack:** Nuxt 4、Vue 3、Drizzle ORM(SQLite)、better-sqlite3、Zod、Bun test、`normalizePublicListPage`、现有 `R.success` / `defineWrappedResponseHandler` 模式。 |
|||
|
|||
**Spec:** `docs/superpowers/specs/2026-04-18-discover-page-design.md` |
|||
|
|||
--- |
|||
|
|||
## File map(将创建或修改) |
|||
|
|||
| 路径 | 职责 | |
|||
| --- | --- | |
|||
| `packages/drizzle-pkg/database/sqlite/schema/auth.ts` | `users` 表新列:`discoverVisible`、`discoverLocation`、`discoverShowLocation` | |
|||
| `packages/drizzle-pkg/migrations/0006_*.sql`(`db:generate` 产出) | SQLite `ALTER TABLE` / 新表迁移片段 | |
|||
| `server/constants/discover-list.ts` | `DISCOVER_LIST_PAGE_SIZE`(建议值 `10`,与 `PUBLIC_LIST_PAGE_SIZE` 相同) | |
|||
| `server/utils/discover-card.ts` | 纯函数:卡片头像 URL、卡片地址文案(可单测) | |
|||
| `server/utils/discover-card.test.ts` | 上述函数单元测试 | |
|||
| `server/service/discover/index.ts` | `listDiscoverUsersPage(page)`:where + count + DTO | |
|||
| `server/api/discover/users.get.ts` | 分页列表 HTTP 处理器 | |
|||
| `server/service/profile/index.ts` | `updateProfile` / `getProfileRow` 扩展 discover 字段与 zod | |
|||
| `server/api/me/profile.put.ts` | body 类型与 `updateProfile` 传参 | |
|||
| `server/api/me/profile.get.ts` | `profile` JSON 增加 discover 三字段(布尔与字符串,便于表单) | |
|||
| `app/components/AppShell.vue` | 桌面/移动导航「发现」 | |
|||
| `app/pages/discover/index.vue` | 网格列表、分页、空状态、链到 `/@slug` | |
|||
| `app/pages/me/profile/index.vue` | 「发现与展示」分组、保存 body、slug 联动提示 | |
|||
|
|||
--- |
|||
|
|||
### Task 1: 数据库 schema 与迁移 |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `packages/drizzle-pkg/database/sqlite/schema/auth.ts` |
|||
- Create: `packages/drizzle-pkg/migrations/0006_discover.sql`(若 `db:generate` 生成不同文件名,以生成结果为准,并确保 `_journal.json` 与 `meta/*_snapshot.json` 一并更新) |
|||
- Modify: `packages/drizzle-pkg/migrations/meta/_journal.json`(若使用 `drizzle-kit generate` 通常自动更新) |
|||
|
|||
- [ ] **Step 1: 在 `users` 表增加列** |
|||
|
|||
在 `packages/drizzle-pkg/database/sqlite/schema/auth.ts` 的 `users` 定义对象中 **紧跟** `avatarVisibility` 或 `socialLinksJson` 附近追加(保持与现有风格一致,列名 snake_case): |
|||
|
|||
```ts |
|||
discoverVisible: integer("discover_visible", { mode: "boolean" }) |
|||
.notNull() |
|||
.default(false), |
|||
discoverLocation: text("discover_location"), |
|||
discoverShowLocation: integer("discover_show_location", { mode: "boolean" }) |
|||
.notNull() |
|||
.default(false), |
|||
``` |
|||
|
|||
若当前仓库的 Drizzle 版本对 `integer(..., { mode: "boolean" })` 报错,可退化为 `integer().notNull().default(0)` 并在应用层用 `=== 1` 判断;**计划以 boolean mode 为首选**。 |
|||
|
|||
- [ ] **Step 2: 生成迁移** |
|||
|
|||
在项目根目录执行: |
|||
|
|||
```bash |
|||
bun run db:generate |
|||
``` |
|||
|
|||
Expected: `packages/drizzle-pkg/migrations/` 下出现新 SQL,`meta/_journal.json` 增加条目。若生成 SQL 仅含 `ALTER TABLE users ADD ...`,检查默认值与 `NOT NULL` 与 spec 一致。 |
|||
|
|||
- [ ] **Step 3: 本地应用迁移** |
|||
|
|||
```bash |
|||
bun run db:migrate |
|||
``` |
|||
|
|||
Expected: 命令成功退出;本地 `db.sqlite`(若使用)含新列。 |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add packages/drizzle-pkg/database/sqlite/schema/auth.ts packages/drizzle-pkg/migrations packages/drizzle-pkg/migrations/meta |
|||
git commit -m "feat(db): add discover visibility columns on users" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 2: 发现卡片 DTO 纯函数与单元测试 |
|||
|
|||
**Files:** |
|||
|
|||
- Create: `server/utils/discover-card.ts` |
|||
- Create: `server/utils/discover-card.test.ts` |
|||
|
|||
- [ ] **Step 1: 写失败测试** |
|||
|
|||
创建 `server/utils/discover-card.test.ts`: |
|||
|
|||
```ts |
|||
import { describe, expect, test } from "bun:test"; |
|||
import { discoverCardAvatarUrl, discoverCardLocationLine } from "./discover-card"; |
|||
|
|||
describe("discoverCardAvatarUrl", () => { |
|||
test("returns null when visibility is not public", () => { |
|||
expect(discoverCardAvatarUrl("https://x/a.png", "private")).toBeNull(); |
|||
expect(discoverCardAvatarUrl("https://x/a.png", "unlisted")).toBeNull(); |
|||
}); |
|||
|
|||
test("returns null when avatar empty", () => { |
|||
expect(discoverCardAvatarUrl(null, "public")).toBeNull(); |
|||
expect(discoverCardAvatarUrl(" ", "public")).toBeNull(); |
|||
}); |
|||
|
|||
test("returns trimmed url when public", () => { |
|||
expect(discoverCardAvatarUrl(" https://x/a.png ", "public")).toBe("https://x/a.png"); |
|||
}); |
|||
}); |
|||
|
|||
describe("discoverCardLocationLine", () => { |
|||
test("returns null when show flag false or text empty", () => { |
|||
expect(discoverCardLocationLine(true, false, "北京")).toBeNull(); |
|||
expect(discoverCardLocationLine(true, true, "")).toBeNull(); |
|||
expect(discoverCardLocationLine(true, true, " ")).toBeNull(); |
|||
}); |
|||
|
|||
test("returns trimmed text when allowed", () => { |
|||
expect(discoverCardLocationLine(true, true, " 上海 ")).toBe("上海"); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
- [ ] **Step 2: 运行测试确认失败** |
|||
|
|||
```bash |
|||
bun test server/utils/discover-card.test.ts |
|||
``` |
|||
|
|||
Expected: FAIL(模块或函数不存在)。 |
|||
|
|||
- [ ] **Step 3: 最小实现** |
|||
|
|||
创建 `server/utils/discover-card.ts`: |
|||
|
|||
```ts |
|||
export function discoverCardAvatarUrl( |
|||
avatar: string | null | undefined, |
|||
avatarVisibility: string, |
|||
): string | null { |
|||
if (avatarVisibility !== "public") { |
|||
return null; |
|||
} |
|||
const t = typeof avatar === "string" ? avatar.trim() : ""; |
|||
return t.length > 0 ? t : null; |
|||
} |
|||
|
|||
export function discoverCardLocationLine( |
|||
discoverVisible: boolean, |
|||
discoverShowLocation: boolean, |
|||
discoverLocation: string | null | undefined, |
|||
): string | null { |
|||
if (!discoverVisible || !discoverShowLocation) { |
|||
return null; |
|||
} |
|||
const t = typeof discoverLocation === "string" ? discoverLocation.trim() : ""; |
|||
return t.length > 0 ? t : null; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: 运行测试确认通过** |
|||
|
|||
```bash |
|||
bun test server/utils/discover-card.test.ts |
|||
``` |
|||
|
|||
Expected: PASS。 |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add server/utils/discover-card.ts server/utils/discover-card.test.ts |
|||
git commit -m "feat(server): add discover card DTO helpers" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 3: Discover 列表服务与 API |
|||
|
|||
**Files:** |
|||
|
|||
- Create: `server/constants/discover-list.ts` |
|||
- Create: `server/service/discover/index.ts` |
|||
- Create: `server/api/discover/users.get.ts` |
|||
|
|||
- [ ] **Step 1: 列表常量** |
|||
|
|||
`server/constants/discover-list.ts`: |
|||
|
|||
```ts |
|||
/** 发现页用户列表每页条数(与公开列表一致) */ |
|||
export const DISCOVER_LIST_PAGE_SIZE = 10; |
|||
``` |
|||
|
|||
- [ ] **Step 2: 服务实现** |
|||
|
|||
`server/service/discover/index.ts`(根据实际 `users` 列类型调整布尔比较;若列为 0/1 integer,用 `eq(users.discoverVisible, true)` 或 `eq(users.discoverVisible, 1)`,以 Drizzle 推断为准): |
|||
|
|||
```ts |
|||
import { dbGlobal } from "drizzle-pkg/lib/db"; |
|||
import { users } from "drizzle-pkg/lib/schema/auth"; |
|||
import { and, eq, isNotNull, sql } from "drizzle-orm"; |
|||
import { DISCOVER_LIST_PAGE_SIZE } from "#server/constants/discover-list"; |
|||
import { normalizePublicListPage } from "#server/utils/public-pagination"; |
|||
import { discoverCardAvatarUrl, discoverCardLocationLine } from "#server/utils/discover-card"; |
|||
|
|||
const listWhere = and( |
|||
eq(users.status, "active"), |
|||
eq(users.discoverVisible, true), |
|||
isNotNull(users.publicSlug), |
|||
sql`length(trim(${users.publicSlug})) > 0`, |
|||
); |
|||
|
|||
export type DiscoverListItem = { |
|||
publicSlug: string; |
|||
displayName: string; |
|||
avatar: string | null; |
|||
location: string | null; |
|||
}; |
|||
|
|||
function mapRow(row: typeof users.$inferSelect): DiscoverListItem { |
|||
const displayName = row.nickname?.trim() || row.username; |
|||
const discoverVis = Boolean(row.discoverVisible); |
|||
const showLoc = Boolean(row.discoverShowLocation); |
|||
return { |
|||
publicSlug: row.publicSlug as string, |
|||
displayName, |
|||
avatar: discoverCardAvatarUrl(row.avatar, row.avatarVisibility), |
|||
location: discoverCardLocationLine(discoverVis, showLoc, row.discoverLocation), |
|||
}; |
|||
} |
|||
|
|||
export async function listDiscoverUsersPage(pageRaw: unknown) { |
|||
const page = normalizePublicListPage(pageRaw); |
|||
const pageSize = DISCOVER_LIST_PAGE_SIZE; |
|||
const offset = (page - 1) * pageSize; |
|||
|
|||
const countRows = await dbGlobal |
|||
.select({ total: sql<number>`count(*)` }) |
|||
.from(users) |
|||
.where(listWhere); |
|||
const total = countRows[0]?.total ?? 0; |
|||
|
|||
const rows = await dbGlobal |
|||
.select() |
|||
.from(users) |
|||
.where(listWhere) |
|||
.limit(pageSize) |
|||
.offset(offset); |
|||
|
|||
return { |
|||
items: rows.map(mapRow), |
|||
total, |
|||
page, |
|||
pageSize, |
|||
}; |
|||
} |
|||
``` |
|||
|
|||
若 `sql\`length(trim(...))\`` 在类型或运行时报错,可改为 `ne(users.publicSlug, "")` 等与 `isNotNull` 组合,确保无空字符串 slug。 |
|||
|
|||
- [ ] **Step 3: HTTP 处理器** |
|||
|
|||
`server/api/discover/users.get.ts`: |
|||
|
|||
```ts |
|||
import { listDiscoverUsersPage } from "#server/service/discover"; |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
await event.context.auth.requireUser(); |
|||
const q = getQuery(event); |
|||
const payload = await listDiscoverUsersPage(q.page); |
|||
return R.success(payload); |
|||
}); |
|||
``` |
|||
|
|||
- [ ] **Step 4: 手动验证 API** |
|||
|
|||
启动 `bun run dev`,用已登录会话(浏览器或 cookie)请求: |
|||
|
|||
```bash |
|||
curl -sS -b "你的会话 cookie" "http://localhost:3000/api/discover/users?page=1" |
|||
``` |
|||
|
|||
Expected: `200`,JSON 结构含 `items`、`total`、`page`、`pageSize`;未登录时: |
|||
|
|||
```bash |
|||
curl -sS -o /dev/null -w "%{http_code}" "http://localhost:3000/api/discover/users" |
|||
``` |
|||
|
|||
Expected: `401`。 |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add server/constants/discover-list.ts server/service/discover/index.ts server/api/discover/users.get.ts |
|||
git commit -m "feat(api): add paginated discover users list" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 4: 个人资料读写扩展 |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `server/service/profile/index.ts` |
|||
- Modify: `server/api/me/profile.put.ts` |
|||
- Modify: `server/api/me/profile.get.ts` |
|||
|
|||
- [ ] **Step 1: 扩展 `updateProfile`** |
|||
|
|||
在 `server/service/profile/index.ts` 中: |
|||
|
|||
1. 增加 zod(示例,可与文件现有风格合并): |
|||
|
|||
```ts |
|||
const discoverLocationSchema = z |
|||
.string() |
|||
.max(200) |
|||
.optional() |
|||
.transform((s) => (s === undefined ? undefined : s.trim() || null)); |
|||
``` |
|||
|
|||
2. `updateProfile` 的 `patch` 类型增加: |
|||
|
|||
```ts |
|||
discoverVisible?: boolean; |
|||
discoverLocation?: string | null; |
|||
discoverShowLocation?: boolean; |
|||
``` |
|||
|
|||
3. 在函数体中处理(注意仅当 `!== undefined` 时写入): |
|||
|
|||
```ts |
|||
if (patch.discoverVisible !== undefined) { |
|||
updates.discoverVisible = patch.discoverVisible; |
|||
} |
|||
if (patch.discoverLocation !== undefined) { |
|||
updates.discoverLocation = discoverLocationSchema.parse(patch.discoverLocation ?? ""); |
|||
} |
|||
if (patch.discoverShowLocation !== undefined) { |
|||
updates.discoverShowLocation = patch.discoverShowLocation; |
|||
} |
|||
``` |
|||
|
|||
若 `discoverLocationSchema` 对 `null` 需单独分支,改为:`patch.discoverLocation === null ? null : discoverLocationSchema.parse(...)`。 |
|||
|
|||
- [ ] **Step 2: `profile.put` body** |
|||
|
|||
`server/api/me/profile.put.ts` 的 `readBody` 类型增加: |
|||
|
|||
```ts |
|||
discoverVisible?: boolean; |
|||
discoverLocation?: string | null; |
|||
discoverShowLocation?: boolean; |
|||
``` |
|||
|
|||
并传入 `updateProfile`。 |
|||
|
|||
- [ ] **Step 3: `profile.get` 响应** |
|||
|
|||
`server/api/me/profile.get.ts` 的 `profile` 对象增加: |
|||
|
|||
```ts |
|||
discoverVisible: Boolean(row.discoverVisible), |
|||
discoverLocation: row.discoverLocation ?? null, |
|||
discoverShowLocation: Boolean(row.discoverShowLocation), |
|||
``` |
|||
|
|||
(若 row 类型尚未含新列,先完成 Task 1 并确保 TypeScript 从 schema 推断更新。) |
|||
|
|||
- [ ] **Step 4: 手动验证** |
|||
|
|||
登录后 `PUT /api/me/profile` 带 `discoverVisible: true` 等,再 `GET /api/me/profile` 确认回读;再 `GET /api/discover/users` 确认列表仅在 slug 存在时出现该用户。 |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add server/service/profile/index.ts server/api/me/profile.put.ts server/api/me/profile.get.ts |
|||
git commit -m "feat(profile): persist discover visibility and location fields" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 5: AppShell 导航 |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `app/components/AppShell.vue` |
|||
|
|||
- [ ] **Step 1: 增加 `discoverNav` 常量** |
|||
|
|||
在 `homeNav` 旁: |
|||
|
|||
```ts |
|||
const discoverNav = { label: '发现', to: '/discover', icon: 'i-lucide-compass' } as const |
|||
``` |
|||
|
|||
- [ ] **Step 2: 桌面主导航** |
|||
|
|||
在「首页」`UButton` 与「控制台」`UDropdownMenu` 之间插入「发现」`UButton`:`to`、`icon`、`label`、`navActive(discoverNav.to)` 的 class 与首页一致。 |
|||
|
|||
- [ ] **Step 3: 移动端菜单** |
|||
|
|||
`mobileMenuItems` 的第一组由 `[homeNav]` 改为 `[homeNav, discoverNav]`(或等价结构),使汉堡菜单可见「发现」。 |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add app/components/AppShell.vue |
|||
git commit -m "feat(nav): add Discover link for logged-in users" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 6: 发现页 `/discover` |
|||
|
|||
**Files:** |
|||
|
|||
- Create: `app/pages/discover/index.vue` |
|||
|
|||
- [ ] **Step 1: 页面骨架** |
|||
|
|||
- `definePageMeta({ title: '发现' })`(或与项目标题惯例一致)。 |
|||
- 使用 `useClientApi` 的 `fetchData` 请求 `/api/discover/users?page=${page}`。 |
|||
- 状态:`items`、`total`、`page`、`pageSize`、`loading`、`error`(可选)。 |
|||
- 模板:响应式网格(如 `grid gap-4 sm:grid-cols-2 lg:grid-cols-3`),每卡 `NuxtLink` 到 `` `/@${item.publicSlug}` ``,展示 `UAvatar`(`src` 可为空)、`displayName`、`@publicSlug`、可选 `location` 一行。 |
|||
- `items.length === 0` 且非 loading:空状态文案(例如「暂时还没有用户出现在发现中」)。 |
|||
- 分页:`UPagination` 或「上一页/下一页」,绑定 `page`,`total` 与 `pageSize` 来自 API。 |
|||
|
|||
参考现有列表页(如 `app/pages/me/posts/index.vue`)的 loading / toast 模式,保持 UX 一致。 |
|||
|
|||
- [ ] **Step 2: 浏览器验证** |
|||
|
|||
登录后打开 `/discover`,确认有数据时卡片可跳转公开主页;未登录访问应被重定向到 `/login?redirect=/discover`。 |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add app/pages/discover/index.vue |
|||
git commit -m "feat(pages): add discover directory page" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 7: 资料页「发现与展示」 |
|||
|
|||
**Files:** |
|||
|
|||
- Modify: `app/pages/me/profile/index.vue` |
|||
|
|||
- [ ] **Step 1: 扩展 `ProfileGet` 与 `state`** |
|||
|
|||
类型与 `reactive` 默认值: |
|||
|
|||
```ts |
|||
discoverVisible: false, |
|||
discoverLocation: '', |
|||
discoverShowLocation: false, |
|||
``` |
|||
|
|||
`load()` 中从 `p.discoverVisible` 等赋值(注意 API 返回布尔)。 |
|||
|
|||
- [ ] **Step 2: 表单区块** |
|||
|
|||
在「公开主页 slug」字段 **之后**(便于上下文)插入 `UCard` 或 `UFormField` 分组 **「发现与展示」**: |
|||
|
|||
- `USwitch` 或 checkbox:`出现在发现页` → `state.discoverVisible` |
|||
- `UInput`:`地址或地区(展示文案)` → `state.discoverLocation`,可 `maxlength="200"` |
|||
- `USwitch`:`在发现卡片上显示上述地址` → `state.discoverShowLocation` |
|||
- 当 `state.discoverVisible && !state.publicSlug.trim()`:`UAlert` 或说明文字:「需设置公开主页 slug 后才会出现在发现列表中」 |
|||
|
|||
- [ ] **Step 3: `save()` body** |
|||
|
|||
在 `fetchData('/api/me/profile', { body: { ... } })` 中增加: |
|||
|
|||
```ts |
|||
discoverVisible: state.discoverVisible, |
|||
discoverLocation: state.discoverLocation.trim() || null, |
|||
discoverShowLocation: state.discoverShowLocation, |
|||
``` |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add app/pages/me/profile/index.vue |
|||
git commit -m "feat(profile): discover visibility and location controls" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Plan self-review |
|||
|
|||
| Spec 段落 | 对应 Task | |
|||
| --- | --- | |
|||
| 数据模型三列 + 列表条件 | Task 1, 3 | |
|||
| GET 列表 API + 鉴权 | Task 3 | |
|||
| 响应字段与头像规则 | Task 2, 3 | |
|||
| profile PUT 扩展 | Task 4 | |
|||
| profile GET 扩展 | Task 4 | |
|||
| AppShell 导航 | Task 5 | |
|||
| `/discover` 页面 | Task 6 | |
|||
| 资料页分组与 slug 提示 | Task 7 | |
|||
| 仅登录访问页面 | Task 6 手动验证(依赖现有 `auth.global.ts`,勿将 `/discover` 加入公开路由) | |
|||
|
|||
**Placeholder scan:** 无 TBD;`listWhere` 若需调整已给出备选句式。 |
|||
|
|||
**类型一致:** `DiscoverListItem` 字段名与前端 `fetchData` 泛型需一致(建议在 `app/pages/discover/index.vue` 内联类型或抽到 `app/types` — 计划保持 YAGNI,内联即可)。 |
|||
|
|||
--- |
|||
|
|||
**Plan complete and saved to `docs/superpowers/plans/2026-04-18-discover-page-implementation-plan.md`. Two execution options:** |
|||
|
|||
**1. Subagent-Driven (recommended)** — 每项 Task 派生子代理并在 Task 之间复核,迭代快 |
|||
|
|||
**2. Inline Execution** — 在本会话用 executing-plans 按检查点批量执行 |
|||
|
|||
**你想用哪一种?** |
|||
Loading…
Reference in new issue