feat: unify account environments and work collection
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
## 宪法
|
||||
|
||||
- 你是我的合伙人,不是只会干活的助手,我的需求你需要结合当前系统实际定位给出合理性建议,而不是一味地遵守(除非我很强硬地需要这么做),如果我说的不对,你要第一时间指出,并给出合理结论。
|
||||
- 任何涉及文件的调研或修改,如果当前是 git 仓库,需要先同步远程提交到本地,避免调研过时问题。
|
||||
- 基于 TDD 进行功能的开发与业务变更,单元测试覆盖率要保证 65% 以上
|
||||
- 禁止自动搞 E2E 测试:不主动编写、不主动运行任何 E2E 测试(Playwright/Cypress 等),仅在使用者明确要求时进行;单元测试要求不变
|
||||
@@ -39,12 +40,14 @@
|
||||
|
||||
产品方向:平台当前仅支持抖音(douyin);小红书等其他平台的业务与代码已全部移除,不得重新引入。
|
||||
|
||||
环境先行:创建时只创建抖音浏览器环境,不填写昵称、UID 或 Cookie,不创建占位账号。未登录环境在账号列表独立显示。首次浏览器身份核验成功后,同一事务创建/关联账号并同步真实 UID、昵称、头像、抖音号与 secUID;同一 UID 只能绑定一个账号,已有绑定禁止换绑或自动覆盖。后续同 UID 核验更新平台资料,不覆盖本地备注和业务设置。浏览器 profile_id 与指纹 seed 在环境创建时确定,账号绑定不得改变它们;旧环境保持原 profile_id 和 seed,不重建浏览器或清空 Cookie。指纹 seed 由环境独立序列分配,不再依赖账号 ID。
|
||||
环境先行:创建时只创建抖音浏览器环境,不填写昵称、UID 或 Cookie,不创建占位账号。未登录环境与已登录账号在“我的账号”同一列表展示,不设独立区域;缺失资料使用前端默认值,不创建占位账号,不写回数据库。待登录行仅保留“登录并同步账号”,不提供账号详情或其他账号操作。统一列表按共用的浏览器环境数据库 ID 倒序排列,新建项在前;登录绑定保留环境 ID,不改变行的相对位置。首次浏览器身份核验成功后,同一事务创建/关联账号并同步真实 UID、昵称、头像、抖音号与 secUID;同一 UID 只能绑定一个账号,已有绑定禁止换绑或自动覆盖。后续同 UID 核验更新平台资料,不覆盖本地备注和业务设置。浏览器 profile_id 与指纹 seed 在环境创建时确定,账号绑定不得改变它们;旧环境保持原 profile_id 和 seed,不重建浏览器或清空 Cookie。指纹 seed 由环境独立序列分配,不再依赖账号 ID。
|
||||
|
||||
作品封面:自有账号与监测账号采集后都下载封面,图片保存为静态文件 `<账号 UID>/<作品 ID>.<实际图片扩展名>`,不把图片字节存入数据库。目录由 `CREATOR_COVER_DIR` 配置,开发默认 `.data/covers`,容器使用持久卷;作品封面接口只读取本地文件,不使用远程图片兜底。下载与文件错误必须可追踪并反映到同步结果;同一来源每次同步最多回填 40 张历史缺失封面。
|
||||
|
||||
账号创建交互:在“我的账号”列表通过 Modal 创建,网关必选,浏览器指纹为默认折叠的可选配置;创建成功关闭弹窗并刷新列表,不保留独立新增页面。未登录环境及空昵称仅在前端显示“待登录”,不将此文案写入数据库。
|
||||
|
||||
自有账号作品:作品总数使用抖音最新非空 `aweme_count`,已保存 `work_count` 仅表示采集进度,不得互相替代;未知总数与零作品必须区分。自有账号作品库保存平台分页返回的全部历史作品,不受采集回看天数限制;回看天数仍限制监控账号作品和评论采集。详情分页浏览已采集作品,并明确展示采集进度与未完整状态,不能将采集成功等同于已采齐。
|
||||
|
||||
前端框架:Umi Max 4.7 + React 19;
|
||||
|
||||
组件库:antd 6.6.5 + @ant-design/pro-components 3.x(beta 线)+ @ant-design/icons;仅使用 antd/pro 默认组件原样实现,禁止自定义封装与样式魔改;组件不满足业务时改交互逻辑适配组件;
|
||||
|
||||
@@ -744,7 +744,7 @@ def valid_douyin_url(raw: object) -> bool:
|
||||
if parsed.path == DOUYIN_PROFILE_OTHER_PATH:
|
||||
return valid_douyin_profile_query(query)
|
||||
if parsed.path == DOUYIN_WORKS_PATH:
|
||||
return valid_douyin_api_query(query, "sec_user_id", "max_cursor")
|
||||
return valid_douyin_works_query(query)
|
||||
if parsed.path == DOUYIN_WORK_DETAIL_PATH:
|
||||
return valid_douyin_work_detail_query(query)
|
||||
if parsed.path == DOUYIN_COMMENTS_PATH:
|
||||
@@ -773,6 +773,37 @@ def valid_douyin_profile_query(query: dict[str, list[str]]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def valid_douyin_works_query(query: dict[str, list[str]]) -> bool:
|
||||
# Keep the works endpoint pinned to the parameters used by Douyin's web
|
||||
# profile page; do not let the pagination fix widen the restricted URL surface.
|
||||
rest = query
|
||||
if "a_bogus" in query:
|
||||
if len(query["a_bogus"]) != 1 or not DOUYIN_SIGNATURE_RE.fullmatch(
|
||||
query["a_bogus"][0]
|
||||
):
|
||||
return False
|
||||
rest = {key: value for key, value in query.items() if key != "a_bogus"}
|
||||
required = {
|
||||
"aid": ["6383"],
|
||||
"device_platform": ["webapp"],
|
||||
"channel": ["channel_pc_web"],
|
||||
"count": ["18"],
|
||||
"locate_query": ["false"],
|
||||
"show_live_replay_strategy": ["1"],
|
||||
"need_time_list": ["1"],
|
||||
"time_list_query": ["0"],
|
||||
"cut_version": ["1"],
|
||||
"publish_video_strategy_type": ["2"],
|
||||
"from_user_page": ["0"],
|
||||
}
|
||||
return (
|
||||
set(rest) == set(required) | {"sec_user_id", "max_cursor"}
|
||||
and all(rest.get(key) == value for key, value in required.items())
|
||||
and valid_account_key_query(rest, "sec_user_id")
|
||||
and numeric_cursor(rest.get("max_cursor"))
|
||||
)
|
||||
|
||||
|
||||
def valid_douyin_api_query(
|
||||
query: dict[str, list[str]], account_field: str, cursor_field: str
|
||||
) -> bool:
|
||||
|
||||
@@ -198,6 +198,19 @@ class GatewayValidationTests(unittest.TestCase):
|
||||
"https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=1&count=20"
|
||||
)
|
||||
)
|
||||
works_url = (
|
||||
"https://www.douyin.com/aweme/v1/web/aweme/post/?"
|
||||
"aid=6383&device_platform=webapp&channel=channel_pc_web&"
|
||||
"sec_user_id=MS4wLjABAAAA9f_a7k0bzVizLYXlpC7R61EIaqJ8Ordug7yp7AB8fGKuuF8Fzqk5_DM-eutXnPIK&"
|
||||
"count=18&max_cursor=1746702477000&locate_query=false&"
|
||||
"show_live_replay_strategy=1&need_time_list=1&time_list_query=0&"
|
||||
"cut_version=1&publish_video_strategy_type=2&from_user_page=0"
|
||||
)
|
||||
self.assertTrue(valid_douyin_url(works_url))
|
||||
self.assertTrue(valid_douyin_url(works_url + "&a_bogus=abcdefghijklmnop"))
|
||||
self.assertFalse(valid_douyin_url(works_url + "&unexpected=1"))
|
||||
self.assertFalse(valid_douyin_url(works_url.replace("count=18", "count=20")))
|
||||
self.assertFalse(valid_douyin_url(works_url.replace("max_cursor=1746702477000", "max_cursor=-1")))
|
||||
self.assertFalse(valid_douyin_url("https://www.douyin.com.evil/"))
|
||||
self.assertTrue(is_douyin_url("https://www.douyin.com/video/123"))
|
||||
self.assertFalse(is_douyin_url("https://www.douyin.com.evil/video/123"))
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
)
|
||||
|
||||
func TestAccountEnvironmentViewPreservesDatabaseOrderingID(t *testing.T) {
|
||||
view := accountEnvironmentView(hub.EnvironmentContext{Env: hub.Env{ID: 42, Alias: "env-order"}})
|
||||
if view["id"] != int64(42) {
|
||||
t.Fatalf("account environment is missing its shared ordering ID: %#v", view)
|
||||
}
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func PhaseAError(c fiber.Ctx, err error) error {
|
||||
func accountEnvironmentView(environment hub.EnvironmentContext) map[string]any {
|
||||
scheduleStatus, scheduleBlockReason := environmentScheduleReadiness(environment)
|
||||
return map[string]any{
|
||||
"alias": environment.Alias, "name": environment.Name, "gateway": environment.Gateway,
|
||||
"id": environment.ID, "alias": environment.Alias, "name": environment.Name, "gateway": environment.Gateway,
|
||||
"binding_version": environment.BindingVersion, "runtime_id": environment.RuntimeID,
|
||||
"runtime_cleanup_pending": environment.RuntimeCleanupPending,
|
||||
"network_exit_id": environment.Exit.ID, "network_exit_health": environment.Exit.HealthStatus,
|
||||
|
||||
@@ -95,8 +95,8 @@ func TestPendingEnvironmentLoginRoutesKeepBrowserIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
pending, err := hs.ListPendingEnvironments(ctx)
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("pending browser created account: %v %v", pending, err)
|
||||
if err != nil || len(pending) != 1 || pending[0].ID != env.ID || env.ID < 1 {
|
||||
t.Fatalf("pending browser created account or lost its ordering ID: %v %v", pending, err)
|
||||
}
|
||||
logged = true
|
||||
response := do(app, http.MethodPost, route+"/verify", `{}`)
|
||||
@@ -108,7 +108,7 @@ func TestPendingEnvironmentLoginRoutesKeepBrowserIdentity(t *testing.T) {
|
||||
t.Fatalf("login response: %#v %v", result, err)
|
||||
}
|
||||
bound, err := hs.GetEnvironmentContext(ctx, alias)
|
||||
if err != nil || bound.AccountID != result.AccountID || bound.RuntimeID != "login-runtime" || bound.ProfileID != alias || bound.Fingerprint.Seed != env.Fingerprint.Seed {
|
||||
if err != nil || bound.AccountID != result.AccountID || bound.RuntimeID != "login-runtime" || bound.ProfileID != alias || bound.Fingerprint.Seed != env.Fingerprint.Seed || bound.ID != env.ID {
|
||||
t.Fatalf("binding changed browser: %#v %v", bound, err)
|
||||
}
|
||||
response = do(app, http.MethodPost, route+"/verify", `{}`)
|
||||
|
||||
@@ -283,6 +283,7 @@ func TestControlPlaneRegisteredRouteMatrix(t *testing.T) {
|
||||
want int
|
||||
}{
|
||||
{http.MethodPost, "/api/not-registered", http.StatusNotFound},
|
||||
{http.MethodPost, "/api/phase-a/accounts", http.StatusNotFound},
|
||||
{http.MethodDelete, "/api/not-registered", http.StatusNotFound},
|
||||
} {
|
||||
t.Run("unregistered "+route.method, func(t *testing.T) {
|
||||
@@ -359,7 +360,6 @@ func controlPlaneRouteMatrix() []controlPlaneRouteCase {
|
||||
{http.MethodPut, "/api/gateways/:name", "/api/gateways/missing", `{"name":"gw-missing","endpoint":"http://gw-missing:8081","token":""}`, http.StatusNotFound},
|
||||
{http.MethodDelete, "/api/gateways/:name", "/api/gateways/missing", "", http.StatusNotFound},
|
||||
|
||||
{http.MethodPost, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusBadRequest},
|
||||
{http.MethodGet, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusOK},
|
||||
{http.MethodGet, "/api/phase-a/accounts/:id", "/api/phase-a/accounts/missing", "", http.StatusNotFound},
|
||||
{http.MethodPost, "/api/phase-a/accounts/:id/pause", "/api/phase-a/accounts/missing/pause", "", http.StatusNotFound},
|
||||
|
||||
@@ -234,8 +234,10 @@ func (s *Store) ownedWorkStats(ctx context.Context) (map[string]ownedWorkStat, e
|
||||
|
||||
// AccountCollectionStatus 自有账号采集状态(checkpoint 形态,对齐竞品的 sync_status 展示语义)。
|
||||
type AccountCollectionStatus struct {
|
||||
Works AccountCheckpointStatus `json:"works"`
|
||||
Comments AccountCheckpointStatus `json:"comments"`
|
||||
WorkCount int64 `json:"work_count"`
|
||||
AwemeCount *int64 `json:"aweme_count"`
|
||||
Works AccountCheckpointStatus `json:"works"`
|
||||
Comments AccountCheckpointStatus `json:"comments"`
|
||||
}
|
||||
|
||||
type AccountCheckpointStatus struct {
|
||||
@@ -254,6 +256,20 @@ func (s *Store) GetAccountCollectionStatus(ctx context.Context, accountID string
|
||||
return AccountCollectionStatus{}, ErrInvalid
|
||||
}
|
||||
status := AccountCollectionStatus{}
|
||||
var total sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM creator_work WHERE source_type = $1 AND source_id = $2),
|
||||
(SELECT metric.aweme_count FROM creator_account_metric metric
|
||||
JOIN social_account account ON account.id = metric.account_id
|
||||
WHERE account.account_id = $2 AND metric.aweme_count IS NOT NULL
|
||||
ORDER BY metric.collected_at DESC LIMIT 1)`, SourceOwned, accountID).Scan(&status.WorkCount, &total)
|
||||
if err != nil {
|
||||
return AccountCollectionStatus{}, databaseError(err)
|
||||
}
|
||||
if total.Valid {
|
||||
status.AwemeCount = &total.Int64
|
||||
}
|
||||
for _, kind := range []struct {
|
||||
name string
|
||||
pointer *AccountCheckpointStatus
|
||||
|
||||
@@ -403,7 +403,9 @@ func (s *Store) CollectSource(ctx context.Context, platform, sourceType, sourceI
|
||||
return err
|
||||
}
|
||||
report.WorksSeen++
|
||||
if work.PublishedAt != nil && work.PublishedAt.Before(report.WindowStart) {
|
||||
// Owned accounts keep their entire work library; the lookback window
|
||||
// still bounds competitor works and the later comments phase.
|
||||
if sourceType == SourceCompetitor && work.PublishedAt != nil && work.PublishedAt.Before(report.WindowStart) {
|
||||
continue
|
||||
}
|
||||
work.Platform, work.SourceType, work.SourceID = platform, sourceType, sourceID
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package creator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ownedHistoryCollector struct {
|
||||
pages map[string]WorkPage
|
||||
workCursors []string
|
||||
commentWorks []string
|
||||
}
|
||||
|
||||
func (c *ownedHistoryCollector) ListWorks(_ context.Context, _, cursor string) (WorkPage, error) {
|
||||
c.workCursors = append(c.workCursors, cursor)
|
||||
page, ok := c.pages[cursor]
|
||||
if !ok {
|
||||
return WorkPage{}, fmt.Errorf("unexpected cursor %q", cursor)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (c *ownedHistoryCollector) ListTopLevelComments(_ context.Context, workKey, _ string) (CommentPage, error) {
|
||||
c.commentWorks = append(c.commentWorks, workKey)
|
||||
return CommentPage{}, nil
|
||||
}
|
||||
|
||||
func TestCreatorPostgresOwnedCollectionIncludesHistoryAndProgress(t *testing.T) {
|
||||
store, accounts, ctx := openCreatorIntegrationStore(t)
|
||||
accountID := createIntegrationAccount(t, ctx, accounts, fmt.Sprint(time.Now().UnixNano()))
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
settings, err := store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := now.Add(-time.Duration(settings.LookbackDays+365) * 24 * time.Hour)
|
||||
recent := now.Add(-time.Hour)
|
||||
pages := map[string]WorkPage{"": {NextCursor: "history", HasMore: true}}
|
||||
for i := 0; i < 28; i++ {
|
||||
published := old
|
||||
if i == 0 {
|
||||
published = recent
|
||||
}
|
||||
work := WorkInput{WorkKey: fmt.Sprintf("owned-history-%02d", i), Title: fmt.Sprintf("作品 %d", i), PublishedAt: &published, PublishedAtStatus: "verified"}
|
||||
cursor := "history"
|
||||
if i < 14 {
|
||||
cursor = ""
|
||||
}
|
||||
page := pages[cursor]
|
||||
page.Items = append(page.Items, work)
|
||||
pages[cursor] = page
|
||||
}
|
||||
collector := &ownedHistoryCollector{pages: pages}
|
||||
total := int64(30)
|
||||
if err := store.RecordAccountMetric(ctx, AccountMetricInput{AccountID: accountID, CollectedAt: now.Add(-time.Minute), AwemeCount: &total}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A newer snapshot lacking aweme_count must not erase the known platform total.
|
||||
followers := int64(100)
|
||||
if err := store.RecordAccountMetric(ctx, AccountMetricInput{AccountID: accountID, CollectedAt: now, FollowerCount: &followers}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := store.CollectSource(ctx, PlatformDouyin, SourceOwned, accountID, collector, now)
|
||||
if err != nil || !report.PaginationComplete || report.WorksSeen != 28 || report.WorksSaved != 28 {
|
||||
t.Fatalf("full history collection: report=%+v err=%v", report, err)
|
||||
}
|
||||
if len(collector.workCursors) != 2 || collector.workCursors[1] != "history" {
|
||||
t.Fatalf("history pagination: %v", collector.workCursors)
|
||||
}
|
||||
// History is listed, but comments remain limited to the configured window.
|
||||
if len(collector.commentWorks) != 1 || collector.commentWorks[0] != "owned-history-00" {
|
||||
t.Fatalf("historical comments should not be scheduled: %v", collector.commentWorks)
|
||||
}
|
||||
first, err := store.ListWorksPage(ctx, WorkFilter{SourceType: SourceOwned, SourceID: accountID}, 1, 25)
|
||||
if err != nil || len(first.Data) != 25 || first.Total != 28 || !first.HasNext {
|
||||
t.Fatalf("first history page: page=%+v err=%v", first, err)
|
||||
}
|
||||
second, err := store.ListWorksPage(ctx, WorkFilter{SourceType: SourceOwned, SourceID: accountID}, 2, 25)
|
||||
if err != nil || len(second.Data) != 3 || second.Total != 28 || second.HasNext {
|
||||
t.Fatalf("last history page: page=%+v err=%v", second, err)
|
||||
}
|
||||
status, err := store.GetAccountCollectionStatus(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Read JSON fields to keep the regression test compilable before implementation.
|
||||
assertCollectionCounts(t, status, 28, &total)
|
||||
views, err := store.ListAccountMonitorViews(ctx)
|
||||
if err != nil || len(views) != 1 || views[0].WorkCount != 28 || views[0].AwemeCount == nil || *views[0].AwemeCount != total {
|
||||
t.Fatalf("list/detail count consistency: views=%+v err=%v", views, err)
|
||||
}
|
||||
// A full subsequent run must not count duplicate works twice.
|
||||
if _, err := store.CollectSource(ctx, PlatformDouyin, SourceOwned, accountID, collector, now.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = store.GetAccountCollectionStatus(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCollectionCounts(t, status, 28, &total)
|
||||
}
|
||||
|
||||
func assertCollectionCounts(t *testing.T, status AccountCollectionStatus, collected int64, total *int64) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(fields["work_count"]) != fmt.Sprint(collected) {
|
||||
t.Fatalf("collected count: status=%s want=%d", data, collected)
|
||||
}
|
||||
want := "null"
|
||||
if total != nil {
|
||||
want = fmt.Sprint(*total)
|
||||
}
|
||||
if string(fields["aweme_count"]) != want {
|
||||
t.Fatalf("platform total: status=%s want=%s", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorPostgresCollectionProgressDistinguishesUnknownAndZero(t *testing.T) {
|
||||
store, accounts, ctx := openCreatorIntegrationStore(t)
|
||||
accountID := createIntegrationAccount(t, ctx, accounts, fmt.Sprint(time.Now().UnixNano()))
|
||||
status, err := store.GetAccountCollectionStatus(ctx, accountID)
|
||||
if err != nil || status.Works.Status != "pending" {
|
||||
t.Fatalf("pending status: %+v err=%v", status, err)
|
||||
}
|
||||
assertCollectionCounts(t, status, 0, nil)
|
||||
zero := int64(0)
|
||||
if err := store.RecordAccountMetric(ctx, AccountMetricInput{AccountID: accountID, CollectedAt: time.Now().UTC(), AwemeCount: &zero}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = store.GetAccountCollectionStatus(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertCollectionCounts(t, status, 0, &zero)
|
||||
}
|
||||
@@ -462,7 +462,7 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro
|
||||
var runtimeID, runtimeNetworkID, runtimeNodeID, cleanupRuntimeID, cleanupNetworkID sql.NullString
|
||||
var cleanupBindingVersion sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT environment.alias, environment.name, gateway.name,
|
||||
SELECT environment.id, environment.alias, environment.name, gateway.name,
|
||||
environment.fingerprint, environment.created_at, COALESCE(account.account_id, ''), COALESCE(account.status, ''), environment.profile_id,
|
||||
environment.version,
|
||||
environment.runtime_cleanup_pending, environment.runtime_cleanup_binding_version,
|
||||
@@ -479,7 +479,7 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro
|
||||
JOIN gateway ON gateway.id = environment.gateway_id
|
||||
LEFT JOIN network_exit network ON network.id = environment.exit_id
|
||||
WHERE environment.alias = $1`, alias).
|
||||
Scan(&result.Alias, &result.Name, &result.Gateway, &encoded, &result.CreatedAt,
|
||||
Scan(&result.ID, &result.Alias, &result.Name, &result.Gateway, &encoded, &result.CreatedAt,
|
||||
&result.AccountID, &result.AccountStatus, &result.ProfileID,
|
||||
&result.BindingVersion, &result.RuntimeCleanupPending, &cleanupBindingVersion,
|
||||
&cleanupRuntimeID, &cleanupNetworkID,
|
||||
|
||||
@@ -24,7 +24,7 @@ func (s *Store) CreateStandaloneEnv(ctx context.Context, gateway string, fingerp
|
||||
}
|
||||
|
||||
func (s *Store) ListPendingEnvironments(ctx context.Context) ([]EnvironmentContext, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT alias FROM browser_env WHERE account_id IS NULL ORDER BY created_at,id`)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT alias FROM browser_env WHERE account_id IS NULL ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending browser environments: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPendingEnvironmentDatabaseIDOrder(t *testing.T) {
|
||||
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
|
||||
}
|
||||
ctx := context.Background()
|
||||
store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL))
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if _, err := store.CreateGateway(ctx, "order-gateway", "http://127.0.0.1:8090", "order-gateway-test-token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := store.CreateStandaloneEnv(ctx, "order-gateway", Fingerprint{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.CreateStandaloneEnv(ctx, "order-gateway", Fingerprint{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.ID < 1 || second.ID <= first.ID {
|
||||
t.Fatalf("environment IDs must increase: first=%d second=%d", first.ID, second.ID)
|
||||
}
|
||||
pending, err := store.ListPendingEnvironments(ctx)
|
||||
if err != nil || len(pending) != 2 || pending[0].ID != second.ID || pending[1].ID != first.ID {
|
||||
t.Fatalf("pending environments must be newest first: %#v err=%v", pending, err)
|
||||
}
|
||||
data, err := json.Marshal(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil || response.ID != second.ID {
|
||||
t.Fatalf("database ID missing from environment JSON: %s err=%v", data, err)
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,7 @@ type Gateway struct {
|
||||
|
||||
// Env 是一个浏览器环境;浏览器安装和默认运行时由 gateway 宿主机配置。
|
||||
type Env struct {
|
||||
ID int64 `json:"id"`
|
||||
Alias string `json:"alias"`
|
||||
Name string `json:"name"`
|
||||
Gateway string `json:"gateway"`
|
||||
@@ -452,7 +453,7 @@ func (s *Store) DeleteGateway(ctx context.Context, name string) error {
|
||||
|
||||
func (s *Store) ListEnvs(ctx context.Context) ([]Env, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at
|
||||
SELECT environment.id, environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at
|
||||
FROM browser_env environment
|
||||
JOIN gateway ON gateway.id = environment.gateway_id
|
||||
ORDER BY environment.created_at, environment.alias`)
|
||||
@@ -476,7 +477,7 @@ func (s *Store) GetEnv(ctx context.Context, alias string) (Env, error) {
|
||||
return Env{}, ErrInvalid
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at
|
||||
SELECT environment.id, environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at
|
||||
FROM browser_env environment
|
||||
JOIN gateway ON gateway.id = environment.gateway_id
|
||||
WHERE environment.alias = $1`, alias)
|
||||
@@ -536,7 +537,7 @@ func (s *Store) DeleteAccountEnvironment(ctx context.Context, accountID string)
|
||||
func scanEnv(rows *sql.Rows) (Env, error) {
|
||||
var env Env
|
||||
var encoded []byte
|
||||
if err := rows.Scan(&env.Alias, &env.Name, &env.Gateway, &encoded, &env.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&env.ID, &env.Alias, &env.Name, &env.Gateway, &encoded, &env.CreatedAt); err != nil {
|
||||
return Env{}, errors.New("decode browser env")
|
||||
}
|
||||
if len(encoded) > 0 {
|
||||
|
||||
@@ -237,9 +237,17 @@ func (c CreatorCollector) ListWorks(ctx context.Context, accountKey, cursor stri
|
||||
maxCursor = cursor
|
||||
}
|
||||
query := douyinAPIQuery()
|
||||
query.Set("channel", "channel_pc_web")
|
||||
query.Set("sec_user_id", accountKey)
|
||||
query.Set("count", "20")
|
||||
query.Set("count", "18")
|
||||
query.Set("max_cursor", maxCursor)
|
||||
query.Set("locate_query", "false")
|
||||
query.Set("show_live_replay_strategy", "1")
|
||||
query.Set("need_time_list", "1")
|
||||
query.Set("time_list_query", "0")
|
||||
query.Set("cut_version", "1")
|
||||
query.Set("publish_video_strategy_type", "2")
|
||||
query.Set("from_user_page", "0")
|
||||
response, err := c.Browser.Get(ctx, worksEndpoint+"?"+query.Encode())
|
||||
if err != nil {
|
||||
return creator.WorkPage{}, err
|
||||
|
||||
@@ -101,6 +101,40 @@ func TestCreatorCollectorGuardsAndCollection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWorksUsesCurrentDouyinWebPaginationParameters(t *testing.T) {
|
||||
browser := &collectorBrowser{response: Response{Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"max_cursor":0,"aweme_list":[]}`)}}
|
||||
collector := CreatorCollector{Browser: browser, AccountKey: "MS4wLjABAAAAkey"}
|
||||
if _, err := collector.ListWorks(context.Background(), "ignored", "1746702477000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(browser.url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
want := map[string]string{
|
||||
"channel": "channel_pc_web",
|
||||
"sec_user_id": "MS4wLjABAAAAkey",
|
||||
"count": "18",
|
||||
"max_cursor": "1746702477000",
|
||||
"locate_query": "false",
|
||||
"show_live_replay_strategy": "1",
|
||||
"need_time_list": "1",
|
||||
"time_list_query": "0",
|
||||
"cut_version": "1",
|
||||
"publish_video_strategy_type": "2",
|
||||
"from_user_page": "0",
|
||||
}
|
||||
if parsed.Path != "/aweme/v1/web/aweme/post/" {
|
||||
t.Fatalf("unexpected endpoint: %s", parsed.Path)
|
||||
}
|
||||
for key, value := range want {
|
||||
if query.Get(key) != value {
|
||||
t.Errorf("query %s = %q, want %q (full query: %s)", key, query.Get(key), value, query.Encode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCreatorCommentsPageAllowsEmptyComments(t *testing.T) {
|
||||
page, err := parseCreatorCommentsPage([]byte(`{"status_code":0,"has_more":false,"cursor":20,"comments":null}`))
|
||||
if err != nil {
|
||||
|
||||
@@ -10,12 +10,13 @@ import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { remove, creatorUpdate, creatorGet, creatorAction } from '@/services/api';
|
||||
import { accountReadiness, conflictMessage, dateTime } from '@/utils/helpers';
|
||||
import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table';
|
||||
import { fixedLeft, fixedRight, tablePagination, useOverflowGrid } from '@/utils/table';
|
||||
import AccountLoginModal from '@/components/accounts/AccountLoginModal';
|
||||
import AccountCreateModal from '@/components/accounts/AccountCreateModal';
|
||||
|
||||
// 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。
|
||||
interface EnvironmentView {
|
||||
id?: number;
|
||||
gateway?: string;
|
||||
name?: string;
|
||||
alias?: string;
|
||||
@@ -25,15 +26,21 @@ interface EnvironmentView {
|
||||
network_exit_health?: string;
|
||||
}
|
||||
|
||||
interface PendingEnvironment extends EnvironmentView {
|
||||
alias: string;
|
||||
network_exit?: { id: string; health_status: string };
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
pending_environment?: string;
|
||||
name: string;
|
||||
platform_account_key: string;
|
||||
avatar_url?: string;
|
||||
douyin_number?: string;
|
||||
tags?: string[];
|
||||
runtime_status?: string;
|
||||
business_status: 'normal' | 'muted' | 'banned' | 'deleted';
|
||||
business_status?: 'normal' | 'muted' | 'banned' | 'deleted';
|
||||
environment?: EnvironmentView;
|
||||
work_count?: number;
|
||||
latest_published_at?: string | null;
|
||||
@@ -54,7 +61,6 @@ function formatCount(value?: number | null): string {
|
||||
export default function AccountManagementList() {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [environments, setEnvironments] = useState<{ alias: string; gateway: string; runtime_id?: string }[]>([]);
|
||||
const [environmentTarget, setEnvironmentTarget] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
@@ -64,7 +70,7 @@ export default function AccountManagementList() {
|
||||
const [actionBusy, setActionBusy] = useState('');
|
||||
const [loginTarget, setLoginTarget] = useState<Row | null>(null);
|
||||
const { message: messageApi } = App.useApp();
|
||||
const { vertical, horizontal } = useOverflowGrid(rows.length);
|
||||
const { vertical } = useOverflowGrid(rows.length);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setPending(true);
|
||||
@@ -73,13 +79,28 @@ export default function AccountManagementList() {
|
||||
const [rows, environments] = await Promise.all([
|
||||
creatorGet('/creator/accounts/monitor-views'), creatorGet('/creator/environments'),
|
||||
]);
|
||||
setEnvironments(environments);
|
||||
setRows(
|
||||
(rows ?? []).map((account: any) => ({
|
||||
...account,
|
||||
name: account.name,
|
||||
tags: account.tags ?? [] })),
|
||||
);
|
||||
setRows([
|
||||
...(rows ?? []).map((account: Row) => ({ ...account, tags: account.tags ?? [] })),
|
||||
...environments.map((environment: PendingEnvironment): Row => ({
|
||||
id: `environment:${environment.alias}`,
|
||||
pending_environment: environment.alias,
|
||||
name: '',
|
||||
platform_account_key: '',
|
||||
tags: [],
|
||||
follower_count: 0,
|
||||
following_count: 0,
|
||||
total_favorited: 0,
|
||||
friend_count: 0,
|
||||
comment_total: 0,
|
||||
aweme_count: 0,
|
||||
work_count: 0,
|
||||
environment: {
|
||||
...environment,
|
||||
network_exit_id: environment.network_exit?.id,
|
||||
network_exit_health: environment.network_exit?.health_status,
|
||||
},
|
||||
})),
|
||||
].sort((a, b) => (b.environment?.id ?? 0) - (a.environment?.id ?? 0)));
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
@@ -143,14 +164,18 @@ export default function AccountManagementList() {
|
||||
{
|
||||
title: '账号',
|
||||
dataIndex: 'name',
|
||||
...fixedLeft<Row>({}),
|
||||
...fixedLeft<Row>({ width: 240 }),
|
||||
render: (_, account) => (
|
||||
<div>
|
||||
<Space><Avatar size="small" src={account.avatar_url} /><Typography.Link onClick={() => history.push(`/accounts/${account.id}`)}>{account.name || '待登录'}</Typography.Link></Space>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }} ellipsis={{ tooltip: true }}>
|
||||
{account.platform_account_key}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Flex align="center" gap={12}>
|
||||
<Avatar size={40} shape="square" src={account.avatar_url || undefined}>{(account.name || '账').slice(0, 1).toUpperCase()}</Avatar>
|
||||
{account.pending_environment ? (
|
||||
<Typography.Text>待登录</Typography.Text>
|
||||
) : (
|
||||
<Typography.Link onClick={() => history.push(`/accounts/${account.id}`)}>
|
||||
{account.name || '待登录'}
|
||||
</Typography.Link>
|
||||
)}
|
||||
</Flex>
|
||||
) },
|
||||
{
|
||||
title: '粉丝',
|
||||
@@ -175,9 +200,14 @@ export default function AccountManagementList() {
|
||||
{
|
||||
title: '作品数据',
|
||||
dataIndex: 'work_count',
|
||||
render: (_, account) => (
|
||||
render: (_, account) => account.pending_environment ? (
|
||||
<Typography.Text type="secondary">登录后显示作品数据</Typography.Text>
|
||||
) : (
|
||||
<Flex vertical>
|
||||
<Typography.Text>{formatCount(account.work_count ?? account.aweme_count)} 个作品</Typography.Text>
|
||||
<Typography.Text>{account.aweme_count == null ? '作品总数未知' : `${account.aweme_count} 个作品`}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
已采集 {account.work_count ?? '未知'} / 总计 {account.aweme_count ?? '未知'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
最近发布 {account.latest_published_at ? dateTime(account.latest_published_at) : '—'}
|
||||
</Typography.Text>
|
||||
@@ -215,6 +245,7 @@ export default function AccountManagementList() {
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (_, account) => {
|
||||
if (account.pending_environment) return <Tag>待登录</Tag>;
|
||||
// 与详情页同款三层判定(绑定/出口/账号),环境未绑定时退化为账号启停状态。
|
||||
const readiness = accountReadiness(account, account.environment ?? null);
|
||||
return (
|
||||
@@ -227,6 +258,7 @@ export default function AccountManagementList() {
|
||||
title: '业务状态',
|
||||
dataIndex: 'business_status',
|
||||
render: (status: Row['business_status']) => {
|
||||
if (!status) return <Typography.Text type="secondary">—</Typography.Text>;
|
||||
const statuses = {
|
||||
normal: { label: '正常', color: 'success' },
|
||||
muted: { label: '禁言', color: 'warning' },
|
||||
@@ -241,35 +273,46 @@ export default function AccountManagementList() {
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
align: 'right',
|
||||
...fixedRight<Row>({}),
|
||||
render: (_, account) => (
|
||||
<Space wrap>
|
||||
<Button type="text" size="small" onClick={() => startAccount(account)} loading={actionBusy === account.id}>
|
||||
启动
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={() => openTags(account)}>
|
||||
编辑标签
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={() => history.push(`/accounts/${account.id}/edit`)}>
|
||||
编辑账号
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除自有账号“${account.name || '待登录'}”吗?相关专属资源也会被清理。`}
|
||||
onConfirm={() => deleteAccount(account)}
|
||||
disabled={actionBusy === account.id}
|
||||
>
|
||||
<Button type="text" size="small" danger disabled={actionBusy === account.id}>
|
||||
删除
|
||||
...fixedRight<Row>({ width: 300 }),
|
||||
render: (_, account) => {
|
||||
const environmentAlias = account.pending_environment;
|
||||
if (environmentAlias) {
|
||||
return (
|
||||
<Button type="link" size="small" onClick={() => setEnvironmentTarget(environmentAlias)}>
|
||||
登录并同步账号
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) },
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Space>
|
||||
<Button type="text" size="small" onClick={() => startAccount(account)} loading={actionBusy === account.id}>
|
||||
启动
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={() => openTags(account)}>
|
||||
编辑标签
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={() => history.push(`/accounts/${account.id}/edit`)}>
|
||||
编辑账号
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除自有账号“${account.name || '待登录'}”吗?相关专属资源也会被清理。`}
|
||||
onConfirm={() => deleteAccount(account)}
|
||||
disabled={actionBusy === account.id}
|
||||
>
|
||||
<Button type="text" size="small" danger disabled={actionBusy === account.id}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">共 {rows.length} 个自有账号</Typography.Text>
|
||||
<Typography.Text type="secondary">共 {rows.length} 项</Typography.Text>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} disabled={pending} aria-label="刷新" />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
@@ -283,17 +326,6 @@ export default function AccountManagementList() {
|
||||
<AccountLoginModal accountID={environmentTarget} accountName="待登录环境" resource="environments"
|
||||
onClose={() => setEnvironmentTarget(null)} onSuccess={() => { setEnvironmentTarget(null); void load(); }} />
|
||||
) : null}
|
||||
{environments.length > 0 ? (
|
||||
<Card title="待登录环境">
|
||||
<Table rowKey="alias" dataSource={environments} pagination={tablePagination()} sticky columns={[
|
||||
{ title: '账号', render: () => <Typography.Text>待登录</Typography.Text> },
|
||||
{ title: '网关', dataIndex: 'gateway' },
|
||||
{ title: '环境标识', dataIndex: 'alias', render: (alias: string) => <Typography.Text copyable>{alias}</Typography.Text> },
|
||||
{ title: '状态', render: () => <Tag>待登录</Tag> },
|
||||
{ title: '操作', render: (_, env) => <Button type="link" onClick={() => setEnvironmentTarget(env.alias)}>登录并同步账号</Button> },
|
||||
]} />
|
||||
</Card>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
@@ -304,8 +336,8 @@ export default function AccountManagementList() {
|
||||
loading={pending}
|
||||
sticky
|
||||
pagination={tablePagination()}
|
||||
scroll={tableScroll(horizontal ? 1320 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)}
|
||||
locale={{ emptyText: '暂无自有账号。' }}
|
||||
scroll={{ x: 'max-content', y: vertical ? 'calc(100vh - 300px)' : undefined }}
|
||||
locale={{ emptyText: '暂无账号或待登录环境。' }}
|
||||
/>
|
||||
<Modal
|
||||
open={Boolean(tagTarget)}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 指标 GET /creator/works/:id/metrics,评论 GET /creator/comments?work_id=(定时采集由后端调度器维护)。
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Line } from '@ant-design/charts';
|
||||
import { Alert, App, Button, Card, Col, Drawer, Empty, Flex, InputNumber, Row, Space, Spin, Table, Typography } from 'antd';
|
||||
import { Alert, App, Button, Card, Col, Drawer, Empty, Flex, InputNumber, Pagination, Row, Space, Spin, Table, Typography } from 'antd';
|
||||
import {
|
||||
CommentOutlined,
|
||||
EyeOutlined,
|
||||
@@ -104,6 +104,12 @@ function GrowthChart({ metrics }: { metrics: any[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface CollectionProgress {
|
||||
work_count: number;
|
||||
aweme_count: number | null;
|
||||
works: { status: string; last_error?: string };
|
||||
}
|
||||
|
||||
interface WorksGalleryProps {
|
||||
/** 不传则不过滤来源(如竞品分析页跨全部竞品);监控/自有账号详情传具体账号 ID。 */
|
||||
sourceId?: string;
|
||||
@@ -116,7 +122,9 @@ interface WorksGalleryProps {
|
||||
export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters }: WorksGalleryProps) {
|
||||
const [works, setWorks] = useState<any[]>([]);
|
||||
const [workPage, setWorkPage] = useState(1);
|
||||
const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false });
|
||||
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);
|
||||
@@ -133,6 +141,16 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
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;
|
||||
@@ -144,10 +162,12 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
}
|
||||
const result = await getList({ resource: 'creator-works', page: workPage, pageSize: 25, filters });
|
||||
setWorks(result.data);
|
||||
setWorkPageInfo({ total: result.total, hasNext: Boolean(result.hasNext) });
|
||||
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]);
|
||||
@@ -188,6 +208,18 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
loadComments(work.id, 1);
|
||||
}
|
||||
|
||||
let progressHint = '采集进度暂不可用';
|
||||
if (collectionProgress) {
|
||||
const { work_count: collected, aweme_count: total, works: checkpoint } = collectionProgress;
|
||||
progressHint = `已采集 ${collected} / 总计 ${total ?? '未知'}`;
|
||||
if (total == null) progressHint += ';抖音作品总数未知,暂无法判断是否完整。';
|
||||
else if (collected < total) progressHint += ';尚未采集完整,当前仅展示已采集作品。';
|
||||
else if (collected > total) progressHint += ';已采集数量与抖音总数不一致,请重新同步确认。';
|
||||
else progressHint += ';当前已采集数量与抖音总数一致。';
|
||||
if (checkpoint.status === 'running') progressHint += ' 正在采集。';
|
||||
if (checkpoint.status === 'failed' || checkpoint.status === 'blocked') progressHint += ` 采集未完成:${checkpoint.last_error || '请查看账号采集状态。'}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
@@ -197,7 +229,12 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">共 {workPageInfo.total} 个作品;作品由系统按采集间隔自动同步。</Typography.Text>
|
||||
<Flex vertical gap={4}>
|
||||
{sourceType === 'owned' ? <Typography.Text>{progressHint}</Typography.Text> : null}
|
||||
<Typography.Text type="secondary">
|
||||
{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>
|
||||
@@ -214,6 +251,9 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{progressError ? (
|
||||
<Alert type="error" showIcon title={progressError.message} style={{ marginBottom: 16 }} action={<Button size="small" onClick={loadWorks}>重试</Button>} />
|
||||
) : null}
|
||||
{worksError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
@@ -269,14 +309,15 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
</Spin>
|
||||
{workPageInfo.total > works.length ? (
|
||||
<Flex justify="flex-end" style={{ marginTop: 12 }}>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setWorkPage((page) => Math.max(1, page - 1))} disabled={workPage === 1 || worksPending}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setWorkPage((page) => page + 1)} disabled={!workPageInfo.hasNext || worksPending}>
|
||||
下一页
|
||||
</Button>
|
||||
</Space>
|
||||
<Pagination
|
||||
current={workPage}
|
||||
pageSize={25}
|
||||
total={workPageInfo.total}
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `${sourceType === 'owned' ? '当前列表共' : '共'} ${total} 个作品`}
|
||||
disabled={worksPending}
|
||||
onChange={(page) => setWorkPage(page)}
|
||||
/>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
@@ -185,7 +185,15 @@ export default function Page() {
|
||||
|
||||
const worksCollection = collectionStatus?.works;
|
||||
const worksMeta = collectionStatusMeta[worksCollection?.status ?? 'pending'] || { label: worksCollection?.status || '待采集' };
|
||||
const latestMetric = accountMetrics.length ? accountMetrics[accountMetrics.length - 1] : null;
|
||||
// 与列表一致:某轮缺少字段时保留该字段最近一次实际采集的值。
|
||||
const latestMetric = accountMetrics.reduce<Record<string, number>>((counts, metric) => {
|
||||
for (const { key } of accountSeriesMeta) {
|
||||
if (metric[key] != null) counts[key] = metric[key];
|
||||
}
|
||||
return counts;
|
||||
}, {});
|
||||
// 作品总数不受曲线查询最近 100 条记录的范围限制。
|
||||
if (collectionStatus?.aweme_count != null) latestMetric.aweme_count = collectionStatus.aweme_count;
|
||||
|
||||
// 账号状态卡局部操作:启动/暂停两态开关留在状态卡右上角(Card extra),不进页头槽。
|
||||
const paused = account.runtime_status === 'paused';
|
||||
|
||||
@@ -10,13 +10,13 @@ const element = (type, props = {}) => ({ type, props });
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Execute the real page/component with isolated hooks and API calls, without a browser.
|
||||
function harness(relativePath, api = {}) {
|
||||
function harness(relativePath, api = {}, componentName = 'default') {
|
||||
let cursor = 0;
|
||||
let props = {};
|
||||
const slots = [];
|
||||
const effects = [];
|
||||
const forms = [];
|
||||
const calls = { success: [], error: [], requests: [], updates: [], navigation: [] };
|
||||
const calls = { success: [], error: [], requests: [], updates: [], navigation: [], lists: [], collectionReads: [] };
|
||||
const useState = (initial) => {
|
||||
const index = cursor++;
|
||||
if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial;
|
||||
@@ -43,7 +43,7 @@ function harness(relativePath, api = {}) {
|
||||
};
|
||||
const named = (name, children = []) => Object.assign(function Component() {},
|
||||
{ displayName: name }, Object.fromEntries(children.map((child) => [child, `${name}.${child}`])));
|
||||
const antd = Object.fromEntries(['Alert', 'Button', 'Card', 'Col', 'Flex', 'Modal', 'Row', 'Select', 'Space', 'Statistic', 'Tag'].map((name) => [name, name]));
|
||||
const antd = Object.fromEntries(['Alert', 'Button', 'Card', 'Col', 'Drawer', 'Empty', 'Flex', 'InputNumber', 'Modal', 'Pagination', 'Row', 'Select', 'Space', 'Spin', 'Statistic', 'Table', 'Tag'].map((name) => [name, name]));
|
||||
antd.Descriptions = named('Descriptions', ['Item']);
|
||||
antd.Typography = named('Typography', ['Title', 'Text', 'Paragraph']);
|
||||
antd.Input = named('Input', ['Password', 'TextArea']);
|
||||
@@ -69,11 +69,28 @@ function harness(relativePath, api = {}) {
|
||||
'react/jsx-runtime': { jsx: element, jsxs: element, Fragment: 'Fragment' },
|
||||
'@umijs/max': { useParams: () => ({ id: 'account-1' }), history: { push: (url) => calls.navigation.push(url) } },
|
||||
'@ant-design/charts': { Line: 'Line' },
|
||||
'@ant-design/icons': new Proxy({}, { get: (_, name) => String(name) }),
|
||||
antd,
|
||||
'@/services/api': {
|
||||
getOne: async () => api.detail,
|
||||
getList: async ({ resource }) => ({ data: resource === 'network-exits' ? (api.exits || []) : [api.profile || {}] }),
|
||||
creatorGet: async (url) => url.endsWith('/metrics') ? (api.metrics || []) : (api.collectionStatus || {}),
|
||||
getList: async (options) => {
|
||||
calls.lists.push(options);
|
||||
if (options.resource === 'creator-works') {
|
||||
if (api.workListError) throw api.workListError;
|
||||
return api.workPages?.[options.page] || { data: [], total: 0, hasNext: false };
|
||||
}
|
||||
if (options.resource === 'creator-comments') return { data: api.comments || [], total: (api.comments || []).length };
|
||||
return { data: options.resource === 'network-exits' ? (api.exits || []) : [api.profile || {}] };
|
||||
},
|
||||
creatorGet: async (url) => {
|
||||
if (url.endsWith('/metrics')) {
|
||||
if (api.metricsError) throw api.metricsError;
|
||||
return api.metrics || [];
|
||||
}
|
||||
calls.collectionReads.push(url);
|
||||
if (api.collectionStatusError) throw api.collectionStatusError;
|
||||
return api.collectionStatus || {};
|
||||
},
|
||||
creatorAction: async () => {}, accountAction: async () => {},
|
||||
creatorUpdate: async (url, payload) => { calls.updates.push({ url, payload }); return api.updateResult || payload; },
|
||||
...api.services,
|
||||
@@ -108,13 +125,13 @@ function harness(relativePath, api = {}) {
|
||||
};
|
||||
loaded._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
||||
fileName: filename, compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText, filename);
|
||||
}).outputText + (componentName === 'default' ? '' : `\nexports[${JSON.stringify(componentName)}] = ${componentName};`), filename);
|
||||
return {
|
||||
calls, forms,
|
||||
render(nextProps = props) {
|
||||
props = nextProps;
|
||||
cursor = 0;
|
||||
const tree = loaded.exports.default(props);
|
||||
const tree = loaded.exports[componentName](props);
|
||||
while (effects.length) effects.shift()();
|
||||
return tree;
|
||||
},
|
||||
@@ -162,6 +179,153 @@ test('owned account metrics are one horizontal row with each number above its la
|
||||
assert.equal(byType(dataCard, 'Descriptions.Item').length, 0);
|
||||
});
|
||||
|
||||
test('作品总数保留最近非空值,不会被下一次缺失字段的快照清空', async () => {
|
||||
const page = harness(detailPath, { detail, metrics: [{ aweme_count: 100 }, { follower_count: 20, aweme_count: null }] });
|
||||
const tree = await page.mount();
|
||||
assert.equal(byType(card(tree, '账号数据'), 'Statistic')[4].props.value, '100');
|
||||
});
|
||||
|
||||
test('详情作品总数使用完整采集历史,而非仅依赖最近指标记录', async () => {
|
||||
const page = harness(detailPath, { detail, metrics: [{ follower_count: 20 }], collectionStatus: { work_count: 28, aweme_count: 100 } });
|
||||
const tree = await page.mount();
|
||||
assert.equal(byType(card(tree, '账号数据'), 'Statistic')[4].props.value, '100');
|
||||
});
|
||||
|
||||
test('自有账号作品展示独立采集进度,历史作品可翻页且筛选不改变进度分母', async () => {
|
||||
const api = {
|
||||
collectionStatus: { work_count: 28, aweme_count: 100, works: { status: 'succeeded' } },
|
||||
workPages: {
|
||||
1: { data: [{ id: 'new-work' }], total: 28, hasNext: true },
|
||||
2: { data: [{ id: 'historical-work' }], total: 28, hasNext: false },
|
||||
},
|
||||
};
|
||||
const page = harness('src/components/creator/WorksGallery.tsx', api);
|
||||
let tree = await page.mount({ sourceId: 'account/1', sourceType: 'owned' });
|
||||
assert.match(text(tree), /已采集 28 \/ 总计 100/);
|
||||
assert.match(text(tree), /尚未采集完整/);
|
||||
assert.equal(page.calls.collectionReads[0], '/creator/accounts/account%2F1/collection-status');
|
||||
assert.equal(byType(tree, 'Pagination')[0].props.total, 28);
|
||||
byType(tree, 'Pagination')[0].props.onChange(2);
|
||||
page.render();
|
||||
await flush();
|
||||
tree = page.render();
|
||||
assert.equal(byType(tree, 'Pagination')[0].props.current, 2);
|
||||
assert.equal(page.calls.lists.at(-1).page, 2);
|
||||
assert.equal(page.calls.lists.at(-1).filters.source_type, 'owned');
|
||||
assert.equal(byType(tree, 'Col')[0].props.children.props.cover.props.workID, 'historical-work');
|
||||
api.workPages[1] = { data: [], total: 0, hasNext: false };
|
||||
byType(tree, 'InputNumber')[0].props.onChange(10);
|
||||
page.render();
|
||||
await flush();
|
||||
tree = page.render();
|
||||
assert.equal(page.calls.lists.at(-1).page, 1);
|
||||
assert.equal(page.calls.lists.at(-1).filters.min_likes_growth, 10);
|
||||
assert.match(text(tree), /已采集 28 \/ 总计 100/);
|
||||
assert.match(text(tree), /符合筛选 0 个作品/);
|
||||
});
|
||||
|
||||
test('作品进度区分未知、零作品与数量不一致,不把采集成功当作完整', async () => {
|
||||
for (const [count, total, expected] of [[7, null, /总计 未知/], [0, 0, /已采集 0 \/ 总计 0/], [7, 3, /数量与抖音总数不一致/]]) {
|
||||
const page = harness('src/components/creator/WorksGallery.tsx', { collectionStatus: { work_count: count, aweme_count: total, works: { status: 'succeeded' } } });
|
||||
const tree = await page.mount({ sourceId: 'account-1', sourceType: 'owned' });
|
||||
assert.match(text(tree), expected);
|
||||
if (total === null) assert.match(text(tree), /暂无法判断是否完整/);
|
||||
}
|
||||
});
|
||||
|
||||
test('读取作品进度失败明确提示,已采集作品仍然展示,刷新能重试', async (t) => {
|
||||
t.mock.method(console, 'error', () => {});
|
||||
const api = { collectionStatusError: new Error('进度读取失败'), workPages: { 1: { data: [{ id: 'work-1' }], total: 1, hasNext: false } } };
|
||||
const page = harness('src/components/creator/WorksGallery.tsx', api);
|
||||
let tree = await page.mount({ sourceId: 'account-1', sourceType: 'owned' });
|
||||
assert.equal(byType(tree, 'Col').length, 1);
|
||||
assert.equal(byType(tree, 'Alert')[0].props.title, '进度读取失败');
|
||||
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();
|
||||
tree = page.render();
|
||||
assert.equal(byType(tree, 'Alert').length, 0);
|
||||
assert.match(text(tree), /已采集 1 \/ 总计 1/);
|
||||
assert.equal(page.calls.collectionReads.length, 2);
|
||||
});
|
||||
|
||||
test('监控账号仍展示已采集列表,不请求自有账号采集进度', async () => {
|
||||
const page = harness('src/components/creator/WorksGallery.tsx');
|
||||
const tree = await page.mount({ sourceId: 'monitor-1', sourceType: 'competitor' });
|
||||
assert.equal(page.calls.collectionReads.length, 0);
|
||||
assert.match(text(tree), /共 0 个作品/);
|
||||
});
|
||||
|
||||
test('作品列表读取失败明确提示并可重试,不影响独立采集进度', async (t) => {
|
||||
t.mock.method(console, 'error', () => {});
|
||||
const api = { workListError: new Error('作品读取失败'), collectionStatus: { work_count: 28, aweme_count: 100, works: { status: 'running' } } };
|
||||
const page = harness('src/components/creator/WorksGallery.tsx', api);
|
||||
let tree = await page.mount({ sourceId: 'account-1', sourceType: 'owned' });
|
||||
assert.equal(byType(tree, 'Alert')[0].props.title, '作品读取失败');
|
||||
assert.match(text(tree), /正在采集/);
|
||||
api.workListError = null;
|
||||
await byType(tree, 'Alert')[0].props.action.props.onClick();
|
||||
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();
|
||||
tree = page.render();
|
||||
assert.match(text(tree), /采集未完成:浏览器未登录/);
|
||||
}
|
||||
});
|
||||
|
||||
test('作品抽屉读取该作品的指标与评论,评论翻页保留每页条数', async () => {
|
||||
const work = { id: 'work/1', title: '历史作品', description: '作品正文', published_at: '2025-01-01', likes_growth: 10, likes: 100 };
|
||||
const page = harness('src/components/creator/WorksGallery.tsx', {
|
||||
workPages: { 1: { data: [work], total: 1, hasNext: false } },
|
||||
metrics: [{ collected_at: '2025-01-01', likes: 100 }],
|
||||
comments: [{ id: 'comment-1', author_name: '作者', published_at: '2025-01-01', content: '评论正文' }],
|
||||
});
|
||||
let tree = await page.mount({ sourceType: 'competitor', sourceId: 'monitor-1' });
|
||||
await byType(tree, 'Col')[0].props.children.props.onClick();
|
||||
await flush();
|
||||
tree = page.render();
|
||||
const tables = byType(tree, 'Table');
|
||||
assert.equal(tables[0].props.dataSource[0].likes, 100);
|
||||
assert.equal(tables[1].props.dataSource[0].content, '评论正文');
|
||||
for (const column of tables[0].props.columns) {
|
||||
if (column.render && column.dataIndex !== 'collected_at') {
|
||||
assert.ok(column.render(0) != null);
|
||||
assert.ok(column.render(null) != null);
|
||||
}
|
||||
}
|
||||
assert.match(text(tables[1].props.columns[0].render(null, {})), /—/);
|
||||
assert.match(text(tables[1].props.columns[0].render(null, { author_name: '作者', published_at: '2025-01-01' })), /作者/);
|
||||
tables[1].props.pagination.onChange(2, 20);
|
||||
page.render();
|
||||
await flush();
|
||||
tree = page.render();
|
||||
assert.equal(byType(tree, 'Table')[1].props.pagination.current, 2);
|
||||
assert.equal(byType(tree, 'Table')[1].props.pagination.pageSize, 20);
|
||||
assert.equal(page.calls.lists.at(-1).filters.work_id, 'work/1');
|
||||
assert.equal(page.calls.lists.at(-1).pageSize, 20);
|
||||
byType(tree, 'Drawer')[0].props.onClose();
|
||||
assert.equal(byType(page.render(), 'Drawer')[0].props.open, false);
|
||||
});
|
||||
|
||||
test('作品统计缺失时使用明确占位,增长曲线保留零值并排除未知值', async () => {
|
||||
const stats = harness('src/components/creator/WorksGallery.tsx', {}, 'WorkStatsGrid');
|
||||
const statsTree = await stats.mount({ work: { play_count: 0, likes: 100 } });
|
||||
assert.match(text(statsTree), /0/);
|
||||
assert.match(text(statsTree), /100/);
|
||||
assert.match(text(statsTree), /—/);
|
||||
const chart = harness('src/components/creator/WorksGallery.tsx', {}, 'GrowthChart');
|
||||
let tree = await chart.mount({ metrics: [] });
|
||||
assert.equal(tree, null);
|
||||
tree = await chart.mount({ metrics: [{ collected_at: '2025-01-01', play_count: 0, likes: 100, shares: null }] });
|
||||
assert.equal(tree.props.data.length, 2);
|
||||
assert.equal(tree.props.data[0].value, 0);
|
||||
assert.equal(tree.props.yAxis.labelFormatter(12000), '1.2w');
|
||||
assert.equal(tree.props.yAxis.labelFormatter(0), '0');
|
||||
});
|
||||
|
||||
test('all fixed resources live in account status and detail has no exit switching', async () => {
|
||||
const page = harness(detailPath, { detail });
|
||||
const tree = await page.mount();
|
||||
|
||||
@@ -12,6 +12,73 @@ const source = ts.createSourceFile(
|
||||
);
|
||||
const code = source.getFullText();
|
||||
|
||||
function accountRenderer(file, title = '账号') {
|
||||
const ast = ts.createSourceFile(file, readFileSync(`${__dirname}/../src/components/accounts/${file}`, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
let renderer;
|
||||
function visit(node) {
|
||||
if (!renderer && ts.isObjectLiteralExpression(node) && node.properties.some(p => ts.isPropertyAssignment(p) && p.name.getText(ast) === 'title' && p.initializer.getText(ast) === `'${title}'`)) {
|
||||
renderer = node.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText(ast) === 'render').initializer.getText(ast);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(ast);
|
||||
assert.ok(renderer, `${file} 缺少账号列`);
|
||||
const { createRequire } = require('node:module');
|
||||
const requireWeb = createRequire(`${__dirname}/../package.json`);
|
||||
const imports = { ...requireWeb('antd'), history: { push() {} }, formatCount: (value) => value == null ? '—' : String(value), dateTime: (value) => value };
|
||||
const js = ts.transpileModule(`const render = ${renderer};`, { compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.CommonJS } }).outputText;
|
||||
return require('node:vm').runInNewContext(`${js}\nrender`, { ...imports, exports: {}, require: requireWeb });
|
||||
}
|
||||
|
||||
test('我的账号列不显示 UID,头像与监控账号一致且详情入口不变', () => {
|
||||
const renderOwned = accountRenderer('AccountManagementList.tsx');
|
||||
const renderMonitoring = accountRenderer('MonitoringAccountList.tsx');
|
||||
for (const [name, avatar] of [['豪的抖音', 'https://example.com/avatar.jpg'], ['a', ''], ['', undefined]]) {
|
||||
const data = { id: 'account-1', name, nickname: name, avatar_url: avatar, platform_account_key: '99491952055' };
|
||||
const owned = renderOwned(undefined, data);
|
||||
const monitored = renderMonitoring(undefined, data);
|
||||
assert.equal(owned.type, monitored.type);
|
||||
assert.equal(owned.props.align, 'center');
|
||||
assert.equal(owned.props.gap, monitored.props.gap);
|
||||
const [ownedAvatar, ownedName] = owned.props.children;
|
||||
const [monitoredAvatar, monitoredName] = monitored.props.children;
|
||||
assert.deepEqual(Object.keys(ownedAvatar.props), Object.keys(monitoredAvatar.props));
|
||||
for (const key of Object.keys(monitoredAvatar.props)) assert.equal(ownedAvatar.props[key], monitoredAvatar.props[key]);
|
||||
assert.equal(ownedName.props.children, name || '待登录');
|
||||
if (name) assert.equal(ownedName.props.children, monitoredName.props.children);
|
||||
assert.equal(data.name, name, '显示占位文案不修改原始昵称');
|
||||
const requireWeb = require('node:module').createRequire(`${__dirname}/../package.json`);
|
||||
const html = requireWeb('react-dom/server').renderToStaticMarkup(owned);
|
||||
assert.ok(!html.includes(data.platform_account_key), '账号列不应显示 UID');
|
||||
}
|
||||
assert.match(code, /history\.push\(`\/accounts\/\$\{account\.id\}`\)/);
|
||||
});
|
||||
|
||||
test('作品总数来自抖音,已采集数量只表示进度', () => {
|
||||
const render = accountRenderer('AccountManagementList.tsx', '作品数据');
|
||||
const requireWeb = require('node:module').createRequire(`${__dirname}/../package.json`);
|
||||
const html = requireWeb('react-dom/server').renderToStaticMarkup(render(undefined, { aweme_count: 100, work_count: 7 }));
|
||||
assert.match(html, /100 个作品/);
|
||||
assert.match(html, /已采集 7 \/ 总计 100/);
|
||||
assert.doesNotMatch(html, />7 个作品/);
|
||||
const unknown = requireWeb('react-dom/server').renderToStaticMarkup(render(undefined, { work_count: 7 }));
|
||||
assert.match(unknown, /作品总数未知/);
|
||||
assert.match(unknown, /已采集 7 \/ 总计 未知/);
|
||||
const zero = requireWeb('react-dom/server').renderToStaticMarkup(render(undefined, { aweme_count: 0, work_count: 0 }));
|
||||
assert.match(zero, /0 个作品/);
|
||||
assert.match(zero, /已采集 0 \/ 总计 0/);
|
||||
});
|
||||
|
||||
test('未登录环境没有真实作品总数,不把占位零当作抖音数据', () => {
|
||||
const render = accountRenderer('AccountManagementList.tsx', '作品数据');
|
||||
const requireWeb = require('node:module').createRequire(`${__dirname}/../package.json`);
|
||||
const html = requireWeb('react-dom/server').renderToStaticMarkup(render(undefined, {
|
||||
pending_environment: true, aweme_count: 0, work_count: 0,
|
||||
}));
|
||||
assert.match(html, /登录后显示作品数据/);
|
||||
assert.doesNotMatch(html, /0 个作品|总计 0/);
|
||||
});
|
||||
|
||||
test('列表展示粉丝、关注、获赞、互关、评论独立数值列', () => {
|
||||
for (const [title, value] of [
|
||||
['粉丝', 'account.follower_count'],
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { Module, createRequire } = require('node:module');
|
||||
const { resolve } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const ts = require('typescript');
|
||||
const requireWeb = createRequire(resolve(__dirname, '../package.json'));
|
||||
const file = resolve(__dirname, '../src/components/accounts/AccountManagementList.tsx');
|
||||
|
||||
function setup({ accounts = [{ id: 'account-1', name: '真实昵称', business_status: 'normal', tags: ['主账号'] }], environments = [{ alias: 'env-1', gateway: 'gw-a' }], loadError, actionError, grid = { vertical: true, horizontal: true } } = {}) {
|
||||
const states = [], calls = [], messages = [], paths = [];
|
||||
let cursor = 0, started = false;
|
||||
const marker = displayName => Object.assign(() => null, { displayName });
|
||||
const form = { setFieldsValue(values) { form.values = values; }, async validateFields() { return form.values; } };
|
||||
const antd = {};
|
||||
for (const name of ['Alert', 'Avatar', 'Button', 'Card', 'Flex', 'Modal', 'Popconfirm', 'Select', 'Space', 'Table', 'Tag']) antd[name] = marker(name);
|
||||
antd.Typography = { Text: marker('Text'), Link: marker('Link') };
|
||||
antd.Form = Object.assign(marker('Form'), { Item: marker('Form.Item'), useForm: () => [form] });
|
||||
antd.App = { useApp: () => ({ message: { success: text => messages.push(text), error: text => messages.push(text) } }) };
|
||||
const loaded = new Module(file, module);
|
||||
const options = { accounts, environments, loadError, actionError };
|
||||
loaded.require = id => {
|
||||
if (id === 'react') return {
|
||||
useState(value) { const index = cursor++; if (!(index in states)) states[index] = value; return [states[index], next => { states[index] = typeof next === 'function' ? next(states[index]) : next; }]; },
|
||||
useCallback: callback => callback,
|
||||
useEffect(effect) { if (!started) { started = true; effect(); } },
|
||||
};
|
||||
if (id === 'antd') return antd;
|
||||
if (id === '@umijs/max') return { history: { push: path => paths.push(path) } };
|
||||
if (id === '@/services/api') return {
|
||||
async creatorGet(path) { calls.push({ path }); if (options.loadError) throw options.loadError; return path === '/creator/environments' ? options.environments : options.accounts; },
|
||||
async creatorAction(path) { calls.push({ path }); if (options.actionError) throw options.actionError; },
|
||||
async creatorUpdate(path, data) { calls.push({ path, data }); if (options.actionError) throw options.actionError; },
|
||||
async remove(resource, id) { calls.push({ resource, id }); if (options.actionError) throw options.actionError; },
|
||||
};
|
||||
if (id === '@/utils/helpers') return { accountReadiness: () => ({ ready: true, label: '可运行' }), dateTime: value => value, conflictMessage: (error, fallback) => error.message || fallback };
|
||||
if (id === '@/utils/table') return { fixedLeft: column => ({ ...column, fixed: 'left' }), fixedRight: column => ({ ...column, fixed: 'right' }), tablePagination: () => ({}), tableScroll: (x, y) => ({ x, y }), useOverflowGrid: () => grid };
|
||||
if (id.startsWith('@/components/accounts/')) return { default: marker(id.split('/').at(-1)) };
|
||||
return requireWeb(id);
|
||||
};
|
||||
loaded._compile(ts.transpileModule(readFileSync(file, 'utf8'), { fileName: file, compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, inlineSourceMap: true, inlineSources: true } }).outputText, file);
|
||||
let tree;
|
||||
const render = () => { cursor = 0; tree = loaded.exports.default(); return tree; };
|
||||
const findAll = (name, node = tree) => {
|
||||
if (!node || typeof node !== 'object') return [];
|
||||
return [...(node.type?.displayName === name ? [node] : []), ...[node.props?.children].flat(Infinity).filter(child => child != null).flatMap(child => findAll(name, child))];
|
||||
};
|
||||
const find = (name, node) => findAll(name, node)[0];
|
||||
const column = title => find('Table').props.columns.find(column => column.title === title);
|
||||
const cell = (title, row) => { const col = column(title); return col.render(row[col.dataIndex], row); };
|
||||
render();
|
||||
return { render, find, findAll, cell, calls, messages, paths, form, options };
|
||||
}
|
||||
const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
|
||||
const text = node => node == null ? '' : typeof node === 'object' ? [node.props?.children].flat(Infinity).map(text).join('') : String(node);
|
||||
const ready = async options => { const s = setup(options); await flush(); s.render(); return s; };
|
||||
|
||||
test('待登录环境与已登录账号共用唯一列表、计数及分页', async () => {
|
||||
const s = await ready();
|
||||
assert.equal(s.findAll('Table').length, 1);
|
||||
assert.equal(s.findAll('Card').length, 1);
|
||||
const rows = s.find('Table').props.dataSource;
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].name, '真实昵称');
|
||||
assert.match(text(s.find('Text')), /共 2 项/);
|
||||
assert.equal(s.find('Table').props.loading, false);
|
||||
assert.notEqual(rows[0].id, rows[1].id);
|
||||
});
|
||||
|
||||
for (const horizontal of [false, true]) {
|
||||
for (const vertical of [false, true]) {
|
||||
test(`账号与操作列不挤压,其他列横向滚动(横向溢出 ${horizontal},纵向溢出 ${vertical})`, async () => {
|
||||
const s = await ready({ grid: { horizontal, vertical } });
|
||||
const { columns, scroll, dataSource } = s.find('Table').props;
|
||||
assert.equal(columns[0].title, '账号');
|
||||
assert.equal(columns[0].fixed, 'left');
|
||||
assert.equal(columns[0].width, 240);
|
||||
assert.equal(columns.at(-1).title, '操作');
|
||||
assert.equal(columns.at(-1).fixed, 'right');
|
||||
assert.equal(columns.at(-1).width, 300);
|
||||
assert.ok(columns.slice(1, -1).every(column => !column.fixed));
|
||||
assert.equal(scroll.x, 'max-content', '宽屏也应按内容宽度滚动,而不是挤压两侧列');
|
||||
assert.equal(scroll.y, vertical ? 'calc(100vh - 300px)' : undefined);
|
||||
const row = dataSource.find(row => !row.pending_environment);
|
||||
assert.notEqual(s.find('Space', s.cell('操作', row)).props.wrap, true, '操作按钮不得换行折叠');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('按共用环境数据库ID倒序排列,新建环境与账号在前,登录绑定不改变位置', async () => {
|
||||
const accounts = [
|
||||
{ id: 'account-z', name: '旧账号', environment: { id: 2, alias: 'env-z' } },
|
||||
{ id: 'account-a', name: '新账号', environment: { id: 12, alias: 'env-a' } },
|
||||
];
|
||||
const environments = [{ id: 9, alias: 'env-9' }, { id: 20, alias: 'env-20' }];
|
||||
const s = await ready({ accounts, environments });
|
||||
assert.deepEqual(s.find('Table').props.dataSource.map(row => row.id), ['environment:env-20', 'account-a', 'environment:env-9', 'account-z']);
|
||||
assert.deepEqual(accounts.map(row => row.id), ['account-z', 'account-a'], '排序不修改服务返回的原数据');
|
||||
s.find('Button', s.cell('操作', s.find('Table').props.dataSource[0])).props.onClick(); s.render();
|
||||
s.options.accounts = [...accounts, { id: 'account-bound', name: '已登录', environment: { id: 20, alias: 'env-20' } }];
|
||||
s.options.environments = [{ id: 9, alias: 'env-9' }];
|
||||
s.find('AccountLoginModal').props.onSuccess(); await flush(); s.render();
|
||||
assert.deepEqual(s.find('Table').props.dataSource.map(row => row.id), ['account-bound', 'account-a', 'environment:env-9', 'account-z']);
|
||||
});
|
||||
|
||||
test('待登录行缺失资料显示默认值,不伪造账号、业务状态或详情入口', async () => {
|
||||
const s = await ready({ accounts: [] });
|
||||
const row = s.find('Table').props.dataSource[0];
|
||||
assert.ok(row, '环境应成为主列表中的一行');
|
||||
assert.equal(row.name, '');
|
||||
const name = s.cell('账号', row);
|
||||
assert.equal(s.find('Avatar', name).props.src, undefined);
|
||||
assert.match(text(name), /待登录/);
|
||||
assert.equal(s.find('Link', name), undefined);
|
||||
for (const title of ['粉丝', '关注', '获赞', '互关', '评论']) assert.equal(s.cell(title, row), '0');
|
||||
assert.equal(text(s.cell('作品数据', row)), '登录后显示作品数据');
|
||||
assert.equal(text(s.cell('标签', row)), '未设置');
|
||||
assert.equal(text(s.cell('业务状态', row)), '—');
|
||||
assert.equal(text(s.cell('状态', row)), '待登录');
|
||||
assert.match(text(s.cell('运行环境', row)), /env-1未启动网关 gw-a机器直连/);
|
||||
const actions = s.cell('操作', row);
|
||||
assert.equal(s.findAll('Button', actions).length, 1);
|
||||
assert.equal(text(actions), '登录并同步账号');
|
||||
assert.equal(s.find('Popconfirm', actions), undefined);
|
||||
assert.equal(s.paths.length, 0);
|
||||
assert.deepEqual(s.options.environments, [{ alias: 'env-1', gateway: 'gw-a' }], '默认值不能写回环境数据');
|
||||
});
|
||||
|
||||
test('登录使用真实环境标识,成功后同一列表刷新为真实账号,不保留重复环境', async () => {
|
||||
const s = await ready({ accounts: [] });
|
||||
const row = s.find('Table').props.dataSource[0];
|
||||
assert.ok(row);
|
||||
s.find('Button', s.cell('操作', row)).props.onClick(); s.render();
|
||||
let modal = s.find('AccountLoginModal');
|
||||
assert.equal(modal.props.accountID, 'env-1');
|
||||
assert.equal(modal.props.resource, 'environments');
|
||||
modal.props.onClose(); s.render(); assert.equal(s.find('AccountLoginModal'), undefined);
|
||||
s.find('Button', s.cell('操作', row)).props.onClick(); s.render();
|
||||
modal = s.find('AccountLoginModal');
|
||||
s.options.accounts = [{ id: 'account-new', name: '新昵称', business_status: 'normal' }];
|
||||
s.options.environments = [];
|
||||
modal.props.onSuccess(); await flush(); s.render();
|
||||
assert.equal(s.find('AccountLoginModal'), undefined);
|
||||
assert.deepEqual(s.find('Table').props.dataSource.map(row => row.id), ['account-new']);
|
||||
});
|
||||
|
||||
test('待登录环境真实名称、网关、运行与出口信息仍被保留', async () => {
|
||||
const s = await ready({ accounts: [], environments: [{ alias: 'env-2', name: '环境二', gateway: 'gw-b', runtime_id: 'runtime-2', runtime_cleanup_pending: true, network_exit: { id: 'exit-2', health_status: 'healthy' } }] });
|
||||
const row = s.find('Table').props.dataSource[0]; assert.ok(row);
|
||||
assert.match(text(s.cell('运行环境', row)), /环境二停止待确认网关 gw-b出口 exit-2 · 健康/);
|
||||
});
|
||||
|
||||
test('空列表不生成占位行,环境加载失败不冒充成功并支持重试', async () => {
|
||||
const empty = await ready({ accounts: [], environments: [] });
|
||||
assert.equal(empty.findAll('Table').length, 1);
|
||||
assert.deepEqual(empty.find('Table').props.dataSource, []);
|
||||
const s = await ready({ loadError: new Error('环境加载失败') });
|
||||
assert.equal(s.find('Alert').props.title, '环境加载失败');
|
||||
assert.equal(s.find('Table').props.loading, false);
|
||||
s.options.loadError = undefined;
|
||||
await s.find('Alert').props.action.props.onClick(); s.render();
|
||||
assert.equal(s.find('Alert'), undefined);
|
||||
assert.equal(s.find('Table').props.dataSource.length, 2);
|
||||
});
|
||||
|
||||
test('已登录账号仍可访问详情、编辑、启动、删除和保存标签', async () => {
|
||||
const s = await ready({ environments: [] });
|
||||
let row = s.find('Table').props.dataSource[0];
|
||||
s.find('Link', s.cell('账号', row)).props.onClick();
|
||||
assert.equal(s.paths.at(-1), '/accounts/account-1');
|
||||
const actions = s.cell('操作', row);
|
||||
s.findAll('Button', actions).find(button => text(button) === '编辑账号').props.onClick();
|
||||
assert.equal(s.paths.at(-1), '/accounts/account-1/edit');
|
||||
await s.findAll('Button', actions).find(button => text(button) === '启动').props.onClick(); s.render();
|
||||
assert.ok(s.calls.some(call => call.path === '/phase-a/accounts/account-1/start'));
|
||||
let login = s.find('AccountLoginModal'); assert.equal(login.props.accountID, 'account-1');
|
||||
login.props.onSuccess(); await flush(); s.render();
|
||||
login = s.find('AccountLoginModal'); login.props.onClose(); s.render();
|
||||
s.findAll('Button', actions).find(button => text(button) === '编辑标签').props.onClick(); s.render();
|
||||
assert.deepEqual(s.form.values.tags, ['主账号']);
|
||||
s.form.values = { tags: ['新标签'] };
|
||||
await s.find('Modal').props.onOk(); s.render();
|
||||
row = s.find('Table').props.dataSource[0]; assert.deepEqual(row.tags, ['新标签']);
|
||||
assert.ok(s.calls.some(call => call.path === '/creator/accounts/account-1/tags'));
|
||||
await s.find('Popconfirm', actions).props.onConfirm();
|
||||
assert.ok(s.calls.some(call => call.resource === 'accounts' && call.id === 'account-1'));
|
||||
});
|
||||
|
||||
test('已登录账号缺失业务状态有明确占位,真实指标和标签保持原样', async () => {
|
||||
const s = await ready({ environments: [], accounts: [{ id: 'account-2', name: '', follower_count: 20000, aweme_count: 100, work_count: 7, latest_published_at: '2026-01-01', environment: { alias: 'env-2', runtime_id: 'runtime-2', network_exit_id: 'exit-2', network_exit_health: 'unchecked' } }] });
|
||||
const row = s.find('Table').props.dataSource[0];
|
||||
assert.equal(s.cell('粉丝', row), '2.0w');
|
||||
assert.equal(s.cell('关注', row), '—');
|
||||
assert.equal(text(s.cell('业务状态', row)), '—');
|
||||
assert.match(text(s.cell('作品数据', row)), /100 个作品已采集 7 \/ 总计 100最近发布 2026-01-01/);
|
||||
assert.match(text(s.cell('运行环境', row)), /env-2运行中网关 —出口 exit-2 · unchecked/);
|
||||
assert.equal(text(s.cell('状态', row)), '可运行');
|
||||
});
|
||||
|
||||
test('账号操作失败显示实际错误,标签取消及新增关闭刷新保持可用', async () => {
|
||||
const s = await ready({ actionError: new Error('操作冲突') });
|
||||
const row = s.find('Table').props.dataSource[0];
|
||||
const actions = s.cell('操作', row);
|
||||
const button = label => s.findAll('Button', actions).find(button => text(button) === label);
|
||||
await button('启动').props.onClick(); s.render(); assert.equal(s.find('AccountLoginModal'), undefined);
|
||||
await s.find('Popconfirm', actions).props.onConfirm();
|
||||
button('编辑标签').props.onClick(); s.render(); await s.find('Modal').props.onOk(); s.render();
|
||||
assert.equal(s.messages.filter(message => message === '操作冲突').length, 3);
|
||||
assert.equal(s.find('Modal').props.open, true);
|
||||
s.find('Modal').props.onCancel(); s.render(); assert.equal(s.find('Modal').props.open, false);
|
||||
await s.find('Modal').props.onOk();
|
||||
const create = () => s.findAll('Button').find(button => text(button) === '新增账号').props.onClick();
|
||||
create(); s.render(); s.find('AccountCreateModal').props.onClose(); s.render(); assert.equal(s.find('AccountCreateModal'), undefined);
|
||||
create(); s.render(); s.find('AccountCreateModal').props.onSuccess(); await flush(); s.render();
|
||||
assert.equal(s.find('AccountCreateModal'), undefined);
|
||||
assert.equal(s.find('Table').props.dataSource.length, 2);
|
||||
});
|
||||
Reference in New Issue
Block a user