feat(web): add first-pass mobile responsive support

This commit is contained in:
shiyu
2026-03-09 11:44:44 +08:00
parent 1cac4f6f98
commit 066bd67273
31 changed files with 1010 additions and 602 deletions
+242 -212
View File
@@ -1,4 +1,4 @@
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd';
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin, Drawer } from 'antd';
import { navGroups } from './nav.ts';
import type { NavItem, NavGroup } from './nav.ts';
import { memo, useEffect, useState, useMemo } from 'react';
@@ -10,7 +10,7 @@ import {
MenuFoldOutlined,
SendOutlined,
WechatOutlined,
WarningOutlined
WarningOutlined,
} from '@ant-design/icons';
import '../styles/sider-menu.css';
import { getLatestVersion } from '../api/config.ts';
@@ -20,6 +20,7 @@ import { useI18n } from '../i18n';
import { useAppWindows } from '../contexts/AppWindowsContext';
import WeChatModal from '../components/WeChatModal';
import { useAuth } from '../contexts/AuthContext';
const { Sider } = Layout;
export interface SideNavProps {
@@ -27,9 +28,20 @@ export interface SideNavProps {
onToggle(): void;
activeKey: string;
onChange(key: string): void;
mobile?: boolean;
open?: boolean;
onClose?: () => void;
}
const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle }: SideNavProps) {
const SideNav = memo(function SideNav({
collapsed,
activeKey,
onChange,
onToggle,
mobile = false,
open = false,
onClose,
}: SideNavProps) {
const status = useSystemStatus();
const { token } = theme.useToken();
const { resolvedMode } = useTheme();
@@ -41,174 +53,170 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
version: string;
body: string;
} | null>(null);
// 根据用户权限过滤导航项
const filteredNavGroups = useMemo(() => {
const isAdmin = user?.is_admin ?? false;
return navGroups
.map(group => ({
.map((group) => ({
...group,
children: group.children.filter(item => !item.adminOnly || isAdmin)
children: group.children.filter((item) => !item.adminOnly || isAdmin),
}))
.filter(group => group.children.length > 0);
.filter((group) => group.children.length > 0);
}, [user]);
useEffect(() => {
getLatestVersion().then(resp => {
getLatestVersion().then((resp) => {
if (resp.latest_version && resp.body) {
setLatestVersion({
version: resp.latest_version,
body: resp.body
body: resp.body,
});
}
});
}, []);
const showVersionModal = () => {
setIsVersionModalOpen(true);
};
const hasUpdate = latestVersion && latestVersion.version !== status?.version;
const { windows, restoreWindow } = useAppWindows();
const minimized = windows.filter(w => w.minimized);
const minimized = windows.filter((w) => w.minimized);
const DEFAULT_APP_ICON =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<rect x="3" y="3" width="18" height="18" rx="4" ry="4" fill="currentColor" />
<rect x="7" y="7" width="10" height="10" rx="2" ry="2" fill="#fff"/>
</svg>`
</svg>`,
);
return (
<>
<Sider
collapsedWidth={60}
collapsible
trigger={null}
collapsed={collapsed}
width={208}
const currentCollapsed = mobile ? false : collapsed;
const handleChange = (key: string) => {
onChange(key);
if (mobile) {
onClose?.();
}
};
const renderNavBody = (bodyCollapsed: boolean, showCollapseButton: boolean) => (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between',
justifyContent: bodyCollapsed ? 'center' : 'space-between',
padding: '0 14px',
fontWeight: 600,
fontSize: 18,
letterSpacing: .5,
flexShrink: 0
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<img
src={status?.logo}
alt="Foxel"
letterSpacing: 0.5,
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', minWidth: 0 }}>
<img
src={status?.logo}
alt="Foxel"
style={{
width: 24,
height: 24,
objectFit: 'contain',
marginRight: bodyCollapsed ? 0 : 8,
...(resolvedMode === 'dark'
? { filter: 'brightness(0) invert(1)' }
: status?.logo?.endsWith('.svg')
? { filter: 'brightness(0) saturate(100%)' }
: {}),
}}
/>
{!bodyCollapsed && (
<span
style={{
width: 24,
height: 24,
objectFit: 'contain',
marginRight: collapsed ? 0 : 8,
...(resolvedMode === 'dark'
? { filter: 'brightness(0) invert(1)' }
: (status?.logo?.endsWith('.svg') ? { filter: 'brightness(0) saturate(100%)' } : {}))
}}
/>
{!collapsed && (
<span style={{ fontWeight: 700, color: resolvedMode === 'dark' ? '#fff' : token.colorText }}>
{status?.title}
</span>
)}
</div>
{/* 展开时显示收缩按钮 */}
{!collapsed && (
<Button
type="text"
icon={<MenuFoldOutlined />}
onClick={onToggle}
style={{ fontSize: 18 }}
/>
)}
</div>
{/* 分组渲染 */}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 4px 8px' }}>
{filteredNavGroups.map((group: NavGroup) => (
<div key={group.key} style={{ marginBottom: 12 }}>
{group.title && (
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: .5,
padding: '6px 10px 4px',
color: token.colorTextTertiary,
textTransform: 'uppercase'
}}
>{t(group.title)}</div>
)}
<Menu
mode="inline"
selectable
inlineIndent={12}
selectedKeys={[activeKey]}
onClick={(e) => onChange(e.key)}
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: t(i.label) }))}
style={{ borderInline: 'none', background: 'transparent' }}
className="sider-menu-group foxel-sider-menu"
/>
</div>
))}
</div>
<div
style={{
bottom: '10px',
position: 'absolute',
width: '100%',
padding: '12px 8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
flexShrink: 0,
borderTop: `1px solid ${token.colorBorderSecondary}`
}}
>
{/* 最小化应用 Dock */}
{!collapsed && minimized.length > 0 && (
<div
style={{
width: '100%',
display: 'flex',
flexDirection: collapsed ? 'column' : 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
flexWrap: collapsed ? 'nowrap' : 'wrap',
maxHeight: collapsed ? 160 : undefined,
overflowY: collapsed ? 'auto' : 'visible',
fontWeight: 700,
color: resolvedMode === 'dark' ? '#fff' : token.colorText,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{minimized.map(w => {
const src = w.app.iconUrl || DEFAULT_APP_ICON;
const title = w.kind === 'file' ? `${w.app.name} - ${w.entry.name}` : w.app.name;
return (
<Tooltip key={w.id} title={title} placement={collapsed ? 'right' : 'top'}>
<Button
shape="circle"
onClick={() => restoreWindow(w.id)}
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
/>
</Tooltip>
);
})}
</div>
{status?.title}
</span>
)}
<div style={{
</div>
{showCollapseButton && !bodyCollapsed && (
<Button type="text" icon={<MenuFoldOutlined />} onClick={onToggle} style={{ fontSize: 18 }} />
)}
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 4px 8px' }}>
{filteredNavGroups.map((group: NavGroup) => (
<div key={group.key} style={{ marginBottom: 12 }}>
{!!group.title && !bodyCollapsed && (
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: 0.5,
padding: '6px 10px 4px',
color: token.colorTextTertiary,
textTransform: 'uppercase',
}}
>
{t(group.title)}
</div>
)}
<Menu
mode="inline"
selectable
inlineIndent={12}
inlineCollapsed={!mobile && bodyCollapsed}
selectedKeys={[activeKey]}
onClick={(e) => handleChange(e.key)}
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: t(i.label) }))}
style={{ borderInline: 'none', background: 'transparent' }}
className="sider-menu-group foxel-sider-menu"
/>
</div>
))}
</div>
<div
style={{
padding: '12px 8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
flexShrink: 0,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}
>
{!bodyCollapsed && minimized.length > 0 && (
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
}}
>
{minimized.map((w) => {
const src = w.app.iconUrl || DEFAULT_APP_ICON;
const title = w.kind === 'file' ? `${w.app.name} - ${w.entry?.name || ''}` : w.app.name;
return (
<Tooltip key={w.id} title={title} placement={bodyCollapsed ? 'right' : 'top'}>
<Button
shape="circle"
onClick={() => restoreWindow(w.id)}
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
/>
</Tooltip>
);
})}
</div>
)}
<div
style={{
fontSize: 12,
color: token.colorTextSecondary,
textAlign: 'center',
@@ -216,67 +224,78 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer'
}} onClick={showVersionModal}>
{hasUpdate ? (
<Tooltip title={t('New version found: {version}', { version: latestVersion?.version || '' })} placement={collapsed ? 'right' : 'top'}>
<a rel="noopener noreferrer"
style={{ textDecoration: 'none' }}>
{collapsed ? (
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<WarningOutlined />} color="warning">
{t('Update available')} [{latestVersion?.version}]
</Tag>
)}
</a>
</Tooltip>
) : (
latestVersion ? (
<Tooltip title={t('You are on the latest: {version}', { version: status?.version || '' })} placement={collapsed ? 'right' : 'top'}>
{collapsed ? (
<Tag icon={<CheckCircleOutlined />} color="success" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<CheckCircleOutlined />} color="success">
{status?.version}
</Tag>
)}
</Tooltip>
cursor: 'pointer',
}}
onClick={() => setIsVersionModalOpen(true)}
>
{hasUpdate ? (
<Tooltip title={t('New version found: {version}', { version: latestVersion?.version || '' })} placement={bodyCollapsed ? 'right' : 'top'}>
<a rel="noopener noreferrer" style={{ textDecoration: 'none' }}>
{bodyCollapsed ? (
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<WarningOutlined />} color="warning">
{t('Update available')} [{latestVersion?.version}]
</Tag>
)}
</a>
</Tooltip>
) : latestVersion ? (
<Tooltip title={t('You are on the latest: {version}', { version: status?.version || '' })} placement={bodyCollapsed ? 'right' : 'top'}>
{bodyCollapsed ? (
<Tag icon={<CheckCircleOutlined />} color="success" style={{ marginInlineEnd: 0 }} />
) : (
collapsed ? null : <Tag>{status?.version}</Tag>
)
)}
</div>
{!collapsed && (
<div style={{ display: 'flex', flexDirection: 'row', gap: 8 }}>
<Button
shape="circle"
icon={<GithubOutlined />}
href="https://github.com/DrizzleTime/Foxel"
target="_blank"
/>
<Button
shape="circle"
icon={<WechatOutlined />}
onClick={() => setIsModalOpen(true)}
/>
<Button
shape="circle"
icon={<SendOutlined />}
href="https://t.me/+thDsBfyqJxZkNTU1"
target="_blank"
/>
<Button
shape="circle"
icon={<FileTextOutlined />}
href="https://foxel.cc"
target="_blank"
/>
</div>
<Tag icon={<CheckCircleOutlined />} color="success">
{status?.version}
</Tag>
)}
</Tooltip>
) : (
!bodyCollapsed && <Tag>{status?.version}</Tag>
)}
</div>
</Sider>
{!bodyCollapsed && (
<div style={{ display: 'flex', flexDirection: 'row', gap: 8 }}>
<Button shape="circle" icon={<GithubOutlined />} href="https://github.com/DrizzleTime/Foxel" target="_blank" />
<Button shape="circle" icon={<WechatOutlined />} onClick={() => setIsModalOpen(true)} />
<Button shape="circle" icon={<SendOutlined />} href="https://t.me/+thDsBfyqJxZkNTU1" target="_blank" />
<Button shape="circle" icon={<FileTextOutlined />} href="https://foxel.cc" target="_blank" />
</div>
)}
</div>
</div>
);
return (
<>
{mobile ? (
<Drawer
placement="left"
open={open}
onClose={onClose}
title={null}
width={280}
styles={{ body: { padding: 0 } }}
>
{renderNavBody(false, false)}
</Drawer>
) : (
<Sider
collapsedWidth={60}
collapsible
trigger={null}
collapsed={collapsed}
width={208}
style={{
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
}}
>
{renderNavBody(currentCollapsed, true)}
</Sider>
)}
<WeChatModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
<Modal
open={isVersionModalOpen}
@@ -318,31 +337,42 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
/>
)}
<Divider titlePlacement="left" plain>{t('Changelog')}</Divider>
<div style={{
maxHeight: '40vh',
overflowY: 'auto',
padding: '8px 16px',
background: token.colorFillAlter,
borderRadius: token.borderRadiusLG,
border: `1px solid ${token.colorBorderSecondary}`
}}>
<Divider titlePlacement="left" plain>
{t('Changelog')}
</Divider>
<div
style={{
maxHeight: '40vh',
overflowY: 'auto',
padding: '8px 16px',
background: token.colorFillAlter,
borderRadius: token.borderRadiusLG,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<ReactMarkdown
components={{
h3: ({ ...props }) => <h3 style={{
fontSize: 16,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
paddingBottom: 8,
marginTop: 24,
marginBottom: 16,
color: token.colorTextHeading
}} {...props} />,
h3: ({ ...props }) => (
<h3
style={{
fontSize: 16,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
paddingBottom: 8,
marginTop: 24,
marginBottom: 16,
color: token.colorTextHeading,
}}
{...props}
/>
),
ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />,
li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />,
p: ({ ...props }) => <p style={{ marginBottom: 8 }} {...props} />,
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
}}
>{latestVersion.body}</ReactMarkdown>
>
{latestVersion.body}
</ReactMarkdown>
</div>
</>
) : (
+32 -12
View File
@@ -10,6 +10,7 @@ import { useAuth } from '../contexts/AuthContext';
import ProfileModal from '../components/ProfileModal';
import NoticesModal from '../components/NoticesModal';
import { useSystemStatus } from '../contexts/SystemContext';
import useResponsive from '../hooks/useResponsive';
const { Header } = Layout;
@@ -17,9 +18,10 @@ export interface TopHeaderProps {
collapsed: boolean;
onToggle(): void;
onOpenAiAgent(): void;
showMenuButton?: boolean;
}
const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }: TopHeaderProps) {
const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent, showMenuButton }: TopHeaderProps) {
const { token } = theme.useToken();
const [searchOpen, setSearchOpen] = useState(false);
const navigate = useNavigate();
@@ -28,6 +30,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
const [profileOpen, setProfileOpen] = useState(false);
const [noticesOpen, setNoticesOpen] = useState(false);
const status = useSystemStatus();
const { isMobile } = useResponsive();
const handleLogout = () => {
authApi.logout();
@@ -37,24 +40,39 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
const openProfile = () => setProfileOpen(true);
return (
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16, backdropFilter: 'saturate(180%) blur(8px)' }}>
{collapsed && (
<Header
style={{
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
gap: isMobile ? 8 : 16,
paddingInline: isMobile ? 12 : 16,
minWidth: 0,
backdropFilter: 'saturate(180%) blur(8px)',
}}
>
{showMenuButton && (
<Button
type="text"
icon={<MenuUnfoldOutlined />}
onClick={onToggle}
style={{ fontSize: 18, marginRight: 8 }}
style={{ fontSize: 18, marginRight: isMobile ? 0 : 8 }}
aria-label={collapsed ? t('Open menu') : t('Collapse menu')}
/>
)}
<Button
icon={<SearchOutlined />}
style={{ maxWidth: 420 }}
style={{ maxWidth: isMobile ? 40 : 420, minWidth: isMobile ? 40 : undefined, paddingInline: isMobile ? 0 : undefined }}
onClick={() => setSearchOpen(true)}
aria-label={t('Search files / tags / types')}
>
{t('Search files / tags / types')}
{!isMobile && t('Search files / tags / types')}
</Button>
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
<Flex style={{ marginLeft: 'auto', minWidth: 0 }} align="center" gap={isMobile ? 4 : 12}>
<Tooltip title={t('Notices')}>
<Button
type="text"
@@ -78,8 +96,8 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
menu={{
items: [
{ key: 'profile', label: t('Profile'), icon: <UserOutlined />, onClick: openProfile },
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout }
]
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout },
],
}}
>
<Button type="text" style={{ paddingInline: 8, height: 40 }}>
@@ -87,9 +105,11 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
<Avatar size={28} src={user?.gravatar_url}>
{(user?.full_name || user?.username || 'A').charAt(0).toUpperCase()}
</Avatar>
<Typography.Text style={{ maxWidth: 160 }} ellipsis>
{user?.full_name || user?.username || t('Admin')}
</Typography.Text>
{!isMobile && (
<Typography.Text style={{ maxWidth: 160 }} ellipsis>
{user?.full_name || user?.username || t('Admin')}
</Typography.Text>
)}
</Flex>
</Button>
</Dropdown>