feat: add source-separated comment aggregation page
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func creatorCommentAggregationFilter(c fiber.Ctx) (creator.CommentAggregationFilter, error) {
|
||||
hours, err := strconv.Atoi(c.Query("hours", "24"))
|
||||
if err != nil {
|
||||
return creator.CommentAggregationFilter{}, creator.ErrInvalid
|
||||
}
|
||||
filter := creator.CommentAggregationFilter{SourceType: strings.TrimSpace(c.Query("source_type")), SourceID: strings.TrimSpace(c.Query("source_id")), Hours: hours}
|
||||
return filter, filter.Validate()
|
||||
}
|
||||
|
||||
func registerCommentAggregation(app *fiber.App, store *creator.Store) {
|
||||
app.Get("/api/creator/comments/aggregation", func(c fiber.Ctx) error {
|
||||
filter, err := creatorCommentAggregationFilter(c)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
page, pageSize, paged, err := creatorPageQuery(c)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
if !paged {
|
||||
page, pageSize = 1, 25
|
||||
}
|
||||
result, err := store.ListCommentAggregationPage(c.Context(), filter, page, pageSize, time.Now().UTC())
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
return c.JSON(result)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func TestCreatorCommentAggregationRoutes(t *testing.T) {
|
||||
app := fiber.New()
|
||||
registerCreatorWithServices(app, nil, nil, nil, nil)
|
||||
for _, query := range []string{"", "source_type=other", "source_type=owned&hours=bad", "source_type=owned&hours=2", "source_type=owned&page=bad", "source_type=owned&page=0", "source_type=owned&page_size=101"} {
|
||||
response, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/creator/comments/aggregation?"+query, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("query=%s status=%d", query, response.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorCommentAggregationRouteIntegration(t *testing.T) {
|
||||
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("set CREATORHUB_POSTGRES_TEST_URL")
|
||||
}
|
||||
store, _, ctx := openCreatorIntegrationStoreForAPITest(t, databaseURL)
|
||||
now := time.Now().UTC()
|
||||
competitor, err := store.UpsertCompetitor(ctx, creator.CompetitorInput{Platform: creator.PlatformDouyin, PlatformAccountKey: "aggregation-rival", Nickname: "竞品账号", HomepageURL: "https://www.douyin.com/user/aggregation-rival"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work, _, err := store.UpsertWork(ctx, creator.WorkInput{Platform: creator.PlatformDouyin, WorkKey: "aggregation-work", SourceType: creator.SourceCompetitor, SourceID: competitor.ID, Title: "作品"}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
comment, _, err := store.SaveComment(ctx, creator.CommentInput{Platform: creator.PlatformDouyin, WorkID: work.ID, CommentKey: "aggregation-comment", Content: "评论", CommentType: "top_level", PublishedAt: &now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := fiber.New()
|
||||
registerCreatorWithServices(app, store, nil, nil, nil)
|
||||
response, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/creator/comments/aggregation?source_type=competitor&source_id="+competitor.ID, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d", response.StatusCode)
|
||||
}
|
||||
var page creator.Page[creator.AggregatedComment]
|
||||
if err := json.NewDecoder(response.Body).Decode(&page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Page != 1 || page.PageSize != 25 || page.Total != 1 || len(page.Data) != 1 || page.Data[0].ID != comment.ID || page.Data[0].SourceName != "竞品账号" {
|
||||
t.Fatalf("page=%+v", page)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err = app.Test(httptest.NewRequest(http.MethodGet, "/api/creator/comments/aggregation?source_type=competitor", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("database failure status=%d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorCommentAggregationFilter(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/", func(c fiber.Ctx) error {
|
||||
filter, err := creatorCommentAggregationFilter(c)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
return c.JSON(filter)
|
||||
})
|
||||
for _, query := range []string{"source_type=owned", "source_type=competitor&source_id=account-1&hours=6", "source_type=owned&hours=168"} {
|
||||
response, err := app.Test(httptest.NewRequest(http.MethodGet, "/?"+query, nil))
|
||||
if err != nil || response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("query=%s response=%v err=%v", query, response, err)
|
||||
}
|
||||
var filter creator.CommentAggregationFilter
|
||||
if err := json.NewDecoder(response.Body).Decode(&filter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if query == "source_type=owned" && filter.Hours != 24 {
|
||||
t.Fatalf("default=%+v", filter)
|
||||
}
|
||||
if query == "source_type=competitor&source_id=account-1&hours=6" && (filter.SourceType != creator.SourceCompetitor || filter.SourceID != "account-1" || filter.Hours != 6) {
|
||||
t.Fatalf("filter=%+v", filter)
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"", "source_type=other", "source_type=owned&hours=bad", "source_type=owned&hours=0", "source_type=owned&hours=-1", "source_type=owned&hours=2", "source_type=owned&hours=169"} {
|
||||
response, err := app.Test(httptest.NewRequest(http.MethodGet, "/?"+query, nil))
|
||||
if err != nil || response.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("query=%s response=%v err=%v", query, response, err)
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
}
|
||||
@@ -440,6 +440,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
c.Set(fiber.HeaderCacheControl, "private, max-age=86400")
|
||||
return c.SendFile(path)
|
||||
})
|
||||
registerCommentAggregation(app, store)
|
||||
app.Get("/api/creator/comments", func(c fiber.Ctx) error {
|
||||
page, pageSize, paged, err := creatorPageQuery(c)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package creator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CommentAggregationFilter uses comment publication time, not collection time.
|
||||
type CommentAggregationFilter struct {
|
||||
SourceType string
|
||||
SourceID string
|
||||
Hours int
|
||||
}
|
||||
|
||||
func (filter CommentAggregationFilter) Validate() error {
|
||||
if filter.SourceType != SourceOwned && filter.SourceType != SourceCompetitor {
|
||||
return ErrInvalid
|
||||
}
|
||||
switch filter.Hours {
|
||||
case 1, 6, 12, 24, 72, 120, 168:
|
||||
return nil
|
||||
default:
|
||||
return ErrInvalid
|
||||
}
|
||||
}
|
||||
|
||||
// AggregatedComment adds the owning account and work to an already collected comment.
|
||||
type AggregatedComment struct {
|
||||
ID string `json:"id"`
|
||||
AuthorUID string `json:"author_uid"`
|
||||
AuthorName string `json:"author_name"`
|
||||
Content string `json:"content"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
SourceAccountKey string `json:"source_account_key"`
|
||||
WorkID string `json:"work_id"`
|
||||
WorkKey string `json:"work_key"`
|
||||
WorkTitle string `json:"work_title"`
|
||||
WorkURL string `json:"work_url"`
|
||||
}
|
||||
|
||||
func buildCommentAggregationQuery(filter CommentAggregationFilter, now time.Time) (string, []any, error) {
|
||||
if err := filter.Validate(); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
query := ` FROM creator_comment c
|
||||
JOIN creator_work w ON w.id = c.work_id
|
||||
LEFT JOIN social_account a ON w.source_type = 'owned' AND a.account_id = w.source_id
|
||||
LEFT JOIN creator_competitor rival ON w.source_type = 'competitor' AND rival.competitor_id = w.source_id
|
||||
WHERE w.source_type = $1 AND c.published_at >= $2 AND c.published_at <= $3`
|
||||
args := []any{filter.SourceType, now.UTC().Add(-time.Duration(filter.Hours) * time.Hour), now.UTC()}
|
||||
if filter.SourceID != "" {
|
||||
args = append(args, filter.SourceID)
|
||||
query += " AND w.source_id = $4"
|
||||
}
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListCommentAggregationPage(ctx context.Context, filter CommentAggregationFilter, page, pageSize int, now time.Time) (Page[AggregatedComment], error) {
|
||||
offset, limit, err := pageBounds(page, pageSize)
|
||||
if err != nil {
|
||||
return Page[AggregatedComment]{}, err
|
||||
}
|
||||
from, args, err := buildCommentAggregationQuery(filter, now)
|
||||
if err != nil {
|
||||
return Page[AggregatedComment]{}, err
|
||||
}
|
||||
result := Page[AggregatedComment]{Data: []AggregatedComment{}, Page: page, PageSize: pageSize}
|
||||
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*)"+from, args...).Scan(&result.Total); err != nil {
|
||||
return Page[AggregatedComment]{}, fmt.Errorf("count comment aggregation source=%s account=%s hours=%d: %w", filter.SourceType, filter.SourceID, filter.Hours, err)
|
||||
}
|
||||
query := `SELECT c.comment_id, c.author_uid, c.author_name, c.content, c.published_at,
|
||||
w.source_type, w.source_id,
|
||||
CASE WHEN w.source_type = 'owned' THEN COALESCE(a.name, '') ELSE COALESCE(rival.nickname, '') END,
|
||||
CASE WHEN w.source_type = 'owned' THEN COALESCE(a.platform_account_key, '') ELSE COALESCE(rival.platform_account_key, '') END,
|
||||
w.work_id, w.work_key, w.title, w.original_url` + from
|
||||
query += fmt.Sprintf(" ORDER BY c.published_at DESC, c.id DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return Page[AggregatedComment]{}, fmt.Errorf("list comment aggregation source=%s account=%s hours=%d page=%d: %w", filter.SourceType, filter.SourceID, filter.Hours, page, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item AggregatedComment
|
||||
if err := rows.Scan(&item.ID, &item.AuthorUID, &item.AuthorName, &item.Content, &item.PublishedAt,
|
||||
&item.SourceType, &item.SourceID, &item.SourceName, &item.SourceAccountKey,
|
||||
&item.WorkID, &item.WorkKey, &item.WorkTitle, &item.WorkURL); err != nil {
|
||||
return Page[AggregatedComment]{}, fmt.Errorf("scan comment aggregation: %w", err)
|
||||
}
|
||||
item.PublishedAt = item.PublishedAt.UTC()
|
||||
result.Data = append(result.Data, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Page[AggregatedComment]{}, fmt.Errorf("read comment aggregation: %w", err)
|
||||
}
|
||||
result.HasNext = offset+len(result.Data) < result.Total
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package creator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildCommentAggregationQuery(t *testing.T) {
|
||||
now := time.Date(2026, 9, 20, 12, 0, 0, 0, time.FixedZone("local", 8*3600))
|
||||
for _, source := range []string{SourceOwned, SourceCompetitor} {
|
||||
for _, hours := range []int{1, 6, 12, 24, 72, 120, 168} {
|
||||
query, args, err := buildCommentAggregationQuery(CommentAggregationFilter{SourceType: source, SourceID: "account-1", Hours: hours}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, clause := range []string{"w.source_type = $1", "c.published_at >= $2", "c.published_at <= $3", "w.source_id = $4", "JOIN creator_work w ON w.id = c.work_id"} {
|
||||
if !strings.Contains(query, clause) {
|
||||
t.Fatalf("missing %q in %s", clause, query)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(args, []any{source, now.UTC().Add(-time.Duration(hours) * time.Hour), now.UTC(), "account-1"}) {
|
||||
t.Fatalf("args = %#v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
query, args, err := buildCommentAggregationQuery(CommentAggregationFilter{SourceType: SourceOwned, Hours: 24}, now)
|
||||
if err != nil || len(args) != 3 || strings.Contains(query, "w.source_id =") {
|
||||
t.Fatalf("all accounts query=%s args=%v err=%v", query, args, err)
|
||||
}
|
||||
for _, filter := range []CommentAggregationFilter{{Hours: 24}, {SourceType: "other", Hours: 24}, {SourceType: SourceOwned}, {SourceType: SourceOwned, Hours: -1}, {SourceType: SourceOwned, Hours: 2}, {SourceType: SourceOwned, Hours: 169}} {
|
||||
if _, _, err := buildCommentAggregationQuery(filter, now); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("filter=%+v err=%v", filter, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentAggregationPageIntegration(t *testing.T) {
|
||||
store, accounts, ctx := openCreatorIntegrationStore(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
ownedID := createIntegrationAccount(t, ctx, accounts, "comments-owned")
|
||||
competitor, err := store.UpsertCompetitor(ctx, CompetitorInput{Platform: PlatformDouyin, PlatformAccountKey: "comments-competitor", HomepageURL: "https://www.douyin.com/user/comments-competitor", Nickname: "竞品昵称"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addWork := func(key, source, id string) Work {
|
||||
work, _, err := store.UpsertWork(ctx, WorkInput{Platform: PlatformDouyin, WorkKey: key, SourceType: source, SourceID: id, Title: key, OriginalURL: "https://www.douyin.com/video/" + key}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return work
|
||||
}
|
||||
owned := addWork("owned-comments", SourceOwned, ownedID)
|
||||
rival := addWork("competitor-comments", SourceCompetitor, competitor.ID)
|
||||
addComment := func(work Work, key string, at *time.Time) Comment {
|
||||
comment, _, err := store.SaveComment(ctx, CommentInput{Platform: PlatformDouyin, WorkID: work.ID, CommentKey: key, AuthorUID: "reader-uid", AuthorName: "评论者", Content: key, PublishedAt: at, CommentType: "top_level"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return comment
|
||||
}
|
||||
newest := addComment(owned, "newest", &now)
|
||||
boundary := now.Add(-time.Hour)
|
||||
addComment(owned, "boundary", &boundary)
|
||||
addComment(owned, "same-time", &boundary)
|
||||
addComment(owned, "unknown", nil)
|
||||
future := now.Add(time.Second)
|
||||
addComment(owned, "future", &future)
|
||||
for _, hours := range []int{6, 12, 24, 72, 120, 168} {
|
||||
at := now.Add(-time.Duration(hours) * time.Hour)
|
||||
addComment(owned, "age-"+at.Format(time.RFC3339), &at)
|
||||
}
|
||||
old := now.Add(-168*time.Hour - time.Second)
|
||||
addComment(owned, "old", &old)
|
||||
rivalComment := addComment(rival, "rival", &now)
|
||||
for index, hours := range []int{1, 6, 12, 24, 72, 120, 168} {
|
||||
page, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceOwned, Hours: hours}, 1, 100, now)
|
||||
if err != nil || page.Total != 3+index || len(page.Data) != 3+index {
|
||||
t.Fatalf("hours=%d page=%+v err=%v", hours, page, err)
|
||||
}
|
||||
if page.Data[0].ID != newest.ID || page.Data[0].SourceID != ownedID || page.Data[0].SourceType != SourceOwned || page.Data[0].SourceName == "" || page.Data[0].SourceAccountKey == "" || page.Data[0].WorkTitle != owned.Title || page.Data[0].WorkURL != owned.OriginalURL || page.Data[0].AuthorUID != "reader-uid" {
|
||||
t.Fatalf("metadata=%+v", page.Data[0])
|
||||
}
|
||||
}
|
||||
first, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceOwned, SourceID: ownedID, Hours: 1}, 1, 2, now)
|
||||
if err != nil || first.Total != 3 || len(first.Data) != 2 || !first.HasNext {
|
||||
t.Fatalf("first=%+v err=%v", first, err)
|
||||
}
|
||||
second, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceOwned, Hours: 1}, 2, 2, now)
|
||||
if err != nil || second.Total != 3 || len(second.Data) != 1 || second.HasNext || second.Data[0].ID == first.Data[1].ID {
|
||||
t.Fatalf("second=%+v err=%v", second, err)
|
||||
}
|
||||
for _, filter := range []CommentAggregationFilter{{SourceType: SourceOwned, SourceID: competitor.ID, Hours: 1}, {SourceType: SourceCompetitor, SourceID: ownedID, Hours: 1}} {
|
||||
page, err := store.ListCommentAggregationPage(ctx, filter, 1, 25, now)
|
||||
if err != nil || page.Total != 0 || page.Data == nil || len(page.Data) != 0 {
|
||||
t.Fatalf("cross source=%+v err=%v", page, err)
|
||||
}
|
||||
}
|
||||
page, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceCompetitor, Hours: 1}, 1, 25, now)
|
||||
if err != nil || page.Total != 1 || page.Data[0].ID != rivalComment.ID || page.Data[0].SourceName != "竞品昵称" || page.Data[0].SourceAccountKey != "comments-competitor" {
|
||||
t.Fatalf("competitor=%+v err=%v", page, err)
|
||||
}
|
||||
page, err = store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceOwned, Hours: 1}, 10, 25, now)
|
||||
if err != nil || page.Total != 3 || page.Data == nil || len(page.Data) != 0 {
|
||||
t.Fatalf("out of range=%+v err=%v", page, err)
|
||||
}
|
||||
for _, pagination := range [][2]int{{0, 25}, {1, 0}, {1, 101}} {
|
||||
if _, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: SourceOwned, Hours: 1}, pagination[0], pagination[1], now); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("pagination=%v err=%v", pagination, err)
|
||||
}
|
||||
}
|
||||
if _, err := store.ListCommentAggregationPage(ctx, CommentAggregationFilter{SourceType: "invalid", Hours: 1}, 1, 25, now); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Form,
|
||||
Select,
|
||||
Table,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { TableColumnsType } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { creatorGet, getList } from "@/services/api";
|
||||
import { conflictMessage } from "@/utils/helpers";
|
||||
|
||||
type SourceType = "owned" | "competitor";
|
||||
type Account = {
|
||||
id: string;
|
||||
name?: string;
|
||||
nickname?: string;
|
||||
platform_account_key?: string;
|
||||
};
|
||||
type AggregatedComment = {
|
||||
id: string;
|
||||
author_uid: string;
|
||||
author_name: string;
|
||||
content: string;
|
||||
published_at: string;
|
||||
source_id: string;
|
||||
source_name: string;
|
||||
source_account_key: string;
|
||||
work_key: string;
|
||||
work_title: string;
|
||||
work_url: string;
|
||||
};
|
||||
const timeOptions = [
|
||||
{ value: 1, label: "最近 1 小时" },
|
||||
{ value: 6, label: "最近 6 小时" },
|
||||
{ value: 12, label: "最近 12 小时" },
|
||||
{ value: 24, label: "最近 1 天" },
|
||||
{ value: 72, label: "最近 3 天" },
|
||||
{ value: 120, label: "最近 5 天" },
|
||||
{ value: 168, label: "最近 7 天" },
|
||||
];
|
||||
const columns: TableColumnsType<AggregatedComment> = [
|
||||
{
|
||||
title: "评论发布时间",
|
||||
dataIndex: "published_at",
|
||||
width: 180,
|
||||
render: (value: string) =>
|
||||
new Date(value).toLocaleString("zh-CN", { hour12: false }),
|
||||
},
|
||||
{
|
||||
title: "评论者",
|
||||
width: 180,
|
||||
render: (_, item) => (
|
||||
<Flex vertical>
|
||||
<Typography.Text>
|
||||
{item.author_name || item.author_uid || "—"}
|
||||
</Typography.Text>
|
||||
{item.author_uid && (
|
||||
<Typography.Text type="secondary">{`UID:${item.author_uid}`}</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{ title: "评论内容", dataIndex: "content", width: 320 },
|
||||
{
|
||||
title: "所属账号",
|
||||
width: 200,
|
||||
render: (_, item) => (
|
||||
<Flex vertical>
|
||||
<Typography.Text>
|
||||
{item.source_name || item.source_account_key || item.source_id}
|
||||
</Typography.Text>
|
||||
{item.source_account_key && (
|
||||
<Typography.Text type="secondary">{`UID:${item.source_account_key}`}</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "对应作品",
|
||||
width: 240,
|
||||
render: (_, item) =>
|
||||
item.work_url ? (
|
||||
<Typography.Link href={item.work_url} target="_blank" rel="noreferrer">
|
||||
{item.work_title || item.work_key}
|
||||
</Typography.Link>
|
||||
) : (
|
||||
<Typography.Text>{item.work_title || item.work_key}</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function CommentAggregationTab({
|
||||
sourceType,
|
||||
}: {
|
||||
sourceType: SourceType;
|
||||
}) {
|
||||
const [filters, setFilters] = useState<{ sourceId?: string; hours: number }>({
|
||||
hours: 24,
|
||||
});
|
||||
const [pagination, setPagination] = useState({ page: 1, pageSize: 25 });
|
||||
const [comments, setComments] = useState<AggregatedComment[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [accountsPending, setAccountsPending] = useState(true);
|
||||
const [accountsError, setAccountsError] = useState<unknown>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const endpoint =
|
||||
sourceType === "owned" ? "/creator/accounts" : "/creator/competitors";
|
||||
setAccountsPending(true);
|
||||
setAccountsError(null);
|
||||
creatorGet(endpoint)
|
||||
.then((result: Account[]) => {
|
||||
if (active) setAccounts(result);
|
||||
})
|
||||
.catch((cause) => {
|
||||
console.error("读取评论聚合账号失败", {
|
||||
sourceType,
|
||||
endpoint,
|
||||
error: cause,
|
||||
});
|
||||
if (active) setAccountsError(cause);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setAccountsPending(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [sourceType, revision]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const query = {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
filters: {
|
||||
source_type: sourceType,
|
||||
source_id: filters.sourceId,
|
||||
hours: filters.hours,
|
||||
},
|
||||
};
|
||||
setPending(true);
|
||||
setError(null);
|
||||
setComments([]);
|
||||
setTotal(0);
|
||||
getList<AggregatedComment>({
|
||||
resource: "creator-comment-aggregation",
|
||||
...query,
|
||||
})
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
setComments(result.data);
|
||||
setTotal(result.total);
|
||||
}
|
||||
})
|
||||
.catch((cause) => {
|
||||
console.error("读取评论聚合列表失败", { ...query, error: cause });
|
||||
if (active) setError(cause);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setPending(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
sourceType,
|
||||
filters.sourceId,
|
||||
filters.hours,
|
||||
pagination.page,
|
||||
pagination.pageSize,
|
||||
revision,
|
||||
]);
|
||||
|
||||
const changeFilters = (next: { sourceId?: string; hours: number }) => {
|
||||
setFilters(next);
|
||||
setPagination((current) => ({ ...current, page: 1 }));
|
||||
};
|
||||
return (
|
||||
<Card>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text>
|
||||
{pending ? "正在读取评论…" : `共 ${total} 条评论`}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={pending || accountsPending}
|
||||
onClick={() => setRevision((current) => current + 1)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Flex>
|
||||
<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: 220 }}
|
||||
value={filters.sourceId}
|
||||
options={accounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: `${(sourceType === "owned" ? account.name : account.nickname) || account.platform_account_key || account.id}${account.platform_account_key ? `(${account.platform_account_key})` : ""}`,
|
||||
}))}
|
||||
onChange={(sourceId) => changeFilters({ ...filters, sourceId })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="评论发布时间">
|
||||
<Select
|
||||
aria-label="评论发布时间"
|
||||
style={{ width: 160 }}
|
||||
value={filters.hours}
|
||||
options={timeOptions}
|
||||
onChange={(hours) => changeFilters({ ...filters, hours })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button onClick={() => changeFilters({ hours: 24 })}>重置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Typography.Text type="secondary">
|
||||
按评论发布时间筛选,仅展示已采集评论;未记录发布时间的评论不在结果中。最新评论在前。
|
||||
</Typography.Text>
|
||||
{Boolean(accountsError) && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title="账号读取失败"
|
||||
description={conflictMessage(
|
||||
accountsError,
|
||||
"无法读取账号,请点击刷新重试。",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{Boolean(error) && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title="评论读取失败"
|
||||
description={conflictMessage(
|
||||
error,
|
||||
"无法读取评论,请点击刷新重试。",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Table<AggregatedComment>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={comments}
|
||||
loading={pending}
|
||||
scroll={{ x: 1120 }}
|
||||
locale={{
|
||||
emptyText: error
|
||||
? "评论读取失败,请点击刷新重试"
|
||||
: "所选时间内暂无已采集评论",
|
||||
}}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [25, 50, 100],
|
||||
}}
|
||||
onChange={(next) =>
|
||||
setPagination((current) => ({
|
||||
page:
|
||||
next.pageSize !== current.pageSize ? 1 : (next.current ?? 1),
|
||||
pageSize: next.pageSize ?? current.pageSize,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CommentAggregationPage() {
|
||||
return (
|
||||
<Tabs
|
||||
destroyOnHidden={false}
|
||||
items={[
|
||||
{
|
||||
key: "owned",
|
||||
label: "我的作品评论",
|
||||
children: <CommentAggregationTab sourceType="owned" />,
|
||||
},
|
||||
{
|
||||
key: "competitor",
|
||||
label: "竞品作品评论",
|
||||
children: <CommentAggregationTab sourceType="competitor" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const resourcePaths = {
|
||||
'creator-competitor-share-jobs': '/creator/competitor-share-jobs',
|
||||
'creator-works': '/creator/works',
|
||||
'creator-comments': '/creator/comments',
|
||||
'creator-comment-aggregation': '/creator/comments/aggregation',
|
||||
'creator-leads': '/creator/leads',
|
||||
'creator-rules': '/creator/rules',
|
||||
} as const;
|
||||
@@ -23,6 +24,7 @@ const filterKeys: Partial<Record<Resource, string[]>> = {
|
||||
'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', 'min_collect_count', 'min_play_count', 'sort_by', 'sort_order', 'growth_hours', 'min_likes_growth'],
|
||||
'creator-comments': ['platform', 'work_id'],
|
||||
'creator-comment-aggregation': ['source_type', 'source_id', 'hours'],
|
||||
'creator-leads': ['platform'],
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
|
||||
{ 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/comments', title: '评论聚合', subtitle: '分别查看我的作品与竞品作品的已采集评论,按所属账号和评论发布时间筛选。' },
|
||||
{ test: (p) => p === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' },
|
||||
{ test: (p) => p.startsWith('/creator/competitors/'), title: '竞品账号详情', subtitle: '查看竞品画像与作品列表,点击作品可追溯指标统计。' },
|
||||
{ test: (p) => p === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' },
|
||||
@@ -40,6 +41,7 @@ export const menu: MenuDataItem[] = [
|
||||
{ path: '/accounts', name: '我的账号', icon: <UserOutlined /> },
|
||||
{ path: '/accounts/monitoring', name: '监控账号', icon: <EyeOutlined /> },
|
||||
{ path: '/creator/competitors', name: '作品分析', icon: <LineChartOutlined /> },
|
||||
{ path: '/creator/comments', name: '评论聚合', icon: <CommentOutlined /> },
|
||||
{ path: '/creator/settings', name: '采集设置', icon: <SettingOutlined /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const Module = require("node:module");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const ts = require("../node_modules/typescript");
|
||||
const target = path.resolve(__dirname, "../src/services/api.ts");
|
||||
let response;
|
||||
const calls = [];
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (parent?.filename === target && request === "@/requestErrorConfig")
|
||||
return {
|
||||
request: async (url) => {
|
||||
calls.push(url);
|
||||
return response;
|
||||
},
|
||||
};
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
const source = ts.transpileModule(fs.readFileSync(target, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const mod = new Module(target, module);
|
||||
mod.filename = target;
|
||||
mod.paths = module.paths;
|
||||
mod._compile(source, target);
|
||||
const { getList, resourcePaths } = mod.exports;
|
||||
|
||||
test("comment aggregation transmits source, account, hours and pagination to dedicated endpoint", async () => {
|
||||
response = { data: [{ id: "comment-1" }], total: 201, has_next: true };
|
||||
const result = await getList({
|
||||
resource: "creator-comment-aggregation",
|
||||
page: 3,
|
||||
pageSize: 25,
|
||||
filters: {
|
||||
source_type: "owned",
|
||||
source_id: "account/1",
|
||||
hours: 6,
|
||||
unsupported: "ignored",
|
||||
},
|
||||
});
|
||||
const query = new URL(calls.at(-1), "https://example.test");
|
||||
assert.equal(query.pathname, "/creator/comments/aggregation");
|
||||
assert.equal(query.searchParams.get("source_type"), "owned");
|
||||
assert.equal(query.searchParams.get("source_id"), "account/1");
|
||||
assert.equal(query.searchParams.get("hours"), "6");
|
||||
assert.equal(query.searchParams.get("page"), "3");
|
||||
assert.equal(query.searchParams.get("page_size"), "25");
|
||||
assert.equal(query.searchParams.has("unsupported"), false);
|
||||
assert.deepEqual(result, {
|
||||
data: [{ id: "comment-1" }],
|
||||
total: 201,
|
||||
hasNext: true,
|
||||
});
|
||||
response = { data: [], total: 0, has_next: false };
|
||||
assert.deepEqual(
|
||||
await getList({
|
||||
resource: "creator-comment-aggregation",
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
filters: { source_type: "competitor", source_id: undefined, hours: 24 },
|
||||
}),
|
||||
{ data: [], total: 0, hasNext: false },
|
||||
);
|
||||
assert.equal(
|
||||
new URL(calls.at(-1), "https://example.test").searchParams.has("source_id"),
|
||||
false,
|
||||
);
|
||||
assert.equal(resourcePaths["creator-comments"], "/creator/comments");
|
||||
});
|
||||
|
||||
test("menu and page header expose comment aggregation", () => {
|
||||
const metadata = fs.readFileSync(
|
||||
path.resolve(__dirname, "../src/utils/metadata.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(metadata, /path: '\/creator\/comments', name: '评论聚合'/);
|
||||
assert.match(metadata, /p === '\/creator\/comments', title: '评论聚合'/);
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const Module = require("node:module");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const ts = require("../node_modules/typescript");
|
||||
|
||||
const target = path.resolve(
|
||||
__dirname,
|
||||
"../src/pages/creator/comments/index.tsx",
|
||||
);
|
||||
const originalLoad = Module._load;
|
||||
const createElement = (type, props, ...children) => ({
|
||||
type,
|
||||
props: { ...props, children: children.length === 1 ? children[0] : children },
|
||||
});
|
||||
const names = [
|
||||
"Alert",
|
||||
"Button",
|
||||
"Card",
|
||||
"Flex",
|
||||
"Form",
|
||||
"Select",
|
||||
"Table",
|
||||
"Tabs",
|
||||
"Typography",
|
||||
];
|
||||
const components = Object.fromEntries(names.map((name) => [name, name]));
|
||||
components.Form = { Item: "Form.Item" };
|
||||
components.Typography = { Text: "Text", Link: "Link" };
|
||||
const same = (a, b) =>
|
||||
a &&
|
||||
b &&
|
||||
a.length === b.length &&
|
||||
a.every((value, index) => Object.is(value, b[index]));
|
||||
let harness;
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (parent?.filename === target) {
|
||||
if (request === "react") return harness.react;
|
||||
if (request === "antd") return components;
|
||||
if (request === "@ant-design/icons")
|
||||
return { ReloadOutlined: "ReloadOutlined" };
|
||||
if (request === "@/services/api")
|
||||
return {
|
||||
getList: ({ resource, ...query }) =>
|
||||
harness.listResource(resource, query),
|
||||
creatorGet: (...args) => harness.creatorGet(...args),
|
||||
};
|
||||
if (request === "@/utils/helpers")
|
||||
return {
|
||||
conflictMessage: (error, fallback) => error?.message || fallback,
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
function load() {
|
||||
const source = ts.transpileModule(
|
||||
"import React from 'react';\n" + fs.readFileSync(target, "utf8"),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
jsx: ts.JsxEmit.React,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
const mod = new Module(target, module);
|
||||
mod.filename = target;
|
||||
mod.paths = module.paths;
|
||||
mod._compile(source, target);
|
||||
return mod.exports;
|
||||
}
|
||||
function makeHarness() {
|
||||
const state = [];
|
||||
const effects = [];
|
||||
const pending = [];
|
||||
const calls = [];
|
||||
const queries = [];
|
||||
let cursor = 0;
|
||||
const h = {
|
||||
state,
|
||||
effects,
|
||||
calls,
|
||||
queries,
|
||||
react: {
|
||||
createElement,
|
||||
useState(initial) {
|
||||
const index = cursor++;
|
||||
if (!(index in state)) state[index] = initial;
|
||||
return [
|
||||
state[index],
|
||||
(value) => {
|
||||
state[index] =
|
||||
typeof value === "function" ? value(state[index]) : value;
|
||||
},
|
||||
];
|
||||
},
|
||||
useEffect(fn, deps) {
|
||||
const index = cursor++;
|
||||
if (!same(effects[index]?.deps, deps))
|
||||
pending.push(() => {
|
||||
effects[index]?.cleanup?.();
|
||||
effects[index] = { deps, cleanup: fn() };
|
||||
});
|
||||
},
|
||||
},
|
||||
creatorGet: async () => [
|
||||
{
|
||||
id: "account-1",
|
||||
name: "我的账号",
|
||||
nickname: "竞品账号",
|
||||
platform_account_key: "uid-1",
|
||||
},
|
||||
],
|
||||
listResource: async (resource, query) => {
|
||||
calls.push(resource);
|
||||
queries.push(query);
|
||||
return { data: [], total: 0 };
|
||||
},
|
||||
render(Component, props) {
|
||||
cursor = 0;
|
||||
const tree = Component(props);
|
||||
while (pending.length) pending.shift()();
|
||||
return tree;
|
||||
},
|
||||
cleanup() {
|
||||
effects.forEach((effect) => effect?.cleanup?.());
|
||||
},
|
||||
};
|
||||
harness = h;
|
||||
return h;
|
||||
}
|
||||
const tick = async () => {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
};
|
||||
function nodes(tree, type) {
|
||||
const matches = [];
|
||||
function walk(node) {
|
||||
if (!node) return;
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk);
|
||||
return;
|
||||
}
|
||||
if (typeof node !== "object") return;
|
||||
if (node.type === type) matches.push(node);
|
||||
walk(node.props?.children);
|
||||
}
|
||||
walk(tree);
|
||||
return matches;
|
||||
}
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test("two independently retained tabs and seven relative publication windows", async () => {
|
||||
const h = makeHarness();
|
||||
const page = load();
|
||||
const root = page.default();
|
||||
const tabs = nodes(root, "Tabs")[0];
|
||||
assert.deepEqual(
|
||||
tabs.props.items.map((item) => [item.key, item.label]),
|
||||
[
|
||||
["owned", "我的作品评论"],
|
||||
["competitor", "竞品作品评论"],
|
||||
],
|
||||
);
|
||||
assert.equal(tabs.props.destroyOnHidden, false);
|
||||
const props = { sourceType: "owned" };
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
const tree = h.render(page.CommentAggregationTab, props);
|
||||
assert.deepEqual(h.calls, ["creator-comment-aggregation"]);
|
||||
assert.deepEqual(h.queries[0], {
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
filters: { source_type: "owned", source_id: undefined, hours: 24 },
|
||||
});
|
||||
const select = nodes(tree, "Select").find(
|
||||
(node) => node.props["aria-label"] === "评论发布时间",
|
||||
);
|
||||
assert.deepEqual(
|
||||
select.props.options.map((option) => option.value),
|
||||
[1, 6, 12, 24, 72, 120, 168],
|
||||
);
|
||||
const accounts = nodes(tree, "Select").find(
|
||||
(node) => node.props["aria-label"] === "所属账号",
|
||||
);
|
||||
assert.deepEqual(accounts.props.options, [
|
||||
{ value: "account-1", label: "我的账号(uid-1)" },
|
||||
]);
|
||||
h.cleanup();
|
||||
});
|
||||
|
||||
test("account and time changes reset pagination and preserve page size", async () => {
|
||||
const h = makeHarness();
|
||||
const page = load();
|
||||
const props = { sourceType: "competitor" };
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
let tree = h.render(page.CommentAggregationTab, props);
|
||||
nodes(tree, "Table")[0].props.onChange({ current: 1, pageSize: 50 });
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
tree = h.render(page.CommentAggregationTab, props);
|
||||
nodes(tree, "Table")[0].props.onChange({ current: 3, pageSize: 50 });
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
tree = h.render(page.CommentAggregationTab, props);
|
||||
assert.equal(h.queries.at(-1).page, 3);
|
||||
assert.equal(h.queries.at(-1).pageSize, 50);
|
||||
nodes(tree, "Select")
|
||||
.find((node) => node.props["aria-label"] === "所属账号")
|
||||
.props.onChange("account-1");
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
tree = h.render(page.CommentAggregationTab, props);
|
||||
assert.deepEqual(h.queries.at(-1), {
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
filters: { source_type: "competitor", source_id: "account-1", hours: 24 },
|
||||
});
|
||||
nodes(tree, "Select")
|
||||
.find((node) => node.props["aria-label"] === "评论发布时间")
|
||||
.props.onChange(6);
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
tree = h.render(page.CommentAggregationTab, props);
|
||||
assert.equal(h.queries.at(-1).filters.hours, 6);
|
||||
assert.equal(h.queries.at(-1).page, 1);
|
||||
nodes(tree, "Button")
|
||||
.find((node) => node.props.children === "重置")
|
||||
.props.onClick();
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
tree = h.render(page.CommentAggregationTab, props);
|
||||
assert.equal(h.queries.at(-1).filters.hours, 24);
|
||||
assert.equal(h.queries.at(-1).filters.source_id, undefined);
|
||||
nodes(tree, "Table")[0].props.onChange({ current: 2, pageSize: 25 });
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
assert.equal(h.queries.at(-1).page, 1);
|
||||
assert.equal(h.queries.at(-1).pageSize, 25);
|
||||
h.cleanup();
|
||||
});
|
||||
|
||||
test("renders commenter, account and work metadata with server pagination", async () => {
|
||||
const h = makeHarness();
|
||||
const page = load();
|
||||
const props = { sourceType: "owned" };
|
||||
const item = {
|
||||
id: "comment-1",
|
||||
content: "评论内容",
|
||||
author_name: "读者",
|
||||
author_uid: "reader-uid",
|
||||
published_at: "2026-09-20T04:00:00Z",
|
||||
source_name: "账号",
|
||||
source_account_key: "uid-1",
|
||||
source_id: "account-1",
|
||||
work_title: "作品标题",
|
||||
work_key: "work-key",
|
||||
work_url: "https://www.douyin.com/video/work-key",
|
||||
};
|
||||
h.listResource = async () => ({ data: [item], total: 101 });
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
const tree = h.render(page.CommentAggregationTab, props);
|
||||
const table = nodes(tree, "Table")[0];
|
||||
assert.deepEqual(table.props.dataSource, [item]);
|
||||
assert.equal(table.props.pagination.total, 101);
|
||||
assert.equal(table.props.pagination.current, 1);
|
||||
assert.deepEqual(
|
||||
table.props.columns.map((column) => column.title),
|
||||
["评论发布时间", "评论者", "评论内容", "所属账号", "对应作品"],
|
||||
);
|
||||
const account = table.props.columns
|
||||
.find((column) => column.title === "所属账号")
|
||||
.render(undefined, item);
|
||||
assert.equal(nodes(account, "Text")[0].props.children, "账号");
|
||||
assert.equal(nodes(account, "Text")[1].props.children, "UID:uid-1");
|
||||
const work = table.props.columns
|
||||
.find((column) => column.title === "对应作品")
|
||||
.render(undefined, item);
|
||||
assert.equal(nodes(work, "Link")[0].props.href, item.work_url);
|
||||
assert.equal(nodes(work, "Link")[0].props.children, item.work_title);
|
||||
const author = table.props.columns
|
||||
.find((column) => column.title === "评论者")
|
||||
.render(undefined, item);
|
||||
assert.equal(nodes(author, "Text")[1].props.children, "UID:reader-uid");
|
||||
const empty = {
|
||||
...item,
|
||||
source_name: "",
|
||||
source_account_key: "",
|
||||
author_name: "",
|
||||
author_uid: "",
|
||||
work_title: "",
|
||||
work_url: "",
|
||||
};
|
||||
assert.equal(
|
||||
nodes(
|
||||
table.props.columns
|
||||
.find((column) => column.title === "所属账号")
|
||||
.render(undefined, empty),
|
||||
"Text",
|
||||
)[0].props.children,
|
||||
"account-1",
|
||||
);
|
||||
assert.equal(
|
||||
nodes(
|
||||
table.props.columns
|
||||
.find((column) => column.title === "对应作品")
|
||||
.render(undefined, empty),
|
||||
"Text",
|
||||
)[0].props.children,
|
||||
"work-key",
|
||||
);
|
||||
h.cleanup();
|
||||
});
|
||||
|
||||
test("obsolete responses do not replace current filters and refresh retries errors", async () => {
|
||||
const h = makeHarness();
|
||||
const page = load();
|
||||
const props = { sourceType: "owned" };
|
||||
const old = deferred();
|
||||
const fresh = deferred();
|
||||
let count = 0;
|
||||
h.listResource = () => (++count === 1 ? old.promise : fresh.promise);
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
let tree = h.render(page.CommentAggregationTab, props);
|
||||
nodes(tree, "Select")
|
||||
.find((node) => node.props["aria-label"] === "评论发布时间")
|
||||
.props.onChange(1);
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
fresh.resolve({ data: [{ id: "fresh" }], total: 1 });
|
||||
await tick();
|
||||
old.resolve({ data: [{ id: "old" }], total: 9 });
|
||||
await tick();
|
||||
tree = h.render(page.CommentAggregationTab, props);
|
||||
assert.deepEqual(nodes(tree, "Table")[0].props.dataSource, [{ id: "fresh" }]);
|
||||
assert.equal(nodes(tree, "Table")[0].props.pagination.total, 1);
|
||||
const errors = [];
|
||||
const previous = console.error;
|
||||
console.error = (...args) => errors.push(args);
|
||||
try {
|
||||
h.listResource = async () => {
|
||||
throw new Error("读取失败");
|
||||
};
|
||||
nodes(tree, "Button")
|
||||
.find((node) => node.props.children === "刷新")
|
||||
.props.onClick();
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
tree = h.render(page.CommentAggregationTab, props);
|
||||
assert.equal(nodes(tree, "Alert")[0].props.title, "评论读取失败");
|
||||
assert.equal(nodes(tree, "Alert")[0].props.description, "读取失败");
|
||||
assert.equal(errors.length, 1);
|
||||
assert.deepEqual(nodes(tree, "Table")[0].props.dataSource, []);
|
||||
h.listResource = async () => ({ data: [{ id: "retried" }], total: 1 });
|
||||
nodes(tree, "Button")
|
||||
.find((node) => node.props.children === "刷新")
|
||||
.props.onClick();
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
tree = h.render(page.CommentAggregationTab, props);
|
||||
assert.equal(nodes(tree, "Alert").length, 0);
|
||||
assert.deepEqual(nodes(tree, "Table")[0].props.dataSource, [
|
||||
{ id: "retried" },
|
||||
]);
|
||||
} finally {
|
||||
console.error = previous;
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("account errors remain visible and disable account filter until retry", async () => {
|
||||
const h = makeHarness();
|
||||
const page = load();
|
||||
const props = { sourceType: "competitor" };
|
||||
const previous = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
h.creatorGet = async () => {
|
||||
throw new Error("账号接口失败");
|
||||
};
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
let tree = h.render(page.CommentAggregationTab, props);
|
||||
assert.equal(nodes(tree, "Alert")[0].props.title, "账号读取失败");
|
||||
assert.equal(
|
||||
nodes(tree, "Select").find(
|
||||
(node) => node.props["aria-label"] === "所属账号",
|
||||
).props.disabled,
|
||||
true,
|
||||
);
|
||||
h.creatorGet = async () => [];
|
||||
nodes(tree, "Button")
|
||||
.find((node) => node.props.children === "刷新")
|
||||
.props.onClick();
|
||||
h.render(page.CommentAggregationTab, props);
|
||||
await tick();
|
||||
tree = h.render(page.CommentAggregationTab, props);
|
||||
assert.equal(nodes(tree, "Alert").length, 0);
|
||||
assert.equal(
|
||||
nodes(tree, "Select").find(
|
||||
(node) => node.props["aria-label"] === "所属账号",
|
||||
).props.disabled,
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
console.error = previous;
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user