Files
3x-ui-agent/frontend/src/pages/settings/GeneralTab.tsx
T
Muse a654a96c38
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
精简版第四轮补裁(P4): 页面层摘除 + Xray更新端点下线 + 本机核心监管下线
页面层(用户真机截图发现前两轮漏裁):
- 设置: 摘除 Telegram机器人/邮件菜单与页面, 及页内残留入口(通知页/外部流量/LDAP/2FA设置页)
- Xray配置: 摘除负载均衡/DNS/高级配置菜单与页面; 基础配置(出站+路由)完好
- 总览: 摘除 Xray更新弹窗(VersionModal+GeodataSection)全部入口, 版本号改为静态展示
- 13个locale同步摘除461个已删功能专属翻译键(逐键核查前后端引用为零; i18n-dead-keys测试全绿)

Xray更新后端(经调查确认仅服务本机bin/xray, 与agent下发/订阅无关):
- 删除 getXrayVersion/installXray/updateGeofile 路由与handler(访问返回404)
- 删除 ServerService 对应实现与校验和函数; 同步api-docs/monitor白名单/openapi

本机核心监管下线(设计: 主控永不运行本机xray, 入站全在agent):
- XrayService.RestartXray 降级为no-op(一次性Warning说明日志); StopXray无进程时幂等返回
- web.go 摘除启动拉起本机核心/CheckXrayRunning看门狗(1s)/ApplyPendingRestart cron(30s)/XrayTrafficJob/CheckClientIpJob 注册
- ApplyPendingRestart 降级为消费即清, 其测试更新为新语义
- XrayMetricsService.Sample 短路: 不再每2s探本机/debug/vars, 状态优雅置为不可用
- 配置生成(纯JSON)/agent链路/订阅/入站CRUD未动; 状态读数优雅归零(stop/Unknown)

注: TestGetXrayConfig_EnabledClientsStillEmitted 在基线945b3a5同样失败(既有问题, 非本轮引入)
2026-10-20 23:39:38 +08:00

343 lines
13 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
import { ClockCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath';
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
interface GeneralTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [outboundTagList, setOutboundTagList] = useState<string[]>([]);
const [balancerTagList, setBalancerTagList] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
// Candidates for the panel egress picker: template outbounds plus
// subscription-derived outbounds, and routing balancers. The panel egress
// is injected as a routing rule, so a balancer tag is a valid target
// (it load-balances the panel's own traffic). The geodata picker, by
// contrast, dials a forced tag and can only use a concrete outbound.
const msg = (await HttpUtil.post('/panel/api/xray/', undefined, {
silent: true,
})) as ApiMsg<string>;
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
try {
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const template = (payload.xraySetting || {}) as Record<string, unknown>;
const tags = new Set<string>();
const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
for (const o of outbounds) {
if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>;
if (rec.protocol === 'blackhole') continue; // dropping traffic is never a useful egress
const tag = rec.tag;
if (typeof tag === 'string' && tag) tags.add(tag);
}
const subTags = Array.isArray(payload.subscriptionOutboundTags)
? payload.subscriptionOutboundTags
: [];
for (const tag of subTags) {
if (typeof tag === 'string' && tag) tags.add(tag);
}
const balancerTags: string[] = [];
const routing = (template.routing || {}) as Record<string, unknown>;
const balancers = Array.isArray(routing.balancers) ? routing.balancers : [];
for (const b of balancers) {
if (!b || typeof b !== 'object') continue;
const tag = (b as Record<string, unknown>).tag;
if (typeof tag === 'string' && tag && !tags.has(tag)) balancerTags.push(tag);
}
setOutboundTagList([...tags]);
setBalancerTagList(balancerTags);
} catch {
setOutboundTagList([]);
setBalancerTagList([]);
}
})();
return () => {
cancelled = true;
};
}, []);
// Outbound tags and balancer tags share one picker. When balancers exist they
// get their own labeled group so it's clear the selection routes through a
// balancer rather than a single outbound.
const outboundOptions = useMemo<
(
| { label: string; value: string }
| { label: string; options: { label: string; value: string }[] }
)[]
>(() => {
const outOpts = outboundTagList.map((tag) => ({ label: tag, value: tag }));
if (balancerTagList.length === 0) return outOpts;
return [
{ label: t('pages.xray.Outbounds'), options: outOpts },
{
label: t('pages.xray.Balancers'),
options: balancerTagList.map((tag) => ({ label: tag, value: tag })),
},
];
}, [outboundTagList, balancerTagList, t]);
function onLangChange(value: string) {
setLang(value);
LanguageManager.setLanguage(value);
}
const langOptions = useMemo(
() =>
LanguageManager.supportedLanguages.map(
(l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span role="img" aria-label={l.name}>
{l.icon}
</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
),
}),
),
[],
);
return (
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.panelListeningIP')}
description={t('pages.settings.panelListeningIPDesc')}
>
<Input
value={allSetting.webListen}
onChange={(e) => updateSetting({ webListen: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.panelListeningDomain')}
description={t('pages.settings.panelListeningDomainDesc')}
>
<Input
value={allSetting.webDomain}
onChange={(e) => updateSetting({ webDomain: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.panelPort')}
badge={<DefaultSettingTag settingKey="webPort" value={allSetting.webPort} />}
description={t('pages.settings.panelPortDesc')}
>
<InputNumber
value={allSetting.webPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ webPort: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.panelUrlPath')}
description={t('pages.settings.panelUrlPathDesc')}
>
<Input
value={allSetting.webBasePath}
onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.sessionMaxAge')}
badge={
<DefaultSettingTag settingKey="sessionMaxAge" value={allSetting.sessionMaxAge} />
}
description={t('pages.settings.sessionMaxAgeDesc')}
>
<InputNumber
value={allSetting.sessionMaxAge}
min={60}
max={525600}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trustedProxyCidrs')}
description={t('pages.settings.trustedProxyCidrsDesc')}
>
<Input
value={allSetting.trustedProxyCIDRs}
placeholder="127.0.0.1/32,::1/128"
onChange={(e) => updateSetting({ trustedProxyCIDRs: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ipLimitAllowlist')}
description={t('pages.settings.ipLimitAllowlistDesc')}
>
<Input
value={allSetting.ipLimitAllowlist}
placeholder="203.0.113.10,198.51.100.0/24"
onChange={(e) => updateSetting({ ipLimitAllowlist: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.panelOutbound')}
description={t('pages.settings.panelOutboundDesc')}
>
<Select
style={{ width: '100%' }}
allowClear
showSearch
value={allSetting.panelOutbound || undefined}
placeholder={t('pages.settings.panelOutboundPh')}
options={outboundOptions}
onChange={(v) =>
updateSetting({ panelOutbound: (v as string | undefined) || '' })
}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.pageSize')}
badge={<DefaultSettingTag settingKey="pageSize" value={allSetting.pageSize} />}
description={t('pages.settings.pageSizeDesc')}
>
<InputNumber
value={allSetting.pageSize}
min={0}
max={1000}
step={5}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ pageSize: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.restartXrayOnClientDisable')}
description={t('pages.settings.restartXrayOnClientDisableDesc')}
>
<Switch
checked={allSetting.restartXrayOnClientDisable}
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.language')}>
<Select
value={lang}
onChange={onLangChange}
style={{ width: '100%' }}
options={langOptions}
/>
</SettingListItem>
</>
),
},
{
key: '3',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.publicKeyPath')}
description={t('pages.settings.publicKeyPathDesc')}
>
<Input
value={allSetting.webCertFile}
onChange={(e) => updateSetting({ webCertFile: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.privateKeyPath')}
description={t('pages.settings.privateKeyPathDesc')}
>
<Input
value={allSetting.webKeyFile}
onChange={(e) => updateSetting({ webKeyFile: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '5',
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.timeZone')}
description={t('pages.settings.timeZoneDesc')}
>
<Input
value={allSetting.timeLocation}
onChange={(e) => updateSetting({ timeLocation: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.datepicker')}
description={t('pages.settings.datepickerDescription')}
>
<Select
value={allSetting.datepicker || 'gregorian'}
onChange={(v) => updateSetting({ datepicker: v as 'gregorian' | 'jalalian' })}
style={{ width: '100%' }}
options={[
{ value: 'gregorian', label: t('pages.settings.calendarGregorian') },
{ value: 'jalalian', label: t('pages.settings.calendarJalalian') },
]}
/>
</SettingListItem>
</>
),
},
]}
/>
);
}