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.
 
 
 

17 KiB

发现页与资料「出现在发现中」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 表新列:discoverVisiblediscoverLocationdiscoverShowLocation
packages/drizzle-pkg/migrations/0006_*.sqldb: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.jsonmeta/*_snapshot.json 一并更新)

  • Modify: packages/drizzle-pkg/migrations/meta/_journal.json(若使用 drizzle-kit generate 通常自动更新)

  • Step 1: 在 users 表增加列

packages/drizzle-pkg/database/sqlite/schema/auth.tsusers 定义对象中 紧跟 avatarVisibilitysocialLinksJson 附近追加(保持与现有风格一致,列名 snake_case):

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: 生成迁移

在项目根目录执行:

bun run db:generate

Expected: packages/drizzle-pkg/migrations/ 下出现新 SQL,meta/_journal.json 增加条目。若生成 SQL 仅含 ALTER TABLE users ADD ...,检查默认值与 NOT NULL 与 spec 一致。

  • Step 3: 本地应用迁移
bun run db:migrate

Expected: 命令成功退出;本地 db.sqlite(若使用)含新列。

  • Step 4: Commit
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

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: 运行测试确认失败
bun test server/utils/discover-card.test.ts

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

  • Step 3: 最小实现

创建 server/utils/discover-card.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: 运行测试确认通过
bun test server/utils/discover-card.test.ts

Expected: PASS。

  • Step 5: Commit
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

/** 发现页用户列表每页条数(与公开列表一致) */
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 推断为准):

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

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)请求:

curl -sS -b "你的会话 cookie" "http://localhost:3000/api/discover/users?page=1"

Expected: 200,JSON 结构含 itemstotalpagepageSize;未登录时:

curl -sS -o /dev/null -w "%{http_code}" "http://localhost:3000/api/discover/users"

Expected: 401

  • Step 5: Commit
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(示例,可与文件现有风格合并):
const discoverLocationSchema = z
  .string()
  .max(200)
  .optional()
  .transform((s) => (s === undefined ? undefined : s.trim() || null));
  1. updateProfilepatch 类型增加:
discoverVisible?: boolean;
discoverLocation?: string | null;
discoverShowLocation?: boolean;
  1. 在函数体中处理(注意仅当 !== undefined 时写入):
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;
}

discoverLocationSchemanull 需单独分支,改为:patch.discoverLocation === null ? null : discoverLocationSchema.parse(...)

  • Step 2: profile.put body

server/api/me/profile.put.tsreadBody 类型增加:

discoverVisible?: boolean;
discoverLocation?: string | null;
discoverShowLocation?: boolean;

并传入 updateProfile

  • Step 3: profile.get 响应

server/api/me/profile.get.tsprofile 对象增加:

discoverVisible: Boolean(row.discoverVisible),
discoverLocation: row.discoverLocation ?? null,
discoverShowLocation: Boolean(row.discoverShowLocation),

(若 row 类型尚未含新列,先完成 Task 1 并确保 TypeScript 从 schema 推断更新。)

  • Step 4: 手动验证

登录后 PUT /api/me/profilediscoverVisible: true 等,再 GET /api/me/profile 确认回读;再 GET /api/discover/users 确认列表仅在 slug 存在时出现该用户。

  • Step 5: Commit
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 旁:

const discoverNav = { label: '发现', to: '/discover', icon: 'i-lucide-compass' } as const
  • Step 2: 桌面主导航

在「首页」UButton 与「控制台」UDropdownMenu 之间插入「发现」UButtontoiconlabelnavActive(discoverNav.to) 的 class 与首页一致。

  • Step 3: 移动端菜单

mobileMenuItems 的第一组由 [homeNav] 改为 [homeNav, discoverNav](或等价结构),使汉堡菜单可见「发现」。

  • Step 4: Commit
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: '发现' })(或与项目标题惯例一致)。

  • 使用 useClientApifetchData 请求 /api/discover/users?page=${page}

  • 状态:itemstotalpagepageSizeloadingerror(可选)。

  • 模板:响应式网格(如 grid gap-4 sm:grid-cols-2 lg:grid-cols-3),每卡 NuxtLink`/@${item.publicSlug}`,展示 UAvatarsrc 可为空)、displayName@publicSlug、可选 location 一行。

  • items.length === 0 且非 loading:空状态文案(例如「暂时还没有用户出现在发现中」)。

  • 分页:UPagination 或「上一页/下一页」,绑定 pagetotalpageSize 来自 API。

参考现有列表页(如 app/pages/me/posts/index.vue)的 loading / toast 模式,保持 UX 一致。

  • Step 2: 浏览器验证

登录后打开 /discover,确认有数据时卡片可跳转公开主页;未登录访问应被重定向到 /login?redirect=/discover

  • Step 3: Commit
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: 扩展 ProfileGetstate

类型与 reactive 默认值:

discoverVisible: false,
discoverLocation: '',
discoverShowLocation: false,

load() 中从 p.discoverVisible 等赋值(注意 API 返回布尔)。

  • Step 2: 表单区块

在「公开主页 slug」字段 之后(便于上下文)插入 UCardUFormField 分组 「发现与展示」

  • 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: { ... } }) 中增加:

discoverVisible: state.discoverVisible,
discoverLocation: state.discoverLocation.trim() || null,
discoverShowLocation: state.discoverShowLocation,
  • Step 4: Commit
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 按检查点批量执行

你想用哪一种?