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

365 lines
14 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.
// 我的账号列表(owned):语义对齐 web.archived AccountManagementList(mode="owned")。
// 监控账号列表已拆分至 components/accounts/MonitoringAccountList.tsx(card list)。
// 数据走 GET /creator/accounts/monitor-views(画像 + 作品聚合 + 最新画像指标快照)。
// 「启动」= POST /phase-a/accounts/:id/start(恢复账号+启动环境),成功后弹窗展示网关浏览器
// 登录弹窗(POST /creator/accounts/:id/login-qr),自动核验身份、刷新过期二维码。
import { useCallback, useEffect, useState } from 'react';
import { history } from '@umijs/max';
import { Alert, App, Avatar, Button, Card, Flex, Form, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
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, 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;
runtime_id?: string;
runtime_cleanup_pending?: boolean;
network_exit_id?: string;
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';
environment?: EnvironmentView;
work_count?: number;
latest_published_at?: string | null;
follower_count?: number | null;
following_count?: number | null;
total_favorited?: number | null;
aweme_count?: number | null;
friend_count?: number | null;
comment_total?: number | null;
}
// 对齐监控列表 formatCount:>=1w 显示 x.xw,空值显示 —。
function formatCount(value?: number | null): string {
if (value === null || value === undefined) return '—';
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : `${value}`;
}
export default function AccountManagementList() {
const [rows, setRows] = useState<Row[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [environmentTarget, setEnvironmentTarget] = useState<string | null>(null);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [tagTarget, setTagTarget] = useState<Row | null>(null);
const [tagForm] = Form.useForm<{ tags: string[] }>();
const [tagBusy, setTagBusy] = useState(false);
const [actionBusy, setActionBusy] = useState('');
const [loginTarget, setLoginTarget] = useState<Row | null>(null);
const { message: messageApi } = App.useApp();
const { vertical } = useOverflowGrid(rows.length);
const load = useCallback(async () => {
setPending(true);
setError(null);
try {
const [rows, environments] = await Promise.all([
creatorGet('/creator/accounts/monitor-views'), creatorGet('/creator/environments'),
]);
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 {
setPending(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function deleteAccount(row: Row) {
setActionBusy(row.id);
try {
await remove('accounts', row.id);
messageApi.success('自有账号已删除。');
await load();
} catch (deleteError) {
messageApi.error(conflictMessage(deleteError, '自有账号删除失败'));
} finally {
setActionBusy('');
}
}
function openTags(row: Row) {
setTagTarget(row);
tagForm.setFieldsValue({ tags: Array.isArray(row.tags) ? row.tags : [] });
}
// 启动:恢复账号并启动环境;成功后直接弹登录画面(环境已就绪,重复点击幂等)。
async function startAccount(row: Row) {
setActionBusy(row.id);
try {
await creatorAction(`/phase-a/accounts/${encodeURIComponent(row.id)}/start`);
messageApi.success('运行环境已启动。');
setLoginTarget(row);
} catch (startError) {
messageApi.error(conflictMessage(startError, '启动未完成:账号或环境不满足就绪条件,请按不可运行原因处理后重试。'));
} finally {
setActionBusy('');
}
}
async function saveTags() {
if (!tagTarget) return;
const { tags } = await tagForm.validateFields();
setTagBusy(true);
try {
await creatorUpdate(`/creator/accounts/${encodeURIComponent(tagTarget.id)}/tags`, { 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 columns: ColumnsType<Row> = [
{
title: '账号',
dataIndex: 'name',
...fixedLeft<Row>({ width: 240 }),
render: (_, account) => (
<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: '粉丝',
render: (_, account) => formatCount(account.follower_count),
},
{
title: '关注',
render: (_, account) => formatCount(account.following_count),
},
{
title: '获赞',
render: (_, account) => formatCount(account.total_favorited),
},
{
title: '互关',
render: (_, account) => formatCount(account.friend_count),
},
{
title: '评论',
render: (_, account) => formatCount(account.comment_total),
},
{
title: '作品数据',
dataIndex: 'work_count',
render: (_, account) => account.pending_environment ? (
<Typography.Text type="secondary">登录后显示作品数据</Typography.Text>
) : (
<Flex vertical>
<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>
</Flex>
) },
{
title: '标签',
dataIndex: 'tags',
render: (tags?: string[]) => (tags?.length ? <Flex wrap="wrap" gap={4}>{tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}</Flex> : <Typography.Text type="secondary">未设置</Typography.Text>) },
{
title: '运行环境',
render: (_, account) => {
const env = account.environment;
if (!env) return <Typography.Text type="secondary">未绑定运行环境</Typography.Text>;
return (
<Flex vertical gap={2}>
<Flex align="center" gap={8}>
<Typography.Text ellipsis={{ tooltip: true }}>{env.name || env.alias || '—'}</Typography.Text>
<Tag color={env.runtime_id && !env.runtime_cleanup_pending ? 'processing' : 'default'}>
{env.runtime_cleanup_pending ? '停止待确认' : env.runtime_id ? '运行中' : '未启动'}
</Tag>
</Flex>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
网关 {env.gateway || '—'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{env.network_exit_id
? `出口 ${env.network_exit_id} · ${env.network_exit_health === 'healthy' ? '健康' : env.network_exit_health || '状态未知'}`
: '机器直连'}
</Typography.Text>
</Flex>
);
} },
{
title: '状态',
dataIndex: 'status',
render: (_, account) => {
if (account.pending_environment) return <Tag>待登录</Tag>;
// 与详情页同款三层判定(绑定/出口/账号),环境未绑定时退化为账号启停状态。
const readiness = accountReadiness(account, account.environment ?? null);
return (
<Tag color={readiness.ready ? 'success' : account.runtime_status === 'paused' && readiness.canResume ? 'warning' : 'error'}>
{readiness.label}
</Tag>
);
} },
{
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' },
banned: { label: '封禁', color: 'error' },
deleted: { label: '注销', color: 'default' },
};
const { label, color } = statuses[status];
return <Tag color={color}>{label}</Tag>;
},
},
{
title: '操作',
key: 'actions',
align: 'right',
...fixedRight<Row>({ width: 300 }),
render: (_, account) => {
const environmentAlias = account.pending_environment;
if (environmentAlias) {
return (
<Button type="link" size="small" onClick={() => setEnvironmentTarget(environmentAlias)}>
登录并同步账号
</Button>
);
}
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>
<Space>
<Button icon={<ReloadOutlined />} onClick={load} disabled={pending} aria-label="刷新" />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
新增账号
</Button>
</Space>
</Flex>
{createOpen ? <AccountCreateModal onClose={() => setCreateOpen(false)}
onSuccess={() => { setCreateOpen(false); void load(); }} /> : null}
{environmentTarget ? (
<AccountLoginModal accountID={environmentTarget} accountName="待登录环境" resource="environments"
onClose={() => setEnvironmentTarget(null)} onSuccess={() => { setEnvironmentTarget(null); void load(); }} />
) : null}
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
<Table
rowKey="id"
columns={columns}
dataSource={rows}
loading={pending}
sticky
pagination={tablePagination()}
scroll={{ x: 'max-content', y: vertical ? 'calc(100vh - 300px)' : undefined }}
locale={{ emptyText: '暂无账号或待登录环境。' }}
/>
<Modal
open={Boolean(tagTarget)}
title={`编辑标签 · ${tagTarget?.name || '待登录'}`}
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>
{loginTarget ? (
<AccountLoginModal accountID={loginTarget.id} accountName={loginTarget.name || '待登录'}
onClose={() => setLoginTarget(null)} onSuccess={() => { void load(); }} />
) : null}
</Card>
);
}