- 列表新增粉丝/关注/获赞/互关/评论数值列(formatCount 风格对齐监控列表),作品数据列作品数取最新快照回填 - 操作列新增「启动」:POST /phase-a/accounts/:id/start 启动环境,成功后弹窗展示网关浏览器登录画面 (login-qr 截图,默认打开个人中心页),支持刷新画面与核验浏览器身份;409 就绪原因透传提示 - 详情页趋势图与账号数据卡补互关序列 - 新增源码断言测试 account-management-list.test.cjs(4 用例全过,antd lint 0 警告)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, '启动未完成:账号或环境不满足就绪条件,请按不可运行原因处理后重试。'));",
|
||||
),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user