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); };
|
||||
}
|
||||
@@ -4,10 +4,11 @@
|
||||
// 布局对齐全站模式:实体标识经 usePageInfo 进页头 content 左侧,返回/登录操作按钮经 usePageActions 进页头右侧。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { history, useParams } from '@umijs/max';
|
||||
import { Alert, App, Button, Card, Col, Descriptions, Divider, Flex, Form, Input, Row, Select, Space, Typography } from 'antd';
|
||||
import { Alert, App, Button, Card, Col, Descriptions, Flex, Form, Input, Row, Select, Space, Typography } from 'antd';
|
||||
import { usePageActions, usePageInfo } from '@/components/PageActions';
|
||||
import FingerprintFields, { fingerprintFormValues, fingerprintPayload } from '@/components/accounts/FingerprintFields';
|
||||
import { creatorAction, creatorGet, creatorUpdate, getOne, getList } from '@/services/api';
|
||||
import { creatorUpdate, getOne, getList } from '@/services/api';
|
||||
import AccountLoginModal from '@/components/accounts/AccountLoginModal';
|
||||
import { conflictMessage, dateTime } from '@/utils/helpers';
|
||||
|
||||
const platformLabelMap: Record<string, string> = { douyin: '抖音' };
|
||||
@@ -42,8 +43,8 @@ export default function Page() {
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [fpBusy, setFpBusy] = useState(false);
|
||||
const [loginQR, setLoginQR] = useState<any>(null);
|
||||
const [form] = Form.useForm<any>();
|
||||
const [fpForm] = Form.useForm<any>();
|
||||
const { message: messageApi } = App.useApp();
|
||||
@@ -54,29 +55,26 @@ export default function Page() {
|
||||
usePageInfo(
|
||||
selected ? (
|
||||
<Flex align="center" gap={8} wrap="wrap">
|
||||
<Typography.Title level={5} style={{ marginBottom: 0 }} ellipsis>
|
||||
<Typography.Title level={5} style={{ margin: 0 }} ellipsis>
|
||||
{selected.name || selected.platform_account_key}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{platformLabelMap[selected.platform] || selected.platform} · {selected.platform_account_key}
|
||||
{platformLabelMap[selected.platform] || selected.platform} · {selected.platform_account_key ? `UID ${selected.platform_account_key}` : 'UID 未绑定'}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
) : null,
|
||||
[selected?.id, selected?.name, selected?.platform],
|
||||
[selected?.id, selected?.name, selected?.platform, selected?.platform_account_key],
|
||||
);
|
||||
|
||||
// 页头 content 行右侧:返回 + 登录操作,不在卡片内重复渲染。
|
||||
usePageActions(
|
||||
<Space>
|
||||
<Button onClick={() => history.push('/accounts')}>返回社媒账号</Button>
|
||||
<Button disabled={busy || !selected || selected.platform !== 'douyin'} onClick={requestLoginQR}>
|
||||
<Button disabled={busy || fpBusy || loginOpen || !selected || selected.platform !== 'douyin'} onClick={() => setLoginOpen(true)}>
|
||||
显示登录二维码
|
||||
</Button>
|
||||
<Button disabled={busy || !selected || selected.platform !== 'douyin'} onClick={verifyLogin}>
|
||||
核验浏览器身份
|
||||
</Button>
|
||||
</Space>,
|
||||
[selected?.id, selected?.platform],
|
||||
[selected?.id, selected?.platform, busy, fpBusy, loginOpen],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -150,45 +148,7 @@ export default function Page() {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestLoginQR() {
|
||||
if (!selected || selected.platform !== 'douyin') return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await creatorAction(`/creator/accounts/${encodeURIComponent(selected.id)}/login-qr`);
|
||||
setLoginQR(result);
|
||||
messageApi.success(
|
||||
result.qr_detected ? '登录二维码已生成,请使用抖音 App 扫码。' : '登录画面已生成;当前未检测到二维码,请按页面提示人工完成验证。',
|
||||
);
|
||||
} catch (qrError) {
|
||||
messageApi.error(conflictMessage(qrError, '登录二维码获取失败;请检查运行环境和网关状态'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyLogin() {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await creatorAction(`/creator/accounts/${encodeURIComponent(selected.id)}/verify`);
|
||||
setProfiles((items) =>
|
||||
items.map((item) =>
|
||||
item.id === selected.id
|
||||
? { ...item, login_status: result.status, login_reason: result.reason, login_checked_at: result.checked_at }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setLoginQR(null);
|
||||
messageApi.success('浏览器身份核验成功。');
|
||||
await load();
|
||||
} catch (verifyError) {
|
||||
messageApi.error(conflictMessage(verifyError, '浏览器身份核验失败;请先在指定环境人工登录'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (pending) return <Card loading />;
|
||||
if (pending && !selected) return <Card loading />;
|
||||
if (error && !selected)
|
||||
return (
|
||||
<div>
|
||||
@@ -202,8 +162,19 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
{loginOpen ? (
|
||||
<AccountLoginModal key={selected.id} accountID={selected.id} accountName={selected.name || selected.platform_account_key}
|
||||
onClose={() => setLoginOpen(false)} onSuccess={() => { void load(); }} />
|
||||
) : null}
|
||||
<Card title="登录状态">
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="UID">
|
||||
{selected.platform_account_key ? (
|
||||
<Typography.Text copyable>{selected.platform_account_key}</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">首次登录核验后自动绑定</Typography.Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="登录核验">
|
||||
{selected.login_status === 'logged_in' ? '已登录' : '需人工确认'}
|
||||
{selected.login_checked_at ? ` · ${dateTime(selected.login_checked_at)}` : ' · 尚未核验'}
|
||||
@@ -211,22 +182,6 @@ export default function Page() {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="密码凭据">{selected.password_configured ? '已配置' : '未配置'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{loginQR ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Typography.Text type="secondary">
|
||||
{loginQR.qr_detected ? '请使用抖音 App 扫描下方二维码。' : '当前画面未检测到二维码,请按画面提示人工完成验证。'}
|
||||
</Typography.Text>
|
||||
<img
|
||||
style={{ marginTop: 12, 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={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||
当前画面有效至 {dateTime(loginQR.expires_at)};完成扫码后请点击“核验浏览器身份”。
|
||||
</Typography.Text>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
<Card title="账号资料">
|
||||
<Form form={form} layout="vertical" onFinish={save} initialValues={profileForm(selected)}>
|
||||
@@ -284,7 +239,7 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
留空项使用浏览器默认值;seed 由系统按账号自动派生并保证唯一,代理由网络出口统一管理,均不在此配置。运行中的环境保存后会以新指纹自动重启。
|
||||
留空项使用浏览器默认值;seed 在环境创建时分配并保证唯一,账号绑定不会改变它,代理由网络出口统一管理,均不在此配置。运行中的环境保存后会以新指纹自动重启。
|
||||
</Typography.Text>
|
||||
<Form form={fpForm} layout="vertical" initialValues={fingerprintFormValues()}>
|
||||
<FingerprintFields />
|
||||
|
||||
@@ -91,16 +91,16 @@ export default function Page() {
|
||||
usePageInfo(
|
||||
account ? (
|
||||
<Flex align="center" gap={8} wrap="wrap">
|
||||
<Typography.Title level={5} style={{ marginBottom: 0 }} ellipsis>
|
||||
<Typography.Title level={5} style={{ margin: 0 }} ellipsis>
|
||||
{account.name}
|
||||
</Typography.Title>
|
||||
{statusTag}
|
||||
<Typography.Text type="secondary">
|
||||
{account.platform_account_key} · {platformLabel(account.platform)}
|
||||
{account.platform_account_key ? `UID ${account.platform_account_key}` : 'UID 未绑定'} · {platformLabel(account.platform)}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
) : null,
|
||||
[account?.id, account?.name, account?.runtime_status, binding, browsersError],
|
||||
[account?.id, account?.name, account?.platform_account_key, account?.runtime_status, binding, browsersError],
|
||||
);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
@@ -209,6 +209,9 @@ export default function Page() {
|
||||
<Card title="账号状态" extra={pauseResume}>
|
||||
<Flex vertical gap={16}>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="UID">
|
||||
{account.platform_account_key ? <Typography.Text copyable>{account.platform_account_key}</Typography.Text> : '首次登录核验后自动绑定'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运行">{`${account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 ${account.version}`}</Descriptions.Item>
|
||||
<Descriptions.Item label="标签">{account.tags?.join('、') || '无'}</Descriptions.Item>
|
||||
<Descriptions.Item label="作品采集">
|
||||
|
||||
@@ -1,103 +1,44 @@
|
||||
// 创建社媒账号:语义对齐 web.archived AccountCreatePage + AccountCreateForm。
|
||||
// cookies 非必填:留空代表创建后走扫码登录。创建成功跳编辑页。
|
||||
// 折叠区块:指纹浏览器环境(可选,字段复用 FingerprintFields);seed 由后端从账号派生,代理由网络出口管理,均不在表单内。
|
||||
import { useState } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import { Alert, Button, Card, Collapse, Flex, Form, Input, Select, Typography } from 'antd';
|
||||
import { create } from '@/services/api';
|
||||
import { conflictMessage, platforms } from '@/utils/helpers';
|
||||
import { Alert, Button, Card, Collapse, Flex, Form, Typography } from 'antd';
|
||||
import { creatorCreate } from '@/services/api';
|
||||
import { conflictMessage } from '@/utils/helpers';
|
||||
import FingerprintFields, { fingerprintPayload, type FingerprintValues } from '@/components/accounts/FingerprintFields';
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
platform: string;
|
||||
platform_account_key: string;
|
||||
tags?: string[];
|
||||
cookies?: string;
|
||||
fingerprint?: FingerprintValues;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [form] = Form.useForm<{ fingerprint?: FingerprintValues }>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<any>(null);
|
||||
|
||||
async function onFinish(values: FormValues) {
|
||||
async function onFinish(values: { fingerprint?: FingerprintValues }) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data: Record<string, unknown> = {
|
||||
name: values.name.trim(),
|
||||
platform: values.platform.trim(),
|
||||
platform_account_key: values.platform_account_key.trim(),
|
||||
tags: values.tags ?? [],
|
||||
};
|
||||
// cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐
|
||||
if (values.cookies?.trim()) data.cookies = values.cookies.trim();
|
||||
// 指纹表单:只提交非空项;留空项由后端使用浏览器默认值
|
||||
const fingerprint = fingerprintPayload(values.fingerprint);
|
||||
if (Object.keys(fingerprint).length) data.fingerprint = fingerprint;
|
||||
const result = await create('accounts', data);
|
||||
const id = result?.data?.id ?? result?.id;
|
||||
if (!id) throw new Error('创建账号未返回账号 ID');
|
||||
history.replace(`/accounts/${encodeURIComponent(id)}/edit`);
|
||||
} catch (createError) {
|
||||
setError(createError);
|
||||
const environment = await creatorCreate('/creator/environments', { fingerprint: fingerprintPayload(values.fingerprint) });
|
||||
if (!environment?.alias) throw new Error('创建环境未返回环境标识');
|
||||
history.replace('/accounts');
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={conflictMessage(error, '该平台的账号 ID 已存在;表单内容已保留。')} />
|
||||
) : null}
|
||||
{error ? <Alert type="error" showIcon title={conflictMessage(error, '创建环境失败,配置已保留。')} /> : null}
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} style={{ maxWidth: 720 }}>
|
||||
<Form.Item name="name" label="账号名称" required rules={[{ required: true, message: '请输入账号名称' }]} extra="展示名称,支持中文">
|
||||
<Input maxLength={128} placeholder="如:店铺一号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="platform" label="平台类型" required rules={[{ required: true, message: '请选择平台' }]} extra="账号所属平台">
|
||||
<Select placeholder="选择平台" options={platforms} />
|
||||
</Form.Item>
|
||||
<Form.Item name="platform_account_key" label="账号 ID" required rules={[{ required: true, message: '请输入账号 ID' }]} extra="平台内唯一标识,同平台不可重复">
|
||||
<Input maxLength={128} />
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label="TAGS" extra="回车或逗号确认,退格删除末位标签">
|
||||
<Select mode="tags" tokenSeparators={[',', ',']} placeholder="如:主账号" open={false} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="cookies"
|
||||
label="Cookies"
|
||||
extra="可选。留空则创建后扫码登录;仅支持浏览器 Cookie Header 格式,如 name=value; token=value"
|
||||
>
|
||||
<Input.TextArea maxLength={8192} rows={3} placeholder="可选,如:sessionid=value; token=value" />
|
||||
</Form.Item>
|
||||
<Collapse
|
||||
style={{ marginBottom: 24 }}
|
||||
items={[
|
||||
{
|
||||
key: 'fingerprint',
|
||||
label: '指纹浏览器环境(可选)',
|
||||
children: (
|
||||
<>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
留空项使用浏览器默认值;seed 由系统按账号自动派生并保证唯一,代理由网络出口统一管理,均不在此配置。
|
||||
</Typography.Text>
|
||||
<FingerprintFields />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Form.Item>
|
||||
<Flex gap={8}>
|
||||
<Button onClick={() => history.push('/accounts')}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={busy}>
|
||||
{busy ? '创建中…' : '创建账号'}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
<Typography.Paragraph type="secondary">
|
||||
创建抖音浏览器环境后扫码登录。昵称、UID、头像和抖音号从登录结果同步,无需填写;登录成功前不会创建账号。
|
||||
</Typography.Paragraph>
|
||||
<Collapse style={{ marginBottom: 24 }} items={[{
|
||||
key: 'fingerprint', label: '浏览器配置(可选)', children: <>
|
||||
<Typography.Paragraph type="secondary">留空使用浏览器默认值。指纹标识由系统生成,登录绑定不会改变浏览器配置。</Typography.Paragraph>
|
||||
<FingerprintFields />
|
||||
</>,
|
||||
}]} />
|
||||
<Form.Item><Flex gap={8}>
|
||||
<Button onClick={() => history.push('/accounts')}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={busy}>创建浏览器环境</Button>
|
||||
</Flex></Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,12 @@ export function dateTime(value?: string | number | null): string {
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
||||
}
|
||||
|
||||
// 对齐归档版 conflictMessage:优先后端 error 字段;409/503 冲突类错误即使无 hint 也直接透出后端
|
||||
// 优先展示后端具体 reason;409/503 冲突类错误即使无 hint 也直接透出后端
|
||||
// 分类文案(如「该竞品正在同步中」),避免接口报错时界面无任何反馈。
|
||||
// 409 时若后端附带 reason_code(如 start/resume 的就绪原因),追加展示帮助定位。
|
||||
export function conflictMessage(error: any, conflictHint?: string): string {
|
||||
const status = error?.status ?? error?.body?.status;
|
||||
const message = error?.message || error?.body?.error || '请求失败';
|
||||
const message = error?.body?.reason || error?.body?.error || error?.message || '请求失败';
|
||||
const reason = error?.body?.reason_code;
|
||||
if (status === 409 && conflictHint) return reason ? `${message}:${conflictHint}(${reason})` : `${message}:${conflictHint}`;
|
||||
if ((status === 409 || status === 503) && message === '请求失败') {
|
||||
|
||||
@@ -19,7 +19,7 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
|
||||
{ test: (p) => p === '/accounts', title: '我的账号', subtitle: '创建和管理自己维护的账号。' },
|
||||
{ test: (p) => p === '/accounts/monitoring', title: '监控账号', subtitle: '管理需要持续跟踪的竞品账号及采集状态。' },
|
||||
{ test: (p) => /^\/accounts\/monitoring\/.+/.test(p), title: '监控账号详情', subtitle: '查看监控账号画像与作品列表,点击作品可追溯指标统计。' },
|
||||
{ test: (p) => p === '/accounts/new', title: '创建社媒账号', subtitle: '创建账号后,再在编辑页配置登录身份与账号策略。' },
|
||||
{ test: (p) => p === '/accounts/new', title: '创建浏览器环境', subtitle: '登录成功后自动同步抖音昵称、UID、头像和抖音号。' },
|
||||
{ test: (p) => /\/edit$/.test(p) && p.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' },
|
||||
{ test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },
|
||||
{ test: (p) => p === '/creator/competitors', title: '竞品分析', subtitle: '查看竞品作品与指标,分享链接导入请前往账号导入。' },
|
||||
|
||||
Reference in New Issue
Block a user