2026-04-18 14:01:54 +08:00
# 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 < KolProfile , typeof body > (
"/api/tenant/kol/manage/profile" ,
body ,
) ;
} ,
async uploadAvatar ( file : File ) {
const normalizedFile = await convertImageFileToWebp ( file ) ;
const progressToken = markImageUploadRequestStarted ( normalizedFile . name ) ;
try {
const formData = new FormData ( ) ;
formData . append ( "file" , normalizedFile , normalizedFile . name ) ;
const response = await apiClient . raw . post < ApiEnvelope < {
url : string ;
object_key : string ;
content_type : string ;
size_bytes : number ;
} > > ( "/api/tenant/kol/manage/profile/avatar" , formData ) ;
markImageUploadRequestFinished ( progressToken ) ;
return {
. . . response . data . data ,
url : resolveApiURL ( response . data . data . url ) ,
} ;
} catch ( error ) {
markImageUploadRequestFailed ( progressToken ) ;
throw error ;
}
} ,
```
Verify `ApiEnvelope` is already imported in this file (it is — already used by `articlesApi.uploadImage` at line 428). If not, add it to the imports from `@geo/http-client` .
- [ ] **Step 2: Typecheck **
Run:
``` bash
cd apps/admin-web && npm run typecheck
```
Expected: exit 0, no errors.
- [ ] **Step 3: Commit **
``` bash
git add apps/admin-web/src/lib/api.ts
git commit -m "feat(kol-web): add updateProfile and uploadAvatar to kolManageApi"
```
---
## Task 6: Frontend — i18n keys
**Files: **
- Modify: `apps/admin-web/src/i18n/messages/zh-CN.ts`
- Modify: `apps/admin-web/src/i18n/messages/en-US.ts`
- [ ] **Step 1: Add `nav.kolProfile` + `kol.profile.*` to zh-CN **
In `apps/admin-web/src/i18n/messages/zh-CN.ts` :
1. Inside the `nav` object (~line 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
<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;
2026-04-18 15:01:40 +08:00
align-items: center;
2026-04-18 14:01:54 +08:00
gap: 32px;
background: #ffffff;
border-radius: 12px;
padding: 24px;
}
.persona-avatar-wrap {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
background: #f5f5f5;
}
.persona-avatar,
.persona-avatar-fallback {
width: 100%;
height: 100%;
object-fit: cover;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: #bfbfbf;
}
.persona-avatar-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
opacity: 0;
transition: opacity 0.15s ease;
}
.persona-avatar-wrap:hover .persona-avatar-mask,
.persona-avatar-wrap.uploading .persona-avatar-mask {
opacity: 1;
}
.persona-avatar-input {
display: none;
}
.persona-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
.persona-field-label {
display: block;
color: #8c8c8c;
font-size: 12px;
margin-bottom: 6px;
}
.persona-field-static {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: text;
padding: 2px 0;
max-width: 100%;
}
.persona-field-static-multiline {
align-items: flex-start;
}
.persona-field-value {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
word-break: break-word;
}
.persona-field-value-bio {
font-size: 14px;
font-weight: 400;
color: #595959;
white-space: pre-wrap;
}
.persona-field-edit-icon {
color: #bfbfbf;
font-size: 14px;
opacity: 0;
transition: opacity 0.15s ease;
}
.persona-field-static:hover .persona-field-edit-icon {
opacity: 1;
}
</style>
` ``
- [ ] **Step 2: Typecheck**
` ``bash
cd apps/admin-web && npm run typecheck
` ``
Expected: exit 0.
- [ ] **Step 3: Commit**
` ``bash
git add apps/admin-web/src/components/kol/KolPersonaCard.vue
git commit -m "feat(kol-web): add KolPersonaCard with inline editing"
` ``
---
## Task 8: Frontend — ` KolProfileView.vue` + route + nav
**Files:**
- Create: ` apps/admin-web/src/views/KolProfileView.vue`
- Modify: ` apps/admin-web/src/router/index.ts`
- Modify: ` apps/admin-web/src/layouts/AppShell.vue`
- [ ] **Step 1: Create the view**
File: ` apps/admin-web/src/views/KolProfileView.vue`
` ``vue
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import { kolManageApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
import KolPersonaCard from "@/components/kol/KolPersonaCard.vue";
const { t } = useI18n();
const profileQuery = useQuery({
queryKey: ["kol", "profile"],
queryFn: () => kolManageApi.profile(),
});
</script>
<template>
<div class="kol-profile-view">
<header class="page-header">
<h2 class="page-title">{{ t("kol.profile.title") }}</h2>
<p class="page-subtitle">{{ t("kol.profile.subtitle") }}</p>
</header>
<a-skeleton v-if="profileQuery.isPending.value" active avatar :paragraph="{ rows: 3 }" />
<a-alert
v-else-if="profileQuery.isError.value"
type="error"
show-icon
:message="formatError(profileQuery.error.value) || t('common.noData')"
/>
<KolPersonaCard
v-else-if="profileQuery.data.value"
:profile="profileQuery.data.value"
/>
</div>
</template>
<style scoped>
.kol-profile-view {
display: flex;
flex-direction: column;
gap: 24px;
}
.page-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
}
.page-subtitle {
margin: 0;
font-size: 13px;
color: #8c8c8c;
}
</style>
` ``
- [ ] **Step 2: Register the route**
In ` apps/admin-web/src/router/index.ts`, find the ` kol/dashboard` block (around line 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.