diff --git a/docs/superpowers/specs/2026-04-18-kol-profile-page-design.md b/docs/superpowers/specs/2026-04-18-kol-profile-page-design.md new file mode 100644 index 0000000..3217693 --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-kol-profile-page-design.md @@ -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 + +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` 实时一致。 +- 页面代码简洁(单主页 + 单卡片组件),不堆砌无关功能。