- KOL 工作台新增「个人主页」独立页,明确与租户/SaaS 账号设置区分 - 定位为精调模版展示页 + KOL 人设自助编辑 - 页面内联编辑、头像复用 image_service、仅展示已发布模版
14 KiB
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:
{ "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去空白后为空或 > 4040002 kol_profile_invalid_bio—bio> 50040003 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 之后插入:
{
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 分组末尾追加一项:
{ key: "/kol/profile", label: t("nav.kolProfile") }
5.3 API 客户端
apps/admin-web/src/lib/api.ts 的 kolManageApi 新增:
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 卡片同步";无则留作后续。
手动验收
- 普通用户访问
/kol/profile→ 跳回/workspace✅ - active KOL 访问 → 正常渲染人设卡片 ✅
- 改头像 / 显示名 / bio 各自触发保存 → 成功 ✅
/workspace面板中的 KOL 卡片kol_display_name/kol_avatar_url同步更新 ✅- 控制台无报错、无 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实时一致。 - 页面代码简洁(单主页 + 单卡片组件),不堆砌无关功能。