Files
geo/docs/superpowers/plans/2026-04-18-kol-profile-page.md
T
root 79c65c1da7 feat(kol): expand variable schema and polish KOL UI
- Add length/range limits to KOL variable definitions: max_length and
  default_value for input/textarea, min_value/max_value/default_number
  for number. Backend validates and normalizes; frontend renders limit
  inputs in KolVariableConfig and applies limits at runtime in
  KolDynamicForm.
- Sort workspace cards by most recent prompt/package update, falling
  back to granted_at. Adds updated_at to KolWorkspaceCard.
- Polish TemplatesView, WorkspaceView, and KOL package management UI;
  route create/update/publish/archive/delete toasts through i18n and
  expand package form copy and status labels.
- Remove stale KnowledgeView.vue.patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:01:40 +08:00

1361 lines
40 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 Profile Page Implementation Plan
> **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:** Let an active KOL self-edit their outward-facing persona (display_name, bio, avatar_url) via a dedicated page under the KOL workspace nav group, independent of the tenant/SaaS account settings.
**Architecture:**
- Backend: add `UpdateProfile` + `UploadAvatar` to `KolProfileService`, a handler pair in `KolManageHandler`, and routes under `/api/tenant/kol/manage/profile*`. Avatar upload reuses the existing object-storage + signed-asset-URL pipeline.
- Frontend: new `/kol/profile` route → `KolProfileView.vue` (thin shell) → `KolPersonaCard.vue` (inline editable fields + avatar upload). API client extended with `updateProfile` / `uploadAvatar`. Nav item appended to `kolWorkspace` group.
**Tech Stack:** Go (gin, pgx), Vue 3 + TS, Ant Design Vue, Vue Query (TanStack), vue-i18n, vue-tsc.
**Spec:** `docs/superpowers/specs/2026-04-18-kol-profile-page-design.md`
---
## File Structure
### Backend (create)
- `server/internal/tenant/app/kol_profile_validate.go` — pure validation helpers for display_name / bio / avatar_url.
- `server/internal/tenant/app/kol_profile_validate_test.go` — unit tests for validators (no DB).
- `server/internal/tenant/app/kol_profile_avatar.go` — avatar image decode/encode/store helper (self-contained, no ArticleService coupling).
### Backend (modify)
- `server/internal/tenant/app/kol_profile_service.go` — add `UpdateProfile` method + `UpdateKolProfileServiceInput` type + `KolAvatarUploadResponse` type + `UploadAvatar` method.
- `server/internal/tenant/transport/kol_manage_handler.go` — add `assets *AssetHandler` field, wire in constructor, add `UpdateProfile` + `UploadAvatar` handlers.
- `server/internal/tenant/transport/router.go` — register `PUT /tenant/kol/manage/profile` and `POST /tenant/kol/manage/profile/avatar`, pass `AssetHandler` into `NewKolManageHandler`.
### Frontend (create)
- `apps/admin-web/src/views/KolProfileView.vue` — page shell: title + subtitle + mounts `KolPersonaCard`.
- `apps/admin-web/src/components/kol/KolPersonaCard.vue` — avatar + display_name + bio with inline edit + per-field autosave.
### Frontend (modify)
- `apps/admin-web/src/lib/api.ts` — add `updateProfile` and `uploadAvatar` to `kolManageApi`.
- `apps/admin-web/src/router/index.ts` — add `kol/profile` route under the authenticated shell.
- `apps/admin-web/src/layouts/AppShell.vue` — append `/kol/profile` to the `kolWorkspace` items.
- `apps/admin-web/src/i18n/messages/zh-CN.ts` — add `nav.kolProfile` + `kol.profile.*` keys.
- `apps/admin-web/src/i18n/messages/en-US.ts` — mirror keys in English.
---
## Task 1: Backend — Validation helpers (TDD)
**Files:**
- Create: `server/internal/tenant/app/kol_profile_validate.go`
- Create: `server/internal/tenant/app/kol_profile_validate_test.go`
- [ ] **Step 1: Write the failing test**
File: `server/internal/tenant/app/kol_profile_validate_test.go`
```go
package app
import (
"strings"
"testing"
)
func TestValidateKolDisplayName(t *testing.T) {
cases := []struct {
name string
input string
wantOK bool
wantVal string
}{
{"trim spaces", " alice ", true, "alice"},
{"keep unicode", "张小明", true, "张小明"},
{"empty rejected", " ", false, ""},
{"40 chars ok", strings.Repeat("a", 40), true, strings.Repeat("a", 40)},
{"41 chars rejected", strings.Repeat("a", 41), false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := validateKolDisplayName(tc.input)
if tc.wantOK && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !tc.wantOK && err == nil {
t.Fatalf("expected error, got nil (value=%q)", got)
}
if tc.wantOK && got != tc.wantVal {
t.Fatalf("got %q want %q", got, tc.wantVal)
}
})
}
}
func TestValidateKolBio(t *testing.T) {
cases := []struct {
name string
input *string
wantOK bool
}{
{"nil ok", nil, true},
{"empty trimmed to nil", strPtr(" "), true},
{"500 chars ok", strPtr(strings.Repeat("b", 500)), true},
{"501 chars rejected", strPtr(strings.Repeat("b", 501)), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := validateKolBio(tc.input)
if tc.wantOK && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !tc.wantOK && err == nil {
t.Fatalf("expected error, got nil")
}
})
}
}
func TestValidateKolAvatarURL(t *testing.T) {
cases := []struct {
name string
input *string
wantOK bool
}{
{"nil ok", nil, true},
{"empty trimmed to nil", strPtr(" "), true},
{"asset path ok", strPtr("/api/public/assets/abc.def"), true},
{"external url rejected", strPtr("https://evil.example.com/a.jpg"), false},
{"javascript scheme rejected", strPtr("javascript:alert(1)"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := validateKolAvatarURL(tc.input)
if tc.wantOK && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !tc.wantOK && err == nil {
t.Fatalf("expected error, got nil")
}
})
}
}
func strPtr(s string) *string { return &s }
```
- [ ] **Step 2: Run the test, expect failure**
Run:
```bash
cd server && go test ./internal/tenant/app/ -run 'TestValidateKol' -v
```
Expected: `FAIL` with `undefined: validateKolDisplayName` (and siblings).
- [ ] **Step 3: Implement the validators**
File: `server/internal/tenant/app/kol_profile_validate.go`
```go
package app
import (
"strings"
"unicode/utf8"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const (
kolDisplayNameMaxLen = 40
kolBioMaxLen = 500
kolAvatarURLPrefix = "/api/public/assets/"
)
// validateKolDisplayName trims input and returns the canonical form.
// Returns an error (40001) if empty or longer than kolDisplayNameMaxLen runes.
func validateKolDisplayName(raw string) (string, error) {
v := strings.TrimSpace(raw)
if v == "" {
return "", response.ErrBadRequest(40001, "kol_profile_invalid_display_name", "display_name is required")
}
if utf8.RuneCountInString(v) > kolDisplayNameMaxLen {
return "", response.ErrBadRequest(40001, "kol_profile_invalid_display_name", "display_name must be at most 40 characters")
}
return v, nil
}
// validateKolBio trims input; empty/whitespace becomes nil.
// Returns an error (40002) if trimmed length exceeds kolBioMaxLen runes.
func validateKolBio(raw *string) (*string, error) {
if raw == nil {
return nil, nil
}
v := strings.TrimSpace(*raw)
if v == "" {
return nil, nil
}
if utf8.RuneCountInString(v) > kolBioMaxLen {
return nil, response.ErrBadRequest(40002, "kol_profile_invalid_bio", "bio must be at most 500 characters")
}
return &v, nil
}
// validateKolAvatarURL accepts either nil/empty or a path under our signed-asset
// prefix. Anything else (external URLs, javascript: schemes, etc.) is rejected.
func validateKolAvatarURL(raw *string) (*string, error) {
if raw == nil {
return nil, nil
}
v := strings.TrimSpace(*raw)
if v == "" {
return nil, nil
}
if !strings.HasPrefix(v, kolAvatarURLPrefix) {
return nil, response.ErrBadRequest(40003, "kol_profile_invalid_avatar_url", "avatar_url must be an asset path served by this service")
}
return &v, nil
}
```
- [ ] **Step 4: Run the test, expect pass**
Run:
```bash
cd server && go test ./internal/tenant/app/ -run 'TestValidateKol' -v
```
Expected: all `PASS`.
- [ ] **Step 5: Commit**
```bash
git add server/internal/tenant/app/kol_profile_validate.go server/internal/tenant/app/kol_profile_validate_test.go
git commit -m "feat(kol): add KOL profile field validators"
```
---
## Task 2: Backend — `UpdateProfile` service method
**Files:**
- Modify: `server/internal/tenant/app/kol_profile_service.go`
- [ ] **Step 1: Define the service input type**
Add near the other type declarations at the top of `server/internal/tenant/app/kol_profile_service.go` (after `type KolProfileResponse struct {...}`, before `NewKolProfileService`):
```go
type UpdateKolProfileServiceInput struct {
DisplayName string
Bio *string
AvatarURL *string
}
```
- [ ] **Step 2: Implement `UpdateProfile` method**
Append to the same file, after `RequireActiveKol`:
```go
// UpdateProfile validates input, updates display_name/bio/avatar_url for the
// acting KOL, and returns the refreshed profile.
func (s *KolProfileService) UpdateProfile(ctx context.Context, actor auth.Actor, input UpdateKolProfileServiceInput) (*repository.KolProfile, error) {
profile, err := s.RequireActiveKol(ctx, actor)
if err != nil {
return nil, err
}
displayName, err := validateKolDisplayName(input.DisplayName)
if err != nil {
return nil, err
}
bio, err := validateKolBio(input.Bio)
if err != nil {
return nil, err
}
avatarURL, err := validateKolAvatarURL(input.AvatarURL)
if err != nil {
return nil, err
}
if err := s.repo.Update(ctx, actor.TenantID, repository.UpdateKolProfileInput{
ID: profile.ID,
DisplayName: displayName,
Bio: bio,
AvatarURL: avatarURL,
}); err != nil {
return nil, response.ErrInternal(50062, "kol_profile_update_failed", err.Error())
}
updated, err := s.repo.GetByID(ctx, profile.ID)
if err != nil {
return nil, response.ErrInternal(50061, "kol_profile_query_failed", err.Error())
}
if updated == nil {
return nil, ErrNotActiveKol
}
return updated, nil
}
```
- [ ] **Step 3: Verify it builds**
Run:
```bash
cd server && go build ./internal/tenant/app/...
```
Expected: no output, exit 0.
- [ ] **Step 4: Run the existing app test suite**
Run:
```bash
cd server && go test ./internal/tenant/app/ -count=1
```
Expected: `ok`.
- [ ] **Step 5: Commit**
```bash
git add server/internal/tenant/app/kol_profile_service.go
git commit -m "feat(kol): add KolProfileService.UpdateProfile"
```
---
## Task 3: Backend — Avatar upload helper
**Files:**
- Create: `server/internal/tenant/app/kol_profile_avatar.go`
- Modify: `server/internal/tenant/app/kol_profile_service.go` (inject object storage dep)
- [ ] **Step 1: Wire object storage dependency into `KolProfileService`**
In `server/internal/tenant/app/kol_profile_service.go`, replace the struct and constructor with (the new fields are additive; existing callers do not pass them yet):
```go
type KolProfileService struct {
repo repository.KolProfileRepository
objectStorage objectstorage.Client
}
func NewKolProfileService(repo repository.KolProfileRepository) *KolProfileService {
return &KolProfileService{repo: repo}
}
// WithObjectStorage enables avatar uploads for this service.
func (s *KolProfileService) WithObjectStorage(client objectstorage.Client) *KolProfileService {
s.objectStorage = client
return s
}
```
Add the import at the top of the file:
```go
import (
// ... existing imports ...
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
)
```
(The actual import path must match the other imports in the file; verify against `article_service.go` which already imports `objectstorage`.)
- [ ] **Step 2: Implement avatar upload**
File: `server/internal/tenant/app/kol_profile_avatar.go`
```go
package app
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/chai2010/webp"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const (
kolAvatarMaxSizeBytes = 2 * 1024 * 1024 // 2 MB
kolAvatarMaxDimension = 2048
kolAvatarEncodeQuality = 85
)
var kolAvatarAllowedMIMEs = map[string]string{
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
}
type KolAvatarUploadResponse struct {
URL string `json:"url"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
}
// UploadAvatar stores an avatar image for the acting KOL and returns its
// stable object key. The caller is responsible for signing/building a URL and
// for persisting the resulting URL to the profile row via UpdateProfile.
func (s *KolProfileService) UploadAvatar(ctx context.Context, actor auth.Actor, filename string, content []byte) (*KolAvatarUploadResponse, error) {
if s.objectStorage == nil {
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
profile, err := s.RequireActiveKol(ctx, actor)
if err != nil {
return nil, err
}
if err := s.objectStorage.Validate(); err != nil {
if errors.Is(err, objectstorage.ErrNotConfigured) {
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
return nil, response.ErrInternal(50064, "kol_avatar_storage_validation_failed", err.Error())
}
if len(content) == 0 {
return nil, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file is required")
}
if len(content) > kolAvatarMaxSizeBytes {
return nil, response.ErrBadRequest(40005, "kol_avatar_too_large", "avatar size cannot exceed 2 MB")
}
contentType := http.DetectContentType(content)
if _, ok := kolAvatarAllowedMIMEs[contentType]; !ok {
return nil, response.ErrBadRequest(40006, "kol_avatar_type_not_supported", "only PNG, JPG, GIF, or WebP is supported")
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(content))
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return nil, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file cannot be decoded")
}
if cfg.Width > kolAvatarMaxDimension || cfg.Height > kolAvatarMaxDimension {
return nil, response.ErrBadRequest(40007, "kol_avatar_dimensions_too_large", "avatar dimensions exceed the maximum allowed size")
}
storedContent := content
storedContentType := contentType
if contentType != "image/webp" {
decoded, _, decodeErr := image.Decode(bytes.NewReader(content))
if decodeErr != nil {
return nil, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file cannot be decoded")
}
var buf bytes.Buffer
if encodeErr := webp.Encode(&buf, decoded, &webp.Options{Quality: kolAvatarEncodeQuality}); encodeErr != nil {
return nil, response.ErrInternal(50065, "kol_avatar_encode_failed", "failed to encode avatar")
}
storedContent = buf.Bytes()
storedContentType = "image/webp"
}
objectKey := buildKolAvatarObjectKey(actor.TenantID, profile.ID, filename)
if err := s.objectStorage.PutBytes(ctx, objectKey, storedContent, storedContentType); err != nil {
if errors.Is(err, objectstorage.ErrNotConfigured) {
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
return nil, response.ErrInternal(50066, "kol_avatar_upload_failed", "failed to upload avatar")
}
return &KolAvatarUploadResponse{
URL: "", // filled in by the handler via AssetHandler
ObjectKey: objectKey,
ContentType: storedContentType,
SizeBytes: int64(len(storedContent)),
}, nil
}
func buildKolAvatarObjectKey(tenantID, profileID int64, filename string) string {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
if ext == "" {
ext = "webp"
}
// Always stored as webp (encoder above forces it); suffix uses the
// final mime extension for clarity.
ext = "webp"
ts := time.Now().UTC().UnixNano()
return fmt.Sprintf("tenants/%d/kol-avatars/%d/%d.%s", tenantID, profileID, ts, ext)
}
```
Note on imports: the existing `article_service.go` already uses `github.com/chai2010/webp` and `image/*` decoders. If the precise import path for `objectstorage` differs, mirror what `article_service.go` uses.
- [ ] **Step 3: Verify it builds**
Run:
```bash
cd server && go build ./internal/tenant/app/...
```
Expected: exit 0. If an import path is wrong, copy the canonical block from `server/internal/tenant/app/article_service.go` (imports section) and adapt.
- [ ] **Step 4: Run full app tests**
```bash
cd server && go test ./internal/tenant/app/ -count=1
```
Expected: `ok`.
- [ ] **Step 5: Commit**
```bash
git add server/internal/tenant/app/kol_profile_avatar.go server/internal/tenant/app/kol_profile_service.go
git commit -m "feat(kol): add avatar upload to KolProfileService"
```
---
## Task 4: Backend — Handlers + routes
**Files:**
- Modify: `server/internal/tenant/transport/kol_manage_handler.go`
- Modify: `server/internal/tenant/transport/router.go`
- [ ] **Step 1: Wire `AssetHandler` + `ObjectStorage` into `KolManageHandler`**
In `server/internal/tenant/transport/kol_manage_handler.go`, replace the struct and constructor with:
```go
type KolManageHandler struct {
profileSvc *app.KolProfileService
pkgSvc *app.KolPackageService
promptSvc *app.KolPromptService
assistSvc *app.KolAssistService
assets *AssetHandler
}
func NewKolManageHandler(a *bootstrap.App, assets *AssetHandler) *KolManageHandler {
profileSvc := app.NewKolProfileService(a.KolProfiles).WithObjectStorage(a.ObjectStorage)
return &KolManageHandler{
profileSvc: profileSvc,
pkgSvc: app.NewKolPackageService(profileSvc, a.KolPackages, a.KolPrompts).WithCache(a.Cache),
promptSvc: app.NewKolPromptService(a.DB, profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions, a.KolPromptAsset).WithCache(a.Cache),
assistSvc: app.NewKolAssistService(profileSvc, a.KolAssists, a.LLM, a.RabbitMQ),
assets: assets,
}
}
```
Verify the field name `a.ObjectStorage` is correct by grepping `bootstrap.App`:
```bash
cd server && grep -n 'ObjectStorage' internal/bootstrap/*.go
```
If the actual exported field is `a.Storage` or similar, use that instead in both places.
- [ ] **Step 2: Add `UpdateProfile` handler**
Append to the same file (after the existing `GetProfile` method):
```go
type updateKolProfileRequest struct {
DisplayName string `json:"display_name"`
Bio *string `json:"bio"`
AvatarURL *string `json:"avatar_url"`
}
func (h *KolManageHandler) UpdateProfile(c *gin.Context) {
actor, ok := actorFromRequest(c)
if !ok {
return
}
var req updateKolProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
profile, err := h.profileSvc.UpdateProfile(c.Request.Context(), actor, app.UpdateKolProfileServiceInput{
DisplayName: req.DisplayName,
Bio: req.Bio,
AvatarURL: req.AvatarURL,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, app.NewKolProfileResponse(profile))
}
```
- [ ] **Step 3: Add `UploadAvatar` handler**
Append to the same file:
```go
import (
// keep existing imports
"io"
"net/http"
)
func (h *KolManageHandler) UploadAvatar(c *gin.Context) {
actor, ok := actorFromRequest(c)
if !ok {
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
response.Error(c, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file is required"))
return
}
f, err := fileHeader.Open()
if err != nil {
response.Error(c, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file cannot be opened"))
return
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
response.Error(c, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file cannot be read"))
return
}
result, err := h.profileSvc.UploadAvatar(c.Request.Context(), actor, fileHeader.Filename, content)
if err != nil {
response.Error(c, err)
return
}
result.URL = h.assets.BuildArticleImageURL(result.ObjectKey)
response.SuccessWithStatus(c, http.StatusCreated, result)
}
```
(If `strconv` is no longer referenced after edits, remove it from the import block. Go build will fail on unused imports.)
- [ ] **Step 4: Register routes + inject AssetHandler**
In `server/internal/tenant/transport/router.go`, update the kol-manage block (around line 5978):
Find:
```go
kolManage := protected.Group("/tenant/kol/manage")
kolManageHandler := NewKolManageHandler(app)
kolManage.GET("/profile", kolManageHandler.GetProfile)
```
Replace with:
```go
kolManage := protected.Group("/tenant/kol/manage")
kolManageHandler := NewKolManageHandler(app, publicAssets)
kolManage.GET("/profile", kolManageHandler.GetProfile)
kolManage.PUT("/profile", kolManageHandler.UpdateProfile)
kolManage.POST("/profile/avatar", kolManageHandler.UploadAvatar)
```
Note: `publicAssets` is the `*AssetHandler` already created at line 15.
- [ ] **Step 5: Build the server**
Run:
```bash
cd server && go build ./...
```
Expected: exit 0.
- [ ] **Step 6: Run full test suite**
Run:
```bash
cd server && go test ./... -count=1
```
Expected: `ok` (integration tests are already gated by `-tags=integration`).
- [ ] **Step 7: Commit**
```bash
git add server/internal/tenant/transport/kol_manage_handler.go server/internal/tenant/transport/router.go
git commit -m "feat(kol): expose PUT /kol/manage/profile and POST /kol/manage/profile/avatar"
```
---
## Task 5: Frontend — API client
**Files:**
- Modify: `apps/admin-web/src/lib/api.ts`
- [ ] **Step 1: Extend `kolManageApi`**
In `apps/admin-web/src/lib/api.ts`, locate the `kolManageApi` object (starts line 270). Immediately after the `profile()` method (line ~273), insert:
```ts
updateProfile(body: {
display_name: string;
bio?: string | null;
avatar_url?: string | null;
}) {
return apiClient.put<KolProfile, typeof body>(
"/api/tenant/kol/manage/profile",
body,
);
},
async uploadAvatar(file: File) {
const normalizedFile = await convertImageFileToWebp(file);
const progressToken = markImageUploadRequestStarted(normalizedFile.name);
try {
const formData = new FormData();
formData.append("file", normalizedFile, normalizedFile.name);
const response = await apiClient.raw.post<ApiEnvelope<{
url: string;
object_key: string;
content_type: string;
size_bytes: number;
}>>("/api/tenant/kol/manage/profile/avatar", formData);
markImageUploadRequestFinished(progressToken);
return {
...response.data.data,
url: resolveApiURL(response.data.data.url),
};
} catch (error) {
markImageUploadRequestFailed(progressToken);
throw error;
}
},
```
Verify `ApiEnvelope` is already imported in this file (it is — already used by `articlesApi.uploadImage` at line 428). If not, add it to the imports from `@geo/http-client`.
- [ ] **Step 2: Typecheck**
Run:
```bash
cd apps/admin-web && npm run typecheck
```
Expected: exit 0, no errors.
- [ ] **Step 3: Commit**
```bash
git add apps/admin-web/src/lib/api.ts
git commit -m "feat(kol-web): add updateProfile and uploadAvatar to kolManageApi"
```
---
## Task 6: Frontend — i18n keys
**Files:**
- Modify: `apps/admin-web/src/i18n/messages/zh-CN.ts`
- Modify: `apps/admin-web/src/i18n/messages/en-US.ts`
- [ ] **Step 1: Add `nav.kolProfile` + `kol.profile.*` to zh-CN**
In `apps/admin-web/src/i18n/messages/zh-CN.ts`:
1. Inside the `nav` object (~line 6887), append after `kolDashboard`:
```ts
kolProfile: "个人主页",
```
2. Inside the top-level `kol` object, add a new `profile` subtree (place it between `manage` and whatever follows, or at the end of the `kol` block — the exact position doesn't matter for i18n resolution):
```ts
profile: {
title: "KOL 个人主页",
subtitle: "此处信息公开展示在 KOL 市场,与账号设置无关",
persona: {
displayNameLabel: "显示名",
displayNamePlaceholder: "请输入你的展示名",
bioLabel: "简介",
bioPlaceholder: "写一句你希望订阅者看到的自我介绍",
avatarLabel: "头像",
avatarHint: "支持 PNG/JPG/GIF/WebP,最大 2 MB",
avatarUploading: "正在上传头像…",
saveSuccess: "已更新",
saveError: "更新失败",
displayNameRequired: "请填写显示名",
displayNameTooLong: "显示名最多 40 个字符",
bioTooLong: "简介最多 500 个字符",
avatarInvalidType: "仅支持 PNG/JPG/GIF/WebP",
avatarTooLarge: "头像大小不能超过 2 MB",
notActive: "你当前不是活跃的 KOL",
},
},
```
- [ ] **Step 2: Mirror into en-US**
In `apps/admin-web/src/i18n/messages/en-US.ts`, perform the same two edits with English copy:
```ts
kolProfile: "Profile",
```
```ts
profile: {
title: "KOL Profile",
subtitle: "These fields are shown in the KOL Marketplace and are separate from your account settings.",
persona: {
displayNameLabel: "Display name",
displayNamePlaceholder: "Enter your public display name",
bioLabel: "Bio",
bioPlaceholder: "A short intro for your subscribers",
avatarLabel: "Avatar",
avatarHint: "PNG/JPG/GIF/WebP, up to 2 MB",
avatarUploading: "Uploading avatar…",
saveSuccess: "Updated",
saveError: "Failed to update",
displayNameRequired: "Display name is required",
displayNameTooLong: "Display name must be at most 40 characters",
bioTooLong: "Bio must be at most 500 characters",
avatarInvalidType: "Only PNG/JPG/GIF/WebP are supported",
avatarTooLarge: "Avatar size cannot exceed 2 MB",
notActive: "You are not an active KOL",
},
},
```
- [ ] **Step 3: Typecheck**
```bash
cd apps/admin-web && npm run typecheck
```
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add apps/admin-web/src/i18n/messages/zh-CN.ts apps/admin-web/src/i18n/messages/en-US.ts
git commit -m "feat(kol-web): add i18n keys for KOL profile page"
```
---
## Task 7: Frontend — `KolPersonaCard.vue`
**Files:**
- Create: `apps/admin-web/src/components/kol/KolPersonaCard.vue`
- [ ] **Step 1: Create the component**
File: `apps/admin-web/src/components/kol/KolPersonaCard.vue`
```vue
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import { EditOutlined, UserOutlined, LoadingOutlined } from "@ant-design/icons-vue";
import type { KolProfile } from "@geo/shared-types";
import { kolManageApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
const props = defineProps<{
profile: KolProfile;
}>();
const { t } = useI18n();
const queryClient = useQueryClient();
const editing = ref<"display_name" | "bio" | null>(null);
const draftDisplayName = ref(props.profile.display_name);
const draftBio = ref(props.profile.bio ?? "");
const avatarUploading = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const avatarURL = computed(() => props.profile.avatar_url ?? "");
const updateMutation = useMutation({
mutationFn: kolManageApi.updateProfile,
onSuccess: async () => {
message.success(t("kol.profile.persona.saveSuccess"));
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["kol", "profile"] }),
queryClient.invalidateQueries({ queryKey: ["workspace", "kol-cards"] }),
queryClient.invalidateQueries({ queryKey: ["kol", "packages"] }),
]);
editing.value = null;
},
onError: (error) => {
message.error(formatError(error) || t("kol.profile.persona.saveError"));
draftDisplayName.value = props.profile.display_name;
draftBio.value = props.profile.bio ?? "";
},
});
function saveFields(patch: { display_name?: string; bio?: string | null; avatar_url?: string | null }) {
updateMutation.mutate({
display_name: patch.display_name ?? props.profile.display_name,
bio: patch.bio !== undefined ? patch.bio : props.profile.bio,
avatar_url: patch.avatar_url !== undefined ? patch.avatar_url : props.profile.avatar_url,
});
}
function commitDisplayName() {
const next = draftDisplayName.value.trim();
if (!next) {
message.error(t("kol.profile.persona.displayNameRequired"));
return;
}
if (next.length > 40) {
message.error(t("kol.profile.persona.displayNameTooLong"));
return;
}
if (next === props.profile.display_name) {
editing.value = null;
return;
}
saveFields({ display_name: next });
}
function commitBio() {
const next = draftBio.value.trim();
if (next.length > 500) {
message.error(t("kol.profile.persona.bioTooLong"));
return;
}
const nextVal = next === "" ? null : next;
if (nextVal === (props.profile.bio ?? null)) {
editing.value = null;
return;
}
saveFields({ bio: nextVal });
}
function cancelEdit() {
draftDisplayName.value = props.profile.display_name;
draftBio.value = props.profile.bio ?? "";
editing.value = null;
}
function startDisplayNameEdit() {
draftDisplayName.value = props.profile.display_name;
editing.value = "display_name";
}
function startBioEdit() {
draftBio.value = props.profile.bio ?? "";
editing.value = "bio";
}
function triggerAvatarPicker() {
fileInput.value?.click();
}
async function handleFilePicked(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
if (!/^image\/(png|jpe?g|gif|webp)$/i.test(file.type)) {
message.error(t("kol.profile.persona.avatarInvalidType"));
return;
}
if (file.size > 2 * 1024 * 1024) {
message.error(t("kol.profile.persona.avatarTooLarge"));
return;
}
avatarUploading.value = true;
try {
const uploaded = await kolManageApi.uploadAvatar(file);
saveFields({ avatar_url: uploaded.url });
} catch (error) {
message.error(formatError(error) || t("kol.profile.persona.saveError"));
} finally {
avatarUploading.value = false;
}
}
</script>
<template>
<section class="persona-card">
<div class="persona-avatar-wrap" :class="{ uploading: avatarUploading }" @click="triggerAvatarPicker">
<template v-if="avatarURL">
<img :src="avatarURL" class="persona-avatar" alt="" />
</template>
<template v-else>
<div class="persona-avatar persona-avatar-fallback">
<UserOutlined />
</div>
</template>
<div class="persona-avatar-mask">
<LoadingOutlined v-if="avatarUploading" />
<span v-else>{{ t("kol.profile.persona.avatarLabel") }}</span>
</div>
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
class="persona-avatar-input"
@change="handleFilePicked"
/>
</div>
<div class="persona-fields">
<!-- Display name -->
<div class="persona-field">
<label class="persona-field-label">{{ t("kol.profile.persona.displayNameLabel") }}</label>
<template v-if="editing === 'display_name'">
<a-input
v-model:value="draftDisplayName"
:disabled="updateMutation.isPending.value"
:placeholder="t('kol.profile.persona.displayNamePlaceholder')"
:max-length="40"
allow-clear
@press-enter="commitDisplayName"
@blur="commitDisplayName"
@keyup.esc="cancelEdit"
/>
</template>
<template v-else>
<div class="persona-field-static" @click="startDisplayNameEdit">
<span class="persona-field-value">{{ profile.display_name }}</span>
<EditOutlined class="persona-field-edit-icon" />
</div>
</template>
</div>
<!-- Bio -->
<div class="persona-field">
<label class="persona-field-label">{{ t("kol.profile.persona.bioLabel") }}</label>
<template v-if="editing === 'bio'">
<a-textarea
v-model:value="draftBio"
:disabled="updateMutation.isPending.value"
:placeholder="t('kol.profile.persona.bioPlaceholder')"
:auto-size="{ minRows: 2, maxRows: 6 }"
:max-length="500"
show-count
@blur="commitBio"
@keydown.ctrl.enter.prevent="commitBio"
@keydown.meta.enter.prevent="commitBio"
@keyup.esc="cancelEdit"
/>
</template>
<template v-else>
<div class="persona-field-static persona-field-static-multiline" @click="startBioEdit">
<span class="persona-field-value persona-field-value-bio">
{{ profile.bio || t("kol.profile.persona.bioPlaceholder") }}
</span>
<EditOutlined class="persona-field-edit-icon" />
</div>
</template>
</div>
</div>
</section>
</template>
<style scoped>
.persona-card {
display: flex;
align-items: center;
gap: 32px;
background: #ffffff;
border-radius: 12px;
padding: 24px;
}
.persona-avatar-wrap {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
background: #f5f5f5;
}
.persona-avatar,
.persona-avatar-fallback {
width: 100%;
height: 100%;
object-fit: cover;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: #bfbfbf;
}
.persona-avatar-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
opacity: 0;
transition: opacity 0.15s ease;
}
.persona-avatar-wrap:hover .persona-avatar-mask,
.persona-avatar-wrap.uploading .persona-avatar-mask {
opacity: 1;
}
.persona-avatar-input {
display: none;
}
.persona-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
.persona-field-label {
display: block;
color: #8c8c8c;
font-size: 12px;
margin-bottom: 6px;
}
.persona-field-static {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: text;
padding: 2px 0;
max-width: 100%;
}
.persona-field-static-multiline {
align-items: flex-start;
}
.persona-field-value {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
word-break: break-word;
}
.persona-field-value-bio {
font-size: 14px;
font-weight: 400;
color: #595959;
white-space: pre-wrap;
}
.persona-field-edit-icon {
color: #bfbfbf;
font-size: 14px;
opacity: 0;
transition: opacity 0.15s ease;
}
.persona-field-static:hover .persona-field-edit-icon {
opacity: 1;
}
</style>
```
- [ ] **Step 2: Typecheck**
```bash
cd apps/admin-web && npm run typecheck
```
Expected: exit 0.
- [ ] **Step 3: Commit**
```bash
git add apps/admin-web/src/components/kol/KolPersonaCard.vue
git commit -m "feat(kol-web): add KolPersonaCard with inline editing"
```
---
## Task 8: Frontend — `KolProfileView.vue` + route + nav
**Files:**
- Create: `apps/admin-web/src/views/KolProfileView.vue`
- Modify: `apps/admin-web/src/router/index.ts`
- Modify: `apps/admin-web/src/layouts/AppShell.vue`
- [ ] **Step 1: Create the view**
File: `apps/admin-web/src/views/KolProfileView.vue`
```vue
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import { kolManageApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
import KolPersonaCard from "@/components/kol/KolPersonaCard.vue";
const { t } = useI18n();
const profileQuery = useQuery({
queryKey: ["kol", "profile"],
queryFn: () => kolManageApi.profile(),
});
</script>
<template>
<div class="kol-profile-view">
<header class="page-header">
<h2 class="page-title">{{ t("kol.profile.title") }}</h2>
<p class="page-subtitle">{{ t("kol.profile.subtitle") }}</p>
</header>
<a-skeleton v-if="profileQuery.isPending.value" active avatar :paragraph="{ rows: 3 }" />
<a-alert
v-else-if="profileQuery.isError.value"
type="error"
show-icon
:message="formatError(profileQuery.error.value) || t('common.noData')"
/>
<KolPersonaCard
v-else-if="profileQuery.data.value"
:profile="profileQuery.data.value"
/>
</div>
</template>
<style scoped>
.kol-profile-view {
display: flex;
flex-direction: column;
gap: 24px;
}
.page-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
}
.page-subtitle {
margin: 0;
font-size: 13px;
color: #8c8c8c;
}
</style>
```
- [ ] **Step 2: Register the route**
In `apps/admin-web/src/router/index.ts`, find the `kol/dashboard` block (around line 160168). Immediately after it, insert:
```ts
{
path: "kol/profile",
name: "kol-profile",
component: () => import("@/views/KolProfileView.vue"),
meta: {
titleKey: "kol.profile.title",
navKey: "/kol/profile",
requiresKol: true,
},
},
```
- [ ] **Step 3: Append the nav item**
In `apps/admin-web/src/layouts/AppShell.vue`, inside the `if (authStore.isActiveKol)` block (around line 112121), change the `items` array to include profile:
```ts
if (authStore.isActiveKol) {
base.push({
key: "kolWorkspace",
title: t("nav.kolWorkspace"),
items: [
{ key: "/kol/manage", label: t("nav.kolManage") },
{ key: "/kol/dashboard", label: t("nav.kolDashboard") },
{ key: "/kol/profile", label: t("nav.kolProfile") },
],
});
}
```
- [ ] **Step 4: Typecheck**
```bash
cd apps/admin-web && npm run typecheck
```
Expected: exit 0.
- [ ] **Step 5: Full build**
```bash
cd apps/admin-web && npm run build
```
Expected: exit 0 (vue-tsc + vite build succeed).
- [ ] **Step 6: Commit**
```bash
git add apps/admin-web/src/views/KolProfileView.vue apps/admin-web/src/router/index.ts apps/admin-web/src/layouts/AppShell.vue
git commit -m "feat(kol-web): add /kol/profile page with nav entry"
```
---
## Task 9: End-to-end verification
**Files:** none (verification only).
- [ ] **Step 1: Build everything**
```bash
cd server && go build ./...
cd ../apps/admin-web && npm run build
```
Expected: both exit 0.
- [ ] **Step 2: Run all server tests**
```bash
cd server && go test ./... -count=1
```
Expected: `ok`.
- [ ] **Step 3: Manual smoke-test checklist**
Launch the stack the way the project normally runs locally (out of scope for this plan — use the project's existing dev command). With an active KOL account signed in:
- [ ] Nav shows a new "KOL 个人主页 / Profile" entry under the KOL workspace group.
- [ ] Navigating to `/kol/profile` renders the page with the current persona (avatar, display_name, bio).
- [ ] Click display name → input appears → change → blur → success toast → nav bar / workspace KOL card reflects the new name.
- [ ] Click bio → textarea → change → Ctrl/⌘+Enter → saves.
- [ ] Click avatar → file picker → pick a ≤ 2 MB PNG → avatar updates, toast shows.
- [ ] Try a > 2 MB file → rejected with avatar-too-large toast.
- [ ] Try clearing display name (empty) → rejected with display-name-required toast, field stays in edit state.
- [ ] Log out, log in as a non-KOL user → `/kol/profile` redirects to `/workspace` (guarded by `requiresKol`).
- [ ] Browser console shows no i18n missing-key warnings.
- [ ] **Step 4: Final commit (if any trailing fixes)**
If the smoke test surfaces fixable issues, commit them individually with descriptive messages. Otherwise no commit is needed at this step.
---
## Self-Review Checklist (already executed by planner)
1. **Spec coverage:**
- Non-goal "不涉及租户/SaaS 账号设置" — no code change touches users/tenants tables ✅
- Non-goal "不展示已发布精调模版" — no package-listing code in this plan ✅
- Section 3 layout (title/subtitle/persona card) — Task 8 + Task 7 ✅
- Section 4.1 PUT endpoint + validation codes — Task 1 + Task 2 + Task 4 ✅
- Section 4.2 avatar upload via image pipeline — Task 3 + Task 4 ✅
- Section 5.5 cache invalidation for `["kol","profile"]` / `["workspace","kol-cards"]` / `["kol","packages"]` — Task 7 mutation `onSuccess` ✅
- Section 6 i18n keys — Task 6 ✅
2. **Placeholders:** none; every step contains concrete code or commands.
3. **Type consistency:**
- Backend: `UpdateKolProfileServiceInput` defined in Task 2, referenced by Task 4 handler — match ✅
- Backend: `KolAvatarUploadResponse` defined in Task 3, used by handler in Task 4 ✅
- Frontend: `kolManageApi.updateProfile` / `uploadAvatar` signatures defined in Task 5 match the Vue component calls in Task 7 ✅
- i18n keys used by Task 7/8 (`kol.profile.persona.*`, `nav.kolProfile`) are all defined in Task 6 ✅
4. **One minor verification deferred to execution time:** the precise import path for `objectstorage` and the `bootstrap.App` field name for the object storage client (`a.ObjectStorage` vs some other name). The plan instructs the implementer to check `article_service.go` / `bootstrap/*.go` — a 30-second grep, not a design risk.