1 changed files with 513 additions and 0 deletions
@ -0,0 +1,513 @@ |
|||||
|
# Post Comments(公开 + 未列出)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:** 为公开文章与未列出分享文章提供无限嵌套评论(访客极简 + 登录 Markdown 文本展示)、软删除与 `GET` 返回 `postId` / 权限位,两端页面共用评论区组件。 |
||||
|
|
||||
|
**Architecture:** 在 `drizzle-pkg` 增加 `post_comments` 表;`server/service/posts` 增加仅服务端使用的帖子解析(返回 `postId` + `ownerUserId`);新建 `server/service/post-comments` 负责列表组树、创建、软删与 DTO;公开路径四套 Nitro 路由(两 GET + 两 POST)+ `DELETE /api/me/posts/:postId/comments/:commentId`;访客校验与限流放在独立 util;前端 `PostComments` 用现有 `$fetch` + `unwrapApiBody` / 公开接口包装约定。 |
||||
|
|
||||
|
**Tech Stack:** Nuxt 4 / Nitro、Vue 3、Drizzle ORM 0.45、SQLite(`better-sqlite3`)、Bun test、`@nuxt/ui` 4.6、`h3` `getRequestIP`。 |
||||
|
|
||||
|
**Spec:** `docs/superpowers/specs/2026-04-18-post-comments-design.md` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 文件结构(将创建 / 修改) |
||||
|
|
||||
|
| 路径 | 职责 | |
||||
|
|------|------| |
||||
|
| `packages/drizzle-pkg/database/sqlite/schema/content.ts` | 定义 `postComments` 表 + 索引 | |
||||
|
| `packages/drizzle-pkg/lib/schema/content.ts` | `export { ..., postComments }` | |
||||
|
| `packages/drizzle-pkg/migrations/0002_*.sql` 与 `meta/*` | 由 `drizzle-kit generate` 生成后提交 | |
||||
|
| `server/service/posts/index.ts` | 新增 `getPublicPostCommentContext`、`getUnlistedPostCommentContext`(`{ id, userId } \| null`) | |
||||
|
| `server/utils/post-comment-guest.ts` | 访客昵称/正文校验(拒绝 URL、Markdown 链接、`[]()`) | |
||||
|
| `server/utils/post-comment-guest.test.ts` | 访客校验单测 | |
||||
|
| `server/service/post-comments/index.ts` | `listCommentTreeForPost`、`createCommentOnPost`、`softDeleteComment` | |
||||
|
| `server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.get.ts` | 公开 GET | |
||||
|
| `server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts` | 公开 POST | |
||||
|
| `server/api/public/unlisted/[publicSlug]/[shareToken]/comments.get.ts` | 未列出 GET | |
||||
|
| `server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts` | 未列出 POST | |
||||
|
| `server/api/me/posts/[postId]/comments/[commentId].delete.ts` | 软删(`defineWrappedResponseHandler` + `requireUser`) | |
||||
|
| `app/components/PostComments.vue` | 列表 + 表单 + 递归展示 + 删除 | |
||||
|
| `app/pages/@[publicSlug]/posts/[postSlug].vue` | 挂载组件 | |
||||
|
| `app/pages/p/[publicSlug]/t/[shareToken].vue` | 在 `kind === 'post'` 分支挂载 | |
||||
|
|
||||
|
**展示策略(与正文对齐):** 用户评论 `body` 与文章 `bodyMarkdown` 一样,在 Vue 模板中用 **文本插值** `{{ }}` 输出,依赖自动转义防 XSS;**不**新增 Markdown 渲染依赖。访客 `body` 经服务端校验为纯文本后同样展示。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 1: Drizzle 表 `post_comments` 与迁移 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `packages/drizzle-pkg/database/sqlite/schema/content.ts` |
||||
|
- Modify: `packages/drizzle-pkg/lib/schema/content.ts` |
||||
|
- Create: `packages/drizzle-pkg/migrations/0002_post-comments.sql`(或由 generate 产出,以实际文件名为准) |
||||
|
- Modify: `packages/drizzle-pkg/migrations/meta/_journal.json`(若 generate 已更新则只提交) |
||||
|
|
||||
|
- [ ] **Step 1: 在 schema 中追加表定义** |
||||
|
|
||||
|
`packages/drizzle-pkg/database/sqlite/schema/content.ts` 在 `posts` 表定义之后追加(注意 `posts` 已 import,自引用用箭头函数): |
||||
|
|
||||
|
```typescript |
||||
|
export const postComments = sqliteTable( |
||||
|
"post_comments", |
||||
|
{ |
||||
|
id: integer().primaryKey(), |
||||
|
postId: integer("post_id") |
||||
|
.notNull() |
||||
|
.references(() => posts.id, { onDelete: "cascade" }), |
||||
|
parentId: integer("parent_id").references(() => postComments.id), |
||||
|
authorUserId: integer("author_user_id").references(() => users.id, { onDelete: "set null" }), |
||||
|
guestDisplayName: text("guest_display_name"), |
||||
|
body: text("body").notNull(), |
||||
|
kind: text("kind").notNull(), |
||||
|
deletedAt: integer("deleted_at", { mode: "timestamp_ms" }), |
||||
|
deletedByUserId: integer("deleted_by_user_id").references(() => users.id, { onDelete: "set null" }), |
||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), |
||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }) |
||||
|
.defaultNow() |
||||
|
.$onUpdate(() => new Date()) |
||||
|
.notNull(), |
||||
|
}, |
||||
|
(table) => [ |
||||
|
index("post_comments_post_id_idx").on(table.postId), |
||||
|
index("post_comments_parent_id_idx").on(table.parentId), |
||||
|
], |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
若 `users` 未在该文件 import,在文件顶部自 `./auth` 引入 `users`(与 `posts` 引用方式一致)。 |
||||
|
|
||||
|
`packages/drizzle-pkg/lib/schema/content.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
export { posts, timelineEvents, postComments } from "../../database/sqlite/schema/content"; |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 生成迁移 SQL** |
||||
|
|
||||
|
Run: |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel && bun run db:generate post-comments |
||||
|
``` |
||||
|
|
||||
|
Expected: `packages/drizzle-pkg/migrations` 下新增一条 migration,且 `meta/_journal.json` 更新。 |
||||
|
|
||||
|
- [ ] **Step 3: 本地执行迁移** |
||||
|
|
||||
|
Run: |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel && bun run db:migrate |
||||
|
``` |
||||
|
|
||||
|
Expected: 无报错,本地 DB 存在 `post_comments` 表。 |
||||
|
|
||||
|
- [ ] **Step 4: Commit** |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel |
||||
|
git add packages/drizzle-pkg/database/sqlite/schema/content.ts packages/drizzle-pkg/lib/schema/content.ts packages/drizzle-pkg/migrations |
||||
|
git commit -m "feat(db): add post_comments table for nested comments" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 2: 访客昵称与正文校验(TDD) |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/utils/post-comment-guest.ts` |
||||
|
- Create: `server/utils/post-comment-guest.test.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 编写失败测试** |
||||
|
|
||||
|
`server/utils/post-comment-guest.test.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
import { describe, expect, test } from "bun:test"; |
||||
|
import { |
||||
|
GuestCommentValidationError, |
||||
|
normalizeGuestDisplayName, |
||||
|
validateGuestCommentBody, |
||||
|
} from "./post-comment-guest"; |
||||
|
|
||||
|
describe("normalizeGuestDisplayName", () => { |
||||
|
test("trims and accepts valid name", () => { |
||||
|
expect(normalizeGuestDisplayName(" ada ")).toBe("ada"); |
||||
|
}); |
||||
|
|
||||
|
test("rejects empty", () => { |
||||
|
expect(() => normalizeGuestDisplayName(" ")).toThrow(GuestCommentValidationError); |
||||
|
}); |
||||
|
|
||||
|
test("rejects too long", () => { |
||||
|
expect(() => normalizeGuestDisplayName("a".repeat(33))).toThrow(GuestCommentValidationError); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("validateGuestCommentBody", () => { |
||||
|
test("accepts plain text", () => { |
||||
|
expect(validateGuestCommentBody("你好,世界")).toBe("你好,世界"); |
||||
|
}); |
||||
|
|
||||
|
test("rejects http URL", () => { |
||||
|
expect(() => validateGuestCommentBody("see http://x.com")).toThrow(GuestCommentValidationError); |
||||
|
}); |
||||
|
|
||||
|
test("rejects markdown link", () => { |
||||
|
expect(() => validateGuestCommentBody("[a](http://b)")).toThrow(GuestCommentValidationError); |
||||
|
}); |
||||
|
|
||||
|
test("rejects too long", () => { |
||||
|
expect(() => validateGuestCommentBody("z".repeat(501))).toThrow(GuestCommentValidationError); |
||||
|
}); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 运行测试确认失败** |
||||
|
|
||||
|
Run: |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel && bun test server/utils/post-comment-guest.test.ts |
||||
|
``` |
||||
|
|
||||
|
Expected: FAIL(模块不存在或未导出)。 |
||||
|
|
||||
|
- [ ] **Step 3: 最小实现** |
||||
|
|
||||
|
`server/utils/post-comment-guest.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
const MAX_NAME = 32; |
||||
|
const MAX_BODY = 500; |
||||
|
|
||||
|
export class GuestCommentValidationError extends Error { |
||||
|
constructor(message: string) { |
||||
|
super(message); |
||||
|
this.name = "GuestCommentValidationError"; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function normalizeGuestDisplayName(raw: string): string { |
||||
|
const t = raw.trim(); |
||||
|
if (!t) { |
||||
|
throw new GuestCommentValidationError("请填写昵称"); |
||||
|
} |
||||
|
if (t.length > MAX_NAME) { |
||||
|
throw new GuestCommentValidationError("昵称过长"); |
||||
|
} |
||||
|
return t; |
||||
|
} |
||||
|
|
||||
|
/** 返回 trim 后正文;违规抛 GuestCommentValidationError(由 API 映射为 400) */ |
||||
|
export function validateGuestCommentBody(raw: string): string { |
||||
|
const t = raw.trim(); |
||||
|
if (!t) { |
||||
|
throw new GuestCommentValidationError("请填写内容"); |
||||
|
} |
||||
|
if (t.length > MAX_BODY) { |
||||
|
throw new GuestCommentValidationError("内容过长"); |
||||
|
} |
||||
|
if (/https?:\/\//i.test(t) || /\bwww\./i.test(t)) { |
||||
|
throw new GuestCommentValidationError("不能包含链接"); |
||||
|
} |
||||
|
if (/\[[^\]]*\]\([^)]*\)/.test(t)) { |
||||
|
throw new GuestCommentValidationError("不能使用 Markdown 链接"); |
||||
|
} |
||||
|
return t; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 4: 运行测试确认通过** |
||||
|
|
||||
|
Run: |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel && bun test server/utils/post-comment-guest.test.ts |
||||
|
``` |
||||
|
|
||||
|
Expected: PASS。 |
||||
|
|
||||
|
- [ ] **Step 5: Commit** |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel |
||||
|
git add server/utils/post-comment-guest.ts server/utils/post-comment-guest.test.ts |
||||
|
git commit -m "feat(comments): guest display name and body validation" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 3: 帖子上下文查询(postId + ownerUserId) |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `server/service/posts/index.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 新增两个导出函数** |
||||
|
|
||||
|
在 `server/service/posts/index.ts` 中 import `postComments` **不需要**;仅查询 `posts` + `users`。追加(与现有 `getPublicPostByPublicSlugAndSlug` 条件一致,但 `select` `posts.id` 与 `posts.userId`): |
||||
|
|
||||
|
```typescript |
||||
|
export async function getPublicPostCommentContext(publicSlug: string, postSlug: string) { |
||||
|
const slug = postSlug.trim(); |
||||
|
if (!slug) return null; |
||||
|
const [row] = await dbGlobal |
||||
|
.select({ |
||||
|
id: posts.id, |
||||
|
userId: posts.userId, |
||||
|
}) |
||||
|
.from(posts) |
||||
|
.innerJoin(users, eq(posts.userId, users.id)) |
||||
|
.where( |
||||
|
and( |
||||
|
eq(users.publicSlug, publicSlug), |
||||
|
eq(users.status, "active"), |
||||
|
eq(posts.visibility, "public"), |
||||
|
eq(posts.slug, slug), |
||||
|
), |
||||
|
) |
||||
|
.limit(1); |
||||
|
return row ?? null; |
||||
|
} |
||||
|
|
||||
|
export async function getUnlistedPostCommentContext(publicSlug: string, shareToken: string) { |
||||
|
const [row] = await dbGlobal |
||||
|
.select({ |
||||
|
id: posts.id, |
||||
|
userId: posts.userId, |
||||
|
}) |
||||
|
.from(posts) |
||||
|
.innerJoin(users, eq(posts.userId, users.id)) |
||||
|
.where( |
||||
|
and( |
||||
|
eq(users.publicSlug, publicSlug), |
||||
|
eq(users.status, "active"), |
||||
|
eq(posts.visibility, "unlisted"), |
||||
|
eq(posts.shareToken, shareToken), |
||||
|
), |
||||
|
) |
||||
|
.limit(1); |
||||
|
return row ?? null; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: Commit** |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel |
||||
|
git add server/service/posts/index.ts |
||||
|
git commit -m "feat(posts): add comment context resolvers for public and unlisted" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 4: `post-comments` 服务(列表树、创建、软删) |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/service/post-comments/index.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 实现服务模块** |
||||
|
|
||||
|
要点(实现时写完整 TypeScript,此处为契约摘要): |
||||
|
|
||||
|
1. import `dbGlobal`、`postComments`、`posts`、`users`、`and`、`asc`、`eq`、`isNull`、`sql`(按需)、`nextIntegerId`、`GuestCommentValidationError`、`normalizeGuestDisplayName`、`validateGuestCommentBody`、`assertUnderRateLimit`、`getRequestIP`(在 handler 层调用限流也可)。 |
||||
|
2. **`listCommentsPayload(postId, ownerUserId, viewerUserId | null)`** |
||||
|
- 查询该 `postId` 全部评论 leftJoin `users` 取 `nickname`、`username`、`publicSlug`。 |
||||
|
- 按 `createdAt` 升序。 |
||||
|
- 在内存构建 `id -> node`,根列表 `parentId == null`,子节点挂到父节点 `children`(数组),顺序保持升序。 |
||||
|
- `viewerCanModeratePost = viewerUserId != null && viewerUserId === ownerUserId`(`ownerUserId` 为帖子作者,来自 Task 3 的 `userId`)。 |
||||
|
- 每条节点:`deleted` = `deletedAt != null`;若删除则 `body: null`,`guestDisplayName` / `author` 置空或最小占位(与 spec 一致);`viewerCanDelete`:未删且 (`viewerCanModeratePost` 或 (`kind==='user'` 且 `authorUserId===viewerUserId`))。 |
||||
|
3. **`createComment(params)`** |
||||
|
- `postId`、`ownerUserId`、`parentId`、`viewer`(`MinimalUser | null`)、`body`、`guestDisplayName?`。 |
||||
|
- 若 `viewer` 非空:`kind='user'`,`body` trim 后长度 ≤8000,否则 400;`authorUserId=viewer.id`;`guestDisplayName=null`。 |
||||
|
- 若 `viewer` 为空:调用 `normalizeGuestDisplayName`、`validateGuestCommentBody`;`kind='guest'`。 |
||||
|
- 校验 `parentId`:若存在,查父行,须同 `postId` 且 `deletedAt` 为空,否则 400。 |
||||
|
- `id = await nextIntegerId(postComments, postComments.id)`,`insert`。 |
||||
|
4. **`softDeleteComment({ postId, commentId, actorUserId })`** |
||||
|
- 查评论与帖子;帖子 `userId` 为 `ownerUserId`。 |
||||
|
- 允许条件:`comment.authorUserId === actorUserId` 或 `ownerUserId === actorUserId`;否则 403。 |
||||
|
- 已删则 400 或 404(选一种并在实现中统一)。 |
||||
|
- `update` 设置 `deletedAt`、`deletedByUserId`。 |
||||
|
|
||||
|
限流建议在 **API 层** 对 POST:`assertUnderRateLimit(\`comment:guest:${ip}\`, 20, 15 * 60 * 1000)` 与登录 `comment:user:${userId}` 更宽(如 60/15min)。 |
||||
|
|
||||
|
- [ ] **Step 2: Commit** |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel |
||||
|
git add server/service/post-comments/index.ts |
||||
|
git commit -m "feat(comments): service for tree listing, create, and soft delete" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 5: 公开 / 未列出 GET + POST 路由 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.get.ts` |
||||
|
- Create: `server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts` |
||||
|
- Create: `server/api/public/unlisted/[publicSlug]/[shareToken]/comments.get.ts` |
||||
|
- Create: `server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 实现四个 handler** |
||||
|
|
||||
|
模式与 `server/api/public/profile/[publicSlug]/posts/[postSlug].get.ts` 相同:`defineEventHandler`,参数校验,`404` 用 `createError`。 |
||||
|
|
||||
|
GET: |
||||
|
`const ctx = await getPublicPostCommentContext(...)` 或 unlisted 版本;若无则 404。 |
||||
|
`const viewer = await event.context.auth.getCurrent()`(`01.context.ts` 已注入 `auth`;公开路由同样应有 `event.context.auth` — 若不存在则改用 `createAuthContext(event).getCurrent()`,以仓库实际 `server/plugins/01.context.ts` 为准)。 |
||||
|
`return R.success(await listCommentsPayload(ctx.id, ctx.userId, viewer?.id ?? null))` — 注意将函数签名在 Task 4 中定义为 `(postId, ownerUserId, viewerId)`。 |
||||
|
|
||||
|
POST: |
||||
|
`readBody` 取 `{ parentId?: number | null, guestDisplayName?: string, body: string }`。 |
||||
|
限流 + `createComment`。 |
||||
|
捕获 `GuestCommentValidationError` → `createError({ statusCode: 400, statusMessage: err.message })`。 |
||||
|
|
||||
|
- [ ] **Step 2: 手动冒烟** |
||||
|
|
||||
|
Run `bun run dev`,对公开文章 curl GET `/api/public/profile/.../posts/.../comments`,Expect `code:0` 且 `data.postId` 存在。 |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel |
||||
|
git add server/api/public/profile server/api/public/unlisted |
||||
|
git commit -m "feat(api): public and unlisted comment list and create endpoints" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 6: `DELETE /api/me/posts/:postId/comments/:commentId` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/api/me/posts/[postId]/comments/[commentId].delete.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 实现 handler** |
||||
|
|
||||
|
```typescript |
||||
|
import { softDeleteComment } from "#server/service/post-comments"; |
||||
|
|
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const user = await event.context.auth.requireUser(); |
||||
|
const postId = Number(event.context.params?.postId); |
||||
|
const commentId = Number(event.context.params?.commentId); |
||||
|
if (!Number.isFinite(postId) || !Number.isFinite(commentId)) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "无效请求" }); |
||||
|
} |
||||
|
await softDeleteComment({ postId, commentId, actorUserId: user.id }); |
||||
|
return R.success({ ok: true }); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
`softDeleteComment` 内部校验 `post` 存在且 `comment.postId === postId`。 |
||||
|
|
||||
|
- [ ] **Step 2: Commit** |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel |
||||
|
git add "server/api/me/posts/[postId]/comments/[commentId].delete.ts" |
||||
|
git commit -m "feat(api): soft-delete comment from me scope" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 7: 前端 `PostComments` 与页面挂载 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `app/components/PostComments.vue` |
||||
|
- Modify: `app/pages/@[publicSlug]/posts/[postSlug].vue` |
||||
|
- Modify: `app/pages/p/[publicSlug]/t/[shareToken].vue` |
||||
|
|
||||
|
- [ ] **Step 1: 组件行为** |
||||
|
|
||||
|
Props:`'public-post' | 'unlisted'` + `publicSlug` + (`postSlug` 或 `shareToken`)。 |
||||
|
|
||||
|
- `useAsyncData` 拉取 comments(key 含路由参数)。 |
||||
|
- 展示树:递归子组件或内联递归模板;软删显示「此评论已删除」类文案(`UAlert` 或 `text-muted`)。 |
||||
|
- 发表:`$fetch` POST 到对应 API;登录用户用 `request`(若需 cookie)——与 `app/pages/me` 一致;公开页 POST 若需 cookie,`$fetch` 加 `credentials: 'include'`。 |
||||
|
- 删除:若 `viewerCanDelete`,调用 `request(\`/api/me/posts/${postId}/comments/${id}\`, { method: 'DELETE' })`,成功后 `refreshNuxtData`。 |
||||
|
|
||||
|
- [ ] **Step 2: 两处页面挂载** |
||||
|
|
||||
|
在 `[postSlug].vue` 的 `template` 中 `article` 后插入 `<PostComments mode="public-post" ... />`。 |
||||
|
在 `t/[shareToken].vue` 的 `kind === 'post'` 分支内 `article` 后插入 `<PostComments mode="unlisted" ... />`。 |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel |
||||
|
git add app/components/PostComments.vue app/pages |
||||
|
git commit -m "feat(ui): post comments on public and unlisted post pages" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 8: 验证与收尾 |
||||
|
|
||||
|
- [ ] **Step 1: 运行单测** |
||||
|
|
||||
|
Run: |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel && bun test server/utils/post-comment-guest.test.ts |
||||
|
``` |
||||
|
|
||||
|
Expected: PASS。 |
||||
|
|
||||
|
- [ ] **Step 2: 构建** |
||||
|
|
||||
|
Run: |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel && bun run build |
||||
|
``` |
||||
|
|
||||
|
Expected: PASS(若失败,检查 Nitro 路由与 import 别名)。 |
||||
|
|
||||
|
- [ ] **Step 3: 手动验收清单(勾选)** |
||||
|
|
||||
|
- 公开文章:列表空、访客发帖、登录发帖、嵌套回复、本人删、作者删访客帖、软删占位。 |
||||
|
- 未列出链接:同上。 |
||||
|
- 访客含 URL → 400。 |
||||
|
- 回复已删父节点 → 400。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 可选增量(非验收) |
||||
|
|
||||
|
- **管理员删评:** 在 `softDeleteComment` 中若 `user.role === 'admin'` 允许删除(需查 `users.role`),与 `#server/utils/admin-guard` 语义对齐。 |
||||
|
- **更严 Markdown:** 若未来正文改为 HTML 渲染,评论侧同步改为同一净化管道。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Plan self-review |
||||
|
|
||||
|
| Spec 章节 | 对应 Task | |
||||
|
|-----------|-----------| |
||||
|
| 数据模型 | Task 1 | |
||||
|
| 访客规则 | Task 2、4、5 | |
||||
|
| GET/POST 双入口 | Task 3、5 | |
||||
|
| 软删 + me DELETE | Task 4、6 | |
||||
|
| 前端共用 | Task 7 | |
||||
|
| 测试 | Task 2、8 | |
||||
|
|
||||
|
**Placeholder scan:** 无 TBD;限流数字已写死示例,可按压测调整。 |
||||
|
**类型一致性:** `listCommentsPayload` 参数顺序在 Task 4/5 中均为 `(postId, ownerUserId, viewerId)`;若实现时改名,需两文件同步。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
**Plan complete and saved to `docs/superpowers/plans/2026-04-18-post-comments-implementation-plan.md`. Two execution options:** |
||||
|
|
||||
|
**1. Subagent-Driven (recommended)** — 每个 Task 派生子代理,Task 之间人工过一遍,迭代快。 |
||||
|
**2. Inline Execution** — 本会话按 Task 顺序实现,批次之间设检查点。 |
||||
|
|
||||
|
**你想用哪一种?** |
||||
Loading…
Reference in new issue