# 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 59–78): 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( "/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>("/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 68–87), 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 ``` - [ ] **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 ``` - [ ] **Step 2: Register the route** In `apps/admin-web/src/router/index.ts`, find the `kol/dashboard` block (around line 160–168). 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 112–121), 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.