feat(web): 账号即环境 UI 收敛——删 /browsers 页面,环境并入账号详情
- 删除 /browsers 列表/创建/详情页与菜单项,运行环境不再是独立资源 - 账号详情:GET /api/phase-a/accounts/:id 内联 environment 字段 (网关/出口/绑定版本/运行实例/不可调度原因),固定资源卡直接展示 - 状态卡两态开关:暂停→「启动账号」(start=resume+启动环境), 运行中→「暂停账号」(联动停环境);出口重绑卡片内完成 (POST /network-exits/:id/rebind 返回内联 environment,出口切换不再跳环境页) - 删除 creator/settings 残留的转写服务字段(后端 settings 无此列) - helpers.ts accountReadiness 改用 runtime_id/runtime_cleanup_pending - network-exits 列表绑定账号列改由账号列表内联数据驱动 - 全量验证:go test ./...(PG)全绿、tsc --noEmit 通过、max build 通过、 node --test 12/12 通过
This commit is contained in:
@@ -71,13 +71,7 @@ func RegisterAccountRoutes(app *fiber.App, store *accountdomain.Store, runtimeSt
|
||||
return c.JSON(accounts)
|
||||
})
|
||||
|
||||
app.Get("/api/phase-a/accounts/:id", func(c fiber.Ctx) error {
|
||||
account, err := store.GetAccount(c.Context(), c.Params("id"))
|
||||
if err != nil {
|
||||
return phaseAError(c, err)
|
||||
}
|
||||
return c.JSON(account)
|
||||
})
|
||||
app.Get("/api/phase-a/accounts/:id", accountDetailPayload(store, runtimeStore))
|
||||
|
||||
app.Post("/api/phase-a/accounts/:id/pause", func(c fiber.Ctx) error {
|
||||
unlock, err := lockAccountResources(c.Context(), runtimeStore, c.Params("id"))
|
||||
@@ -290,3 +284,37 @@ func phaseAError(c fiber.Ctx, err error) error {
|
||||
func PhaseAError(c fiber.Ctx, err error) error {
|
||||
return phaseAError(c, err)
|
||||
}
|
||||
|
||||
// accountEnvironmentView 账号详情/出口重绑共用:账号即环境下内联展示的环境视图(schedule_block_reason 取值同 envView)。
|
||||
func accountEnvironmentView(environment hub.EnvironmentContext) map[string]any {
|
||||
scheduleStatus, scheduleBlockReason := environmentScheduleReadiness(environment)
|
||||
return map[string]any{
|
||||
"alias": environment.Alias, "name": environment.Name, "gateway": environment.Gateway,
|
||||
"binding_version": environment.BindingVersion, "runtime_id": environment.RuntimeID,
|
||||
"runtime_cleanup_pending": environment.RuntimeCleanupPending,
|
||||
"network_exit_id": environment.Exit.ID, "network_exit_health": environment.Exit.HealthStatus,
|
||||
"schedule_status": scheduleStatus, "schedule_block_reason": scheduleBlockReason,
|
||||
}
|
||||
}
|
||||
|
||||
// accountDetailPayload 账号详情:环境信息内联在响应中(账号即环境),前端无需单独请求 /browsers。
|
||||
func accountDetailPayload(store *accountdomain.Store, runtimeStore HubStore) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
account, err := store.GetAccount(c.Context(), c.Params("id"))
|
||||
if err != nil {
|
||||
return phaseAError(c, err)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"id": account.ID, "name": account.Name, "platform": account.Platform,
|
||||
"platform_account_key": account.PlatformAccountKey, "tags": account.Tags,
|
||||
"authorization_status": account.AuthorizationStatus, "runtime_status": account.RuntimeStatus,
|
||||
"version": account.Version,
|
||||
}
|
||||
if runtimeStore != nil {
|
||||
if environment, envErr := runtimeStore.GetEnvironmentContextForAccount(c.Context(), account.ID); envErr == nil {
|
||||
payload["environment"] = accountEnvironmentView(environment)
|
||||
}
|
||||
}
|
||||
return c.JSON(payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1975,7 +1975,7 @@ func rebindBrowser(store HubStore, probe NetworkExitProbe, resolve func(hub.Netw
|
||||
if err := finish("succeeded", "environment_rebound", after); err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
return c.JSON(after)
|
||||
return c.JSON(map[string]any{"account_id": after.AccountID, "environment": accountEnvironmentView(after)})
|
||||
}
|
||||
|
||||
func deleteBrowser(store HubStore) fiber.Handler {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// 账号环境出口重绑:账号即环境下唯一的换绑入口(环境跟随账号,不再有独立环境页)。
|
||||
// 仅列举检测通过的出口;重绑走 POST /network-exits/:id/rebind(后端联动账号环境),成功后由父级刷新。
|
||||
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 {
|
||||
binding: any;
|
||||
disabled?: boolean;
|
||||
onRebound: () => void | Promise<void>;
|
||||
onError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export default function RebindEnvironment({ binding, disabled, onRebound, onError }: RebindEnvironmentProps) {
|
||||
const [exits, setExits] = useState<any[]>([]);
|
||||
const [exitsError, setExitsError] = useState(false);
|
||||
const [exitID, setExitID] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getList({ resource: 'network-exits' })
|
||||
.then((result) => {
|
||||
if (!cancelled) setExits((result.data ?? []).filter((item: any) => item.health_status === 'healthy'));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setExitsError(true);
|
||||
});
|
||||
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]);
|
||||
|
||||
const unchanged = exitID === (binding.network_exit_id ?? '');
|
||||
|
||||
async function rebind() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await request(`/network-exits/${encodeURIComponent(binding.network_exit_id ?? '')}/rebind`, jsonOptions('POST', { network_exit_id: exitID }));
|
||||
await onRebound();
|
||||
} catch (reason) {
|
||||
onError(reason);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex vertical gap={8}>
|
||||
<Flex align="center" gap={8} wrap="wrap">
|
||||
<Typography.Text type="secondary">切换出口</Typography.Text>
|
||||
<Select
|
||||
style={{ minWidth: 220 }}
|
||||
value={exitID}
|
||||
onChange={setExitID}
|
||||
options={options}
|
||||
placeholder={exitsError ? '出口列表不可用' : '选择目标出口'}
|
||||
disabled={disabled || exitsError}
|
||||
size="small"
|
||||
/>
|
||||
<Button size="small" type="primary" disabled={disabled || unchanged || busy} loading={busy} onClick={rebind}>
|
||||
重绑
|
||||
</Button>
|
||||
</Flex>
|
||||
{exitsError ? <Typography.Text type="secondary" style={{ fontSize: 12 }}>出口状态未知,请稍后重试。</Typography.Text> : null}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,11 @@ 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 { accountAction, getOne, getList, creatorGet, creatorAction } from '@/services/api';
|
||||
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 语义一致)。
|
||||
@@ -104,23 +106,11 @@ export default function Page() {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [accountResult, browsersResult] = await Promise.allSettled([
|
||||
getOne('accounts', id),
|
||||
getList({ resource: 'browsers' }),
|
||||
]);
|
||||
if (accountResult.status === 'fulfilled') {
|
||||
setAccount(accountResult.value);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(accountResult.reason);
|
||||
}
|
||||
if (browsersResult.status === 'fulfilled') {
|
||||
setBrowsersError(null);
|
||||
setBinding((browsersResult.value.data ?? []).find((item: any) => item.account_id === id) ?? null);
|
||||
} else {
|
||||
setBrowsersError(browsersResult.reason);
|
||||
setBinding(undefined);
|
||||
}
|
||||
// 账号即环境:环境信息在账号详情响应内(内联),不再单独请求 /browsers 列表。
|
||||
const accountResult = await getOne('accounts', id);
|
||||
setAccount(accountResult);
|
||||
setBinding(accountResult.environment ?? null);
|
||||
setError(null);
|
||||
// 监控数据:采集失败不阻塞页面主体,采集状态为空时按待采集展示。
|
||||
try {
|
||||
setCollectionStatus(await creatorGet(`/creator/accounts/${encodeURIComponent(id)}/collection-status`));
|
||||
@@ -170,16 +160,20 @@ export default function Page() {
|
||||
[account?.id],
|
||||
);
|
||||
|
||||
async function runAction(action: 'pause' | 'resume') {
|
||||
// 两态开关:start = 恢复账号并启动环境(409 时透传 readiness/reason_code);stop 复用 pause(联动停环境)。
|
||||
async function runAction(action: 'start' | 'pause') {
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await accountAction(account.id, action);
|
||||
messageApi.success(
|
||||
action === 'pause' ? '账号已暂停,运行环境已停止。' : '账号已恢复。',
|
||||
);
|
||||
if (action === 'start') {
|
||||
await request(`/phase-a/accounts/${encodeURIComponent(account.id)}/start`, { method: 'POST' });
|
||||
messageApi.success('账号已恢复,运行环境已启动。');
|
||||
} else {
|
||||
await accountAction(account.id, 'pause');
|
||||
messageApi.success('账号已暂停,运行环境已停止。');
|
||||
}
|
||||
await loadAll();
|
||||
} catch (reason) {
|
||||
messageApi.error(conflictMessage(reason));
|
||||
messageApi.error(conflictMessage(reason, '操作未完成:账号或环境不满足就绪条件,请按不可运行原因处理后重试。'));
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
@@ -192,15 +186,19 @@ export default function Page() {
|
||||
const worksMeta = collectionStatusMeta[worksCollection?.status ?? 'pending'] || { label: worksCollection?.status || '待采集' };
|
||||
const latestMetric = accountMetrics.length ? accountMetrics[accountMetrics.length - 1] : null;
|
||||
|
||||
// 账号状态卡局部操作:暂停/恢复留在状态卡右上角(Card extra),不进页头槽。
|
||||
// 账号状态卡局部操作:启动/暂停两态开关留在状态卡右上角(Card extra),不进页头槽。
|
||||
const paused = account.runtime_status === 'paused';
|
||||
const pauseResume = (
|
||||
<Space wrap>
|
||||
<Button danger disabled={actionBusy || account.runtime_status === 'paused'} onClick={() => setPauseOpen(true)}>
|
||||
暂停账号
|
||||
</Button>
|
||||
<Button type="primary" disabled={actionBusy || !readiness?.canResume} loading={actionBusy} onClick={() => runAction('resume')}>
|
||||
恢复账号
|
||||
</Button>
|
||||
{paused ? (
|
||||
<Button type="primary" disabled={actionBusy || !readiness?.canResume} loading={actionBusy} onClick={() => runAction('start')}>
|
||||
启动账号
|
||||
</Button>
|
||||
) : (
|
||||
<Button danger disabled={actionBusy} onClick={() => setPauseOpen(true)}>
|
||||
暂停账号
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
@@ -231,7 +229,15 @@ export default function Page() {
|
||||
) : null}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Alert type="info" showIcon title="暂停账号会同时停止其运行环境;恢复账号后可在运行环境页重新启动。" />
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
title={
|
||||
readiness?.canResume
|
||||
? '账号当前暂停,资源就绪可启动;启动后自动恢复采集与响应调度。'
|
||||
: '暂停账号会同时停止其运行环境;再次启动时会一并恢复。'
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
<Card title="账号数据">
|
||||
@@ -268,15 +274,23 @@ export default function Page() {
|
||||
<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.schedule_block_reason || '无'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运行实例">{binding.runtime_id || '未启动'}</Descriptions.Item>
|
||||
<Descriptions.Item label="不可调度原因">{binding.schedule_block_reason || '无'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Link onClick={() => history.push('/browsers')}>前往运行环境</Typography.Link>
|
||||
<RebindEnvironment
|
||||
binding={binding}
|
||||
disabled={actionBusy}
|
||||
onRebound={async () => {
|
||||
messageApi.success('出口重绑成功,账号环境已同步。');
|
||||
await loadAll();
|
||||
}}
|
||||
onError={(reason) => messageApi.error(conflictMessage(reason, '出口重绑失败:目标出口需检测通过且账号环境处于可重绑状态。'))}
|
||||
/>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex vertical gap={12}>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
// 运行环境详情:语义对齐 web.archived BrowsersPage.jsx 的 BrowserDetail。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { history, useParams } from '@umijs/max';
|
||||
import { Alert, App, Button, Card, Descriptions, Flex, Space, Tag, Typography } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { getOne } from '@/services/api';
|
||||
|
||||
const stateText: Record<string, string> = {
|
||||
created: '已创建',
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
degraded: '异常',
|
||||
failed: '失败待清理',
|
||||
cleanup_pending: '待清理',
|
||||
gateway_unreachable: '网关不可达',
|
||||
runtime_missing: '运行实例缺失',
|
||||
recorded: '已记录',
|
||||
missing: '未部署',
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
// 官方规范:静态 message 吃不到 ConfigProvider 上下文,用 App.useApp() 取实例。
|
||||
const { message } = App.useApp();
|
||||
const { id = '' } = useParams<{ id: string }>();
|
||||
const [runtime, setRuntime] = useState<any>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
getOne('browsers', id)
|
||||
.then((result) => {
|
||||
if (!cancelled) setRuntime(result);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) setError(loadError);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPending(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (pending) return <Card loading />;
|
||||
if (error || !runtime) return <Alert type="error" showIcon title={error?.message || '运行环境不存在'} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: 0 }}>
|
||||
{`${runtime.name}(${runtime.alias})`}
|
||||
</Typography.Title>
|
||||
<Button onClick={() => history.push('/browsers')}>返回运行环境</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<Flex vertical gap={12}>
|
||||
<Tag color={runtime.state === 'running' ? 'success' : runtime.state === 'stopped' ? 'warning' : 'default'}>
|
||||
{stateText[runtime.state] ?? `未知状态:${runtime.state}`}
|
||||
</Tag>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="账号与指纹">
|
||||
{runtime.account_id} · seed {runtime.fingerprint?.seed}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="固定出口">
|
||||
{`${runtime.network_exit_id || '未绑定'} · 健康:${runtime.network_exit_health || '未知'}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定版本 / 运行实例">
|
||||
{`${runtime.binding_version} · ${runtime.runtime_instance_id || '无'}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="网关">{runtime.gateway_reachable ? '可达' : '不可达'}</Descriptions.Item>
|
||||
{runtime.runtime_node_id ? <Descriptions.Item label="节点">{runtime.runtime_node_id}</Descriptions.Item> : null}
|
||||
{runtime.runtime_cleanup_state === 'pending' ? (
|
||||
<Descriptions.Item label="资源待清理">
|
||||
<Typography.Text type="danger">{runtime.runtime_cleanup_error || '原因未返回'}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{runtime.endpoint ? (
|
||||
<Descriptions.Item label="CDP">
|
||||
<Space size={4}>
|
||||
<Typography.Text code style={{ fontSize: 12 }} ellipsis>
|
||||
{runtime.endpoint}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="复制 CDP 地址"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(runtime.endpoint);
|
||||
message.success('已复制 CDP 地址');
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Space wrap>
|
||||
<Button type="primary" onClick={() => history.push(`/accounts/${encodeURIComponent(runtime.account_id)}`)}>
|
||||
查看账号
|
||||
</Button>
|
||||
{runtime.network_exit_id ? (
|
||||
<Button onClick={() => history.push(`/network-exits/${encodeURIComponent(runtime.network_exit_id)}`)}>查看出口</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// 运行环境列表:语义对齐 web.archived BrowsersPage.jsx 的 BrowserList。
|
||||
// 动作保持显式动词(start/stop/recycle),启停禁用条件与归档版一致。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import { Alert, App, Button, Card, Flex, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import { CopyOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { browserAction, getList } from '@/services/api';
|
||||
import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table';
|
||||
|
||||
const stateText: Record<string, string> = {
|
||||
created: '已创建',
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
degraded: '异常',
|
||||
failed: '失败待清理',
|
||||
cleanup_pending: '待清理',
|
||||
gateway_unreachable: '网关不可达',
|
||||
runtime_missing: '运行实例缺失',
|
||||
recorded: '已记录',
|
||||
missing: '未部署' };
|
||||
const blockText: Record<string, string> = {
|
||||
account_revoked: '授权已撤销',
|
||||
account_paused: '账号已暂停',
|
||||
binding_missing: '绑定缺失',
|
||||
network_exit_missing: '固定出口缺失',
|
||||
network_exit_unhealthy: '固定出口不健康',
|
||||
runtime_stop_pending: '停止结果待确认',
|
||||
runtime_missing: '运行实例缺失' };
|
||||
|
||||
const scheduleBlock = (runtime: any) =>
|
||||
runtime.schedule_status === 'ready' ? '' : blockText[runtime.schedule_block_reason] || runtime.schedule_block_reason || '不可调度';
|
||||
|
||||
export default function Page() {
|
||||
const [runtimes, setRuntimes] = useState<any[]>([]);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [busy, setBusy] = useState('');
|
||||
const { message: messageApi } = App.useApp();
|
||||
const { vertical, horizontal } = useOverflowGrid(runtimes.length);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getList({ resource: 'browsers' });
|
||||
setRuntimes(result.data ?? []);
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function runAction(runtime: any, action: 'start' | 'stop' | 'recycle') {
|
||||
setBusy(runtime.alias);
|
||||
try {
|
||||
await browserAction(runtime.alias, action);
|
||||
await load();
|
||||
} catch (reason: any) {
|
||||
messageApi.error(reason.message || '操作失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRecycle(runtime: any) {
|
||||
await runAction(runtime, 'recycle');
|
||||
}
|
||||
|
||||
const columns: ColumnsType<any> = [
|
||||
{
|
||||
title: '环境',
|
||||
dataIndex: 'name',
|
||||
...fixedLeft<any>({}),
|
||||
render: (_, runtime) => (
|
||||
<div style={{ cursor: 'pointer' }} onClick={() => history.push(`/browsers/${encodeURIComponent(runtime.alias)}`)}>
|
||||
<Typography.Text strong>{runtime.name}</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }} ellipsis>
|
||||
{runtime.alias} · seed {runtime.fingerprint?.seed}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
) },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'state',
|
||||
render: (state: string) => {
|
||||
const known = Object.hasOwn(stateText, state);
|
||||
return (
|
||||
<Tag color={state === 'running' ? 'success' : state === 'stopped' ? 'warning' : 'default'}>{known ? stateText[state] : `未知状态:${state}`}</Tag>
|
||||
);
|
||||
} },
|
||||
{
|
||||
title: '固定绑定',
|
||||
dataIndex: 'account_id',
|
||||
render: (_, runtime) => {
|
||||
const block = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlock(runtime);
|
||||
return (
|
||||
<div>
|
||||
<Typography.Paragraph style={{ marginBottom: 0 }}>{runtime.account_id || '未绑定账号'}</Typography.Paragraph>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 4, fontSize: 12 }} ellipsis>
|
||||
{runtime.network_exit_id || '当前机器直连'}
|
||||
</Typography.Paragraph>
|
||||
<Tag color={block ? 'warning' : 'success'}>{block || '可调度'}</Tag>
|
||||
</div>
|
||||
);
|
||||
} },
|
||||
{
|
||||
title: 'CDP',
|
||||
dataIndex: 'endpoint',
|
||||
render: (endpoint?: string) =>
|
||||
endpoint ? (
|
||||
<Space size={4}>
|
||||
<Typography.Text code style={{ fontSize: 12 }} ellipsis>
|
||||
{endpoint}
|
||||
</Typography.Text>
|
||||
<Button type="text" size="small" icon={<CopyOutlined />} aria-label="复制 CDP 地址" onClick={() => navigator.clipboard?.writeText(endpoint)} />
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">—</Typography.Text>
|
||||
) },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
align: 'right',
|
||||
...fixedRight<any>({}),
|
||||
render: (_, runtime) => {
|
||||
const running = runtime.state === 'running';
|
||||
const stopping = ['running', 'degraded', 'starting', 'failed', 'cleanup_pending'].includes(runtime.state);
|
||||
const gatewayUnavailable = runtime.gateway_reachable === false;
|
||||
const block = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlock(runtime);
|
||||
return (
|
||||
<Space wrap style={{ justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
title={`启动 ${runtime.name}${block ? `(${block})` : ''}`}
|
||||
disabled={busy === runtime.alias || running || gatewayUnavailable || block !== ''}
|
||||
onClick={() => runAction(runtime, 'start')}
|
||||
>
|
||||
启动
|
||||
</Button>
|
||||
<Button type="text" size="small" disabled={busy === runtime.alias || !stopping || gatewayUnavailable} onClick={() => runAction(runtime, 'stop')}>
|
||||
停止
|
||||
</Button>
|
||||
<Popconfirm title={`回收 ${runtime.name}(${runtime.alias})?运行资源将被回收,Profile 目录保留。`} onConfirm={() => handleRecycle(runtime)}>
|
||||
<Button type="text" size="small" danger disabled={busy === runtime.alias || gatewayUnavailable}>
|
||||
回收
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
} },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">共 {runtimes.length} 个运行环境</Typography.Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/browsers/new')}>
|
||||
创建环境
|
||||
</Button>
|
||||
</Flex>
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="alias"
|
||||
columns={columns}
|
||||
dataSource={runtimes}
|
||||
loading={pending}
|
||||
sticky
|
||||
pagination={tablePagination()}
|
||||
scroll={tableScroll(horizontal ? 1000 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)}
|
||||
locale={{ emptyText: '暂无运行环境,创建第一个隔离 Profile。' }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
// 创建运行环境:语义对齐 web.archived BrowsersPage.jsx 的 BrowserCreatePage。
|
||||
// 高级指纹参数用 antd Collapse 收纳;空值不提交,收敛到 seed 驱动。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import { Alert, App, Button, Card, Checkbox, Col, Collapse, Form, Input, InputNumber, Row, Select, Space, Typography } from 'antd';
|
||||
import { create, getList } from '@/services/api';
|
||||
import { conflictMessage } from '@/utils/helpers';
|
||||
|
||||
const aliasPattern = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||
const platformOptions = ['windows', 'linux', 'macos'].map((value) => ({ value, label: value }));
|
||||
const brandOptions = ['Chrome', 'Edge', 'Opera', 'Vivaldi'].map((value) => ({ value, label: value }));
|
||||
const spoofingFields = ['font', 'audio', 'canvas', 'clientrects', 'gpu'];
|
||||
|
||||
// 高级字段空值不提交,收敛到 seed 驱动(对齐归档版 fingerprintPayload)。
|
||||
function fingerprintPayload(form: any) {
|
||||
const payload: Record<string, unknown> = { seed: Number(form.seed) };
|
||||
const advanced = {
|
||||
platform: form.platform,
|
||||
platform_version: form.platform_version,
|
||||
brand: form.brand,
|
||||
brand_version: form.brand_version,
|
||||
lang: form.lang,
|
||||
accept_lang: form.accept_lang,
|
||||
timezone: form.timezone };
|
||||
for (const [key, value] of Object.entries(advanced)) if (value) payload[key] = value;
|
||||
if (form.hardware_concurrency) payload.hardware_concurrency = Number(form.hardware_concurrency);
|
||||
if (form.disable_spoofing?.length > 0) payload.disable_spoofing = form.disable_spoofing.join(',');
|
||||
return payload;
|
||||
}
|
||||
|
||||
// 已用指纹种子之外的最小可用种子(对齐归档版 nextFingerprintSeed)。
|
||||
function nextFingerprintSeed(runtimes: any[]): string {
|
||||
const used = new Set(runtimes.map((runtime) => Number(runtime.fingerprint?.seed)).filter((seed) => Number.isInteger(seed) && seed > 0));
|
||||
let seed = 1000;
|
||||
while (used.has(seed)) seed += 1;
|
||||
return String(seed);
|
||||
}
|
||||
|
||||
// 后端把 fingerprint 校验错误映射成具体中文字段提示(internal/environment/fingerprint.go Validate)。
|
||||
function fingerprintErrorMessage(errorText: string): string {
|
||||
const fieldTexts: Array<[RegExp, string]> = [
|
||||
[/seed must be 1\.\.2147483647/, '指纹种子取值为 1 到 2147483647 的整数'],
|
||||
[/platform must be/, '操作系统仅支持 windows、linux、macos'],
|
||||
[/brand must be/, '浏览器品牌仅支持 Chrome、Edge、Opera、Vivaldi'],
|
||||
[/platform_version/, '系统版本格式不正确,如 11.0.0'],
|
||||
[/brand_version/, '品牌版本格式不正确,如 132.0.6834.159'],
|
||||
[/hardware_concurrency/, 'CPU 核心数取值为 0 到 128'],
|
||||
[/lang must be/, '语言格式不正确,如 zh-CN'],
|
||||
[/accept_lang/, '接受语言格式不正确,如 zh-CN,en-US'],
|
||||
[/timezone/, '时区格式不正确,如 Asia/Shanghai'],
|
||||
[/proxy_server/, '代理地址格式不正确'],
|
||||
[/disable_spoofing/, '禁用指纹伪装只能是 font、audio、canvas、clientrects、gpu 的子集'],
|
||||
];
|
||||
return fieldTexts.find(([pattern]) => pattern.test(errorText))?.[1] || `指纹参数校验未通过:${errorText}`;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [form] = Form.useForm();
|
||||
const [runtimes, setRuntimes] = useState<any[]>([]);
|
||||
const [gateways, setGateways] = useState<any[]>([]);
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [networkExits, setNetworkExits] = useState<any[]>([]);
|
||||
const [submitBusy, setSubmitBusy] = useState(false);
|
||||
const { message: messageApi } = App.useApp();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
getList({ resource: 'browsers' }),
|
||||
getList({ resource: 'gateways' }),
|
||||
getList({ resource: 'accounts' }),
|
||||
getList({ resource: 'network-exits' }),
|
||||
]).then(([browsers, gatewaysResult, accountsResult, exitsResult]) => {
|
||||
if (browsers.status === 'fulfilled') {
|
||||
setRuntimes(browsers.value.data ?? []);
|
||||
form.setFieldsValue({ seed: nextFingerprintSeed(browsers.value.data ?? []) });
|
||||
}
|
||||
if (gatewaysResult.status === 'fulfilled') {
|
||||
setGateways(gatewaysResult.value.data ?? []);
|
||||
const first = (gatewaysResult.value.data ?? [])[0];
|
||||
if (first) form.setFieldsValue({ gateway: first.name });
|
||||
}
|
||||
if (accountsResult.status === 'fulfilled') {
|
||||
const paused = (accountsResult.value.data ?? []).filter(
|
||||
(account: any) => account.authorization_status === 'authorized' && account.runtime_status === 'paused',
|
||||
);
|
||||
setAccounts(paused);
|
||||
if (paused[0]?.id) form.setFieldsValue({ account_id: paused[0].id });
|
||||
}
|
||||
if (exitsResult.status === 'fulfilled') {
|
||||
setNetworkExits((exitsResult.value.data ?? []).filter((item: any) => item.health_status === 'healthy'));
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const pausedAccounts = accounts;
|
||||
const healthyExits = networkExits;
|
||||
|
||||
async function submit(values: any) {
|
||||
setSubmitBusy(true);
|
||||
try {
|
||||
await create('browsers', {
|
||||
alias: values.alias,
|
||||
name: values.name.trim(),
|
||||
gateway: values.gateway,
|
||||
account_id: values.account_id,
|
||||
network_exit_id: values.network_exit_id || '',
|
||||
fingerprint: fingerprintPayload(values) });
|
||||
messageApi.success('运行环境已创建。');
|
||||
history.push('/browsers');
|
||||
} catch (reason: any) {
|
||||
const status = reason.status ?? reason.body?.status;
|
||||
const errorText = reason.body?.error || reason.message || '';
|
||||
messageApi.error(
|
||||
status === 503
|
||||
? `环境不可用(503):${errorText}`
|
||||
: status === 400 && /must be|seed|platform|brand|lang|timezone|concurrency|spoofing/i.test(errorText)
|
||||
? fingerprintErrorMessage(errorText)
|
||||
: conflictMessage(reason, '账号或运行环境已有活动绑定;表单内容已保留。'),
|
||||
);
|
||||
} finally {
|
||||
setSubmitBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => history.push('/browsers')}>返回运行环境</Button>
|
||||
</div>
|
||||
<Card variant="outlined">
|
||||
<Form form={form} layout="vertical" onFinish={submit}>
|
||||
<Row gutter={[16, 0]}>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="name" label="环境名称" required rules={[{ required: true, message: '请输入环境名称' }]} extra="展示名称,支持中文,如:店铺一号">
|
||||
<Input maxLength={64} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item
|
||||
name="alias"
|
||||
label="别名"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入别名' },
|
||||
{ pattern: aliasPattern, message: '小写字母、数字与中划线,最长 32 字符' },
|
||||
]}
|
||||
extra="用于 Profile 标识与 API 路径"
|
||||
>
|
||||
<Input placeholder="如:shop-01" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="gateway" label="网关" required rules={[{ required: true, message: '请选择网关' }]} extra={gateways.length === 0 ? '请先在「网关管理」注册网关' : '环境由该 native gateway 管理'}>
|
||||
<Select style={{ width: 200 }} placeholder="选择网关" disabled={gateways.length === 0} options={gateways.map((item) => ({ value: item.name, label: item.name }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item
|
||||
name="seed"
|
||||
label="指纹种子"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入指纹种子' },
|
||||
{ type: 'integer', min: 1, max: 2147483647, message: '指纹种子取值为 1 到 2147483647 的整数' },
|
||||
]}
|
||||
extra="1..2147483647 的整数,不同种子派生不同浏览器指纹"
|
||||
>
|
||||
<InputNumber min={1} max={2147483647} style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="account_id" label="社媒账号" required rules={[{ required: true, message: '请选择账号' }]} extra={pausedAccounts.length === 0 ? '请先准备已授权且暂停的账号' : '绑定后该账号固定使用此环境'}>
|
||||
<Select style={{ width: 240 }} placeholder="选择账号" disabled={pausedAccounts.length === 0} options={pausedAccounts.map((item) => ({ value: item.id, label: `${item.id} · ${item.platform}` }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="network_exit_id" label="网络出口" extra="留空时使用当前机器网络出口直连">
|
||||
<Select
|
||||
style={{ width: 240 }}
|
||||
allowClear
|
||||
placeholder="当前机器直连"
|
||||
options={healthyExits.map((item) => ({ value: item.id, label: `${item.id} · ${item.protocol}://${item.host}:${item.port}` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'advanced',
|
||||
label: '高级指纹参数',
|
||||
children: (
|
||||
<Row gutter={[16, 0]}>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="platform" label="操作系统 platform">
|
||||
<Select style={{ width: 140 }} allowClear placeholder="默认" options={platformOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="platform_version" label="系统版本 platform-version" extra="如 11.0.0">
|
||||
<Input style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="brand" label="浏览器品牌 brand">
|
||||
<Select style={{ width: 140 }} allowClear placeholder="默认 Chromium" options={brandOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="brand_version" label="品牌版本 brand-version" extra="如 132.0.6834.159">
|
||||
<Input style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="hardware_concurrency" label="CPU 核心数 hardware-concurrency" extra="留空由种子生成">
|
||||
<InputNumber min={1} max={128} style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="lang" label="语言 lang" extra="如 zh-CN">
|
||||
<Input style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="accept_lang" label="接受语言 accept-lang" extra="如 zh-CN,en-US">
|
||||
<Input style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="timezone" label="时区 timezone" extra="如 Asia/Shanghai">
|
||||
<Input style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item name="disable_spoofing" label="禁用指纹伪装 disable-spoofing">
|
||||
<Checkbox.Group options={spoofingFields.map((field) => ({ value: field, label: field }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={submitBusy}>
|
||||
{submitBusy ? '创建中…' : '创建环境'}
|
||||
</Button>
|
||||
<Button onClick={() => history.push('/browsers')}>取消</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,9 +24,6 @@ const editableFields = [
|
||||
'ai_provider',
|
||||
'ai_model',
|
||||
'ai_configured',
|
||||
'transcription_provider',
|
||||
'transcription_model',
|
||||
'transcription_configured',
|
||||
];
|
||||
|
||||
const editableSettings = (value: any) => Object.fromEntries(editableFields.map((field) => [field, value[field]]));
|
||||
@@ -128,22 +125,6 @@ export default function Page() {
|
||||
</Form.Item>
|
||||
</div>
|
||||
) },
|
||||
{
|
||||
key: 'transcription',
|
||||
label: '转写服务',
|
||||
children: (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<Form.Item name="transcription_provider" label="转写 Provider">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="transcription_model" label="转写 Model">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="transcription_configured" valuePropName="checked">
|
||||
<Checkbox>已完成转写配置审批</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
|
||||
@@ -105,7 +105,7 @@ function ExitFormModal({
|
||||
|
||||
export default function Page() {
|
||||
const [exits, setExits] = useState<any[]>([]);
|
||||
const [browsers, setBrowsers] = useState<any[]>([]);
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [bindingsError, setBindingsError] = useState<any>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
@@ -130,8 +130,8 @@ export default function Page() {
|
||||
|
||||
const loadBindings = useCallback(async () => {
|
||||
try {
|
||||
const result = await getList({ resource: 'browsers' });
|
||||
setBrowsers(result.data ?? []);
|
||||
const result = await getList({ resource: 'accounts' });
|
||||
setAccounts(result.data ?? []);
|
||||
setBindingsError(null);
|
||||
} catch (loadError) {
|
||||
setBindingsError(loadError);
|
||||
@@ -145,12 +145,12 @@ export default function Page() {
|
||||
|
||||
const accountsByExit = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const runtime of browsers) {
|
||||
if (!runtime.network_exit_id || !runtime.account_id) continue;
|
||||
map.set(runtime.network_exit_id, [...(map.get(runtime.network_exit_id) || []), runtime.account_id]);
|
||||
for (const account of accounts) {
|
||||
if (!account.network_exit_id) continue;
|
||||
map.set(account.network_exit_id, [...(map.get(account.network_exit_id) || []), account.name || account.id]);
|
||||
}
|
||||
return map;
|
||||
}, [browsers]);
|
||||
}, [accounts]);
|
||||
|
||||
async function createExit(values: ExitFormValues): Promise<boolean> {
|
||||
setBusy('create');
|
||||
|
||||
+1
-14
@@ -3,7 +3,6 @@
|
||||
import { AUTH_STORAGE_KEY, RequestError, jsonOptions, request, unauthorized } from '@/requestErrorConfig';
|
||||
|
||||
export const resourcePaths = {
|
||||
browsers: '/browsers',
|
||||
gateways: '/gateways',
|
||||
accounts: '/phase-a/accounts',
|
||||
'network-exits': '/network-exits',
|
||||
@@ -56,7 +55,7 @@ export async function getList<T = any>({ resource, page, pageSize, filters = {}
|
||||
};
|
||||
}
|
||||
|
||||
const GETONE_ALLOWED: Resource[] = ['accounts', 'network-exits', 'browsers', 'creator-accounts', 'creator-competitors', 'creator-competitor-share-jobs', 'creator-works', 'creator-comments', 'creator-rules'];
|
||||
const GETONE_ALLOWED: Resource[] = ['accounts', 'network-exits', 'creator-accounts', 'creator-competitors', 'creator-competitor-share-jobs', 'creator-works', 'creator-comments', 'creator-rules'];
|
||||
|
||||
export async function getOne<T = any>(resource: Resource, id: string | number): Promise<T> {
|
||||
if (!GETONE_ALLOWED.includes(resource)) throw new Error(`${resource} 不支持 getOne`);
|
||||
@@ -68,7 +67,6 @@ export async function create(resource: Resource, variables: unknown): Promise<an
|
||||
}
|
||||
|
||||
export async function update(resource: Resource, id: string | number, variables: unknown): Promise<void> {
|
||||
if (resource === 'browsers') throw new Error('browsers 不支持 update');
|
||||
await request(`${resourcePaths[resource]}/${encodeURIComponent(id)}`, jsonOptions('PUT', variables));
|
||||
}
|
||||
|
||||
@@ -79,17 +77,6 @@ export async function remove(resource: Resource, id: string | number): Promise<v
|
||||
|
||||
// ===== 显式动词的领域动作(不伪装成 CRUD)=====
|
||||
|
||||
export async function browserAction(alias: string, action: 'start' | 'stop' | 'recycle'): Promise<void> {
|
||||
const targets: Record<string, [string, string]> = {
|
||||
start: [`/browsers/${encodeURIComponent(alias)}/start`, 'POST'],
|
||||
stop: [`/browsers/${encodeURIComponent(alias)}/stop`, 'POST'],
|
||||
recycle: [`/browsers/${encodeURIComponent(alias)}`, 'DELETE'],
|
||||
};
|
||||
const target = targets[action];
|
||||
if (!target) throw new Error(`未知运行环境操作: ${action}`);
|
||||
await request(target[0], { method: target[1] });
|
||||
}
|
||||
|
||||
export async function accountAction(id: string, action: 'pause' | 'resume'): Promise<void> {
|
||||
if (action !== 'pause' && action !== 'resume') throw new Error(`未知账号操作: ${action}`);
|
||||
await request(`/phase-a/accounts/${encodeURIComponent(id)}/${action}`, { method: 'POST' });
|
||||
|
||||
@@ -77,14 +77,14 @@ export function accountReadiness(account: any, binding: any, bindingError: any =
|
||||
if (bindingError) return { label: '环境状态未知', reason: 'environment_unavailable', canResume: false, ready: false };
|
||||
if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false };
|
||||
let blocked = '';
|
||||
if (binding.cleanup_pending) {
|
||||
if (binding.runtime_cleanup_pending) {
|
||||
blocked = 'runtime_stop_pending';
|
||||
} else if (binding.network_exit_id && binding.network_exit_health !== 'healthy') {
|
||||
blocked = 'network_exit_unhealthy';
|
||||
}
|
||||
if (blocked) return { label: readinessReasonText[blocked], reason: blocked, canResume: false, ready: false };
|
||||
if (account.runtime_status === 'paused') {
|
||||
const stopped = !binding.runtime_instance_id;
|
||||
const stopped = !binding.runtime_id;
|
||||
return { label: stopped ? '资源就绪,可恢复' : '等待运行环境停止', reason: stopped ? '' : 'runtime_stop_pending', canResume: stopped, ready: false };
|
||||
}
|
||||
const schedule = binding.schedule_block_reason || '';
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
CommentOutlined,
|
||||
SettingOutlined,
|
||||
ProfileOutlined,
|
||||
LaptopOutlined,
|
||||
ClusterOutlined,
|
||||
GatewayOutlined,
|
||||
DashboardOutlined,
|
||||
@@ -26,9 +25,6 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
|
||||
{ test: (p) => p === '/creator/competitors', title: '竞品分析', subtitle: '查看竞品作品与指标,分享链接导入请前往账号导入。' },
|
||||
{ test: (p) => p === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' },
|
||||
{ test: (p) => p.startsWith('/creator/competitors/'), title: '竞品账号详情', subtitle: '查看竞品画像与作品列表,点击作品可追溯指标统计。' },
|
||||
{ test: (p) => p === '/browsers', title: '运行环境', subtitle: '启动、停止并回收隔离的指纹浏览器运行环境。' },
|
||||
{ test: (p) => p === '/browsers/new', title: '创建运行环境', subtitle: '为已授权且暂停的账号绑定网关、指纹与网络出口。' },
|
||||
{ test: (p) => p.startsWith('/browsers/'), title: '运行环境详情', subtitle: '查看环境状态、账号绑定和运行实例信息。' },
|
||||
{ test: (p) => p === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' },
|
||||
{ test: (p) => p.startsWith('/network-exits/'), title: '网络出口详情', subtitle: '查看出口配置、健康状态和绑定关系。' },
|
||||
{ test: (p) => p === '/gateways', title: '网关管理', subtitle: '注册并检查宿主机 native browser gateway。' },
|
||||
@@ -51,7 +47,6 @@ export const menu: MenuDataItem[] = [
|
||||
{
|
||||
name: '资源',
|
||||
children: [
|
||||
{ path: '/browsers', name: '运行环境', icon: <LaptopOutlined /> },
|
||||
{ path: '/network-exits', name: '网络出口', icon: <ClusterOutlined /> },
|
||||
{ path: '/gateways', name: '网关管理', icon: <GatewayOutlined /> },
|
||||
],
|
||||
|
||||
@@ -8,7 +8,6 @@ const files = [
|
||||
'src/components/accounts/MonitoringAccountList.tsx',
|
||||
'src/components/accounts/AccountManagementList.tsx',
|
||||
'src/pages/accounts/import.tsx',
|
||||
'src/pages/browsers/index.tsx',
|
||||
'src/pages/network-exits/index.tsx',
|
||||
'src/pages/gateways/index.tsx',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user