1 changed files with 492 additions and 0 deletions
@ -0,0 +1,492 @@ |
|||||
|
# 公开「关于我」页 + `media_refs` ownerType(profile)实现计划 |
||||
|
|
||||
|
> **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:** 实现 `docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md`:公开路由 `/@:publicSlug/about`、轻量 API `GET /api/public/profile/:publicSlug/about`,并将 `media_refs` 从「仅 post」演进为 `(owner_type, owner_id, asset_id)`,新增 `syncProfileMediaRefs`,使简介与头像中的站内图纳入孤儿判定。 |
||||
|
|
||||
|
**Architecture:** 数据上 **`users.bio_markdown` / `avatar` 仍为唯一真相**;`media_refs` 中 `owner_type` 为 `post` 时 `owner_id === posts.id`,为 `profile` 时 `owner_id === users.id`。**不对 `owner_id` 做多态外键**;删除文章时**先**删 `owner_type='post'` 的引用行再删 `posts` 行,再 `reconcileAssetTimestampsAfterRefChange`。资料保存路径在 `updateProfile` 成功后对 `bioMarkdown`/`avatar` 变更调用 `syncProfileMediaRefs`。公开 about 页仅拉轻量 API;主页 bio 与 about 共用 `renderSafeMarkdown` + `v-html`。 |
||||
|
|
||||
|
**Tech Stack:** Nuxt 4.4 / Nitro、Drizzle + SQLite(`drizzle-pkg`)、Bun、现有 `markdown-it` + `isomorphic-dompurify`(`app/utils/render-markdown.ts`)。 |
||||
|
|
||||
|
**Spec:** `docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md` |
||||
|
|
||||
|
**现状说明:** 表已从 `post_media_refs` 重命名为 **`media_refs`**(`0004` 迁移);本计划在 **`media_refs` 上**做列结构升级,不是第二次改名。 |
||||
|
|
||||
|
**测试说明:** 以 spec §7 **手工验收**为主;每任务完成后执行 `bun run build` 或通过 `bun run dev` 冒烟。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 文件结构(将创建 / 修改) |
||||
|
|
||||
|
| 路径 | 职责 | |
||||
|
|------|------| |
||||
|
| `packages/drizzle-pkg/database/sqlite/schema/content.ts` | `mediaRefs` 改为 `ownerType` + `ownerId` + `assetId`;移除对 `posts` 的外键 | |
||||
|
| `packages/drizzle-pkg/migrations/0005_media_refs_owner_type.sql` | 重建表并回填 `post` 行 | |
||||
|
| `packages/drizzle-pkg/migrations/meta/_journal.json` | 追加 `0005` 条目 | |
||||
|
| `packages/drizzle-pkg/migrations/meta/0005_snapshot.json` | Drizzle meta(由 0004 派生并替换 `media_refs` 块) | |
||||
|
| `packages/drizzle-pkg/db.sqlite` | 本地迁移后提交(若仓库跟踪该文件) | |
||||
|
| `server/constants/media-refs.ts`(或并入 `server/constants/media.ts`) | `MEDIA_REF_OWNER_POST`、`MEDIA_REF_OWNER_PROFILE` 字符串常量 | |
||||
|
| `server/utils/post-media-urls.ts` | 新增 `mergeProfileMediaUrls(bioMarkdown, avatar)`(内部复用 `mergePostMediaUrls`) | |
||||
|
| `server/service/media/index.ts` | `syncPostMediaRefs`、`syncProfileMediaRefs`、`reconcile*`、孤儿与删除判定改为按 `mediaRefs` 全表 | |
||||
|
| `server/service/posts/index.ts` | `deletePost`:先删 `media_refs` post 行再删 post;`syncPostMediaRefs` 改用新列名 | |
||||
|
| `server/service/profile/index.ts` | `updateProfile` 末尾在 `bioMarkdown`/`avatar` 参与 patch 时调用 `syncProfileMediaRefs` | |
||||
|
| `server/api/me/profile.put.ts` | 若同步放在 service 内则可能无需改;若放在 handler 则调用 service 暴露的同步(优先 **集中在 `updateProfile`**) | |
||||
|
| `server/api/public/profile/[publicSlug]/about.get.ts` | 轻量 200/404 | |
||||
|
| `app/pages/@[publicSlug]/about/index.vue` | about 页布局 + `renderSafeMarkdown` | |
||||
|
| `app/pages/@[publicSlug]/index.vue` | bio 区块改为 `v-html` + 条件「查看全文」链到 `/@slug/about` | |
||||
|
| `docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md` | 可选:将文中 `post_media_refs` 改为 `media_refs`(文档一致性,非阻塞) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 1: `media_refs` 表结构与迁移 `0005` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `packages/drizzle-pkg/database/sqlite/schema/content.ts` |
||||
|
- Create: `packages/drizzle-pkg/migrations/0005_media_refs_owner_type.sql` |
||||
|
- Modify: `packages/drizzle-pkg/migrations/meta/_journal.json` |
||||
|
- Create: `packages/drizzle-pkg/migrations/meta/0005_snapshot.json` |
||||
|
|
||||
|
- [ ] **Step 1: 替换 `mediaRefs` 表定义为三列复合主键** |
||||
|
|
||||
|
在 `packages/drizzle-pkg/database/sqlite/schema/content.ts` 中,将现有 `mediaRefs`(`postId` + `assetId`)整块替换为: |
||||
|
|
||||
|
```typescript |
||||
|
export const mediaRefs = sqliteTable( |
||||
|
"media_refs", |
||||
|
{ |
||||
|
ownerType: text("owner_type").notNull(), |
||||
|
ownerId: integer("owner_id").notNull(), |
||||
|
assetId: integer("asset_id") |
||||
|
.notNull() |
||||
|
.references(() => mediaAssets.id, { onDelete: "cascade" }), |
||||
|
}, |
||||
|
(table) => [ |
||||
|
primaryKey({ columns: [table.ownerType, table.ownerId, table.assetId] }), |
||||
|
index("media_refs_asset_id_idx").on(table.assetId), |
||||
|
], |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
**不得**保留 `post_id` → `posts` 的外键(与 spec 一致,删除 post 由应用层先清 ref)。 |
||||
|
|
||||
|
- [ ] **Step 2: 新增 SQL 迁移文件** |
||||
|
|
||||
|
创建 `packages/drizzle-pkg/migrations/0005_media_refs_owner_type.sql`: |
||||
|
|
||||
|
```sql |
||||
|
CREATE TABLE `media_refs_new` ( |
||||
|
`owner_type` text NOT NULL, |
||||
|
`owner_id` integer NOT NULL, |
||||
|
`asset_id` integer NOT NULL, |
||||
|
PRIMARY KEY(`owner_type`, `owner_id`, `asset_id`), |
||||
|
FOREIGN KEY (`asset_id`) REFERENCES `media_assets`(`id`) ON UPDATE no action ON DELETE cascade |
||||
|
); |
||||
|
--> statement-breakpoint |
||||
|
INSERT INTO `media_refs_new` (`owner_type`, `owner_id`, `asset_id`) |
||||
|
SELECT 'post', `post_id`, `asset_id` FROM `media_refs`; |
||||
|
--> statement-breakpoint |
||||
|
DROP TABLE `media_refs`; |
||||
|
--> statement-breakpoint |
||||
|
ALTER TABLE `media_refs_new` RENAME TO `media_refs`; |
||||
|
--> statement-breakpoint |
||||
|
CREATE INDEX `media_refs_asset_id_idx` ON `media_refs` (`asset_id`); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 3: 更新 `_journal.json`** |
||||
|
|
||||
|
在 `entries` 数组末尾追加(`when` 可用当前毫秒时间戳): |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"idx": 5, |
||||
|
"version": "6", |
||||
|
"when": 1776700000000, |
||||
|
"tag": "0005_media_refs_owner_type", |
||||
|
"breakpoints": true |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 4: 生成 `0005_snapshot.json`** |
||||
|
|
||||
|
复制 `packages/drizzle-pkg/migrations/meta/0004_snapshot.json` → `0005_snapshot.json`,然后: |
||||
|
|
||||
|
1. 将顶层 `"id"` 改为 **新 UUID**,`"prevId"` 改为 **`0004_snapshot.json` 的 `id`**(当前为 `2b8a9201-fbd7-4993-871d-a351e56f204a`)。 |
||||
|
2. 在 `"tables"` 中替换整个 **`media_refs`** 对象为下列结构(删除 `post_id` 及指向 `posts` 的外键,主键为三列): |
||||
|
|
||||
|
```json |
||||
|
"media_refs": { |
||||
|
"name": "media_refs", |
||||
|
"columns": { |
||||
|
"owner_type": { |
||||
|
"name": "owner_type", |
||||
|
"type": "text", |
||||
|
"primaryKey": false, |
||||
|
"notNull": true, |
||||
|
"autoincrement": false |
||||
|
}, |
||||
|
"owner_id": { |
||||
|
"name": "owner_id", |
||||
|
"type": "integer", |
||||
|
"primaryKey": false, |
||||
|
"notNull": true, |
||||
|
"autoincrement": false |
||||
|
}, |
||||
|
"asset_id": { |
||||
|
"name": "asset_id", |
||||
|
"type": "integer", |
||||
|
"primaryKey": false, |
||||
|
"notNull": true, |
||||
|
"autoincrement": false |
||||
|
} |
||||
|
}, |
||||
|
"indexes": { |
||||
|
"media_refs_asset_id_idx": { |
||||
|
"name": "media_refs_asset_id_idx", |
||||
|
"columns": ["asset_id"], |
||||
|
"isUnique": false |
||||
|
} |
||||
|
}, |
||||
|
"foreignKeys": { |
||||
|
"media_refs_asset_id_media_assets_id_fk": { |
||||
|
"name": "media_refs_asset_id_media_assets_id_fk", |
||||
|
"tableFrom": "media_refs", |
||||
|
"tableTo": "media_assets", |
||||
|
"columnsFrom": ["asset_id"], |
||||
|
"columnsTo": ["id"], |
||||
|
"onDelete": "cascade", |
||||
|
"onUpdate": "no action" |
||||
|
} |
||||
|
}, |
||||
|
"compositePrimaryKeys": { |
||||
|
"media_refs_owner_type_owner_id_asset_id_pk": { |
||||
|
"columns": ["owner_type", "owner_id", "asset_id"], |
||||
|
"name": "media_refs_owner_type_owner_id_asset_id_pk" |
||||
|
} |
||||
|
}, |
||||
|
"uniqueConstraints": {}, |
||||
|
"checkConstraints": {} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 5: 运行迁移并校验** |
||||
|
|
||||
|
```bash |
||||
|
cd /home/dash/projects/person-panel && bun run db:migrate |
||||
|
``` |
||||
|
|
||||
|
Expected: 无报错;`sqlite3 packages/drizzle-pkg/db.sqlite ".schema media_refs"` 可见三列与主键。 |
||||
|
|
||||
|
- [ ] **Step 6: 更新 `lib/schema` 导出** |
||||
|
|
||||
|
确认 `packages/drizzle-pkg/lib/schema/content.ts` 仍 `export { … mediaRefs … }`(无 `postId` 引用)。 |
||||
|
|
||||
|
- [ ] **Step 7: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add packages/drizzle-pkg/database/sqlite/schema/content.ts \ |
||||
|
packages/drizzle-pkg/migrations/0005_media_refs_owner_type.sql \ |
||||
|
packages/drizzle-pkg/migrations/meta/_journal.json \ |
||||
|
packages/drizzle-pkg/migrations/meta/0005_snapshot.json \ |
||||
|
packages/drizzle-pkg/db.sqlite packages/drizzle-pkg/lib/schema/content.ts |
||||
|
git commit -m "feat(db): media_refs owner_type and owner_id for post/profile" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 2: 常量与 `mergeProfileMediaUrls` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/constants/media-refs.ts`(或修改 `server/constants/media.ts` 追加导出) |
||||
|
- Modify: `server/utils/post-media-urls.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 定义 owner 常量** |
||||
|
|
||||
|
```typescript |
||||
|
// server/constants/media-refs.ts |
||||
|
export const MEDIA_REF_OWNER_POST = "post" as const; |
||||
|
export const MEDIA_REF_OWNER_PROFILE = "profile" as const; |
||||
|
export type MediaRefOwnerType = typeof MEDIA_REF_OWNER_POST | typeof MEDIA_REF_OWNER_PROFILE; |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 新增合并函数** |
||||
|
|
||||
|
在 `server/utils/post-media-urls.ts` 末尾追加: |
||||
|
|
||||
|
```typescript |
||||
|
export function mergeProfileMediaUrls(bioMarkdown: string | null | undefined, avatar: string | null | undefined): string[] { |
||||
|
return mergePostMediaUrls(bioMarkdown ?? "", avatar ?? null); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
(`mergePostMediaUrls` 已在同文件,直接调用。) |
||||
|
|
||||
|
- [ ] **Step 3: `bun run build`** |
||||
|
|
||||
|
Expected: 通过(此时若 Task 1 已改 schema 但 media service 未改,可能 TS 报错 —— **顺序上**可先完成 Task 3 再 build,或将本 Task 与 Task 3 同一 commit)。 |
||||
|
|
||||
|
- [ ] **Step 4: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/constants/media-refs.ts server/utils/post-media-urls.ts |
||||
|
git commit -m "feat(media): profile URL merge helper and ref owner constants" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 3: `server/service/media/index.ts` 同步与孤儿逻辑 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `server/service/media/index.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 更新 import** |
||||
|
|
||||
|
增加:`and`、`eq` 与 `MEDIA_REF_OWNER_POST`、`MEDIA_REF_OWNER_PROFILE`;`mergeProfileMediaUrls` 从 `#server/utils/post-media-urls` 引入。 |
||||
|
|
||||
|
- [ ] **Step 2: 重写 `syncPostMediaRefs`** |
||||
|
|
||||
|
逻辑保持不变,但: |
||||
|
|
||||
|
- `beforeRows` / `delete` / `insert` 条件改为 `and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, postId))`。 |
||||
|
- `insert` values:`{ ownerType: MEDIA_REF_OWNER_POST, ownerId: postId, assetId }`。 |
||||
|
|
||||
|
- [ ] **Step 3: 新增 `syncProfileMediaRefs`** |
||||
|
|
||||
|
```typescript |
||||
|
export async function syncProfileMediaRefs( |
||||
|
userId: number, |
||||
|
bioMarkdown: string | null, |
||||
|
avatar: string | null, |
||||
|
): Promise<void> { |
||||
|
const beforeRows = await dbGlobal |
||||
|
.select({ assetId: mediaRefs.assetId }) |
||||
|
.from(mediaRefs) |
||||
|
.where(and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_PROFILE), eq(mediaRefs.ownerId, userId))); |
||||
|
const beforeIds = beforeRows.map((r) => r.assetId); |
||||
|
|
||||
|
await dbGlobal |
||||
|
.delete(mediaRefs) |
||||
|
.where(and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_PROFILE), eq(mediaRefs.ownerId, userId))); |
||||
|
|
||||
|
const urls = mergeProfileMediaUrls(bioMarkdown, avatar); |
||||
|
const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))]; |
||||
|
|
||||
|
let afterIds: number[] = []; |
||||
|
if (keys.length > 0) { |
||||
|
const assetRows = await dbGlobal |
||||
|
.select({ id: mediaAssets.id }) |
||||
|
.from(mediaAssets) |
||||
|
.where(and(eq(mediaAssets.userId, userId), inArray(mediaAssets.storageKey, keys))); |
||||
|
afterIds = assetRows.map((r) => r.id); |
||||
|
if (afterIds.length > 0) { |
||||
|
await dbGlobal |
||||
|
.insert(mediaRefs) |
||||
|
.values( |
||||
|
afterIds.map((assetId) => ({ |
||||
|
ownerType: MEDIA_REF_OWNER_PROFILE, |
||||
|
ownerId: userId, |
||||
|
assetId, |
||||
|
})), |
||||
|
) |
||||
|
.onConflictDoNothing(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
await reconcileAssetTimestampsAfterRefChange([...new Set([...beforeIds, ...afterIds])]); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 4: `reconcileAssetTimestampsAfterRefChange`** |
||||
|
|
||||
|
其中 `count()` 查询已 `from(mediaRefs).where(eq(mediaRefs.assetId, id))`,**无需改 WHERE**(全表任意 owner 即算引用)。 |
||||
|
|
||||
|
- [ ] **Step 5: `assertAssetDeletableOrThrow` 文案** |
||||
|
|
||||
|
将 `statusMessage` 从「仍被文章引用」改为 **「资源仍被引用」**(因可能来自 profile)。 |
||||
|
|
||||
|
- [ ] **Step 6: `bun run build`** |
||||
|
|
||||
|
Expected: PASS。 |
||||
|
|
||||
|
- [ ] **Step 7: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/service/media/index.ts |
||||
|
git commit -m "feat(media): syncPostMediaRefs owner columns and syncProfileMediaRefs" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 4: `deletePost` 顺序与 posts 服务 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `server/service/posts/index.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 调整 `deletePost`** |
||||
|
|
||||
|
在 `dbGlobal.delete(posts)` **之前**: |
||||
|
|
||||
|
```typescript |
||||
|
const refRows = await dbGlobal |
||||
|
.select({ assetId: mediaRefs.assetId }) |
||||
|
.from(mediaRefs) |
||||
|
.where(and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, id))); |
||||
|
const touched = refRows.map((r) => r.assetId); |
||||
|
await dbGlobal |
||||
|
.delete(mediaRefs) |
||||
|
.where(and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, id))); |
||||
|
await dbGlobal.delete(posts).where(and(eq(posts.id, id), eq(posts.userId, userId))); |
||||
|
await reconcileAssetTimestampsAfterRefChange(touched); |
||||
|
``` |
||||
|
|
||||
|
删除原来的「仅 select + delete post 依赖级联」路径(级联已不存在)。 |
||||
|
|
||||
|
- [ ] **Step 2: `bun run build`** |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/service/posts/index.ts |
||||
|
git commit -m "fix(posts): delete media_refs before post row" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 5: `updateProfile` 触发 `syncProfileMediaRefs` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `server/service/profile/index.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 在 `updateProfile` 末尾同步** |
||||
|
|
||||
|
`await dbGlobal.update(users)...` 之后、`return getProfileRow` 之前: |
||||
|
|
||||
|
- 若 `patch.bioMarkdown !== undefined || patch.avatar !== undefined`:读取 **最终行**(可先 `const row = await getProfileRow(userId)`),调用: |
||||
|
|
||||
|
```typescript |
||||
|
import { syncProfileMediaRefs } from "#server/service/media"; |
||||
|
|
||||
|
// ... |
||||
|
await syncProfileMediaRefs(userId, row.bioMarkdown ?? null, row.avatar ?? null); |
||||
|
``` |
||||
|
|
||||
|
注意:`avatar` / `bioMarkdown` 以**落库后**的 `row` 为准,避免 patch 只改其一却传错另一字段。 |
||||
|
|
||||
|
- [ ] **Step 2: `bun run build`** |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/service/profile/index.ts |
||||
|
git commit -m "feat(profile): sync profile media refs after update" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 6: 公开 `GET .../about` API |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/api/public/profile/[publicSlug]/about.get.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 实现 handler** |
||||
|
|
||||
|
与 `[publicSlug].get.ts` 相同方式解析 `publicSlug`;查 `users` `publicSlug` + `active`。若不存在 → 404。 |
||||
|
|
||||
|
若 `bioVisibility !== 'public'` 或 `!(bioMarkdown?.trim())` → 404。 |
||||
|
|
||||
|
否则返回(形状与聚合接口对齐,减少前端分叉): |
||||
|
|
||||
|
```typescript |
||||
|
return R.success({ |
||||
|
user: { |
||||
|
publicSlug: owner.publicSlug, |
||||
|
nickname: owner.nickname, |
||||
|
avatar: owner.avatarVisibility === "public" ? owner.avatar : null, |
||||
|
}, |
||||
|
bio: { markdown: owner.bioMarkdown }, |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: `bun run build`** |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/api/public/profile/\[publicSlug\]/about.get.ts |
||||
|
git commit -m "feat(api): public profile about endpoint" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 7: 前端 about 页 + 主页 bio 与链接 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `app/pages/@[publicSlug]/about/index.vue` |
||||
|
- Modify: `app/pages/@[publicSlug]/index.vue` |
||||
|
|
||||
|
- [ ] **Step 1: 新增 `about/index.vue`** |
||||
|
|
||||
|
- `definePageMeta({ layout: 'public' })` |
||||
|
- `useAsyncData` → `$fetch` `/api/public/profile/${encodeURIComponent(slug)}/about`(与现 unwrap 模式一致,见 `@[publicSlug]/index.vue`) |
||||
|
- 404:`throw createError({ statusCode: 404 })` 或使用项目统一错误页 |
||||
|
- 正文:`div.prose` + `v-html="renderSafeMarkdown(data.bio.markdown)"`(`import { renderSafeMarkdown } from '../../utils/render-markdown'`,路径按文件深度调整) |
||||
|
- `useHead`:`title` 含昵称 + 「关于」 |
||||
|
|
||||
|
- [ ] **Step 2: 修改主页 `index.vue`** |
||||
|
|
||||
|
- 将所有展示 `data.bio.markdown` 的 **`{{ }}`** 改为 **`v-html="renderSafeMarkdown(data.bio.markdown)"`**(须 `import { renderSafeMarkdown } from '...'`)。 |
||||
|
- 在 **bio 区块**(两处布局若均有 bio)增加 `ULink` 或 `NuxtLink`:**仅当** `data.bio?.markdown` 存在时显示「查看全文」→ `` `/${slug}/about` `` 或 `` `/@${slug}/about` ``(与现路由一致:动态段为 `publicSlug` 时路径为 `/@xxx/about`)。 |
||||
|
|
||||
|
- [ ] **Step 3: `bun run build`** |
||||
|
|
||||
|
- [ ] **Step 4: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add app/pages/\@\[publicSlug\]/about/index.vue app/pages/\@\[publicSlug\]/index.vue |
||||
|
git commit -m "feat(public): about page and bio markdown rendering" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 8: 规格 §7 手工验收 |
||||
|
|
||||
|
- [ ] **文章 regression**:创建/编辑/删除带图文章,孤儿列表行为与升级前一致。 |
||||
|
- [ ] **Profile**:公开 bio 含站内图 → 保存资料 → 孤儿列表不出现误报;移除图并保存 → 进入冷却/可删流程符合宽限期。 |
||||
|
- [ ] **About URL**:bio 公开且非空 → `/@slug/about` 可访问;bio 改 private 或清空 → **404**。 |
||||
|
- [ ] **全文链接**:主页「查看全文」仅在 about 可 200 时显示。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 计划自检(对照 spec) |
||||
|
|
||||
|
| Spec 章节 | 覆盖任务 | |
||||
|
|-----------|----------| |
||||
|
| §2 路由与 404 规则 | Task 6、7 | |
||||
|
| §3 `ownerType` / `ownerId` / 迁移 | Task 1 | |
||||
|
| §3.2 `syncProfileMediaRefs` + URL 范围 | Task 2、3、5 | |
||||
|
| §3.3 孤儿与删除 | Task 3、4 | |
||||
|
| §3.4 迁移与 backfill | Task 1 迁移;**不强制**全站 backfill,历史数据靠「再保存一次资料」修复 | |
||||
|
| §4 API | Task 6 | |
||||
|
| §5 前端 | Task 7 | |
||||
|
| §6 非目标 | 未包含 timeline 引用 | |
||||
|
| §7 测试 | Task 8 | |
||||
|
|
||||
|
**占位符扫描:** 无 TBD。 |
||||
|
**类型一致:** `owner_type` 字面量与 `MEDIA_REF_OWNER_*` 常量一致;`owner_id` 在 post/profile 下语义与 spec §8 一致。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
**计划已保存至:** `docs/superpowers/plans/2026-04-18-public-profile-about-media-refs-owner-implementation-plan.md` |
||||
|
|
||||
|
**执行方式可以二选一:** |
||||
|
|
||||
|
1. **Subagent-Driven(推荐)** — 每任务派生子代理并在任务间评审,迭代快。 |
||||
|
2. **Inline Execution** — 在本会话用 executing-plans 按检查点批量执行。 |
||||
|
|
||||
|
你更倾向哪一种? |
||||
Loading…
Reference in new issue