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:
2026-09-28 20:30:23 +08:00
parent bcc33fe497
commit 7dea052e55
13 changed files with 175 additions and 643 deletions
@@ -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>
);
}