feat(account): create browser environments before verified login
douyin-release-gate / verify (push) Failing after 4m22s

This commit is contained in:
2026-10-06 05:55:53 +08:00
parent d0b95ba236
commit f1a62e25de
47 changed files with 1867 additions and 508 deletions
@@ -0,0 +1,74 @@
import { Alert, Button, Flex, Image, Modal, Spin, Typography } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import React, { useEffect, useRef, useState } from 'react';
import { request } from '@/requestErrorConfig';
import { startAccountLogin, type LoginState } from './account-login';
const phaseTitles: Record<LoginState['phase'], string> = {
loading: '正在获取二维码', checking: '正在核验登录状态', waiting: '等待扫码或手机确认',
refreshing: '二维码已过期,正在自动刷新', success: '登录成功', error: '登录流程已停止',
};
export default function AccountLoginModal({ accountID, accountName, resource = 'accounts', onClose, onSuccess }: {
accountID: string;
accountName: string;
resource?: 'accounts' | 'environments';
onClose: () => void;
onSuccess: () => void;
}) {
const [state, setState] = useState<LoginState>({ phase: 'loading', logs: [] });
const [attempt, setAttempt] = useState(0);
const [now, setNow] = useState(Date.now());
const successRef = useRef(onSuccess);
successRef.current = onSuccess;
useEffect(() => startAccountLogin(accountID, request, setState, () => successRef.current(), resource), [accountID, resource, attempt]);
useEffect(() => {
if (state.phase === 'success' || state.phase === 'error') return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [state.phase]);
const remaining = state.qr ? Math.max(0, Math.ceil((Date.parse(state.qr.expires_at) - now) / 1000)) : 0;
const busy = ['loading', 'checking', 'refreshing'].includes(state.phase);
const elapsed = Math.max(0, Math.floor((now - (state.logs.at(-1)?.time ?? now)) / 1000));
return (
<Modal title={`登录二维码 · ${accountName}`} open onCancel={onClose} mask={{ closable: false }}
footer={[
state.phase === 'error' ? <Button key="retry" icon={<ReloadOutlined />} onClick={() => setAttempt(value => value + 1)}>重试登录</Button> : null,
<Button key="close" onClick={onClose}>{state.phase === 'success' ? '完成' : '关闭'}</Button>,
]}
>
<Flex vertical gap="middle">
<Alert type={state.phase === 'error' ? 'error' : state.phase === 'success' ? 'success' : 'info'} showIcon
title={phaseTitles[state.phase]}
description={state.error || (state.phase === 'success' ? '账号已登录,检查已停止。' : '使用抖音扫码,并在手机上确认。状态自动检查,二维码到期自动刷新。关闭弹窗将停止检查。')}
/>
{state.phase !== 'success' && state.phase !== 'error' && (
<Flex vertical align="center" gap="small">
{state.qr && remaining > 0 ? (
<Image src={`data:${state.qr.content_type};base64,${state.qr.image_base64}`} alt="抖音登录二维码" width={240} preview={false} />
) : busy ? (
<Flex vertical align="center" gap="small">
<Spin />
<Typography.Text type="secondary">
{remaining === 0 && state.qr ? '二维码已到期,正在检查并刷新' : phaseTitles[state.phase]} · 已等待 {elapsed} 秒
</Typography.Text>
</Flex>
) : null}
{state.qr && remaining > 0 ? <Typography.Text type="secondary">剩余 {remaining} 秒 · 到期自动刷新</Typography.Text> : null}
{state.phase === 'checking' && remaining > 0 ? <Typography.Text type="secondary">正在检查登录状态 · 已等待 {elapsed} 秒</Typography.Text> : null}
</Flex>
)}
<Typography.Title level={5}>登录进度</Typography.Title>
<div role="log" aria-live="polite" aria-relevant="additions" style={{ maxHeight: 180, overflowY: 'auto' }}>
<Flex vertical gap="small">
{state.logs.slice().reverse().map((log, index) => (
<Typography.Text key={log.id} type={state.phase === 'error' && index === 0 ? 'danger' : 'secondary'}>
{new Date(log.time).toLocaleTimeString('zh-CN', { hour12: false })} · {log.message}
</Typography.Text>
))}
</Flex>
</div>
</Flex>
</Modal>
);
}
@@ -2,15 +2,16 @@
// 监控账号列表已拆分至 components/accounts/MonitoringAccountList.tsx(card list)。
// 数据走 GET /creator/accounts/monitor-views(画像 + 作品聚合 + 最新画像指标快照)。
// 「启动」= POST /phase-a/accounts/:id/start(恢复账号+启动环境),成功后弹窗展示网关浏览器
// 登录画面(POST /creator/accounts/:id/login-qr,默认打开 douyin.com/user/self),扫码后核验身份。
// 登录弹窗(POST /creator/accounts/:id/login-qr),自动核验身份、刷新过期二维码。
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 { 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, tableScroll, useOverflowGrid } from '@/utils/table';
import AccountLoginModal from '@/components/accounts/AccountLoginModal';
// 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。
interface EnvironmentView {
@@ -27,6 +28,8 @@ interface Row {
id: string;
name: string;
platform_account_key: string;
avatar_url?: string;
douyin_number?: string;
tags?: string[];
runtime_status?: string;
environment?: EnvironmentView;
@@ -46,15 +49,10 @@ function formatCount(value?: number | null): string {
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() {
const [rows, setRows] = useState<Row[]>([]);
const [environments, setEnvironments] = useState<{ alias: string; runtime_id?: string }[]>([]);
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);
@@ -62,8 +60,6 @@ export default function AccountManagementList() {
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);
@@ -71,7 +67,10 @@ export default function AccountManagementList() {
setPending(true);
setError(null);
try {
const rows = await creatorGet('/creator/accounts/monitor-views');
const [rows, environments] = await Promise.all([
creatorGet('/creator/accounts/monitor-views'), creatorGet('/creator/environments'),
]);
setEnvironments(environments);
setRows(
(rows ?? []).map((account: any) => ({
...account,
@@ -114,8 +113,6 @@ export default function AccountManagementList() {
await creatorAction(`/phase-a/accounts/${encodeURIComponent(row.id)}/start`);
messageApi.success('运行环境已启动。');
setLoginTarget(row);
setLoginQR(null);
requestLoginQR(row);
} catch (startError) {
messageApi.error(conflictMessage(startError, '启动未完成:账号或环境不满足就绪条件,请按不可运行原因处理后重试。'));
} finally {
@@ -123,37 +120,6 @@ export default function AccountManagementList() {
}
}
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);
}
}
// 扫码后核验:成功视为已登录,刷新列表同步最新账号状态。
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();
@@ -177,7 +143,7 @@ export default function AccountManagementList() {
...fixedLeft<Row>({}),
render: (_, account) => (
<div>
<Typography.Link onClick={() => history.push(`/accounts/${account.id}`)}>{account.name}</Typography.Link>
<Space><Avatar size="small" src={account.avatar_url} /><Typography.Link onClick={() => history.push(`/accounts/${account.id}`)}>{account.name}</Typography.Link></Space>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }} ellipsis>
{account.platform_account_key}
</Typography.Paragraph>
@@ -290,10 +256,23 @@ export default function AccountManagementList() {
<Space>
<Button icon={<ReloadOutlined />} onClick={load} disabled={pending} aria-label="刷新" />
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/accounts/new')}>
创建账号
创建浏览器环境
</Button>
</Space>
</Flex>
{environmentTarget ? (
<AccountLoginModal accountID={environmentTarget} accountName="待登录环境" resource="environments"
onClose={() => setEnvironmentTarget(null)} onSuccess={() => { setEnvironmentTarget(null); void load(); }} />
) : null}
{environments.length > 0 ? (
<Card title="待登录环境">
<Table rowKey="alias" dataSource={environments} pagination={tablePagination()} sticky columns={[
{ title: '环境标识', dataIndex: 'alias', render: (alias: string) => <Typography.Text copyable>{alias}</Typography.Text> },
{ title: '状态', render: () => <Tag>待登录</Tag> },
{ title: '操作', render: (_, env) => <Button type="link" onClick={() => setEnvironmentTarget(env.alias)}>登录并同步账号</Button> },
]} />
</Card>
) : null}
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
@@ -323,43 +302,10 @@ 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>
{loginTarget ? (
<AccountLoginModal accountID={loginTarget.id} accountName={loginTarget.name}
onClose={() => setLoginTarget(null)} onSuccess={() => { void load(); }} />
) : null}
</Card>
);
}
@@ -0,0 +1,101 @@
import { conflictMessage } from '@/utils/helpers';
export interface LoginQR {
content_type: string;
image_base64: string;
qr_detected: boolean;
expires_at: string;
}
export interface LoginResult {
status: string;
reason?: string;
}
export interface LoginState {
phase: 'loading' | 'checking' | 'waiting' | 'refreshing' | 'success' | 'error';
qr?: LoginQR;
error?: string;
logs: { id: number; time: number; message: string }[];
}
type LoginRequest = <T>(path: string, options: { method?: string; signal: AbortSignal }) => Promise<T>;
// A single sequential loop prevents overlapping checks and QR reloads.
// Closing the modal aborts both the current request and the scheduled check.
export function startAccountLogin(
accountID: string,
request: LoginRequest,
onChange: (state: LoginState) => void,
onSuccess: (result: LoginResult) => void,
resource: 'accounts' | 'environments' = 'accounts',
): () => void {
const controller = new AbortController();
const { signal } = controller;
let timer: ReturnType<typeof setTimeout> | undefined;
let state: LoginState = { phase: 'loading', logs: [] };
let logID = 0;
const path = `/creator/${resource}/${encodeURIComponent(accountID)}`;
const update = (phase: LoginState['phase'], message: string, qr = state.qr, error?: string) => {
if (signal.aborted) return;
state = { phase, qr, error, logs: [...state.logs, { id: ++logID, time: Date.now(), message }].slice(-100) };
onChange(state);
};
const pause = (milliseconds: number) => new Promise<void>(resolve => {
const finish = () => { signal.removeEventListener('abort', finish); resolve(); };
timer = setTimeout(finish, milliseconds);
signal.addEventListener('abort', finish, { once: true });
});
const loadQR = async (refresh: boolean) => {
// Remove the expired image immediately, rather than leaving it scannable.
state = { ...state, qr: undefined };
update(refresh ? 'refreshing' : 'loading', refresh ? '二维码已过期,正在自动刷新。' : '正在获取登录二维码。');
const qr = await request<LoginQR>(`${path}/login-qr`, {
method: 'POST', signal: AbortSignal.any([signal, AbortSignal.timeout(45000)]),
});
if (signal.aborted) return;
if (!qr?.qr_detected || !qr.image_base64 || !qr.content_type?.startsWith('image/')) {
throw new Error('未获取到可用的登录二维码,请查看浏览器环境。');
}
const expires = Date.parse(qr.expires_at);
if (!Number.isFinite(expires) || expires <= Date.now()) {
throw new Error('登录二维码有效期无效或已经过期。');
}
update('waiting', refresh ? '新二维码已加载,等待扫码或手机确认。' : '二维码已加载,等待扫码或手机确认。', qr);
};
void (async () => {
try {
while (!signal.aborted) {
update(state.qr ? 'checking' : 'loading', state.qr ? '正在核验登录状态。' : '正在检查已有登录会话。');
const result = await request<LoginResult>(`${path}/verify`, {
method: 'POST', signal: AbortSignal.any([signal, AbortSignal.timeout(45000)]),
});
if (signal.aborted) return;
if (result?.status === 'logged_in') {
update('success', '登录成功,账号身份核验通过。');
onSuccess(result);
return;
}
if (result?.status !== 'manual_login' || result.reason !== 'awaiting_login') {
throw new Error(`登录核验返回异常状态:${result?.reason || result?.status || '缺少状态'}。`);
}
if (!state.qr) {
await loadQR(false);
continue;
}
if (Date.parse(state.qr.expires_at) <= Date.now()) {
// Verify first: refreshing navigates the browser and could interrupt a completed login.
await loadQR(true);
continue;
}
update('waiting', '尚未完成登录,等待扫码或手机确认。');
await pause(Math.min(3000, Date.parse(state.qr!.expires_at) - Date.now()));
}
} catch (error) {
if (signal.aborted) return;
const message = conflictMessage(error);
update('error', `登录流程已停止:${message}`, state.qr, message);
}
})();
return () => { controller.abort(); if (timer !== undefined) clearTimeout(timer); };
}