diff --git a/internal/controlplane/api/creator.go b/internal/controlplane/api/creator.go
index bc54bf2..db03a6f 100644
--- a/internal/controlplane/api/creator.go
+++ b/internal/controlplane/api/creator.go
@@ -1778,8 +1778,20 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
if !claimed {
return creator.CollectionReport{}, creator.ErrConflict
}
+ // blocked(环境暂不可用)也必须排下一次重试:否则 next_sync_at 置空后定时获取对该账号永久失效。
+ nextRetry := func() *time.Time {
+ nextBase := now
+ if competitor.NextSyncAt != nil {
+ nextBase = competitor.NextSyncAt.UTC()
+ }
+ next := creator.NextFixedRun(nextBase, time.Now().UTC(), time.Duration(settings.NewWorkIntervalSeconds)*time.Second)
+ if next.IsZero() {
+ return nil
+ }
+ return &next
+ }
blocked := func(blockErr error) (creator.CollectionReport, error) {
- markErr := store.MarkCompetitorSync(ctx, competitorID, leaseToken, "blocked", "", blockErr.Error(), nil)
+ markErr := store.MarkCompetitorSync(ctx, competitorID, leaseToken, "blocked", "", blockErr.Error(), nextRetry())
return creator.CollectionReport{}, errors.Join(blockErr, markErr)
}
if competitor.Platform != creator.PlatformDouyin {
@@ -1834,7 +1846,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
next := creator.NextFixedRun(nextBase, time.Now().UTC(), time.Duration(settings.NewWorkIntervalSeconds)*time.Second)
status := "failed"
if errors.Is(collectErr, creator.ErrUnavailable) || errors.Is(collectErr, creator.ErrConflict) {
- status, next = "blocked", time.Time{}
+ status = "blocked"
}
var nextAt *time.Time
if !next.IsZero() {
@@ -1928,7 +1940,17 @@ func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseASto
continue
}
if claimed {
- if markErr := store.MarkCompetitorSync(ctx, competitor.ID, leaseToken, "blocked", "", err.Error(), nil); markErr != nil {
+ // blocked 也排下一次重试(按新作品间隔),避免无采集账号时永久卡死。
+ nextBase := now
+ if competitor.NextSyncAt != nil {
+ nextBase = competitor.NextSyncAt.UTC()
+ }
+ next := creator.NextFixedRun(nextBase, now, time.Duration(settings.NewWorkIntervalSeconds)*time.Second)
+ var nextAt *time.Time
+ if !next.IsZero() {
+ nextAt = &next
+ }
+ if markErr := store.MarkCompetitorSync(ctx, competitor.ID, leaseToken, "blocked", "", err.Error(), nextAt); markErr != nil {
logrus.WithError(markErr).WithField("competitor_id", competitor.ID).Warn("creator competitor sync block update failed")
}
}
diff --git a/internal/creator/integration_test.go b/internal/creator/integration_test.go
index efbfc02..2ae0764 100644
--- a/internal/creator/integration_test.go
+++ b/internal/creator/integration_test.go
@@ -289,6 +289,21 @@ func TestCreatorPostgresContentAndWorkflow(t *testing.T) {
if err := store.MarkCompetitorSync(ctx, competitor.ID, leaseToken, "idle", "", "", nil); err != nil {
t.Fatal(err)
}
+ // blocked 同步也必须排下一次重试:否则 next_sync_at 置空后定时获取对该账号永久失效。
+ blockedToken, blockedClaimed, err := store.ClaimCompetitorSync(ctx, competitor.ID, true, dueNow)
+ if err != nil || !blockedClaimed {
+ t.Fatalf("claim competitor sync for blocked: err=%v claimed=%v", err, blockedClaimed)
+ }
+ blockedNext := dueNow.Add(2 * time.Hour)
+ if err := store.MarkCompetitorSync(ctx, competitor.ID, blockedToken, "blocked", "", "runtime unavailable", &blockedNext); err != nil {
+ t.Fatal(err)
+ }
+ if due, err := store.ListDueCompetitors(ctx, blockedNext); err != nil || len(due) != 1 || due[0].ID != competitor.ID {
+ t.Fatalf("blocked competitor must reschedule: due=%+v err=%v", due, err)
+ }
+ if due, err := store.ListDueCompetitors(ctx, dueNow); err != nil || len(due) != 0 {
+ t.Fatalf("blocked competitor must not fire before retry time: due=%+v err=%v", due, err)
+ }
work, inserted, err = store.UpsertWork(ctx, WorkInput{Platform: PlatformDouyin, WorkKey: workKey, SourceType: SourceCompetitor, SourceID: competitor.ID, Title: "", Body: "", PublishedAt: nil, Likes: nil, CommentsCount: nil, Shares: nil}, now)
if err != nil || inserted || len(work.Sources) != 2 {
t.Fatalf("work source association was not retained: work=%+v inserted=%v err=%v", work, inserted, err)
diff --git a/web/src/components/accounts/MonitoringAccountList.tsx b/web/src/components/accounts/MonitoringAccountList.tsx
index 8dc4020..429907c 100644
--- a/web/src/components/accounts/MonitoringAccountList.tsx
+++ b/web/src/components/accounts/MonitoringAccountList.tsx
@@ -22,8 +22,8 @@ import {
message,
} from 'antd';
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
-import { getList, remove, creatorAction, creatorUpdate } from '@/services/api';
-import { conflictMessage, dateTime, isCollectionAccount, platformLabel } from '@/utils/helpers';
+import { getList, remove, creatorAction, creatorUpdate, syncCompetitorAuto } from '@/services/api';
+import { conflictMessage, dateTime, platformLabel } from '@/utils/helpers';
interface CompetitorRow {
id: string;
@@ -88,14 +88,7 @@ export default function MonitoringAccountList() {
async function syncCompetitor(competitor: CompetitorRow) {
setActionBusy(competitor.id);
try {
- const result = await getList({ resource: 'creator-accounts' });
- const account = (result.data ?? []).find(
- (candidate: any) => candidate.platform === competitor.platform && isCollectionAccount(candidate),
- );
- if (!account) {
- throw new Error(`没有可用于同步${platformLabel(competitor.platform)}监控账号的已登录自有账号。`);
- }
- await creatorAction(`/creator/competitors/${encodeURIComponent(competitor.id)}/sync`, { account_id: account.id });
+ await syncCompetitorAuto(competitor);
messageApi.success('监测账号同步任务已提交。');
await load();
} catch (actionError) {
@@ -177,7 +170,10 @@ export default function MonitoringAccountList() {
itemRender={(row) => {
const meta = syncStatusMeta[row.sync_status ?? ''] || { label: row.sync_status || '—' };
return (
-
+
history.push(`/accounts/monitoring/${row.id}`)}
+ >
@@ -197,7 +193,7 @@ export default function MonitoringAccountList() {
-
+ e.stopPropagation()}>
diff --git a/web/src/pages/accounts/monitoring/$id/index.tsx b/web/src/pages/accounts/monitoring/$id/index.tsx
new file mode 100644
index 0000000..b298978
--- /dev/null
+++ b/web/src/pages/accounts/monitoring/$id/index.tsx
@@ -0,0 +1,330 @@
+// 监控账号详情:形态对齐账号详情页(页头槽 + 卡片流)——
+// 页头 info 为账号标识(头像 + 昵称 + 同步状态 + 平台),actions 为返回/同步/暂停恢复;
+// 主体两张 Card:账号画像(Descriptions)与作品列表(Row/Col card 网格,分页)。
+// 点击作品弹出 Drawer 展示统计信息:作品详情 + 指标采集记录表。
+// 作品列表走 GET /creator/works?source_id=,定时采集由后端调度器维护。
+import { useCallback, useEffect, useState } from 'react';
+import { history, useParams } from '@umijs/max';
+import {
+ Alert,
+ Avatar,
+ Button,
+ Card,
+ Col,
+ Descriptions,
+ Drawer,
+ Empty,
+ Flex,
+ Row,
+ Space,
+ Spin,
+ Table,
+ Tag,
+ Typography,
+ message,
+} from 'antd';
+import { CommentOutlined, LikeOutlined, ReloadOutlined, ShareAltOutlined } from '@ant-design/icons';
+import { creatorAction, creatorGet, getOne, getList, syncCompetitorAuto } from '@/services/api';
+import { conflictMessage, dateTime, platformLabel } from '@/utils/helpers';
+import { usePageActions, usePageInfo } from '@/components/PageActions';
+
+interface CompetitorDetail {
+ id: string;
+ platform: string;
+ platform_account_key: string;
+ unique_id?: string;
+ nickname: string;
+ avatar_url: string;
+ homepage_url?: string;
+ tags?: string[];
+ enabled: boolean;
+ follower_count?: number | null;
+ following_count?: number | null;
+ aweme_count?: number | null;
+ sync_status?: string;
+ sync_error?: string;
+ last_sync_at?: string | null;
+ next_sync_at?: string | null;
+}
+
+const syncStatusMeta: Record = {
+ idle: { color: 'success', label: '监控中' },
+ running: { color: 'processing', label: '同步中' },
+ paused: { label: '已暂停' },
+ failed: { color: 'error', label: '同步失败' },
+ blocked: { color: 'warning', label: '同步受阻' },
+};
+
+function formatCount(value?: number | null): string {
+ if (value === null || value === undefined) return '—';
+ return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : `${value}`;
+}
+
+export default function Page() {
+ const { id = '' } = useParams<{ id: string }>();
+ const [competitor, setCompetitor] = useState(null);
+ const [pending, setPending] = useState(true);
+ const [error, setError] = useState(null);
+ const [actionBusy, setActionBusy] = useState(false);
+ const [works, setWorks] = useState([]);
+ const [workPage, setWorkPage] = useState(1);
+ const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false });
+ const [worksPending, setWorksPending] = useState(true);
+ const [worksError, setWorksError] = useState(null);
+ const [detailWork, setDetailWork] = useState(null);
+ const [workMetrics, setWorkMetrics] = useState([]);
+ const [metricsPending, setMetricsPending] = useState(false);
+ const [messageApi, contextHolder] = message.useMessage();
+
+ const loadCompetitor = useCallback(async () => {
+ setPending(true);
+ setError(null);
+ try {
+ setCompetitor(await getOne('creator-competitors', id));
+ } catch (loadError) {
+ setError(loadError);
+ } finally {
+ setPending(false);
+ }
+ }, [id]);
+
+ const loadWorks = useCallback(async () => {
+ setWorksPending(true);
+ setWorksError(null);
+ try {
+ const result = await getList({ resource: 'creator-works', page: workPage, pageSize: 25, filters: { source_id: id } });
+ setWorks(result.data);
+ setWorkPageInfo({ total: result.total, hasNext: Boolean(result.hasNext) });
+ } catch (loadError) {
+ setWorksError(loadError);
+ } finally {
+ setWorksPending(false);
+ }
+ }, [id, workPage]);
+
+ useEffect(() => {
+ loadCompetitor();
+ }, [loadCompetitor]);
+ useEffect(() => {
+ loadWorks();
+ }, [loadWorks]);
+
+ const syncMeta = syncStatusMeta[competitor?.sync_status ?? ''] || { label: competitor?.sync_status || '—' };
+
+ // 页头 content 行:左侧账号标识,右侧操作(返回/同步/暂停恢复),对齐账号详情页形态。
+ usePageInfo(
+ competitor ? (
+
+
+ {(competitor.nickname || competitor.platform_account_key).slice(0, 1).toUpperCase()}
+
+
+ {competitor.nickname || competitor.platform_account_key}
+
+ {syncMeta.label}
+
+ {competitor.unique_id || competitor.platform_account_key} · {platformLabel(competitor.platform)}
+
+
+ ) : null,
+ [competitor?.id, competitor?.nickname, competitor?.sync_status],
+ );
+
+ usePageActions(
+ competitor ? (
+
+
+
+
+
+ ) : null,
+ [competitor?.id, competitor?.enabled],
+ );
+
+ async function openWorkDetail(work: any) {
+ setDetailWork(work);
+ setWorkMetrics([]);
+ setMetricsPending(true);
+ try {
+ setWorkMetrics((await creatorGet(`/creator/works/${encodeURIComponent(work.id)}/metrics`)) || []);
+ } catch (actionError) {
+ messageApi.error(conflictMessage(actionError, '作品统计读取失败'));
+ } finally {
+ setMetricsPending(false);
+ }
+ }
+
+ if (pending) return ;
+ if (error || !competitor) return ;
+
+ return (
+
+ {contextHolder}
+
+ {competitor.sync_error}
+ ) : (
+ '—'
+ ),
+ },
+ ]}
+ />
+
+ } size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />
+ }
+ >
+
+ 共 {workPageInfo.total} 个作品;作品由系统按采集间隔自动同步。
+
+ {worksError ? (
+ 重试}
+ />
+ ) : null}
+
+ {works.length ? (
+
+ {works.map((work) => (
+
+ openWorkDetail(work)}
+ cover={
+ work.cover_url ? (
+
+ ) : (
+
+ 无封面
+
+ )
+ }
+ >
+
+
+ {work.title || '无标题'}
+
+
+ {work.published_at ? dateTime(work.published_at) : '发布时间待核验'}
+
+
+ {work.likes ?? '—'}
+ {work.comments_count ?? '—'}
+ {work.shares ?? '—'}
+
+
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+ {workPageInfo.total > works.length ? (
+
+
+
+
+
+
+ ) : null}
+
+ setDetailWork(null)} size="large" title="作品统计">
+ {detailWork ? (
+
+ {detailWork.cover_url ? (
+
+ ) : null}
+ ID:{detailWork.work_key || detailWork.id}
+ {detailWork.title || '无标题'}
+
+ {detailWork.body || '暂无正文'}
+
+
+ 发布:{detailWork.published_at ? dateTime(detailWork.published_at) : '—'}
+ {detailWork.original_url ? (
+ 打开原文
+ ) : null}
+
+ dateTime(value) },
+ { title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' },
+ { title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' },
+ { title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' },
+ ]}
+ locale={{ emptyText: '暂无指标记录。' }}
+ />
+
+ ) : null}
+
+
+ );
+}
diff --git a/web/src/pages/accounts/monitoring.tsx b/web/src/pages/accounts/monitoring/index.tsx
similarity index 100%
rename from web/src/pages/accounts/monitoring.tsx
rename to web/src/pages/accounts/monitoring/index.tsx
diff --git a/web/src/services/api.ts b/web/src/services/api.ts
index cc4e270..5576b60 100644
--- a/web/src/services/api.ts
+++ b/web/src/services/api.ts
@@ -1,6 +1,7 @@
// 领域 API 层:资源路径与语义逐一对照 web.archived/src/shared/api/dataProvider.js。
// 全部经由 requestErrorConfig.request(Basic Auth + 401 全局登出)。
import { AUTH_STORAGE_KEY, RequestError, jsonOptions, request, unauthorized } from '@/requestErrorConfig';
+import { isCollectionAccount, platformLabel } from '@/utils/helpers';
export const resourcePaths = {
browsers: '/browsers',
@@ -149,6 +150,18 @@ export function creatorGet(path: string) {
return request(path);
}
+// 监控账号同步:自动挑选可用的自有采集账号(登录且授权)作为执行身份。
+export async function syncCompetitorAuto(competitor: { id: string; platform: string }): Promise {
+ const result = await getList({ resource: 'creator-accounts' });
+ const account = (result.data ?? []).find(
+ (candidate: any) => candidate.platform === competitor.platform && isCollectionAccount(candidate),
+ );
+ if (!account) {
+ throw new Error(`没有可用于同步${platformLabel(competitor.platform)}监控账号的已登录自有账号。`);
+ }
+ await creatorAction(`/creator/competitors/${encodeURIComponent(competitor.id)}/sync`, { account_id: account.id });
+}
+
export function creatorUpdate(path: string, data: unknown) {
return request(path, jsonOptions('PUT', data));
}
diff --git a/web/src/utils/metadata.tsx b/web/src/utils/metadata.tsx
index 653b4ef..8607f8d 100644
--- a/web/src/utils/metadata.tsx
+++ b/web/src/utils/metadata.tsx
@@ -19,6 +19,7 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
{ test: (p) => p === '/accounts/import', title: '账号导入', subtitle: '管理分享链接解析任务,解析成功后自动加入监控账号。' },
{ test: (p) => p === '/accounts', title: '我的账号', subtitle: '创建和管理自己维护的账号。' },
{ test: (p) => p === '/accounts/monitoring', title: '监控账号', subtitle: '管理需要持续跟踪的竞品账号及采集状态。' },
+ { test: (p) => /^\/accounts\/monitoring\/.+/.test(p), title: '监控账号详情', subtitle: '查看监控账号画像与作品列表,点击作品可追溯指标统计。' },
{ test: (p) => p === '/accounts/new', title: '创建社媒账号', subtitle: '创建账号后,再在编辑页配置登录身份与账号策略。' },
{ test: (p) => /\/edit$/.test(p) && p.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' },
{ test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },