ui: improve desktop/mobile usability and long i18n label layout

- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
ui: improve desktop/mobile usability and long i18n label layout
- Global CSS: wrap long translations, safer forms/modals/tables on mobile
- PageHeader stacks actions on narrow screens
- Users mobile cards show traffic, expiry, remark/tags without losing info
- Layout content width + drawer width polish
This commit is contained in:
風雪 committed 2026-09-13 23:29:23 +08:00
1 parent 4d6cb94145
commit e332cfdac3
7 files changed
+232 -72

No files matched your search

+3 -2
View File
@@ -14,7 +14,6 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
// Close drawer when switching to desktop
useEffect(() => {
if (!isMobile) setDrawerOpen(false);
}, [isMobile]);
@@ -39,6 +38,8 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
overflow: 'auto',
minWidth: 0,
maxWidth: isMobile ? undefined : 1400,
width: isMobile ? undefined : 'calc(100% - 48px)',
alignSelf: isMobile ? undefined : 'center',
}}
>
{children}
@@ -50,7 +51,7 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
placement="left"
open={isMobile && drawerOpen}
onClose={() => setDrawerOpen(false)}
width={280}
width={Math.min(300, typeof window !== 'undefined' ? window.innerWidth - 24 : 280)}
styles={{ body: { padding: 0 } }}
title={null}
closable={false}
+1 -1
View File
@@ -85,7 +85,7 @@ const HeaderBar: React.FC<Props> = ({ collapsed, setCollapsed, onOpenMobileNav }
{!isMobile && (LOCALE_OPTIONS.find((o) => o.key === locale)?.label || locale)}
</Button>
</Dropdown>
<Tag icon={<UserOutlined />} style={{ marginInlineEnd: 0 }}>
<Tag icon={<UserOutlined />} title={displayName} style={{ marginInlineEnd: 0 }}>
{isMobile ? shortName : displayName}
</Tag>
</Space>
+37 -41
View File
@@ -2,56 +2,52 @@ import React from 'react';
import { Typography, Space } from 'antd';
import useIsMobile from '../hooks/useIsMobile';
const { Title } = Typography;
const { Title, Text } = Typography;
/** Consistent page title + optional subtitle/actions for mobile & desktop. */
const PageHeader: React.FC<{
export interface PageHeaderProps {
title: React.ReactNode;
subtitle?: React.ReactNode;
extra?: React.ReactNode;
}> = ({ title, subtitle, extra }) => {
style?: React.CSSProperties;
}
/**
* Shared page title block. Stacks actions under the title on narrow screens
* so long translations (DE/RU/AR/…) remain readable and tappable.
*/
const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, extra, style }) => {
const isMobile = useIsMobile();
const sub =
subtitle === undefined || subtitle === null || subtitle === false || subtitle === ''
? null
: subtitle;
return (
<div
className="page-header"
style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'stretch' : 'flex-start',
justifyContent: 'space-between',
gap: isMobile ? 8 : 16,
marginBottom: isMobile ? 12 : 16,
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
{title}
</Title>
{sub != null ? (
<p
className="page-header-subtitle"
style={{
margin: '6px 0 0',
fontSize: isMobile ? 13 : 14,
lineHeight: 1.45,
opacity: 0.7,
color: 'var(--ant-color-text-secondary, inherit)',
}}
>
{sub}
</p>
<div className="page-header" style={{ marginBottom: isMobile ? 12 : 20, ...style }}>
<div
className="page-header-title-row"
style={{
display: 'flex',
alignItems: isMobile ? 'stretch' : 'flex-start',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0, flex: '1 1 200px' }}>
<Title level={isMobile ? 4 : 3} style={{ margin: 0, lineHeight: 1.3 }}>
{title}
</Title>
{subtitle ? (
<Text type="secondary" className="page-header-subtitle" style={{ display: 'block', marginTop: 4 }}>
{subtitle}
</Text>
) : null}
</div>
{extra ? (
<div className="page-header-actions" style={{ flex: isMobile ? '1 1 100%' : '0 0 auto' }}>
<Space wrap size="small" style={{ width: isMobile ? '100%' : undefined }}>
{extra}
</Space>
</div>
) : null}
</div>
{extra ? (
<Space wrap style={{ width: isMobile ? '100%' : undefined }}>
{extra}
</Space>
) : null}
</div>
);
};
+2 -2
View File
@@ -46,7 +46,7 @@ const Dashboard: React.FC = () => {
return (
<div>
<PageHeader title={t('dashboard.title')} subtitle={t('dashboard.subtitle')} />
<Row gutter={[16, 16]}>
<Row gutter={isMobile ? [12, 12] : [16, 16]}>
<Col xs={24} md={12} lg={8}>
<Card title={t('dashboard.users') || 'Users'}>
<Statistic title={t('dashboard.onlineUsers') || 'Online'} value={users.online ?? data?.onlineUsers ?? 0} />
@@ -73,7 +73,7 @@ const Dashboard: React.FC = () => {
</Col>
<Col xs={24} md={12} lg={8}>
<Card title={t('dashboard.listeners')}>
<Row gutter={16}>
<Row gutter={isMobile ? [12, 12] : 16}>
<Col span={8}><Statistic title={t('dashboard.total')} value={data?.listeners?.total || 0} /></Col>
<Col span={8}><Statistic title={t('dashboard.enabled')} value={data?.listeners?.enabled || 0} valueStyle={{ color: '#3f8600' }} /></Col>
<Col span={8}><Statistic title={t('dashboard.disabled')} value={data?.listeners?.disabled || 0} valueStyle={{ color: '#cf1322' }} /></Col>
+2 -2
View File
@@ -233,7 +233,7 @@ const columns = [
</Spin>
) : (
<Table rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} dataSource={filteredListeners} columns={columns} rowKey="id" loading={loading} scroll={{ x: 880 }} size="middle" />
)}</Card> }, { key: 'templates', label: t('listeners.templates'), children: <Card title={t('listeners.templates')} extra={<Button icon={<ReloadOutlined />} onClick={loadTemplates}>{t('common.refresh')}</Button>}><Table dataSource={templates} columns={templateColumns} rowKey="id" loading={templateLoading} pagination={{ pageSize: 10 }} /></Card> }]} />
)}</Card> }, { key: 'templates', label: t('listeners.templates'), children: <Card title={t('listeners.templates')} extra={<Button icon={<ReloadOutlined />} onClick={loadTemplates}>{t('common.refresh')}</Button>}><Table dataSource={templates} columns={templateColumns} rowKey="id" loading={templateLoading} pagination={{ pageSize: 10 }} size={isMobile ? "small" : "middle"} /></Card> }]} />
<ListenerRuntimeDrawer listener={runtimeListener ? data.find(l => l.id === runtimeListener.id) || runtimeListener : null} status={runtimeListener ? details[runtimeListener.id] || statuses[runtimeListener.id] : undefined} checking={!!runtimeListener && testing.includes(runtimeListener.id)} onClose={() => setRuntimeListener(null)} onCheck={() => { if (runtimeListener) void test(runtimeListener.id).catch(() => {}); }} />
<Modal confirmLoading={submitting} closable={!submitting} maskClosable={!submitting} keyboard={!submitting} cancelButtonProps={{ disabled: submitting }} okText={submitting ? runtimeText.saving : t('common.save')} open={modalOpen} title={editing ? t('listeners.edit') : t('listeners.create')} onCancel={() => { setModalOpen(false); setEditing(null); form.resetFields(); }} onOk={() => form.validateFields().then((vals) => onSubmit(vals))} width={isMobile ? '100%' : 720} style={isMobile ? { top: 8, maxWidth: '100vw', margin: 0, padding: 0 } : undefined} className={isMobile ? 'mobile-full-modal' : undefined} destroyOnClose styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
{submitError && <Alert type="error" showIcon title={runtimeText.failed} description={<><div>{submitError}</div><div>{runtimeText.failedHint}</div></>} style={{ marginBottom: 16 }} />}
@@ -256,7 +256,7 @@ const columns = [
<Modal open={cloneModal} title={t('listeners.clone')} onCancel={() => setCloneModal(false)} onOk={() => cloneForm.submit()}><Form form={cloneForm} layout="vertical" onFinish={doClone}><Form.Item name="name" label={t('listeners.name')} rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="port" label={t('listeners.newPort')} rules={[{ required: true }]}><Input placeholder="443" /></Form.Item></Form></Modal>
<Modal open={templateModal} title={t('listeners.saveTemplate')} onCancel={() => setTemplateModal(false)} onOk={() => templateForm.submit()}><Form form={templateForm} layout="vertical" onFinish={saveTemplate}><Form.Item name="name" label={t('listeners.templateName')} rules={[{ required: true }]}><Input /></Form.Item><Descriptions column={1} size="small"><Descriptions.Item label={t('listeners.protocol')}>{templateSource?.protocol}</Descriptions.Item></Descriptions></Form></Modal>
<Modal open={instantiateModal} title={t('listeners.instantiate')} onCancel={() => setInstantiateModal(false)} onOk={() => instantiateForm.submit()}><Form form={instantiateForm} layout="vertical" onFinish={doInstantiate}><Form.Item name="name" label={t('listeners.name')} rules={[{ required: true }]}><Input /></Form.Item><Form.Item name="port" label={t('listeners.newPort')} rules={[{ required: true }]}><Input placeholder="443" /></Form.Item></Form></Modal>
<Modal open={versionsModal} title={`${t('listeners.versions')} — ${versionListener?.name || ''}`} onCancel={() => setVersionsModal(false)} footer={null} width={800}><Table dataSource={versions} rowKey="id" pagination={false} columns={[{ title: t('listeners.version'), dataIndex: 'version', width: 100 }, { title: t('listeners.reason'), dataIndex: 'reason', render: (v: string) => v || '-' }, { title: t('listeners.createdAt'), dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleString() }, { title: t('common.actions'), render: (_: any, v: ListenerVersion) => <Space><Button size="small" icon={<DiffOutlined />} onClick={() => showDiff(v.version)}>{t('listeners.diff')}</Button><Popconfirm title={t('listeners.rollbackConfirm')} onConfirm={() => doRollback(v.version)}><Button size="small" type="primary">{t('listeners.rollback')}</Button></Popconfirm></Space> }]} /></Modal>
<Modal open={versionsModal} title={`${t('listeners.versions')} — ${versionListener?.name || ''}`} onCancel={() => setVersionsModal(false)} footer={null} width={800}><Table dataSource={versions} rowKey="id" pagination={false} columns={[{ title: t('listeners.version'), dataIndex: 'version', width: 100 }, { title: t('listeners.reason'), dataIndex: 'reason', render: (v: string) = size={isMobile ? "small" : "middle"}> v || '-' }, { title: t('listeners.createdAt'), dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleString() }, { title: t('common.actions'), render: (_: any, v: ListenerVersion) => <Space><Button size="small" icon={<DiffOutlined />} onClick={() => showDiff(v.version)}>{t('listeners.diff')}</Button><Popconfirm title={t('listeners.rollbackConfirm')} onConfirm={() => doRollback(v.version)}><Button size="small" type="primary">{t('listeners.rollback')}</Button></Popconfirm></Space> }]} /></Modal>
<Modal open={diffModal} title={t('listeners.diff')} onCancel={() => setDiffModal(false)} footer={null} width={900}><pre style={{ maxHeight: '65vh', overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 }}>{diffText || t('common.empty')}</pre></Modal>
<Modal open={uriModal} title={t('listeners.urisTitle')} onCancel={() => setUriModal(false)} footer={null} width={isMobile ? '100%' : 560} style={isMobile ? { top: 8 } : undefined} className={isMobile ? 'mobile-full-modal' : undefined}>
<Space direction="vertical" style={{ width: '100%' }}>
+24 -4
View File
@@ -572,10 +572,30 @@ const Users: React.FC = () => {
<Tag color={record.enabled ? 'processing' : 'default'}>
{record.enabled ? t('common.enabled') : t('common.disabled')}
</Tag>
<span className="mobile-entity-sub">
{formatBytes(used)}
{limit > 0 ? ` / ${formatBytes(limit)}` : ` / ${t('users.unlimited')}`}
</span>
{record.group ? <Tag>{record.group}</Tag> : null}
</div>
<div className="mobile-entity-rows">
<div className="mobile-entity-row">
<span className="label">{t('users.traffic')}</span>
<span className="value">
{formatBytes(used)}
{limit > 0 ? ` / ${formatBytes(limit)}` : ` / ${t('users.unlimited')}`}
</span>
</div>
<div className="mobile-entity-row">
<span className="label">{t('users.expire')}</span>
<span className="value">
{!record.expire_time || String(record.expire_time).startsWith('0001')
? t('users.neverExpire')
: dayjs(record.expire_time).format('YYYY-MM-DD')}
</span>
</div>
{(record.tags || record.remark) && (
<div className="mobile-entity-row">
<span className="label">{t('users.remark') || 'Remark'}</span>
<span className="value">{[record.remark, record.tags].filter(Boolean).join(' · ')}</span>
</div>
)}
</div>
</div>
<Dropdown
+163 -20
View File
@@ -1,4 +1,4 @@
/* 3m-ui global layout polish — mobile + desktop */
/* 3m-ui global layout polish — mobile + desktop + long i18n labels */
html, body, #root {
max-width: 100vw;
@@ -9,30 +9,80 @@ body {
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
/* Prevent long translated strings from breaking layout */
.ant-btn > span,
.ant-menu-title-content,
.ant-select-selection-item,
.ant-form-item-label > label,
.ant-table-cell,
.ant-tag {
overflow-wrap: anywhere;
word-break: break-word;
}
.ant-form-item-label > label {
white-space: normal !important;
height: auto !important;
line-height: 1.4 !important;
}
.ant-menu-item,
.ant-menu-submenu-title {
white-space: normal !important;
height: auto !important;
line-height: 1.35 !important;
padding-block: 10px !important;
}
.ant-menu-item .ant-menu-title-content,
.ant-menu-submenu-title .ant-menu-title-content {
white-space: normal;
}
/* Desktop readability */
@media (min-width: 768px) {
.app-page-content {
max-width: 1400px;
width: 100%;
margin-inline: auto !important;
}
.page-header {
margin-bottom: 20px;
}
.page-header h3.ant-typography {
letter-spacing: -0.01em;
}
.page-header .page-header-subtitle {
max-width: 720px;
line-height: 1.5;
}
.ant-card {
border-radius: 10px;
}
.ant-table-wrapper {
border-radius: 8px;
}
/* Comfortable row density */
.ant-table-thead > tr > th {
font-weight: 600;
}
.ant-card + .ant-card {
margin-top: 16px;
}
/* Desktop action rows stay usable with long labels */
.page-toolbar,
.ant-card-extra {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
}
@media (max-width: 767px) {
/* iOS zoom prevention on focus */
input.ant-input,
.ant-input,
.ant-input-number-input,
@@ -47,8 +97,9 @@ body {
top: 0;
z-index: 100;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
background: color-mix(in srgb, var(--ant-color-bg-container, #fff) 88%, transparent) !important;
border-bottom: 1px solid var(--ant-color-border-secondary, rgba(0,0,0,.06));
border-bottom: 1px solid var(--ant-color-border-secondary, rgba(0, 0, 0, 0.06));
height: 52px !important;
line-height: 52px !important;
padding-inline: 8px !important;
@@ -57,6 +108,15 @@ body {
min-width: 40px;
min-height: 40px;
}
.app-header-bar .ant-space {
flex-wrap: nowrap;
gap: 4px !important;
}
.app-header-bar .ant-tag {
max-width: 96px;
overflow: hidden;
text-overflow: ellipsis;
}
.app-page-content {
margin: 8px !important;
@@ -64,6 +124,30 @@ body {
border-radius: 12px !important;
}
.page-header {
margin-bottom: 12px !important;
}
.page-header .page-header-title-row {
flex-direction: column;
align-items: stretch !important;
gap: 8px;
}
.page-header .page-header-actions {
width: 100%;
}
.page-header .page-header-actions .ant-space {
width: 100%;
display: flex;
flex-wrap: wrap;
}
.page-header .page-header-actions .ant-btn {
flex: 1 1 auto;
}
.page-header .page-header-subtitle {
font-size: 13px;
line-height: 1.45;
}
.ant-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
@@ -98,6 +182,7 @@ body {
}
.ant-modal-footer .ant-btn {
margin: 0 !important;
min-height: 40px;
}
.ant-card {
@@ -108,6 +193,11 @@ body {
min-height: auto;
padding: 10px 12px !important;
}
.ant-card-head-title {
white-space: normal !important;
padding: 4px 0 !important;
line-height: 1.35;
}
.ant-card-head-wrapper {
flex-wrap: wrap;
gap: 8px;
@@ -118,36 +208,42 @@ body {
}
.ant-card-extra .ant-space {
width: 100%;
justify-content: flex-start;
display: flex;
flex-wrap: wrap;
}
.ant-card-body {
padding: 12px !important;
}
.ant-space {
flex-wrap: wrap !important;
}
.ant-card-extra .ant-input-search,
.ant-card-extra .ant-input-affix-wrapper {
width: 100% !important;
max-width: 100% !important;
}
.ant-tabs-nav {
margin-bottom: 8px !important;
margin-bottom: 12px !important;
}
.ant-tabs-tab {
padding: 8px 10px !important;
}
.ant-tabs-tab-btn {
white-space: normal;
text-align: center;
}
.ant-form-item {
margin-bottom: 12px !important;
}
.ant-form-item-control-input-content > .ant-input,
.ant-form-item-control-input-content > .ant-input-password,
.ant-form-item-control-input-content > .ant-input-number,
.ant-form-item-control-input-content > .ant-select,
.ant-form-item-control-input-content > .ant-picker {
width: 100% !important;
}
.ant-statistic-content {
font-size: 20px !important;
}
.ant-statistic-title {
white-space: normal;
line-height: 1.3;
}
.settings-section-select .ant-select-selector {
min-height: 44px !important;
@@ -168,6 +264,7 @@ body {
.ant-dropdown-menu-item {
padding: 10px 14px !important;
white-space: normal;
}
.login-page {
@@ -180,11 +277,14 @@ body {
max-width: 100% !important;
}
.ant-btn-primary {
.ant-btn {
min-height: 36px;
}
.ant-btn-primary {
min-height: 40px;
}
/* Entity lists */
/* Entity lists (users / listeners mobile cards) */
.mobile-entity-list {
display: flex;
flex-direction: column;
@@ -194,7 +294,7 @@ body {
border: 1px solid var(--ant-color-border-secondary, #f0f0f0);
border-radius: 12px;
background: var(--ant-color-bg-container, #fff);
padding: 10px 12px;
padding: 12px;
}
.mobile-entity-card-main {
display: flex;
@@ -215,7 +315,7 @@ body {
}
.mobile-entity-sub {
font-weight: 400;
opacity: 0.65;
opacity: 0.7;
font-size: 13px;
}
.mobile-entity-meta {
@@ -223,14 +323,48 @@ body {
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 6px;
margin-top: 8px;
font-size: 13px;
}
.mobile-entity-rows {
display: grid;
gap: 4px;
margin-top: 8px;
font-size: 12.5px;
line-height: 1.4;
opacity: 0.85;
}
.mobile-entity-row {
display: flex;
gap: 8px;
justify-content: space-between;
min-width: 0;
}
.mobile-entity-row .label {
opacity: 0.65;
flex-shrink: 0;
}
.mobile-entity-row .value {
text-align: end;
word-break: break-word;
min-width: 0;
}
.mobile-empty {
text-align: center;
padding: 32px 12px;
opacity: 0.55;
}
/* Drawer nav: comfortable touch targets + wrapping labels */
.app-mobile-drawer .ant-menu-item {
margin-block: 2px !important;
width: calc(100% - 8px) !important;
}
/* Stats grids */
.ant-row {
row-gap: 12px;
}
}
@media (max-width: 480px) {
@@ -238,4 +372,13 @@ body {
margin: 6px !important;
padding: 10px !important;
}
.ant-card-body {
padding: 10px !important;
}
}
/* Dark mode soft adjustments */
html.dark .mobile-entity-card,
[data-theme='dark'] .mobile-entity-card {
border-color: rgba(255, 255, 255, 0.12);
}