feat(accounts): consolidate detail resources and move exit switching to edit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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="暂停账号"
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const Module = require('node:module');
|
||||
const { test } = require('node:test');
|
||||
const ts = require('typescript');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
const element = (type, props = {}) => ({ type, props });
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Execute the real page/component with isolated hooks and API calls, without a browser.
|
||||
function harness(relativePath, api = {}) {
|
||||
let cursor = 0;
|
||||
let props = {};
|
||||
const slots = [];
|
||||
const effects = [];
|
||||
const forms = [];
|
||||
const calls = { success: [], error: [], requests: [], updates: [], navigation: [] };
|
||||
const useState = (initial) => {
|
||||
const index = cursor++;
|
||||
if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial;
|
||||
return [slots[index], (value) => { slots[index] = typeof value === 'function' ? value(slots[index]) : value; }];
|
||||
};
|
||||
const useMemo = (factory, deps) => {
|
||||
const index = cursor++;
|
||||
if (!slots[index] || deps.some((value, i) => !Object.is(value, slots[index].deps[i]))) {
|
||||
slots[index] = { value: factory(), deps };
|
||||
}
|
||||
return slots[index].value;
|
||||
};
|
||||
const react = {
|
||||
useState, useMemo,
|
||||
useCallback: (callback, deps) => useMemo(() => callback, deps),
|
||||
useEffect(callback, deps) {
|
||||
const index = cursor++;
|
||||
if (!slots[index] || deps.some((value, i) => !Object.is(value, slots[index].deps[i]))) {
|
||||
slots[index]?.cleanup?.();
|
||||
slots[index] = { deps };
|
||||
effects.push(() => { slots[index].cleanup = callback(); });
|
||||
}
|
||||
},
|
||||
};
|
||||
const named = (name, children = []) => Object.assign(function Component() {},
|
||||
{ displayName: name }, Object.fromEntries(children.map((child) => [child, `${name}.${child}`])));
|
||||
const antd = Object.fromEntries(['Alert', 'Button', 'Card', 'Col', 'Flex', 'Modal', 'Row', 'Select', 'Space', 'Statistic', 'Tag'].map((name) => [name, name]));
|
||||
antd.Descriptions = named('Descriptions', ['Item']);
|
||||
antd.Typography = named('Typography', ['Title', 'Text', 'Paragraph']);
|
||||
antd.Input = named('Input', ['Password', 'TextArea']);
|
||||
antd.Form = named('Form', ['Item']);
|
||||
antd.Form.useForm = () => {
|
||||
const [form] = useState(() => {
|
||||
const instance = {
|
||||
values: {},
|
||||
setFieldsValue(values) { Object.assign(this.values, values); },
|
||||
getFieldValue(key) { return this.values[key]; },
|
||||
async validateFields() { return this.values; },
|
||||
};
|
||||
forms.push(instance);
|
||||
return instance;
|
||||
});
|
||||
return [form];
|
||||
};
|
||||
antd.App = { useApp: () => ({ message: {
|
||||
success: (value) => calls.success.push(value), error: (value) => calls.error.push(value),
|
||||
} }) };
|
||||
const modules = {
|
||||
react,
|
||||
'react/jsx-runtime': { jsx: element, jsxs: element, Fragment: 'Fragment' },
|
||||
'@umijs/max': { useParams: () => ({ id: 'account-1' }), history: { push: (url) => calls.navigation.push(url) } },
|
||||
'@ant-design/charts': { Line: 'Line' },
|
||||
antd,
|
||||
'@/services/api': {
|
||||
getOne: async () => api.detail,
|
||||
getList: async ({ resource }) => ({ data: resource === 'network-exits' ? (api.exits || []) : [api.profile || {}] }),
|
||||
creatorGet: async (url) => url.endsWith('/metrics') ? (api.metrics || []) : (api.collectionStatus || {}),
|
||||
creatorAction: async () => {}, accountAction: async () => {},
|
||||
creatorUpdate: async (url, payload) => { calls.updates.push({ url, payload }); return api.updateResult || payload; },
|
||||
...api.services,
|
||||
},
|
||||
'@/requestErrorConfig': {
|
||||
jsonOptions: (method, body) => ({ method, body: JSON.stringify(body) }),
|
||||
request: async (url, options) => { calls.requests.push({ url, options }); if (api.requestError) throw api.requestError; return api.requestResult; },
|
||||
},
|
||||
'@/utils/helpers': {
|
||||
accountReadiness: () => ({ ready: true, canResume: true, label: '就绪' }),
|
||||
conflictMessage: (error) => error.message,
|
||||
dateTime: (value) => value, platformLabel: () => '抖音',
|
||||
},
|
||||
'@/components/PageActions': {
|
||||
usePageInfo: (tree) => { calls.info = tree; },
|
||||
usePageActions: (tree) => { calls.actions = tree; },
|
||||
},
|
||||
'@/components/accounts/RebindEnvironment': { default: 'RebindEnvironment', __esModule: true },
|
||||
'@/components/creator/WorksGallery': { default: 'WorksGallery', __esModule: true },
|
||||
'@/components/accounts/AccountLoginModal': { default: 'AccountLoginModal', __esModule: true },
|
||||
'@/components/accounts/FingerprintFields': {
|
||||
__esModule: true, default: 'FingerprintFields',
|
||||
fingerprintFormValues: (fingerprint) => ({ fingerprint }), fingerprintPayload: (value) => value,
|
||||
},
|
||||
};
|
||||
const filename = path.join(root, relativePath);
|
||||
const loaded = new Module(filename);
|
||||
loaded.filename = filename;
|
||||
loaded.require = (name) => {
|
||||
assert.ok(name in modules, `Unexpected import: ${name}`);
|
||||
return modules[name];
|
||||
};
|
||||
loaded._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
||||
fileName: filename, compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText, filename);
|
||||
return {
|
||||
calls, forms,
|
||||
render(nextProps = props) {
|
||||
props = nextProps;
|
||||
cursor = 0;
|
||||
const tree = loaded.exports.default(props);
|
||||
while (effects.length) effects.shift()();
|
||||
return tree;
|
||||
},
|
||||
async mount(nextProps) { this.render(nextProps); await flush(); return this.render(); },
|
||||
unmount() { slots.forEach((slot) => slot?.cleanup?.()); },
|
||||
};
|
||||
}
|
||||
|
||||
function findAll(tree, predicate) {
|
||||
if (!tree || typeof tree !== 'object') return [];
|
||||
if (Array.isArray(tree)) return tree.flatMap((child) => findAll(child, predicate));
|
||||
return [...(predicate(tree) ? [tree] : []), ...findAll(tree.props?.children, predicate), ...findAll(tree.props?.extra, predicate)];
|
||||
}
|
||||
const byType = (tree, type) => findAll(tree, (node) => node.type === type);
|
||||
const card = (tree, title) => findAll(tree, (node) => node.type === 'Card' && node.props.title === title)[0];
|
||||
const text = (tree) => {
|
||||
if (tree == null) return '';
|
||||
if (Array.isArray(tree)) return tree.map(text).join('');
|
||||
return typeof tree === 'object' ? text(tree.props?.children) : String(tree);
|
||||
};
|
||||
const detailPath = 'src/pages/accounts/$id/index.tsx';
|
||||
const editPath = 'src/pages/accounts/$id/edit.tsx';
|
||||
const rebindPath = 'src/components/accounts/RebindEnvironment.tsx';
|
||||
const binding = { alias: 'environment/1', name: '环境一', gateway: 'gateway-1', network_exit_id: 'exit-current', network_exit_health: 'healthy', binding_version: 3, runtime_id: 'runtime-1', fingerprint: { seed: 42 } };
|
||||
const detail = { id: 'account-1', name: '账号一', platform: 'douyin', platform_account_key: '123', runtime_status: 'active', version: 2, tags: ['测试'], environment: binding };
|
||||
const profile = { ...detail, note: '原备注', cooldown_seconds: 60 };
|
||||
|
||||
test('owned account metrics are one horizontal row with each number above its label', async () => {
|
||||
const page = harness(detailPath, { detail, metrics: [{ follower_count: 12500, following_count: 0, total_favorited: null, friend_count: 8, aweme_count: 100 }] });
|
||||
const tree = await page.mount();
|
||||
const dataCard = card(tree, '账号数据');
|
||||
const row = byType(dataCard, 'Flex').find((node) => byType(node, 'Statistic').length === 5 && !node.props.vertical);
|
||||
assert.ok(row, 'all five metrics must share a horizontal Flex');
|
||||
assert.notEqual(row.props.wrap, 'wrap');
|
||||
const cells = byType(row, 'Flex').filter((node) => node.props.vertical);
|
||||
assert.equal(cells.length, 5);
|
||||
const labels = ['粉丝', '关注', '获赞', '互关', '作品总数'];
|
||||
const values = ['1.3w', '0', '—', '8', '100'];
|
||||
cells.forEach((cell, index) => {
|
||||
const [number, label] = cell.props.children;
|
||||
assert.equal(number.type, 'Statistic');
|
||||
assert.equal(number.props.value, values[index]);
|
||||
assert.equal(text(label), labels[index]);
|
||||
});
|
||||
assert.equal(byType(dataCard, 'Descriptions.Item').length, 0);
|
||||
});
|
||||
|
||||
test('all fixed resources live in account status and detail has no exit switching', async () => {
|
||||
const page = harness(detailPath, { detail });
|
||||
const tree = await page.mount();
|
||||
assert.equal(card(tree, '固定资源'), undefined);
|
||||
const status = card(tree, '账号状态');
|
||||
for (const label of ['运行环境', '网关', '网络出口', '绑定版本', '运行实例', '不可调度原因']) {
|
||||
assert.equal(byType(status, 'Descriptions.Item').filter((node) => node.props.label === label).length, 1);
|
||||
}
|
||||
assert.match(text(status), /gateway-1/);
|
||||
assert.match(text(status), /exit-current/);
|
||||
assert.equal(byType(tree, 'RebindEnvironment').length, 0);
|
||||
});
|
||||
|
||||
test('detail retains missing data, no-environment state, trends, and account actions', async () => {
|
||||
const page = harness(detailPath, { detail: { ...detail, environment: null, runtime_status: 'paused' } });
|
||||
let tree = await page.mount();
|
||||
assert.match(text(card(tree, '账号状态')), /尚未绑定运行环境/);
|
||||
assert.deepEqual(byType(card(tree, '账号数据'), 'Statistic').map((node) => node.props.value), ['—', '—', '—', '—', '—']);
|
||||
await byType(card(tree, '账号状态'), 'Button')[0].props.onClick();
|
||||
assert.equal(page.calls.requests[0].url, '/phase-a/accounts/account-1/start');
|
||||
await byType(page.calls.actions, 'Button')[1].props.onClick();
|
||||
byType(page.calls.actions, 'Button')[2].props.onClick();
|
||||
assert.deepEqual(page.calls.navigation, ['/accounts/account-1/edit']);
|
||||
|
||||
const active = harness(detailPath, { detail, metrics: [{ collected_at: 't1', follower_count: 1 }, { collected_at: 't2', follower_count: 2 }] });
|
||||
tree = await active.mount();
|
||||
const trend = findAll(card(tree, '账号数据'), (node) => typeof node.type === 'function' && node.type.name === 'AccountTrendChart')[0];
|
||||
const chart = trend.type(trend.props);
|
||||
assert.equal(chart.props.data.length, 2);
|
||||
assert.equal(chart.props.yAxis.labelFormatter(20000), '2.0w');
|
||||
assert.equal(chart.props.yAxis.labelFormatter(20), '20');
|
||||
byType(card(tree, '账号状态'), 'Button')[0].props.onClick();
|
||||
tree = active.render();
|
||||
assert.equal(byType(tree, 'Modal')[0].props.open, true);
|
||||
await byType(tree, 'Modal')[0].props.onOk();
|
||||
assert.equal(byType(active.render(), 'Modal')[0].props.open, false);
|
||||
});
|
||||
|
||||
test('edit has an independent network exit card; switching does not reset unsaved forms', async () => {
|
||||
const page = harness(editPath, { detail, profile });
|
||||
let tree = await page.mount();
|
||||
const exitCard = card(tree, '网络出口');
|
||||
assert.ok(exitCard);
|
||||
assert.match(text(exitCard), /exit-current/);
|
||||
assert.match(text(exitCard), /运行中的环境/);
|
||||
const rebind = byType(exitCard, 'RebindEnvironment')[0];
|
||||
assert.equal(rebind.props.binding.alias, binding.alias);
|
||||
page.forms[0].values.note = '未保存的备注';
|
||||
page.forms[1].values.fingerprint = { seed: 42, user_agent: '未保存的指纹' };
|
||||
const changed = { ...binding, network_exit_id: 'exit-next', binding_version: 4 };
|
||||
await rebind.props.onRebound(changed);
|
||||
tree = page.render();
|
||||
assert.match(text(card(tree, '网络出口')), /exit-next/);
|
||||
assert.equal(page.forms[0].values.note, '未保存的备注');
|
||||
assert.equal(page.forms[1].values.fingerprint.user_agent, '未保存的指纹');
|
||||
byType(card(tree, '网络出口'), 'RebindEnvironment')[0].props.onError(new Error('出口检测失败'));
|
||||
assert.deepEqual(page.calls.error, ['出口检测失败']);
|
||||
});
|
||||
|
||||
test('editing a missing environment shows an explanation without a switch', async () => {
|
||||
const page = harness(editPath, { detail: { ...detail, environment: null }, profile });
|
||||
const tree = await page.mount();
|
||||
assert.match(text(card(tree, '网络出口')), /尚未绑定运行环境/);
|
||||
assert.equal(byType(tree, 'RebindEnvironment').length, 0);
|
||||
});
|
||||
|
||||
test('profile and fingerprint continue to save separately', async () => {
|
||||
const page = harness(editPath, { detail, profile, updateResult: { ...profile, ...binding } });
|
||||
let tree = await page.mount();
|
||||
const profileForm = findAll(card(tree, '账号资料'), (node) => node.props?.onFinish)[0];
|
||||
await profileForm.props.onFinish({ note: '新备注' });
|
||||
assert.equal(page.calls.updates[0].url, '/creator/accounts/account-1/profile');
|
||||
assert.equal(page.calls.updates[0].payload.cooldown_seconds, 60);
|
||||
await byType(card(tree, '指纹浏览器环境'), 'Button')[0].props.onClick();
|
||||
assert.equal(page.calls.updates[1].url, '/creator/accounts/account-1/fingerprint');
|
||||
assert.deepEqual(page.calls.updates[1].payload, binding.fingerprint);
|
||||
tree = page.render();
|
||||
byType(page.calls.actions, 'Button')[1].props.onClick();
|
||||
tree = page.render();
|
||||
assert.equal(byType(tree, 'AccountLoginModal').length, 1);
|
||||
byType(tree, 'AccountLoginModal')[0].props.onClose();
|
||||
assert.equal(byType(page.render(), 'AccountLoginModal').length, 0);
|
||||
});
|
||||
|
||||
test('switch uses the environment alias, selects the current exit, and returns updated binding', async () => {
|
||||
const changed = { ...binding, network_exit_id: 'exit-next', binding_version: 4 };
|
||||
const page = harness(rebindPath, { exits: [
|
||||
{ id: 'exit-current', health_status: 'healthy' },
|
||||
{ id: 'exit-next', health_status: 'healthy', observed_public_ip: '1.2.3.4' },
|
||||
{ id: 'exit-bad', health_status: 'failed' },
|
||||
], requestResult: { account_id: detail.id, environment: changed } });
|
||||
// The switch returns the server's environment view, without another full-page reload.
|
||||
page.calls.rebound = [];
|
||||
let tree = await page.mount({ binding, onRebound: (result) => page.calls.rebound.push(result), onError: (error) => page.calls.error.push(error.message) });
|
||||
assert.equal(byType(tree, 'Select')[0].props.value, 'exit-current');
|
||||
assert.equal(byType(tree, 'Button')[0].props.disabled, true);
|
||||
const options = byType(tree, 'Select')[0].props.options;
|
||||
assert.deepEqual(options.map((item) => item.value), ['', 'exit-current', 'exit-next']);
|
||||
byType(tree, 'Select')[0].props.onChange('exit-next');
|
||||
tree = page.render();
|
||||
assert.equal(byType(tree, 'Button')[0].props.disabled, false);
|
||||
await byType(tree, 'Button')[0].props.onClick();
|
||||
assert.equal(page.calls.requests[0].url, '/browsers/environment%2F1/rebind');
|
||||
assert.equal(page.calls.requests[0].options.body, JSON.stringify({ network_exit_id: 'exit-next' }));
|
||||
assert.deepEqual(page.calls.rebound, [changed]);
|
||||
page.unmount();
|
||||
});
|
||||
|
||||
test('unavailable exits and rejected switches stay visible and do not report success', async (t) => {
|
||||
const logs = [];
|
||||
t.mock.method(console, 'error', (...args) => logs.push(args));
|
||||
const error = new Error('出口不可用');
|
||||
const unavailable = harness(rebindPath, { services: { getList: async () => { throw error; } } });
|
||||
const tree = await unavailable.mount({ binding, onRebound: () => assert.fail('must not succeed'), onError: (reason) => unavailable.calls.error.push(reason.message) });
|
||||
assert.deepEqual(unavailable.calls.error, ['出口不可用']);
|
||||
assert.equal(byType(tree, 'Select')[0].props.disabled, true);
|
||||
assert.equal(byType(tree, 'Button')[0].props.disabled, true);
|
||||
assert.match(text(tree), /出口状态未知/);
|
||||
|
||||
const failed = harness(rebindPath, { requestError: error });
|
||||
let failedTree = await failed.mount({ binding, onRebound: () => assert.fail('must not succeed'), onError: (reason) => failed.calls.error.push(reason.message) });
|
||||
byType(failedTree, 'Select')[0].props.onChange('');
|
||||
failedTree = failed.render();
|
||||
await byType(failedTree, 'Button')[0].props.onClick();
|
||||
assert.deepEqual(failed.calls.error, ['出口不可用']);
|
||||
assert.equal(byType(failed.render(), 'Button')[0].props.loading, false);
|
||||
assert.equal(logs.length, 2);
|
||||
assert.equal(logs[1][1].environmentAlias, binding.alias);
|
||||
assert.equal(logs[1][1].networkExitID, '');
|
||||
});
|
||||
|
||||
test('direct connection succeeds, but missing or mismatched environment responses never report success', async (t) => {
|
||||
t.mock.method(console, 'error', () => {});
|
||||
for (const environment of [{ ...binding, network_exit_id: '' }, undefined, { alias: 'wrong-environment' }]) {
|
||||
const page = harness(rebindPath, { requestResult: { environment } });
|
||||
const rebound = [];
|
||||
let tree = await page.mount({ binding, onRebound: (result) => rebound.push(result), onError: (reason) => page.calls.error.push(reason.message) });
|
||||
byType(tree, 'Select')[0].props.onChange('');
|
||||
tree = page.render();
|
||||
await byType(tree, 'Button')[0].props.onClick();
|
||||
assert.equal(page.calls.requests[0].options.body, JSON.stringify({ network_exit_id: '' }));
|
||||
if (environment?.alias === binding.alias) {
|
||||
assert.deepEqual(rebound, [environment]);
|
||||
assert.deepEqual(page.calls.error, []);
|
||||
} else {
|
||||
assert.deepEqual(rebound, []);
|
||||
assert.match(page.calls.error[0], /运行环境不匹配/);
|
||||
}
|
||||
page.unmount();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user