feat(accounts): 我的账号列表展示画像指标并新增「启动」操作
douyin-release-gate / verify (push) Failing after 4m12s

- 列表新增粉丝/关注/获赞/互关/评论数值列(formatCount 风格对齐监控列表),作品数据列作品数取最新快照回填
- 操作列新增「启动」:POST /phase-a/accounts/:id/start 启动环境,成功后弹窗展示网关浏览器登录画面
  (login-qr 截图,默认打开个人中心页),支持刷新画面与核验浏览器身份;409 就绪原因透传提示
- 详情页趋势图与账号数据卡补互关序列
- 新增源码断言测试 account-management-list.test.cjs(4 用例全过,antd lint 0 警告)
This commit is contained in:
2026-09-30 12:50:19 +08:00
parent 04407fb6b2
commit c6a4d20c03
3 changed files with 188 additions and 4 deletions
@@ -1,12 +1,14 @@
// 我的账号列表(owned):语义对齐 web.archived AccountManagementList(mode="owned")。
// 监控账号列表已拆分至 components/accounts/MonitoringAccountList.tsx(card list)。
// 数据走 GET /creator/accounts/monitor-views(画像 + 作品数/最近发布聚合),对齐竞品列表画像形态。
// 数据走 GET /creator/accounts/monitor-views(画像 + 作品聚合 + 最新画像指标快照)。
// 「启动」= POST /phase-a/accounts/:id/start(恢复账号+启动环境),成功后弹窗展示网关浏览器
// 登录画面(POST /creator/accounts/:id/login-qr,默认打开 douyin.com/user/self),扫码后核验身份。
import { useCallback, useEffect, useState } from 'react';
import { history } from '@umijs/max';
import { Alert, App, 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 } from '@/services/api';
import { remove, creatorUpdate, creatorGet, creatorAction } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table';
@@ -20,6 +22,25 @@ interface Row {
authorization_status?: string;
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}`;
}
interface LoginQR {
content_type: string;
image_base64: string;
qr_detected: boolean;
expires_at: string;
}
export default function AccountManagementList() {
@@ -30,6 +51,9 @@ export default function AccountManagementList() {
const [tagForm] = Form.useForm<{ tags: string[] }>();
const [tagBusy, setTagBusy] = useState(false);
const [actionBusy, setActionBusy] = useState('');
const [loginTarget, setLoginTarget] = useState<Row | null>(null);
const [loginQR, setLoginQR] = useState<LoginQR | null>(null);
const [loginBusy, setLoginBusy] = useState(false);
const { message: messageApi } = App.useApp();
const { vertical, horizontal } = useOverflowGrid(rows.length);
@@ -73,6 +97,53 @@ export default function AccountManagementList() {
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);
setLoginQR(null);
requestLoginQR(row);
} catch (startError) {
messageApi.error(conflictMessage(startError, '启动未完成:账号或环境不满足就绪条件,请按不可运行原因处理后重试。'));
} finally {
setActionBusy('');
}
}
async function requestLoginQR(row?: Row) {
const target = row ?? loginTarget;
if (!target) return;
setLoginBusy(true);
try {
const result = await creatorAction(`/creator/accounts/${encodeURIComponent(target.id)}/login-qr`);
setLoginQR(result);
} catch (qrError) {
messageApi.error(conflictMessage(qrError, '登录画面获取失败;请检查运行环境和网关状态'));
} finally {
setLoginBusy(false);
}
}
// 扫码后核验:成功视为已登录,刷新列表拿到最新 authorization_status。
async function verifyLogin() {
if (!loginTarget) return;
setLoginBusy(true);
try {
await creatorAction(`/creator/accounts/${encodeURIComponent(loginTarget.id)}/verify`);
messageApi.success('浏览器身份核验成功,账号已登录。');
setLoginTarget(null);
setLoginQR(null);
await load();
} catch (verifyError) {
messageApi.error(conflictMessage(verifyError, '浏览器身份核验失败;请先扫码登录'));
} finally {
setLoginBusy(false);
}
}
async function saveTags() {
if (!tagTarget) return;
const { tags } = await tagForm.validateFields();
@@ -102,12 +173,32 @@ export default function AccountManagementList() {
</Typography.Paragraph>
</div>
) },
{
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) => (
<Flex vertical>
<Typography.Text>{account.work_count ?? 0} 个作品</Typography.Text>
<Typography.Text>{formatCount(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>
@@ -135,6 +226,9 @@ export default function AccountManagementList() {
...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>
@@ -175,7 +269,7 @@ export default function AccountManagementList() {
loading={pending}
sticky
pagination={tablePagination()}
scroll={tableScroll(horizontal ? 960 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)}
scroll={tableScroll(horizontal ? 1200 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)}
locale={{ emptyText: '暂无自有账号。' }}
/>
<Modal
@@ -194,6 +288,43 @@ export default function AccountManagementList() {
</Form.Item>
</Form>
</Modal>
<Modal
open={Boolean(loginTarget)}
title={`扫码登录 · ${loginTarget?.name || '账号'}`}
width={720}
onCancel={() => {
if (!loginBusy) {
setLoginTarget(null);
setLoginQR(null);
}
}}
footer={[
<Button key="refresh" onClick={() => requestLoginQR()} loading={loginBusy}>
刷新登录画面
</Button>,
<Button key="verify" type="primary" onClick={verifyLogin} loading={loginBusy}>
核验浏览器身份
</Button>,
]}
>
{loginQR ? (
<Flex vertical gap={8}>
<Typography.Text type="secondary">
{loginQR.qr_detected ? '请使用抖音 App 扫描下方二维码。' : '当前画面未检测到二维码,请按画面提示人工完成验证。'}
</Typography.Text>
<img
style={{ maxHeight: 520, width: '100%', objectFit: 'contain', border: '1px solid #f0f0f0' }}
src={`data:${loginQR.content_type};base64,${loginQR.image_base64}`}
alt="抖音登录二维码或人工验证画面"
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
当前画面有效至 {dateTime(loginQR.expires_at)};完成扫码后请点击“核验浏览器身份”。
</Typography.Text>
</Flex>
) : (
<Typography.Text type="secondary">正在获取登录画面…</Typography.Text>
)}
</Modal>
</Card>
);
}
+2
View File
@@ -28,6 +28,7 @@ const accountSeriesMeta = [
{ key: 'following_count', label: '关注' },
{ key: 'total_favorited', label: '获赞' },
{ key: 'aweme_count', label: '作品总数' },
{ key: 'friend_count', label: '互关' },
] as const;
function formatCount(value?: number | null): string {
@@ -246,6 +247,7 @@ export default function Page() {
<Descriptions.Item label="粉丝">{formatCount(latestMetric?.follower_count)}</Descriptions.Item>
<Descriptions.Item label="关注">{formatCount(latestMetric?.following_count)}</Descriptions.Item>
<Descriptions.Item label="获赞">{formatCount(latestMetric?.total_favorited)}</Descriptions.Item>
<Descriptions.Item label="互关">{formatCount(latestMetric?.friend_count)}</Descriptions.Item>
<Descriptions.Item label="作品总数">{formatCount(latestMetric?.aweme_count)}</Descriptions.Item>
</Descriptions>
{accountMetrics.length >= 2 ? (
@@ -0,0 +1,51 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { test } = require('node:test');
const ts = require('typescript');
const source = ts.createSourceFile(
'AccountManagementList.tsx',
readFileSync(`${__dirname}/../src/components/accounts/AccountManagementList.tsx`, 'utf8'),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const code = source.getFullText();
test('列表展示粉丝、关注、获赞、互关、评论独立数值列', () => {
for (const [title, value] of [
['粉丝', 'account.follower_count'],
['关注', 'account.following_count'],
['获赞', 'account.total_favorited'],
['互关', 'account.friend_count'],
['评论', 'account.comment_total'],
]) {
assert.ok(
code.includes(`title: '${title}',\n render: (_, account) => formatCount(${value}),`),
`${title} 应为独立数值列`,
);
}
});
test('操作列包含「启动」:先启动环境再弹出登录画面', () => {
assert.match(code, /onClick=\{\(\) => startAccount\(account\)\}/);
assert.match(code, /\/phase-a\/accounts\/\$\{encodeURIComponent\(row\.id\)\}\/start/);
assert.match(code, /setLoginTarget\(row\)/);
assert.match(code, /requestLoginQR\(row\)/);
});
test('登录弹窗复用 login-qr 截图并支持刷新与核验', () => {
assert.match(code, /\/creator\/accounts\/\$\{encodeURIComponent\(target\.id\)\}\/login-qr/);
assert.match(code, /\/creator\/accounts\/\$\{encodeURIComponent\(loginTarget\.id\)\}\/verify/);
assert.match(code, /刷新登录画面/);
assert.match(code, /核验浏览器身份/);
assert.match(code, /image_base64/);
});
test('启动失败走 conflictMessage 透传 409 就绪原因', () => {
assert.ok(
code.includes(
"messageApi.error(conflictMessage(startError, '启动未完成:账号或环境不满足就绪条件,请按不可运行原因处理后重试。'));",
),
);
});