From b6bd4f02fbc98cbe719c58919ccc160363d786a7 Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 17 Apr 2026 15:19:11 +0800 Subject: [PATCH] feat(kol): workspace KOL cards + article source type --- .../src/components/ArticleSourceMeta.vue | 14 ++++- apps/admin-web/src/lib/api.ts | 4 ++ apps/admin-web/src/lib/display.ts | 3 + packages/shared-types/src/index.ts | 11 ++++ server/internal/tenant/app/article_service.go | 5 +- server/internal/tenant/app/cache_support.go | 5 ++ .../internal/tenant/app/workspace_service.go | 24 ++++++++ server/internal/tenant/domain/workspace.go | 10 ++++ .../repository/generated/article.sql.go | 4 +- .../tenant/repository/generated/querier.go | 1 + .../repository/generated/workspace.sql.go | 55 +++++++++++++++++++ .../tenant/repository/queries/article.sql | 2 +- .../tenant/repository/queries/workspace.sql | 16 ++++++ .../tenant/repository/workspace_repo.go | 33 +++++++++++ server/internal/tenant/transport/router.go | 1 + .../tenant/transport/workspace_handler.go | 9 +++ 16 files changed, 192 insertions(+), 5 deletions(-) diff --git a/apps/admin-web/src/components/ArticleSourceMeta.vue b/apps/admin-web/src/components/ArticleSourceMeta.vue index c23e235..d99b3df 100644 --- a/apps/admin-web/src/components/ArticleSourceMeta.vue +++ b/apps/admin-web/src/components/ArticleSourceMeta.vue @@ -4,6 +4,7 @@ import { CalendarOutlined, ClockCircleOutlined, EditOutlined, + TeamOutlined, ThunderboltOutlined, } from "@ant-design/icons-vue"; import { computed, type Component } from "vue"; @@ -23,7 +24,7 @@ const sourceLabel = computed(() => const createdAtLabel = computed(() => formatDateTime(props.createdAt)); const sourceVariant = computed< - "template" | "custom" | "instant" | "schedule" | "free-create" | "default" + "template" | "custom" | "instant" | "schedule" | "free-create" | "kol" | "default" >(() => { if (props.sourceType === "template") { return "template"; @@ -31,6 +32,9 @@ const sourceVariant = computed< if (props.sourceType === "free_create") { return "free-create"; } + if (props.sourceType === "kol") { + return "kol"; + } if (props.sourceType === "custom_generation") { if (props.generationMode === "schedule") { return "schedule"; @@ -55,6 +59,8 @@ const badgeIcon = computed(() => { return ThunderboltOutlined; case "free-create": return EditOutlined; + case "kol": + return TeamOutlined; default: return AppstoreOutlined; } @@ -127,6 +133,12 @@ const badgeIcon = computed(() => { color: #18794e; } +.article-source-meta__badge--kol { + border: 1px solid #d6e4ff; + background: linear-gradient(180deg, #f5f8ff 0%, #eaf1ff 100%); + color: #1d4ed8; +} + .article-source-meta__badge--default { border: 1px solid #dde3ea; background: linear-gradient(180deg, #fbfcfd 0%, #f4f6f8 100%); diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 2eb50f2..44791a4 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -47,6 +47,7 @@ import type { KolDashboardTrendPoint, Keyword, KeywordRequest, + KolWorkspaceCard, LoginRequest, LoginResponse, MediaPlatform, @@ -199,6 +200,9 @@ export const workspaceApi = { templateCards() { return apiClient.get("/api/tenant/workspace/template-cards"); }, + kolCards() { + return apiClient.get("/api/tenant/workspace/kol-cards"); + }, }; export const templatesApi = { diff --git a/apps/admin-web/src/lib/display.ts b/apps/admin-web/src/lib/display.ts index 70643aa..1c040b0 100644 --- a/apps/admin-web/src/lib/display.ts +++ b/apps/admin-web/src/lib/display.ts @@ -77,6 +77,9 @@ export function getSourceTypeLabel(sourceType?: string | null, generationMode?: if (sourceType === "free_create") { return i18n.global.t("status.sourceType.free_create"); } + if (sourceType === "kol") { + return i18n.global.t("status.sourceType.kol"); + } if (sourceType === "custom_generation") { if (generationMode === "schedule") { return i18n.global.t("status.sourceType.schedule_task"); diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 2b40a54..98923fe 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -85,6 +85,16 @@ export interface TemplateCard { prompt_visibility: string; } +export interface KolWorkspaceCard { + subscription_prompt_id: number; + granted_at: string; + prompt_name: string; + platform_hint: string | null; + package_name: string; + package_cover: string | null; + kol_display_name: string; +} + export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; @@ -228,6 +238,7 @@ export interface ArticleListItem { id: number; source_type: string; template_id: number | null; + kol_prompt_id: number | null; template_name: string | null; prompt_rule_id: number | null; prompt_rule_name: string | null; diff --git a/server/internal/tenant/app/article_service.go b/server/internal/tenant/app/article_service.go index b474d02..bfe124c 100644 --- a/server/internal/tenant/app/article_service.go +++ b/server/internal/tenant/app/article_service.go @@ -81,6 +81,7 @@ type ArticleListItem struct { ID int64 `json:"id"` SourceType string `json:"source_type"` TemplateID *int64 `json:"template_id"` + KolPromptID *int64 `json:"kol_prompt_id"` TemplateName *string `json:"template_name"` PromptRuleID *int64 `json:"prompt_rule_id"` PromptRuleName *string `json:"prompt_rule_name"` @@ -221,7 +222,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param return nil, response.ErrInternal(50010, "query_failed", "failed to count articles") } - listQuery := `SELECT a.id, a.source_type, a.template_id, a.prompt_rule_id, a.generate_status, a.publish_status, a.created_at, + listQuery := `SELECT a.id, a.source_type, a.template_id, a.kol_prompt_id, a.prompt_rule_id, a.generate_status, a.publish_status, a.created_at, COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, COALESCE(av.word_count, 0), av.source_label, t.template_name, pr.name, a.wizard_state_json, gt.input_params_json FROM articles a LEFT JOIN article_versions av ON av.id = a.current_version_id @@ -299,7 +300,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param var dbSourceType string var wizardStateJSON []byte var inputParamsJSON []byte - if err := rows.Scan(&item.ID, &dbSourceType, &item.TemplateID, &item.PromptRuleID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt, + if err := rows.Scan(&item.ID, &dbSourceType, &item.TemplateID, &item.KolPromptID, &item.PromptRuleID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt, &item.Title, &item.GenerationErrorMessage, &item.WordCount, &item.SourceLabel, &item.TemplateName, &item.PromptRuleName, &wizardStateJSON, &inputParamsJSON); err != nil { return nil, response.ErrInternal(50010, "scan_failed", err.Error()) } diff --git a/server/internal/tenant/app/cache_support.go b/server/internal/tenant/app/cache_support.go index 9e8dd0b..05e55bc 100644 --- a/server/internal/tenant/app/cache_support.go +++ b/server/internal/tenant/app/cache_support.go @@ -64,11 +64,16 @@ func workspaceTemplateCardsCacheKey(tenantID int64) string { return fmt.Sprintf("workspace:template_cards:%d", tenantID) } +func workspaceKolCardsCacheKey(tenantID int64) string { + return fmt.Sprintf("workspace:kol_cards:%d", tenantID) +} + func invalidateWorkspaceCaches(ctx context.Context, c sharedcache.Cache, tenantID int64) { deleteCacheKey(ctx, c, workspaceOverviewCacheKey(tenantID)) deleteCacheKey(ctx, c, workspaceRecentArticlesCacheKey(tenantID)) deleteCacheKey(ctx, c, workspaceQuotaSummaryCacheKey(tenantID)) deleteCacheKey(ctx, c, workspaceTemplateCardsCacheKey(tenantID)) + deleteCacheKey(ctx, c, workspaceKolCardsCacheKey(tenantID)) } func brandListCacheKey(tenantID int64) string { diff --git a/server/internal/tenant/app/workspace_service.go b/server/internal/tenant/app/workspace_service.go index e203492..fbffe4c 100644 --- a/server/internal/tenant/app/workspace_service.go +++ b/server/internal/tenant/app/workspace_service.go @@ -148,3 +148,27 @@ func (s *WorkspaceService) TemplateCards(ctx context.Context) ([]TemplateCard, e return cards, nil }) } + +func (s *WorkspaceService) KolCards(ctx context.Context) ([]domain.KolWorkspaceCard, error) { + actor := auth.MustActor(ctx) + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, workspaceKolCardsCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]domain.KolWorkspaceCard, error) { + rows, err := s.repo.ListKolCards(loadCtx, actor.TenantID) + if err != nil { + return nil, response.ErrInternal(50010, "query_failed", "failed to fetch KOL cards") + } + + cards := make([]domain.KolWorkspaceCard, 0, len(rows)) + for _, row := range rows { + cards = append(cards, domain.KolWorkspaceCard{ + SubscriptionPromptID: row.SubscriptionPromptID, + GrantedAt: row.GrantedAt, + PromptName: row.PromptName, + PlatformHint: row.PlatformHint, + PackageName: row.PackageName, + PackageCover: row.PackageCover, + KolDisplayName: row.KolDisplayName, + }) + } + return cards, nil + }) +} diff --git a/server/internal/tenant/domain/workspace.go b/server/internal/tenant/domain/workspace.go index 8ff0eb4..a994398 100644 --- a/server/internal/tenant/domain/workspace.go +++ b/server/internal/tenant/domain/workspace.go @@ -30,3 +30,13 @@ type QuotaSummary struct { Balance int `json:"balance"` ResetAt *time.Time `json:"reset_at"` } + +type KolWorkspaceCard struct { + SubscriptionPromptID int64 `json:"subscription_prompt_id"` + GrantedAt time.Time `json:"granted_at"` + PromptName string `json:"prompt_name"` + PlatformHint *string `json:"platform_hint"` + PackageName string `json:"package_name"` + PackageCover *string `json:"package_cover"` + KolDisplayName string `json:"kol_display_name"` +} diff --git a/server/internal/tenant/repository/generated/article.sql.go b/server/internal/tenant/repository/generated/article.sql.go index 69eaaa5..269caac 100644 --- a/server/internal/tenant/repository/generated/article.sql.go +++ b/server/internal/tenant/repository/generated/article.sql.go @@ -208,7 +208,7 @@ func (q *Queries) GetArticleVersions(ctx context.Context, articleID int64) ([]Ge } const listArticles = `-- name: ListArticles :many -SELECT a.id, a.tenant_id, a.source_type, a.template_id, +SELECT a.id, a.tenant_id, a.source_type, a.template_id, a.kol_prompt_id, a.generate_status, a.publish_status, a.created_at, a.updated_at, av.title, av.word_count, av.source_label, t.template_name @@ -242,6 +242,7 @@ type ListArticlesRow struct { TenantID int64 `json:"tenant_id"` SourceType string `json:"source_type"` TemplateID pgtype.Int8 `json:"template_id"` + KolPromptID pgtype.Int8 `json:"kol_prompt_id"` GenerateStatus string `json:"generate_status"` PublishStatus string `json:"publish_status"` CreatedAt pgtype.Timestamptz `json:"created_at"` @@ -275,6 +276,7 @@ func (q *Queries) ListArticles(ctx context.Context, arg ListArticlesParams) ([]L &i.TenantID, &i.SourceType, &i.TemplateID, + &i.KolPromptID, &i.GenerateStatus, &i.PublishStatus, &i.CreatedAt, diff --git a/server/internal/tenant/repository/generated/querier.go b/server/internal/tenant/repository/generated/querier.go index 77af5e1..44bbda8 100644 --- a/server/internal/tenant/repository/generated/querier.go +++ b/server/internal/tenant/repository/generated/querier.go @@ -100,6 +100,7 @@ type Querier interface { ListImageReferenceArticles(ctx context.Context, arg ListImageReferenceArticlesParams) ([]ListImageReferenceArticlesRow, error) ListImageReferences(ctx context.Context, arg ListImageReferencesParams) ([]ListImageReferencesRow, error) ListKeywords(ctx context.Context, arg ListKeywordsParams) ([]ListKeywordsRow, error) + ListKolCardsForTenant(ctx context.Context, tenantID int64) ([]ListKolCardsForTenantRow, error) ListKolPackageIDsByProfile(ctx context.Context, arg ListKolPackageIDsByProfileParams) ([]int64, error) ListKolPackagesByProfile(ctx context.Context, arg ListKolPackagesByProfileParams) ([]ListKolPackagesByProfileRow, error) ListKolPromptsByPackage(ctx context.Context, arg ListKolPromptsByPackageParams) ([]ListKolPromptsByPackageRow, error) diff --git a/server/internal/tenant/repository/generated/workspace.sql.go b/server/internal/tenant/repository/generated/workspace.sql.go index dbc9b4d..7619d24 100644 --- a/server/internal/tenant/repository/generated/workspace.sql.go +++ b/server/internal/tenant/repository/generated/workspace.sql.go @@ -160,3 +160,58 @@ func (q *Queries) GetRecentArticles(ctx context.Context, tenantID int64) ([]GetR } return items, nil } + +const listKolCardsForTenant = `-- name: ListKolCardsForTenant :many +SELECT sp.id AS subscription_prompt_id, sp.granted_at, + p.name AS prompt_name, p.platform_hint, + k.name AS package_name, k.cover_url AS package_cover, + pf.display_name AS kol_display_name +FROM kol_subscription_prompts sp +JOIN kol_prompts p ON p.id = sp.prompt_id AND p.deleted_at IS NULL +JOIN kol_packages k ON k.id = sp.package_id AND k.deleted_at IS NULL +JOIN kol_profiles pf ON pf.id = k.kol_profile_id +WHERE sp.tenant_id = $1::bigint + AND sp.status = 'active' + AND p.status = 'active' + AND p.published_revision_no IS NOT NULL +ORDER BY sp.granted_at DESC +LIMIT 8 +` + +type ListKolCardsForTenantRow struct { + SubscriptionPromptID int64 `json:"subscription_prompt_id"` + GrantedAt pgtype.Timestamptz `json:"granted_at"` + PromptName string `json:"prompt_name"` + PlatformHint pgtype.Text `json:"platform_hint"` + PackageName string `json:"package_name"` + PackageCover pgtype.Text `json:"package_cover"` + KolDisplayName string `json:"kol_display_name"` +} + +func (q *Queries) ListKolCardsForTenant(ctx context.Context, tenantID int64) ([]ListKolCardsForTenantRow, error) { + rows, err := q.db.Query(ctx, listKolCardsForTenant, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListKolCardsForTenantRow + for rows.Next() { + var i ListKolCardsForTenantRow + if err := rows.Scan( + &i.SubscriptionPromptID, + &i.GrantedAt, + &i.PromptName, + &i.PlatformHint, + &i.PackageName, + &i.PackageCover, + &i.KolDisplayName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/server/internal/tenant/repository/queries/article.sql b/server/internal/tenant/repository/queries/article.sql index 4760b2f..1dcc385 100644 --- a/server/internal/tenant/repository/queries/article.sql +++ b/server/internal/tenant/repository/queries/article.sql @@ -1,5 +1,5 @@ -- name: ListArticles :many -SELECT a.id, a.tenant_id, a.source_type, a.template_id, +SELECT a.id, a.tenant_id, a.source_type, a.template_id, a.kol_prompt_id, a.generate_status, a.publish_status, a.created_at, a.updated_at, av.title, av.word_count, av.source_label, t.template_name diff --git a/server/internal/tenant/repository/queries/workspace.sql b/server/internal/tenant/repository/queries/workspace.sql index 56dd6ce..28c0f8d 100644 --- a/server/internal/tenant/repository/queries/workspace.sql +++ b/server/internal/tenant/repository/queries/workspace.sql @@ -46,3 +46,19 @@ FROM tenant_plan_subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.tenant_id = sqlc.arg(tenant_id) AND s.status = 'active' AND s.deleted_at IS NULL LIMIT 1; + +-- name: ListKolCardsForTenant :many +SELECT sp.id AS subscription_prompt_id, sp.granted_at, + p.name AS prompt_name, p.platform_hint, + k.name AS package_name, k.cover_url AS package_cover, + pf.display_name AS kol_display_name +FROM kol_subscription_prompts sp +JOIN kol_prompts p ON p.id = sp.prompt_id AND p.deleted_at IS NULL +JOIN kol_packages k ON k.id = sp.package_id AND k.deleted_at IS NULL +JOIN kol_profiles pf ON pf.id = k.kol_profile_id +WHERE sp.tenant_id = sqlc.arg(tenant_id)::bigint + AND sp.status = 'active' + AND p.status = 'active' + AND p.published_revision_no IS NOT NULL +ORDER BY sp.granted_at DESC +LIMIT 8; diff --git a/server/internal/tenant/repository/workspace_repo.go b/server/internal/tenant/repository/workspace_repo.go index 993c52f..a8429a2 100644 --- a/server/internal/tenant/repository/workspace_repo.go +++ b/server/internal/tenant/repository/workspace_repo.go @@ -28,6 +28,16 @@ type WorkspacePlan struct { EndAt time.Time } +type WorkspaceKolCard struct { + SubscriptionPromptID int64 + GrantedAt time.Time + PromptName string + PlatformHint *string + PackageName string + PackageCover *string + KolDisplayName string +} + type WorkspaceRepository interface { CountArticlesByTenant(ctx context.Context, tenantID int64) (int64, error) CountPublishedArticlesByTenant(ctx context.Context, tenantID int64) (int64, error) @@ -35,6 +45,7 @@ type WorkspaceRepository interface { GetRecentArticles(ctx context.Context, tenantID int64) ([]WorkspaceRecentArticle, error) GetQuotaSummary(ctx context.Context, tenantID int64) (int, error) GetActivePlanForTenant(ctx context.Context, tenantID int64) (*WorkspacePlan, error) + ListKolCards(ctx context.Context, tenantID int64) ([]WorkspaceKolCard, error) } type workspaceRepository struct { @@ -104,3 +115,25 @@ func (r *workspaceRepository) GetActivePlanForTenant(ctx context.Context, tenant EndAt: timeFromTimestamp(row.EndAt), }, nil } + +func (r *workspaceRepository) ListKolCards(ctx context.Context, tenantID int64) ([]WorkspaceKolCard, error) { + rows, err := r.q.ListKolCardsForTenant(ctx, tenantID) + if err != nil { + return nil, err + } + + cards := make([]WorkspaceKolCard, 0, len(rows)) + for _, row := range rows { + cards = append(cards, WorkspaceKolCard{ + SubscriptionPromptID: row.SubscriptionPromptID, + GrantedAt: timeFromTimestamp(row.GrantedAt), + PromptName: row.PromptName, + PlatformHint: nullableText(row.PlatformHint), + PackageName: row.PackageName, + PackageCover: nullableText(row.PackageCover), + KolDisplayName: row.KolDisplayName, + }) + } + + return cards, nil +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 85b4f38..d35301e 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -41,6 +41,7 @@ func RegisterRoutes(app *bootstrap.App) { workspace.GET("/recent-articles", wsHandler.RecentArticles) workspace.GET("/quota-summary", wsHandler.QuotaSummary) workspace.GET("/template-cards", wsHandler.TemplateCards) + workspace.GET("/kol-cards", wsHandler.KolCards) templates := protected.Group("/tenant/templates") tplHandler := NewTemplateHandler(app) diff --git a/server/internal/tenant/transport/workspace_handler.go b/server/internal/tenant/transport/workspace_handler.go index 1c8ffff..8f195ad 100644 --- a/server/internal/tenant/transport/workspace_handler.go +++ b/server/internal/tenant/transport/workspace_handler.go @@ -60,3 +60,12 @@ func (h *WorkspaceHandler) TemplateCards(c *gin.Context) { } response.Success(c, data) } + +func (h *WorkspaceHandler) KolCards(c *gin.Context) { + data, err := h.svc.KolCards(c.Request.Context()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +}