diff --git a/web/src/components/dashboard/RouteManagerHubPanel.jsx b/web/src/components/dashboard/RouteManagerHubPanel.jsx
new file mode 100644
index 000000000000..9fb0999517ac
--- /dev/null
+++ b/web/src/components/dashboard/RouteManagerHubPanel.jsx
@@ -0,0 +1,402 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button, Card, Empty, Spin, Tag, Typography } from '@douyinfe/semi-ui';
+import { Activity, RefreshCw, Waypoints } from 'lucide-react';
+import {
+ buildRouteManagerHubAlertDetail,
+ buildRouteManagerHubQuickPanelLinks,
+ buildRouteManagerHubDashboardSnapshot,
+ buildRouteManagerHubFamilySummaryPills,
+ formatRouteManagerHubAlertSeverity,
+ getRouteManagerHubTaskStatusColor,
+ getRouteManagerHubAlertHref,
+ getRouteManagerHubAlertsHref,
+ formatRouteManagerHubStatus,
+ getRouteManagerHubHref,
+ getRouteManagerHubNodeHref,
+ getRouteManagerHubNodesHref,
+ formatRouteManagerHubNodeStatus,
+ getRouteManagerHubScheduleHref,
+ getRouteManagerHubTaskHref,
+ getRouteManagerHubTasksHref,
+ formatRouteManagerHubTaskStatus,
+ formatRouteManagerHubTaskType,
+ shouldShowRouteManagerHubEntry,
+} from '../../helpers';
+
+const { Text } = Typography;
+
+const TONE_TO_TAG_COLOR = {
+ success: 'green',
+ warning: 'orange',
+ danger: 'red',
+};
+
+const NODE_STATUS_COLOR = {
+ online: 'green',
+ stale: 'orange',
+ sleep: 'grey',
+ offline: 'red',
+};
+
+const ALERT_SEVERITY_COLOR = {
+ critical: 'red',
+ warning: 'orange',
+};
+
+const RouteManagerHubPanel = ({
+ hubStatus,
+ hubNodes,
+ hubSchedules,
+ hubTasks,
+ hubAlerts,
+ hubSummary,
+ hubLoading,
+ hubError,
+ loadHubData,
+ CARD_PROPS,
+ t,
+}) => {
+ const statusInfo = formatRouteManagerHubStatus(hubStatus, t);
+ const snapshot = buildRouteManagerHubDashboardSnapshot({
+ onlineNodes: hubSummary?.onlineNodes,
+ busyNodes: hubSummary?.busyNodes,
+ pendingTasks: hubSummary?.pendingTasks,
+ activeSchedules: hubSummary?.activeSchedules,
+ criticalAlerts: hubSummary?.criticalAlerts,
+ unacknowledgedAlerts: hubSummary?.unacknowledgedAlerts,
+ ai: hubSummary?.ai,
+ network: hubSummary?.network,
+ homeAssistant: hubSummary?.homeAssistant,
+ primaryNode: hubSummary?.primaryNode,
+ nodes: hubNodes,
+ schedules: hubSchedules,
+ tasks: hubTasks,
+ alerts: hubAlerts,
+ });
+ const canOpenHub = shouldShowRouteManagerHubEntry(hubStatus);
+ const routeManagerHubHref = getRouteManagerHubHref();
+ const routeManagerHubNodesHref = getRouteManagerHubNodesHref();
+ const routeManagerHubTasksHref = getRouteManagerHubTasksHref();
+ const routeManagerHubAlertsHref = getRouteManagerHubAlertsHref();
+ const quickPanelLinks = buildRouteManagerHubQuickPanelLinks(snapshot, t);
+
+ const metrics = [
+ { label: t('在线节点'), value: snapshot.onlineNodes },
+ { label: t('活动节点'), value: snapshot.busyNodes },
+ { label: t('待处理任务'), value: snapshot.pendingTasks },
+ ];
+ const familySummaryPills = buildRouteManagerHubFamilySummaryPills(snapshot, t);
+
+ return (
+
+
+
+ {t('家域中枢值守')}
+
+
+ }
+ onClick={loadHubData}
+ loading={hubLoading}
+ size='small'
+ theme='borderless'
+ type='tertiary'
+ className='text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full'
+ />
+ {canOpenHub ? (
+
+ ) : null}
+
+
+ }
+ >
+
+
+
+
+
+ {statusInfo.tone === 'success'
+ ? t('已连接')
+ : statusInfo.tone === 'warning'
+ ? t('待配置')
+ : t('异常')}
+
+ {hubError || statusInfo.message}
+
+
+
+
{t('主站内可直接查看家庭控制系统最近运行情况')}
+
+
+
+ {familySummaryPills.length > 0 ? (
+
+ {familySummaryPills.map((item) => (
+
+ {item}
+
+ ))}
+
+ ) : null}
+
+
+ {metrics.map((metric) => (
+
+
{metric.label}
+
+ {metric.value}
+
+
+ ))}
+
+
+
+
+ {t('关键面板')}
+ {t('主站内快速切换家庭控制能力区')}
+
+
+
+
+
+
+
+
+ {hubNodes.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
{t('关键告警')}
+
+
+ {t('严重')} {snapshot.criticalAlerts} · {t('未确认')}{' '}
+ {snapshot.unacknowledgedAlerts}
+
+
+ {t('查看告警中心')}
+
+
+
+
+ {snapshot.recentAlerts.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {snapshot.recentSchedules.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {t('最近任务')}
+ {t('展示最近 3 条调度/执行结果')}
+
+
+ {snapshot.recentTasks.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+};
+
+export default RouteManagerHubPanel;
diff --git a/web/src/components/dashboard/index.jsx b/web/src/components/dashboard/index.jsx
index b032d07d87f6..f416fcfe53fb 100644
--- a/web/src/components/dashboard/index.jsx
+++ b/web/src/components/dashboard/index.jsx
@@ -28,6 +28,7 @@ import ChartsPanel from './ChartsPanel';
import ApiInfoPanel from './ApiInfoPanel';
import AnnouncementsPanel from './AnnouncementsPanel';
import FaqPanel from './FaqPanel';
+import RouteManagerHubPanel from './RouteManagerHubPanel';
import UptimePanel from './UptimePanel';
import SearchModal from './modals/SearchModal';
@@ -51,6 +52,7 @@ import {
getUptimeStatusText,
renderMonitorList,
} from '../../helpers/dashboard';
+import { buildRouteManagerHubAvailabilitySignature } from '../../helpers/hubAvailability';
const Dashboard = () => {
// ========== Context ==========
@@ -132,12 +134,19 @@ const Dashboard = () => {
label: dashboardData.t(info.label),
}),
);
+ const hubAvailabilitySignature = buildRouteManagerHubAvailabilitySignature(
+ statusState?.status?.hub_status,
+ );
// ========== Effects ==========
useEffect(() => {
initChart();
}, []);
+ useEffect(() => {
+ void dashboardData.loadHubData();
+ }, [hubAvailabilitySignature]);
+
return (
{
CHART_CONFIG={CHART_CONFIG}
/>
+
+
+
+
{/* API信息和图表面板 */}
.
For commercial licensing, please contact support@quantumnous.com
*/
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useContext, useEffect, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { getLucideIcon } from '../../helpers/render';
@@ -25,7 +25,14 @@ import { ChevronLeft } from 'lucide-react';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
import { useSidebar } from '../../hooks/common/useSidebar';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
-import { isAdmin, isRoot, showError } from '../../helpers';
+import {
+ getRouteManagerHubSidebarItems,
+ isAdmin,
+ isRoot,
+ showError,
+ shouldShowRouteManagerHubEntry,
+} from '../../helpers';
+import { StatusContext } from '../../context/Status';
import SkeletonWrapper from './components/SkeletonWrapper';
import { Nav, Divider, Button } from '@douyinfe/semi-ui';
@@ -53,6 +60,7 @@ const routerMap = {
const SiderBar = ({ onNavigate = () => {} }) => {
const { t } = useTranslation();
+ const [statusState] = useContext(StatusContext);
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const {
isModuleVisible,
@@ -67,6 +75,23 @@ const SiderBar = ({ onNavigate = () => {} }) => {
const [openedKeys, setOpenedKeys] = useState([]);
const location = useLocation();
const [routerMapState, setRouterMapState] = useState(routerMap);
+ const routeManagerHubVisible = shouldShowRouteManagerHubEntry(
+ statusState?.status?.hub_status,
+ );
+ const routeManagerHubSidebarItems = useMemo(
+ () => getRouteManagerHubSidebarItems(t),
+ [t],
+ );
+ const fullReloadRouteMap = useMemo(
+ () =>
+ routeManagerHubVisible
+ ? routeManagerHubSidebarItems.reduce((result, item) => {
+ result[item.itemKey] = item.to;
+ return result;
+ }, {})
+ : {},
+ [routeManagerHubSidebarItems, routeManagerHubVisible],
+ );
const workspaceItems = useMemo(() => {
const items = [
@@ -105,6 +130,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
className:
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
},
+ {
+ text: t('家域中枢'),
+ itemKey: 'hub',
+ items: routeManagerHubSidebarItems,
+ className: routeManagerHubVisible ? '' : 'tableHiddle',
+ },
];
// 根据配置过滤项目
@@ -118,6 +149,8 @@ const SiderBar = ({ onNavigate = () => {} }) => {
localStorage.getItem('enable_data_export'),
localStorage.getItem('enable_drawing'),
localStorage.getItem('enable_task'),
+ routeManagerHubVisible,
+ routeManagerHubSidebarItems,
t,
isModuleVisible,
]);
@@ -338,6 +371,8 @@ const SiderBar = ({ onNavigate = () => {} }) => {
// 渲染子菜单项
const renderSubItem = (item) => {
+ if (item.className === 'tableHiddle') return null;
+
if (item.items && item.items.length > 0) {
const isSelected = selectedKeys.includes(item.itemKey);
const textColor = isSelected ? SELECTED_COLOR : 'inherit';
@@ -361,6 +396,8 @@ const SiderBar = ({ onNavigate = () => {} }) => {
}
>
{item.items.map((subItem) => {
+ if (subItem.className === 'tableHiddle') return null;
+
const isSubSelected = selectedKeys.includes(subItem.itemKey);
const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit';
@@ -368,6 +405,7 @@ const SiderBar = ({ onNavigate = () => {} }) => {
{} }) => {
hoverStyle='sidebar-nav-item:hover'
selectedStyle='sidebar-nav-item-selected'
renderWrapper={({ itemElement, props }) => {
+ const fullReloadHref = fullReloadRouteMap[props.itemKey];
+ if (fullReloadHref) {
+ return (
+
+ {itemElement}
+
+ );
+ }
+
const to =
routerMapState[props.itemKey] || routerMap[props.itemKey];
@@ -457,7 +508,7 @@ const SiderBar = ({ onNavigate = () => {} }) => {
{!collapsed && (
{t('控制台')}
)}
- {workspaceItems.map((item) => renderNavItem(item))}
+ {workspaceItems.map((item) => renderSubItem(item))}
>
)}
diff --git a/web/src/components/settings/OtherSetting.jsx b/web/src/components/settings/OtherSetting.jsx
index f8e0b53756a1..0c9e9454a62d 100644
--- a/web/src/components/settings/OtherSetting.jsx
+++ b/web/src/components/settings/OtherSetting.jsx
@@ -28,7 +28,21 @@ import {
Space,
Card,
} from '@douyinfe/semi-ui';
-import { API, showError, showSuccess, timestamp2string } from '../../helpers';
+import {
+ API,
+ buildRouteManagerHubCheckFallbackStatus,
+ buildStatusSnapshotWithClearedHubStatus,
+ buildStatusSnapshotWithHubStatus,
+ formatRouteManagerHubStatus,
+ getOptionUpdateErrorMessage,
+ getRouteManagerHubStatusBanner,
+ mergeRouteManagerHubStatusBanner,
+ ensureOptionUpdateSucceeded,
+ setStatusData,
+ showError,
+ showSuccess,
+ timestamp2string,
+} from '../../helpers';
import { marked } from 'marked';
import { useTranslation } from 'react-i18next';
import { StatusContext } from '../../context/Status';
@@ -48,7 +62,10 @@ const OtherSetting = () => {
Footer: '',
About: '',
HomePageContent: '',
+ RouteManagerURL: '',
});
+ const [persistedRouteManagerURL, setPersistedRouteManagerURL] = useState('');
+ const [hubStatus, setHubStatus] = useState(null);
let [loading, setLoading] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [statusState, statusDispatch] = useContext(StatusContext);
@@ -59,17 +76,17 @@ const OtherSetting = () => {
const updateOption = async (key, value) => {
setLoading(true);
- const res = await API.put('/api/option/', {
- key,
- value,
- });
- const { success, message } = res.data;
- if (success) {
+ try {
+ const res = await API.put('/api/option/', {
+ key,
+ value,
+ });
+
+ ensureOptionUpdateSucceeded(res.data);
setInputs((inputs) => ({ ...inputs, [key]: value }));
- } else {
- showError(message);
+ } finally {
+ setLoading(false);
}
- setLoading(false);
};
const [loadingInput, setLoadingInput] = useState({
@@ -79,10 +96,19 @@ const OtherSetting = () => {
SystemName: false,
Logo: false,
HomePageContent: false,
+ RouteManagerURL: false,
+ RouteManagerHubStatus: false,
About: false,
Footer: false,
CheckUpdate: false,
});
+
+ const reportOptionUpdateError = (error, fallbackMessage) => {
+ const message = getOptionUpdateErrorMessage(error, fallbackMessage);
+ console.error(message, error);
+ showError(message);
+ };
+
const handleInputChange = async (value, e) => {
const name = e.target.id;
setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -97,8 +123,7 @@ const OtherSetting = () => {
await updateOption('Notice', inputs.Notice);
showSuccess(t('公告已更新'));
} catch (error) {
- console.error(t('公告更新失败'), error);
- showError(t('公告更新失败'));
+ reportOptionUpdateError(error, t('公告更新失败'));
} finally {
setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: false }));
}
@@ -116,8 +141,7 @@ const OtherSetting = () => {
);
showSuccess(t('用户协议已更新'));
} catch (error) {
- console.error(t('用户协议更新失败'), error);
- showError(t('用户协议更新失败'));
+ reportOptionUpdateError(error, t('用户协议更新失败'));
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
@@ -138,8 +162,7 @@ const OtherSetting = () => {
);
showSuccess(t('隐私政策已更新'));
} catch (error) {
- console.error(t('隐私政策更新失败'), error);
- showError(t('隐私政策更新失败'));
+ reportOptionUpdateError(error, t('隐私政策更新失败'));
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
@@ -159,8 +182,7 @@ const OtherSetting = () => {
await updateOption('SystemName', inputs.SystemName);
showSuccess(t('系统名称已更新'));
} catch (error) {
- console.error(t('系统名称更新失败'), error);
- showError(t('系统名称更新失败'));
+ reportOptionUpdateError(error, t('系统名称更新失败'));
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
@@ -174,10 +196,9 @@ const OtherSetting = () => {
try {
setLoadingInput((loadingInput) => ({ ...loadingInput, Logo: true }));
await updateOption('Logo', inputs.Logo);
- showSuccess('Logo 已更新');
+ showSuccess(t('Logo 已更新'));
} catch (error) {
- console.error('Logo 更新失败', error);
- showError('Logo 更新失败');
+ reportOptionUpdateError(error, t('Logo 更新失败'));
} finally {
setLoadingInput((loadingInput) => ({ ...loadingInput, Logo: false }));
}
@@ -187,17 +208,34 @@ const OtherSetting = () => {
try {
setLoadingInput((loadingInput) => ({
...loadingInput,
- HomePageContent: true,
+ [key]: true,
}));
await updateOption(key, inputs[key]);
- showSuccess('首页内容已更新');
+ if (key === 'RouteManagerURL') {
+ const nextRouteManagerURL = inputs[key] || '';
+ setPersistedRouteManagerURL(nextRouteManagerURL);
+ await checkRouteManagerHubStatusWithFeedback(
+ false,
+ nextRouteManagerURL,
+ true,
+ );
+ }
+ showSuccess(
+ key === 'RouteManagerURL'
+ ? t('Route Manager 地址已更新')
+ : t('首页内容已更新'),
+ );
} catch (error) {
- console.error('首页内容更新失败', error);
- showError('首页内容更新失败');
+ reportOptionUpdateError(
+ error,
+ key === 'RouteManagerURL'
+ ? t('Route Manager 地址更新失败')
+ : t('首页内容更新失败'),
+ );
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
- HomePageContent: false,
+ [key]: false,
}));
}
};
@@ -206,23 +244,115 @@ const OtherSetting = () => {
try {
setLoadingInput((loadingInput) => ({ ...loadingInput, About: true }));
await updateOption('About', inputs.About);
- showSuccess('关于内容已更新');
+ showSuccess(t('关于内容已更新'));
} catch (error) {
- console.error('关于内容更新失败', error);
- showError('关于内容更新失败');
+ reportOptionUpdateError(error, t('关于内容更新失败'));
} finally {
setLoadingInput((loadingInput) => ({ ...loadingInput, About: false }));
}
};
+
+ const getSavedStatusSnapshot = () => {
+ const savedStatus = localStorage.getItem('status');
+
+ if (!savedStatus) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(savedStatus);
+ } catch (error) {
+ console.error('解析本地状态缓存失败', error);
+ return null;
+ }
+ };
+
+ const syncGlobalHubStatus = (nextHubStatus, options = {}) => {
+ const { clear = false } = options;
+ const savedStatusSnapshot = getSavedStatusSnapshot();
+
+ const nextStatusSnapshot = clear
+ ? buildStatusSnapshotWithClearedHubStatus(
+ statusState?.status,
+ savedStatusSnapshot,
+ )
+ : buildStatusSnapshotWithHubStatus(
+ statusState?.status,
+ nextHubStatus,
+ savedStatusSnapshot,
+ );
+
+ if (!nextStatusSnapshot) {
+ return;
+ }
+
+ statusDispatch({ type: 'set', payload: nextStatusSnapshot });
+ setStatusData(nextStatusSnapshot);
+ };
+
+ const checkRouteManagerHubStatusWithFeedback = async (
+ notify = true,
+ pendingRouteManagerURL = '',
+ respectExplicitEmptyRouteManagerURL = false,
+ ) => {
+ try {
+ setLoadingInput((loadingInput) => ({
+ ...loadingInput,
+ RouteManagerHubStatus: true,
+ }));
+ const res = await API.get('/api/hub/status');
+ syncGlobalHubStatus(res.data?.data || null);
+ const nextStatus = formatRouteManagerHubStatus(res.data?.data, t);
+ setHubStatus(nextStatus);
+ if (!notify) {
+ return nextStatus;
+ }
+ if (nextStatus.tone === 'success') {
+ showSuccess(nextStatus.message);
+ } else {
+ showError(nextStatus.message);
+ }
+ return nextStatus;
+ } catch (error) {
+ const fallbackStatus = {
+ tone: 'danger',
+ message: t('Route Manager 状态检查失败'),
+ source: 'manual-check',
+ };
+ syncGlobalHubStatus(
+ buildRouteManagerHubCheckFallbackStatus({
+ currentStatus: statusState?.status,
+ routeManagerURL: persistedRouteManagerURL,
+ pendingRouteManagerURL,
+ fallbackStatus: getSavedStatusSnapshot(),
+ respectExplicitEmptyRouteManagerURL,
+ }),
+ );
+ setHubStatus(fallbackStatus);
+ console.error(t('Route Manager 状态检查失败'), error);
+ if (notify) {
+ showError(fallbackStatus.message);
+ }
+ return fallbackStatus;
+ } finally {
+ setLoadingInput((loadingInput) => ({
+ ...loadingInput,
+ RouteManagerHubStatus: false,
+ }));
+ }
+ };
+
+ const checkRouteManagerHubStatus = async () => {
+ return checkRouteManagerHubStatusWithFeedback(true);
+ };
// 个性化设置 - 页脚
const submitFooter = async () => {
try {
setLoadingInput((loadingInput) => ({ ...loadingInput, Footer: true }));
await updateOption('Footer', inputs.Footer);
- showSuccess('页脚内容已更新');
+ showSuccess(t('页脚内容已更新'));
} catch (error) {
- console.error('页脚内容更新失败', error);
- showError('页脚内容更新失败');
+ reportOptionUpdateError(error, t('页脚内容更新失败'));
} finally {
setLoadingInput((loadingInput) => ({ ...loadingInput, Footer: false }));
}
@@ -289,6 +419,7 @@ const OtherSetting = () => {
}
});
setInputs(newInputs);
+ setPersistedRouteManagerURL(newInputs.RouteManagerURL || '');
formAPISettingGeneral.current.setValues(newInputs);
formAPIPersonalization.current.setValues(newInputs);
} else {
@@ -300,6 +431,19 @@ const OtherSetting = () => {
getOptions();
}, []);
+ useEffect(() => {
+ const savedStatusSnapshot = getSavedStatusSnapshot();
+
+ const nextHubStatus = getRouteManagerHubStatusBanner(
+ statusState?.status,
+ t,
+ savedStatusSnapshot,
+ );
+ setHubStatus((currentHubStatus) =>
+ mergeRouteManagerHubStatusBanner(currentHubStatus, nextHubStatus),
+ );
+ }, [statusState?.status, t]);
+
// Function to open GitHub release page
const openGitHubRelease = () => {
window.open(
@@ -444,10 +588,36 @@ const OtherSetting = () => {
+
+
+
+ {hubStatus ? (
+
+ ) : null}
{
+ test('builds a stable signature for each availability combination', () => {
+ expect(buildRouteManagerHubAvailabilitySignature()).toBe('0:0');
+ expect(
+ buildRouteManagerHubAvailabilitySignature({
+ configured: true,
+ reachable: false,
+ }),
+ ).toBe('1:0');
+ expect(
+ buildRouteManagerHubAvailabilitySignature({
+ configured: true,
+ reachable: true,
+ }),
+ ).toBe('1:1');
+ });
+
+ test('treats async hub status arrival as an availability change', () => {
+ expect(
+ hasRouteManagerHubAvailabilityChanged(undefined, {
+ configured: true,
+ reachable: true,
+ }),
+ ).toBe(true);
+ expect(
+ hasRouteManagerHubAvailabilityChanged(
+ {
+ configured: true,
+ reachable: true,
+ },
+ {
+ configured: true,
+ reachable: true,
+ },
+ ),
+ ).toBe(false);
+ expect(
+ hasRouteManagerHubAvailabilityChanged(
+ {
+ configured: true,
+ reachable: true,
+ },
+ {
+ configured: false,
+ reachable: false,
+ },
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/web/src/helpers/hubDashboard.js b/web/src/helpers/hubDashboard.js
new file mode 100644
index 000000000000..7b749b3401eb
--- /dev/null
+++ b/web/src/helpers/hubDashboard.js
@@ -0,0 +1,644 @@
+import {
+ getRouteManagerHubAIFleetHref,
+ getRouteManagerHubDNSSecurityHref,
+ getRouteManagerHubEgressHref,
+ getRouteManagerHubHomeAssistantEntitiesHref,
+ getRouteManagerHubNetworkPanelHref,
+} from './hubNavigation.js';
+
+function interpolateTemplate(message, params = {}) {
+ return Object.entries(params).reduce(
+ (result, [key, value]) => result.replaceAll(`{{${key}}}`, String(value)),
+ message,
+ );
+}
+
+function translateHubText(t, key, params = {}) {
+ if (typeof t !== 'function') {
+ return interpolateTemplate(key, params);
+ }
+
+ return interpolateTemplate(t(key, params), params);
+}
+
+const ROUTE_MANAGER_HUB_NODE_STATUS_LABELS = {
+ online: '在线',
+ stale: '待确认',
+ sleep: '休眠',
+ offline: '离线',
+};
+
+const ROUTE_MANAGER_HUB_TASK_STATUS_LABELS = {
+ pending: '待执行',
+ running: '进行中',
+ succeeded: '已完成',
+ failed: '失败',
+};
+
+const ROUTE_MANAGER_HUB_TASK_STATUS_COLORS = {
+ pending: 'blue',
+ running: 'orange',
+ succeeded: 'green',
+ failed: 'red',
+};
+
+const ROUTE_MANAGER_HUB_TASK_TYPE_LABELS = {
+ wake: '唤醒任务',
+ shell: 'Shell 任务',
+ docker: 'Docker 任务',
+ browser: '浏览器自动化',
+ ai_inference: '本地推理',
+ network_policy: '网络策略',
+ home_assistant_action: '家庭动作',
+};
+
+const ROUTE_MANAGER_HUB_ALERT_SEVERITY_LABELS = {
+ critical: '严重',
+ warning: '一般',
+};
+
+function formatRouteManagerHubEnumLabel(
+ value,
+ labels,
+ t = (label) => label,
+ fallbackKey = '未知状态',
+) {
+ const normalizedValue = String(value || '').trim().toLowerCase();
+ const labelKey = labels[normalizedValue] || fallbackKey;
+
+ return translateHubText(t, labelKey);
+}
+
+function safeParseTaskOutput(rawOutput) {
+ if (typeof rawOutput !== 'string' || rawOutput.trim() === '') {
+ return null;
+ }
+
+ try {
+ return JSON.parse(rawOutput);
+ } catch {
+ return null;
+ }
+}
+
+export function formatRouteManagerHubNodeStatus(
+ status,
+ t = (label) => label,
+) {
+ return formatRouteManagerHubEnumLabel(
+ status,
+ ROUTE_MANAGER_HUB_NODE_STATUS_LABELS,
+ t,
+ );
+}
+
+export function formatRouteManagerHubTaskStatus(
+ status,
+ t = (label) => label,
+) {
+ return formatRouteManagerHubEnumLabel(
+ status,
+ ROUTE_MANAGER_HUB_TASK_STATUS_LABELS,
+ t,
+ );
+}
+
+export function getRouteManagerHubTaskStatusColor(status) {
+ const normalizedStatus = String(status || '').trim().toLowerCase();
+
+ return ROUTE_MANAGER_HUB_TASK_STATUS_COLORS[normalizedStatus] || 'grey';
+}
+
+export function formatRouteManagerHubTaskType(type, t = (label) => label) {
+ return formatRouteManagerHubEnumLabel(
+ type,
+ ROUTE_MANAGER_HUB_TASK_TYPE_LABELS,
+ t,
+ '未知类型',
+ );
+}
+
+export function formatRouteManagerHubAlertSeverity(
+ severity,
+ t = (label) => label,
+) {
+ return formatRouteManagerHubEnumLabel(
+ severity,
+ ROUTE_MANAGER_HUB_ALERT_SEVERITY_LABELS,
+ t,
+ );
+}
+
+export function formatRouteManagerHubAlertSubtitle(
+ subtitle,
+ t = (label) => label,
+) {
+ const normalizedSubtitle = String(subtitle || '').trim();
+
+ if (!normalizedSubtitle) {
+ return '';
+ }
+
+ const [leadingPart, ...remainingParts] = normalizedSubtitle.split(' · ');
+ const normalizedLeadingPart = String(leadingPart || '').trim().toLowerCase();
+
+ if (
+ remainingParts.length === 0 ||
+ !Object.prototype.hasOwnProperty.call(
+ ROUTE_MANAGER_HUB_TASK_TYPE_LABELS,
+ normalizedLeadingPart,
+ )
+ ) {
+ return normalizedSubtitle;
+ }
+
+ return [
+ formatRouteManagerHubTaskType(normalizedLeadingPart, t),
+ ...remainingParts,
+ ].join(' · ');
+}
+
+export function buildRouteManagerHubAlertDetail(
+ alert = {},
+ t = (label) => label,
+) {
+ const preview = String(alert?.preview || '').trim();
+ if (preview) {
+ return preview;
+ }
+
+ const subtitle = formatRouteManagerHubAlertSubtitle(alert?.subtitle, t);
+ if (subtitle) {
+ return subtitle;
+ }
+
+ const metaText = String(alert?.metaText || alert?.meta_text || '').trim();
+ if (metaText) {
+ return metaText;
+ }
+
+ const targetNodeID = String(
+ alert?.targetNodeID || alert?.target_node_id || '',
+ ).trim();
+ if (targetNodeID) {
+ return targetNodeID;
+ }
+
+ return '-';
+}
+
+export function extractRouteManagerTaskPreview(task) {
+ const directPreview = task?.preview?.trim();
+ if (directPreview) {
+ return directPreview;
+ }
+
+ const executeStep = Array.isArray(task?.steps)
+ ? task.steps.find((step) => step?.type === 'execute')
+ : null;
+ const parsedOutput = safeParseTaskOutput(executeStep?.output);
+
+ const stdoutPreview = parsedOutput?.stdout?.trim();
+ if (stdoutPreview) {
+ return stdoutPreview;
+ }
+
+ const stderrPreview = parsedOutput?.stderr?.trim();
+ if (stderrPreview) {
+ return stderrPreview;
+ }
+
+ const pageTitlePreview = parsedOutput?.page_title?.trim();
+ if (pageTitlePreview) {
+ return pageTitlePreview;
+ }
+
+ const pageURLPreview = parsedOutput?.page_url?.trim();
+ if (pageURLPreview) {
+ return pageURLPreview;
+ }
+
+ return task?.command?.trim() || '';
+}
+
+export function buildRouteManagerHubDashboardSnapshot({
+ onlineNodes,
+ online_nodes,
+ busyNodes,
+ busy_nodes,
+ pendingTasks,
+ pending_tasks,
+ activeSchedules,
+ active_schedules,
+ criticalAlerts,
+ critical_alerts,
+ unacknowledgedAlerts,
+ unacknowledged_alerts,
+ ai = {},
+ network = {},
+ homeAssistant = {},
+ home_assistant = {},
+ primaryNode = {},
+ primary_node = {},
+ nodes = [],
+ schedules = [],
+ tasks = [],
+ alerts = [],
+} = {}) {
+ const normalizedHomeAssistant =
+ homeAssistant && Object.keys(homeAssistant).length > 0
+ ? homeAssistant
+ : home_assistant;
+ const normalizedPrimaryNode =
+ primaryNode && Object.keys(primaryNode).length > 0
+ ? primaryNode
+ : primary_node;
+
+ const computedOnlineNodes = nodes.filter(
+ (node) => node?.status === 'online',
+ ).length;
+ const computedBusyNodes = nodes.filter((node) => {
+ const browserRuntime = node?.browser_runtime;
+ return (
+ (browserRuntime?.active_tasks || 0) > 0 ||
+ (browserRuntime?.pending_tasks || 0) > 0
+ );
+ }).length;
+ const computedPendingTasks = tasks.filter(
+ (task) => task?.status === 'pending',
+ ).length;
+ const computedActiveSchedules = schedules.filter(
+ (schedule) => schedule?.enabled,
+ ).length;
+ const computedCriticalAlerts = alerts.filter(
+ (alert) => alert?.severity === 'critical',
+ ).length;
+ const computedUnacknowledgedAlerts = alerts.filter(
+ (alert) => !alert?.acknowledged,
+ ).length;
+
+ const recentTasks = tasks.slice(0, 3).map((task) => ({
+ id: task?.id,
+ type: task?.type || '',
+ status: task?.status || 'unknown',
+ targetNodeID: task?.target_node_id || '',
+ preview: extractRouteManagerTaskPreview(task),
+ }));
+ const recentSchedules = schedules.slice(0, 3).map((schedule) => ({
+ id: schedule?.id,
+ name: schedule?.name || '',
+ type: schedule?.type || '',
+ targetNodeID: schedule?.target_node_id || '',
+ dailyAt: schedule?.daily_at || '',
+ enabled: Boolean(schedule?.enabled),
+ nextRunAt: schedule?.next_run_at || '',
+ }));
+ const recentAlerts = alerts.slice(0, 3).map((alert) => ({
+ id: alert?.id || '',
+ kind: alert?.kind || '',
+ severity: alert?.severity || 'unknown',
+ title: alert?.title || '',
+ subtitle: alert?.subtitle || '',
+ preview: alert?.preview || '',
+ metaText: alert?.metaText || alert?.meta_text || '',
+ targetNodeID: alert?.targetNodeID || alert?.target_node_id || '',
+ acknowledged: Boolean(alert?.acknowledged),
+ }));
+
+ return {
+ onlineNodes:
+ typeof onlineNodes === 'number'
+ ? onlineNodes
+ : typeof online_nodes === 'number'
+ ? online_nodes
+ : computedOnlineNodes,
+ busyNodes:
+ typeof busyNodes === 'number'
+ ? busyNodes
+ : typeof busy_nodes === 'number'
+ ? busy_nodes
+ : computedBusyNodes,
+ pendingTasks:
+ typeof pendingTasks === 'number'
+ ? pendingTasks
+ : typeof pending_tasks === 'number'
+ ? pending_tasks
+ : computedPendingTasks,
+ activeSchedules:
+ typeof activeSchedules === 'number'
+ ? activeSchedules
+ : typeof active_schedules === 'number'
+ ? active_schedules
+ : computedActiveSchedules,
+ criticalAlerts:
+ typeof criticalAlerts === 'number'
+ ? criticalAlerts
+ : typeof critical_alerts === 'number'
+ ? critical_alerts
+ : computedCriticalAlerts,
+ unacknowledgedAlerts:
+ typeof unacknowledgedAlerts === 'number'
+ ? unacknowledgedAlerts
+ : typeof unacknowledged_alerts === 'number'
+ ? unacknowledged_alerts
+ : computedUnacknowledgedAlerts,
+ ai: {
+ modelCount: Number(ai?.modelCount || ai?.model_count || 0),
+ aiCapableNodes: Number(ai?.aiCapableNodes || ai?.ai_capable_nodes || 0),
+ onlineAINodes: Number(ai?.onlineAINodes || ai?.online_ai_nodes || 0),
+ },
+ network: {
+ mihomoConfigured: Boolean(
+ network?.mihomoConfigured ?? network?.mihomo_configured,
+ ),
+ mihomoReachable: Boolean(
+ network?.mihomoReachable ?? network?.mihomo_reachable,
+ ),
+ dnsConfigured: Boolean(
+ network?.dnsConfigured ?? network?.dns_configured,
+ ),
+ dnsReachable: Boolean(network?.dnsReachable ?? network?.dns_reachable),
+ dnsProtectionEnabled: Boolean(
+ network?.dnsProtectionEnabled ?? network?.dns_protection_enabled,
+ ),
+ egressIP: network?.egressIP || network?.egress_ip || '',
+ egressRegion: network?.egressRegion || network?.egress_region || '',
+ egressISP: network?.egressISP || network?.egress_isp || '',
+ egressSource: network?.egressSource || network?.egress_source || '',
+ },
+ homeAssistant: {
+ configured: Boolean(
+ normalizedHomeAssistant?.configured ??
+ normalizedHomeAssistant?.Configured,
+ ),
+ reachable: Boolean(
+ normalizedHomeAssistant?.reachable ??
+ normalizedHomeAssistant?.Reachable,
+ ),
+ entityCount: Number(
+ normalizedHomeAssistant?.entityCount ||
+ normalizedHomeAssistant?.entity_count ||
+ 0,
+ ),
+ },
+ primaryNode: {
+ nodeID: normalizedPrimaryNode?.nodeID || normalizedPrimaryNode?.node_id || '',
+ hostname: normalizedPrimaryNode?.hostname || '',
+ status: normalizedPrimaryNode?.status || '',
+ ipAddress:
+ normalizedPrimaryNode?.ipAddress || normalizedPrimaryNode?.ip_address || '',
+ },
+ recentSchedules,
+ recentTasks,
+ recentAlerts,
+ };
+}
+
+function buildRouteManagerHubAIQuickDetail(
+ snapshot = {},
+ t = (label) => label,
+) {
+ const primaryNodeName =
+ snapshot?.primaryNode?.hostname || snapshot?.primaryNode?.nodeID || '';
+ const modelCount = Number(snapshot?.ai?.modelCount || 0);
+ const onlineAINodes = Number(snapshot?.ai?.onlineAINodes || 0);
+ const aiParts = [];
+
+ if (primaryNodeName) {
+ aiParts.push(primaryNodeName);
+ }
+ if (modelCount > 0) {
+ aiParts.push(translateHubText(t, '{{count}} 模型', { count: modelCount }));
+ }
+ if (onlineAINodes > 0) {
+ aiParts.push(
+ translateHubText(t, '{{count}} 在线 AI 节点', {
+ count: onlineAINodes,
+ }),
+ );
+ }
+
+ return aiParts.join(' · ');
+}
+
+function buildRouteManagerHubNetworkQuickDetail(
+ snapshot = {},
+ t = (label) => label,
+) {
+ const networkParts = [];
+
+ if (snapshot?.network?.dnsProtectionEnabled) {
+ networkParts.push(translateHubText(t, '净网已开启'));
+ } else if (snapshot?.network?.dnsConfigured) {
+ networkParts.push(translateHubText(t, '净网待确认'));
+ }
+
+ if (snapshot?.network?.mihomoReachable) {
+ networkParts.push(translateHubText(t, '代理可达'));
+ } else if (snapshot?.network?.mihomoConfigured) {
+ networkParts.push(translateHubText(t, '代理待确认'));
+ }
+
+ if (snapshot?.network?.egressRegion) {
+ networkParts.push(snapshot.network.egressRegion);
+ } else if (snapshot?.network?.egressIP) {
+ networkParts.push(snapshot.network.egressIP);
+ }
+
+ if (snapshot?.network?.egressSource) {
+ networkParts.push(snapshot.network.egressSource);
+ }
+
+ return networkParts.join(' · ');
+}
+
+function buildRouteManagerHubDNSQuickDetail(
+ snapshot = {},
+ t = (label) => label,
+) {
+ const networkParts = [];
+
+ if (snapshot?.network?.dnsProtectionEnabled) {
+ networkParts.push(translateHubText(t, '净网已开启'));
+ } else if (snapshot?.network?.dnsConfigured) {
+ networkParts.push(translateHubText(t, '净网待确认'));
+ }
+
+ if (snapshot?.network?.dnsReachable) {
+ networkParts.push(translateHubText(t, 'DNS 可达'));
+ } else if (snapshot?.network?.dnsConfigured) {
+ networkParts.push(translateHubText(t, 'DNS 待确认'));
+ }
+
+ return networkParts.join(' · ');
+}
+
+function buildRouteManagerHubEgressQuickDetail(snapshot = {}) {
+ const egressParts = [];
+
+ if (snapshot?.network?.egressRegion) {
+ egressParts.push(snapshot.network.egressRegion);
+ } else if (snapshot?.network?.egressIP) {
+ egressParts.push(snapshot.network.egressIP);
+ }
+
+ if (snapshot?.network?.egressISP) {
+ egressParts.push(snapshot.network.egressISP);
+ }
+
+ if (snapshot?.network?.egressSource) {
+ egressParts.push(snapshot.network.egressSource);
+ }
+
+ return egressParts.join(' · ');
+}
+
+function buildRouteManagerHubHomeAssistantQuickDetail(
+ snapshot = {},
+ t = (label) => label,
+) {
+ const haEntityCount = Number(snapshot?.homeAssistant?.entityCount || 0);
+
+ if (!snapshot?.homeAssistant?.reachable && haEntityCount <= 0) {
+ return '';
+ }
+
+ return [
+ translateHubText(
+ t,
+ snapshot?.homeAssistant?.reachable ? '桥接在线' : '桥接待确认',
+ ),
+ translateHubText(t, '{{count}} 实体', { count: haEntityCount }),
+ ].join(' · ');
+}
+
+export function buildRouteManagerHubQuickHighlights(
+ snapshot = {},
+ t = (label) => label,
+) {
+ const highlights = [];
+ const aiDetail = buildRouteManagerHubAIQuickDetail(snapshot, t);
+ const networkDetail = buildRouteManagerHubNetworkQuickDetail(snapshot, t);
+ const dnsDetail = buildRouteManagerHubDNSQuickDetail(snapshot, t);
+ const egressDetail = buildRouteManagerHubEgressQuickDetail(snapshot);
+ const haDetail = buildRouteManagerHubHomeAssistantQuickDetail(snapshot, t);
+
+ if (aiDetail) {
+ highlights.push({
+ key: 'ai',
+ labelKey: '家庭算力编队',
+ detail: aiDetail,
+ });
+ }
+
+ if (networkDetail) {
+ highlights.push({
+ key: 'network',
+ labelKey: '网络代理状态',
+ detail: networkDetail,
+ });
+ }
+
+ if (dnsDetail) {
+ highlights.push({
+ key: 'dns',
+ labelKey: 'DNS 广告屏蔽',
+ detail: dnsDetail,
+ });
+ }
+
+ if (egressDetail) {
+ highlights.push({
+ key: 'egress',
+ labelKey: '出口网络画像',
+ detail: egressDetail,
+ });
+ }
+
+ if (haDetail) {
+ highlights.push({
+ key: 'ha',
+ labelKey: '家庭实体桥接',
+ detail: haDetail,
+ });
+ }
+
+ return highlights;
+}
+
+export function buildRouteManagerHubQuickPanelLinks(
+ snapshot = {},
+ t = (label) => label,
+) {
+ const translate = typeof t === 'function' ? t : (label) => label;
+ const aiDetail = buildRouteManagerHubAIQuickDetail(snapshot, translate);
+ const networkDetail = buildRouteManagerHubNetworkQuickDetail(
+ snapshot,
+ translate,
+ );
+ const dnsDetail = buildRouteManagerHubDNSQuickDetail(snapshot, translate);
+ const egressDetail = buildRouteManagerHubEgressQuickDetail(snapshot);
+ const haDetail = buildRouteManagerHubHomeAssistantQuickDetail(
+ snapshot,
+ translate,
+ );
+
+ return [
+ {
+ key: 'ai',
+ label: translate('家庭算力编队'),
+ href: getRouteManagerHubAIFleetHref(),
+ description: aiDetail || translate('直达 AI 中心的主算力编队面板'),
+ },
+ {
+ key: 'network_mihomo',
+ label: translate('网络代理状态'),
+ href: getRouteManagerHubNetworkPanelHref('mihomo'),
+ description:
+ networkDetail || translate('直达网络中心的 Mihomo 代理状态面板'),
+ },
+ {
+ key: 'network_dns',
+ label: translate('DNS 广告屏蔽'),
+ href: getRouteManagerHubDNSSecurityHref(),
+ description:
+ dnsDetail || translate('直达网络中心的 DNS 广告屏蔽面板'),
+ },
+ {
+ key: 'network_egress',
+ label: translate('出口网络画像'),
+ href: getRouteManagerHubEgressHref(),
+ description:
+ egressDetail || translate('直达网络中心的出口网络画像面板'),
+ },
+ {
+ key: 'ha',
+ label: translate('家庭实体桥接'),
+ href: getRouteManagerHubHomeAssistantEntitiesHref(),
+ description: haDetail || translate('直达家庭自动化的实体桥接面板'),
+ },
+ ];
+}
+
+export function buildRouteManagerHubFamilySummaryPills(
+ snapshot = {},
+ t = (label) => label,
+) {
+ return [
+ snapshot?.primaryNode?.hostname || snapshot?.primaryNode?.nodeID
+ ? translateHubText(t, '主算力 {{name}}', {
+ name: snapshot.primaryNode.hostname || snapshot.primaryNode.nodeID,
+ })
+ : '',
+ snapshot?.network?.dnsProtectionEnabled
+ ? translateHubText(t, '净网已开启')
+ : snapshot?.network?.dnsConfigured
+ ? translateHubText(t, '净网待确认')
+ : '',
+ snapshot?.homeAssistant?.entityCount > 0
+ ? translateHubText(t, '家庭桥接 {{count}} 实体', {
+ count: snapshot.homeAssistant.entityCount,
+ })
+ : '',
+ ].filter(Boolean);
+}
diff --git a/web/src/helpers/hubDashboard.test.mjs b/web/src/helpers/hubDashboard.test.mjs
new file mode 100644
index 000000000000..40b301942400
--- /dev/null
+++ b/web/src/helpers/hubDashboard.test.mjs
@@ -0,0 +1,756 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ buildRouteManagerHubAlertDetail,
+ buildRouteManagerHubDashboardSnapshot,
+ buildRouteManagerHubFamilySummaryPills,
+ formatRouteManagerHubAlertSeverity,
+ formatRouteManagerHubAlertSubtitle,
+ formatRouteManagerHubNodeStatus,
+ getRouteManagerHubTaskStatusColor,
+ formatRouteManagerHubTaskStatus,
+ formatRouteManagerHubTaskType,
+ buildRouteManagerHubQuickPanelLinks,
+ buildRouteManagerHubQuickHighlights,
+ extractRouteManagerTaskPreview,
+} from './hubDashboard.js';
+
+describe('extractRouteManagerTaskPreview', () => {
+ test('uses shell stdout as preview when available', () => {
+ expect(
+ extractRouteManagerTaskPreview({
+ type: 'shell',
+ command: 'echo shadow-agent-smoke',
+ steps: [
+ {
+ type: 'execute',
+ output: JSON.stringify({
+ stdout: 'shadow-agent-smoke\n',
+ }),
+ },
+ ],
+ }),
+ ).toBe('shadow-agent-smoke');
+ });
+
+ test('uses browser page title when available', () => {
+ expect(
+ extractRouteManagerTaskPreview({
+ type: 'browser',
+ command: 'http://route-manager-shadow:19080/',
+ steps: [
+ {
+ type: 'execute',
+ output: JSON.stringify({
+ page_title: '家域中枢',
+ page_url: 'http://route-manager-shadow:19080/',
+ }),
+ },
+ ],
+ }),
+ ).toBe('家域中枢');
+ });
+
+ test('uses stderr as preview when stdout is unavailable', () => {
+ expect(
+ extractRouteManagerTaskPreview({
+ type: 'shell',
+ command: 'sh -c fail',
+ steps: [
+ {
+ type: 'execute',
+ output: JSON.stringify({
+ stderr: 'shadow-alert-smoke\n',
+ }),
+ },
+ ],
+ }),
+ ).toBe('shadow-alert-smoke');
+ });
+
+ test('falls back to the command when task output is unavailable', () => {
+ expect(
+ extractRouteManagerTaskPreview({
+ type: 'shell',
+ command: 'echo fallback',
+ steps: [],
+ }),
+ ).toBe('echo fallback');
+ });
+});
+
+describe('buildRouteManagerHubDashboardSnapshot', () => {
+ test('builds node and task summaries for dashboard cards', () => {
+ const snapshot = buildRouteManagerHubDashboardSnapshot({
+ onlineNodes: 4,
+ busyNodes: 2,
+ pendingTasks: 6,
+ activeSchedules: 2,
+ criticalAlerts: 2,
+ unacknowledgedAlerts: 1,
+ ai: {
+ model_count: 5,
+ ai_capable_nodes: 2,
+ online_ai_nodes: 1,
+ },
+ network: {
+ mihomo_configured: true,
+ mihomo_reachable: false,
+ dns_configured: true,
+ dns_reachable: true,
+ dns_protection_enabled: true,
+ egress_ip: '119.29.29.29',
+ egress_region: '中国 广东 深圳',
+ egress_isp: '腾讯云',
+ egress_source: 'probe+qqwry',
+ },
+ home_assistant: {
+ configured: true,
+ reachable: true,
+ entity_count: 18,
+ },
+ primary_node: {
+ node_id: 'shadow-node-001',
+ hostname: 'shadow-host',
+ status: 'online',
+ ip_address: '192.168.31.18',
+ },
+ nodes: [
+ {
+ node_id: 'shadow-node-001',
+ status: 'online',
+ browser_runtime: {
+ active_tasks: 1,
+ pending_tasks: 0,
+ },
+ },
+ {
+ node_id: 'shadow-node-002',
+ status: 'offline',
+ browser_runtime: {
+ active_tasks: 0,
+ pending_tasks: 2,
+ },
+ },
+ ],
+ tasks: [
+ {
+ id: 9,
+ type: 'browser',
+ status: 'succeeded',
+ target_node_id: 'shadow-node-001',
+ command: 'http://route-manager-shadow:19080/',
+ steps: [
+ {
+ type: 'execute',
+ output: JSON.stringify({
+ page_title: '家域中枢',
+ }),
+ },
+ ],
+ },
+ {
+ id: 8,
+ type: 'shell',
+ status: 'pending',
+ target_node_id: 'shadow-node-001',
+ command: 'echo waiting',
+ steps: [],
+ },
+ {
+ id: 7,
+ type: 'shell',
+ status: 'failed',
+ target_node_id: 'shadow-node-002',
+ command: 'echo failed',
+ steps: [],
+ },
+ {
+ id: 6,
+ type: 'shell',
+ status: 'succeeded',
+ target_node_id: 'shadow-node-001',
+ command: 'echo hidden',
+ steps: [],
+ },
+ ],
+ schedules: [
+ {
+ id: 9,
+ name: '晚间模型巡检',
+ type: 'shell',
+ target_node_id: 'shadow-node-001',
+ daily_at: '23:30',
+ enabled: true,
+ next_run_at: '2026-03-24T23:30:00+08:00',
+ },
+ {
+ id: 8,
+ name: '晨间模型巡检',
+ type: 'browser',
+ target_node_id: 'shadow-node-002',
+ daily_at: '08:15',
+ enabled: true,
+ next_run_at: '2026-03-25T08:15:00+08:00',
+ },
+ {
+ id: 7,
+ name: '午间同步',
+ type: 'shell',
+ target_node_id: 'shadow-node-001',
+ daily_at: '12:00',
+ enabled: false,
+ next_run_at: '2026-03-25T12:00:00+08:00',
+ },
+ {
+ id: 6,
+ name: '隐藏计划',
+ type: 'shell',
+ target_node_id: 'shadow-node-001',
+ daily_at: '01:00',
+ enabled: true,
+ next_run_at: '2026-03-25T01:00:00+08:00',
+ },
+ ],
+ alerts: [
+ {
+ id: 'schedule:9:12',
+ kind: 'schedule',
+ severity: 'critical',
+ title: '晚间模型巡检',
+ subtitle: '任务 #12 · 失败',
+ preview: '最近夜巡执行失败,请查看任务链路',
+ meta_text: 'shadow-node-001',
+ target_node_id: 'shadow-node-001',
+ acknowledged: false,
+ },
+ {
+ id: 'task:8',
+ kind: 'task',
+ severity: 'warning',
+ title: '任务 #8 执行失败',
+ subtitle: 'shell · shadow-node-001',
+ meta_text: 'shadow-node-001',
+ target_node_id: 'shadow-node-001',
+ acknowledged: true,
+ },
+ {
+ id: 'system:network:mihomo',
+ kind: 'system',
+ severity: 'critical',
+ title: 'Mihomo 代理内核异常',
+ subtitle: '代理运行时不可达',
+ meta_text: 'gateway-mini',
+ target_node_id: 'shadow-node-003',
+ acknowledged: false,
+ },
+ {
+ id: 'task:6',
+ kind: 'task',
+ severity: 'warning',
+ title: '任务 #6 执行失败',
+ subtitle: 'shell · shadow-node-001',
+ meta_text: 'shadow-node-001',
+ target_node_id: 'shadow-node-001',
+ acknowledged: false,
+ },
+ ],
+ });
+
+ expect(snapshot.onlineNodes).toBe(4);
+ expect(snapshot.busyNodes).toBe(2);
+ expect(snapshot.pendingTasks).toBe(6);
+ expect(snapshot.activeSchedules).toBe(2);
+ expect(snapshot.criticalAlerts).toBe(2);
+ expect(snapshot.unacknowledgedAlerts).toBe(1);
+ expect(snapshot.ai).toEqual({
+ modelCount: 5,
+ aiCapableNodes: 2,
+ onlineAINodes: 1,
+ });
+ expect(snapshot.network).toEqual({
+ mihomoConfigured: true,
+ mihomoReachable: false,
+ dnsConfigured: true,
+ dnsReachable: true,
+ dnsProtectionEnabled: true,
+ egressIP: '119.29.29.29',
+ egressRegion: '中国 广东 深圳',
+ egressISP: '腾讯云',
+ egressSource: 'probe+qqwry',
+ });
+ expect(snapshot.homeAssistant).toEqual({
+ configured: true,
+ reachable: true,
+ entityCount: 18,
+ });
+ expect(snapshot.primaryNode).toEqual({
+ nodeID: 'shadow-node-001',
+ hostname: 'shadow-host',
+ status: 'online',
+ ipAddress: '192.168.31.18',
+ });
+ expect(snapshot.recentSchedules).toHaveLength(3);
+ expect(snapshot.recentTasks).toHaveLength(3);
+ expect(snapshot.recentAlerts).toHaveLength(3);
+ expect(snapshot.recentSchedules[0]).toMatchObject({
+ id: 9,
+ name: '晚间模型巡检',
+ dailyAt: '23:30',
+ enabled: true,
+ });
+ expect(snapshot.recentTasks[0]).toMatchObject({
+ id: 9,
+ preview: '家域中枢',
+ status: 'succeeded',
+ });
+ expect(snapshot.recentTasks[1]).toMatchObject({
+ id: 8,
+ preview: 'echo waiting',
+ status: 'pending',
+ });
+ expect(snapshot.recentAlerts[0]).toMatchObject({
+ id: 'schedule:9:12',
+ severity: 'critical',
+ title: '晚间模型巡检',
+ preview: '最近夜巡执行失败,请查看任务链路',
+ acknowledged: false,
+ });
+ });
+
+ test('accepts snake_case summary counters from the hub API payload', () => {
+ const snapshot = buildRouteManagerHubDashboardSnapshot({
+ online_nodes: 3,
+ busy_nodes: 1,
+ pending_tasks: 4,
+ active_schedules: 2,
+ critical_alerts: 1,
+ unacknowledged_alerts: 5,
+ ai: {
+ model_count: 7,
+ ai_capable_nodes: 2,
+ online_ai_nodes: 1,
+ },
+ });
+
+ expect(snapshot.onlineNodes).toBe(3);
+ expect(snapshot.busyNodes).toBe(1);
+ expect(snapshot.pendingTasks).toBe(4);
+ expect(snapshot.activeSchedules).toBe(2);
+ expect(snapshot.criticalAlerts).toBe(1);
+ expect(snapshot.unacknowledgedAlerts).toBe(5);
+ expect(snapshot.ai).toEqual({
+ modelCount: 7,
+ aiCapableNodes: 2,
+ onlineAINodes: 1,
+ });
+ });
+});
+
+describe('buildRouteManagerHubQuickHighlights', () => {
+ test('builds AI, network, dns, egress, and home-assistant highlights for embedded hub home', () => {
+ const highlights = buildRouteManagerHubQuickHighlights(
+ buildRouteManagerHubDashboardSnapshot({
+ ai: {
+ model_count: 6,
+ online_ai_nodes: 2,
+ },
+ network: {
+ dns_protection_enabled: true,
+ mihomo_reachable: true,
+ egress_region: '中国 广东 深圳',
+ egress_source: 'probe+qqwry',
+ },
+ home_assistant: {
+ reachable: true,
+ entity_count: 18,
+ },
+ primary_node: {
+ hostname: 'desktop-1',
+ },
+ }),
+ );
+
+ expect(highlights).toEqual([
+ {
+ key: 'ai',
+ labelKey: '家庭算力编队',
+ detail: 'desktop-1 · 6 模型 · 2 在线 AI 节点',
+ },
+ {
+ key: 'network',
+ labelKey: '网络代理状态',
+ detail: '净网已开启 · 代理可达 · 中国 广东 深圳 · probe+qqwry',
+ },
+ {
+ key: 'dns',
+ labelKey: 'DNS 广告屏蔽',
+ detail: '净网已开启',
+ },
+ {
+ key: 'egress',
+ labelKey: '出口网络画像',
+ detail: '中国 广东 深圳 · probe+qqwry',
+ },
+ {
+ key: 'ha',
+ labelKey: '家庭实体桥接',
+ detail: '桥接在线 · 18 实体',
+ },
+ ]);
+ });
+
+ test('translates highlight details when a translator is provided', () => {
+ const highlights = buildRouteManagerHubQuickHighlights(
+ buildRouteManagerHubDashboardSnapshot({
+ ai: {
+ model_count: 6,
+ online_ai_nodes: 2,
+ },
+ network: {
+ dns_protection_enabled: true,
+ mihomo_reachable: true,
+ egress_region: 'Shenzhen',
+ egress_source: 'probe',
+ },
+ home_assistant: {
+ reachable: true,
+ entity_count: 18,
+ },
+ primary_node: {
+ hostname: 'desktop-1',
+ },
+ }),
+ (label, params = {}) => `translated:${label}:${JSON.stringify(params)}`,
+ );
+
+ expect(highlights).toEqual([
+ {
+ key: 'ai',
+ labelKey: '家庭算力编队',
+ detail:
+ 'desktop-1 · translated:6 模型:{"count":6} · translated:2 在线 AI 节点:{"count":2}',
+ },
+ {
+ key: 'network',
+ labelKey: '网络代理状态',
+ detail:
+ 'translated:净网已开启:{} · translated:代理可达:{} · Shenzhen · probe',
+ },
+ {
+ key: 'dns',
+ labelKey: 'DNS 广告屏蔽',
+ detail: 'translated:净网已开启:{}',
+ },
+ {
+ key: 'egress',
+ labelKey: '出口网络画像',
+ detail: 'Shenzhen · probe',
+ },
+ {
+ key: 'ha',
+ labelKey: '家庭实体桥接',
+ detail: 'translated:桥接在线:{} · translated:18 实体:{"count":18}',
+ },
+ ]);
+ });
+
+ test('falls back to egress ip and source when geo region is unavailable', () => {
+ const highlights = buildRouteManagerHubQuickHighlights(
+ buildRouteManagerHubDashboardSnapshot({
+ network: {
+ dns_configured: true,
+ dns_protection_enabled: false,
+ mihomo_configured: true,
+ egress_ip: '119.29.29.29',
+ egress_source: 'probe',
+ },
+ }),
+ );
+
+ expect(highlights).toEqual([
+ {
+ key: 'network',
+ labelKey: '网络代理状态',
+ detail: '净网待确认 · 代理待确认 · 119.29.29.29 · probe',
+ },
+ {
+ key: 'dns',
+ labelKey: 'DNS 广告屏蔽',
+ detail: '净网待确认 · DNS 待确认',
+ },
+ {
+ key: 'egress',
+ labelKey: '出口网络画像',
+ detail: '119.29.29.29 · probe',
+ },
+ ]);
+ });
+});
+
+describe('route-manager hub enum formatting', () => {
+ test('translates node status values with route-manager wording', () => {
+ const t = (label) => `translated:${label}`;
+
+ expect(formatRouteManagerHubNodeStatus('online', t)).toBe(
+ 'translated:在线',
+ );
+ expect(formatRouteManagerHubNodeStatus('stale', t)).toBe(
+ 'translated:待确认',
+ );
+ expect(formatRouteManagerHubNodeStatus('sleep', t)).toBe(
+ 'translated:休眠',
+ );
+ expect(formatRouteManagerHubNodeStatus('offline', t)).toBe(
+ 'translated:离线',
+ );
+ expect(formatRouteManagerHubNodeStatus('unexpected', t)).toBe(
+ 'translated:未知状态',
+ );
+ });
+
+ test('translates task status values with route-manager wording', () => {
+ const t = (label) => `translated:${label}`;
+
+ expect(formatRouteManagerHubTaskStatus('pending', t)).toBe(
+ 'translated:待执行',
+ );
+ expect(formatRouteManagerHubTaskStatus('running', t)).toBe(
+ 'translated:进行中',
+ );
+ expect(formatRouteManagerHubTaskStatus('succeeded', t)).toBe(
+ 'translated:已完成',
+ );
+ expect(formatRouteManagerHubTaskStatus('failed', t)).toBe(
+ 'translated:失败',
+ );
+ expect(formatRouteManagerHubTaskStatus('', t)).toBe(
+ 'translated:未知状态',
+ );
+ });
+
+ test('translates alert severity values with route-manager wording', () => {
+ const t = (label) => `translated:${label}`;
+
+ expect(formatRouteManagerHubAlertSeverity('critical', t)).toBe(
+ 'translated:严重',
+ );
+ expect(formatRouteManagerHubAlertSeverity('warning', t)).toBe(
+ 'translated:一般',
+ );
+ expect(formatRouteManagerHubAlertSeverity('notice', t)).toBe(
+ 'translated:未知状态',
+ );
+ });
+
+ test('translates task type values with route-manager wording', () => {
+ const t = (label) => `translated:${label}`;
+
+ expect(formatRouteManagerHubTaskType('shell', t)).toBe(
+ 'translated:Shell 任务',
+ );
+ expect(formatRouteManagerHubTaskType('browser', t)).toBe(
+ 'translated:浏览器自动化',
+ );
+ expect(formatRouteManagerHubTaskType('wake', t)).toBe(
+ 'translated:唤醒任务',
+ );
+ expect(formatRouteManagerHubTaskType('custom', t)).toBe(
+ 'translated:未知类型',
+ );
+ });
+
+ test('translates alert subtitle task types without rewriting other subtitles', () => {
+ const t = (label) => `translated:${label}`;
+
+ expect(formatRouteManagerHubAlertSubtitle('shell · shadow-node-001', t)).toBe(
+ 'translated:Shell 任务 · shadow-node-001',
+ );
+ expect(
+ formatRouteManagerHubAlertSubtitle('browser · shadow-node-002', t),
+ ).toBe('translated:浏览器自动化 · shadow-node-002');
+ expect(formatRouteManagerHubAlertSubtitle('任务 #12 · 失败', t)).toBe(
+ '任务 #12 · 失败',
+ );
+ });
+
+ test('returns distinct tag colors for task statuses including running', () => {
+ expect(getRouteManagerHubTaskStatusColor('pending')).toBe('blue');
+ expect(getRouteManagerHubTaskStatusColor('running')).toBe('orange');
+ expect(getRouteManagerHubTaskStatusColor('succeeded')).toBe('green');
+ expect(getRouteManagerHubTaskStatusColor('failed')).toBe('red');
+ expect(getRouteManagerHubTaskStatusColor('unknown')).toBe('grey');
+ });
+
+ test('prefers alert preview as the primary alert detail text', () => {
+ expect(
+ buildRouteManagerHubAlertDetail(
+ {
+ preview: 'ollama daemon unavailable',
+ subtitle: 'shell · shadow-node-001',
+ metaText: '任务 #2',
+ targetNodeID: 'shadow-node-001',
+ },
+ (label) => `translated:${label}`,
+ ),
+ ).toBe('ollama daemon unavailable');
+ });
+
+ test('falls back to translated subtitle or metadata when alert preview is empty', () => {
+ const t = (label) => `translated:${label}`;
+
+ expect(
+ buildRouteManagerHubAlertDetail(
+ {
+ subtitle: 'browser · shadow-node-001',
+ metaText: '任务 #3',
+ targetNodeID: 'shadow-node-001',
+ },
+ t,
+ ),
+ ).toBe('translated:浏览器自动化 · shadow-node-001');
+ expect(
+ buildRouteManagerHubAlertDetail(
+ {
+ subtitle: '',
+ metaText: '任务 #3',
+ targetNodeID: 'shadow-node-001',
+ },
+ t,
+ ),
+ ).toBe('任务 #3');
+ });
+});
+
+describe('buildRouteManagerHubQuickPanelLinks', () => {
+ test('builds AI, mihomo, dns, egress, and home-assistant quick panel links', () => {
+ const snapshot = buildRouteManagerHubDashboardSnapshot({
+ ai: {
+ model_count: 6,
+ online_ai_nodes: 2,
+ },
+ network: {
+ dns_configured: true,
+ dns_reachable: true,
+ dns_protection_enabled: true,
+ mihomo_configured: true,
+ mihomo_reachable: true,
+ egress_region: '中国 广东 深圳',
+ egress_isp: '腾讯云',
+ egress_source: 'probe+qqwry',
+ },
+ home_assistant: {
+ configured: true,
+ reachable: true,
+ entity_count: 18,
+ },
+ primary_node: {
+ hostname: 'desktop-1',
+ },
+ });
+
+ const links = buildRouteManagerHubQuickPanelLinks(
+ snapshot,
+ (label) => `translated:${label}`,
+ );
+
+ expect(links).toEqual([
+ {
+ key: 'ai',
+ label: 'translated:家庭算力编队',
+ href: '/hub/?view=ai&panel=fleet',
+ description: 'desktop-1 · translated:6 模型 · translated:2 在线 AI 节点',
+ },
+ {
+ key: 'network_mihomo',
+ label: 'translated:网络代理状态',
+ href: '/hub/?view=network&panel=mihomo',
+ description:
+ 'translated:净网已开启 · translated:代理可达 · 中国 广东 深圳 · probe+qqwry',
+ },
+ {
+ key: 'network_dns',
+ label: 'translated:DNS 广告屏蔽',
+ href: '/hub/?view=network&panel=dns',
+ description: 'translated:净网已开启 · translated:DNS 可达',
+ },
+ {
+ key: 'network_egress',
+ label: 'translated:出口网络画像',
+ href: '/hub/?view=network&panel=egress',
+ description: '中国 广东 深圳 · 腾讯云 · probe+qqwry',
+ },
+ {
+ key: 'ha',
+ label: 'translated:家庭实体桥接',
+ href: '/hub/?view=ha&panel=entities',
+ description: 'translated:桥接在线 · translated:18 实体',
+ },
+ ]);
+ });
+
+ test('falls back to dedicated panel copy when dns and egress summaries are unavailable', () => {
+ const links = buildRouteManagerHubQuickPanelLinks(
+ buildRouteManagerHubDashboardSnapshot(),
+ (label) => `translated:${label}`,
+ );
+
+ expect(links).toEqual([
+ {
+ key: 'ai',
+ label: 'translated:家庭算力编队',
+ href: '/hub/?view=ai&panel=fleet',
+ description: 'translated:直达 AI 中心的主算力编队面板',
+ },
+ {
+ key: 'network_mihomo',
+ label: 'translated:网络代理状态',
+ href: '/hub/?view=network&panel=mihomo',
+ description: 'translated:直达网络中心的 Mihomo 代理状态面板',
+ },
+ {
+ key: 'network_dns',
+ label: 'translated:DNS 广告屏蔽',
+ href: '/hub/?view=network&panel=dns',
+ description: 'translated:直达网络中心的 DNS 广告屏蔽面板',
+ },
+ {
+ key: 'network_egress',
+ label: 'translated:出口网络画像',
+ href: '/hub/?view=network&panel=egress',
+ description: 'translated:直达网络中心的出口网络画像面板',
+ },
+ {
+ key: 'ha',
+ label: 'translated:家庭实体桥接',
+ href: '/hub/?view=ha&panel=entities',
+ description: 'translated:直达家庭自动化的实体桥接面板',
+ },
+ ]);
+ });
+});
+
+describe('buildRouteManagerHubFamilySummaryPills', () => {
+ test('builds translated family summary pills', () => {
+ const pills = buildRouteManagerHubFamilySummaryPills(
+ buildRouteManagerHubDashboardSnapshot({
+ network: {
+ dns_protection_enabled: true,
+ },
+ home_assistant: {
+ entity_count: 18,
+ },
+ primary_node: {
+ hostname: 'desktop-1',
+ },
+ }),
+ (label, params = {}) => `translated:${label}:${JSON.stringify(params)}`,
+ );
+
+ expect(pills).toEqual([
+ 'translated:主算力 desktop-1:{"name":"desktop-1"}',
+ 'translated:净网已开启:{}',
+ 'translated:家庭桥接 18 实体:{"count":18}',
+ ]);
+ });
+});
diff --git a/web/src/helpers/hubNavigation.js b/web/src/helpers/hubNavigation.js
new file mode 100644
index 000000000000..0a9b74606dd0
--- /dev/null
+++ b/web/src/helpers/hubNavigation.js
@@ -0,0 +1,130 @@
+export function shouldShowRouteManagerHubEntry(status) {
+ return Boolean(status?.configured);
+}
+
+export function getRouteManagerHubHref() {
+ return '/hub/';
+}
+
+export function getRouteManagerHubSidebarItems(t = (label) => label) {
+ const translate = typeof t === 'function' ? t : (label) => label;
+
+ return [
+ {
+ itemKey: 'hub_overview',
+ text: translate('家域中枢总览'),
+ to: getRouteManagerHubHref(),
+ },
+ {
+ itemKey: 'hub_nodes',
+ text: translate('节点中心'),
+ to: getRouteManagerHubNodesHref(),
+ },
+ {
+ itemKey: 'hub_tasks',
+ text: translate('任务中心'),
+ to: getRouteManagerHubTasksHref(),
+ },
+ {
+ itemKey: 'hub_alerts',
+ text: translate('告警中心'),
+ to: getRouteManagerHubAlertsHref(),
+ },
+ {
+ itemKey: 'hub_ai_fleet',
+ text: translate('家庭算力编队'),
+ to: getRouteManagerHubAIFleetHref(),
+ },
+ {
+ itemKey: 'hub_network_mihomo',
+ text: translate('网络代理状态'),
+ to: getRouteManagerHubNetworkPanelHref('mihomo'),
+ },
+ {
+ itemKey: 'hub_network_dns',
+ text: translate('DNS 广告屏蔽'),
+ to: getRouteManagerHubDNSSecurityHref(),
+ },
+ {
+ itemKey: 'hub_network_egress',
+ text: translate('出口网络画像'),
+ to: getRouteManagerHubEgressHref(),
+ },
+ {
+ itemKey: 'hub_ha_entities',
+ text: translate('家庭实体桥接'),
+ to: getRouteManagerHubHomeAssistantEntitiesHref(),
+ },
+ ];
+}
+
+export function getRouteManagerHubNodesHref() {
+ return '/hub/?view=nodes';
+}
+
+export function getRouteManagerHubAlertsHref() {
+ return '/hub/?view=alerts';
+}
+
+export function getRouteManagerHubTasksHref() {
+ return '/hub/?view=tasks';
+}
+
+export function getRouteManagerHubAIFleetHref() {
+ return '/hub/?view=ai&panel=fleet';
+}
+
+export function getRouteManagerHubNetworkPanelHref(panel = 'mihomo') {
+ const normalizedPanel = typeof panel === 'string' ? panel.trim() : '';
+ return normalizedPanel
+ ? `/hub/?view=network&panel=${encodeURIComponent(normalizedPanel)}`
+ : '/hub/?view=network';
+}
+
+export function getRouteManagerHubDNSSecurityHref() {
+ return getRouteManagerHubNetworkPanelHref('dns');
+}
+
+export function getRouteManagerHubEgressHref() {
+ return getRouteManagerHubNetworkPanelHref('egress');
+}
+
+export function getRouteManagerHubHomeAssistantEntitiesHref() {
+ return '/hub/?view=ha&panel=entities';
+}
+
+export function getRouteManagerHubTaskHref(taskID) {
+ const normalizedTaskID = Number(taskID) || 0;
+ if (normalizedTaskID <= 0) {
+ return getRouteManagerHubHref();
+ }
+
+ return `/hub/?view=tasks&task_id=${normalizedTaskID}`;
+}
+
+export function getRouteManagerHubNodeHref(nodeID) {
+ const normalizedNodeID = typeof nodeID === 'string' ? nodeID.trim() : '';
+ if (!normalizedNodeID) {
+ return getRouteManagerHubNodesHref();
+ }
+
+ return `/hub/?view=nodes&node_id=${encodeURIComponent(normalizedNodeID)}`;
+}
+
+export function getRouteManagerHubAlertHref(alertID) {
+ const normalizedAlertID = typeof alertID === 'string' ? alertID.trim() : '';
+ if (!normalizedAlertID) {
+ return getRouteManagerHubAlertsHref();
+ }
+
+ return `/hub/?view=alerts&alert_id=${encodeURIComponent(normalizedAlertID)}`;
+}
+
+export function getRouteManagerHubScheduleHref(scheduleID) {
+ const normalizedScheduleID = Number(scheduleID) || 0;
+ if (normalizedScheduleID <= 0) {
+ return getRouteManagerHubTasksHref();
+ }
+
+ return `/hub/?view=tasks&schedule_id=${normalizedScheduleID}`;
+}
diff --git a/web/src/helpers/hubNavigation.test.mjs b/web/src/helpers/hubNavigation.test.mjs
new file mode 100644
index 000000000000..ee2a3934cf3f
--- /dev/null
+++ b/web/src/helpers/hubNavigation.test.mjs
@@ -0,0 +1,186 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ getRouteManagerHubAIFleetHref,
+ getRouteManagerHubAlertHref,
+ getRouteManagerHubAlertsHref,
+ getRouteManagerHubDNSSecurityHref,
+ getRouteManagerHubEgressHref,
+ getRouteManagerHubHref,
+ getRouteManagerHubHomeAssistantEntitiesHref,
+ getRouteManagerHubNodeHref,
+ getRouteManagerHubNodesHref,
+ getRouteManagerHubNetworkPanelHref,
+ getRouteManagerHubScheduleHref,
+ getRouteManagerHubSidebarItems,
+ getRouteManagerHubTaskHref,
+ shouldShowRouteManagerHubEntry,
+} from './hubNavigation.js';
+
+describe('shouldShowRouteManagerHubEntry', () => {
+ test('shows the hub entry when the hub is configured', () => {
+ expect(
+ shouldShowRouteManagerHubEntry({
+ configured: true,
+ reachable: true,
+ }),
+ ).toBe(true);
+ });
+
+ test('hides the hub entry when the hub is not configured', () => {
+ expect(
+ shouldShowRouteManagerHubEntry({
+ configured: false,
+ reachable: false,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe('getRouteManagerHubHref', () => {
+ test('returns the same-origin hub entry path', () => {
+ expect(getRouteManagerHubHref()).toBe('/hub/');
+ });
+});
+
+describe('getRouteManagerHubSidebarItems', () => {
+ test('returns the hub sidebar submenu entries in a stable order', () => {
+ const t = (label) => `translated:${label}`;
+
+ expect(getRouteManagerHubSidebarItems(t)).toEqual([
+ {
+ itemKey: 'hub_overview',
+ text: 'translated:家域中枢总览',
+ to: '/hub/',
+ },
+ {
+ itemKey: 'hub_nodes',
+ text: 'translated:节点中心',
+ to: '/hub/?view=nodes',
+ },
+ {
+ itemKey: 'hub_tasks',
+ text: 'translated:任务中心',
+ to: '/hub/?view=tasks',
+ },
+ {
+ itemKey: 'hub_alerts',
+ text: 'translated:告警中心',
+ to: '/hub/?view=alerts',
+ },
+ {
+ itemKey: 'hub_ai_fleet',
+ text: 'translated:家庭算力编队',
+ to: '/hub/?view=ai&panel=fleet',
+ },
+ {
+ itemKey: 'hub_network_mihomo',
+ text: 'translated:网络代理状态',
+ to: '/hub/?view=network&panel=mihomo',
+ },
+ {
+ itemKey: 'hub_network_dns',
+ text: 'translated:DNS 广告屏蔽',
+ to: '/hub/?view=network&panel=dns',
+ },
+ {
+ itemKey: 'hub_network_egress',
+ text: 'translated:出口网络画像',
+ to: '/hub/?view=network&panel=egress',
+ },
+ {
+ itemKey: 'hub_ha_entities',
+ text: 'translated:家庭实体桥接',
+ to: '/hub/?view=ha&panel=entities',
+ },
+ ]);
+ });
+});
+
+describe('getRouteManagerHubNodesHref', () => {
+ test('returns the hub nodes entry path', () => {
+ expect(getRouteManagerHubNodesHref()).toBe('/hub/?view=nodes');
+ });
+});
+
+describe('getRouteManagerHubAlertsHref', () => {
+ test('returns the hub alerts entry path', () => {
+ expect(getRouteManagerHubAlertsHref()).toBe('/hub/?view=alerts');
+ });
+});
+
+describe('panel deep links', () => {
+ test('builds the ai fleet deep link', () => {
+ expect(getRouteManagerHubAIFleetHref()).toBe('/hub/?view=ai&panel=fleet');
+ });
+
+ test('builds the network mihomo panel deep link', () => {
+ expect(getRouteManagerHubNetworkPanelHref('mihomo')).toBe(
+ '/hub/?view=network&panel=mihomo',
+ );
+ });
+
+ test('builds the dns ad blocking deep link', () => {
+ expect(getRouteManagerHubDNSSecurityHref()).toBe(
+ '/hub/?view=network&panel=dns',
+ );
+ });
+
+ test('builds the egress identity deep link', () => {
+ expect(getRouteManagerHubEgressHref()).toBe(
+ '/hub/?view=network&panel=egress',
+ );
+ });
+
+ test('builds the home assistant entities deep link', () => {
+ expect(getRouteManagerHubHomeAssistantEntitiesHref()).toBe(
+ '/hub/?view=ha&panel=entities',
+ );
+ });
+});
+
+describe('getRouteManagerHubTaskHref', () => {
+ test('builds a hub task detail deep link', () => {
+ expect(getRouteManagerHubTaskHref(19)).toBe('/hub/?view=tasks&task_id=19');
+ });
+
+ test('falls back to the hub home when task id is invalid', () => {
+ expect(getRouteManagerHubTaskHref(0)).toBe('/hub/');
+ });
+});
+
+describe('getRouteManagerHubScheduleHref', () => {
+ test('builds a hub schedule deep link', () => {
+ expect(getRouteManagerHubScheduleHref(9)).toBe(
+ '/hub/?view=tasks&schedule_id=9',
+ );
+ });
+
+ test('falls back to the hub tasks page when schedule id is invalid', () => {
+ expect(getRouteManagerHubScheduleHref(0)).toBe('/hub/?view=tasks');
+ });
+});
+
+describe('getRouteManagerHubNodeHref', () => {
+ test('builds a hub node deep link', () => {
+ expect(getRouteManagerHubNodeHref('shadow-node-001')).toBe(
+ '/hub/?view=nodes&node_id=shadow-node-001',
+ );
+ });
+
+ test('falls back to the hub node center when node id is empty', () => {
+ expect(getRouteManagerHubNodeHref('')).toBe('/hub/?view=nodes');
+ });
+});
+
+describe('getRouteManagerHubAlertHref', () => {
+ test('builds a hub alert deep link', () => {
+ expect(getRouteManagerHubAlertHref('schedule:9:12')).toBe(
+ '/hub/?view=alerts&alert_id=schedule%3A9%3A12',
+ );
+ });
+
+ test('falls back to the hub alert center when alert id is empty', () => {
+ expect(getRouteManagerHubAlertHref('')).toBe('/hub/?view=alerts');
+ });
+});
diff --git a/web/src/helpers/hubStatus.js b/web/src/helpers/hubStatus.js
new file mode 100644
index 000000000000..eb448f30887c
--- /dev/null
+++ b/web/src/helpers/hubStatus.js
@@ -0,0 +1,121 @@
+function interpolateMessage(message, params = {}) {
+ return Object.entries(params).reduce(
+ (result, [key, value]) => result.replaceAll(`{{${key}}}`, String(value)),
+ message,
+ );
+}
+
+function translateMessage(t, key, params = {}) {
+ if (typeof t === 'function') {
+ return interpolateMessage(t(key, params), params);
+ }
+
+ return interpolateMessage(key, params);
+}
+
+function withBannerSource(banner, status) {
+ if (status?.source) {
+ return {
+ ...banner,
+ source: status.source,
+ };
+ }
+
+ return banner;
+}
+
+export function formatRouteManagerHubStatus(status, t) {
+ if (!status?.configured) {
+ return withBannerSource(
+ {
+ tone: 'warning',
+ message: translateMessage(t, 'Route Manager 地址未配置'),
+ },
+ status,
+ );
+ }
+
+ if (status.reachable) {
+ return withBannerSource(
+ {
+ tone: 'success',
+ message: translateMessage(
+ t,
+ '家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢',
+ ),
+ },
+ status,
+ );
+ }
+
+ if (typeof status?.upstream_status === 'number' && status.upstream_status > 0) {
+ return withBannerSource(
+ {
+ tone: 'danger',
+ message: translateMessage(
+ t,
+ '家庭管理控制系统暂时不可达,上游状态 {{upstreamStatus}}',
+ { upstreamStatus: status.upstream_status },
+ ),
+ },
+ status,
+ );
+ }
+
+ return withBannerSource(
+ {
+ tone: 'danger',
+ message: translateMessage(
+ t,
+ '家庭管理控制系统暂时不可达,请检查地址和 shadow 服务',
+ ),
+ },
+ status,
+ );
+}
+
+export function getRouteManagerHubStatusBanner(
+ statusSnapshot,
+ t,
+ fallbackStatusSnapshot = null,
+) {
+ const resolvedStatusSnapshot =
+ statusSnapshot && typeof statusSnapshot === 'object'
+ ? statusSnapshot
+ : fallbackStatusSnapshot && typeof fallbackStatusSnapshot === 'object'
+ ? fallbackStatusSnapshot
+ : null;
+
+ if (
+ !resolvedStatusSnapshot ||
+ !resolvedStatusSnapshot.hub_status ||
+ typeof resolvedStatusSnapshot.hub_status !== 'object'
+ ) {
+ return null;
+ }
+
+ return formatRouteManagerHubStatus(resolvedStatusSnapshot.hub_status, t);
+}
+
+export function mergeRouteManagerHubStatusBanner(currentBanner, nextBanner) {
+ if (nextBanner) {
+ if (
+ currentBanner?.source === 'manual-check' &&
+ currentBanner?.tone === 'danger' &&
+ nextBanner?.source === 'sync-fallback'
+ ) {
+ return currentBanner;
+ }
+
+ return nextBanner;
+ }
+
+ if (
+ currentBanner?.source === 'manual-check' &&
+ currentBanner?.tone === 'danger'
+ ) {
+ return currentBanner;
+ }
+
+ return null;
+}
diff --git a/web/src/helpers/hubStatus.test.mjs b/web/src/helpers/hubStatus.test.mjs
new file mode 100644
index 000000000000..9244a9d0706d
--- /dev/null
+++ b/web/src/helpers/hubStatus.test.mjs
@@ -0,0 +1,227 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ formatRouteManagerHubStatus,
+ getRouteManagerHubStatusBanner,
+ mergeRouteManagerHubStatusBanner,
+} from './hubStatus.js';
+
+describe('formatRouteManagerHubStatus', () => {
+ test('formats unconfigured hub state', () => {
+ const t = (key) => `translated:${key}`;
+
+ expect(
+ formatRouteManagerHubStatus(
+ { configured: false, reachable: false },
+ t,
+ ),
+ ).toEqual({
+ tone: 'warning',
+ message: 'translated:Route Manager 地址未配置',
+ });
+ });
+
+ test('formats reachable hub state', () => {
+ const t = (key) => `translated:${key}`;
+
+ expect(
+ formatRouteManagerHubStatus({
+ configured: true,
+ reachable: true,
+ service: 'route-manager',
+ },
+ t,
+ ),
+ ).toEqual({
+ tone: 'success',
+ message: 'translated:家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢',
+ });
+ });
+
+ test('formats unreachable hub state with upstream status', () => {
+ const t = (key, params) => `translated:${key}:${params?.upstreamStatus ?? ''}`;
+
+ expect(
+ formatRouteManagerHubStatus({
+ configured: true,
+ reachable: false,
+ upstream_status: 502,
+ },
+ t,
+ ),
+ ).toEqual({
+ tone: 'danger',
+ message: 'translated:家庭管理控制系统暂时不可达,上游状态 502:502',
+ });
+ });
+
+ test('preserves source metadata when formatting hub state', () => {
+ expect(
+ formatRouteManagerHubStatus({
+ configured: true,
+ reachable: false,
+ source: 'sync-fallback',
+ }),
+ ).toEqual({
+ tone: 'danger',
+ message: '家庭管理控制系统暂时不可达,请检查地址和 shadow 服务',
+ source: 'sync-fallback',
+ });
+ });
+
+ test('falls back to local strings when no translator is provided', () => {
+ expect(
+ formatRouteManagerHubStatus({
+ configured: true,
+ reachable: false,
+ upstream_status: 502,
+ }),
+ ).toEqual({
+ tone: 'danger',
+ message: '家庭管理控制系统暂时不可达,上游状态 502',
+ });
+ });
+
+ test('interpolates placeholders when translator returns the key unchanged', () => {
+ const t = (key) => key;
+
+ expect(
+ formatRouteManagerHubStatus(
+ {
+ configured: true,
+ reachable: false,
+ upstream_status: 502,
+ },
+ t,
+ ),
+ ).toEqual({
+ tone: 'danger',
+ message: '家庭管理控制系统暂时不可达,上游状态 502',
+ });
+ });
+});
+
+describe('getRouteManagerHubStatusBanner', () => {
+ test('returns null before the status snapshot is loaded', () => {
+ expect(getRouteManagerHubStatusBanner(null)).toBeNull();
+ });
+
+ test('returns null when the loaded status snapshot has no hub status yet', () => {
+ expect(
+ getRouteManagerHubStatusBanner({
+ system_name: 'AI Gateway',
+ }),
+ ).toBeNull();
+ });
+
+ test('returns null when the loaded status snapshot has a null hub status', () => {
+ expect(
+ getRouteManagerHubStatusBanner({
+ system_name: 'AI Gateway',
+ hub_status: null,
+ }),
+ ).toBeNull();
+ });
+
+ test('falls back to a saved status snapshot when the live status is not ready', () => {
+ const t = (key) => `translated:${key}`;
+
+ expect(
+ getRouteManagerHubStatusBanner(
+ null,
+ t,
+ {
+ system_name: 'AI Gateway',
+ hub_status: {
+ configured: true,
+ reachable: true,
+ service: 'route-manager',
+ },
+ },
+ ),
+ ).toEqual({
+ tone: 'success',
+ message: 'translated:家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢',
+ });
+ });
+
+ test('formats the current hub state from a loaded status snapshot', () => {
+ const t = (key) => `translated:${key}`;
+
+ expect(
+ getRouteManagerHubStatusBanner(
+ {
+ system_name: 'AI Gateway',
+ hub_status: {
+ configured: true,
+ reachable: true,
+ service: 'route-manager',
+ },
+ },
+ t,
+ ),
+ ).toEqual({
+ tone: 'success',
+ message: 'translated:家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢',
+ });
+ });
+});
+
+describe('mergeRouteManagerHubStatusBanner', () => {
+ test('keeps a manual error banner when the synced status snapshot has cleared hub status', () => {
+ expect(
+ mergeRouteManagerHubStatusBanner(
+ {
+ tone: 'danger',
+ message: 'translated:Route Manager 状态检查失败',
+ source: 'manual-check',
+ },
+ null,
+ ),
+ ).toEqual({
+ tone: 'danger',
+ message: 'translated:Route Manager 状态检查失败',
+ source: 'manual-check',
+ });
+ });
+
+ test('replaces the manual error banner when a new snapshot banner is available', () => {
+ expect(
+ mergeRouteManagerHubStatusBanner(
+ {
+ tone: 'danger',
+ message: 'translated:Route Manager 状态检查失败',
+ source: 'manual-check',
+ },
+ {
+ tone: 'success',
+ message: 'translated:家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢',
+ },
+ ),
+ ).toEqual({
+ tone: 'success',
+ message: 'translated:家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢',
+ });
+ });
+
+ test('keeps the manual error banner when the synced fallback banner only reflects the cleared cache state', () => {
+ expect(
+ mergeRouteManagerHubStatusBanner(
+ {
+ tone: 'danger',
+ message: 'translated:Route Manager 状态检查失败',
+ source: 'manual-check',
+ },
+ {
+ tone: 'danger',
+ message: 'translated:家庭管理控制系统暂时不可达,请检查地址和 shadow 服务',
+ source: 'sync-fallback',
+ },
+ ),
+ ).toEqual({
+ tone: 'danger',
+ message: 'translated:Route Manager 状态检查失败',
+ source: 'manual-check',
+ });
+ });
+});
diff --git a/web/src/helpers/hubStatusState.js b/web/src/helpers/hubStatusState.js
new file mode 100644
index 000000000000..98c3de2cb796
--- /dev/null
+++ b/web/src/helpers/hubStatusState.js
@@ -0,0 +1,65 @@
+export function buildStatusSnapshotWithHubStatus(
+ currentStatus = null,
+ nextHubStatus = null,
+ fallbackStatus = null,
+) {
+ const baseStatus =
+ currentStatus && typeof currentStatus === 'object'
+ ? currentStatus
+ : fallbackStatus && typeof fallbackStatus === 'object'
+ ? fallbackStatus
+ : null;
+
+ if (!baseStatus) {
+ return null;
+ }
+
+ return {
+ ...baseStatus,
+ hub_status: nextHubStatus,
+ };
+}
+
+export function buildStatusSnapshotWithClearedHubStatus(
+ currentStatus = null,
+ fallbackStatus = null,
+) {
+ return buildStatusSnapshotWithHubStatus(currentStatus, null, fallbackStatus);
+}
+
+export function buildRouteManagerHubCheckFallbackStatus({
+ currentStatus = null,
+ routeManagerURL = '',
+ pendingRouteManagerURL = '',
+ fallbackStatus = null,
+ respectExplicitEmptyRouteManagerURL = false,
+} = {}) {
+ const normalizedRouteManagerURL =
+ typeof routeManagerURL === 'string' ? routeManagerURL.trim() : '';
+ const normalizedPendingRouteManagerURL =
+ typeof pendingRouteManagerURL === 'string'
+ ? pendingRouteManagerURL.trim()
+ : '';
+
+ if (
+ respectExplicitEmptyRouteManagerURL &&
+ normalizedRouteManagerURL.length === 0 &&
+ normalizedPendingRouteManagerURL.length === 0
+ ) {
+ return {
+ configured: false,
+ reachable: false,
+ source: 'sync-fallback',
+ };
+ }
+
+ return {
+ configured:
+ normalizedRouteManagerURL.length > 0 ||
+ normalizedPendingRouteManagerURL.length > 0 ||
+ Boolean(currentStatus?.hub_status?.configured) ||
+ Boolean(fallbackStatus?.hub_status?.configured),
+ reachable: false,
+ source: 'sync-fallback',
+ };
+}
diff --git a/web/src/helpers/hubStatusState.test.mjs b/web/src/helpers/hubStatusState.test.mjs
new file mode 100644
index 000000000000..b81685e9a114
--- /dev/null
+++ b/web/src/helpers/hubStatusState.test.mjs
@@ -0,0 +1,168 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ buildRouteManagerHubCheckFallbackStatus,
+ buildStatusSnapshotWithClearedHubStatus,
+ buildStatusSnapshotWithHubStatus,
+} from './hubStatusState.js';
+
+describe('buildStatusSnapshotWithHubStatus', () => {
+ test('merges the latest hub status into an existing status snapshot', () => {
+ const currentStatus = {
+ system_name: 'AI Gateway',
+ hub_status: {
+ configured: false,
+ reachable: false,
+ },
+ docs_link: 'https://docs.example.com',
+ };
+ const nextHubStatus = {
+ configured: true,
+ reachable: true,
+ message: 'route-manager reachable',
+ upstream_status: 200,
+ };
+
+ expect(
+ buildStatusSnapshotWithHubStatus(currentStatus, nextHubStatus),
+ ).toEqual({
+ system_name: 'AI Gateway',
+ hub_status: nextHubStatus,
+ docs_link: 'https://docs.example.com',
+ });
+ expect(currentStatus.hub_status.configured).toBe(false);
+ });
+
+ test('returns null when there is no base status snapshot to update', () => {
+ expect(buildStatusSnapshotWithHubStatus(null, { configured: true })).toBe(
+ null,
+ );
+ });
+
+ test('falls back to a saved status snapshot when context status is not ready', () => {
+ const savedStatus = {
+ system_name: 'AI Gateway',
+ footer_html: 'footer
',
+ hub_status: {
+ configured: false,
+ reachable: false,
+ },
+ };
+ const nextHubStatus = {
+ configured: true,
+ reachable: true,
+ message: 'route-manager reachable',
+ };
+
+ expect(
+ buildStatusSnapshotWithHubStatus(null, nextHubStatus, savedStatus),
+ ).toEqual({
+ system_name: 'AI Gateway',
+ footer_html: 'footer
',
+ hub_status: nextHubStatus,
+ });
+ });
+
+ test('clears a saved hub status snapshot after a failed hub check', () => {
+ const savedStatus = {
+ system_name: 'AI Gateway',
+ footer_html: 'footer
',
+ hub_status: {
+ configured: true,
+ reachable: true,
+ service: 'route-manager',
+ },
+ };
+
+ expect(
+ buildStatusSnapshotWithClearedHubStatus(null, savedStatus),
+ ).toEqual({
+ system_name: 'AI Gateway',
+ footer_html: 'footer
',
+ hub_status: null,
+ });
+ });
+});
+
+describe('buildRouteManagerHubCheckFallbackStatus', () => {
+ test('marks the hub as configured but unreachable when Route Manager URL is present', () => {
+ expect(
+ buildRouteManagerHubCheckFallbackStatus({
+ routeManagerURL: 'http://route-manager-shadow:19080',
+ }),
+ ).toEqual({
+ configured: true,
+ reachable: false,
+ source: 'sync-fallback',
+ });
+ });
+
+ test('treats a just-saved Route Manager URL as configured before persisted state catches up', () => {
+ expect(
+ buildRouteManagerHubCheckFallbackStatus({
+ routeManagerURL: '',
+ pendingRouteManagerURL: 'http://route-manager-shadow:19080',
+ currentStatus: {
+ hub_status: {
+ configured: false,
+ reachable: false,
+ },
+ },
+ fallbackStatus: {
+ hub_status: {
+ configured: false,
+ reachable: false,
+ },
+ },
+ }),
+ ).toEqual({
+ configured: true,
+ reachable: false,
+ source: 'sync-fallback',
+ });
+ });
+
+ test('falls back to the saved hub configuration when the input URL is not ready', () => {
+ expect(
+ buildRouteManagerHubCheckFallbackStatus({
+ currentStatus: null,
+ fallbackStatus: {
+ hub_status: {
+ configured: true,
+ reachable: true,
+ },
+ },
+ }),
+ ).toEqual({
+ configured: true,
+ reachable: false,
+ source: 'sync-fallback',
+ });
+ });
+
+ test('treats an explicitly cleared Route Manager URL as unconfigured', () => {
+ expect(
+ buildRouteManagerHubCheckFallbackStatus({
+ routeManagerURL: '',
+ pendingRouteManagerURL: '',
+ respectExplicitEmptyRouteManagerURL: true,
+ currentStatus: {
+ hub_status: {
+ configured: true,
+ reachable: true,
+ },
+ },
+ fallbackStatus: {
+ hub_status: {
+ configured: true,
+ reachable: true,
+ },
+ },
+ }),
+ ).toEqual({
+ configured: false,
+ reachable: false,
+ source: 'sync-fallback',
+ });
+ });
+});
diff --git a/web/src/helpers/iframeContext.js b/web/src/helpers/iframeContext.js
new file mode 100644
index 000000000000..b0bc4238fd72
--- /dev/null
+++ b/web/src/helpers/iframeContext.js
@@ -0,0 +1,28 @@
+export function postIframeContext(iframe, context = {}) {
+ const iframeWindow = iframe?.contentWindow;
+ if (!iframeWindow || typeof iframeWindow.postMessage !== 'function') {
+ return false;
+ }
+
+ let posted = false;
+
+ if (Object.prototype.hasOwnProperty.call(context, 'themeMode')) {
+ iframeWindow.postMessage({ themeMode: context.themeMode ?? '' }, '*');
+ posted = true;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(context, 'lang')) {
+ iframeWindow.postMessage({ lang: context.lang ?? '' }, '*');
+ posted = true;
+ }
+
+ return posted;
+}
+
+export function postThemeModeToIframe(iframe, themeMode) {
+ return postIframeContext(iframe, { themeMode });
+}
+
+export function postLanguageToIframe(iframe, lang) {
+ return postIframeContext(iframe, { lang });
+}
diff --git a/web/src/helpers/iframeContext.test.mjs b/web/src/helpers/iframeContext.test.mjs
new file mode 100644
index 000000000000..b91e19bbe8c9
--- /dev/null
+++ b/web/src/helpers/iframeContext.test.mjs
@@ -0,0 +1,108 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ postIframeContext,
+ postLanguageToIframe,
+ postThemeModeToIframe,
+} from './iframeContext.js';
+
+describe('postIframeContext', () => {
+ test('posts only the context fields that are provided', () => {
+ const calls = [];
+ const iframe = {
+ contentWindow: {
+ postMessage(payload, targetOrigin) {
+ calls.push({ payload, targetOrigin });
+ },
+ },
+ };
+
+ expect(
+ postIframeContext(iframe, {
+ themeMode: 'dark',
+ lang: 'en',
+ }),
+ ).toBe(true);
+
+ expect(calls).toEqual([
+ {
+ payload: { themeMode: 'dark' },
+ targetOrigin: '*',
+ },
+ {
+ payload: { lang: 'en' },
+ targetOrigin: '*',
+ },
+ ]);
+ });
+
+ test('skips missing context fields', () => {
+ const calls = [];
+ const iframe = {
+ contentWindow: {
+ postMessage(payload, targetOrigin) {
+ calls.push({ payload, targetOrigin });
+ },
+ },
+ };
+
+ expect(
+ postIframeContext(iframe, {
+ themeMode: 'light',
+ }),
+ ).toBe(true);
+
+ expect(calls).toEqual([
+ {
+ payload: { themeMode: 'light' },
+ targetOrigin: '*',
+ },
+ ]);
+ });
+
+ test('returns false when the iframe window is unavailable', () => {
+ expect(postIframeContext(null, { lang: 'zh-CN' })).toBe(false);
+ });
+});
+
+describe('theme and language helpers', () => {
+ test('posts only theme mode when requested', () => {
+ const calls = [];
+ const iframe = {
+ contentWindow: {
+ postMessage(payload, targetOrigin) {
+ calls.push({ payload, targetOrigin });
+ },
+ },
+ };
+
+ expect(postThemeModeToIframe(iframe, 'dark')).toBe(true);
+
+ expect(calls).toEqual([
+ {
+ payload: { themeMode: 'dark' },
+ targetOrigin: '*',
+ },
+ ]);
+ });
+
+ test('posts only language when requested', () => {
+ const calls = [];
+ const iframe = {
+ contentWindow: {
+ postMessage(payload, targetOrigin) {
+ calls.push({ payload, targetOrigin });
+ },
+ },
+ };
+
+ expect(postLanguageToIframe(iframe, 'fr')).toBe(true);
+
+ expect(calls).toEqual([
+ {
+ payload: { lang: 'fr' },
+ targetOrigin: '*',
+ },
+ ]);
+ });
+});
diff --git a/web/src/helpers/index.js b/web/src/helpers/index.js
index a86c3bca5996..ca63211bd9b4 100644
--- a/web/src/helpers/index.js
+++ b/web/src/helpers/index.js
@@ -29,4 +29,9 @@ export * from './token';
export * from './boolean';
export * from './dashboard';
export * from './passkey';
+export * from './optionUpdate';
export * from './statusCodeRules';
+export * from './hubStatus';
+export * from './hubStatusState';
+export * from './hubNavigation';
+export * from './hubDashboard';
diff --git a/web/src/helpers/optionUpdate.js b/web/src/helpers/optionUpdate.js
new file mode 100644
index 000000000000..eaa2af816ace
--- /dev/null
+++ b/web/src/helpers/optionUpdate.js
@@ -0,0 +1,27 @@
+export function ensureOptionUpdateSucceeded(
+ responseData = {},
+ fallbackMessage = '',
+) {
+ const { success, message } = responseData || {};
+
+ if (success) {
+ return;
+ }
+
+ const normalizedMessage =
+ typeof message === 'string' && message.trim() !== ''
+ ? message
+ : fallbackMessage;
+
+ throw new Error(normalizedMessage);
+}
+
+export function getOptionUpdateErrorMessage(
+ error,
+ fallbackMessage = '',
+) {
+ const message =
+ typeof error?.message === 'string' ? error.message.trim() : '';
+
+ return message || fallbackMessage;
+}
diff --git a/web/src/helpers/optionUpdate.test.mjs b/web/src/helpers/optionUpdate.test.mjs
new file mode 100644
index 000000000000..70cf2f13d5a7
--- /dev/null
+++ b/web/src/helpers/optionUpdate.test.mjs
@@ -0,0 +1,60 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ ensureOptionUpdateSucceeded,
+ getOptionUpdateErrorMessage,
+} from './optionUpdate.js';
+
+describe('ensureOptionUpdateSucceeded', () => {
+ test('throws the backend message when the option update response is unsuccessful', () => {
+ expect(() =>
+ ensureOptionUpdateSucceeded({
+ success: false,
+ message: 'Route Manager URL is invalid',
+ }),
+ ).toThrow('Route Manager URL is invalid');
+ });
+
+ test('throws the fallback message when the backend message is empty', () => {
+ expect(() =>
+ ensureOptionUpdateSucceeded(
+ {
+ success: false,
+ message: '',
+ },
+ 'Option update failed',
+ ),
+ ).toThrow('Option update failed');
+ });
+
+ test('returns without throwing when the option update succeeds', () => {
+ expect(() =>
+ ensureOptionUpdateSucceeded({
+ success: true,
+ message: 'ok',
+ }),
+ ).not.toThrow();
+ });
+});
+
+describe('getOptionUpdateErrorMessage', () => {
+ test('prefers the thrown error message when available', () => {
+ expect(
+ getOptionUpdateErrorMessage(
+ new Error('Route Manager URL is invalid'),
+ 'Option update failed',
+ ),
+ ).toBe('Route Manager URL is invalid');
+ });
+
+ test('falls back to the provided message when the error has no message', () => {
+ expect(
+ getOptionUpdateErrorMessage(
+ {
+ message: '',
+ },
+ 'Option update failed',
+ ),
+ ).toBe('Option update failed');
+ });
+});
diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js
index cd74ada20280..59daf9a5e7b7 100644
--- a/web/src/hooks/common/useSidebar.js
+++ b/web/src/hooks/common/useSidebar.js
@@ -38,6 +38,7 @@ export const DEFAULT_ADMIN_CONFIG = {
log: true,
midjourney: true,
task: true,
+ hub: true,
},
personal: {
enabled: true,
diff --git a/web/src/hooks/dashboard/useDashboardData.js b/web/src/hooks/dashboard/useDashboardData.js
index b51bcc40c5c2..1c3f3d1f8be1 100644
--- a/web/src/hooks/dashboard/useDashboardData.js
+++ b/web/src/hooks/dashboard/useDashboardData.js
@@ -20,12 +20,54 @@ For commercial licensing, please contact support@quantumnous.com
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { API, isAdmin, showError, timestamp2string } from '../../helpers';
+import {
+ API,
+ buildRouteManagerHubDashboardSnapshot,
+ isAdmin,
+ showError,
+ timestamp2string,
+} from '../../helpers';
import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard';
import { TIME_OPTIONS } from '../../constants/dashboard.constants';
import { useIsMobile } from '../common/useIsMobile';
import { useMinimumLoadingTime } from '../common/useMinimumLoadingTime';
+const createEmptyHubSummary = () => ({
+ onlineNodes: 0,
+ busyNodes: 0,
+ pendingTasks: 0,
+ activeSchedules: 0,
+ criticalAlerts: 0,
+ unacknowledgedAlerts: 0,
+ ai: {
+ modelCount: 0,
+ aiCapableNodes: 0,
+ onlineAINodes: 0,
+ },
+ network: {
+ mihomoConfigured: false,
+ mihomoReachable: false,
+ dnsConfigured: false,
+ dnsReachable: false,
+ dnsProtectionEnabled: false,
+ egressIP: '',
+ egressRegion: '',
+ egressISP: '',
+ egressSource: '',
+ },
+ homeAssistant: {
+ configured: false,
+ reachable: false,
+ entityCount: 0,
+ },
+ primaryNode: {
+ nodeID: '',
+ hostname: '',
+ status: '',
+ ipAddress: '',
+ },
+});
+
export const useDashboardData = (userState, userDispatch, statusState) => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -80,6 +122,13 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
const [uptimeData, setUptimeData] = useState([]);
const [uptimeLoading, setUptimeLoading] = useState(false);
const [activeUptimeTab, setActiveUptimeTab] = useState('');
+ const [hubNodes, setHubNodes] = useState([]);
+ const [hubSchedules, setHubSchedules] = useState([]);
+ const [hubTasks, setHubTasks] = useState([]);
+ const [hubAlerts, setHubAlerts] = useState([]);
+ const [hubSummary, setHubSummary] = useState(() => createEmptyHubSummary());
+ const [hubLoading, setHubLoading] = useState(false);
+ const [hubError, setHubError] = useState('');
// ========== 常量 ==========
const now = new Date();
@@ -91,6 +140,8 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
statusState?.status?.announcements_enabled ?? true;
const faqEnabled = statusState?.status?.faq_enabled ?? true;
const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true;
+ const hubConfigured = statusState?.status?.hub_status?.configured ?? false;
+ const hubReachable = statusState?.status?.hub_status?.reachable ?? false;
const hasApiInfoPanel = apiInfoEnabled;
const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled;
@@ -115,9 +166,34 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
const avgTPM = isNaN(consumeTokens / timeDiff)
? '0'
: (consumeTokens / timeDiff).toFixed(3);
-
- return { avgRPM, avgTPM, timeDiff };
- }, [times, consumeTokens, inputs.start_timestamp, inputs.end_timestamp]);
+ const totalPromptTokens = quotaData.reduce(
+ (sum, item) => sum + Number(item.prompt_token_used || 0),
+ 0,
+ );
+ const totalCachedTokens = quotaData.reduce(
+ (sum, item) => sum + Number(item.cached_token_used || 0),
+ 0,
+ );
+ const cacheHitRate =
+ totalPromptTokens > 0
+ ? `${((totalCachedTokens / totalPromptTokens) * 100).toFixed(2)}%`
+ : '0.00%';
+
+ return {
+ avgRPM,
+ avgTPM,
+ timeDiff,
+ totalPromptTokens,
+ totalCachedTokens,
+ cacheHitRate,
+ };
+ }, [
+ times,
+ consumeTokens,
+ quotaData,
+ inputs.start_timestamp,
+ inputs.end_timestamp,
+ ]);
const getGreeting = useMemo(() => {
const hours = new Date().getHours();
@@ -213,6 +289,85 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
}
}, [activeUptimeTab]);
+ const loadHubData = useCallback(async () => {
+ if (!hubConfigured || !hubReachable) {
+ setHubNodes([]);
+ setHubSchedules([]);
+ setHubTasks([]);
+ setHubAlerts([]);
+ setHubSummary(createEmptyHubSummary());
+ setHubError('');
+ return;
+ }
+
+ setHubLoading(true);
+ try {
+ const summaryRes = await API.get('/hub/api/dashboard/summary', {
+ skipErrorHandler: true,
+ });
+
+ if (summaryRes.data?.success) {
+ const snapshot = buildRouteManagerHubDashboardSnapshot(
+ summaryRes.data?.data || {},
+ );
+ setHubSummary({
+ onlineNodes: snapshot.onlineNodes,
+ busyNodes: snapshot.busyNodes,
+ pendingTasks: snapshot.pendingTasks,
+ activeSchedules: snapshot.activeSchedules,
+ criticalAlerts: snapshot.criticalAlerts,
+ unacknowledgedAlerts: snapshot.unacknowledgedAlerts,
+ ai: snapshot.ai,
+ network: snapshot.network,
+ homeAssistant: snapshot.homeAssistant,
+ primaryNode: snapshot.primaryNode,
+ });
+ setHubNodes(
+ Array.isArray(summaryRes.data?.data?.nodes)
+ ? summaryRes.data.data.nodes
+ : [],
+ );
+ setHubSchedules(
+ Array.isArray(summaryRes.data?.data?.schedules)
+ ? summaryRes.data.data.schedules
+ : [],
+ );
+ setHubTasks(
+ Array.isArray(summaryRes.data?.data?.tasks)
+ ? summaryRes.data.data.tasks
+ : [],
+ );
+ setHubAlerts(
+ Array.isArray(summaryRes.data?.data?.alerts)
+ ? summaryRes.data.data.alerts
+ : [],
+ );
+ } else {
+ setHubSummary(createEmptyHubSummary());
+ setHubNodes([]);
+ setHubSchedules([]);
+ setHubTasks([]);
+ setHubAlerts([]);
+ setHubError(
+ summaryRes.data?.message || t('家域中枢摘要加载失败,请稍后重试'),
+ );
+ return;
+ }
+
+ setHubError('');
+ } catch (error) {
+ console.error('加载家域中枢摘要失败', error);
+ setHubSummary(createEmptyHubSummary());
+ setHubNodes([]);
+ setHubSchedules([]);
+ setHubTasks([]);
+ setHubAlerts([]);
+ setHubError(t('家域中枢摘要加载失败,请稍后重试'));
+ } finally {
+ setHubLoading(false);
+ }
+ }, [hubConfigured, hubReachable, t]);
+
const getUserData = useCallback(async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
@@ -225,9 +380,9 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
const refresh = useCallback(async () => {
const data = await loadQuotaData();
- await loadUptimeData();
+ await Promise.all([loadUptimeData(), loadHubData()]);
return data;
- }, [loadQuotaData, loadUptimeData]);
+ }, [loadQuotaData, loadUptimeData, loadHubData]);
const handleSearchConfirm = useCallback(
async (updateChartDataCallback) => {
@@ -293,6 +448,13 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
uptimeLoading,
activeUptimeTab,
setActiveUptimeTab,
+ hubNodes,
+ hubSchedules,
+ hubTasks,
+ hubAlerts,
+ hubSummary,
+ hubLoading,
+ hubError,
// 计算值
timeOptions,
@@ -312,6 +474,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
handleCloseModal,
loadQuotaData,
loadUptimeData,
+ loadHubData,
getUserData,
refresh,
handleSearchConfirm,
diff --git a/web/src/i18n/hubLocaleCoverage.test.mjs b/web/src/i18n/hubLocaleCoverage.test.mjs
new file mode 100644
index 000000000000..b6068feed280
--- /dev/null
+++ b/web/src/i18n/hubLocaleCoverage.test.mjs
@@ -0,0 +1,153 @@
+import { describe, expect, test } from 'bun:test';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const LOCALES_DIR = join(__dirname, 'locales');
+
+const HUB_LOCALE_KEYS = [
+ 'Route Manager 地址未配置',
+ 'Route Manager 地址',
+ 'Route Manager 地址已更新',
+ 'Route Manager 地址更新失败',
+ '家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢',
+ '家庭管理控制系统暂时不可达,上游状态 {{upstreamStatus}}',
+ '家庭管理控制系统暂时不可达,请检查地址和 shadow 服务',
+ 'Route Manager 已连接,可通过 /hub/ 打开家域中枢',
+ 'Route Manager 暂时不可达,上游状态 {{upstreamStatus}}',
+ 'Route Manager 暂时不可达,请检查地址和 shadow 服务',
+ 'Route Manager 状态检查失败',
+ '设置 Route Manager 地址',
+ '检查 Hub 连通性',
+ '首页内容已更新',
+ '首页内容更新失败',
+ 'Logo 已更新',
+ 'Logo 更新失败',
+ '关于内容已更新',
+ '关于内容更新失败',
+ '页脚内容已更新',
+ '页脚内容更新失败',
+ '加载首页内容失败...',
+ '在此输入首页内容,支持 Markdown & HTML 代码。设置后默认首页状态信息将不再显示;如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。同源相对路径如 /hub/ 也可直接嵌入;当嵌入 /hub/ 时,主站仍会在 iframe 上方显示家域中枢摘要。',
+ '家域中枢已接入主站',
+ '全屏打开中枢',
+ '全屏打开家域中枢',
+ '打开 Route Manager Hub 中枢',
+ '中枢总览',
+ '打开家庭管理控制系统',
+ '家域中枢总览',
+ '家域中枢',
+ '家域中枢值守',
+ '打开家域中枢',
+ '打开中枢',
+ '已连接',
+ '待配置',
+ '异常',
+ '在线',
+ '离线',
+ '休眠',
+ '待确认',
+ '待执行',
+ '进行中',
+ '已完成',
+ '已启用',
+ '失败',
+ '一般',
+ '未知状态',
+ '未知类型',
+ '家庭算力编队',
+ '网络代理状态',
+ 'DNS 广告屏蔽',
+ '出口网络画像',
+ '家庭实体桥接',
+ '在线节点',
+ '活动节点',
+ '待处理任务',
+ '主站内可直接查看中枢最近运行情况',
+ '主站内可直接查看家庭控制系统最近运行情况',
+ '关键面板',
+ '主站内快速切换中枢能力区',
+ '主站内快速切换家庭控制能力区',
+ '直达 AI 中心的主算力编队面板',
+ '直达网络中心的 Mihomo 代理状态面板',
+ '直达网络中心的 DNS 广告屏蔽面板',
+ '直达网络中心的出口网络画像面板',
+ '直达家庭自动化的实体桥接面板',
+ '{{count}} 模型',
+ '{{count}} 在线 AI 节点',
+ '净网已开启',
+ '净网待确认',
+ '代理可达',
+ '代理待确认',
+ 'DNS 可达',
+ 'DNS 待确认',
+ '桥接在线',
+ '桥接待确认',
+ '{{count}} 实体',
+ '主算力 {{name}}',
+ '家庭桥接 {{count}} 实体',
+ '关键节点',
+ '节点中心',
+ '查看节点中心',
+ '暂无节点摘要',
+ '中枢连接正常后,这里会显示最近在线节点和节点入口',
+ '关键告警',
+ '告警中心',
+ '查看告警中心',
+ '未命名告警',
+ '暂无中枢告警',
+ '中枢连接正常后,这里会显示最近告警和失败原因入口',
+ '关键计划',
+ '已激活',
+ '查看任务中心',
+ '未命名计划',
+ '已暂停',
+ '暂无中枢计划',
+ '中枢连接正常后,这里会显示最近计划和定时执行入口',
+ '任务中心',
+ '最近任务',
+ '展示最近 3 条调度/执行结果',
+ '唤醒任务',
+ 'Shell 任务',
+ 'Docker 任务',
+ '浏览器自动化',
+ '本地推理',
+ '网络策略',
+ '家庭动作',
+ '暂无任务预览',
+ '暂无中枢任务',
+ '中枢连接正常后,这里会显示最近任务和节点活动',
+ '严重',
+ '未确认',
+ '待处理',
+ '家域中枢摘要加载失败,请稍后重试',
+ '快捷入口',
+ '打开新窗口',
+ '页面不存在',
+ '当前快捷入口不存在,或你没有访问权限。',
+];
+
+const LOCALE_FILES = [
+ 'en.json',
+ 'fr.json',
+ 'ja.json',
+ 'ru.json',
+ 'vi.json',
+ 'zh-CN.json',
+ 'zh-TW.json',
+];
+
+describe('hub locale coverage', () => {
+ test.each(LOCALE_FILES)('%s includes all hub locale keys', (filename) => {
+ const locale = JSON.parse(
+ readFileSync(join(LOCALES_DIR, filename), 'utf8'),
+ ).translation;
+ const missingKeys = HUB_LOCALE_KEYS.filter(
+ (key) => !Object.prototype.hasOwnProperty.call(locale, key),
+ );
+
+ expect(missingKeys).toEqual([]);
+ });
+});
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index ebe07ae81931..f95cf023baca 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -889,6 +889,115 @@
"命中该亲和规则后,会把此模板合并到渠道参数覆盖中(同名键由模板覆盖)。": "When this affinity rule is matched, the template is merged into the channel parameter overrides (same-name keys are overridden by the template).",
"和": "and",
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Unlike Claude, Gemini thinking models automatically decide whether to think by default. They work normally even without the adapter enabled. If you need billing, set the price of models without suffix to the thinking price. Use format like gemini-2.5-pro-preview-06-05-thinking-128 to specify exact thinking budget.",
+ "Route Manager 地址未配置": "Route Manager URL is not configured",
+ "Route Manager 地址": "Route Manager URL",
+ "Route Manager 地址已更新": "Route Manager URL updated",
+ "Route Manager 地址更新失败": "Failed to update Route Manager URL",
+ "家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢": "The Home Control System is connected. Open the Home Hub at /hub/",
+ "家庭管理控制系统暂时不可达,上游状态 {{upstreamStatus}}": "The Home Control System is temporarily unreachable. Upstream status {{upstreamStatus}}",
+ "家庭管理控制系统暂时不可达,请检查地址和 shadow 服务": "The Home Control System is temporarily unreachable. Check the URL and shadow service.",
+ "Route Manager 已连接,可通过 /hub/ 打开家域中枢": "Route Manager is connected. Open the Home Hub at /hub/",
+ "Route Manager 暂时不可达,上游状态 {{upstreamStatus}}": "Route Manager is temporarily unreachable. Upstream status {{upstreamStatus}}",
+ "Route Manager 暂时不可达,请检查地址和 shadow 服务": "Route Manager is temporarily unreachable. Check the URL and shadow service.",
+ "Route Manager 状态检查失败": "Failed to check Route Manager status",
+ "设置 Route Manager 地址": "Set Route Manager URL",
+ "检查 Hub 连通性": "Check Hub Connectivity",
+ "首页内容已更新": "Home page content updated",
+ "首页内容更新失败": "Failed to update home page content",
+ "Logo 已更新": "Logo updated",
+ "Logo 更新失败": "Failed to update logo",
+ "关于内容已更新": "About content updated",
+ "关于内容更新失败": "Failed to update about content",
+ "页脚内容已更新": "Footer content updated",
+ "页脚内容更新失败": "Failed to update footer content",
+ "加载首页内容失败...": "Failed to load home page content...",
+ "家域中枢已接入主站": "Home Hub is connected to the main site",
+ "全屏打开中枢": "Open Hub Full Screen",
+ "全屏打开家域中枢": "Open Home Hub Full Screen",
+ "打开 Route Manager Hub 中枢": "Open the Route Manager Hub",
+ "打开家庭管理控制系统": "Open the Home Control System",
+ "中枢总览": "Hub Overview",
+ "家域中枢总览": "Home Hub Overview",
+ "家域中枢": "Home Hub",
+ "家域中枢值守": "Home Hub Watch",
+ "打开中枢": "Open Hub",
+ "打开家域中枢": "Open Home Hub",
+ "已连接": "Connected",
+ "待配置": "Needs Setup",
+ "异常": "Error",
+ "在线": "Online",
+ "离线": "Offline",
+ "休眠": "Sleeping",
+ "待确认": "Pending confirmation",
+ "待执行": "Pending execution",
+ "一般": "General",
+ "家庭算力编队": "Home Compute Fleet",
+ "网络代理状态": "Network Proxy Status",
+ "DNS 广告屏蔽": "DNS Ad Blocking",
+ "出口网络画像": "Egress Network Identity",
+ "家庭实体桥接": "Home Entity Bridge",
+ "在线节点": "Online Nodes",
+ "活动节点": "Busy Nodes",
+ "待处理任务": "Pending Tasks",
+ "主站内可直接查看中枢最近运行情况": "View recent hub activity directly from the main site",
+ "主站内可直接查看家庭控制系统最近运行情况": "View recent home control activity directly from the main site",
+ "关键面板": "Key Panels",
+ "主站内快速切换中枢能力区": "Jump between hub capability areas from the main site",
+ "主站内快速切换家庭控制能力区": "Jump between home control areas from the main site",
+ "直达 AI 中心的主算力编队面板": "Open the primary compute fleet panel in the AI Center",
+ "直达网络中心的 Mihomo 代理状态面板": "Open the Mihomo proxy status panel in the Network Center",
+ "直达网络中心的 DNS 广告屏蔽面板": "Open the DNS ad blocking panel in the Network Center",
+ "直达网络中心的出口网络画像面板": "Open the egress network identity panel in the Network Center",
+ "直达家庭自动化的实体桥接面板": "Open the entity bridge panel in Home Automation",
+ "{{count}} 模型": "{{count}} models",
+ "{{count}} 在线 AI 节点": "{{count}} AI nodes online",
+ "净网已开启": "Clean network enabled",
+ "净网待确认": "Clean network pending",
+ "代理可达": "Proxy reachable",
+ "代理待确认": "Proxy pending",
+ "DNS 可达": "DNS reachable",
+ "DNS 待确认": "DNS pending",
+ "桥接在线": "Bridge online",
+ "桥接待确认": "Bridge pending",
+ "{{count}} 实体": "{{count}} entities",
+ "主算力 {{name}}": "Primary compute {{name}}",
+ "家庭桥接 {{count}} 实体": "Home bridge {{count}} entities",
+ "关键节点": "Key Nodes",
+ "节点中心": "Node Center",
+ "查看节点中心": "View Node Center",
+ "暂无节点摘要": "No node summary yet",
+ "中枢连接正常后,这里会显示最近在线节点和节点入口": "Once the hub is connected, recent online nodes and entry links will appear here",
+ "关键告警": "Key Alerts",
+ "告警中心": "Alert Center",
+ "查看告警中心": "View Alert Center",
+ "未命名告警": "Unnamed Alert",
+ "暂无中枢告警": "No hub alerts yet",
+ "中枢连接正常后,这里会显示最近告警和失败原因入口": "Once the hub is connected, recent alerts and failure entry links will appear here",
+ "关键计划": "Key Schedules",
+ "激活": "Active",
+ "已激活": "Active",
+ "查看任务中心": "View Task Center",
+ "未命名计划": "Unnamed Schedule",
+ "已暂停": "Paused",
+ "暂无中枢计划": "No hub schedules yet",
+ "中枢连接正常后,这里会显示最近计划和定时执行入口": "Once the hub is connected, recent schedules and timed execution entry links will appear here",
+ "任务中心": "Task Center",
+ "最近任务": "Recent Tasks",
+ "展示最近 3 条调度/执行结果": "Showing the latest 3 schedule and execution results",
+ "唤醒任务": "Wake Task",
+ "Shell 任务": "Shell Task",
+ "Docker 任务": "Docker Task",
+ "浏览器自动化": "Browser Automation",
+ "本地推理": "Local Inference",
+ "网络策略": "Network Policy",
+ "家庭动作": "Home Action",
+ "暂无任务预览": "No task preview yet",
+ "暂无中枢任务": "No hub tasks yet",
+ "中枢连接正常后,这里会显示最近任务和节点活动": "Once the hub is connected, recent tasks and node activity will appear here",
+ "严重": "Critical",
+ "未确认": "Unacknowledged",
+ "待处理": "Pending",
+ "家域中枢摘要加载失败,请稍后重试": "Failed to load the Home Hub summary. Please try again later.",
"响应": "Response",
"响应时间": "Response time",
"响应缺少凭据": "Response missing credentials",
@@ -930,7 +1039,7 @@
"在此输入用户协议内容,支持 Markdown & HTML 代码": "Enter user agreement content here, supports Markdown & HTML code",
"在此输入系统名称": "Enter the system name here",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Enter privacy policy content here, supports Markdown & HTML code",
- "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Enter the home page content here, supports Markdown",
+ "在此输入首页内容,支持 Markdown & HTML 代码。设置后默认首页状态信息将不再显示;如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。同源相对路径如 /hub/ 也可直接嵌入;当嵌入 /hub/ 时,主站仍会在 iframe 上方显示家域中枢摘要。": "Enter the home page content here, supports Markdown & HTML code. After saving, the default home status section will no longer be shown. If you enter a link, it will be used as the iframe src, which lets you use any web page as the home page. Same-origin relative paths such as /hub/ can also be embedded directly; when /hub/ is embedded, the main site will still show the Home Hub summary above the iframe.",
"域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
"域名白名单": "Domain Whitelist",
"域名黑名单": "Domain Blacklist",
@@ -1442,6 +1551,7 @@
"提示:链接中的{key}将被替换为API密钥,{address}将被替换为服务器地址": "Tip: {key} in the link will be replaced with the API key, {address} will be replaced with the server address",
"提示价格:{{symbol}}{{price}} / 1M tokens": "Prompt price: {{symbol}}{{price}} / 1M tokens",
"提示缓存倍率": "Prompt cache ratio",
+ "缓存命中率": "Cache hit rate",
"搜索供应商": "Search vendor",
"搜索关键字": "Search keywords",
"搜索失败": "Search failed",
@@ -2276,6 +2386,10 @@
"管理员": "Admin",
"管理员区域": "Administrator Area",
"管理员暂时未设置任何关于内容": "The administrator has not set any custom About content yet",
+ "快捷入口": "Shortcuts",
+ "打开新窗口": "Open in new window",
+ "页面不存在": "Page not found",
+ "当前快捷入口不存在,或你没有访问权限。": "This shortcut no longer exists, or you do not have permission to access it.",
"管理员未开启 Creem 充值!": "The administrator has not enabled Creem recharge!",
"管理员未开启Stripe充值!": "Administrator has not enabled Stripe recharge!",
"管理员未开启在线充值!": "The administrator has not enabled online recharge!",
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index 5ea5095255d1..d3308702a81f 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -884,6 +884,115 @@
"命中该亲和规则后,会把此模板合并到渠道参数覆盖中(同名键由模板覆盖)。": "Lorsque cette règle d'affinité est déclenchée, le modèle est fusionné dans les remplacements de paramètres du canal (les clés homonymes sont remplacées par le modèle).",
"和": "et",
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Contrairement à Claude, les modèles de réflexion Gemini décident automatiquement s'ils doivent réfléchir. Ils fonctionnent normalement même sans l'adaptateur activé. Si vous avez besoin de facturation, définissez le prix des modèles sans suffixe au prix de réflexion. Utilisez un format comme gemini-2.5-pro-preview-06-05-thinking-128 pour spécifier le budget de réflexion exact.",
+ "Route Manager 地址未配置": "L'URL de Route Manager n'est pas configurée",
+ "Route Manager 地址": "URL de Route Manager",
+ "Route Manager 地址已更新": "L'URL de Route Manager a été mise à jour",
+ "Route Manager 地址更新失败": "Échec de la mise à jour de l'URL de Route Manager",
+ "家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢": "Le système de contrôle domestique est connecté. Ouvrez Home Hub via /hub/",
+ "家庭管理控制系统暂时不可达,上游状态 {{upstreamStatus}}": "Le système de contrôle domestique est temporairement inaccessible. Statut amont {{upstreamStatus}}",
+ "家庭管理控制系统暂时不可达,请检查地址和 shadow 服务": "Le système de contrôle domestique est temporairement inaccessible. Vérifiez l'URL et le service shadow.",
+ "Route Manager 已连接,可通过 /hub/ 打开家域中枢": "Route Manager est connecté. Ouvrez Home Hub via /hub/",
+ "Route Manager 暂时不可达,上游状态 {{upstreamStatus}}": "Route Manager est temporairement inaccessible. Statut amont {{upstreamStatus}}",
+ "Route Manager 暂时不可达,请检查地址和 shadow 服务": "Route Manager est temporairement inaccessible. Vérifiez l'URL et le service shadow.",
+ "Route Manager 状态检查失败": "Échec de la vérification de l'état de Route Manager",
+ "设置 Route Manager 地址": "Définir l'URL de Route Manager",
+ "检查 Hub 连通性": "Vérifier la connectivité du Hub",
+ "首页内容已更新": "Le contenu de la page d'accueil a été mis à jour",
+ "首页内容更新失败": "Échec de la mise à jour du contenu de la page d'accueil",
+ "Logo 已更新": "Le logo a été mis à jour",
+ "Logo 更新失败": "Échec de la mise à jour du logo",
+ "关于内容已更新": "Le contenu À propos a été mis à jour",
+ "关于内容更新失败": "Échec de la mise à jour du contenu À propos",
+ "页脚内容已更新": "Le contenu du pied de page a été mis à jour",
+ "页脚内容更新失败": "Échec de la mise à jour du contenu du pied de page",
+ "加载首页内容失败...": "Échec du chargement du contenu de la page d'accueil...",
+ "家域中枢已接入主站": "Home Hub est connecté au site principal",
+ "全屏打开中枢": "Ouvrir le Hub en plein écran",
+ "全屏打开家域中枢": "Ouvrir le Home Hub en plein écran",
+ "打开 Route Manager Hub 中枢": "Ouvrir le Route Manager Hub",
+ "打开家庭管理控制系统": "Ouvrir le système de contrôle domestique",
+ "中枢总览": "Vue d'ensemble du Hub",
+ "家域中枢总览": "Vue d'ensemble du Home Hub",
+ "家域中枢": "Home Hub",
+ "家域中枢值守": "Surveillance du Home Hub",
+ "打开中枢": "Ouvrir le Hub",
+ "打开家域中枢": "Ouvrir le Home Hub",
+ "已连接": "Connecté",
+ "待配置": "À configurer",
+ "异常": "Erreur",
+ "在线": "En ligne",
+ "离线": "Hors ligne",
+ "休眠": "En veille",
+ "待确认": "À confirmer",
+ "待执行": "En attente d'exécution",
+ "一般": "Général",
+ "家庭算力编队": "Flotte de calcul domestique",
+ "网络代理状态": "État du proxy réseau",
+ "DNS 广告屏蔽": "Blocage pub DNS",
+ "出口网络画像": "Identité réseau de sortie",
+ "家庭实体桥接": "Pont d'entités domestiques",
+ "在线节点": "Nœuds en ligne",
+ "活动节点": "Nœuds occupés",
+ "待处理任务": "Tâches en attente",
+ "主站内可直接查看中枢最近运行情况": "Consultez l'activité récente du hub directement depuis le site principal",
+ "主站内可直接查看家庭控制系统最近运行情况": "Consultez l'activité récente du contrôle domestique directement depuis le site principal",
+ "关键面板": "Panneaux clés",
+ "主站内快速切换中枢能力区": "Basculez rapidement entre les zones de capacité du hub depuis le site principal",
+ "主站内快速切换家庭控制能力区": "Basculez rapidement entre les zones de contrôle domestique depuis le site principal",
+ "直达 AI 中心的主算力编队面板": "Ouvrir le panneau principal de la flotte de calcul dans le centre IA",
+ "直达网络中心的 Mihomo 代理状态面板": "Ouvrir le panneau d'état du proxy Mihomo dans le centre réseau",
+ "直达网络中心的 DNS 广告屏蔽面板": "Ouvrir le panneau de blocage pub DNS dans le centre réseau",
+ "直达网络中心的出口网络画像面板": "Ouvrir le panneau d'identité réseau de sortie dans le centre réseau",
+ "直达家庭自动化的实体桥接面板": "Ouvrir le panneau de pont d'entités dans l'automatisation domestique",
+ "{{count}} 模型": "{{count}} modèles",
+ "{{count}} 在线 AI 节点": "{{count}} nœuds IA en ligne",
+ "净网已开启": "Filtrage réseau activé",
+ "净网待确认": "Filtrage réseau à confirmer",
+ "代理可达": "Proxy accessible",
+ "代理待确认": "Proxy à confirmer",
+ "DNS 可达": "DNS accessible",
+ "DNS 待确认": "DNS à confirmer",
+ "桥接在线": "Pont en ligne",
+ "桥接待确认": "Pont à confirmer",
+ "{{count}} 实体": "{{count}} entités",
+ "主算力 {{name}}": "Calcul principal {{name}}",
+ "家庭桥接 {{count}} 实体": "Pont domestique {{count}} entités",
+ "关键节点": "Nœuds clés",
+ "节点中心": "Centre des nœuds",
+ "查看节点中心": "Voir le centre des nœuds",
+ "暂无节点摘要": "Aucun résumé de nœud pour le moment",
+ "中枢连接正常后,这里会显示最近在线节点和节点入口": "Une fois le hub connecté, les nœuds récemment en ligne et leurs liens apparaîtront ici",
+ "关键告警": "Alertes clés",
+ "告警中心": "Centre d'alertes",
+ "查看告警中心": "Voir le centre d'alertes",
+ "未命名告警": "Alerte sans nom",
+ "暂无中枢告警": "Aucune alerte du hub pour le moment",
+ "中枢连接正常后,这里会显示最近告警和失败原因入口": "Une fois le hub connecté, les alertes récentes et les liens vers les causes d'échec apparaîtront ici",
+ "关键计划": "Planifications clés",
+ "激活": "Actif",
+ "已激活": "Actif",
+ "查看任务中心": "Voir le centre des tâches",
+ "未命名计划": "Planification sans nom",
+ "已暂停": "En pause",
+ "暂无中枢计划": "Aucune planification du hub pour le moment",
+ "中枢连接正常后,这里会显示最近计划和定时执行入口": "Une fois le hub connecté, les planifications récentes et les accès d'exécution programmée apparaîtront ici",
+ "任务中心": "Centre des tâches",
+ "最近任务": "Tâches récentes",
+ "展示最近 3 条调度/执行结果": "Affichage des 3 derniers résultats de planification et d'exécution",
+ "唤醒任务": "Tâche de réveil",
+ "Shell 任务": "Tâche shell",
+ "Docker 任务": "Tâche Docker",
+ "浏览器自动化": "Automatisation du navigateur",
+ "本地推理": "Inférence locale",
+ "网络策略": "Politique réseau",
+ "家庭动作": "Action domotique",
+ "暂无任务预览": "Aucun aperçu de tâche pour le moment",
+ "暂无中枢任务": "Aucune tâche du hub pour le moment",
+ "中枢连接正常后,这里会显示最近任务和节点活动": "Une fois le hub connecté, les tâches récentes et l'activité des nœuds apparaîtront ici",
+ "严重": "Critique",
+ "未确认": "Non reconnues",
+ "待处理": "En attente",
+ "家域中枢摘要加载失败,请稍后重试": "Échec du chargement du résumé du Home Hub. Réessayez plus tard.",
"响应": "Réponse",
"响应时间": "Temps de réponse",
"响应缺少凭据": "Identifiants manquants dans la réponse",
@@ -923,7 +1032,7 @@
"在此输入用户协议内容,支持 Markdown & HTML 代码": "Saisissez le contenu de l'accord utilisateur ici, prend en charge le code Markdown & HTML",
"在此输入系统名称": "Saisissez le nom du système ici",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Saisissez le contenu de la politique de confidentialité ici, prend en charge le code Markdown & HTML",
- "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Saisissez le contenu de la page d'accueil ici, prend en charge Markdown & HTML. Après configuration, les informations d'état de la page d'accueil ne seront plus affichées. Si un lien est saisi, il sera utilisé comme attribut src de l'iframe, ce qui vous permet de définir n'importe quelle page web comme page d'accueil",
+ "在此输入首页内容,支持 Markdown & HTML 代码。设置后默认首页状态信息将不再显示;如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。同源相对路径如 /hub/ 也可直接嵌入;当嵌入 /hub/ 时,主站仍会在 iframe 上方显示家域中枢摘要。": "Saisissez le contenu de la page d'accueil ici, avec prise en charge de Markdown et HTML. Après enregistrement, la zone d'état par défaut de la page d'accueil ne sera plus affichée. Si vous saisissez un lien, il sera utilisé comme attribut src de l'iframe, ce qui vous permet d'utiliser n'importe quelle page web comme page d'accueil. Les chemins relatifs de même origine comme /hub/ peuvent aussi être intégrés directement ; lorsque /hub/ est intégré, le site principal continue d'afficher le résumé du Home Hub au-dessus de l'iframe.",
"域名IP过滤详细说明": "⚠️ Il s'agit d'une option expérimentale. Un domaine peut se résoudre en plusieurs adresses IPv4/IPv6. Si cette option est activée, assurez-vous que la liste de filtres IP couvre ces adresses, sinon l'accès peut échouer.",
"域名白名单": "Liste blanche de domaines",
"域名黑名单": "Liste noire de domaines",
@@ -2253,6 +2362,10 @@
"管理员": "Admin",
"管理员区域": "Zone administrateur",
"管理员暂时未设置任何关于内容": "L'administrateur n'a encore défini aucun contenu personnalisé \"À propos\".",
+ "快捷入口": "Raccourcis",
+ "打开新窗口": "Ouvrir dans une nouvelle fenêtre",
+ "页面不存在": "Page introuvable",
+ "当前快捷入口不存在,或你没有访问权限。": "Ce raccourci n'existe plus, ou vous n'êtes pas autorisé à y accéder.",
"管理员未开启 Creem 充值!": "L'administrateur n'a pas activé la recharge Creem !",
"管理员未开启Stripe充值!": "L'administrateur n'a pas activé la recharge Stripe !",
"管理员未开启在线充值!": "L'administrateur n'a pas activé la recharge en ligne !",
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index 6ff73ba5c100..1822ff73538f 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -875,6 +875,115 @@
"命中该亲和规则后,会把此模板合并到渠道参数覆盖中(同名键由模板覆盖)。": "このアフィニティルールがヒットすると、テンプレートがチャネルパラメータオーバーライドにマージされます(同名キーはテンプレートで上書きされます)。",
"和": "および",
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考価格設置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Claudeとは異なり、Geminiの思考モデルはデフォルトで思考するかどうかを自動的に決定します。アダプターを有効にしなくても正常に動作します。課金が必要な場合は、サフィックスなしモデルの価格を思考価格に設定してください。gemini-2.5-pro-preview-06-05-thinking-128のような形式を使用して、正確な思考予算を指定できます。",
+ "Route Manager 地址未配置": "Route Manager URL が未設定です",
+ "Route Manager 地址": "Route Manager URL",
+ "Route Manager 地址已更新": "Route Manager URL を更新しました",
+ "Route Manager 地址更新失败": "Route Manager URL の更新に失敗しました",
+ "家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢": "家庭管理コントロールシステムは接続済みです。/hub/ から家域中枢を開けます",
+ "家庭管理控制系统暂时不可达,上游状态 {{upstreamStatus}}": "家庭管理コントロールシステムは一時的に到達できません。上流ステータス {{upstreamStatus}}",
+ "家庭管理控制系统暂时不可达,请检查地址和 shadow 服务": "家庭管理コントロールシステムは一時的に到達できません。URL と shadow サービスを確認してください",
+ "Route Manager 已连接,可通过 /hub/ 打开家域中枢": "Route Manager に接続しました。/hub/ から家域中枢を開けます",
+ "Route Manager 暂时不可达,上游状态 {{upstreamStatus}}": "Route Manager は一時的に到達できません。上流ステータス {{upstreamStatus}}",
+ "Route Manager 暂时不可达,请检查地址和 shadow 服务": "Route Manager は一時的に到達できません。URL と shadow サービスを確認してください",
+ "Route Manager 状态检查失败": "Route Manager 状態の確認に失敗しました",
+ "设置 Route Manager 地址": "Route Manager URL を設定",
+ "检查 Hub 连通性": "Hub 接続性を確認",
+ "首页内容已更新": "ホームページ内容を更新しました",
+ "首页内容更新失败": "ホームページ内容の更新に失敗しました",
+ "Logo 已更新": "Logo を更新しました",
+ "Logo 更新失败": "Logo の更新に失敗しました",
+ "关于内容已更新": "About 内容を更新しました",
+ "关于内容更新失败": "About 内容の更新に失敗しました",
+ "页脚内容已更新": "フッター内容を更新しました",
+ "页脚内容更新失败": "フッター内容の更新に失敗しました",
+ "加载首页内容失败...": "ホームページ内容の読み込みに失敗しました...",
+ "家域中枢已接入主站": "Home Hub はメインサイトに接続されています",
+ "全屏打开中枢": "Hub を全画面で開く",
+ "全屏打开家域中枢": "Home Hub を全画面で開く",
+ "打开 Route Manager Hub 中枢": "Route Manager Hub を開く",
+ "打开家庭管理控制系统": "家庭管理コントロールシステムを開く",
+ "中枢总览": "Hub 概要",
+ "家域中枢总览": "Home Hub 概要",
+ "家域中枢": "Home Hub",
+ "家域中枢值守": "Home Hub 監視",
+ "打开中枢": "Hub を開く",
+ "打开家域中枢": "Home Hub を開く",
+ "已连接": "接続済み",
+ "待配置": "要設定",
+ "异常": "異常",
+ "在线": "オンライン",
+ "离线": "オフライン",
+ "休眠": "スリープ",
+ "待确认": "要確認",
+ "待执行": "実行待ち",
+ "一般": "一般",
+ "家庭算力编队": "家庭用計算フリート",
+ "网络代理状态": "ネットワークプロキシ状態",
+ "DNS 广告屏蔽": "DNS 広告ブロック",
+ "出口网络画像": "出口ネットワーク識別",
+ "家庭实体桥接": "家庭エンティティブリッジ",
+ "在线节点": "オンラインノード",
+ "活动节点": "稼働中ノード",
+ "待处理任务": "保留中タスク",
+ "主站内可直接查看中枢最近运行情况": "メインサイトからハブの最近の稼働状況を直接確認できます",
+ "主站内可直接查看家庭控制系统最近运行情况": "メインサイトから家庭管理システムの最近の稼働状況を直接確認できます",
+ "关键面板": "主要パネル",
+ "主站内快速切换中枢能力区": "メインサイトからハブ機能エリアをすばやく切り替えます",
+ "主站内快速切换家庭控制能力区": "メインサイトから家庭管理機能エリアをすばやく切り替えます",
+ "直达 AI 中心的主算力编队面板": "AI センターの主要計算フリートパネルを開く",
+ "直达网络中心的 Mihomo 代理状态面板": "ネットワークセンターの Mihomo プロキシ状態パネルを開く",
+ "直达网络中心的 DNS 广告屏蔽面板": "ネットワークセンターの DNS 広告ブロックパネルを開く",
+ "直达网络中心的出口网络画像面板": "ネットワークセンターの出口ネットワーク識別パネルを開く",
+ "直达家庭自动化的实体桥接面板": "ホームオートメーションのエンティティブリッジパネルを開く",
+ "{{count}} 模型": "{{count}} モデル",
+ "{{count}} 在线 AI 节点": "{{count}} 個のオンライン AI ノード",
+ "净网已开启": "ネット浄化有効",
+ "净网待确认": "ネット浄化確認待ち",
+ "代理可达": "プロキシ到達可能",
+ "代理待确认": "プロキシ確認待ち",
+ "DNS 可达": "DNS 到達可能",
+ "DNS 待确认": "DNS 確認待ち",
+ "桥接在线": "ブリッジ接続中",
+ "桥接待确认": "ブリッジ確認待ち",
+ "{{count}} 实体": "{{count}} エンティティ",
+ "主算力 {{name}}": "主要計算 {{name}}",
+ "家庭桥接 {{count}} 实体": "ホームブリッジ {{count}} エンティティ",
+ "关键节点": "主要ノード",
+ "节点中心": "ノードセンター",
+ "查看节点中心": "ノードセンターを表示",
+ "暂无节点摘要": "ノード概要はまだありません",
+ "中枢连接正常后,这里会显示最近在线节点和节点入口": "ハブ接続後、最近オンラインになったノードとその入口がここに表示されます",
+ "关键告警": "重要アラート",
+ "告警中心": "アラートセンター",
+ "查看告警中心": "アラートセンターを表示",
+ "未命名告警": "無題のアラート",
+ "暂无中枢告警": "ハブアラートはまだありません",
+ "中枢连接正常后,这里会显示最近告警和失败原因入口": "ハブ接続後、最近のアラートと失敗原因への入口がここに表示されます",
+ "关键计划": "主要スケジュール",
+ "激活": "有効",
+ "已激活": "有効",
+ "查看任务中心": "タスクセンターを表示",
+ "未命名计划": "無題のスケジュール",
+ "已暂停": "一時停止",
+ "暂无中枢计划": "ハブスケジュールはまだありません",
+ "中枢连接正常后,这里会显示最近计划和定时执行入口": "中枢接続が正常になると、ここに最近の計画と定時実行への入口が表示されます",
+ "任务中心": "タスクセンター",
+ "最近任务": "最近のタスク",
+ "展示最近 3 条调度/执行结果": "直近3件のスケジュール / 実行結果を表示",
+ "唤醒任务": "ウェイクタスク",
+ "Shell 任务": "Shell タスク",
+ "Docker 任务": "Docker タスク",
+ "浏览器自动化": "ブラウザー自動化",
+ "本地推理": "ローカル推論",
+ "网络策略": "ネットワークポリシー",
+ "家庭动作": "ホーム操作",
+ "暂无任务预览": "タスクプレビューはまだありません",
+ "暂无中枢任务": "ハブタスクはまだありません",
+ "中枢连接正常后,这里会显示最近任务和节点活动": "ハブ接続後、最近のタスクとノード活動がここに表示されます",
+ "严重": "重大",
+ "未确认": "未確認",
+ "待处理": "保留中",
+ "家域中枢摘要加载失败,请稍后重试": "Home Hub サマリーの読み込みに失敗しました。後でもう一度お試しください",
"响应": "レスポンス",
"响应时间": "応答時間",
"响应缺少凭据": "レスポンスに資格情報がありません",
@@ -914,7 +1023,7 @@
"在此输入用户协议内容,支持 Markdown & HTML 代码": "ユーザー利用規約のコンテンツを入力してください。MarkdownとHTMLコードに対応しています",
"在此输入系统名称": "システム名称を入力してください",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "プライバシーポリシーのコンテンツを入力してください。MarkdownとHTMLコードに対応しています",
- "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "ホームのコンテンツを入力してください。MarkdownとHTMLに対応しています。設定後は、ホームのステータス情報が表示されなくなります。リンクを入力した場合は、そのリンクがiframeのsrc属性として使用され、任意のWebページをホームとして設定できます",
+ "在此输入首页内容,支持 Markdown & HTML 代码。设置后默认首页状态信息将不再显示;如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。同源相对路径如 /hub/ 也可直接嵌入;当嵌入 /hub/ 时,主站仍会在 iframe 上方显示家域中枢摘要。": "ホームのコンテンツを入力してください。Markdown と HTML に対応しています。保存後はデフォルトのホーム状態エリアは表示されなくなります。リンクを入力した場合は、そのリンクが iframe の src 属性として使用され、任意の Web ページをホームに設定できます。/hub/ のような同一オリジンの相対パスも直接埋め込めますが、/hub/ を埋め込んだ場合でもメインサイトは iframe の上に Home Hub の概要を表示し続けます。",
"域名IP过滤详细说明": "ドメインIPフィルタリングの詳細説明",
"域名白名单": "ドメインホワイトリスト",
"域名黑名单": "ドメインブラックリスト",
@@ -2234,6 +2343,10 @@
"管理员": "管理者",
"管理员区域": "管理者エリア",
"管理员暂时未设置任何关于内容": "管理者はまだ「このサービスについて」のコンテンツを設定していません",
+ "快捷入口": "ショートカット",
+ "打开新窗口": "新しいウィンドウで開く",
+ "页面不存在": "ページが見つかりません",
+ "当前快捷入口不存在,或你没有访问权限。": "このショートカットは存在しないか、アクセス権限がありません。",
"管理员未开启 Creem 充值!": "The administrator has not enabled Creem recharge!",
"管理员未开启Stripe充值!": "管理者がStripeチャージを有効にしていません",
"管理员未开启在线充值!": "管理者がオンラインチャージを有効にしていません",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index f34e80fb1985..f4c32128d083 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -890,6 +890,115 @@
"命中该亲和规则后,会把此模板合并到渠道参数覆盖中(同名键由模板覆盖)。": "При срабатывании этого правила аффинити шаблон объединяется с переопределениями параметров канала (одноимённые ключи переопределяются шаблоном).",
"和": "и",
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "В отличие от Claude, модели мышления Gemini автоматически решают, использовать ли режим мышления. Они работают нормально даже без включённого адаптера. Если нужна тарификация, установите цену моделей без суффикса на цену мышления. Используйте формат gemini-2.5-pro-preview-06-05-thinking-128 для точного указания бюджета мышления.",
+ "Route Manager 地址未配置": "URL Route Manager не настроен",
+ "Route Manager 地址": "URL Route Manager",
+ "Route Manager 地址已更新": "URL Route Manager обновлен",
+ "Route Manager 地址更新失败": "Не удалось обновить URL Route Manager",
+ "家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢": "Система домашнего управления подключена. Откройте Home Hub по адресу /hub/",
+ "家庭管理控制系统暂时不可达,上游状态 {{upstreamStatus}}": "Система домашнего управления временно недоступна. Статус upstream: {{upstreamStatus}}",
+ "家庭管理控制系统暂时不可达,请检查地址和 shadow 服务": "Система домашнего управления временно недоступна. Проверьте URL и shadow-сервис.",
+ "Route Manager 已连接,可通过 /hub/ 打开家域中枢": "Route Manager подключен. Откройте Home Hub по адресу /hub/",
+ "Route Manager 暂时不可达,上游状态 {{upstreamStatus}}": "Route Manager временно недоступен. Статус upstream: {{upstreamStatus}}",
+ "Route Manager 暂时不可达,请检查地址和 shadow 服务": "Route Manager временно недоступен. Проверьте URL и shadow-сервис.",
+ "Route Manager 状态检查失败": "Не удалось проверить статус Route Manager",
+ "设置 Route Manager 地址": "Задать URL Route Manager",
+ "检查 Hub 连通性": "Проверить доступность Hub",
+ "首页内容已更新": "Содержимое главной страницы обновлено",
+ "首页内容更新失败": "Не удалось обновить содержимое главной страницы",
+ "Logo 已更新": "Логотип обновлён",
+ "Logo 更新失败": "Не удалось обновить логотип",
+ "关于内容已更新": "Содержимое страницы «О нас» обновлено",
+ "关于内容更新失败": "Не удалось обновить содержимое страницы «О нас»",
+ "页脚内容已更新": "Содержимое подвала обновлено",
+ "页脚内容更新失败": "Не удалось обновить содержимое подвала",
+ "加载首页内容失败...": "Не удалось загрузить содержимое главной страницы...",
+ "家域中枢已接入主站": "Home Hub подключён к основному сайту",
+ "全屏打开中枢": "Открыть Hub на весь экран",
+ "全屏打开家域中枢": "Открыть Home Hub на весь экран",
+ "打开 Route Manager Hub 中枢": "Открыть Route Manager Hub",
+ "打开家庭管理控制系统": "Открыть систему домашнего управления",
+ "中枢总览": "Обзор Hub",
+ "家域中枢总览": "Обзор Home Hub",
+ "家域中枢": "Home Hub",
+ "家域中枢值守": "Мониторинг Home Hub",
+ "打开中枢": "Открыть Hub",
+ "打开家域中枢": "Открыть Home Hub",
+ "已连接": "Подключено",
+ "待配置": "Требуется настройка",
+ "异常": "Ошибка",
+ "在线": "В сети",
+ "离线": "Не в сети",
+ "休眠": "Спящий режим",
+ "待确认": "Требует подтверждения",
+ "待执行": "Ожидает выполнения",
+ "一般": "Обычный",
+ "家庭算力编队": "Домашний вычислительный флот",
+ "网络代理状态": "Состояние сетевого прокси",
+ "DNS 广告屏蔽": "DNS-блокировка рекламы",
+ "出口网络画像": "Профиль выходной сети",
+ "家庭实体桥接": "Мост домашних сущностей",
+ "在线节点": "Узлы онлайн",
+ "活动节点": "Занятые узлы",
+ "待处理任务": "Ожидающие задачи",
+ "主站内可直接查看中枢最近运行情况": "Просматривайте недавнюю активность hub прямо с основного сайта",
+ "主站内可直接查看家庭控制系统最近运行情况": "Просматривайте недавнюю активность домашнего управления прямо с основного сайта",
+ "关键面板": "Ключевые панели",
+ "主站内快速切换中枢能力区": "Быстро переключайтесь между зонами возможностей hub с основного сайта",
+ "主站内快速切换家庭控制能力区": "Быстро переключайтесь между зонами домашнего управления с основного сайта",
+ "直达 AI 中心的主算力编队面板": "Открыть панель основного вычислительного флота в AI Center",
+ "直达网络中心的 Mihomo 代理状态面板": "Открыть панель состояния прокси Mihomo в Network Center",
+ "直达网络中心的 DNS 广告屏蔽面板": "Открыть панель DNS-блокировки рекламы в Network Center",
+ "直达网络中心的出口网络画像面板": "Открыть панель профиля выходной сети в Network Center",
+ "直达家庭自动化的实体桥接面板": "Открыть панель моста сущностей в Home Automation",
+ "{{count}} 模型": "{{count}} моделей",
+ "{{count}} 在线 AI 节点": "{{count}} ИИ-узлов онлайн",
+ "净网已开启": "Фильтрация включена",
+ "净网待确认": "Фильтрация ожидает подтверждения",
+ "代理可达": "Прокси доступен",
+ "代理待确认": "Прокси ожидает подтверждения",
+ "DNS 可达": "DNS доступен",
+ "DNS 待确认": "DNS ожидает подтверждения",
+ "桥接在线": "Мост онлайн",
+ "桥接待确认": "Мост ожидает подтверждения",
+ "{{count}} 实体": "{{count}} сущностей",
+ "主算力 {{name}}": "Основная вычислительная {{name}}",
+ "家庭桥接 {{count}} 实体": "Домашний мост {{count}} сущностей",
+ "关键节点": "Ключевые узлы",
+ "节点中心": "Центр узлов",
+ "查看节点中心": "Открыть центр узлов",
+ "暂无节点摘要": "Пока нет сводки по узлам",
+ "中枢连接正常后,这里会显示最近在线节点和节点入口": "После подключения hub здесь появятся недавно активные узлы и ссылки для перехода",
+ "关键告警": "Ключевые оповещения",
+ "告警中心": "Центр оповещений",
+ "查看告警中心": "Открыть центр оповещений",
+ "未命名告警": "Безымянное оповещение",
+ "暂无中枢告警": "Пока нет оповещений hub",
+ "中枢连接正常后,这里会显示最近告警和失败原因入口": "После подключения hub здесь появятся недавние оповещения и ссылки на причины сбоев",
+ "关键计划": "Ключевые расписания",
+ "激活": "Активно",
+ "已激活": "Активно",
+ "查看任务中心": "Открыть центр задач",
+ "未命名计划": "Безымянное расписание",
+ "已暂停": "На паузе",
+ "暂无中枢计划": "Пока нет расписаний hub",
+ "中枢连接正常后,这里会显示最近计划和定时执行入口": "После подключения хаба здесь появятся последние расписания и ссылки для запуска по расписанию",
+ "任务中心": "Центр задач",
+ "最近任务": "Последние задачи",
+ "展示最近 3 条调度/执行结果": "Показаны последние 3 результата расписаний и запусков",
+ "唤醒任务": "Задача пробуждения",
+ "Shell 任务": "Shell-задача",
+ "Docker 任务": "Задача Docker",
+ "浏览器自动化": "Автоматизация браузера",
+ "本地推理": "Локальный инференс",
+ "网络策略": "Сетевая политика",
+ "家庭动作": "Домашнее действие",
+ "暂无任务预览": "Пока нет превью задачи",
+ "暂无中枢任务": "Пока нет задач hub",
+ "中枢连接正常后,这里会显示最近任务和节点活动": "После подключения hub здесь появятся последние задачи и активность узлов",
+ "严重": "Критично",
+ "未确认": "Неподтвержденные",
+ "待处理": "В ожидании",
+ "家域中枢摘要加载失败,请稍后重试": "Не удалось загрузить сводку Home Hub. Повторите попытку позже.",
"响应": "Ответ",
"响应时间": "Время ответа",
"响应缺少凭据": "В ответе отсутствуют учётные данные",
@@ -929,7 +1038,7 @@
"在此输入用户协议内容,支持 Markdown & HTML 代码": "Введите здесь содержимое пользовательского соглашения, поддерживается Markdown & HTML код",
"在此输入系统名称": "Введите здесь название системы",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Введите здесь содержимое политики конфиденциальности, поддерживается Markdown & HTML код",
- "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Введите здесь содержание главной страницы, поддерживается код Markdown и HTML. После настройки информация о состоянии на главной странице больше не будет отображаться. Если введена ссылка, она будет использована как атрибут src для iframe, что позволяет установить любую веб-страницу как главную страницу",
+ "在此输入首页内容,支持 Markdown & HTML 代码。设置后默认首页状态信息将不再显示;如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。同源相对路径如 /hub/ 也可直接嵌入;当嵌入 /hub/ 时,主站仍会在 iframe 上方显示家域中枢摘要。": "Введите здесь содержимое главной страницы, поддерживаются Markdown и HTML. После сохранения стандартный блок состояния на главной странице больше не будет показываться. Если ввести ссылку, она будет использована как src для iframe, что позволяет назначить любую веб-страницу главной. Относительные пути того же происхождения, например /hub/, тоже можно встраивать напрямую; при встраивании /hub/ основной сайт всё равно будет показывать сводку Home Hub над iframe.",
"域名IP过滤详细说明": "⚠️ Эта функция является экспериментальной опцией, доменное имя может быть разрешено в несколько адресов IPv4/IPv6, если включено, убедитесь, что список фильтрации IP покрывает эти адреса, иначе это может привести к сбою доступа.",
"域名白名单": "Белый список доменов",
"域名黑名单": "Чёрный список доменов",
@@ -2267,6 +2376,10 @@
"管理员": "Администратор",
"管理员区域": "Область администратора",
"管理员暂时未设置任何关于内容": "Администратор пока не установил никакой информации о проекте",
+ "快捷入口": "Быстрые ссылки",
+ "打开新窗口": "Открыть в новом окне",
+ "页面不存在": "Страница не найдена",
+ "当前快捷入口不存在,或你没有访问权限。": "Этот ярлык больше не существует, или у вас нет прав доступа.",
"管理员未开启 Creem 充值!": "Администратор не включил пополнение через Creem!",
"管理员未开启Stripe充值!": "Администратор не включил пополнение через Stripe!",
"管理员未开启在线充值!": "Администратор не включил онлайн пополнение!",
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index a6067d305b1f..6071385f2de9 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -876,6 +876,114 @@
"命中该亲和规则后,会把此模板合并到渠道参数覆盖中(同名键由模板覆盖)。": "Khi quy tắc ưu ái này trúng, mẫu sẽ được hợp nhất vào ghi đè tham số kênh (key cùng tên bị mẫu ghi đè).",
"和": "và",
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Không giống Claude, mô hình tư duy Gemini tự động quyết định có suy nghĩ hay không. Chúng hoạt động bình thường ngay cả khi không bật adapter. Nếu cần tính phí, hãy đặt giá của mô hình không có hậu tố theo giá tư duy. Sử dụng định dạng như gemini-2.5-pro-preview-06-05-thinking-128 để chỉ định ngân sách tư duy chính xác.",
+ "Route Manager 地址未配置": "URL Route Manager chưa được cấu hình",
+ "Route Manager 地址": "URL Route Manager",
+ "Route Manager 地址已更新": "Đã cập nhật URL Route Manager",
+ "Route Manager 地址更新失败": "Cập nhật URL Route Manager thất bại",
+ "家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢": "Hệ thống điều khiển gia đình đã kết nối. Có thể mở Home Hub tại /hub/",
+ "家庭管理控制系统暂时不可达,上游状态 {{upstreamStatus}}": "Hệ thống điều khiển gia đình tạm thời không thể truy cập. Trạng thái upstream {{upstreamStatus}}",
+ "家庭管理控制系统暂时不可达,请检查地址和 shadow 服务": "Hệ thống điều khiển gia đình tạm thời không thể truy cập. Hãy kiểm tra URL và dịch vụ shadow.",
+ "Route Manager 已连接,可通过 /hub/ 打开家域中枢": "Route Manager đã kết nối. Mở Home Hub tại /hub/",
+ "Route Manager 暂时不可达,上游状态 {{upstreamStatus}}": "Route Manager tạm thời không thể truy cập. Trạng thái upstream {{upstreamStatus}}",
+ "Route Manager 暂时不可达,请检查地址和 shadow 服务": "Route Manager tạm thời không thể truy cập. Hãy kiểm tra URL và dịch vụ shadow.",
+ "Route Manager 状态检查失败": "Kiểm tra trạng thái Route Manager thất bại",
+ "设置 Route Manager 地址": "Thiết lập URL Route Manager",
+ "检查 Hub 连通性": "Kiểm tra kết nối Hub",
+ "首页内容已更新": "Đã cập nhật nội dung trang chủ",
+ "首页内容更新失败": "Cập nhật nội dung trang chủ thất bại",
+ "Logo 已更新": "Logo đã được cập nhật",
+ "Logo 更新失败": "Cập nhật logo thất bại",
+ "关于内容已更新": "Nội dung Giới thiệu đã được cập nhật",
+ "关于内容更新失败": "Cập nhật nội dung Giới thiệu thất bại",
+ "页脚内容已更新": "Nội dung chân trang đã được cập nhật",
+ "页脚内容更新失败": "Cập nhật nội dung chân trang thất bại",
+ "加载首页内容失败...": "Tải nội dung trang chủ thất bại...",
+ "家域中枢已接入主站": "Home Hub đã kết nối với trang chính",
+ "全屏打开中枢": "Mở Hub toàn màn hình",
+ "全屏打开家域中枢": "Mở Home Hub toàn màn hình",
+ "打开 Route Manager Hub 中枢": "Mở Route Manager Hub",
+ "打开家庭管理控制系统": "Mở hệ thống điều khiển gia đình",
+ "中枢总览": "Tổng quan Hub",
+ "家域中枢总览": "Tổng quan Home Hub",
+ "家域中枢": "Home Hub",
+ "家域中枢值守": "Giám sát Home Hub",
+ "打开中枢": "Mở Hub",
+ "打开家域中枢": "Mở Home Hub",
+ "已连接": "Đã kết nối",
+ "待配置": "Cần cấu hình",
+ "异常": "Lỗi",
+ "在线": "Trực tuyến",
+ "离线": "Ngoại tuyến",
+ "休眠": "Ngủ",
+ "待确认": "Chờ xác nhận",
+ "待执行": "Chờ thực thi",
+ "一般": "Thông thường",
+ "家庭算力编队": "Đội hình tính toán gia đình",
+ "网络代理状态": "Trạng thái proxy mạng",
+ "DNS 广告屏蔽": "Chặn quảng cáo DNS",
+ "出口网络画像": "Nhận diện mạng đi ra",
+ "家庭实体桥接": "Cầu nối thực thể gia đình",
+ "在线节点": "Nút trực tuyến",
+ "活动节点": "Nút đang bận",
+ "待处理任务": "Tác vụ chờ xử lý",
+ "主站内可直接查看中枢最近运行情况": "Xem hoạt động gần đây của hub trực tiếp từ trang chính",
+ "主站内可直接查看家庭控制系统最近运行情况": "Xem hoạt động gần đây của hệ thống điều khiển gia đình trực tiếp từ trang chính",
+ "关键面板": "Bảng điều khiển chính",
+ "主站内快速切换中枢能力区": "Chuyển nhanh giữa các khu vực khả năng của hub từ trang chính",
+ "主站内快速切换家庭控制能力区": "Chuyển nhanh giữa các khu vực điều khiển gia đình từ trang chính",
+ "直达 AI 中心的主算力编队面板": "Mở bảng đội hình tính toán chính trong AI Center",
+ "直达网络中心的 Mihomo 代理状态面板": "Mở bảng trạng thái proxy Mihomo trong Network Center",
+ "直达网络中心的 DNS 广告屏蔽面板": "Mở bảng chặn quảng cáo DNS trong Network Center",
+ "直达网络中心的出口网络画像面板": "Mở bảng nhận diện mạng đi ra trong Network Center",
+ "直达家庭自动化的实体桥接面板": "Mở bảng cầu nối thực thể trong Home Automation",
+ "{{count}} 模型": "{{count}} mô hình",
+ "{{count}} 在线 AI 节点": "{{count}} nút AI trực tuyến",
+ "净网已开启": "Lọc mạng đã bật",
+ "净网待确认": "Lọc mạng chờ xác nhận",
+ "代理可达": "Proxy khả dụng",
+ "代理待确认": "Proxy chờ xác nhận",
+ "DNS 可达": "DNS khả dụng",
+ "DNS 待确认": "DNS chờ xác nhận",
+ "桥接在线": "Cầu nối trực tuyến",
+ "桥接待确认": "Cầu nối chờ xác nhận",
+ "{{count}} 实体": "{{count}} thực thể",
+ "主算力 {{name}}": "Nút tính toán chính {{name}}",
+ "家庭桥接 {{count}} 实体": "Cầu nối gia đình {{count}} thực thể",
+ "关键节点": "Nút chính",
+ "节点中心": "Trung tâm nút",
+ "查看节点中心": "Xem trung tâm nút",
+ "暂无节点摘要": "Chưa có tóm tắt nút",
+ "中枢连接正常后,这里会显示最近在线节点和节点入口": "Khi hub kết nối thành công, các nút trực tuyến gần đây và lối vào sẽ hiển thị tại đây",
+ "关键告警": "Cảnh báo chính",
+ "告警中心": "Trung tâm cảnh báo",
+ "查看告警中心": "Xem trung tâm cảnh báo",
+ "未命名告警": "Cảnh báo chưa đặt tên",
+ "暂无中枢告警": "Chưa có cảnh báo hub",
+ "中枢连接正常后,这里会显示最近告警和失败原因入口": "Khi hub kết nối thành công, các cảnh báo gần đây và lối vào nguyên nhân lỗi sẽ hiển thị tại đây",
+ "关键计划": "Lịch chính",
+ "已激活": "Đang hoạt động",
+ "查看任务中心": "Xem trung tâm tác vụ",
+ "未命名计划": "Lịch chưa đặt tên",
+ "已暂停": "Tạm dừng",
+ "暂无中枢计划": "Chưa có lịch hub",
+ "中枢连接正常后,这里会显示最近计划和定时执行入口": "Sau khi trung tâm kết nối bình thường, tại đây sẽ hiển thị các lịch gần đây và lối vào thực thi theo lịch",
+ "任务中心": "Trung tâm tác vụ",
+ "最近任务": "Tác vụ gần đây",
+ "展示最近 3 条调度/执行结果": "Hiển thị 3 kết quả điều phối / thực thi gần nhất",
+ "唤醒任务": "Tác vụ đánh thức",
+ "Shell 任务": "Tác vụ Shell",
+ "Docker 任务": "Tác vụ Docker",
+ "浏览器自动化": "Tự động hóa trình duyệt",
+ "本地推理": "Suy luận cục bộ",
+ "网络策略": "Chính sách mạng",
+ "家庭动作": "Hành động gia đình",
+ "暂无任务预览": "Chưa có xem trước tác vụ",
+ "暂无中枢任务": "Chưa có tác vụ hub",
+ "中枢连接正常后,这里会显示最近任务和节点活动": "Khi hub kết nối thành công, các tác vụ gần đây và hoạt động nút sẽ hiển thị tại đây",
+ "严重": "Nghiêm trọng",
+ "未确认": "Chưa xác nhận",
+ "待处理": "Chờ xử lý",
+ "家域中枢摘要加载失败,请稍后重试": "Tải tóm tắt Home Hub thất bại. Vui lòng thử lại sau.",
"响应": "Phản hồi",
"响应时间": "Thời gian phản hồi",
"响应缺少凭据": "Phản hồi thiếu thông tin xác thực",
@@ -915,7 +1023,7 @@
"在此输入用户协议内容,支持 Markdown & HTML 代码": "Nhập nội dung thỏa thuận người dùng tại đây, hỗ trợ mã Markdown & HTML",
"在此输入系统名称": "Nhập tên hệ thống tại đây",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Nhập nội dung chính sách bảo mật tại đây, hỗ trợ mã Markdown & HTML",
- "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Nhập nội dung trang chủ tại đây, hỗ trợ Markdown",
+ "在此输入首页内容,支持 Markdown & HTML 代码。设置后默认首页状态信息将不再显示;如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。同源相对路径如 /hub/ 也可直接嵌入;当嵌入 /hub/ 时,主站仍会在 iframe 上方显示家域中枢摘要。": "Nhập nội dung trang chủ tại đây, hỗ trợ Markdown và HTML. Sau khi lưu, khu vực trạng thái mặc định của trang chủ sẽ không còn hiển thị. Nếu nhập một liên kết, liên kết đó sẽ được dùng làm src của iframe, cho phép bạn dùng bất kỳ trang web nào làm trang chủ. Các đường dẫn tương đối cùng nguồn như /hub/ cũng có thể được nhúng trực tiếp; khi nhúng /hub/, trang chính vẫn sẽ hiển thị phần tóm tắt Home Hub phía trên iframe.",
"域名IP过滤详细说明": "⚠️ Đây là tùy chọn thử nghiệm. Một tên miền có thể phân giải thành nhiều địa chỉ IPv4/IPv6. Nếu bật, hãy đảm bảo danh sách lọc IP bao gồm các địa chỉ này, nếu không truy cập có thể thất bại.",
"域名白名单": "Danh sách trắng tên miền",
"域名黑名单": "Danh sách đen tên miền",
@@ -2498,6 +2606,10 @@
"管理员": "Quản trị viên",
"管理员区域": "Khu vực quản trị viên",
"管理员暂时未设置任何关于内容": "Quản trị viên chưa đặt bất kỳ nội dung Giới thiệu tùy chỉnh nào",
+ "快捷入口": "Lối tắt",
+ "打开新窗口": "Mở trong cửa sổ mới",
+ "页面不存在": "Không tìm thấy trang",
+ "当前快捷入口不存在,或你没有访问权限。": "Lối tắt này không còn tồn tại hoặc bạn không có quyền truy cập.",
"管理员未开启 Creem 充值!": "The administrator has not enabled Creem recharge!",
"管理员未开启Stripe充值!": "Quản trị viên chưa bật nạp tiền Stripe!",
"管理员未开启在线充值!": "Quản trị viên chưa bật nạp tiền trực tuyến!",
diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json
index 02681108c3e6..b6ac3971475e 100644
--- a/web/src/i18n/locales/zh-CN.json
+++ b/web/src/i18n/locales/zh-CN.json
@@ -696,6 +696,115 @@
"周": "周",
"和": "和",
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。",
+ "Route Manager 地址未配置": "Route Manager 地址未配置",
+ "Route Manager 地址": "Route Manager 地址",
+ "Route Manager 地址已更新": "Route Manager 地址已更新",
+ "Route Manager 地址更新失败": "Route Manager 地址更新失败",
+ "家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢": "家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢",
+ "家庭管理控制系统暂时不可达,上游状态 {{upstreamStatus}}": "家庭管理控制系统暂时不可达,上游状态 {{upstreamStatus}}",
+ "家庭管理控制系统暂时不可达,请检查地址和 shadow 服务": "家庭管理控制系统暂时不可达,请检查地址和 shadow 服务",
+ "Route Manager 已连接,可通过 /hub/ 打开家域中枢": "Route Manager 已连接,可通过 /hub/ 打开家域中枢",
+ "Route Manager 暂时不可达,上游状态 {{upstreamStatus}}": "Route Manager 暂时不可达,上游状态 {{upstreamStatus}}",
+ "Route Manager 暂时不可达,请检查地址和 shadow 服务": "Route Manager 暂时不可达,请检查地址和 shadow 服务",
+ "Route Manager 状态检查失败": "Route Manager 状态检查失败",
+ "设置 Route Manager 地址": "设置 Route Manager 地址",
+ "检查 Hub 连通性": "检查 Hub 连通性",
+ "首页内容已更新": "首页内容已更新",
+ "首页内容更新失败": "首页内容更新失败",
+ "Logo 已更新": "Logo 已更新",
+ "Logo 更新失败": "Logo 更新失败",
+ "关于内容已更新": "关于内容已更新",
+ "关于内容更新失败": "关于内容更新失败",
+ "页脚内容已更新": "页脚内容已更新",
+ "页脚内容更新失败": "页脚内容更新失败",
+ "加载首页内容失败...": "加载首页内容失败...",
+ "家域中枢已接入主站": "家域中枢已接入主站",
+ "全屏打开中枢": "全屏打开中枢",
+ "全屏打开家域中枢": "全屏打开家域中枢",
+ "打开 Route Manager Hub 中枢": "打开 Route Manager Hub 中枢",
+ "打开家庭管理控制系统": "打开家庭管理控制系统",
+ "中枢总览": "中枢总览",
+ "家域中枢总览": "家域中枢总览",
+ "家域中枢": "家域中枢",
+ "家域中枢值守": "家域中枢值守",
+ "打开中枢": "打开中枢",
+ "打开家域中枢": "打开家域中枢",
+ "已连接": "已连接",
+ "待配置": "待配置",
+ "异常": "异常",
+ "在线": "在线",
+ "离线": "离线",
+ "休眠": "休眠",
+ "待确认": "待确认",
+ "待执行": "待执行",
+ "一般": "一般",
+ "家庭算力编队": "家庭算力编队",
+ "网络代理状态": "网络代理状态",
+ "DNS 广告屏蔽": "DNS 广告屏蔽",
+ "出口网络画像": "出口网络画像",
+ "家庭实体桥接": "家庭实体桥接",
+ "在线节点": "在线节点",
+ "活动节点": "活动节点",
+ "待处理任务": "待处理任务",
+ "主站内可直接查看中枢最近运行情况": "主站内可直接查看中枢最近运行情况",
+ "主站内可直接查看家庭控制系统最近运行情况": "主站内可直接查看家庭控制系统最近运行情况",
+ "关键面板": "关键面板",
+ "主站内快速切换中枢能力区": "主站内快速切换中枢能力区",
+ "主站内快速切换家庭控制能力区": "主站内快速切换家庭控制能力区",
+ "直达 AI 中心的主算力编队面板": "直达 AI 中心的主算力编队面板",
+ "直达网络中心的 Mihomo 代理状态面板": "直达网络中心的 Mihomo 代理状态面板",
+ "直达网络中心的 DNS 广告屏蔽面板": "直达网络中心的 DNS 广告屏蔽面板",
+ "直达网络中心的出口网络画像面板": "直达网络中心的出口网络画像面板",
+ "直达家庭自动化的实体桥接面板": "直达家庭自动化的实体桥接面板",
+ "{{count}} 模型": "{{count}} 模型",
+ "{{count}} 在线 AI 节点": "{{count}} 在线 AI 节点",
+ "净网已开启": "净网已开启",
+ "净网待确认": "净网待确认",
+ "代理可达": "代理可达",
+ "代理待确认": "代理待确认",
+ "DNS 可达": "DNS 可达",
+ "DNS 待确认": "DNS 待确认",
+ "桥接在线": "桥接在线",
+ "桥接待确认": "桥接待确认",
+ "{{count}} 实体": "{{count}} 实体",
+ "主算力 {{name}}": "主算力 {{name}}",
+ "家庭桥接 {{count}} 实体": "家庭桥接 {{count}} 实体",
+ "关键节点": "关键节点",
+ "节点中心": "节点中心",
+ "查看节点中心": "查看节点中心",
+ "暂无节点摘要": "暂无节点摘要",
+ "中枢连接正常后,这里会显示最近在线节点和节点入口": "中枢连接正常后,这里会显示最近在线节点和节点入口",
+ "关键告警": "关键告警",
+ "告警中心": "告警中心",
+ "查看告警中心": "查看告警中心",
+ "未命名告警": "未命名告警",
+ "暂无中枢告警": "暂无中枢告警",
+ "中枢连接正常后,这里会显示最近告警和失败原因入口": "中枢连接正常后,这里会显示最近告警和失败原因入口",
+ "关键计划": "关键计划",
+ "激活": "激活",
+ "已激活": "已激活",
+ "查看任务中心": "查看任务中心",
+ "未命名计划": "未命名计划",
+ "已暂停": "已暂停",
+ "暂无中枢计划": "暂无中枢计划",
+ "中枢连接正常后,这里会显示最近计划和定时执行入口": "中枢连接正常后,这里会显示最近计划和定时执行入口",
+ "任务中心": "任务中心",
+ "最近任务": "最近任务",
+ "展示最近 3 条调度/执行结果": "展示最近 3 条调度/执行结果",
+ "唤醒任务": "唤醒任务",
+ "Shell 任务": "Shell 任务",
+ "Docker 任务": "Docker 任务",
+ "浏览器自动化": "浏览器自动化",
+ "本地推理": "本地推理",
+ "网络策略": "网络策略",
+ "家庭动作": "家庭动作",
+ "暂无任务预览": "暂无任务预览",
+ "暂无中枢任务": "暂无中枢任务",
+ "中枢连接正常后,这里会显示最近任务和节点活动": "中枢连接正常后,这里会显示最近任务和节点活动",
+ "严重": "严重",
+ "未确认": "未确认",
+ "待处理": "待处理",
+ "家域中枢摘要加载失败,请稍后重试": "家域中枢摘要加载失败,请稍后重试",
"响应": "响应",
"响应时间": "响应时间",
"商品价格 ID": "商品价格 ID",
@@ -730,7 +839,7 @@
"在此输入用户协议内容,支持 Markdown & HTML 代码": "在此输入用户协议内容,支持 Markdown & HTML 代码",
"在此输入系统名称": "在此输入系统名称",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此输入隐私政策内容,支持 Markdown & HTML 代码",
- "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页",
+ "在此输入首页内容,支持 Markdown & HTML 代码。设置后默认首页状态信息将不再显示;如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。同源相对路径如 /hub/ 也可直接嵌入;当嵌入 /hub/ 时,主站仍会在 iframe 上方显示家域中枢摘要。": "在此输入首页内容,支持 Markdown & HTML 代码。设置后默认首页状态信息将不再显示;如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。同源相对路径如 /hub/ 也可直接嵌入;当嵌入 /hub/ 时,主站仍会在 iframe 上方显示家域中枢摘要。",
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
"域名白名单": "域名白名单",
"域名黑名单": "域名黑名单",
@@ -1135,6 +1244,7 @@
"提示:链接中的{key}将被替换为API密钥,{address}将被替换为服务器地址": "提示:链接中的{key}将被替换为API密钥,{address}将被替换为服务器地址",
"提示价格:{{symbol}}{{price}} / 1M tokens": "提示价格:{{symbol}}{{price}} / 1M tokens",
"提示缓存倍率": "提示缓存倍率",
+ "缓存命中率": "缓存命中率",
"缓存创建倍率": "缓存创建倍率",
"默认为 5m 缓存创建倍率;1h 缓存创建倍率按固定乘法自动计算(当前为 1.6x)": "默认为 5m 缓存创建倍率;1h 缓存创建倍率按固定乘法自动计算(当前为 1.6x)",
"搜索供应商": "搜索供应商",
@@ -1808,6 +1918,10 @@
"管理员": "管理员",
"管理员区域": "管理员区域",
"管理员暂时未设置任何关于内容": "管理员暂时未设置任何关于内容",
+ "快捷入口": "快捷入口",
+ "打开新窗口": "打开新窗口",
+ "页面不存在": "页面不存在",
+ "当前快捷入口不存在,或你没有访问权限。": "当前快捷入口不存在,或你没有访问权限。",
"管理员未开启 Creem 充值!": "管理员未开启 Creem 充值!",
"管理员未开启Stripe充值!": "管理员未开启Stripe充值!",
"管理员未开启在线充值!": "管理员未开启在线充值!",
diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json
index 61980d9451d8..6ab3bf7cc396 100644
--- a/web/src/i18n/locales/zh-TW.json
+++ b/web/src/i18n/locales/zh-TW.json
@@ -698,6 +698,115 @@
"周": "周",
"和": "和",
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,如果您需要计费,推荐设置无后缀模型价格按思考价格设置。支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "和Claude不同,預設情況下Gemini的思考模型會自動決定要不要思考,就算不開啟相容模型也可以正常使用,如果您需要計費,推薦設定無後綴模型價格按思考價格設定。支援使用 gemini-2.5-pro-preview-06-05-thinking-128 格式來精確傳遞思考預算。",
+ "Route Manager 地址未配置": "Route Manager 位址未配置",
+ "Route Manager 地址": "Route Manager 位址",
+ "Route Manager 地址已更新": "Route Manager 位址已更新",
+ "Route Manager 地址更新失败": "Route Manager 位址更新失敗",
+ "家庭管理控制系统已接入,可通过 /hub/ 打开家域中枢": "家庭管理控制系統已接入,可透過 /hub/ 打開家域中樞",
+ "家庭管理控制系统暂时不可达,上游状态 {{upstreamStatus}}": "家庭管理控制系統暫時不可達,上游狀態 {{upstreamStatus}}",
+ "家庭管理控制系统暂时不可达,请检查地址和 shadow 服务": "家庭管理控制系統暫時不可達,請檢查位址和 shadow 服務",
+ "Route Manager 已连接,可通过 /hub/ 打开家域中枢": "Route Manager 已連線,可透過 /hub/ 打開家域中樞",
+ "Route Manager 暂时不可达,上游状态 {{upstreamStatus}}": "Route Manager 暫時不可達,上游狀態 {{upstreamStatus}}",
+ "Route Manager 暂时不可达,请检查地址和 shadow 服务": "Route Manager 暫時不可達,請檢查位址和 shadow 服務",
+ "Route Manager 状态检查失败": "Route Manager 狀態檢查失敗",
+ "设置 Route Manager 地址": "設定 Route Manager 位址",
+ "检查 Hub 连通性": "檢查 Hub 連通性",
+ "首页内容已更新": "首頁內容已更新",
+ "首页内容更新失败": "首頁內容更新失敗",
+ "Logo 已更新": "Logo 已更新",
+ "Logo 更新失败": "Logo 更新失敗",
+ "关于内容已更新": "關於內容已更新",
+ "关于内容更新失败": "關於內容更新失敗",
+ "页脚内容已更新": "頁腳內容已更新",
+ "页脚内容更新失败": "頁腳內容更新失敗",
+ "加载首页内容失败...": "載入首頁內容失敗...",
+ "家域中枢已接入主站": "家域中樞已接入主站",
+ "全屏打开中枢": "全螢幕打開中樞",
+ "全屏打开家域中枢": "全螢幕打開家域中樞",
+ "打开 Route Manager Hub 中枢": "打開 Route Manager Hub 中樞",
+ "打开家庭管理控制系统": "打開家庭管理控制系統",
+ "中枢总览": "中樞總覽",
+ "家域中枢总览": "家域中樞總覽",
+ "家域中枢": "家域中樞",
+ "家域中枢值守": "家域中樞值守",
+ "打开中枢": "打開中樞",
+ "打开家域中枢": "打開家域中樞",
+ "已连接": "已連線",
+ "待配置": "待配置",
+ "异常": "異常",
+ "在线": "在線",
+ "离线": "離線",
+ "休眠": "休眠",
+ "待确认": "待確認",
+ "待执行": "待執行",
+ "一般": "一般",
+ "家庭算力编队": "家庭算力編隊",
+ "网络代理状态": "網路代理狀態",
+ "DNS 广告屏蔽": "DNS 廣告攔截",
+ "出口网络画像": "出口網路畫像",
+ "家庭实体桥接": "家庭實體橋接",
+ "在线节点": "在線節點",
+ "活动节点": "活動節點",
+ "待处理任务": "待處理任務",
+ "主站内可直接查看中枢最近运行情况": "主站內可直接查看中樞最近運行情況",
+ "主站内可直接查看家庭控制系统最近运行情况": "主站內可直接查看家庭控制系統最近運行情況",
+ "关键面板": "關鍵面板",
+ "主站内快速切换中枢能力区": "主站內快速切換中樞能力區",
+ "主站内快速切换家庭控制能力区": "主站內快速切換家庭控制能力區",
+ "直达 AI 中心的主算力编队面板": "直達 AI 中心的主算力編隊面板",
+ "直达网络中心的 Mihomo 代理状态面板": "直達網路中心的 Mihomo 代理狀態面板",
+ "直达网络中心的 DNS 广告屏蔽面板": "直達網路中心的 DNS 廣告攔截面板",
+ "直达网络中心的出口网络画像面板": "直達網路中心的出口網路畫像面板",
+ "直达家庭自动化的实体桥接面板": "直達家庭自動化的實體橋接面板",
+ "{{count}} 模型": "{{count}} 模型",
+ "{{count}} 在线 AI 节点": "{{count}} 在線 AI 節點",
+ "净网已开启": "淨網已開啟",
+ "净网待确认": "淨網待確認",
+ "代理可达": "代理可達",
+ "代理待确认": "代理待確認",
+ "DNS 可达": "DNS 可達",
+ "DNS 待确认": "DNS 待確認",
+ "桥接在线": "橋接在線",
+ "桥接待确认": "橋接待確認",
+ "{{count}} 实体": "{{count}} 實體",
+ "主算力 {{name}}": "主算力 {{name}}",
+ "家庭桥接 {{count}} 实体": "家庭橋接 {{count}} 實體",
+ "关键节点": "關鍵節點",
+ "节点中心": "節點中心",
+ "查看节点中心": "查看節點中心",
+ "暂无节点摘要": "暫無節點摘要",
+ "中枢连接正常后,这里会显示最近在线节点和节点入口": "中樞連線正常後,這裡會顯示最近在線節點和節點入口",
+ "关键告警": "關鍵告警",
+ "告警中心": "告警中心",
+ "查看告警中心": "查看告警中心",
+ "未命名告警": "未命名告警",
+ "暂无中枢告警": "暫無中樞告警",
+ "中枢连接正常后,这里会显示最近告警和失败原因入口": "中樞連線正常後,這裡會顯示最近告警和失敗原因入口",
+ "关键计划": "關鍵計畫",
+ "激活": "啟用",
+ "已激活": "已啟用",
+ "查看任务中心": "查看任務中心",
+ "未命名计划": "未命名計畫",
+ "已暂停": "已暫停",
+ "暂无中枢计划": "暫無中樞計畫",
+ "中枢连接正常后,这里会显示最近计划和定时执行入口": "中樞連線正常後,這裡會顯示最近計畫和定時執行入口",
+ "任务中心": "任務中心",
+ "最近任务": "最近任務",
+ "展示最近 3 条调度/执行结果": "展示最近 3 條調度 / 執行結果",
+ "唤醒任务": "喚醒任務",
+ "Shell 任务": "Shell 任務",
+ "Docker 任务": "Docker 任務",
+ "浏览器自动化": "瀏覽器自動化",
+ "本地推理": "本地推理",
+ "网络策略": "網路策略",
+ "家庭动作": "家庭動作",
+ "暂无任务预览": "暫無任務預覽",
+ "暂无中枢任务": "暫無中樞任務",
+ "中枢连接正常后,这里会显示最近任务和节点活动": "中樞連線正常後,這裡會顯示最近任務和節點活動",
+ "严重": "嚴重",
+ "未确认": "未確認",
+ "待处理": "待處理",
+ "家域中枢摘要加载失败,请稍后重试": "家域中樞摘要載入失敗,請稍後重試",
"响应": "響應",
"响应时间": "響應時間",
"商品价格 ID": "商品價格 ID",
@@ -732,7 +841,7 @@
"在此输入用户协议内容,支持 Markdown & HTML 代码": "在此輸入使用者協議內容,支援 Markdown & HTML 程式碼",
"在此输入系统名称": "在此輸入系統名稱",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此輸入隱私政策內容,支援 Markdown & HTML 程式碼",
- "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "在此輸入首頁內容,支援 Markdown & HTML 程式碼,設定後首頁的狀態訊息將不再顯示。如果輸入的是一個連結,則會使用該連結作為 iframe 的 src 屬性,這允許你設定任意網頁作為首頁",
+ "在此输入首页内容,支持 Markdown & HTML 代码。设置后默认首页状态信息将不再显示;如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。同源相对路径如 /hub/ 也可直接嵌入;当嵌入 /hub/ 时,主站仍会在 iframe 上方显示家域中枢摘要。": "在此輸入首頁內容,支援 Markdown & HTML 程式碼。設定後預設首頁狀態資訊將不再顯示;如果輸入的是一個連結,則會使用該連結作為 iframe 的 src 屬性,這允許你設定任意網頁作為首頁。同源相對路徑如 /hub/ 也可直接嵌入;當嵌入 /hub/ 時,主站仍會在 iframe 上方顯示家域中樞摘要。",
"域名IP过滤详细说明": "⚠️此功能為實驗性選項,域名可能解析到多個 IPv4/IPv6 位址,若開啟,請確保 IP 過濾列表覆蓋這些位址,否則可能導致訪問失敗。",
"域名白名单": "域名白名單",
"域名黑名单": "域名黑名單",
@@ -1815,6 +1924,10 @@
"管理员": "管理員",
"管理员区域": "管理員區域",
"管理员暂时未设置任何关于内容": "管理員暫時未設定任何關於內容",
+ "快捷入口": "快捷入口",
+ "打开新窗口": "在新視窗開啟",
+ "页面不存在": "頁面不存在",
+ "当前快捷入口不存在,或你没有访问权限。": "目前的快捷入口不存在,或你沒有存取權限。",
"管理员未开启 Creem 充值!": "管理員未開啟 Creem 儲值!",
"管理员未开启Stripe充值!": "管理員未開啟Stripe儲值!",
"管理员未开启在线充值!": "管理員未開啟在線儲值!",
diff --git a/web/src/pages/About/aboutPageContent.js b/web/src/pages/About/aboutPageContent.js
new file mode 100644
index 000000000000..e64cdb1b6653
--- /dev/null
+++ b/web/src/pages/About/aboutPageContent.js
@@ -0,0 +1,45 @@
+import { marked } from 'marked';
+
+export function isEmbeddableAboutPageURL(value) {
+ return typeof value === 'string' && value.trim().startsWith('https://');
+}
+
+export async function loadAboutPageContent(
+ requestAboutContent,
+ fallbackContent = '',
+) {
+ try {
+ const { success, message, data } = await requestAboutContent();
+
+ if (!success) {
+ return {
+ content: fallbackContent,
+ errorMessage: message || fallbackContent,
+ shouldPersist: false,
+ };
+ }
+
+ if (typeof data !== 'string') {
+ return {
+ content: fallbackContent,
+ errorMessage: message || fallbackContent,
+ shouldPersist: false,
+ };
+ }
+
+ const resolvedContent =
+ !isEmbeddableAboutPageURL(data) ? marked.parse(data) : data;
+
+ return {
+ content: resolvedContent,
+ errorMessage: '',
+ shouldPersist: true,
+ };
+ } catch {
+ return {
+ content: fallbackContent,
+ errorMessage: fallbackContent,
+ shouldPersist: false,
+ };
+ }
+}
diff --git a/web/src/pages/About/aboutPageContent.test.mjs b/web/src/pages/About/aboutPageContent.test.mjs
new file mode 100644
index 000000000000..a65b19869a58
--- /dev/null
+++ b/web/src/pages/About/aboutPageContent.test.mjs
@@ -0,0 +1,68 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ isEmbeddableAboutPageURL,
+ loadAboutPageContent,
+} from './aboutPageContent.js';
+
+describe('isEmbeddableAboutPageURL', () => {
+ test('accepts absolute https urls', () => {
+ expect(isEmbeddableAboutPageURL('https://example.com/about')).toBe(true);
+ });
+
+ test('rejects non-https and markdown content', () => {
+ expect(isEmbeddableAboutPageURL('http://example.com/about')).toBe(false);
+ expect(isEmbeddableAboutPageURL('# about')).toBe(false);
+ });
+});
+
+describe('loadAboutPageContent', () => {
+ test('falls back to the provided message when the API payload is unsuccessful', async () => {
+ const result = await loadAboutPageContent(
+ async () => ({
+ success: false,
+ message: 'backend failed',
+ data: '',
+ }),
+ '加载关于内容失败...',
+ );
+
+ expect(result).toEqual({
+ content: '加载关于内容失败...',
+ errorMessage: 'backend failed',
+ shouldPersist: false,
+ });
+ });
+
+ test('falls back to the provided message when the request rejects', async () => {
+ const result = await loadAboutPageContent(
+ async () => {
+ throw new Error('network failed');
+ },
+ '加载关于内容失败...',
+ );
+
+ expect(result).toEqual({
+ content: '加载关于内容失败...',
+ errorMessage: '加载关于内容失败...',
+ shouldPersist: false,
+ });
+ });
+
+ test('falls back to the provided message when the API returns non-string content', async () => {
+ const result = await loadAboutPageContent(
+ async () => ({
+ success: true,
+ message: '',
+ data: ['unsafe'],
+ }),
+ '加载关于内容失败...',
+ );
+
+ expect(result).toEqual({
+ content: '加载关于内容失败...',
+ errorMessage: '加载关于内容失败...',
+ shouldPersist: false,
+ });
+ });
+});
diff --git a/web/src/pages/About/index.jsx b/web/src/pages/About/index.jsx
index cd4ad8f1e71f..f154f986aef5 100644
--- a/web/src/pages/About/index.jsx
+++ b/web/src/pages/About/index.jsx
@@ -17,37 +17,57 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import React, { useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { API, showError } from '../../helpers';
-import { marked } from 'marked';
import { Empty } from '@douyinfe/semi-ui';
import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import { useTranslation } from 'react-i18next';
+import { useActualTheme } from '../../context/Theme';
+import { postIframeContext } from '../../helpers/iframeContext';
+import {
+ isEmbeddableAboutPageURL,
+ loadAboutPageContent,
+} from './aboutPageContent';
const About = () => {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const [about, setAbout] = useState('');
const [aboutLoaded, setAboutLoaded] = useState(false);
const currentYear = new Date().getFullYear();
+ const actualTheme = useActualTheme();
+ const handleAboutIframeLoad = useCallback(
+ (event) => {
+ postIframeContext(event.currentTarget, {
+ themeMode: actualTheme,
+ lang: i18n.language,
+ });
+ },
+ [actualTheme, i18n.language],
+ );
const displayAbout = async () => {
setAbout(localStorage.getItem('about') || '');
- const res = await API.get('/api/about');
- const { success, message, data } = res.data;
- if (success) {
- let aboutContent = data;
- if (!data.startsWith('https://')) {
- aboutContent = marked.parse(data);
- }
- setAbout(aboutContent);
- localStorage.setItem('about', aboutContent);
+
+ const fallbackContent = t('加载关于内容失败...');
+ const result = await loadAboutPageContent(
+ async () => {
+ const res = await API.get('/api/about');
+ return res.data;
+ },
+ fallbackContent,
+ );
+
+ setAbout(result.content);
+
+ if (result.shouldPersist) {
+ localStorage.setItem('about', result.content);
} else {
- showError(message);
- setAbout(t('加载关于内容失败...'));
+ showError(result.errorMessage);
}
+
setAboutLoaded(true);
};
@@ -153,10 +173,12 @@ const About = () => {
) : (
<>
- {about.startsWith('https://') ? (
+ {isEmbeddableAboutPageURL(about) ? (
) : (
{
+ test('accepts absolute http urls', () => {
+ expect(isEmbeddableHomePageURL('http://example.com/hub')).toBe(true);
+ });
+
+ test('accepts absolute https urls', () => {
+ expect(isEmbeddableHomePageURL('https://example.com/hub')).toBe(true);
+ });
+
+ test('accepts same-origin relative hub paths', () => {
+ expect(isEmbeddableHomePageURL('/hub/')).toBe(true);
+ expect(isEmbeddableHomePageURL('/custom/page')).toBe(true);
+ });
+
+ test('rejects markdown content', () => {
+ expect(isEmbeddableHomePageURL('# hello')).toBe(false);
+ expect(isEmbeddableHomePageURL('**bold**')).toBe(false);
+ });
+});
+
+describe('isRouteManagerHubHomePageURL', () => {
+ test('accepts same-origin hub paths', () => {
+ expect(isRouteManagerHubHomePageURL('/hub/')).toBe(true);
+ expect(isRouteManagerHubHomePageURL('/hub')).toBe(true);
+ expect(isRouteManagerHubHomePageURL('/hub/?view=tasks')).toBe(true);
+ expect(isRouteManagerHubHomePageURL('/hub?view=alerts')).toBe(true);
+ });
+
+ test('accepts same-origin absolute hub urls', () => {
+ expect(
+ isRouteManagerHubHomePageURL(
+ 'https://console.example.com/hub',
+ 'https://console.example.com',
+ ),
+ ).toBe(true);
+ expect(
+ isRouteManagerHubHomePageURL(
+ 'https://console.example.com/hub/',
+ 'https://console.example.com',
+ ),
+ ).toBe(true);
+ expect(
+ isRouteManagerHubHomePageURL(
+ 'https://console.example.com/hub/?view=network',
+ 'https://console.example.com',
+ ),
+ ).toBe(true);
+ });
+
+ test('rejects cross-origin hub urls', () => {
+ expect(
+ isRouteManagerHubHomePageURL(
+ 'https://hub.partner.example/hub/',
+ 'https://console.example.com',
+ ),
+ ).toBe(false);
+ });
+
+ test('rejects non-hub embeddable urls', () => {
+ expect(isRouteManagerHubHomePageURL('/custom/page')).toBe(false);
+ expect(
+ isRouteManagerHubHomePageURL(
+ 'https://console.example.com/docs',
+ 'https://console.example.com',
+ ),
+ ).toBe(false);
+ });
+});
+
+describe('postHomePageIframeContext', () => {
+ test('posts theme and language to the iframe window when available', () => {
+ const calls = [];
+ const iframe = {
+ contentWindow: {
+ postMessage(payload, targetOrigin) {
+ calls.push({ payload, targetOrigin });
+ },
+ },
+ };
+
+ expect(
+ postHomePageIframeContext(iframe, {
+ themeMode: 'dark',
+ lang: 'en',
+ }),
+ ).toBe(true);
+
+ expect(calls).toEqual([
+ {
+ payload: { themeMode: 'dark' },
+ targetOrigin: '*',
+ },
+ {
+ payload: { lang: 'en' },
+ targetOrigin: '*',
+ },
+ ]);
+ });
+
+ test('returns false when the iframe window is unavailable', () => {
+ expect(
+ postHomePageIframeContext(null, {
+ themeMode: 'light',
+ lang: 'zh-CN',
+ }),
+ ).toBe(false);
+ });
+});
+
+describe('loadHomePageContent', () => {
+ test('falls back to the provided message when the API payload is unsuccessful', async () => {
+ const result = await loadHomePageContent(
+ async () => ({
+ success: false,
+ message: 'backend failed',
+ data: '',
+ }),
+ '加载首页内容失败...',
+ );
+
+ expect(result).toEqual({
+ content: '加载首页内容失败...',
+ errorMessage: 'backend failed',
+ shouldPersist: false,
+ });
+ });
+
+ test('falls back to the provided message when the request rejects', async () => {
+ const result = await loadHomePageContent(
+ async () => {
+ throw new Error('network failed');
+ },
+ '加载首页内容失败...',
+ );
+
+ expect(result).toEqual({
+ content: '加载首页内容失败...',
+ errorMessage: '加载首页内容失败...',
+ shouldPersist: false,
+ });
+ });
+
+ test('falls back to the provided message when the API returns non-string content', async () => {
+ const result = await loadHomePageContent(
+ async () => ({
+ success: true,
+ message: '',
+ data: { unsafe: true },
+ }),
+ '加载首页内容失败...',
+ );
+
+ expect(result).toEqual({
+ content: '加载首页内容失败...',
+ errorMessage: '加载首页内容失败...',
+ shouldPersist: false,
+ });
+ });
+});
diff --git a/web/src/pages/Home/index.jsx b/web/src/pages/Home/index.jsx
index c153c1b3da99..7efde3cc1c09 100644
--- a/web/src/pages/Home/index.jsx
+++ b/web/src/pages/Home/index.jsx
@@ -17,7 +17,13 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import React, { useContext, useEffect, useState } from 'react';
+import React, {
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
import {
Button,
Typography,
@@ -25,12 +31,31 @@ import {
ScrollList,
ScrollItem,
} from '@douyinfe/semi-ui';
-import { API, showError, copy, showSuccess } from '../../helpers';
+import {
+ API,
+ buildRouteManagerHubDashboardSnapshot,
+ buildRouteManagerHubQuickHighlights,
+ formatRouteManagerHubStatus,
+ getRouteManagerHubAIFleetHref,
+ getRouteManagerHubDNSSecurityHref,
+ getRouteManagerHubEgressHref,
+ getRouteManagerHubHref,
+ getRouteManagerHubHomeAssistantEntitiesHref,
+ getRouteManagerHubNetworkPanelHref,
+ showError,
+ copy,
+ showSuccess,
+} from '../../helpers';
import { useIsMobile } from '../../hooks/common/useIsMobile';
import { API_ENDPOINTS } from '../../constants/common.constant';
import { StatusContext } from '../../context/Status';
import { useActualTheme } from '../../context/Theme';
-import { marked } from 'marked';
+import {
+ isEmbeddableHomePageURL,
+ isRouteManagerHubHomePageURL,
+ loadHomePageContent,
+ postHomePageIframeContext,
+} from './homePageContent';
import { useTranslation } from 'react-i18next';
import {
IconGithubLogo,
@@ -65,12 +90,39 @@ import {
const { Text } = Typography;
+const EMBEDDED_HUB_QUICK_LINKS = [
+ {
+ key: 'ai',
+ href: getRouteManagerHubAIFleetHref(),
+ },
+ {
+ key: 'network',
+ href: getRouteManagerHubNetworkPanelHref('mihomo'),
+ },
+ {
+ key: 'dns',
+ href: getRouteManagerHubDNSSecurityHref(),
+ },
+ {
+ key: 'egress',
+ href: getRouteManagerHubEgressHref(),
+ },
+ {
+ key: 'ha',
+ href: getRouteManagerHubHomeAssistantEntitiesHref(),
+ },
+];
+
const Home = () => {
const { t, i18n } = useTranslation();
const [statusState] = useContext(StatusContext);
const actualTheme = useActualTheme();
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
const [homePageContent, setHomePageContent] = useState('');
+ const [embeddedHubSnapshot, setEmbeddedHubSnapshot] = useState(() =>
+ buildRouteManagerHubDashboardSnapshot(),
+ );
+ const [embeddedHubSummaryError, setEmbeddedHubSummaryError] = useState('');
const [noticeVisible, setNoticeVisible] = useState(false);
const isMobile = useIsMobile();
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
@@ -80,33 +132,46 @@ const Home = () => {
const endpointItems = API_ENDPOINTS.map((e) => ({ value: e }));
const [endpointIndex, setEndpointIndex] = useState(0);
const isChinese = i18n.language.startsWith('zh');
+ const hubStatus = statusState?.status?.hub_status || null;
+ const formattedHubStatus = hubStatus
+ ? formatRouteManagerHubStatus(hubStatus, t)
+ : null;
+ const routeManagerHubHref = getRouteManagerHubHref();
+ const embeddedHubHighlights = buildRouteManagerHubQuickHighlights(
+ embeddedHubSnapshot,
+ t,
+ );
+ const embeddedHubQuickLinks = useMemo(() => EMBEDDED_HUB_QUICK_LINKS, []);
+ const handleHomePageIframeLoad = useCallback(
+ (event) => {
+ postHomePageIframeContext(event.currentTarget, {
+ themeMode: actualTheme,
+ lang: i18n.language,
+ });
+ },
+ [actualTheme, i18n.language],
+ );
const displayHomePageContent = async () => {
setHomePageContent(localStorage.getItem('home_page_content') || '');
- const res = await API.get('/api/home_page_content');
- const { success, message, data } = res.data;
- if (success) {
- let content = data;
- if (!data.startsWith('https://')) {
- content = marked.parse(data);
- }
- setHomePageContent(content);
- localStorage.setItem('home_page_content', content);
-
- // 如果内容是 URL,则发送主题模式
- if (data.startsWith('https://')) {
- const iframe = document.querySelector('iframe');
- if (iframe) {
- iframe.onload = () => {
- iframe.contentWindow.postMessage({ themeMode: actualTheme }, '*');
- iframe.contentWindow.postMessage({ lang: i18n.language }, '*');
- };
- }
- }
+
+ const fallbackContent = t('加载首页内容失败...');
+ const result = await loadHomePageContent(
+ async () => {
+ const res = await API.get('/api/home_page_content');
+ return res.data;
+ },
+ fallbackContent,
+ );
+
+ setHomePageContent(result.content);
+
+ if (result.shouldPersist) {
+ localStorage.setItem('home_page_content', result.content);
} else {
- showError(message);
- setHomePageContent('加载首页内容失败...');
+ showError(result.errorMessage);
}
+
setHomePageContentLoaded(true);
};
@@ -141,6 +206,59 @@ const Home = () => {
displayHomePageContent().then();
}, []);
+ useEffect(() => {
+ let active = true;
+
+ async function loadEmbeddedHubSummary() {
+ if (
+ !isRouteManagerHubHomePageURL(homePageContent) ||
+ !hubStatus?.configured ||
+ !hubStatus?.reachable
+ ) {
+ if (active) {
+ setEmbeddedHubSnapshot(buildRouteManagerHubDashboardSnapshot());
+ setEmbeddedHubSummaryError('');
+ }
+ return;
+ }
+
+ try {
+ const res = await API.get('/hub/api/dashboard/summary', {
+ skipErrorHandler: true,
+ });
+
+ if (!active) {
+ return;
+ }
+
+ if (res.data?.success) {
+ setEmbeddedHubSnapshot(
+ buildRouteManagerHubDashboardSnapshot(res.data?.data || {}),
+ );
+ setEmbeddedHubSummaryError('');
+ return;
+ }
+
+ setEmbeddedHubSnapshot(buildRouteManagerHubDashboardSnapshot());
+ setEmbeddedHubSummaryError(t('家域中枢摘要加载失败,请稍后重试'));
+ } catch (error) {
+ if (!active) {
+ return;
+ }
+
+ console.error('加载首页家域中枢摘要失败', error);
+ setEmbeddedHubSnapshot(buildRouteManagerHubDashboardSnapshot());
+ setEmbeddedHubSummaryError(t('家域中枢摘要加载失败,请稍后重试'));
+ }
+ }
+
+ void loadEmbeddedHubSummary();
+
+ return () => {
+ active = false;
+ };
+ }, [homePageContent, hubStatus?.configured, hubStatus?.reachable, t]);
+
useEffect(() => {
const timer = setInterval(() => {
setEndpointIndex((prev) => (prev + 1) % endpointItems.length);
@@ -336,11 +454,73 @@ const Home = () => {
) : (
- {homePageContent.startsWith('https://') ? (
-
+ {isEmbeddableHomePageURL(homePageContent) ? (
+ <>
+ {isRouteManagerHubHomePageURL(homePageContent) &&
+ formattedHubStatus ? (
+
+
+
+ {t('家域中枢已接入主站')}
+
+ {formattedHubStatus.message}
+
+ {embeddedHubSummaryError ? (
+ {embeddedHubSummaryError}
+ ) : null}
+
+
+
+ {embeddedHubHighlights.length > 0 ? (
+
+ ) : null}
+
+ ) : null}
+
+ >
) : (
mergeAdminConfig({});
+
export default function SettingsSidebarModulesAdmin(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [statusState, statusDispatch] = useContext(StatusContext);
// 左侧边栏模块管理状态(管理员全局控制)
- const [sidebarModulesAdmin, setSidebarModulesAdmin] = useState({
- chat: {
- enabled: true,
- playground: true,
- chat: true,
- },
- console: {
- enabled: true,
- detail: true,
- token: true,
- log: true,
- midjourney: true,
- task: true,
- },
- personal: {
- enabled: true,
- topup: true,
- personal: true,
- },
- admin: {
- enabled: true,
- channel: true,
- models: true,
- deployment: true,
- redemption: true,
- user: true,
- subscription: true,
- setting: true,
- },
- });
+ const [sidebarModulesAdmin, setSidebarModulesAdmin] = useState(() =>
+ getDefaultSidebarModulesAdmin(),
+ );
// 处理区域级别开关变更
function handleSectionChange(sectionKey) {
@@ -100,37 +76,7 @@ export default function SettingsSidebarModulesAdmin(props) {
// 重置为默认配置
function resetSidebarModules() {
- const defaultModules = {
- chat: {
- enabled: true,
- playground: true,
- chat: true,
- },
- console: {
- enabled: true,
- detail: true,
- token: true,
- log: true,
- midjourney: true,
- task: true,
- },
- personal: {
- enabled: true,
- topup: true,
- personal: true,
- },
- admin: {
- enabled: true,
- channel: true,
- models: true,
- deployment: true,
- redemption: true,
- user: true,
- subscription: true,
- setting: true,
- },
- };
- setSidebarModulesAdmin(defaultModules);
+ setSidebarModulesAdmin(getDefaultSidebarModulesAdmin());
showSuccess(t('已重置为默认配置'));
}
@@ -174,32 +120,9 @@ export default function SettingsSidebarModulesAdmin(props) {
if (props.options && props.options.SidebarModulesAdmin) {
try {
const modules = JSON.parse(props.options.SidebarModulesAdmin);
- setSidebarModulesAdmin(modules);
+ setSidebarModulesAdmin(mergeAdminConfig(modules));
} catch (error) {
- // 使用默认配置
- const defaultModules = {
- chat: { enabled: true, playground: true, chat: true },
- console: {
- enabled: true,
- detail: true,
- token: true,
- log: true,
- midjourney: true,
- task: true,
- },
- personal: { enabled: true, topup: true, personal: true },
- admin: {
- enabled: true,
- channel: true,
- models: true,
- deployment: true,
- redemption: true,
- user: true,
- subscription: true,
- setting: true,
- },
- };
- setSidebarModulesAdmin(defaultModules);
+ setSidebarModulesAdmin(getDefaultSidebarModulesAdmin());
}
}
}, [props.options]);
@@ -233,6 +156,11 @@ export default function SettingsSidebarModulesAdmin(props) {
description: t('绘图任务记录'),
},
{ key: 'task', title: t('任务日志'), description: t('系统任务记录') },
+ {
+ key: 'hub',
+ title: t('家域中枢'),
+ description: t('打开家庭管理控制系统'),
+ },
],
},
{
@@ -382,7 +310,8 @@ export default function SettingsSidebarModulesAdmin(props) {