mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
chore(ui): polish empty states + sidebar icon + i18n page titles
- AppSidebar: switch the inbounds icon from UserOutlined (a single
person — wrong semantic) to ImportOutlined, matching the empty-state
icon and reflecting the actual concept of an incoming entry point.
- usePageTitle: stop hardcoding English titles; resolve them through
i18n (menu.* keys are already translated), so the browser tab now
follows the active language.
- InboundList / NodeList: replace the bare "—" empty cell with a
centered icon + t('noData') message (ImportOutlined for inbounds,
ClusterOutlined for nodes), and swap opacity:0.4 for
var(--ant-color-text-secondary) so the text stays readable on the
light theme's tinted card background.
This commit is contained in:
parent
2bba1d21d2
commit
6286bb8676
6 changed files with 54 additions and 20 deletions
|
|
@ -10,6 +10,7 @@ import {
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
HeartOutlined,
|
HeartOutlined,
|
||||||
|
ImportOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
MoonFilled,
|
MoonFilled,
|
||||||
|
|
@ -18,7 +19,6 @@ import {
|
||||||
SunOutlined,
|
SunOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
UserOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
@ -29,11 +29,11 @@ const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||||
const LOGOUT_KEY = '__logout__';
|
const LOGOUT_KEY = '__logout__';
|
||||||
|
|
||||||
type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
|
type IconName = 'dashboard' | 'inbound' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
|
||||||
|
|
||||||
const iconByName: Record<IconName, ComponentType> = {
|
const iconByName: Record<IconName, ComponentType> = {
|
||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
user: UserOutlined,
|
inbound: ImportOutlined,
|
||||||
team: TeamOutlined,
|
team: TeamOutlined,
|
||||||
setting: SettingOutlined,
|
setting: SettingOutlined,
|
||||||
tool: ToolOutlined,
|
tool: ToolOutlined,
|
||||||
|
|
@ -101,7 +101,7 @@ export default function AppSidebar() {
|
||||||
|
|
||||||
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
|
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
|
||||||
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
|
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
|
||||||
{ key: '/inbounds', icon: 'user', title: t('menu.inbounds') },
|
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
|
||||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
||||||
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const TITLES: Record<string, string> = {
|
const TITLE_KEYS: Record<string, string> = {
|
||||||
'/': 'Overview',
|
'/': 'menu.dashboard',
|
||||||
'/inbounds': 'Inbounds',
|
'/inbounds': 'menu.inbounds',
|
||||||
'/clients': 'Clients',
|
'/clients': 'menu.clients',
|
||||||
'/nodes': 'Nodes',
|
'/nodes': 'menu.nodes',
|
||||||
'/settings': 'Settings',
|
'/settings': 'menu.settings',
|
||||||
'/xray': 'Xray Config',
|
'/xray': 'menu.xray',
|
||||||
'/api-docs': 'API Docs',
|
'/api-docs': 'menu.apiDocs',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePageTitle() {
|
export function usePageTitle() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const title = TITLES[pathname] || '3X-UI';
|
const key = TITLE_KEYS[pathname];
|
||||||
|
const title = key ? t(key) : '3X-UI';
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
document.title = host ? `${host} - ${title}` : title;
|
document.title = host ? `${host} - ${title}` : title;
|
||||||
}, [pathname]);
|
}, [pathname, t]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,8 +132,12 @@
|
||||||
|
|
||||||
.card-empty {
|
.card-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.4;
|
color: var(--ant-color-text-secondary);
|
||||||
padding: 20px 0;
|
padding: 24px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
|
||||||
|
|
@ -600,7 +600,10 @@ export default function InboundList({
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="inbound-cards">
|
<div className="inbound-cards">
|
||||||
{sortedInbounds.length === 0 ? (
|
{sortedInbounds.length === 0 ? (
|
||||||
<div className="card-empty">—</div>
|
<div className="card-empty">
|
||||||
|
<ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
||||||
|
<div>{t('noData')}</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sortedInbounds.map((record) => (
|
sortedInbounds.map((record) => (
|
||||||
<div key={record.id} className="inbound-card">
|
<div key={record.id} className="inbound-card">
|
||||||
|
|
@ -641,6 +644,14 @@ export default function InboundList({
|
||||||
scroll={{ x: 1000 }}
|
scroll={{ x: 1000 }}
|
||||||
style={{ marginTop: 10 }}
|
style={{ marginTop: 10 }}
|
||||||
size="small"
|
size="small"
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<div className="card-empty">
|
||||||
|
<ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||||
|
<div>{t('noData')}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
onChange={(_p, _f, sorter) => {
|
onChange={(_p, _f, sorter) => {
|
||||||
const single = Array.isArray(sorter) ? sorter[0] : sorter;
|
const single = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||||
const colKey = (single?.columnKey || single?.field) as SortKey | undefined;
|
const colKey = (single?.columnKey || single?.field) as SortKey | undefined;
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,10 @@
|
||||||
|
|
||||||
.card-empty {
|
.card-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.4;
|
color: var(--ant-color-text-secondary);
|
||||||
padding: 20px 0;
|
padding: 24px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
import type { BadgeProps } from 'antd';
|
import type { BadgeProps } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
|
ClusterOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
|
|
@ -279,7 +280,10 @@ export default function NodeList({
|
||||||
<>
|
<>
|
||||||
<div className="node-cards">
|
<div className="node-cards">
|
||||||
{dataSource.length === 0 ? (
|
{dataSource.length === 0 ? (
|
||||||
<div className="card-empty">—</div>
|
<div className="card-empty">
|
||||||
|
<ClusterOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
||||||
|
<div>{t('noData')}</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
dataSource.map((record) => (
|
dataSource.map((record) => (
|
||||||
<div key={record.id} className="node-card">
|
<div key={record.id} className="node-card">
|
||||||
|
|
@ -435,6 +439,14 @@ export default function NodeList({
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content' }}
|
||||||
size="middle"
|
size="middle"
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<div className="card-empty">
|
||||||
|
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||||
|
<div>{t('noData')}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
expandable={{
|
expandable={{
|
||||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue