From fa7b78797dfae93e00a44d25b2e586dbd8fdef14 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 6 Oct 2026 14:40:32 +0800 Subject: [PATCH] feat: unify owned and competitor work analysis --- AGENTS.md | 2 + internal/controlplane/api/creator.go | 6 +- .../api/creator_pure_unit_test.go | 13 +- internal/creator/content.go | 83 +++-- internal/creator/growth.go | 25 +- internal/creator/growth_test.go | 8 +- internal/creator/models.go | 4 + .../creator/work_analysis_integration_test.go | 99 ++++++ internal/creator/work_analysis_test.go | 83 +++++ web/src/components/creator/WorksGallery.tsx | 284 ++++++++++++------ web/src/pages/creator/competitors/index.tsx | 258 +++++++++++++--- web/src/services/api.ts | 2 +- web/src/utils/metadata.tsx | 4 +- web/tests/account-detail-resources.test.cjs | 74 ++++- web/tests/competitor-analysis-source.test.cjs | 186 +++++++----- 15 files changed, 851 insertions(+), 280 deletions(-) create mode 100644 internal/creator/work_analysis_integration_test.go create mode 100644 internal/creator/work_analysis_test.go diff --git a/AGENTS.md b/AGENTS.md index 38dad1f..aef7f24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,8 @@ 自有账号作品:作品总数使用抖音最新非空 `aweme_count`,已保存 `work_count` 仅表示采集进度,不得互相替代;未知总数与零作品必须区分。自有账号作品库保存平台分页返回的全部历史作品,不受采集回看天数限制;回看天数仍限制监控账号作品和评论采集。详情分页浏览已采集作品,并明确展示采集进度与未完整状态,不能将采集成功等同于已采齐。 +作品分析:左侧原“竞品分析”统一为“作品分析”,页面通过“竞品账号”“我的账号”两个 TAB 严格区分作品来源;各 TAB 独立保留筛选与分页。支持所属账号、发布时间、时间核验状态和互动阈值筛选,以及发布时间、点赞、评论、分享、收藏、24h 点赞增量升降序;自有作品额外支持观看量。默认最新发布在前,条件变化回到第一页,筛选与排序作用于全部已采集作品后再分页;缺失指标显示“—”,排序放末尾、阈值筛选不将其当作 0。未采集作品不参与分析。 + 前端框架:Umi Max 4.7 + React 19; 组件库:antd 6.6.5 + @ant-design/pro-components 3.x(beta 线)+ @ant-design/icons;仅使用 antd/pro 默认组件原样实现,禁止自定义封装与样式魔改;组件不满足业务时改交互逻辑适配组件; diff --git a/internal/controlplane/api/creator.go b/internal/controlplane/api/creator.go index daa11d2..91ce3e0 100644 --- a/internal/controlplane/api/creator.go +++ b/internal/controlplane/api/creator.go @@ -585,17 +585,17 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto } func workFilter(c fiber.Ctx) (creator.WorkFilter, error) { - filter := creator.WorkFilter{Platform: c.Query("platform"), SourceID: c.Query("source_id"), SourceType: c.Query("source_type"), PublishedAtStatus: c.Query("published_at_status")} + filter := creator.WorkFilter{Platform: c.Query("platform"), SourceID: c.Query("source_id"), SourceType: c.Query("source_type"), PublishedAtStatus: c.Query("published_at_status"), SortBy: c.Query("sort_by"), SortOrder: c.Query("sort_order")} for _, field := range []struct { name string target **int64 - }{{"min_likes", &filter.MinLikes}, {"min_comments", &filter.MinComments}, {"min_shares", &filter.MinShares}} { + }{{"min_likes", &filter.MinLikes}, {"min_comments", &filter.MinComments}, {"min_shares", &filter.MinShares}, {"min_collect_count", &filter.MinCollectCount}, {"min_play_count", &filter.MinPlayCount}} { value := c.Query(field.name) if value == "" { continue } parsed, err := strconv.ParseInt(value, 10, 64) - if err != nil { + if err != nil || parsed < 0 { return creator.WorkFilter{}, creator.ErrInvalid } *field.target = &parsed diff --git a/internal/controlplane/api/creator_pure_unit_test.go b/internal/controlplane/api/creator_pure_unit_test.go index 26180be..77cd02c 100644 --- a/internal/controlplane/api/creator_pure_unit_test.go +++ b/internal/controlplane/api/creator_pure_unit_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -97,12 +98,20 @@ func TestWorkFilterParsesNumericAndTimeFilters(t *testing.T) { } return c.JSON(filter) }) - request := httptest.NewRequest(http.MethodGet, "/?platform=douyin&source_id=source-1&source_type=owned&published_at_status=published&min_likes=1&min_comments=2&min_shares=3&published_after=2026-09-17T00:00:00Z&published_before=2026-09-18T00:00:00Z", nil) + request := httptest.NewRequest(http.MethodGet, "/?platform=douyin&source_id=source-1&source_type=owned&published_at_status=published&min_likes=1&min_comments=2&min_shares=3&min_collect_count=4&min_play_count=0&sort_by=likes&sort_order=asc&published_after=2026-09-17T00:00:00Z&published_before=2026-09-18T00:00:00Z", nil) response, err := app.Test(request) if err != nil || response.StatusCode != http.StatusOK { t.Fatalf("valid work filter status = %d, err = %v", response.StatusCode, err) } - for _, query := range []string{"min_likes=bad", "min_comments=bad", "min_shares=bad", "published_after=bad", "published_before=bad"} { + var got creator.WorkFilter + if err := json.NewDecoder(response.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if got.SortBy != "likes" || got.SortOrder != "asc" || got.MinCollectCount == nil || *got.MinCollectCount != 4 || got.MinPlayCount == nil || *got.MinPlayCount != 0 { + t.Fatalf("analysis query not propagated: %+v", got) + } + for _, query := range []string{"min_likes=bad", "min_comments=bad", "min_shares=bad", "min_collect_count=bad", "min_play_count=bad", "min_likes=-1", "min_collect_count=-1", "min_play_count=-1", "published_after=bad", "published_before=bad"} { response, err := app.Test(httptest.NewRequest(http.MethodGet, "/?"+query, nil)) if err != nil || response.StatusCode != http.StatusBadRequest { t.Errorf("invalid work filter %q status = %d, err = %v", query, response.StatusCode, err) diff --git a/internal/creator/content.go b/internal/creator/content.go index 9bcf68a..dc31ad2 100644 --- a/internal/creator/content.go +++ b/internal/creator/content.go @@ -510,35 +510,56 @@ func (s *Store) GetWorkByKey(ctx context.Context, platform, workKey string) (Wor return result, nil } -func (s *Store) ListWorks(ctx context.Context, filter WorkFilter) ([]Work, error) { - query, args := workSelect, make([]any, 0, 8) - where := make([]string, 0, 7) +func buildWorkQuery(filter WorkFilter) (string, []any, error) { + column := filter.SortBy + if column == "" { + column = "published_at" + } + switch column { + case "published_at", "likes", "comments_count", "shares", "collect_count", "play_count", "likes_growth": + default: + return "", nil, ErrInvalid + } + order := filter.SortOrder + if order == "" { + order = "desc" + } + if order != "asc" && order != "desc" { + return "", nil, ErrInvalid + } + if column == "likes_growth" { + // 增量在读取采样后计算;先保持发布时间与 ID 的稳定顺序。 + column, order = "published_at", "desc" + } + if filter.PublishedAfter != nil && filter.PublishedBefore != nil && filter.PublishedAfter.After(*filter.PublishedBefore) { + return "", nil, ErrInvalid + } + query, args := workSelect, make([]any, 0, 11) + where := make([]string, 0, 11) add := func(clause string, value any) { args = append(args, value) where = append(where, fmt.Sprintf(clause, len(args))) } if filter.Platform != "" { if !ValidatePlatform(filter.Platform) { - return nil, ErrInvalid + return "", nil, ErrInvalid } add("platform = $%d", filter.Platform) } if filter.PublishedAtStatus != "" { if filter.PublishedAtStatus != "verified" && filter.PublishedAtStatus != "future" && filter.PublishedAtStatus != "pending_verification" && filter.PublishedAtStatus != "invalid" { - return nil, ErrInvalid + return "", nil, ErrInvalid } add("published_at_status = $%d", filter.PublishedAtStatus) } if filter.SourceType != "" { if filter.SourceType != SourceOwned && filter.SourceType != SourceCompetitor { - return nil, ErrInvalid + return "", nil, ErrInvalid } - args = append(args, filter.SourceType) - where = append(where, fmt.Sprintf(`creator_work.source_type = $%d`, len(args))) + add("creator_work.source_type = $%d", filter.SourceType) } if filter.SourceID != "" { - args = append(args, filter.SourceID) - where = append(where, fmt.Sprintf(`creator_work.source_id = $%d`, len(args))) + add("creator_work.source_id = $%d", filter.SourceID) } if filter.PublishedAfter != nil { add("published_at >= $%d", filter.PublishedAfter.UTC()) @@ -546,31 +567,30 @@ func (s *Store) ListWorks(ctx context.Context, filter WorkFilter) ([]Work, error if filter.PublishedBefore != nil { add("published_at <= $%d", filter.PublishedBefore.UTC()) } - if filter.MinLikes != nil { - if *filter.MinLikes < 0 { - return nil, ErrInvalid + for _, metric := range []struct { + column string + minimum *int64 + }{{"likes", filter.MinLikes}, {"comments_count", filter.MinComments}, {"shares", filter.MinShares}, {"collect_count", filter.MinCollectCount}, {"play_count", filter.MinPlayCount}} { + if metric.minimum == nil { + continue } - where = append(where, fmt.Sprintf("likes IS NOT NULL AND likes >= $%d", len(args)+1)) - args = append(args, *filter.MinLikes) - } - if filter.MinComments != nil { - if *filter.MinComments < 0 { - return nil, ErrInvalid + if *metric.minimum < 0 { + return "", nil, ErrInvalid } - where = append(where, fmt.Sprintf("comments_count IS NOT NULL AND comments_count >= $%d", len(args)+1)) - args = append(args, *filter.MinComments) - } - if filter.MinShares != nil { - if *filter.MinShares < 0 { - return nil, ErrInvalid - } - where = append(where, fmt.Sprintf("shares IS NOT NULL AND shares >= $%d", len(args)+1)) - args = append(args, *filter.MinShares) + add(metric.column+" IS NOT NULL AND "+metric.column+" >= $%d", *metric.minimum) } if len(where) > 0 { query += ` WHERE ` + strings.Join(where, ` AND `) } - query += ` ORDER BY published_at DESC NULLS LAST, id` + query += " ORDER BY " + column + " " + strings.ToUpper(order) + " NULLS LAST, id" + return query, args, nil +} + +func (s *Store) ListWorks(ctx context.Context, filter WorkFilter) ([]Work, error) { + query, args, err := buildWorkQuery(filter) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, databaseError(err) @@ -780,7 +800,7 @@ func (s *Store) ListWorksPage(ctx context.Context, filter WorkFilter, page, page if err != nil { return Page[Work]{}, err } - if filter.Growth.Hours != 0 || filter.Growth.MinLikes != nil { + if filter.Growth.Hours != 0 || filter.Growth.MinLikes != nil || filter.SortBy == "likes_growth" { ids := make([]string, 0, len(items)) for _, item := range items { ids = append(ids, item.ID) @@ -794,6 +814,9 @@ func (s *Store) ListWorksPage(ctx context.Context, filter WorkFilter, page, page return Page[Work]{}, err } } + if filter.SortBy == "likes_growth" { + sortWorksGrowth(items, filter.SortOrder) + } return slicePage(items, page, pageSize) } diff --git a/internal/creator/growth.go b/internal/creator/growth.go index eefccef..e479a0f 100644 --- a/internal/creator/growth.go +++ b/internal/creator/growth.go @@ -127,8 +127,7 @@ func (s *Store) LoadWorksGrowth(ctx context.Context, workIDs []string, filter Wo return result, nil } -// ApplyWorkGrowthFilter 为作品列表附加增速并按条件过滤、排序(增速降序)。 -// 只有设置了 MinLikes 才改变排序;否则保持原顺序仅附加增速。 +// ApplyWorkGrowthFilter 为作品列表附加增量并按条件过滤,不改变显式选择的顺序。 func ApplyWorkGrowthFilter(works []Work, growth map[string]WorkGrowth, filter WorkGrowthFilter) ([]Work, error) { filter, err := normalizeGrowthFilter(filter) if err != nil { @@ -147,16 +146,20 @@ func ApplyWorkGrowthFilter(works []Work, growth map[string]WorkGrowth, filter Wo } result = append(result, work) } - if filter.MinLikes != nil { - sort.SliceStable(result, func(i, j int) bool { - gi, gj := result[i].LikesGrowth, result[j].LikesGrowth - if gi == nil || gj == nil { - return gj == nil && gi != nil - } - return *gi > *gj - }) - } return result, nil } +func sortWorksGrowth(works []Work, order string) { + sort.SliceStable(works, func(i, j int) bool { + left, right := works[i].LikesGrowth, works[j].LikesGrowth + if left == nil || right == nil { + return left != nil && right == nil + } + if order == "asc" { + return *left < *right + } + return *left > *right + }) +} + func int64Ptr(value int64) *int64 { return &value } diff --git a/internal/creator/growth_test.go b/internal/creator/growth_test.go index ae95a83..704ec23 100644 --- a/internal/creator/growth_test.go +++ b/internal/creator/growth_test.go @@ -57,7 +57,7 @@ func TestComputeWorkGrowth(t *testing.T) { }) } -func TestApplyWorkGrowthFilterSortsAndFilters(t *testing.T) { +func TestApplyWorkGrowthFilterPreservesOrderAndFilters(t *testing.T) { mk := func(id string, growth int64) (Work, WorkGrowth) { value := growth return Work{ID: id}, WorkGrowth{Likes: &value, Coverage: "full"} @@ -73,10 +73,10 @@ func TestApplyWorkGrowthFilterSortsAndFilters(t *testing.T) { if err != nil { t.Fatal(err) } - if len(filtered) != 2 || filtered[0].ID != "b" || filtered[1].ID != "a" { - t.Fatalf("unexpected filter/sort result: %+v", filtered) + if len(filtered) != 2 || filtered[0].ID != "a" || filtered[1].ID != "b" { + t.Fatalf("unexpected filter/order result: %+v", filtered) } - if filtered[0].LikesGrowth == nil || *filtered[0].LikesGrowth != 900 { + if filtered[0].LikesGrowth == nil || *filtered[0].LikesGrowth != 100 { t.Fatalf("growth not attached: %+v", filtered[0]) } } diff --git a/internal/creator/models.go b/internal/creator/models.go index 438b170..78c2772 100644 --- a/internal/creator/models.go +++ b/internal/creator/models.go @@ -231,6 +231,10 @@ type WorkFilter struct { MinLikes *int64 MinComments *int64 MinShares *int64 + MinCollectCount *int64 + MinPlayCount *int64 + SortBy string + SortOrder string Growth WorkGrowthFilter // 点赞增量筛选(爆品),空窗口不启用 } diff --git a/internal/creator/work_analysis_integration_test.go b/internal/creator/work_analysis_integration_test.go new file mode 100644 index 0000000..88f3f5c --- /dev/null +++ b/internal/creator/work_analysis_integration_test.go @@ -0,0 +1,99 @@ +package creator + +import ( + "fmt" + "testing" + "time" +) + +func TestCreatorPostgresWorkAnalysisSourcesFiltersAndOrdering(t *testing.T) { + store, accounts, ctx := openCreatorIntegrationStore(t) + ownedID := createIntegrationAccount(t, ctx, accounts, "analysis-owned") + competitor, err := store.CreateCompetitor(ctx, CompetitorInput{Platform: PlatformDouyin, PlatformAccountKey: "analysis-competitor", Nickname: "分析竞品", HomepageURL: "https://www.douyin.com/user/analysis-competitor"}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + older := now.Add(-72 * time.Hour) + newer := now.Add(-48 * time.Hour) + inputs := []WorkInput{ + {Platform: PlatformDouyin, WorkKey: "zero", SourceType: SourceOwned, SourceID: ownedID, PublishedAt: &older, Likes: int64Ptr(0), CommentsCount: int64Ptr(0), Shares: int64Ptr(0), CollectCount: int64Ptr(0), PlayCount: int64Ptr(0)}, + {Platform: PlatformDouyin, WorkKey: "positive", SourceType: SourceOwned, SourceID: ownedID, PublishedAt: &newer, Likes: int64Ptr(10), CommentsCount: int64Ptr(10), Shares: int64Ptr(10), CollectCount: int64Ptr(10), PlayCount: int64Ptr(10)}, + {Platform: PlatformDouyin, WorkKey: "unknown", SourceType: SourceOwned, SourceID: ownedID}, + {Platform: PlatformDouyin, WorkKey: "competitor", SourceType: SourceCompetitor, SourceID: competitor.ID, PublishedAt: &newer, Likes: int64Ptr(999)}, + } + works := make([]Work, len(inputs)) + for i, input := range inputs { + work, _, err := store.UpsertWork(ctx, input, now.Add(-26*time.Hour)) + if err != nil { + t.Fatal(err) + } + works[i] = work + } + for _, field := range []string{"published_at", "likes", "comments_count", "shares", "collect_count", "play_count"} { + for _, order := range []string{"asc", "desc"} { + t.Run(fmt.Sprintf("%s_%s", field, order), func(t *testing.T) { + filter := WorkFilter{SourceType: SourceOwned, SortBy: field, SortOrder: order} + first := works[0].ID + second := works[1].ID + if order == "desc" { + first, second = second, first + } + for pageNumber, id := range []string{first, second, works[2].ID} { + page, err := store.ListWorksPage(ctx, filter, pageNumber+1, 1) + if err != nil || page.Total != 3 || len(page.Data) != 1 || page.Data[0].ID != id { + t.Fatalf("page=%+v expected=%s err=%v", page, id, err) + } + } + }) + } + } + for _, filter := range []WorkFilter{ + {SourceType: SourceOwned, SourceID: ownedID, MinCollectCount: int64Ptr(0), MinPlayCount: int64Ptr(0)}, + {SourceType: SourceOwned, MinLikes: int64Ptr(0), MinComments: int64Ptr(0), MinShares: int64Ptr(0)}, + {SourceType: SourceOwned, PublishedAtStatus: "verified"}, + } { + page, err := store.ListWorksPage(ctx, filter, 1, 10) + if err != nil || page.Total != 2 { + t.Fatalf("filter=%+v page=%+v err=%v", filter, page, err) + } + } + page, err := store.ListWorksPage(ctx, WorkFilter{SourceType: SourceCompetitor, SourceID: competitor.ID}, 1, 10) + if err != nil || page.Total != 1 || page.Data[0].ID != works[3].ID { + t.Fatalf("competitor leaked owned works: %+v %v", page, err) + } + page, err = store.ListWorksPage(ctx, WorkFilter{SourceType: SourceOwned, SourceID: "missing"}, 1, 10) + if err != nil || page.Total != 0 || page.Data == nil { + t.Fatalf("empty account: %+v %v", page, err) + } + settings, err := store.GetSettings(ctx) + if err != nil { + t.Fatal(err) + } + for i, growth := range []int64{50, 5} { + for _, point := range []struct { + at time.Time + likes int64 + }{{now.Add(-25 * time.Hour), 100}, {now.Add(-time.Hour), 100 + growth}} { + if _, err := store.RecordMetric(ctx, MetricInput{WorkID: works[i].ID, CollectedAt: point.at, Likes: int64Ptr(point.likes)}, settings, point.at); err != nil { + t.Fatal(err) + } + } + } + for _, order := range []string{"asc", "desc"} { + filter := WorkFilter{SourceType: SourceOwned, SortBy: "likes_growth", SortOrder: order, Growth: WorkGrowthFilter{Hours: 24}} + expected := works[0].ID + if order == "asc" { + expected = works[1].ID + } + page, err = store.ListWorksPage(ctx, filter, 1, 1) + if err != nil || page.Total != 3 || page.Data[0].ID != expected { + t.Fatalf("growth ordering: %+v err=%v", page, err) + } + filter.Growth.MinLikes = int64Ptr(10) + page, err = store.ListWorksPage(ctx, filter, 1, 1) + if err != nil || page.Total != 1 || page.Data[0].ID != works[0].ID { + t.Fatalf("growth threshold before page: %+v err=%v", page, err) + } + } +} diff --git a/internal/creator/work_analysis_test.go b/internal/creator/work_analysis_test.go new file mode 100644 index 0000000..814657b --- /dev/null +++ b/internal/creator/work_analysis_test.go @@ -0,0 +1,83 @@ +package creator + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestBuildWorkQueryAnalysis(t *testing.T) { + for _, field := range []string{"published_at", "likes", "comments_count", "shares", "collect_count", "play_count", "likes_growth"} { + for _, order := range []string{"asc", "desc"} { + query, _, err := buildWorkQuery(WorkFilter{SortBy: field, SortOrder: order}) + if err != nil { + t.Fatalf("%s %s: %v", field, order, err) + } + expected := field + " " + strings.ToUpper(order) + " NULLS LAST" + if field == "likes_growth" { + expected = "published_at DESC NULLS LAST" + } + if !strings.Contains(query, expected) || !strings.HasSuffix(query, ", id") { + t.Fatalf("unexpected query: %s", query) + } + } + } + after := time.Now().Add(-time.Hour) + before := time.Now() + query, args, err := buildWorkQuery(WorkFilter{Platform: "douyin", SourceType: SourceOwned, SourceID: "owned-1", PublishedAtStatus: "verified", PublishedAfter: &after, PublishedBefore: &before, MinLikes: int64Ptr(0), MinComments: int64Ptr(1), MinShares: int64Ptr(2), MinCollectCount: int64Ptr(3), MinPlayCount: int64Ptr(4)}) + if err != nil || len(args) != 11 { + t.Fatalf("query=%s args=%v err=%v", query, args, err) + } + for _, column := range []string{"likes", "comments_count", "shares", "collect_count", "play_count"} { + if !strings.Contains(query, column+" IS NOT NULL AND "+column+" >=") { + t.Fatalf("nil threshold must be excluded: %s", query) + } + } + query, _, err = buildWorkQuery(WorkFilter{}) + if err != nil || !strings.Contains(query, "ORDER BY published_at DESC NULLS LAST, id") { + t.Fatalf("default: %s %v", query, err) + } + for _, status := range []string{"verified", "future", "pending_verification", "invalid"} { + if _, _, err := buildWorkQuery(WorkFilter{PublishedAtStatus: status, SourceType: SourceCompetitor}); err != nil { + t.Fatal(err) + } + } + for _, filter := range []WorkFilter{ + {SortBy: "likes; DROP TABLE creator_work"}, {SortOrder: "invalid"}, {Platform: "invalid"}, {SourceType: "invalid"}, {PublishedAtStatus: "unknown"}, + {PublishedAfter: &before, PublishedBefore: &after}, + {MinLikes: int64Ptr(-1)}, {MinComments: int64Ptr(-1)}, {MinShares: int64Ptr(-1)}, {MinCollectCount: int64Ptr(-1)}, {MinPlayCount: int64Ptr(-1)}, + } { + if _, _, err := buildWorkQuery(filter); !errors.Is(err, ErrInvalid) { + t.Fatalf("invalid filter accepted: %+v, %v", filter, err) + } + } +} + +func TestAnalysisGrowthSortBeforePagination(t *testing.T) { + works := []Work{{ID: "unknown"}, {ID: "low", LikesGrowth: int64Ptr(1)}, {ID: "high", LikesGrowth: int64Ptr(9)}, {ID: "equal", LikesGrowth: int64Ptr(9)}} + for _, tc := range []struct { + order string + ids []string + }{ + {"desc", []string{"high", "equal", "low", "unknown"}}, + {"asc", []string{"low", "high", "equal", "unknown"}}, + } { + items := append([]Work(nil), works...) + sortWorksGrowth(items, tc.order) + for i, id := range tc.ids { + if items[i].ID != id { + t.Fatalf("%s: %+v", tc.order, items) + } + } + page, err := slicePage(items, 2, 2) + if err != nil || page.Total != 4 || page.Data[0].ID != tc.ids[2] { + t.Fatalf("page=%+v err=%v", page, err) + } + } + sortWorksGrowth([]Work{{ID: "a"}, {ID: "b"}}, "desc") + filtered, err := ApplyWorkGrowthFilter(works, nil, WorkGrowthFilter{MinLikes: int64Ptr(0)}) + if err != nil || len(filtered) != 3 || filtered[0].ID != "low" { + t.Fatalf("filter must preserve explicit sorting: %+v %v", filtered, err) + } +} diff --git a/web/src/components/creator/WorksGallery.tsx b/web/src/components/creator/WorksGallery.tsx index 6685803..eb42484 100644 --- a/web/src/components/creator/WorksGallery.tsx +++ b/web/src/components/creator/WorksGallery.tsx @@ -5,13 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Line } from '@ant-design/charts'; import { Alert, App, Button, Card, Col, Drawer, Empty, Flex, InputNumber, Pagination, Row, Space, Spin, Table, Typography } from 'antd'; -import { - CommentOutlined, - EyeOutlined, - FireOutlined, - LikeOutlined, - ReloadOutlined, - ShareAltOutlined } from '@ant-design/icons'; +import { CommentOutlined, EyeOutlined, StarOutlined, FireOutlined, LikeOutlined, ReloadOutlined, ShareAltOutlined } from '@ant-design/icons'; import { creatorAction, creatorGet, fetchCreatorBinary, getList } from '@/services/api'; import { conflictMessage, dateTime } from '@/utils/helpers'; @@ -39,21 +33,30 @@ export function WorkCover({ workID, title }: { workID: string; title?: string }) } return ( - {missing ? '封面未缓存' : '封面加载中'} + + {missing ? '封面未缓存' : '封面加载中'} + ); } -// 观看/点赞/评论/分享全宽四列 GRID,紧贴封面(卡片 body 顶部,本行自带上下边框)。 -function WorkStatsGrid({ work }: { work: any }) { +// 竞品突出互动指标;自有账号额外展示观看量。缺失值与 0 保持区分。 +function WorkStatsGrid({ work, showViews }: { work: any; showViews: boolean }) { const items = [ - { key: 'play_count', label: '观看', icon: EyeOutlined }, + ...(showViews ? [{ key: 'play_count', label: '观看', icon: EyeOutlined }] : []), { key: 'likes', label: '点赞', icon: LikeOutlined }, { key: 'comments_count', label: '评论', icon: CommentOutlined }, { key: 'shares', label: '分享', icon: ShareAltOutlined }, + { key: 'collect_count', label: '收藏', icon: StarOutlined }, ]; return ( - + {items.map(({ key, label, icon: Icon }) => ( @@ -86,7 +89,8 @@ function GrowthChart({ metrics }: { metrics: any[] }) { .map((series) => ({ time: dateTime(point.collected_at), metric: series.label, - value: point[series.key] })), + value: point[series.key], + })), ), [metrics], ); @@ -98,7 +102,9 @@ function GrowthChart({ metrics }: { metrics: any[] }) { yField="value" colorField="metric" height={240} - yAxis={{ labelFormatter: (v: number) => (v >= 10000 ? `${(v / 10000).toFixed(1)}w` : `${v}`) }} + yAxis={{ + labelFormatter: (v: number) => (v >= 10000 ? `${(v / 10000).toFixed(1)}w` : `${v}`), + }} legend={{ position: 'top' }} /> ); @@ -115,19 +121,25 @@ interface WorksGalleryProps { sourceId?: string; sourceType?: 'owned' | 'competitor'; emptyHint?: string; - /** 页面自有筛选(如竞品分析页的互动量/发布时间条件)透传到作品列表查询。 */ + /** 页面自有筛选与排序条件透传到作品列表查询。 */ extraFilters?: Record; + /** 分析页在外部筛选卡中统一管理增量筛选,避免重复入口。 */ + showGrowthFilter?: boolean; } -export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters }: WorksGalleryProps) { +export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters, showGrowthFilter = true }: WorksGalleryProps) { const [works, setWorks] = useState([]); - const [workPage, setWorkPage] = useState(1); + const [pageState, setPageState] = useState({ filterKey: '', page: 1 }); + const [worksRefresh, setWorksRefresh] = useState(0); const [workPageInfo, setWorkPageInfo] = useState({ total: 0 }); const [collectionProgress, setCollectionProgress] = useState(null); const [progressError, setProgressError] = useState(null); const [worksPending, setWorksPending] = useState(true); const [worksError, setWorksError] = useState(null); const [minGrowth, setMinGrowth] = useState(null); + const filterKey = JSON.stringify([sourceId, sourceType, extraFilters, minGrowth]); + const workPage = pageState.filterKey === filterKey ? pageState.page : 1; + const setWorkPage = (page: number) => setPageState({ filterKey, page }); const [detailWork, setDetailWork] = useState(null); const [workMetrics, setWorkMetrics] = useState([]); const [metricsPending, setMetricsPending] = useState(false); @@ -138,48 +150,80 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil const [commentsPending, setCommentsPending] = useState(false); const { message: messageApi } = App.useApp(); - const loadWorks = useCallback(async () => { - setWorksPending(true); - setWorksError(null); - setProgressError(null); - setCollectionProgress(null); - const progressRequest = sourceType === 'owned' && sourceId - ? creatorGet(`/creator/accounts/${encodeURIComponent(sourceId)}/collection-status`) - .then((progress) => setCollectionProgress(progress)) - .catch((error) => { - console.error('读取账号作品采集进度失败', { sourceId, sourceType, error }); - setProgressError(error); - }) - : Promise.resolve(); - try { - const filters: Record = { ...extraFilters }; - if (sourceId) filters.source_id = sourceId; - if (sourceType) filters.source_type = sourceType; - if (minGrowth !== null) { - // 爆品筛选:growth_hours 开启后端 24h 点赞增量计算,min_likes_growth 设下限。 - filters.growth_hours = 24; - filters.min_likes_growth = minGrowth; - } - const result = await getList({ resource: 'creator-works', page: workPage, pageSize: 25, filters }); - setWorks(result.data); - setWorkPageInfo({ total: result.total }); - } catch (loadError) { - console.error('读取作品列表失败', { sourceId, sourceType, page: workPage, minGrowth, extraFilters, error: loadError }); - setWorksError(loadError); - } finally { - await progressRequest; - setWorksPending(false); - } - }, [sourceId, sourceType, workPage, minGrowth, extraFilters]); - + const loadWorks = () => setWorksRefresh((value) => value + 1); useEffect(() => { - loadWorks(); - }, [loadWorks]); + let active = true; + async function fetchWorks() { + setWorksPending(true); + setWorksError(null); + setProgressError(null); + setCollectionProgress(null); + const progressRequest = + sourceType === 'owned' && sourceId + ? creatorGet(`/creator/accounts/${encodeURIComponent(sourceId)}/collection-status`) + .then((progress) => { + if (active) setCollectionProgress(progress); + }) + .catch((error) => { + console.error('读取账号作品采集进度失败', { + sourceId, + sourceType, + error, + }); + if (active) setProgressError(error); + }) + : Promise.resolve(); + try { + const filters: Record = { + ...extraFilters, + }; + if (sourceId) filters.source_id = sourceId; + if (sourceType) filters.source_type = sourceType; + if (minGrowth !== null) { + // 爆品筛选:growth_hours 开启后端 24h 点赞增量计算,min_likes_growth 设下限。 + filters.growth_hours = 24; + filters.min_likes_growth = minGrowth; + } + const result = await getList({ + resource: 'creator-works', + page: workPage, + pageSize: 25, + filters, + }); + if (active) { + setWorks(result.data); + setWorkPageInfo({ total: result.total }); + } + } catch (loadError) { + console.error('读取作品列表失败', { + sourceId, + sourceType, + page: workPage, + minGrowth, + extraFilters, + error: loadError, + }); + if (active) setWorksError(loadError); + } finally { + await progressRequest; + if (active) setWorksPending(false); + } + } + fetchWorks(); + return () => { + active = false; + }; + }, [sourceId, sourceType, workPage, minGrowth, extraFilters, worksRefresh]); const loadComments = useCallback(async (workID: string, page: number, pageSize = 10) => { setCommentsPending(true); try { - const result = await getList({ resource: 'creator-comments', page, pageSize, filters: { work_id: workID } }); + const result = await getList({ + resource: 'creator-comments', + page, + pageSize, + filters: { work_id: workID }, + }); setComments(result.data); setCommentTotal(result.total); } catch { @@ -222,37 +266,47 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil return ( <> - } size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" /> - } - > + } size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />}> - {sourceType === 'owned' ? {progressHint} : null} + {sourceType === 'owned' && sourceId ? {progressHint} : null} - {worksError ? '作品列表暂不可用' : `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`};作品由系统按采集间隔自动同步。 + {worksError + ? '作品列表暂不可用' + : `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`} + ;作品由系统按采集间隔自动同步。 - - - 24h 点赞增量 ≥ - { - setMinGrowth(value ?? null); - setWorkPage(1); - }} - /> - + {showGrowthFilter ? ( + + + 24h 点赞增量 ≥ + { + setMinGrowth(value ?? null); + setWorkPage(1); + }} + /> + + ) : null} {progressError ? ( - 重试} /> + + 重试 + + } + /> ) : null} {worksError ? ( 重试} + action={ + + } /> ) : null} @@ -275,7 +333,7 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil onClick={() => openWorkDetail(work)} cover={} > - +
{/* 无标题作品不渲染标题行,避免占位文案增加卡片噪音。 */} @@ -294,9 +352,17 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
- {work.likes_growth != null && work.likes_growth > 0 ? ( - - 24h +{work.likes_growth} + {work.likes_growth != null || extraFilters?.growth_hours ? ( + + 24h 点赞增量 {work.likes_growth == null ? '—' : `+${work.likes_growth}`} + {work.likes_growth_coverage === 'partial' ? '(样本不足)' : ''} ) : null}
@@ -324,17 +390,24 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil setDetailWork(null)} size="large" title="作品统计"> {detailWork ? ( - ID:{detailWork.work_key || detailWork.id} + + ID:{detailWork.work_key || detailWork.id} + {detailWork.title ? ( - {detailWork.title} + + {detailWork.title} + ) : null} - - {detailWork.body || '暂无正文'} - + {detailWork.body || '暂无正文'} - 发布:{detailWork.published_at ? dateTime(detailWork.published_at) : '—'} + + 发布: + {detailWork.published_at ? dateTime(detailWork.published_at) : '—'} + {detailWork.original_url ? ( - 打开原文 + + 打开原文 + ) : null} {workMetrics.length >= 2 ? : null} @@ -345,16 +418,34 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil pagination={false} dataSource={workMetrics} columns={[ - { title: '采集时间', dataIndex: 'collected_at', render: (value: string) => dateTime(value) }, - { title: '播放', dataIndex: 'play_count', render: (v) => v ?? '—' }, + { + title: '采集时间', + dataIndex: 'collected_at', + render: (value: string) => dateTime(value), + }, + { + title: '播放', + dataIndex: 'play_count', + render: (v) => v ?? '—', + }, { title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' }, - { title: '收藏', dataIndex: 'collect_count', render: (v) => v ?? '—' }, - { title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' }, + { + title: '收藏', + dataIndex: 'collect_count', + render: (v) => v ?? '—', + }, + { + title: '评论', + dataIndex: 'comments_count', + render: (v) => v ?? '—', + }, { title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' }, ]} locale={{ emptyText: '暂无指标记录。' }} /> - 评论 + + 评论 + ({}); + const [accounts, setAccounts] = useState([]); + const [accountsPending, setAccountsPending] = useState(true); + const [accountsError, setAccountsError] = useState(null); + const [retry, setRetry] = useState(0); + const { message } = App.useApp(); + useEffect(() => { + let active = true; + setAccountsPending(true); + setAccountsError(null); + const endpoint = sourceType === 'owned' ? '/creator/accounts' : '/creator/competitors'; + creatorGet(endpoint) + .then((result: AccountOption[]) => { + if (active) setAccounts(result); + }) + .catch((error) => { + console.error('读取作品分析账号失败', { sourceType, endpoint, error }); + if (active) { + setAccountsError(error); + message.error(conflictMessage(error, '账号读取失败')); + } + }) + .finally(() => { + if (active) setAccountsPending(false); + }); + return () => { + active = false; + }; + }, [sourceType, retry, message]); - // 序列化为后端 workFilter 的 query 参数;日期取本地日历日的 00:00/23:59(UTC 边界)。 const extraFilters = useMemo( () => ({ + growth_hours: 24, + sort_by: filters.sortBy ?? 'published_at', + sort_order: filters.sortOrder ?? 'desc', + published_after: filters.dates?.[0].startOf('day').toISOString(), + published_before: filters.dates?.[1].endOf('day').toISOString(), + published_at_status: filters.publishedAtStatus, min_likes: filters.minLikes, min_comments: filters.minComments, min_shares: filters.minShares, - published_at_status: filters.publishedAtStatus, - published_after: filters.publishedAfter ? `${filters.publishedAfter.format('YYYY-MM-DD')}T00:00:00Z` : undefined, - published_before: filters.publishedBefore ? `${filters.publishedBefore.format('YYYY-MM-DD')}T23:59:59Z` : undefined, + min_collect_count: filters.minCollectCount, + min_play_count: sourceType === 'owned' ? filters.minPlayCount : undefined, + min_likes_growth: filters.minLikesGrowth, }), - [filters], + [filters, sourceType], ); + const metrics = sourceType === 'owned' ? [...metricFilters, { key: 'minPlayCount' as const, label: '最低观看' }] : metricFilters; + const sortOptions = sourceType === 'owned' ? [...commonSortOptions, { value: 'play_count', label: '观看' }] : commonSortOptions; return ( - 调整条件后自动刷新}> - - setFilters((f) => ({ ...f, minLikes: value ?? undefined }))} /> - setFilters((f) => ({ ...f, minComments: value ?? undefined }))} /> - setFilters((f) => ({ ...f, minShares: value ?? undefined }))} /> - ({ + value: account.id, + label: (sourceType === 'owned' ? account.name : account.nickname) || account.platform_account_key, + }))} + onChange={(value) => setFilters((current) => ({ ...current, sourceId: value }))} + /> + + + + setFilters((current) => ({ + ...current, + dates: dates?.[0] && dates[1] ? [dates[0], dates[1]] : undefined, + })) + } + /> + + + setFilters((current) => ({ ...current, sortBy: value }))} + /> + + +