365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
// 我的账号列表(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>
|
||
);
|
||
}
|