feat: unify owned and competitor work analysis
This commit is contained in:
@@ -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 默认组件原样实现,禁止自定义封装与样式魔改;组件不满足业务时改交互逻辑适配组件;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+53
-30
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+14
-11
@@ -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 }
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,10 @@ type WorkFilter struct {
|
||||
MinLikes *int64
|
||||
MinComments *int64
|
||||
MinShares *int64
|
||||
MinCollectCount *int64
|
||||
MinPlayCount *int64
|
||||
SortBy string
|
||||
SortOrder string
|
||||
Growth WorkGrowthFilter // 点赞增量筛选(爆品),空窗口不启用
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Flex align="center" justify="center" style={{ aspectRatio: '3 / 4', background: '#f5f5f5' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{missing ? '封面未缓存' : '封面加载中'}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{missing ? '封面未缓存' : '封面加载中'}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
// 观看/点赞/评论/分享全宽四列 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 (
|
||||
<Flex style={{ padding: '6px 0', borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Flex
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
{items.map(({ key, label, icon: Icon }) => (
|
||||
<Flex key={key} vertical align="center" gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, lineHeight: '20px' }}>
|
||||
@@ -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<string, string | number | undefined>;
|
||||
/** 分析页在外部筛选卡中统一管理增量筛选,避免重复入口。 */
|
||||
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<any[]>([]);
|
||||
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<CollectionProgress | null>(null);
|
||||
const [progressError, setProgressError] = useState<any>(null);
|
||||
const [worksPending, setWorksPending] = useState(true);
|
||||
const [worksError, setWorksError] = useState<any>(null);
|
||||
const [minGrowth, setMinGrowth] = useState<number | null>(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<any>(null);
|
||||
const [workMetrics, setWorkMetrics] = useState<any[]>([]);
|
||||
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<string, string | number | undefined> = { ...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<string, string | number | undefined> = {
|
||||
...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 (
|
||||
<>
|
||||
<Card
|
||||
title="作品列表"
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />
|
||||
}
|
||||
>
|
||||
<Card title="作品列表" extra={<Button icon={<ReloadOutlined />} size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />}>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Flex vertical gap={4}>
|
||||
{sourceType === 'owned' ? <Typography.Text>{progressHint}</Typography.Text> : null}
|
||||
{sourceType === 'owned' && sourceId ? <Typography.Text>{progressHint}</Typography.Text> : null}
|
||||
<Typography.Text type="secondary">
|
||||
{worksError ? '作品列表暂不可用' : `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`};作品由系统按采集间隔自动同步。
|
||||
{worksError
|
||||
? '作品列表暂不可用'
|
||||
: `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`}
|
||||
;作品由系统按采集间隔自动同步。
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap={8}>
|
||||
<FireOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
|
||||
<Typography.Text type="secondary">24h 点赞增量 ≥</Typography.Text>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
placeholder="不限"
|
||||
style={{ width: 96 }}
|
||||
value={minGrowth}
|
||||
onChange={(value) => {
|
||||
setMinGrowth(value ?? null);
|
||||
setWorkPage(1);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
{showGrowthFilter ? (
|
||||
<Flex align="center" gap={8}>
|
||||
<FireOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
|
||||
<Typography.Text type="secondary">24h 点赞增量 ≥</Typography.Text>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
placeholder="不限"
|
||||
style={{ width: 96 }}
|
||||
value={minGrowth}
|
||||
onChange={(value) => {
|
||||
setMinGrowth(value ?? null);
|
||||
setWorkPage(1);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Flex>
|
||||
{progressError ? (
|
||||
<Alert type="error" showIcon title={progressError.message} style={{ marginBottom: 16 }} action={<Button size="small" onClick={loadWorks}>重试</Button>} />
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={progressError.message}
|
||||
style={{ marginBottom: 16 }}
|
||||
action={
|
||||
<Button size="small" onClick={loadWorks}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{worksError ? (
|
||||
<Alert
|
||||
@@ -260,7 +314,11 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={worksError.message}
|
||||
action={<Button size="small" onClick={loadWorks}>重试</Button>}
|
||||
action={
|
||||
<Button size="small" onClick={loadWorks}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Spin spinning={worksPending}>
|
||||
@@ -275,7 +333,7 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
onClick={() => openWorkDetail(work)}
|
||||
cover={<WorkCover workID={work.id} title={work.title} />}
|
||||
>
|
||||
<WorkStatsGrid work={work} />
|
||||
<WorkStatsGrid work={work} showViews={sourceType !== 'competitor'} />
|
||||
<div style={{ padding: '10px 16px 0' }}>
|
||||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||||
{/* 无标题作品不渲染标题行,避免占位文案增加卡片噪音。 */}
|
||||
@@ -294,9 +352,17 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</div>
|
||||
{work.likes_growth != null && work.likes_growth > 0 ? (
|
||||
<Typography.Text type="warning" style={{ display: 'block', padding: '8px 16px 0', fontSize: 12 }}>
|
||||
<FireOutlined /> 24h +{work.likes_growth}
|
||||
{work.likes_growth != null || extraFilters?.growth_hours ? (
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '8px 16px 0',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<FireOutlined /> 24h 点赞增量 {work.likes_growth == null ? '—' : `+${work.likes_growth}`}
|
||||
{work.likes_growth_coverage === 'partial' ? '(样本不足)' : ''}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Card>
|
||||
@@ -324,17 +390,24 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
<Drawer open={Boolean(detailWork)} onClose={() => setDetailWork(null)} size="large" title="作品统计">
|
||||
{detailWork ? (
|
||||
<Flex vertical gap={12}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>ID:{detailWork.work_key || detailWork.id}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
ID:{detailWork.work_key || detailWork.id}
|
||||
</Typography.Text>
|
||||
{detailWork.title ? (
|
||||
<Typography.Paragraph strong style={{ marginBottom: 0 }}>{detailWork.title}</Typography.Paragraph>
|
||||
<Typography.Paragraph strong style={{ marginBottom: 0 }}>
|
||||
{detailWork.title}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
|
||||
{detailWork.body || '暂无正文'}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{detailWork.body || '暂无正文'}</Typography.Paragraph>
|
||||
<Space wrap>
|
||||
<Typography.Text type="secondary">发布:{detailWork.published_at ? dateTime(detailWork.published_at) : '—'}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
发布:
|
||||
{detailWork.published_at ? dateTime(detailWork.published_at) : '—'}
|
||||
</Typography.Text>
|
||||
{detailWork.original_url ? (
|
||||
<Typography.Link href={detailWork.original_url} target="_blank">打开原文</Typography.Link>
|
||||
<Typography.Link href={detailWork.original_url} target="_blank">
|
||||
打开原文
|
||||
</Typography.Link>
|
||||
) : null}
|
||||
</Space>
|
||||
{workMetrics.length >= 2 ? <GrowthChart metrics={workMetrics} /> : 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: '暂无指标记录。' }}
|
||||
/>
|
||||
<Typography.Title level={5} style={{ marginBottom: 0 }}>评论</Typography.Title>
|
||||
<Typography.Title level={5} style={{ marginBottom: 0 }}>
|
||||
评论
|
||||
</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
@@ -370,7 +461,8 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
setCommentPageSize(pageSize);
|
||||
loadComments(detailWork.id, page, pageSize);
|
||||
},
|
||||
hideOnSinglePage: true }}
|
||||
hideOnSinglePage: true,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '作者 / 时间',
|
||||
|
||||
@@ -1,75 +1,235 @@
|
||||
// 竞品分析:跨全部竞品的作品筛选与列表。
|
||||
// 作品列表/详情/指标/评论复用共享组件 WorksGallery(与监控账号、自有账号详情页同一套展现),
|
||||
// 本页仅保留作品筛选卡,筛选条件经 extraFilters 透传(后端 workFilter 支持同名 query 参数)。
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, DatePicker, Flex, InputNumber, Select, Space, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, App, Button, Card, DatePicker, Flex, Form, InputNumber, Select, Tabs, Typography } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import WorksGallery from '@/components/creator/WorksGallery';
|
||||
import { creatorGet } from '@/services/api';
|
||||
import { conflictMessage } from '@/utils/helpers';
|
||||
|
||||
type SourceType = 'owned' | 'competitor';
|
||||
interface Filters {
|
||||
sourceId?: string;
|
||||
dates?: [Dayjs, Dayjs];
|
||||
publishedAtStatus?: string;
|
||||
minLikes?: number;
|
||||
minComments?: number;
|
||||
minShares?: number;
|
||||
minCollectCount?: number;
|
||||
minPlayCount?: number;
|
||||
minLikesGrowth?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}
|
||||
interface AccountOption {
|
||||
id: string;
|
||||
name?: string;
|
||||
nickname?: string;
|
||||
platform_account_key: string;
|
||||
}
|
||||
const publishedAtStatusOptions = [
|
||||
{ value: 'verified', label: '已核验' },
|
||||
{ value: 'future', label: '未来时间' },
|
||||
{ value: 'pending_verification', label: '待核验' },
|
||||
{ value: 'invalid', label: '无效时间' },
|
||||
];
|
||||
const metricFilters: { key: keyof Filters; label: string }[] = [
|
||||
{ key: 'minLikes', label: '最低点赞' },
|
||||
{ key: 'minComments', label: '最低评论' },
|
||||
{ key: 'minShares', label: '最低分享' },
|
||||
{ key: 'minCollectCount', label: '最低收藏' },
|
||||
{ key: 'minLikesGrowth', label: '最低24h点赞增量' },
|
||||
];
|
||||
const commonSortOptions = [
|
||||
{ value: 'published_at', label: '发布时间' },
|
||||
{ value: 'likes', label: '点赞' },
|
||||
{ value: 'comments_count', label: '评论' },
|
||||
{ value: 'shares', label: '分享' },
|
||||
{ value: 'collect_count', label: '收藏' },
|
||||
{ value: 'likes_growth', label: '24h点赞增量' },
|
||||
];
|
||||
|
||||
interface Filters {
|
||||
minLikes?: number;
|
||||
minComments?: number;
|
||||
minShares?: number;
|
||||
publishedAtStatus?: string;
|
||||
publishedAfter?: Dayjs;
|
||||
publishedBefore?: Dayjs;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
// 每个 TAB 独立挂载并保留条件、分页,不混合两种来源。
|
||||
export function WorkAnalysisTab({ sourceType }: { sourceType: SourceType }) {
|
||||
const [filters, setFilters] = useState<Filters>({});
|
||||
const [accounts, setAccounts] = useState<AccountOption[]>([]);
|
||||
const [accountsPending, setAccountsPending] = useState(true);
|
||||
const [accountsError, setAccountsError] = useState<unknown>(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 (
|
||||
<Flex vertical gap={16}>
|
||||
<Card title="筛选作品" extra={<Typography.Text type="secondary" style={{ fontSize: 12 }}>调整条件后自动刷新</Typography.Text>}>
|
||||
<Space size={12} wrap style={{ display: 'flex' }}>
|
||||
<InputNumber style={{ width: 130 }} placeholder="最低点赞" min={0} value={filters.minLikes} onChange={(value) => setFilters((f) => ({ ...f, minLikes: value ?? undefined }))} />
|
||||
<InputNumber style={{ width: 130 }} placeholder="最低评论" min={0} value={filters.minComments} onChange={(value) => setFilters((f) => ({ ...f, minComments: value ?? undefined }))} />
|
||||
<InputNumber style={{ width: 130 }} placeholder="最低转发" min={0} value={filters.minShares} onChange={(value) => setFilters((f) => ({ ...f, minShares: value ?? undefined }))} />
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部状态"
|
||||
allowClear
|
||||
value={filters.publishedAtStatus}
|
||||
onChange={(value) => setFilters((f) => ({ ...f, publishedAtStatus: value }))}
|
||||
options={publishedAtStatusOptions}
|
||||
/>
|
||||
<DatePicker
|
||||
style={{ width: 160 }}
|
||||
placeholder="发布开始日期"
|
||||
value={filters.publishedAfter}
|
||||
onChange={(value) => setFilters((f) => ({ ...f, publishedAfter: value ?? undefined }))}
|
||||
allowClear
|
||||
/>
|
||||
<DatePicker
|
||||
style={{ width: 160 }}
|
||||
placeholder="发布结束日期"
|
||||
value={filters.publishedBefore}
|
||||
onChange={(value) => setFilters((f) => ({ ...f, publishedBefore: value ?? undefined }))}
|
||||
allowClear
|
||||
/>
|
||||
</Space>
|
||||
<Card title="筛选与排序" extra={<Button onClick={() => setFilters({})}>重置</Button>}>
|
||||
<Flex vertical gap={16}>
|
||||
<Form layout="inline">
|
||||
<Form.Item label="所属账号">
|
||||
<Select
|
||||
aria-label="所属账号"
|
||||
placeholder="全部账号"
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
loading={accountsPending}
|
||||
disabled={accountsPending || Boolean(accountsError)}
|
||||
style={{ width: 180 }}
|
||||
value={filters.sourceId}
|
||||
options={accounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: (sourceType === 'owned' ? account.name : account.nickname) || account.platform_account_key,
|
||||
}))}
|
||||
onChange={(value) => setFilters((current) => ({ ...current, sourceId: value }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="发布时间">
|
||||
<DatePicker.RangePicker
|
||||
aria-label="发布时间"
|
||||
value={filters.dates}
|
||||
onChange={(dates) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
dates: dates?.[0] && dates[1] ? [dates[0], dates[1]] : undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="时间核验状态">
|
||||
<Select
|
||||
aria-label="时间核验状态"
|
||||
placeholder="全部状态"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
value={filters.publishedAtStatus}
|
||||
options={publishedAtStatusOptions}
|
||||
onChange={(value) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
publishedAtStatus: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Form layout="inline">
|
||||
{metrics.map(({ key, label }) => (
|
||||
<Form.Item key={key} label={label}>
|
||||
<InputNumber
|
||||
aria-label={label}
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: 120 }}
|
||||
value={filters[key] as number | undefined}
|
||||
onChange={(value) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[key]: value ?? undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item label="排序指标">
|
||||
<Select
|
||||
aria-label="排序指标"
|
||||
style={{ width: 160 }}
|
||||
value={filters.sortBy ?? 'published_at'}
|
||||
options={sortOptions}
|
||||
onChange={(value) => setFilters((current) => ({ ...current, sortBy: value }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="排序方向">
|
||||
<Select
|
||||
aria-label="排序方向"
|
||||
style={{ width: 100 }}
|
||||
value={filters.sortOrder ?? 'desc'}
|
||||
options={[
|
||||
{ value: 'desc', label: '降序' },
|
||||
{ value: 'asc', label: '升序' },
|
||||
]}
|
||||
onChange={(value) => setFilters((current) => ({ ...current, sortOrder: value }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Typography.Text type="secondary">调整条件后自动刷新;缺失指标显示“—”,不计为 0。</Typography.Text>
|
||||
{accountsError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
title="账号读取失败"
|
||||
description="暂时无法按账号筛选,其他筛选仍可使用。"
|
||||
action={
|
||||
<Button size="small" onClick={() => setRetry((value) => value + 1)}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card>
|
||||
<WorksGallery sourceType="competitor" emptyHint="暂无符合条件的作品。" extraFilters={extraFilters} />
|
||||
{sourceType === 'owned' ? (
|
||||
<Typography.Text type="secondary">仅展示已采集作品,未采集作品不参与筛选和排序;观看量未返回时显示“—”。</Typography.Text>
|
||||
) : null}
|
||||
<WorksGallery sourceType={sourceType} sourceId={filters.sourceId} extraFilters={extraFilters} showGrowthFilter={false} emptyHint="暂无符合条件的作品。" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Tabs
|
||||
defaultActiveKey="competitor"
|
||||
destroyOnHidden={false}
|
||||
items={[
|
||||
{
|
||||
key: 'competitor',
|
||||
label: '竞品账号',
|
||||
children: <WorkAnalysisTab sourceType="competitor" />,
|
||||
},
|
||||
{
|
||||
key: 'owned',
|
||||
label: '我的账号',
|
||||
children: <WorkAnalysisTab sourceType="owned" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export type Resource = keyof typeof resourcePaths;
|
||||
const filterKeys: Partial<Record<Resource, string[]>> = {
|
||||
'creator-competitors': ['platform'],
|
||||
'creator-competitor-share-jobs': ['platform', 'status'],
|
||||
'creator-works': ['platform', 'source_id', 'source_type', 'published_at_status', 'published_after', 'published_before', 'min_likes', 'min_comments', 'min_shares', 'growth_hours', 'min_likes_growth'],
|
||||
'creator-works': ['platform', 'source_id', 'source_type', 'published_at_status', 'published_after', 'published_before', 'min_likes', 'min_comments', 'min_shares', 'min_collect_count', 'min_play_count', 'sort_by', 'sort_order', 'growth_hours', 'min_likes_growth'],
|
||||
'creator-comments': ['platform', 'work_id'],
|
||||
'creator-leads': ['platform'],
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
|
||||
{ test: (p) => /^\/accounts\/monitoring\/.+/.test(p), title: '监控账号详情', subtitle: '查看监控账号画像与作品列表,点击作品可追溯指标统计。' },
|
||||
{ test: (p) => /\/edit$/.test(p) && p.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' },
|
||||
{ test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },
|
||||
{ test: (p) => p === '/creator/competitors', title: '竞品分析', subtitle: '查看竞品作品与指标,分享链接导入请前往账号导入。' },
|
||||
{ test: (p) => p === '/creator/competitors', title: '作品分析', subtitle: '分别查看竞品与自有账号作品,按账号、发布时间和表现筛选排序。' },
|
||||
{ test: (p) => p === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' },
|
||||
{ test: (p) => p.startsWith('/creator/competitors/'), title: '竞品账号详情', subtitle: '查看竞品画像与作品列表,点击作品可追溯指标统计。' },
|
||||
{ test: (p) => p === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' },
|
||||
@@ -39,7 +39,7 @@ export const menu: MenuDataItem[] = [
|
||||
{ path: '/accounts/import', name: '账号导入', icon: <CloudDownloadOutlined /> },
|
||||
{ path: '/accounts', name: '我的账号', icon: <UserOutlined /> },
|
||||
{ path: '/accounts/monitoring', name: '监控账号', icon: <EyeOutlined /> },
|
||||
{ path: '/creator/competitors', name: '竞品分析', icon: <LineChartOutlined /> },
|
||||
{ path: '/creator/competitors', name: '作品分析', icon: <LineChartOutlined /> },
|
||||
{ path: '/creator/settings', name: '采集设置', icon: <SettingOutlined /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -152,6 +152,68 @@ const text = (tree) => {
|
||||
if (Array.isArray(tree)) return tree.map(text).join('');
|
||||
return typeof tree === 'object' ? text(tree.props?.children) : String(tree);
|
||||
};
|
||||
test('analysis filters and sorting reset pagination before requesting, but ordinary paging is preserved', async () => {
|
||||
const page = harness('src/components/creator/WorksGallery.tsx', {
|
||||
workPages: { 1: { data: [], total: 48 }, 2: { data: [], total: 48 } },
|
||||
});
|
||||
let props = { sourceType: 'competitor', showGrowthFilter: false, extraFilters: { sort_by: 'likes', sort_order: 'desc', growth_hours: 24 } };
|
||||
let tree = await page.mount(props);
|
||||
assert.equal(byType(tree, 'InputNumber').length, 0);
|
||||
byType(tree, 'Pagination')[0].props.onChange(2);
|
||||
page.render(); await flush();
|
||||
assert.equal(page.calls.lists.at(-1).page, 2);
|
||||
for (const changes of [{ sourceId: 'competitor-2' }, { extraFilters: { sort_by: 'likes', sort_order: 'asc', growth_hours: 24 } }, { extraFilters: { min_likes: 0, sort_by: 'likes', sort_order: 'asc', growth_hours: 24 } }]) {
|
||||
props = { ...props, ...changes };
|
||||
tree = page.render(props); await flush();
|
||||
assert.equal(page.calls.lists.at(-1).page, 1);
|
||||
assert.equal(byType(tree, 'Pagination')[0].props.current, 1);
|
||||
byType(tree, 'Pagination')[0].props.onChange(2);
|
||||
page.render(); await flush();
|
||||
assert.equal(page.calls.lists.at(-1).page, 2);
|
||||
}
|
||||
page.unmount();
|
||||
});
|
||||
|
||||
test('stale responses from a previous sort cannot replace current works', async () => {
|
||||
const pending = [];
|
||||
const page = harness('src/components/creator/WorksGallery.tsx', { services: {
|
||||
getList: options => new Promise(resolve => pending.push({ options, resolve })),
|
||||
} });
|
||||
page.render({ sourceType: 'competitor', extraFilters: { sort_by: 'likes', sort_order: 'desc' } });
|
||||
page.render({ sourceType: 'competitor', extraFilters: { sort_by: 'likes', sort_order: 'asc' } });
|
||||
pending[1].resolve({ data: [{ id: 'latest', title: '正确排序的作品' }], total: 1 });
|
||||
await flush();
|
||||
pending[0].resolve({ data: [{ id: 'stale', title: '过期排序的作品' }], total: 1 });
|
||||
await flush();
|
||||
const tree = page.render();
|
||||
assert.match(text(tree), /正确排序的作品/);
|
||||
assert.doesNotMatch(text(tree), /过期排序的作品/);
|
||||
page.unmount();
|
||||
});
|
||||
|
||||
for (const showViews of [true, false]) {
|
||||
test(`work metric grid views=${showViews} keeps missing values distinct from zero`, () => {
|
||||
const grid = harness('src/components/creator/WorksGallery.tsx', {}, 'WorkStatsGrid');
|
||||
const tree = grid.render({ work: { likes: 0, play_count: null, collect_count: 2 }, showViews });
|
||||
assert.equal(text(tree).includes('观看'), showViews);
|
||||
assert.match(text(tree), /0\s*点赞/);
|
||||
assert.match(text(tree), /2\s*收藏/);
|
||||
assert.match(text(tree), /—\s*评论/);
|
||||
grid.unmount();
|
||||
});
|
||||
}
|
||||
|
||||
test('analysis cards display zero and unknown growth, and identify partial samples', async () => {
|
||||
const page = harness('src/components/creator/WorksGallery.tsx', { workPages: { 1: { data: [
|
||||
{ id: 'zero', likes_growth: 0, likes_growth_coverage: 'partial' },
|
||||
{ id: 'unknown', likes_growth: null },
|
||||
], total: 2 } } });
|
||||
const tree = await page.mount({ sourceType: 'competitor', showGrowthFilter: false, extraFilters: { growth_hours: 24 } });
|
||||
assert.match(text(tree), /24h 点赞增量\s*\+0\s*(样本不足)/);
|
||||
assert.match(text(tree), /24h 点赞增量\s*—/);
|
||||
page.unmount();
|
||||
});
|
||||
|
||||
const detailPath = 'src/pages/accounts/$id/index.tsx';
|
||||
const editPath = 'src/pages/accounts/$id/edit.tsx';
|
||||
const rebindPath = 'src/components/accounts/RebindEnvironment.tsx';
|
||||
@@ -311,7 +373,9 @@ test('读取作品进度失败明确提示,已采集作品仍然展示,刷
|
||||
assert.match(text(tree), /采集进度暂不可用/);
|
||||
api.collectionStatusError = null;
|
||||
api.collectionStatus = { work_count: 1, aweme_count: 1, works: { status: 'succeeded' } };
|
||||
await findAll(tree, (node) => node.type === 'Button' && node.props['aria-label'] === '刷新作品')[0].props.onClick();
|
||||
findAll(tree, (node) => node.type === 'Button' && node.props['aria-label'] === '刷新作品')[0].props.onClick();
|
||||
page.render();
|
||||
await flush();
|
||||
tree = page.render();
|
||||
assert.equal(byType(tree, 'Alert').length, 0);
|
||||
assert.match(text(tree), /已采集 1 \/ 总计 1/);
|
||||
@@ -333,12 +397,16 @@ test('作品列表读取失败明确提示并可重试,不影响独立采集
|
||||
assert.equal(byType(tree, 'Alert')[0].props.title, '作品读取失败');
|
||||
assert.match(text(tree), /正在采集/);
|
||||
api.workListError = null;
|
||||
await byType(tree, 'Alert')[0].props.action.props.onClick();
|
||||
byType(tree, 'Alert')[0].props.action.props.onClick();
|
||||
page.render();
|
||||
await flush();
|
||||
tree = page.render();
|
||||
assert.equal(byType(tree, 'Alert').length, 0);
|
||||
for (const status of ['failed', 'blocked']) {
|
||||
api.collectionStatus.works = { status, last_error: '浏览器未登录' };
|
||||
await findAll(tree, (node) => node.type === 'Button' && node.props['aria-label'] === '刷新作品')[0].props.onClick();
|
||||
findAll(tree, (node) => node.type === 'Button' && node.props['aria-label'] === '刷新作品')[0].props.onClick();
|
||||
page.render();
|
||||
await flush();
|
||||
tree = page.render();
|
||||
assert.match(text(tree), /采集未完成:浏览器未登录/);
|
||||
}
|
||||
|
||||
@@ -1,87 +1,115 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const Module = require('node:module');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const filename = path.join(__dirname, '../src/pages/creator/competitors/index.tsx');
|
||||
|
||||
const pagePath = path.join(__dirname, '../src/pages/creator/competitors/index.tsx');
|
||||
|
||||
function createPage() {
|
||||
let filters = {};
|
||||
const gallery = 'WorksGallery';
|
||||
const element = (type, props) => ({ type, props });
|
||||
const mocks = {
|
||||
react: {
|
||||
useMemo: (factory) => factory(),
|
||||
useState: () => [filters, (update) => { filters = update(filters); }],
|
||||
},
|
||||
'react/jsx-runtime': { jsx: element, jsxs: element },
|
||||
antd: {
|
||||
Card: 'Card', DatePicker: 'DatePicker', Flex: 'Flex', InputNumber: 'InputNumber',
|
||||
Select: 'Select', Space: 'Space', Typography: { Text: 'Text' },
|
||||
},
|
||||
'@/components/creator/WorksGallery': { __esModule: true, default: gallery },
|
||||
function harness({ sourceType = 'competitor', failure = false, response } = {}) {
|
||||
let cursor = 0;
|
||||
const states = [];
|
||||
const effects = [];
|
||||
const dependencies = [];
|
||||
const requests = [];
|
||||
const errors = [];
|
||||
const component = name => Object.assign(props => ({ name, props }), { displayName: name });
|
||||
const React = {
|
||||
createElement(type, props, ...children) { return typeof type === 'function' ? type({ ...props, children }) : { name: type, props: { ...props, children } }; },
|
||||
useState(initial) { const index = cursor++; if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial; return [states[index], next => { states[index] = typeof next === 'function' ? next(states[index]) : next; }]; },
|
||||
useMemo(fn) { return fn(); },
|
||||
useEffect(fn, deps) { const index = cursor++; if (!dependencies[index] || deps.some((value, i) => value !== dependencies[index][i])) { dependencies[index] = deps; effects.push(fn); } },
|
||||
};
|
||||
const compiled = ts.transpileModule(fs.readFileSync(pagePath, 'utf8'), {
|
||||
fileName: pagePath,
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true,
|
||||
inlineSourceMap: true, inlineSources: true,
|
||||
},
|
||||
}).outputText;
|
||||
const loaded = new Module(pagePath, module);
|
||||
loaded.filename = pagePath;
|
||||
loaded.paths = Module._nodeModulePaths(path.dirname(pagePath));
|
||||
loaded.require = (name) => Object.hasOwn(mocks, name) ? mocks[name] : module.require(name);
|
||||
loaded._compile(compiled, pagePath);
|
||||
|
||||
function descendants(node) {
|
||||
if (!node || typeof node !== 'object') return [];
|
||||
return [node, ...[node.props?.children].flat(Infinity).flatMap(descendants)];
|
||||
}
|
||||
return () => {
|
||||
const nodes = descendants(loaded.exports.default());
|
||||
return {
|
||||
gallery: nodes.find((node) => node.type === gallery).props,
|
||||
controls: nodes.filter((node) => node.props.onChange),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function assertCompetitorOnly(props) {
|
||||
assert.equal(props.sourceType, 'competitor', '竞品分析必须排除自有账号作品');
|
||||
assert.equal(props.sourceId, undefined, '竞品分析应包含所有监测账号,而非限定某个账号');
|
||||
}
|
||||
|
||||
test('竞品分析首次加载只查询监测账号作品', () => {
|
||||
const render = createPage();
|
||||
assertCompetitorOnly(render().gallery);
|
||||
});
|
||||
|
||||
test('调整及清空各项筛选后仍只查询监测账号作品', () => {
|
||||
const render = createPage();
|
||||
const date = { format: (pattern) => {
|
||||
assert.equal(pattern, 'YYYY-MM-DD');
|
||||
return '2026-05-18';
|
||||
const DatePicker = component('DatePicker');
|
||||
DatePicker.RangePicker = component('RangePicker');
|
||||
const antd = Object.fromEntries(['Card', 'Flex', 'Form', 'InputNumber', 'Select', 'Tabs', 'Button', 'Alert'].map(name => [name, component(name)]));
|
||||
antd.DatePicker = DatePicker;
|
||||
antd.Form.Item = component('Form.Item');
|
||||
antd.Typography = { Text: component('Text') };
|
||||
const message = { error: value => errors.push(value) };
|
||||
antd.App = { useApp: () => ({ message }) };
|
||||
const context = { exports: {}, console, React, require(name) {
|
||||
if (name === 'react') return { ...React, default: React };
|
||||
if (name === '@/utils/helpers') return { conflictMessage: error => error.message };
|
||||
if (name === 'antd') return antd;
|
||||
if (name === '@ant-design/pro-components') return { PageContainer: component('PageContainer') };
|
||||
if (name === '@/components/creator/WorksGallery') return { __esModule: true, default: component('WorksGallery') };
|
||||
if (name === '@/services/api') return { creatorGet: async endpoint => { requests.push(endpoint); if (failure) throw new Error('账号读取失败'); return response ?? (endpoint.endsWith('/accounts') ? [{ id: 'owned-1', name: '我的账号', platform_account_key: 'uid-1' }] : [{ id: 'competitor-1', nickname: '竞品账号', platform_account_key: 'uid-2' }]); } };
|
||||
throw new Error(`Unexpected dependency: ${name}`);
|
||||
} };
|
||||
const cases = [
|
||||
['最低点赞', 'min_likes', 12, 12],
|
||||
['最低评论', 'min_comments', 3, 3],
|
||||
['最低转发', 'min_shares', 5, 5],
|
||||
['全部状态', 'published_at_status', 'verified', 'verified'],
|
||||
['发布开始日期', 'published_after', date, '2026-05-18T00:00:00Z'],
|
||||
['发布结束日期', 'published_before', date, '2026-05-18T23:59:59Z'],
|
||||
];
|
||||
for (const [placeholder, key, value, expected] of cases) {
|
||||
render().controls.find((control) => control.props.placeholder === placeholder).props.onChange(value);
|
||||
assertCompetitorOnly(render().gallery);
|
||||
assert.equal(render().gallery.extraFilters[key], expected);
|
||||
}
|
||||
for (const [placeholder, key] of cases) {
|
||||
const clearValue = key === 'published_at_status' ? undefined : null;
|
||||
render().controls.find((control) => control.props.placeholder === placeholder).props.onChange(clearValue);
|
||||
assertCompetitorOnly(render().gallery);
|
||||
assert.equal(render().gallery.extraFilters[key], undefined);
|
||||
}
|
||||
const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { compilerOptions: { jsx: ts.JsxEmit.React, module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }, fileName: filename }).outputText;
|
||||
vm.runInNewContext(compiled, context, { filename });
|
||||
return { requests, errors, render(panel = true) { cursor = 0; return panel ? context.exports.WorkAnalysisTab({ sourceType }) : context.exports.default(); }, async flush() { const cleanups = effects.splice(0).map(fn => fn()); await new Promise(resolve => setImmediate(resolve)); return cleanups; } };
|
||||
}
|
||||
function nodes(tree, name) {
|
||||
if (!tree || typeof tree !== 'object') return [];
|
||||
if (Array.isArray(tree)) return tree.flatMap(child => nodes(child, name));
|
||||
return [...(tree.name === name ? [tree] : []), ...nodes(tree.props?.children, name), ...nodes(tree.props?.extra, name)];
|
||||
}
|
||||
function control(tree, name, label) { return nodes(tree, name).find(node => node.props['aria-label'] === label); }
|
||||
function plain(value) { return JSON.parse(JSON.stringify(value)); }
|
||||
|
||||
test('作品分析只有两个分开挂载的来源 TAB', () => {
|
||||
const page = harness().render(false);
|
||||
const tabs = nodes(page, 'Tabs')[0];
|
||||
assert.deepEqual(plain(tabs.props.items.map(({ key, label }) => ({ key, label }))), [{ key: 'competitor', label: '竞品账号' }, { key: 'owned', label: '我的账号' }]);
|
||||
assert.equal(tabs.props.destroyOnHidden, false);
|
||||
});
|
||||
|
||||
for (const sourceType of ['competitor', 'owned']) {
|
||||
test(`${sourceType}: 筛选、排序、账号、重置与指标范围`, async () => {
|
||||
const app = harness({ sourceType });
|
||||
let tree = app.render();
|
||||
assert.equal(nodes(tree, 'WorksGallery')[0].props.sourceType, sourceType);
|
||||
assert.deepEqual(plain(nodes(tree, 'WorksGallery')[0].props.extraFilters), { growth_hours: 24, sort_by: 'published_at', sort_order: 'desc' });
|
||||
await app.flush();
|
||||
tree = app.render();
|
||||
assert.deepEqual(app.requests, [sourceType === 'owned' ? '/creator/accounts' : '/creator/competitors']);
|
||||
const account = control(tree, 'Select', '所属账号');
|
||||
assert.equal(account.props.options[0].value, `${sourceType}-1`);
|
||||
account.props.onChange(`${sourceType}-1`);
|
||||
for (const [label, value] of [['最低点赞', 0], ['最低评论', 2], ['最低分享', 3], ['最低收藏', 4], ['最低24h点赞增量', 5]]) control(tree, 'InputNumber', label).props.onChange(value);
|
||||
control(tree, 'Select', '时间核验状态').props.onChange('verified');
|
||||
control(tree, 'Select', '排序指标').props.onChange('likes_growth');
|
||||
control(tree, 'Select', '排序方向').props.onChange('asc');
|
||||
const dates = [{ startOf: () => ({ toISOString: () => '2026-01-01T00:00:00.000Z' }) }, { endOf: () => ({ toISOString: () => '2026-01-03T23:59:59.999Z' }) }];
|
||||
control(tree, 'RangePicker', '发布时间').props.onChange(dates);
|
||||
if (sourceType === 'owned') control(tree, 'InputNumber', '最低观看').props.onChange(0);
|
||||
else assert.equal(control(tree, 'InputNumber', '最低观看'), undefined);
|
||||
tree = app.render();
|
||||
const gallery = nodes(tree, 'WorksGallery')[0];
|
||||
assert.equal(gallery.props.sourceId, `${sourceType}-1`);
|
||||
assert.deepEqual(plain(gallery.props.extraFilters), { growth_hours: 24, sort_by: 'likes_growth', sort_order: 'asc', published_after: '2026-01-01T00:00:00.000Z', published_before: '2026-01-03T23:59:59.999Z', published_at_status: 'verified', min_likes: 0, min_comments: 2, min_shares: 3, min_collect_count: 4, min_likes_growth: 5, ...(sourceType === 'owned' ? { min_play_count: 0 } : {}) });
|
||||
const sorts = control(tree, 'Select', '排序指标').props.options.map(option => option.value);
|
||||
assert.equal(sorts.includes('play_count'), sourceType === 'owned');
|
||||
assert(sorts.includes('collect_count'));
|
||||
nodes(tree, 'Button').find(node => node.props.children.includes('重置')).props.onClick();
|
||||
tree = app.render();
|
||||
assert.equal(nodes(tree, 'WorksGallery')[0].props.sourceId, undefined);
|
||||
assert.deepEqual(plain(nodes(tree, 'WorksGallery')[0].props.extraFilters), { growth_hours: 24, sort_by: 'published_at', sort_order: 'desc' });
|
||||
});
|
||||
}
|
||||
|
||||
test('账号读取失败明确提示,支持重试,不显示为正常空列表', async () => {
|
||||
const app = harness({ failure: true });
|
||||
app.render(); await app.flush();
|
||||
const tree = app.render();
|
||||
assert.deepEqual(app.errors, ['账号读取失败']);
|
||||
const alert = nodes(tree, 'Alert')[0];
|
||||
assert.equal(alert.props.type, 'error');
|
||||
nodes(alert.props.action, 'Button')[0].props.onClick();
|
||||
app.render(); await app.flush();
|
||||
assert.equal(app.requests.length, 2);
|
||||
});
|
||||
|
||||
test('TAB 卸载后忽略账号请求结果', async () => {
|
||||
let resolve;
|
||||
const app = harness({ response: new Promise(done => { resolve = done; }) });
|
||||
app.render();
|
||||
const cleanups = await app.flush();
|
||||
cleanups.forEach(cleanup => cleanup?.());
|
||||
resolve([{ id: 'late', nickname: '卸载后返回的账号', platform_account_key: 'late' }]);
|
||||
await app.flush();
|
||||
assert.equal(control(app.render(), 'Select', '所属账号').props.options.length, 0);
|
||||
assert.equal(app.errors.length, 0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user