From c6a4d20c03a5a4d7540b1bb3a5f2a39f6bffd1e4 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 30 Sep 2026 12:50:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(accounts):=20=E6=88=91=E7=9A=84=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA=E7=94=BB=E5=83=8F?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E5=B9=B6=E6=96=B0=E5=A2=9E=E3=80=8C=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E3=80=8D=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 列表新增粉丝/关注/获赞/互关/评论数值列(formatCount 风格对齐监控列表),作品数据列作品数取最新快照回填 - 操作列新增「启动」:POST /phase-a/accounts/:id/start 启动环境,成功后弹窗展示网关浏览器登录画面 (login-qr 截图,默认打开个人中心页),支持刷新画面与核验浏览器身份;409 就绪原因透传提示 - 详情页趋势图与账号数据卡补互关序列 - 新增源码断言测试 account-management-list.test.cjs(4 用例全过,antd lint 0 警告) --- .../accounts/AccountManagementList.tsx | 139 +++++++++++++++++- web/src/pages/accounts/$id/index.tsx | 2 + web/tests/account-management-list.test.cjs | 51 +++++++ 3 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 web/tests/account-management-list.test.cjs diff --git a/web/src/components/accounts/AccountManagementList.tsx b/web/src/components/accounts/AccountManagementList.tsx index 69accf4..dae89a8 100644 --- a/web/src/components/accounts/AccountManagementList.tsx +++ b/web/src/components/accounts/AccountManagementList.tsx @@ -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(null); + const [loginQR, setLoginQR] = useState(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() { ) }, + { + 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.work_count ?? 0} 个作品 + {formatCount(account.work_count ?? account.aweme_count)} 个作品 最近发布 {account.latest_published_at ? dateTime(account.latest_published_at) : '—'} @@ -135,6 +226,9 @@ export default function AccountManagementList() { ...fixedRight({}), render: (_, account) => ( + @@ -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: '暂无自有账号。' }} /> + { + if (!loginBusy) { + setLoginTarget(null); + setLoginQR(null); + } + }} + footer={[ + , + , + ]} + > + {loginQR ? ( + + + {loginQR.qr_detected ? '请使用抖音 App 扫描下方二维码。' : '当前画面未检测到二维码,请按画面提示人工完成验证。'} + + 抖音登录二维码或人工验证画面 + + 当前画面有效至 {dateTime(loginQR.expires_at)};完成扫码后请点击“核验浏览器身份”。 + + + ) : ( + 正在获取登录画面… + )} + ); } diff --git a/web/src/pages/accounts/$id/index.tsx b/web/src/pages/accounts/$id/index.tsx index 8b84df5..4ef130c 100644 --- a/web/src/pages/accounts/$id/index.tsx +++ b/web/src/pages/accounts/$id/index.tsx @@ -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() { {formatCount(latestMetric?.follower_count)} {formatCount(latestMetric?.following_count)} {formatCount(latestMetric?.total_favorited)} + {formatCount(latestMetric?.friend_count)} {formatCount(latestMetric?.aweme_count)} {accountMetrics.length >= 2 ? ( diff --git a/web/tests/account-management-list.test.cjs b/web/tests/account-management-list.test.cjs new file mode 100644 index 0000000..acb0b40 --- /dev/null +++ b/web/tests/account-management-list.test.cjs @@ -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, '启动未完成:账号或环境不满足就绪条件,请按不可运行原因处理后重试。'));", + ), + ); +});