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); };
}
+22 -67
View File
@@ -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 />
+6 -3
View File
@@ -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="作品采集">
+24 -83
View File
@@ -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>
);
+2 -2
View File
@@ -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 === '请求失败') {
+1 -1
View File
@@ -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: '查看竞品作品与指标,分享链接导入请前往账号导入。' },