feat(accounts): consolidate detail resources and move exit switching to edit

This commit is contained in:
2026-10-06 10:30:53 +08:00
parent 94df412579
commit 67bbd199b2
4 changed files with 406 additions and 73 deletions
@@ -1,22 +1,20 @@
// 账号环境出口重绑:账号即环境下唯一的换绑入口(环境跟随账号,不再有独立环境页)。
// 仅列举检测通过的出口;重绑走 POST /browsers/:alias/rebind(alias=账号 ID,成功后由父级刷新)。
// 编辑页网络出口切换:仅列举检测通过的出口,以运行环境 alias 请求并返回最新环境状态。
import { useEffect, useMemo, useState } from 'react';
import { Button, Flex, Select, Typography } from 'antd';
import { getList } from '@/services/api';
import { jsonOptions, request } from '@/requestErrorConfig';
interface RebindEnvironmentProps {
accountID: string;
binding: any;
disabled?: boolean;
onRebound: () => void | Promise<void>;
onRebound: (environment: any) => void | Promise<void>;
onError: (error: unknown) => void;
}
export default function RebindEnvironment({ accountID, binding, disabled, onRebound, onError }: RebindEnvironmentProps) {
export default function RebindEnvironment({ binding, disabled, onRebound, onError }: RebindEnvironmentProps) {
const [exits, setExits] = useState<any[]>([]);
const [exitsError, setExitsError] = useState(false);
const [exitID, setExitID] = useState('');
const [exitID, setExitID] = useState(binding.network_exit_id ?? '');
const [busy, setBusy] = useState(false);
useEffect(() => {
@@ -25,32 +23,39 @@ export default function RebindEnvironment({ accountID, binding, disabled, onRebo
.then((result) => {
if (!cancelled) setExits((result.data ?? []).filter((item: any) => item.health_status === 'healthy'));
})
.catch(() => {
if (!cancelled) setExitsError(true);
.catch((reason) => {
if (!cancelled) {
console.error('[network-exit] Failed to load exits', { environmentAlias: binding.alias, error: reason });
setExitsError(true);
onError(reason);
}
});
return () => {
cancelled = true;
};
}, []);
// 直连(当前状态)或任一健康出口;已在用的出口不重复出现在选项里。
// 当前选择回显已绑定出口;直连与检测通过的出口均可选。
const options = useMemo(() => {
const choices = [{ value: '', label: '当前机器直连' }];
for (const exit of exits) {
if (exit.id === binding.network_exit_id) continue;
choices.push({ value: exit.id, label: `${exit.id}${exit.observed_public_ip ? ` · ${exit.observed_public_ip}` : ''}` });
}
return choices;
}, [exits, binding.network_exit_id]);
}, [exits]);
const unchanged = exitID === (binding.network_exit_id ?? '');
async function rebind() {
setBusy(true);
try {
await request(`/browsers/${encodeURIComponent(accountID)}/rebind`, jsonOptions('POST', { network_exit_id: exitID }));
await onRebound();
const result = await request(`/browsers/${encodeURIComponent(binding.alias)}/rebind`, jsonOptions('POST', { network_exit_id: exitID }));
if (result?.environment?.alias !== binding.alias) {
throw new Error('网络出口切换结果中的运行环境不匹配,请刷新页面确认实际状态。');
}
await onRebound(result.environment);
} catch (reason) {
console.error('[network-exit] Failed to switch exit', { environmentAlias: binding.alias, networkExitID: exitID, error: reason });
onError(reason);
} finally {
setBusy(false);
@@ -60,21 +65,22 @@ export default function RebindEnvironment({ accountID, binding, disabled, onRebo
return (
<Flex vertical gap={8}>
<Flex align="center" gap={8} wrap="wrap">
<Typography.Text type="secondary">切换出口</Typography.Text>
<Typography.Text type="secondary">目标出口</Typography.Text>
<Select
style={{ minWidth: 220 }}
value={exitID}
onChange={setExitID}
options={options}
placeholder={exitsError ? '出口列表不可用' : '选择目标出口'}
disabled={disabled || exitsError}
disabled={disabled || exitsError || busy}
aria-label="目标网络出口"
size="small"
/>
<Button size="small" type="primary" disabled={disabled || unchanged || busy} loading={busy} onClick={rebind}>
重绑
<Button size="small" type="primary" disabled={disabled || exitsError || unchanged || busy} loading={busy} onClick={rebind}>
保存网络出口
</Button>
</Flex>
{exitsError ? <Typography.Text type="secondary" style={{ fontSize: 12 }}>出口状态未知,请稍后重试。</Typography.Text> : null}
{exitsError ? <Typography.Text type="secondary" style={{ fontSize: 12 }}>出口状态未知,请刷新页面后重试。</Typography.Text> : null}
</Flex>
);
}
+33 -1
View File
@@ -1,4 +1,4 @@
// 编辑社媒账号:账号资料维护 + 指纹浏览器环境(单独保存) + 登录二维码/身份核验。
// 编辑社媒账号:账号资料维护 + 网络出口切换 + 指纹浏览器环境(分别保存) + 登录二维码/身份核验。
// 资料表单三行分组:登录用户名/更新密码/业务状态;实名状态/实名姓名/实名证件号;账号备注。
// 指纹字段复用 FingerprintFields 共享组件;保存后运行中的环境由后端停后以新指纹重启,未运行仅落库下次启动生效。
// 布局对齐全站模式:实体标识经 usePageInfo 进页头 content 左侧,返回/登录操作按钮经 usePageActions 进页头右侧。
@@ -9,6 +9,7 @@ import { usePageActions, usePageInfo } from '@/components/PageActions';
import FingerprintFields, { fingerprintFormValues, fingerprintPayload } from '@/components/accounts/FingerprintFields';
import { creatorUpdate, getOne, getList } from '@/services/api';
import AccountLoginModal from '@/components/accounts/AccountLoginModal';
import RebindEnvironment from '@/components/accounts/RebindEnvironment';
import { conflictMessage, dateTime } from '@/utils/helpers';
const platformLabelMap: Record<string, string> = { douyin: '抖音' };
@@ -40,6 +41,7 @@ function profileForm(profile: any) {
export default function Page() {
const { id: selectedID = '' } = useParams<{ id: string }>();
const [profiles, setProfiles] = useState<any[]>([]);
const [binding, setBinding] = useState<any>(null);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [busy, setBusy] = useState(false);
@@ -92,6 +94,7 @@ export default function Page() {
} else {
setError(new Error('账号不存在'));
}
setBinding(detail.environment ?? null);
// 指纹浏览器环境:账号详情内联 environment.fingerprint 回显(未绑定环境时保持空表单)。
fpForm.setFieldsValue(fingerprintFormValues(detail?.environment?.fingerprint));
} catch (loadError) {
@@ -137,6 +140,7 @@ export default function Page() {
`/creator/accounts/${encodeURIComponent(selected.id)}/fingerprint`,
payload,
);
setBinding(result);
fpForm.setFieldsValue(fingerprintFormValues(result?.fingerprint));
messageApi.success(
result?.runtime_id ? '指纹已保存,运行环境已用新配置重启。' : '指纹已保存,将在环境下次启动时生效。',
@@ -230,6 +234,34 @@ export default function Page() {
</Flex>
</Form>
</Card>
<Card title="网络出口">
<Flex vertical gap={16}>
{binding ? (
<>
<Descriptions column={1} size="small">
<Descriptions.Item label="当前出口">
{binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'}
</Descriptions.Item>
</Descriptions>
<Typography.Text type="secondary">
仅可切换到检测通过的网络出口或当前机器直连;运行中的环境切换后会重启。此操作单独保存,不影响未保存的账号资料和指纹。
</Typography.Text>
<RebindEnvironment
key={`${binding.alias}:${binding.binding_version}`}
binding={binding}
disabled={busy || fpBusy || loginOpen}
onRebound={(environment) => {
setBinding(environment);
messageApi.success('网络出口已切换。');
}}
onError={(reason) => messageApi.error(conflictMessage(reason, '网络出口切换失败:目标出口需检测通过且账号环境处于可切换状态。'))}
/>
</>
) : (
<Typography.Text type="secondary">尚未绑定运行环境,无法切换网络出口。</Typography.Text>
)}
</Flex>
</Card>
<Card
title="指纹浏览器环境"
extra={
+34 -54
View File
@@ -1,17 +1,16 @@
// 账号详情:样式对齐 Pro Advanced Profile(卡片流)——
// 主体为带标题 Card 纵向堆叠(账号状态含采集状态 / 账号数据粉丝趋势 / 固定资源 / 作品数据 / 文本草稿);
// 主体为带标题 Card 纵向堆叠(账号状态含采集状态与固定资源 / 账号数据粉丝趋势 / 作品数据);
// 实体标识(账号名 + 状态 Tag + 平台明细)经 usePageInfo 进页头 content 行左侧,操作按钮在页头右侧。
// 数据监控:采集状态走 GET /creator/accounts/:id/collection-status,粉丝时序走 :id/metrics,
// 作品网格走共享 WorksGallery(source_type='owned'),强制同步直调 POST /creator/accounts/:id/sync。
import { useCallback, useEffect, useMemo, useState } from 'react';
import { history, useParams } from '@umijs/max';
import { Line } from '@ant-design/charts';
import { Alert, App, Button, Card, Descriptions, Flex, Modal, Space, Tag, Typography } from 'antd';
import { Alert, App, Button, Card, Descriptions, Flex, Modal, Space, Statistic, Tag, Typography } from 'antd';
import { accountAction, getOne, creatorGet, creatorAction } from '@/services/api';
import { request } from '@/requestErrorConfig';
import { accountReadiness, conflictMessage, dateTime, platformLabel } from '@/utils/helpers';
import { usePageActions, usePageInfo } from '@/components/PageActions';
import RebindEnvironment from '@/components/accounts/RebindEnvironment';
import WorksGallery from '@/components/creator/WorksGallery';
// 采集状态映射:checkpoint 形态(pending=尚未采集过;其余与竞品 sync_status 语义一致)。
@@ -27,8 +26,8 @@ const accountSeriesMeta = [
{ key: 'follower_count', label: '粉丝' },
{ key: 'following_count', label: '关注' },
{ key: 'total_favorited', label: '获赞' },
{ key: 'aweme_count', label: '作品总数' },
{ key: 'friend_count', label: '互关' },
{ key: 'aweme_count', label: '作品总数' },
] as const;
function formatCount(value?: number | null): string {
@@ -233,6 +232,29 @@ export default function Page() {
) : null}
</Descriptions.Item>
</Descriptions>
{browsersError ? (
<Alert
type="error"
showIcon
title="环境不可用:运行环境与网络出口状态未知,依赖资源状态的操作暂不可用。"
action={<Button size="small" onClick={loadAll}>重试环境状态</Button>}
/>
) : binding ? (
<Descriptions column={1} size="small">
<Descriptions.Item label="运行环境">{`${binding.name}(${binding.alias})`}</Descriptions.Item>
<Descriptions.Item label="网关">{binding.gateway}</Descriptions.Item>
<Descriptions.Item label="网络出口">
{binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'}
</Descriptions.Item>
<Descriptions.Item label="绑定版本">{binding.binding_version}</Descriptions.Item>
<Descriptions.Item label="运行实例">{binding.runtime_id || '未启动'}</Descriptions.Item>
<Descriptions.Item label="不可调度原因">{binding.schedule_block_reason || '无'}</Descriptions.Item>
</Descriptions>
) : (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
尚未绑定运行环境。
</Typography.Paragraph>
)}
<Alert
type="info"
showIcon
@@ -246,13 +268,14 @@ export default function Page() {
</Card>
<Card title="账号数据">
<Flex vertical gap={12}>
<Descriptions column={{ xs: 2, sm: 4 }} size="small">
<Descriptions.Item label="粉丝">{formatCount(latestMetric?.follower_count)}</Descriptions.Item>
<Descriptions.Item label="关注">{formatCount(latestMetric?.following_count)}</Descriptions.Item>
<Descriptions.Item label="获赞">{formatCount(latestMetric?.total_favorited)}</Descriptions.Item>
<Descriptions.Item label="互关">{formatCount(latestMetric?.friend_count)}</Descriptions.Item>
<Descriptions.Item label="作品总数">{formatCount(latestMetric?.aweme_count)}</Descriptions.Item>
</Descriptions>
<Flex gap={16} wrap={false} style={{ overflowX: 'auto' }}>
{accountSeriesMeta.map((metric) => (
<Flex key={metric.key} vertical gap={4} flex="1 0 auto">
<Statistic value={formatCount(latestMetric?.[metric.key])} />
<Typography.Text type="secondary">{metric.label}</Typography.Text>
</Flex>
))}
</Flex>
{accountMetrics.length >= 2 ? (
<AccountTrendChart metrics={accountMetrics} />
) : (
@@ -263,49 +286,6 @@ export default function Page() {
</Flex>
</Card>
<WorksGallery sourceId={account.id} sourceType="owned" />
<Card title="固定资源">
{browsersError ? (
<Alert
type="error"
showIcon
title="环境不可用:运行环境与网络出口状态未知,依赖资源状态的操作暂不可用。"
action={
<Button size="small" onClick={loadAll}>
重试环境状态
</Button>
}
/>
) : binding ? (
<Flex vertical gap={12}>
<Descriptions column={1} size="small">
<Descriptions.Item label="运行环境">{`${binding.name}(${binding.alias})`}</Descriptions.Item>
<Descriptions.Item label="网关">{binding.gateway}</Descriptions.Item>
<Descriptions.Item label="网络出口">
{binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'}
</Descriptions.Item>
<Descriptions.Item label="绑定版本">{binding.binding_version}</Descriptions.Item>
<Descriptions.Item label="运行实例">{binding.runtime_id || '未启动'}</Descriptions.Item>
<Descriptions.Item label="不可调度原因">{binding.schedule_block_reason || '无'}</Descriptions.Item>
</Descriptions>
<RebindEnvironment
accountID={id}
binding={binding}
disabled={actionBusy}
onRebound={async () => {
messageApi.success('出口重绑成功,账号环境已同步。');
await loadAll();
}}
onError={(reason) => messageApi.error(conflictMessage(reason, '出口重绑失败:目标出口需检测通过且账号环境处于可重绑状态。'))}
/>
</Flex>
) : (
<Flex vertical gap={12}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
尚未绑定运行环境。账号创建时若绑定失败,可联系管理员排查后重试。
</Typography.Paragraph>
</Flex>
)}
</Card>
<Modal
open={pauseOpen}
title="暂停账号"