diff --git a/docs/superpowers/plans/2026-04-18-kol-profile-page.md b/docs/superpowers/plans/2026-04-18-kol-profile-page.md new file mode 100644 index 0000000..72fb61e --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-kol-profile-page.md @@ -0,0 +1,1360 @@ +# 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.