feat(account): create browser environments before verified login
douyin-release-gate / verify (push) Failing after 4m22s
douyin-release-gate / verify (push) Failing after 4m22s
This commit is contained in:
@@ -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); };
|
||||
}
|
||||
Reference in New Issue
Block a user