feat(ui): 第22条 改名 L-UI + 第14/21条 + 第18/19/20条前端
- 第22条 L-UI:登录页品牌名、侧栏 brand-text(收起态 L)、抽屉品牌、 usePageTitle 默认标题(HTML 标题)、13 个语言文件里用户可见串中的 3X-UI 字样(更新说明×2、TG 版本行)一并改为 L-UI;仓库/二进制/ 服务名/路径/内部包名不动 - 第18条:formatPanelVersion 对 V/A 系显示版本原样返回(新增单测); PanelUpdateModal 与首页动作条改用 display* 字段判更新与显示 - 第14条:首页运行时间卡片删除 Xray 半边与分隔线,只留 OS - 第21条:节点表列宽 操作190→150/已启用80→64/客户端180→150/ 延迟100→84/心跳120→96、地址列 330;agent 行 IPv4·IPv6 单行 (浅点分隔、沿用可见性 class),传统行不变 - 第20条:agent 行云朵按钮与移动端菜单改用「更新 Agent」文案; NodesPage 对 agent 行用无渠道选择的简单确认(传统行不变); schemas 增 agentUpdateState/Error/Target - 第19条:节点编辑页(agent)拉 /panel/api/agent/install/:id 展示 完整安装命令(master 可编辑、含 token 时完整命令+复制按钮); 无 token 时展示无 token 命令+agent.json 路径提示,不报错不空白 - i18n 新增 4 键(updateAgent/updateAgentConfirmContent/ agentInstallCommand/agentTokenNotStored)补齐全部 13 个语言文件 (zh-CN 中文、其余英文),tsc 干净、panel-version 测试全过、 i18n 失败仍为既有 forwarding 键集问题(第10条)零新增
This commit is contained in:
@@ -172,8 +172,17 @@ export default function IndexPage() {
|
||||
<OverviewActionBar
|
||||
isMobile={isMobile}
|
||||
panelVersion={displayVersion}
|
||||
latestVersion={panelUpdateInfo.latestVersion}
|
||||
updateAvailable={panelUpdateInfo.updateAvailable}
|
||||
latestVersion={
|
||||
panelUpdateInfo.channel === 'dev'
|
||||
? panelUpdateInfo.latestVersion
|
||||
: panelUpdateInfo.displayLatestVersion || panelUpdateInfo.latestVersion
|
||||
}
|
||||
updateAvailable={
|
||||
panelUpdateInfo.channel === 'dev'
|
||||
? panelUpdateInfo.updateAvailable
|
||||
: (panelUpdateInfo.displayUpdateAvailable ??
|
||||
panelUpdateInfo.updateAvailable)
|
||||
}
|
||||
onOpenConfig={openConfig}
|
||||
onOpenBackup={() => setBackupOpen(true)}
|
||||
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
||||
|
||||
@@ -17,6 +17,11 @@ export interface PanelUpdateInfo {
|
||||
currentCommit?: string;
|
||||
latestCommit?: string;
|
||||
updateAvailable: boolean;
|
||||
// V-series display versions (items 18/20); the stable channel's update
|
||||
// decision and labels use these when present.
|
||||
displayCurrentVersion?: string;
|
||||
displayLatestVersion?: string;
|
||||
displayUpdateAvailable?: boolean;
|
||||
}
|
||||
|
||||
interface BusyEvent {
|
||||
@@ -47,6 +52,13 @@ export default function PanelUpdateModal({
|
||||
|
||||
const isDev = info.channel === 'dev';
|
||||
|
||||
// On the stable channel the V-series display comparison decides (item
|
||||
// 20); the dev channel keeps its commit-based decision.
|
||||
const masterUpdateAvailable = isDev
|
||||
? info.updateAvailable
|
||||
: (info.displayUpdateAvailable ?? info.updateAvailable);
|
||||
const displayLatest = info.displayLatestVersion || info.latestVersion;
|
||||
|
||||
async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
|
||||
await PromiseUtil.sleep(5000);
|
||||
const deadline = Date.now() + 90_000;
|
||||
@@ -85,13 +97,14 @@ export default function PanelUpdateModal({
|
||||
title: t('pages.index.panelUpdateDialog'),
|
||||
content: t('pages.index.panelUpdateDialogDesc').replace(
|
||||
'#version#',
|
||||
info.latestVersion || '',
|
||||
isDev ? info.latestVersion || '' : displayLatest || '',
|
||||
),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const baseTip = t('pages.index.dontRefresh');
|
||||
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
|
||||
const tipVersion = isDev ? info.latestVersion : displayLatest;
|
||||
const tip = tipVersion ? `${baseTip} (${tipVersion})` : baseTip;
|
||||
onClose();
|
||||
onBusy({ busy: true, tip });
|
||||
const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
|
||||
@@ -128,7 +141,7 @@ export default function PanelUpdateModal({
|
||||
<>
|
||||
{contextHolder}
|
||||
<Modal open={open} title={t('pages.index.updatePanel')} footer={null} onCancel={onClose}>
|
||||
{info.updateAvailable && (
|
||||
{masterUpdateAvailable && (
|
||||
<Alert
|
||||
type="warning"
|
||||
className="mb-12"
|
||||
@@ -166,12 +179,14 @@ export default function PanelUpdateModal({
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{info.updateAvailable ? (
|
||||
{masterUpdateAvailable ? (
|
||||
<div className="version-list-item">
|
||||
<span>
|
||||
{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}
|
||||
</span>
|
||||
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
|
||||
<Tag color="purple">
|
||||
{(isDev ? info.latestCommit : formatPanelVersion(displayLatest)) || '-'}
|
||||
</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<div className="version-list-item">
|
||||
@@ -184,7 +199,7 @@ export default function PanelUpdateModal({
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!info.updateAvailable}
|
||||
disabled={!masterUpdateAvailable}
|
||||
onClick={updatePanel}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
>
|
||||
|
||||
@@ -29,18 +29,11 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
|
||||
<ClockCircleOutlined />
|
||||
{t('pages.index.uptime')}
|
||||
</div>
|
||||
<div className="ov-strip-split">
|
||||
<div>
|
||||
<div className="ov-strip-sub">Xray</div>
|
||||
<div className="ov-strip-value">
|
||||
{TimeFormatter.formatSecond(status.appStats.uptime)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="ov-strip-split-sep" />
|
||||
<div>
|
||||
<div className="ov-strip-sub">OS</div>
|
||||
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
|
||||
</div>
|
||||
{/* The lite master runs no local xray core, so the uptime card
|
||||
shows the OS uptime only (the Xray half always read 0s). */}
|
||||
<div>
|
||||
<div className="ov-strip-sub">OS</div>
|
||||
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function LoginPage() {
|
||||
) : (
|
||||
<div className="login-card">
|
||||
<div className="brand">
|
||||
<span className="brand-name">3X-UI</span>
|
||||
<span className="brand-name">L-UI</span>
|
||||
<span className="brand-accent" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="welcome">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
||||
import type { Msg } from '@/utils';
|
||||
import { HttpUtil, type Msg } from '@/utils';
|
||||
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||
@@ -81,6 +81,17 @@ export default function NodeFormModal({
|
||||
const [agentToken, setAgentToken] = useState<string | null>(null);
|
||||
const [agentNodeName, setAgentNodeName] = useState('');
|
||||
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
||||
// Edit mode (item 19): the stored install material for an existing agent
|
||||
// node, fetched from the master; null until loaded / for non-agent nodes.
|
||||
// Keyed by node id so a stale result from a previously edited node is
|
||||
// never shown (visibility is derived during render below).
|
||||
const [editInstall, setEditInstall] = useState<{
|
||||
nodeId: number;
|
||||
name: string;
|
||||
token: string;
|
||||
hasToken: boolean;
|
||||
} | null>(null);
|
||||
const [editMasterUrl, setEditMasterUrl] = useState('');
|
||||
const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
|
||||
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
|
||||
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
||||
@@ -136,6 +147,39 @@ export default function NodeFormModal({
|
||||
setTestResult(null);
|
||||
}
|
||||
|
||||
// Load the stored install material when editing an agent node so the
|
||||
// full install command can be re-displayed (item 19). Nodes created
|
||||
// before tokens were stored answer hasToken=false until the agent's
|
||||
// next authenticated request backfills the token.
|
||||
const editNodeId = mode === 'edit' && isAgent ? (node?.id ?? 0) : 0;
|
||||
useEffect(() => {
|
||||
if (!open || !editNodeId) return;
|
||||
let cancelled = false;
|
||||
HttpUtil.get<{ name: string; token: string; hasToken: boolean }>(
|
||||
`/panel/api/agent/install/${editNodeId}`,
|
||||
).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) {
|
||||
setEditInstall({ nodeId: editNodeId, ...msg.obj });
|
||||
setEditMasterUrl(currentPanelMasterUrl());
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, editNodeId]);
|
||||
|
||||
const shownInstall = editInstall && editInstall.nodeId === editNodeId ? editInstall : null;
|
||||
|
||||
const editInstallCmd = useMemo(() => {
|
||||
if (!shownInstall || !editMasterUrl) return '';
|
||||
const base =
|
||||
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
|
||||
` --master ${editMasterUrl}`;
|
||||
return shownInstall.hasToken
|
||||
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
|
||||
: `${base} --name ${shownInstall.name}`;
|
||||
}, [shownInstall, editMasterUrl]);
|
||||
|
||||
const title = useMemo(
|
||||
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
|
||||
[mode, t],
|
||||
@@ -391,6 +435,43 @@ export default function NodeFormModal({
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && isAgent && shownInstall && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>{t('pages.nodes.agentInstallCommand')}</div>
|
||||
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
|
||||
<Input
|
||||
value={editMasterUrl}
|
||||
onChange={(e) => setEditMasterUrl(e.target.value.trim())}
|
||||
style={{ marginBottom: 12, fontFamily: 'monospace' }}
|
||||
/>
|
||||
{!shownInstall.hasToken && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
title={t('pages.nodes.agentTokenNotStored')}
|
||||
/>
|
||||
)}
|
||||
<Input.TextArea
|
||||
value={editInstallCmd}
|
||||
readOnly
|
||||
rows={4}
|
||||
style={{ fontFamily: 'monospace', marginBottom: 12 }}
|
||||
/>
|
||||
<Button
|
||||
disabled={!editInstallCmd}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(editInstallCmd).then(
|
||||
() => messageApi.success(t('pages.nodes.agentTokenCopied')),
|
||||
() => messageApi.error(t('pages.nodes.agentTokenCopyFailed')),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('pages.nodes.copyToken')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.allowPrivateAddress')}
|
||||
|
||||
@@ -239,7 +239,7 @@ export default function NodeList({
|
||||
{
|
||||
title: t('pages.nodes.actions'),
|
||||
align: 'center',
|
||||
width: 190,
|
||||
width: 150,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<Tooltip
|
||||
@@ -264,13 +264,23 @@ export default function NodeList({
|
||||
/>
|
||||
</Tooltip>
|
||||
{isUpdateEligible(record) && (
|
||||
<Tooltip title={t('pages.nodes.updatePanel')}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
aria-label={t('pages.nodes.updatePanel')}
|
||||
aria-label={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -303,7 +313,7 @@ export default function NodeList({
|
||||
title: t('pages.nodes.enable'),
|
||||
dataIndex: 'enable',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
width: 64,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<span style={{ opacity: 0.4 }}>—</span>
|
||||
@@ -362,17 +372,20 @@ export default function NodeList({
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'url',
|
||||
width: 330,
|
||||
ellipsis: true,
|
||||
render: (_value, record) => {
|
||||
if (record.nodeType === 'agent') {
|
||||
// Agent nodes have no panel to link to: show the host IPs the
|
||||
// agent reports with its heartbeat as plain text (P13).
|
||||
// agent reports with its heartbeat as plain text on one line
|
||||
// (P13, layout per item 21).
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return (
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{ips.length > 0
|
||||
? ips.map((ip) => (
|
||||
<span key={ip} style={{ display: 'block' }}>
|
||||
? ips.map((ip, i) => (
|
||||
<span key={ip}>
|
||||
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
||||
{ip}
|
||||
</span>
|
||||
))
|
||||
@@ -436,10 +449,11 @@ export default function NodeList({
|
||||
dataIndex: 'panelVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) => {
|
||||
// Agent rows carry an "agent <version>" label, not an upstream
|
||||
// panel version: the update badge must never light up for them
|
||||
// (the version comparator falls back to string inequality for
|
||||
// unparseable versions, which would always differ) (P13).
|
||||
// Agent rows carry the A-series display version ("A1.0.0"), not
|
||||
// an upstream panel version: the update badge must never light
|
||||
// up for them (the version comparator falls back to string
|
||||
// inequality for unparseable versions, which would always
|
||||
// differ) (P13, item 18).
|
||||
const canUpdate =
|
||||
record.nodeType !== 'agent' &&
|
||||
isUpdateEligible(record) &&
|
||||
@@ -474,7 +488,7 @@ export default function NodeList({
|
||||
{
|
||||
title: t('clients'),
|
||||
align: 'center',
|
||||
width: 180,
|
||||
width: 150,
|
||||
render: (_value, record) => (
|
||||
<Space size={2}>
|
||||
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
@@ -527,7 +541,7 @@ export default function NodeList({
|
||||
title: t('pages.nodes.latency'),
|
||||
dataIndex: 'latencyMs',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
width: 84,
|
||||
render: (_value, record) =>
|
||||
record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
|
||||
},
|
||||
@@ -535,7 +549,7 @@ export default function NodeList({
|
||||
title: t('pages.nodes.lastHeartbeat'),
|
||||
dataIndex: 'lastHeartbeat',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
width: 96,
|
||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||
},
|
||||
],
|
||||
@@ -652,7 +666,12 @@ export default function NodeList({
|
||||
key: 'update',
|
||||
label: (
|
||||
<>
|
||||
<CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}
|
||||
<CloudDownloadOutlined />{' '}
|
||||
{t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
</>
|
||||
),
|
||||
onClick: () => onUpdateNode(record),
|
||||
|
||||
@@ -232,6 +232,18 @@ export default function NodesPage() {
|
||||
|
||||
const onUpdateNode = useCallback(
|
||||
(node: NodeRecord) => {
|
||||
// Agent nodes update through the master-recorded intent (item 20):
|
||||
// there is no channel to pick, so the confirm is a plain message.
|
||||
if (node.nodeType === 'agent') {
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate([node.id], false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
|
||||
Reference in New Issue
Block a user