Files
geo/docs/superpowers/specs/2026-04-18-kol-profile-page-design.md
T
root 26dd75e548 docs: add KOL profile page design spec
- KOL 工作台新增「个人主页」独立页,明确与租户/SaaS 账号设置区分
- 定位为精调模版展示页 + KOL 人设自助编辑
- 页面内联编辑、头像复用 image_service、仅展示已发布模版
2026-04-18 13:55:20 +08:00

298 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# KOL 个人主页(对外人设信息,可编辑)设计
- 日期:2026-04-18
- 涉及端:`apps/admin-web``server/internal/tenant`
- 负责:自服务(KOL 本人操作)
## 1. 背景与目标
现有「KOL 工作台」(`kolWorkspace` 导航组,仅 `requiresKol` 用户可见)包含两页:
- `/kol/manage`:管理 Package / Prompt(精调模版)
- `/kol/dashboard`:订阅与消耗数据看板
KOL 当前在「KOL 市场」上展示的人设(`display_name` / `bio` / `avatar_url`)没有任何自助编辑入口,必须依赖后台管理员或脚本改库。
本次新增一个 **KOL 个人主页**:展示并允许 KOL 自助编辑自己**对外**的人设信息。页面只做这一件事。
### 非目标(明确不做)
- **不涉及租户/SaaS 账号设置**:不改密码、邮箱、租户名、用户昵称等;这些归属于账号系统,本页与之物理与语义都隔离。
- 不做访客态的公开主页(`/u/:kol_id` 这类 public URL),本次仅做 KOL 自己进入的自助页。
- 不做已发布精调模版的展示/预览(用户明确不要),如需查看作品入口仍在 `/kol/manage`
- 不做市场可见状态(`market_enabled`)开关,避免与后台审批逻辑冲突;本期连只读展示也不做,保持纯粹。
- 不做订阅者、消耗等统计(已在 `/kol/dashboard`)。
## 2. 与"租户/SaaS 个人信息"的区分
| 维度 | 本次新增:KOL 个人主页 | 租户/SaaS 账号设置(现有/未来) |
|---|---|---|
| 定位 | 对外人设(KOL 市场可见的身份信息) | 登录账号与租户系统设置 |
| 数据源 | `kol_profile` 表(`display_name` / `bio` / `avatar_url` | `users` / `tenants` 表 |
| 路由 | `/kol/profile` | 与本页无关,保持现状 |
| 导航分组 | `kolWorkspace``requiresKol: true` | 顶部头像/设置入口 |
| 可访问性 | 仅当前用户是 active KOL | 所有登录用户 |
| 页面副标题 | "此处信息公开展示在 KOL 市场,与账号设置无关" | —— |
页面内通过副标题显式告知用户:"这不是你的账号设置,这是你在 KOL 市场上别人看到的你"。
## 3. 页面布局
路由:`/kol/profile`,名 `kol-profile``meta.requiresKol = true``meta.navKey = "/kol/profile"`
页面极简,只有一张人设卡片:
```
┌─────────────────────────────────────────────────────────┐
│ KOL 个人主页 │
│ 此处信息公开展示在 KOL 市场,与账号设置无关 │
├─────────────────────────────────────────────────────────┤
│ ┌─ 人设卡片 ───────────────────────────────────────┐ │
│ │ │ │
│ │ [ 头像 ] 显示名(hover 出现铅笔,点击编辑)│ │
│ │ │ │
│ │ 简介(hover 出现铅笔,点击编辑) │ │
│ │ 多行,支持换行 │ │
│ │ │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 3.1 标题区
- 标题:`kol.profile.title = "KOL 个人主页"`
- 副标题:`kol.profile.subtitle = "此处信息公开展示在 KOL 市场,与账号设置无关"`
### 3.2 人设卡片(页面内联编辑)
三个可编辑字段,使用 Ant Design Vue 的 `a-typography-paragraph``editable` 能力或等价自研方案;交互要点:
| 字段 | 控件 | 校验 | 保存时机 |
| --- | --- | --- | --- |
| `avatar_url` | 头像圆形 120×120,悬浮"更换头像"遮罩,点击触发文件选择器 | 仅允许 image/*;最大 2MB;对象存储白名单域 | 上传完成后立即触发 `updateProfile` 落库 |
| `display_name` | hover 铅笔 → 单行 input | 必填;`trim().length` 1-40 | 失焦 或 回车 |
| `bio` | hover 铅笔 → textareaautosize 2-6 行) | 选填;≤ 500 字符 | 失焦 或 ⌘/Ctrl + Enter |
细节:
- 校验失败:inline 红字,不退出编辑态,保留用户输入。
- 保存成功:`message.success('已更新')`,卡片局部视觉无跳动。
- 保存失败:`message.error(formatError(err))`,退回编辑前值,但保留用户当前输入给用户重试。
- 保存期间:当前字段 disabled + loading 小 spinner。
- 头像上传失败:遮罩隐藏、提示错误,`avatar_url` 不变。
- 头像上传成功但 `updateProfile` 失败:文件留在对象存储(孤儿),UI 回滚到原 `avatar_url`;接受该小量孤儿(与现有文章图片策略一致)。
交互选择的理由:页面只有一张卡片,不需要统一"保存"按钮;每字段各自失焦保存最贴近"我就改一个地方"的使用场景,路径最短。
## 4. 后端改动
### 4.1 新增接口:更新 KOL 人设
- Method`PUT`
- Path`/api/tenant/kol/manage/profile`
- 认证:`requiresAuth` + handler 内调用 `profileSvc.RequireActiveKol`
- 入参 JSON
```json
{
"display_name": "string, required, 1-40",
"bio": "string|null, 0-500",
"avatar_url": "string|null, must belong to our image_service domain"
}
```
- 出参:更新后的 `KolProfileResponse`
- 实现要点:
- `KolProfileService` 新增 `UpdateProfile(ctx, actor, input) (*KolProfile, error)`
- 先 `RequireActiveKol` 拿到 `profile`(保证 user_id → profile_id 映射 + status=active
- 调 `repo.Update(ctx, actor.TenantID, UpdateKolProfileInput{ID: profile.ID, DisplayName, Bio, AvatarURL})`repo 方法已存在,见 `kol_profile_repo.go:94`
- 重新 `GetByID` 返回最新值
- Handler`kol_manage_handler.go` 新增 `UpdateProfile(c *gin.Context)`,复用 `actorFromRequest`、`response.Success` 风格。
- Router`server/internal/tenant/transport/router.go` 在 `GET /tenant/kol/manage/profile` 旁新增 `PUT` 绑定(同一路径 + 不同方法)。
校验错误码:
- `40001 kol_profile_invalid_display_name` — `display_name` 去空白后为空或 > 40
- `40002 kol_profile_invalid_bio` — `bio` > 500
- `40003 kol_profile_invalid_avatar_url` — `avatar_url` 非空且不在白名单域
白名单域:沿用 `image_service` 已有配置;具体字段名 Plan 阶段确认。
### 4.2 头像上传:复用 image_service
- 新增 `POST /api/tenant/kol/manage/profile/avatar`multipart/form-data,字段 `file`):
- 前端先用已有 `convertImageFileToWebp` 压成 webp 后再上传(与 `articlesApi.uploadImage` 同路径)。
- 后端复用 `image_service`,归类 `folder = "kol-avatar"` / `ownerType = "kol_profile"`;返回 `{ url, object_key, content_type, size_bytes }`。
- 本接口**不自动写 `avatar_url` 入库**:前端拿到 `url` 后,立刻发 `PUT /api/tenant/kol/manage/profile { avatar_url }` 落库。单一写入点,避免部分成功。
Plan 阶段核查:`image_service` 是否已支持"非 article 归属"的归类;若不支持,补一个最小化 scoped-to-kol-profile 的上传路径。
### 4.3 不改动
- 不新增市场端接口。
- 不改 `kol_profile` 表结构。
- 不改 `market_enabled` 语义。
## 5. 前端改动
### 5.1 路由
`apps/admin-web/src/router/index.ts`:在 `kol/dashboard` 之后插入:
```ts
{
path: "kol/profile",
name: "kol-profile",
component: () => import("@/views/KolProfileView.vue"),
meta: {
titleKey: "kol.profile.title",
navKey: "/kol/profile",
requiresKol: true,
},
},
```
### 5.2 导航
`apps/admin-web/src/layouts/AppShell.vue` 的 `kolWorkspace` 分组末尾追加一项:
```ts
{ key: "/kol/profile", label: t("nav.kolProfile") }
```
### 5.3 API 客户端
`apps/admin-web/src/lib/api.ts` 的 `kolManageApi` 新增:
```ts
updateProfile(body: {
display_name: string;
bio?: string | null;
avatar_url?: string | null;
}): Promise<KolProfile>
uploadAvatar(file: File): Promise<{
url: string;
object_key: string;
content_type: string;
size_bytes: number;
}>
```
`uploadAvatar` 内部调用已有 `convertImageFileToWebp`,与 `articlesApi.uploadImage` 同构。
### 5.4 新组件
- `apps/admin-web/src/views/KolProfileView.vue`(主页,薄壳:header + 一张卡片)
- `apps/admin-web/src/components/kol/KolPersonaCard.vue`(人设卡片 + 三字段内联编辑;单一职责,便于单测)
页面总体不超过 ~250 行代码。
### 5.5 状态
- `useQuery(["kol", "profile"], kolManageApi.profile)` 读取。
- `useMutation(kolManageApi.updateProfile)` 写入,成功后:
- `invalidateQueries({ queryKey: ["kol", "profile"] })`
- `invalidateQueries({ queryKey: ["workspace", "kol-cards"] })`(工作台 KOL 卡片用到 `kol_display_name` / `kol_avatar_url`
- `invalidateQueries({ queryKey: ["kol", "packages"] })`(包列表响应里带了 `kol_display_name`/`kol_avatar_url`
- `useMutation(kolManageApi.uploadAvatar)` 独立:上传成功后立即链式触发 `updateProfile({ avatar_url: url, display_name: current, bio: current })`。
- 内联编辑的"正在编辑哪个字段"用组件内局部 `ref<'display_name' | 'bio' | null>` 管理,不落全局 store。
## 6. i18n
`apps/admin-web/src/i18n/messages/{en-US,zh-CN}.ts` 新增:
```
nav.kolProfile
kol.profile.title
kol.profile.subtitle
kol.profile.persona.displayNameLabel
kol.profile.persona.displayNamePlaceholder
kol.profile.persona.bioLabel
kol.profile.persona.bioPlaceholder
kol.profile.persona.avatarLabel
kol.profile.persona.avatarUploading
kol.profile.persona.saveSuccess
kol.profile.persona.saveError
kol.profile.persona.displayNameRequired
kol.profile.persona.displayNameTooLong
kol.profile.persona.bioTooLong
kol.profile.persona.avatarInvalidType
kol.profile.persona.avatarTooLarge
kol.profile.persona.notActive
```
两种语言同步添加;具体文案 Plan 阶段定稿。
## 7. 数据流
```
Browser Server
─────── ──────
进入 /kol/profile
└── GET /api/tenant/kol/manage/profile → KolProfile
用户点击头像 → 选择文件
├── 前端 convertImageFileToWebp
├── POST /api/tenant/kol/manage/profile/avatar (form-data)
│ → { url } (落对象存储,未写 DB)
└── PUT /api/tenant/kol/manage/profile
{ avatar_url, display_name: current, bio: current }
→ KolProfile
⇢ invalidate ["kol","profile"] / ["workspace","kol-cards"] / ["kol","packages"]
用户编辑 display_name / bio → 失焦(或回车)
└── PUT /api/tenant/kol/manage/profile
{ display_name, bio, avatar_url: current }
→ KolProfile
⇢ 同上 invalidate
```
`PUT` 总是发送三字段的全量值,后端 `repo.Update` 对应 SQL 也是整行覆写,避免部分字段被旧值覆盖。
## 8. 错误处理
- 网络失败:`message.error(formatError(err))`,回滚到编辑前值,保留当前输入。
- 校验失败(后端):按错误码定位到字段,inline 红字;不退出编辑态。
- `requiresKol` 守卫:`router.beforeEach` 已处理。
- 后端运行时返回 `ErrNotActiveKol`:前端兜底 `message.error(t('kol.profile.persona.notActive'))` 并路由回 `/workspace`。
## 9. 测试
### 后端
- `kol_profile_service` 新增单测:
- `UpdateProfile` 成功
- 非 active KOL 拒绝
- `display_name` 空 / 超长各自返对应错误码
- `bio` 超长返对应错误码
- `avatar_url` 非白名单返对应错误码
- `kol_manage_handler` 新增 handler 测试(遵循现有 handler_test 风格)
- 若新增 avatar 上传路径:`image_service` 覆盖"非 article 归属"分支
### 前端
- `KolPersonaCard` 单测:显示名为空时报错、超长时报错、保存成功后退出编辑态、保存失败回滚。
- `KolProfileView` 页面级测试:加载态、加载失败态、正常态。
- 若项目已有 E2E:覆盖"换头像 → 改显示名 → 改 bio → 刷新工作台 KOL 卡片同步";无则留作后续。
### 手动验收
1. 普通用户访问 `/kol/profile` → 跳回 `/workspace` ✅
2. active KOL 访问 → 正常渲染人设卡片 ✅
3. 改头像 / 显示名 / bio 各自触发保存 → 成功 ✅
4. `/workspace` 面板中的 KOL 卡片 `kol_display_name` / `kol_avatar_url` 同步更新 ✅
5. 控制台无报错、无 i18n 缺失告警 ✅
## 10. 风险与权衡
- **内联编辑 vs Modal**:页面只一张卡片,Modal 冗余;内联编辑 + 失焦保存最短路径,代价是对校验文案与回滚逻辑要求较高。采用"错误不退出编辑态 + 失败回滚编辑前值"的 UX。
- **每字段独立保存 vs 全局"保存"按钮**:独立保存减少心智负担;全局按钮更"事务"。选前者,因为本页目的是"顺手改一处",不是一次性录入。
- **头像白名单**:严格白名单可防盗链/XSS,但对象存储域名变更时需同步改后端配置;风险可控。
- **缓存同步**`display_name` / `avatar_url` 改动后需手动 `invalidateQueries` 多个 keyPlan 阶段勿漏。
- **孤儿头像文件**:头像上传成功但随后 `updateProfile` 失败时会遗留对象存储文件;与现有文章图片策略一致,接受。
## 11. 成功判据
- KOL 可在不经后台的情况下自助更新 `display_name` / `bio` / `avatar_url`。
- 页面语义上与账号设置清晰隔离(文案、路由、导航分组、数据源四位一体)。
- 更新后,工作台、KOL 市场等现有页面引用到的 `display_name` / `avatar_url` 实时一致。
- 页面代码简洁(单主页 + 单卡片组件),不堆砌无关功能。