Files
creator-hub/web/src/components/accounts/MonitoringAccountList.tsx
T

267 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 监控账号列表(monitoring):一行一个账号的 card list,对齐 pro table-list 的卡片列表形态。
// 展示作者头像与粉丝/关注/作品/最近发布等账号画像属性(migration 038 字段 + work 聚合)。
import { useCallback, useEffect, useState } from 'react';
import { history } from '@umijs/max';
import {
Alert,
Avatar,
Button,
Card,
Descriptions,
Empty,
Flex,
Form,
Listy,
Modal,
Popconfirm,
Select,
Space,
Spin,
Tag,
Typography,
message,
} from 'antd';
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
import { getList, remove, creatorAction, creatorUpdate, syncCompetitorAuto } from '@/services/api';
import { conflictMessage, dateTime, platformLabel } from '@/utils/helpers';
interface CompetitorRow {
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;
work_count?: number;
latest_published_at?: string | null;
}
const syncStatusMeta: Record<string, { color?: string; label: string }> = {
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 MonitoringAccountList() {
const [rows, setRows] = useState<CompetitorRow[]>([]);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [tagTarget, setTagTarget] = useState<CompetitorRow | null>(null);
const [tagForm] = Form.useForm<{ tags: string[] }>();
const [tagBusy, setTagBusy] = useState(false);
const [actionBusy, setActionBusy] = useState('');
const [messageApi, contextHolder] = message.useMessage();
const load = useCallback(async () => {
setPending(true);
setError(null);
try {
const result = await getList({ resource: 'creator-competitors' });
setRows(result.data ?? []);
} catch (loadError) {
setError(loadError);
} finally {
setPending(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function syncCompetitor(competitor: CompetitorRow) {
setActionBusy(competitor.id);
try {
await syncCompetitorAuto(competitor);
messageApi.success('监测账号同步任务已提交。');
await load();
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '监测账号同步失败'));
} finally {
setActionBusy('');
}
}
async function toggleCompetitor(competitor: CompetitorRow) {
setActionBusy(competitor.id);
try {
await creatorAction(
`/creator/competitors/${encodeURIComponent(competitor.id)}/${competitor.enabled ? 'pause' : 'resume'}`,
);
messageApi.success(competitor.enabled ? '监测账号已暂停。' : '监测账号已恢复。');
await load();
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '监测账号状态更新失败'));
} finally {
setActionBusy('');
}
}
async function deleteAccount(row: CompetitorRow) {
setActionBusy(row.id);
try {
await remove('creator-competitors', row.id);
messageApi.success('监测账号已删除。');
await load();
} catch (deleteError) {
messageApi.error(conflictMessage(deleteError, '监测账号删除失败'));
} finally {
setActionBusy('');
}
}
function openTags(row: CompetitorRow) {
setTagTarget(row);
tagForm.setFieldsValue({ tags: Array.isArray(row.tags) ? row.tags : [] });
}
async function saveTags() {
if (!tagTarget) return;
const { tags } = await tagForm.validateFields();
setTagBusy(true);
try {
await creatorUpdate(`/creator/competitors/${encodeURIComponent(tagTarget.id)}`, { tags });
setRows((current) => current.map((row) => (row.id === tagTarget.id ? { ...row, tags } : row)));
setTagTarget(null);
messageApi.success('标签已保存。');
} catch (saveError) {
messageApi.error(conflictMessage(saveError, '标签保存失败'));
} finally {
setTagBusy(false);
}
}
const syncMeta = syncStatusMeta[tagTarget?.sync_status ?? ''] || { label: tagTarget?.sync_status || '—' };
return (
<Card variant="outlined">
{contextHolder}
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Typography.Text type="secondary">共 {rows.length} 个监控账号</Typography.Text>
<Space>
<Button icon={<ReloadOutlined />} onClick={load} disabled={pending} aria-label="刷新" />
<Button type="primary" icon={<CloudUploadOutlined />} onClick={() => history.push('/accounts/import')}>
导入竞品账号
</Button>
</Space>
</Flex>
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
<Listy
rowKey={(row) => row.id}
items={rows}
itemRender={(row) => {
const meta = syncStatusMeta[row.sync_status ?? ''] || { label: row.sync_status || '—' };
return (
<div
style={{ padding: '16px 0', borderBottom: '1px solid rgba(5,5,5,0.06)', cursor: 'pointer' }}
onClick={() => history.push(`/accounts/monitoring/${row.id}`)}
>
<Flex justify="space-between" align="flex-start" gap={16}>
<Flex align="flex-start" gap={12}>
<Avatar size={44} src={row.avatar_url || undefined}>
{(row.nickname || row.platform_account_key).slice(0, 1).toUpperCase()}
</Avatar>
<div>
<Space wrap size={8}>
<Typography.Text strong>{row.nickname || row.platform_account_key}</Typography.Text>
<Tag>{platformLabel(row.platform)}</Tag>
<Tag color={meta.color}>{meta.label}</Tag>
{(row.tags ?? []).map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }} ellipsis>
{row.platform_account_key}
</Typography.Paragraph>
</div>
</Flex>
<Space wrap onClick={(e) => e.stopPropagation()}>
<Button size="small" onClick={() => openTags(row)}>
编辑标签
</Button>
<Button size="small" disabled={actionBusy === row.id} onClick={() => syncCompetitor(row)}>
同步
</Button>
<Button size="small" disabled={actionBusy === row.id} onClick={() => toggleCompetitor(row)}>
{row.enabled ? '暂停' : '恢复'}
</Button>
<Popconfirm
title={`确定删除监测账号“${row.nickname || row.platform_account_key}”吗?相关专属资源也会被清理。`}
onConfirm={() => deleteAccount(row)}
disabled={actionBusy === row.id}
>
<Button size="small" danger disabled={actionBusy === row.id}>
删除
</Button>
</Popconfirm>
</Space>
</Flex>
<Descriptions
style={{ marginTop: 12 }}
size="small"
column={{ xs: 2, sm: 3, md: 6 }}
items={[
{ key: 'follower', label: '粉丝', children: formatCount(row.follower_count) },
{ key: 'following', label: '关注', children: formatCount(row.following_count) },
{ key: 'works', label: '作品', children: formatCount(row.work_count ?? row.aweme_count ?? null) },
{ key: 'published', label: '最近发布', children: row.latest_published_at ? dateTime(row.latest_published_at) : '—' },
{ key: 'lastSync', label: '上次同步', children: row.last_sync_at ? dateTime(row.last_sync_at) : '—' },
{
key: 'syncError',
label: '同步异常',
children: row.sync_error ? (
<Typography.Text type="danger" style={{ fontSize: 12 }}>{row.sync_error}</Typography.Text>
) : (
'—'
),
},
]}
/>
</div>
);
}}
/>
{pending ? (
<div style={{ textAlign: 'center', padding: '32px 0' }}>
<Spin />
</div>
) : null}
{!pending && !error && rows.length === 0 ? <Empty description="暂无监控账号。" /> : null}
<Modal
open={Boolean(tagTarget)}
title={`编辑标签 · ${tagTarget?.nickname || tagTarget?.platform_account_key || '账号'}`}
onCancel={() => {
if (!tagBusy) setTagTarget(null);
}}
onOk={saveTags}
confirmLoading={tagBusy}
okText={tagBusy ? '保存中…' : '保存标签'}
>
<Form form={tagForm} layout="vertical">
<Form.Item name="tags" label="标签" extra="回车或逗号确认,最多 20 个标签,每个最多 32 个字符。">
<Select mode="tags" tokenSeparators={[',', ',']} placeholder="如:重点监控" open={false} />
</Form.Item>
</Form>
</Modal>
</Card>
);
}