docs: add KOL profile page design spec
- KOL 工作台新增「个人主页」独立页,明确与租户/SaaS 账号设置区分 - 定位为精调模版展示页 + KOL 人设自助编辑 - 页面内联编辑、头像复用 image_service、仅展示已发布模版
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
# 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 铅笔 → textarea(autosize 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` 多个 key,Plan 阶段勿漏。
|
||||
- **孤儿头像文件**:头像上传成功但随后 `updateProfile` 失败时会遗留对象存储文件;与现有文章图片策略一致,接受。
|
||||
|
||||
## 11. 成功判据
|
||||
|
||||
- KOL 可在不经后台的情况下自助更新 `display_name` / `bio` / `avatar_url`。
|
||||
- 页面语义上与账号设置清晰隔离(文案、路由、导航分组、数据源四位一体)。
|
||||
- 更新后,工作台、KOL 市场等现有页面引用到的 `display_name` / `avatar_url` 实时一致。
|
||||
- 页面代码简洁(单主页 + 单卡片组件),不堆砌无关功能。
|
||||
Reference in New Issue
Block a user