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

40 KiB
Raw Blame History

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

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:

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

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:

cd server && go test ./internal/tenant/app/ -run 'TestValidateKol' -v

Expected: all PASS.

  • Step 5: Commit
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):

type UpdateKolProfileServiceInput struct {
	DisplayName string
	Bio         *string
	AvatarURL   *string
}
  • Step 2: Implement UpdateProfile method

Append to the same file, after RequireActiveKol:

// 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:

cd server && go build ./internal/tenant/app/...

Expected: no output, exit 0.

  • Step 4: Run the existing app test suite

Run:

cd server && go test ./internal/tenant/app/ -count=1

Expected: ok.

  • Step 5: Commit
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):

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:

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

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:

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
cd server && go test ./internal/tenant/app/ -count=1

Expected: ok.

  • Step 5: Commit
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:

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:

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):

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:

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:

kolManage := protected.Group("/tenant/kol/manage")
kolManageHandler := NewKolManageHandler(app)
kolManage.GET("/profile", kolManageHandler.GetProfile)

Replace with:

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:

cd server && go build ./...

Expected: exit 0.

  • Step 6: Run full test suite

Run:

cd server && go test ./... -count=1

Expected: ok (integration tests are already gated by -tags=integration).

  • Step 7: Commit
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:

  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:

cd apps/admin-web && npm run typecheck

Expected: exit 0, no errors.

  • Step 3: Commit
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:

        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):

        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:

    kolProfile: "Profile",
    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
cd apps/admin-web && npm run typecheck

Expected: exit 0.

  • Step 4: Commit
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

<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
cd apps/admin-web && npm run typecheck

Expected: exit 0.

  • Step 3: Commit
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

<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:

        {
          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:

  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
cd apps/admin-web && npm run typecheck

Expected: exit 0.

  • Step 5: Full build
cd apps/admin-web && npm run build

Expected: exit 0 (vue-tsc + vite build succeed).

  • Step 6: Commit
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
cd server && go build ./...
cd ../apps/admin-web && npm run build

Expected: both exit 0.

  • Step 2: Run all server tests
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.