79c65c1da7
- 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>
1361 lines
40 KiB
Markdown
1361 lines
40 KiB
Markdown
# 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;
|
||
align-items: center;
|
||
gap: 32px;
|
||
background: #ffffff;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
}
|
||
.persona-avatar-wrap {
|
||
position: relative;
|
||
width: 120px;
|
||
height: 120px;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
background: #f5f5f5;
|
||
}
|
||
.persona-avatar,
|
||
.persona-avatar-fallback {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 40px;
|
||
color: #bfbfbf;
|
||
}
|
||
.persona-avatar-mask {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 13px;
|
||
opacity: 0;
|
||
transition: opacity 0.15s ease;
|
||
}
|
||
.persona-avatar-wrap:hover .persona-avatar-mask,
|
||
.persona-avatar-wrap.uploading .persona-avatar-mask {
|
||
opacity: 1;
|
||
}
|
||
.persona-avatar-input {
|
||
display: none;
|
||
}
|
||
.persona-fields {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
min-width: 0;
|
||
}
|
||
.persona-field-label {
|
||
display: block;
|
||
color: #8c8c8c;
|
||
font-size: 12px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.persona-field-static {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: text;
|
||
padding: 2px 0;
|
||
max-width: 100%;
|
||
}
|
||
.persona-field-static-multiline {
|
||
align-items: flex-start;
|
||
}
|
||
.persona-field-value {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #1a1a1a;
|
||
word-break: break-word;
|
||
}
|
||
.persona-field-value-bio {
|
||
font-size: 14px;
|
||
font-weight: 400;
|
||
color: #595959;
|
||
white-space: pre-wrap;
|
||
}
|
||
.persona-field-edit-icon {
|
||
color: #bfbfbf;
|
||
font-size: 14px;
|
||
opacity: 0;
|
||
transition: opacity 0.15s ease;
|
||
}
|
||
.persona-field-static:hover .persona-field-edit-icon {
|
||
opacity: 1;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
```bash
|
||
cd apps/admin-web && npm run typecheck
|
||
```
|
||
Expected: exit 0.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/admin-web/src/components/kol/KolPersonaCard.vue
|
||
git commit -m "feat(kol-web): add KolPersonaCard with inline editing"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Frontend — `KolProfileView.vue` + route + nav
|
||
|
||
**Files:**
|
||
- Create: `apps/admin-web/src/views/KolProfileView.vue`
|
||
- Modify: `apps/admin-web/src/router/index.ts`
|
||
- Modify: `apps/admin-web/src/layouts/AppShell.vue`
|
||
|
||
- [ ] **Step 1: Create the view**
|
||
|
||
File: `apps/admin-web/src/views/KolProfileView.vue`
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import { useQuery } from "@tanstack/vue-query";
|
||
import { useI18n } from "vue-i18n";
|
||
|
||
import { kolManageApi } from "@/lib/api";
|
||
import { formatError } from "@/lib/errors";
|
||
import KolPersonaCard from "@/components/kol/KolPersonaCard.vue";
|
||
|
||
const { t } = useI18n();
|
||
|
||
const profileQuery = useQuery({
|
||
queryKey: ["kol", "profile"],
|
||
queryFn: () => kolManageApi.profile(),
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="kol-profile-view">
|
||
<header class="page-header">
|
||
<h2 class="page-title">{{ t("kol.profile.title") }}</h2>
|
||
<p class="page-subtitle">{{ t("kol.profile.subtitle") }}</p>
|
||
</header>
|
||
|
||
<a-skeleton v-if="profileQuery.isPending.value" active avatar :paragraph="{ rows: 3 }" />
|
||
<a-alert
|
||
v-else-if="profileQuery.isError.value"
|
||
type="error"
|
||
show-icon
|
||
:message="formatError(profileQuery.error.value) || t('common.noData')"
|
||
/>
|
||
<KolPersonaCard
|
||
v-else-if="profileQuery.data.value"
|
||
:profile="profileQuery.data.value"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.kol-profile-view {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.page-title {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: #1a1a1a;
|
||
}
|
||
.page-subtitle {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
color: #8c8c8c;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: Register the route**
|
||
|
||
In `apps/admin-web/src/router/index.ts`, find the `kol/dashboard` block (around line 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.
|