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

View File

@@ -3,6 +3,7 @@ import { Space, Button } from 'antd';
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons'; import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons';
import type { AppDescriptor, AppComponentProps, AppOpenComponentProps } from './types'; import type { AppDescriptor, AppComponentProps, AppOpenComponentProps } from './types';
import type { VfsEntry } from '../api/client'; import type { VfsEntry } from '../api/client';
import useResponsive from '../hooks/useResponsive';
export interface AppWindowItem { export interface AppWindowItem {
id: string; id: string;
@@ -29,6 +30,7 @@ interface AppWindowsLayerProps {
} }
export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClose, onToggleMax, onBringToFront, onUpdateWindow }) => { export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClose, onToggleMax, onBringToFront, onUpdateWindow }) => {
const { isMobile } = useResponsive();
const dragRef = useRef<{ const dragRef = useRef<{
id: string; id: string;
startX: number; startX: number;
@@ -124,6 +126,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
}, [onMouseMove, onMouseUp]); }, [onMouseMove, onMouseUp]);
const startDrag = (e: React.MouseEvent, w: AppWindowItem) => { const startDrag = (e: React.MouseEvent, w: AppWindowItem) => {
if (isMobile) return;
if (e.detail === 2) return; if (e.detail === 2) return;
if (w.maximized) return; if (w.maximized) return;
if ((e.target as HTMLElement).closest('button')) return; if ((e.target as HTMLElement).closest('button')) return;
@@ -141,6 +144,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
const startResize = (e: React.MouseEvent, w: AppWindowItem, dir: string) => { const startResize = (e: React.MouseEvent, w: AppWindowItem, dir: string) => {
e.stopPropagation(); e.stopPropagation();
if (isMobile) return;
if (w.maximized) return; if (w.maximized) return;
onBringToFront(w.id); onBringToFront(w.id);
resizeRef.current = { resizeRef.current = {
@@ -202,6 +206,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
const ContentComp = (isFileWindow ? FileComp : OpenComp) as React.FC<any> | undefined; const ContentComp = (isFileWindow ? FileComp : OpenComp) as React.FC<any> | undefined;
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
const titleText = isFileWindow ? `${w.app.name} - ${w.entry?.name || ''}` : w.app.name; const titleText = isFileWindow ? `${w.app.name} - ${w.entry?.name || ''}` : w.app.name;
const effectiveMaximized = isMobile || w.maximized;
if (!ContentComp) { if (!ContentComp) {
return null; return null;
@@ -215,10 +220,10 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
onMouseDown={() => onBringToFront(w.id)} onMouseDown={() => onBringToFront(w.id)}
style={{ style={{
position: 'fixed', position: 'fixed',
top: w.maximized ? 0 : w.y, top: effectiveMaximized ? 0 : w.y,
left: w.maximized ? 0 : w.x, left: effectiveMaximized ? 0 : w.x,
width: w.maximized ? '100vw' : w.width, width: effectiveMaximized ? '100vw' : w.width,
height: w.maximized ? '100vh' : w.height, height: effectiveMaximized ? '100dvh' : w.height,
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderRadius: 0, borderRadius: 0,
@@ -259,14 +264,14 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
onMouseDown={() => onBringToFront(w.id)} onMouseDown={() => onBringToFront(w.id)}
style={{ style={{
position: 'fixed', position: 'fixed',
top: w.maximized ? 0 : w.y, top: effectiveMaximized ? 0 : w.y,
left: w.maximized ? 0 : w.x, left: effectiveMaximized ? 0 : w.x,
width: w.maximized ? '100vw' : w.width, width: effectiveMaximized ? '100vw' : w.width,
height: w.maximized ? '100vh' : w.height, height: effectiveMaximized ? '100dvh' : w.height,
background: 'var(--ant-color-bg-elevated, var(--ant-color-bg-container))', background: 'var(--ant-color-bg-elevated, var(--ant-color-bg-container))',
border: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.18))', border: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.18))',
borderRadius: w.maximized ? 0 : 12, borderRadius: effectiveMaximized ? 0 : 12,
boxShadow: w.maximized boxShadow: effectiveMaximized
? 'none' ? 'none'
: interacting : interacting
? '0 20px 50px -12px rgba(0,0,0,0.35)' ? '0 20px 50px -12px rgba(0,0,0,0.35)'
@@ -282,9 +287,11 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
> >
<div <div
onMouseDown={(e) => startDrag(e, w)} onMouseDown={(e) => startDrag(e, w)}
onDoubleClick={() => onToggleMax(w.id)} onDoubleClick={() => {
if (!isMobile) onToggleMax(w.id);
}}
style={{ style={{
height: 40, height: isMobile ? 48 : 40,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -296,7 +303,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
fontWeight: 600, fontWeight: 600,
letterSpacing: .2, letterSpacing: .2,
userSelect: 'none', userSelect: 'none',
cursor: w.maximized ? 'default' : 'grab' cursor: effectiveMaximized ? 'default' : 'grab'
}} }}
> >
<span <span
@@ -311,36 +318,40 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
{titleText} {titleText}
</span> </span>
<Space size={4}> <Space size={4}>
<Button {!isMobile && (
type="text" <Button
size="small" type="text"
aria-label="最小化" size="small"
icon={<MinusOutlined />} aria-label="最小化"
onClick={() => onUpdateWindow(w.id, { minimized: true })} icon={<MinusOutlined />}
style={{ onClick={() => onUpdateWindow(w.id, { minimized: true })}
color: 'var(--ant-color-text-secondary, #555)', style={{
width: 30, color: 'var(--ant-color-text-secondary, #555)',
height: 30, width: 30,
display: 'flex', height: 30,
alignItems: 'center', display: 'flex',
justifyContent: 'center' alignItems: 'center',
}} justifyContent: 'center'
/> }}
<Button />
type="text" )}
size="small" {!isMobile && (
aria-label={w.maximized ? '还原' : '最大化'} <Button
icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />} type="text"
onClick={() => onToggleMax(w.id)} size="small"
style={{ aria-label={w.maximized ? '还原' : '最大化'}
color: 'var(--ant-color-text-secondary, #555)', icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
width: 30, onClick={() => onToggleMax(w.id)}
height: 30, style={{
display: 'flex', color: 'var(--ant-color-text-secondary, #555)',
alignItems: 'center', width: 30,
justifyContent: 'center' height: 30,
}} display: 'flex',
/> alignItems: 'center',
justifyContent: 'center'
}}
/>
)}
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -367,7 +378,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
overflow: 'hidden' overflow: 'hidden'
}} }}
> >
{!w.maximized && resizeHandles(w)} {!effectiveMaximized && !isMobile && resizeHandles(w)}
{isFileWindow ? ( {isFileWindow ? (
<ContentComp <ContentComp
filePath={w.filePath || ''} filePath={w.filePath || ''}

View File

@@ -2,7 +2,25 @@ import { Card, type CardProps } from 'antd';
import { memo } from 'react'; import { memo } from 'react';
const PageCard = memo((props: CardProps) => { const PageCard = memo((props: CardProps) => {
return <Card styles={{ body: { overflowY: 'auto', height: 'calc(100vh - 145px)' } }} {...props} />; const bodyStyles = (props.styles as { body?: React.CSSProperties } | undefined)?.body;
return (
<Card
{...props}
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...(props.style || {}) }}
styles={{
body: {
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
display: 'flex',
flexDirection: 'column',
...(bodyStyles || {}),
},
} as any}
/>
);
}); });
export default PageCard; export default PageCard;

View File

@@ -1,5 +1,5 @@
html,body,#root { height: 100%; } html,body,#root { min-height: 100%; height: 100%; }
body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background: var(--ant-color-bg-layout, #f9f9f9); } body { margin: 0; font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background: var(--ant-color-bg-layout, #f9f9f9); }
::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
@@ -283,3 +283,54 @@ body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto
.plugins-tabs .ant-tabs-tabpane-active { .plugins-tabs .ant-tabs-tabpane-active {
display: flex; display: flex;
} }
@media (max-width: 767px) {
html, body, #root {
min-height: 100dvh;
}
body {
overflow-x: hidden;
}
.fx-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.fx-grid-item {
padding: 10px;
border-radius: 12px;
}
.fx-grid-item .thumb {
height: 104px;
}
.ant-table-wrapper .ant-table-content {
overflow-x: auto;
}
.ant-table-wrapper table {
min-width: max-content;
}
.ant-drawer .ant-drawer-content-wrapper {
max-width: 100vw !important;
}
.ant-drawer-left > .ant-drawer-content-wrapper,
.ant-drawer-right > .ant-drawer-content-wrapper {
width: 100vw !important;
}
.ant-modal-root .ant-modal {
max-width: calc(100vw - 16px) !important;
width: calc(100vw - 16px) !important;
margin: 8px auto;
}
.ant-modal-root .ant-modal .ant-modal-content {
padding: 16px;
}
}

View File

@@ -0,0 +1,14 @@
import { Grid } from 'antd';
export function useResponsive() {
const screens = Grid.useBreakpoint();
return {
screens,
isMobile: !screens.md,
isTablet: !!screens.md && !screens.xl,
isDesktop: !!screens.md,
};
}
export default useResponsive;

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 { navGroups } from './nav.ts';
import type { NavItem, NavGroup } from './nav.ts'; import type { NavItem, NavGroup } from './nav.ts';
import { memo, useEffect, useState, useMemo } from 'react'; import { memo, useEffect, useState, useMemo } from 'react';
@@ -10,7 +10,7 @@ import {
MenuFoldOutlined, MenuFoldOutlined,
SendOutlined, SendOutlined,
WechatOutlined, WechatOutlined,
WarningOutlined WarningOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import '../styles/sider-menu.css'; import '../styles/sider-menu.css';
import { getLatestVersion } from '../api/config.ts'; import { getLatestVersion } from '../api/config.ts';
@@ -20,6 +20,7 @@ import { useI18n } from '../i18n';
import { useAppWindows } from '../contexts/AppWindowsContext'; import { useAppWindows } from '../contexts/AppWindowsContext';
import WeChatModal from '../components/WeChatModal'; import WeChatModal from '../components/WeChatModal';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
const { Sider } = Layout; const { Sider } = Layout;
export interface SideNavProps { export interface SideNavProps {
@@ -27,9 +28,20 @@ export interface SideNavProps {
onToggle(): void; onToggle(): void;
activeKey: string; activeKey: string;
onChange(key: string): void; 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 status = useSystemStatus();
const { token } = theme.useToken(); const { token } = theme.useToken();
const { resolvedMode } = useTheme(); const { resolvedMode } = useTheme();
@@ -41,174 +53,170 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
version: string; version: string;
body: string; body: string;
} | null>(null); } | null>(null);
// 根据用户权限过滤导航项
const filteredNavGroups = useMemo(() => { const filteredNavGroups = useMemo(() => {
const isAdmin = user?.is_admin ?? false; const isAdmin = user?.is_admin ?? false;
return navGroups return navGroups
.map(group => ({ .map((group) => ({
...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]); }, [user]);
useEffect(() => { useEffect(() => {
getLatestVersion().then(resp => { getLatestVersion().then((resp) => {
if (resp.latest_version && resp.body) { if (resp.latest_version && resp.body) {
setLatestVersion({ setLatestVersion({
version: resp.latest_version, version: resp.latest_version,
body: resp.body body: resp.body,
}); });
} }
}); });
}, []); }, []);
const showVersionModal = () => {
setIsVersionModalOpen(true);
};
const hasUpdate = latestVersion && latestVersion.version !== status?.version; const hasUpdate = latestVersion && latestVersion.version !== status?.version;
const { windows, restoreWindow } = useAppWindows(); const { windows, restoreWindow } = useAppWindows();
const minimized = windows.filter(w => w.minimized); const minimized = windows.filter((w) => w.minimized);
const DEFAULT_APP_ICON = const DEFAULT_APP_ICON =
'data:image/svg+xml;utf8,' + 'data:image/svg+xml;utf8,' +
encodeURIComponent( encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"> `<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="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"/> <rect x="7" y="7" width="10" height="10" rx="2" ry="2" fill="#fff"/>
</svg>` </svg>`,
); );
return ( const currentCollapsed = mobile ? false : collapsed;
<>
<Sider const handleChange = (key: string) => {
collapsedWidth={60} onChange(key);
collapsible if (mobile) {
trigger={null} onClose?.();
collapsed={collapsed} }
width={208} };
const renderNavBody = (bodyCollapsed: boolean, showCollapseButton: boolean) => (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{ style={{
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{
height: 56, height: 56,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between', justifyContent: bodyCollapsed ? 'center' : 'space-between',
padding: '0 14px', padding: '0 14px',
fontWeight: 600, fontWeight: 600,
fontSize: 18, fontSize: 18,
letterSpacing: .5, letterSpacing: 0.5,
flexShrink: 0 flexShrink: 0,
}}> }}
<div style={{ display: 'flex', alignItems: 'center' }}> >
<img <div style={{ display: 'flex', alignItems: 'center', minWidth: 0 }}>
src={status?.logo} <img
alt="Foxel" 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={{ style={{
width: 24, fontWeight: 700,
height: 24, color: resolvedMode === 'dark' ? '#fff' : token.colorText,
objectFit: 'contain', overflow: 'hidden',
marginRight: collapsed ? 0 : 8, textOverflow: 'ellipsis',
...(resolvedMode === 'dark' whiteSpace: 'nowrap',
? { 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',
}} }}
> >
{minimized.map(w => { {status?.title}
const src = w.app.iconUrl || DEFAULT_APP_ICON; </span>
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>
)} )}
<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, fontSize: 12,
color: token.colorTextSecondary, color: token.colorTextSecondary,
textAlign: 'center', textAlign: 'center',
@@ -216,67 +224,78 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
cursor: 'pointer' cursor: 'pointer',
}} onClick={showVersionModal}> }}
{hasUpdate ? ( onClick={() => setIsVersionModalOpen(true)}
<Tooltip title={t('New version found: {version}', { version: latestVersion?.version || '' })} placement={collapsed ? 'right' : 'top'}> >
<a rel="noopener noreferrer" {hasUpdate ? (
style={{ textDecoration: 'none' }}> <Tooltip title={t('New version found: {version}', { version: latestVersion?.version || '' })} placement={bodyCollapsed ? 'right' : 'top'}>
{collapsed ? ( <a rel="noopener noreferrer" style={{ textDecoration: 'none' }}>
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} /> {bodyCollapsed ? (
) : ( <Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
<Tag icon={<WarningOutlined />} color="warning"> ) : (
{t('Update available')} [{latestVersion?.version}] <Tag icon={<WarningOutlined />} color="warning">
</Tag> {t('Update available')} [{latestVersion?.version}]
)} </Tag>
</a> )}
</Tooltip> </a>
) : ( </Tooltip>
latestVersion ? ( ) : latestVersion ? (
<Tooltip title={t('You are on the latest: {version}', { version: status?.version || '' })} placement={collapsed ? 'right' : 'top'}> <Tooltip title={t('You are on the latest: {version}', { version: status?.version || '' })} placement={bodyCollapsed ? 'right' : 'top'}>
{collapsed ? ( {bodyCollapsed ? (
<Tag icon={<CheckCircleOutlined />} color="success" style={{ marginInlineEnd: 0 }} /> <Tag icon={<CheckCircleOutlined />} color="success" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<CheckCircleOutlined />} color="success">
{status?.version}
</Tag>
)}
</Tooltip>
) : ( ) : (
collapsed ? null : <Tag>{status?.version}</Tag> <Tag icon={<CheckCircleOutlined />} color="success">
) {status?.version}
)} </Tag>
</div> )}
{!collapsed && ( </Tooltip>
<div style={{ display: 'flex', flexDirection: 'row', gap: 8 }}> ) : (
<Button !bodyCollapsed && <Tag>{status?.version}</Tag>
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>
</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)} /> <WeChatModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
<Modal <Modal
open={isVersionModalOpen} open={isVersionModalOpen}
@@ -318,31 +337,42 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
/> />
)} )}
<Divider titlePlacement="left" plain>{t('Changelog')}</Divider> <Divider titlePlacement="left" plain>
<div style={{ {t('Changelog')}
maxHeight: '40vh', </Divider>
overflowY: 'auto', <div
padding: '8px 16px', style={{
background: token.colorFillAlter, maxHeight: '40vh',
borderRadius: token.borderRadiusLG, overflowY: 'auto',
border: `1px solid ${token.colorBorderSecondary}` padding: '8px 16px',
}}> background: token.colorFillAlter,
borderRadius: token.borderRadiusLG,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<ReactMarkdown <ReactMarkdown
components={{ components={{
h3: ({ ...props }) => <h3 style={{ h3: ({ ...props }) => (
fontSize: 16, <h3
borderBottom: `1px solid ${token.colorBorderSecondary}`, style={{
paddingBottom: 8, fontSize: 16,
marginTop: 24, borderBottom: `1px solid ${token.colorBorderSecondary}`,
marginBottom: 16, paddingBottom: 8,
color: token.colorTextHeading marginTop: 24,
}} {...props} />, marginBottom: 16,
color: token.colorTextHeading,
}}
{...props}
/>
),
ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />, ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />,
li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />, li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />,
p: ({ ...props }) => <p 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> </div>
</> </>
) : ( ) : (

View File

@@ -10,6 +10,7 @@ import { useAuth } from '../contexts/AuthContext';
import ProfileModal from '../components/ProfileModal'; import ProfileModal from '../components/ProfileModal';
import NoticesModal from '../components/NoticesModal'; import NoticesModal from '../components/NoticesModal';
import { useSystemStatus } from '../contexts/SystemContext'; import { useSystemStatus } from '../contexts/SystemContext';
import useResponsive from '../hooks/useResponsive';
const { Header } = Layout; const { Header } = Layout;
@@ -17,9 +18,10 @@ export interface TopHeaderProps {
collapsed: boolean; collapsed: boolean;
onToggle(): void; onToggle(): void;
onOpenAiAgent(): 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 { token } = theme.useToken();
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -28,6 +30,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
const [profileOpen, setProfileOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false);
const [noticesOpen, setNoticesOpen] = useState(false); const [noticesOpen, setNoticesOpen] = useState(false);
const status = useSystemStatus(); const status = useSystemStatus();
const { isMobile } = useResponsive();
const handleLogout = () => { const handleLogout = () => {
authApi.logout(); authApi.logout();
@@ -37,24 +40,39 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
const openProfile = () => setProfileOpen(true); const openProfile = () => setProfileOpen(true);
return ( return (
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16, backdropFilter: 'saturate(180%) blur(8px)' }}> <Header
{collapsed && ( 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 <Button
type="text" type="text"
icon={<MenuUnfoldOutlined />} icon={<MenuUnfoldOutlined />}
onClick={onToggle} onClick={onToggle}
style={{ fontSize: 18, marginRight: 8 }} style={{ fontSize: 18, marginRight: isMobile ? 0 : 8 }}
aria-label={collapsed ? t('Open menu') : t('Collapse menu')}
/> />
)} )}
<Button <Button
icon={<SearchOutlined />} icon={<SearchOutlined />}
style={{ maxWidth: 420 }} style={{ maxWidth: isMobile ? 40 : 420, minWidth: isMobile ? 40 : undefined, paddingInline: isMobile ? 0 : undefined }}
onClick={() => setSearchOpen(true)} onClick={() => setSearchOpen(true)}
aria-label={t('Search files / tags / types')}
> >
{t('Search files / tags / types')} {!isMobile && t('Search files / tags / types')}
</Button> </Button>
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} /> <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')}> <Tooltip title={t('Notices')}>
<Button <Button
type="text" type="text"
@@ -78,8 +96,8 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
menu={{ menu={{
items: [ items: [
{ key: 'profile', label: t('Profile'), icon: <UserOutlined />, onClick: openProfile }, { 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 }}> <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}> <Avatar size={28} src={user?.gravatar_url}>
{(user?.full_name || user?.username || 'A').charAt(0).toUpperCase()} {(user?.full_name || user?.username || 'A').charAt(0).toUpperCase()}
</Avatar> </Avatar>
<Typography.Text style={{ maxWidth: 160 }} ellipsis> {!isMobile && (
{user?.full_name || user?.username || t('Admin')} <Typography.Text style={{ maxWidth: 160 }} ellipsis>
</Typography.Text> {user?.full_name || user?.username || t('Admin')}
</Typography.Text>
)}
</Flex> </Flex>
</Button> </Button>
</Dropdown> </Dropdown>

View File

@@ -202,7 +202,7 @@ const AdaptersPage = memo(function AdaptersPage() {
<PageCard <PageCard
title={t('Storage Adapters')} title={t('Storage Adapters')}
extra={ extra={
<Space> <Space wrap>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button> <Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Button type="primary" onClick={openCreate}>{t('Create Adapter')}</Button> <Button type="primary" onClick={openCreate}>{t('Create Adapter')}</Button>
</Space> </Space>
@@ -214,6 +214,7 @@ const AdaptersPage = memo(function AdaptersPage() {
columns={columns as any} columns={columns as any}
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }} style={{ marginBottom: 0 }}
/> />
<Drawer <Drawer

View File

@@ -3,6 +3,7 @@ import { Table, message, Tag, Input, Select, Button, Space, Modal, DatePicker, D
import PageCard from '../components/PageCard'; import PageCard from '../components/PageCard';
import { auditApi, type AuditLogItem, type PaginatedAuditLogs } from '../api/audit'; import { auditApi, type AuditLogItem, type PaginatedAuditLogs } from '../api/audit';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import useResponsive from '../hooks/useResponsive';
import { format, formatISO } from 'date-fns'; import { format, formatISO } from 'date-fns';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@@ -47,6 +48,7 @@ const renderHttpMethodTag = (method: string) => {
}; };
const AuditLogsPage = memo(function AuditLogsPage() { const AuditLogsPage = memo(function AuditLogsPage() {
const { isMobile } = useResponsive();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<PaginatedAuditLogs | null>(null); const [data, setData] = useState<PaginatedAuditLogs | null>(null);
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
@@ -264,7 +266,7 @@ const AuditLogsPage = memo(function AuditLogsPage() {
{selectedLog && ( {selectedLog && (
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions <Descriptions
column={2} column={isMobile ? 1 : 2}
bordered bordered
size="small" size="small"
labelStyle={{ minWidth: 120, whiteSpace: 'nowrap', fontWeight: 500 }} labelStyle={{ minWidth: 120, whiteSpace: 'nowrap', fontWeight: 500 }}

View File

@@ -29,10 +29,12 @@ import { SearchResultsView } from './components/SearchResultsView';
import type { ViewMode } from './types'; import type { ViewMode } from './types';
import { vfsApi, type VfsEntry } from '../../api/client'; import { vfsApi, type VfsEntry } from '../../api/client';
import { LoadingSkeleton } from './components/LoadingSkeleton'; import { LoadingSkeleton } from './components/LoadingSkeleton';
import useResponsive from '../../hooks/useResponsive';
const FileExplorerPage = memo(function FileExplorerPage() { const FileExplorerPage = memo(function FileExplorerPage() {
const { navKey = 'files', '*': restPath = '' } = useParams(); const { navKey = 'files', '*': restPath = '' } = useParams();
const { token } = theme.useToken(); const { token } = theme.useToken();
const { isMobile } = useResponsive();
const [viewMode, setViewMode] = useState<ViewMode>('grid'); const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [showSkeleton, setShowSkeleton] = useState(false); const [showSkeleton, setShowSkeleton] = useState(false);
@@ -43,7 +45,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey); const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection(); const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows(); const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu(); const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, openContextMenuAt, closeContextMenus } = useContextMenu();
const uploader = useUploader(path, refresh); const uploader = useUploader(path, refresh);
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader; const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
const { thumbs } = useThumbnails(entries, path); const { thumbs } = useThumbnails(entries, path);
@@ -91,6 +93,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
openResult: openSearchResult, openResult: openSearchResult,
selectResult: selectSearchResult, selectResult: selectSearchResult,
openResultContextMenu: openSearchContextMenu, openResultContextMenu: openSearchContextMenu,
openResultContextMenuAt: openSearchContextMenuAt,
clearSelection: clearSearchSelection, clearSelection: clearSearchSelection,
} = fileSearch; } = fileSearch;
@@ -103,6 +106,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
load(routePath, 1, pagination.pageSize, sortBy, sortOrder); load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]); }, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
useEffect(() => {
if (isMobile && viewMode !== 'grid') {
setViewMode('grid');
}
}, [isMobile, viewMode]);
const effectiveRefresh = useCallback(() => { const effectiveRefresh = useCallback(() => {
if (isSearching) { if (isSearching) {
refreshSearch(); refreshSearch();
@@ -230,13 +239,32 @@ const FileExplorerPage = memo(function FileExplorerPage() {
void handleFileDrop(e.dataTransfer); void handleFileDrop(e.dataTransfer);
}; };
const getAnchorPoint = useCallback((anchor: HTMLElement) => {
const rect = anchor.getBoundingClientRect();
return {
x: Math.min(rect.right, window.innerWidth - 24),
y: Math.min(rect.bottom + 8, window.innerHeight - 24),
};
}, []);
const openEntryMenuFromAnchor = useCallback((entry: VfsEntry, anchor: HTMLElement) => {
const point = getAnchorPoint(anchor);
openContextMenuAt(entry, point.x, point.y);
}, [getAnchorPoint, openContextMenuAt]);
const openSearchMenuFromAnchor = useCallback((fullPath: string, anchor: HTMLElement) => {
const point = getAnchorPoint(anchor);
void openSearchContextMenuAt(point.x, point.y, fullPath);
}, [getAnchorPoint, openSearchContextMenuAt]);
return ( return (
<div <div
style={{ style={{
background: token.colorBgContainer, background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`, border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadius, borderRadius: token.borderRadius,
height: 'calc(100vh - 88px)', height: '100%',
minHeight: 0,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
position: 'relative' position: 'relative'
@@ -254,10 +282,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
viewMode={viewMode} viewMode={viewMode}
sortBy={sortBy} sortBy={sortBy}
sortOrder={sortOrder} sortOrder={sortOrder}
isMobile={isMobile}
onGoUp={goUp} onGoUp={goUp}
onNavigate={navigateTo} onNavigate={navigateTo}
onRefresh={effectiveRefresh} onRefresh={effectiveRefresh}
onCreateDir={() => setCreatingDir(true)} onCreateDir={() => setCreatingDir(true)}
onCreateFile={() => setCreatingFile(true)}
onUploadFile={openFilePicker} onUploadFile={openFilePicker}
onUploadDirectory={openDirectoryPicker} onUploadDirectory={openDirectoryPicker}
onSetViewMode={setViewMode} onSetViewMode={setViewMode}
@@ -279,7 +309,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onChange={handleDirectoryInputChange} onChange={handleDirectoryInputChange}
/> />
<div style={{ flex: 1, overflow: 'auto', paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={openBlankContextMenu}> <div style={{ flex: 1, overflow: 'auto', minHeight: 0, paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={isMobile ? undefined : openBlankContextMenu}>
{isSearching ? ( {isSearching ? (
<SearchResultsView <SearchResultsView
viewMode={viewMode} viewMode={viewMode}
@@ -289,10 +319,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
items={searchItems} items={searchItems}
selectedPaths={searchSelectedPaths} selectedPaths={searchSelectedPaths}
entrySnapshot={searchEntrySnapshot} entrySnapshot={searchEntrySnapshot}
mobile={isMobile}
onClearSearch={clearSearchParams} onClearSearch={clearSearchParams}
onSelect={selectSearchResult} onSelect={selectSearchResult}
onOpen={(fullPath) => { void openSearchResult(fullPath); }} onOpen={(fullPath) => { void openSearchResult(fullPath); }}
onContextMenu={(e, fullPath) => { void openSearchContextMenu(e, fullPath); }} onContextMenu={(e, fullPath) => { void openSearchContextMenu(e, fullPath); }}
onOpenMenu={openSearchMenuFromAnchor}
/> />
) : showSkeleton && loading && (entries.length === 0 || path !== routePath) ? ( ) : showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
<LoadingSkeleton mode={viewMode} /> <LoadingSkeleton mode={viewMode} />
@@ -304,10 +336,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
thumbs={thumbs} thumbs={thumbs}
selectedEntries={selectedEntries} selectedEntries={selectedEntries}
path={path} path={path}
mobile={isMobile}
onSelect={handleSelect} onSelect={handleSelect}
onSelectRange={handleSelectRange} onSelectRange={handleSelectRange}
onOpen={handleOpenEntry} onOpen={handleOpenEntry}
onContextMenu={openContextMenu} onContextMenu={openContextMenu}
onOpenMenu={openEntryMenuFromAnchor}
/> />
) : ( ) : (
<FileListView <FileListView
@@ -408,6 +442,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
<ContextMenu <ContextMenu
x={ctxMenu?.x || blankCtxMenu!.x} x={ctxMenu?.x || blankCtxMenu!.x}
y={ctxMenu?.y || blankCtxMenu!.y} y={ctxMenu?.y || blankCtxMenu!.y}
mobile={isMobile}
entry={ctxMenu?.entry} entry={ctxMenu?.entry}
entries={isSearching ? searchContextEntries : entries} entries={isSearching ? searchContextEntries : entries}
selectedEntries={isSearching ? searchSelectedNames : selectedEntries} selectedEntries={isSearching ? searchSelectedNames : selectedEntries}

View File

@@ -1,5 +1,5 @@
import React, { useLayoutEffect, useRef, useState } from 'react'; import React, { useLayoutEffect, useRef, useState } from 'react';
import { Menu, theme } from 'antd'; import { Drawer, Menu, theme } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import type { VfsEntry } from '../../../api/client'; import type { VfsEntry } from '../../../api/client';
import type { ProcessorTypeMeta } from '../../../api/processors'; import type { ProcessorTypeMeta } from '../../../api/processors';
@@ -14,6 +14,7 @@ import {
interface ContextMenuProps { interface ContextMenuProps {
x: number; x: number;
y: number; y: number;
mobile?: boolean;
entry?: VfsEntry; entry?: VfsEntry;
entries: VfsEntry[]; entries: VfsEntry[];
selectedEntries: string[]; selectedEntries: string[];
@@ -51,7 +52,7 @@ interface ActionMenuItem {
export const ContextMenu: React.FC<ContextMenuProps> = (props) => { export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
const { token } = theme.useToken(); const { token } = theme.useToken();
const { t } = useI18n(); const { t } = useI18n();
const { x, y, entry, entries, selectedEntries, processorTypes, onClose, ...actions } = props; const { x, y, mobile = false, entry, entries, selectedEntries, processorTypes, onClose, ...actions } = props;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: x, top: y }); const [position, setPosition] = useState({ left: x, top: y });
@@ -244,12 +245,36 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
} }
}, [position.left, position.top, items.length]); }, [position.left, position.top, items.length]);
if (mobile) {
return (
<Drawer
open
placement="bottom"
onClose={onClose}
title={entry ? t('Actions') : t('Quick Actions')}
height="auto"
styles={{ body: { padding: 8 } }}
>
<Menu
items={items}
selectable={false}
onClick={({ key }) => {
const handler = handlerMap.get(String(key));
if (handler) handler();
onClose();
}}
style={{ borderRadius: token.borderRadius, background: 'transparent', border: 'none' }}
/>
</Drawer>
);
}
return ( return (
<div <div
ref={containerRef} ref={containerRef}
style={{ position: 'fixed', top: position.top, left: position.left, zIndex: 9999, boxShadow: '0 4px 16px rgba(0,0,0,.15)', borderRadius: token.borderRadius, background: token.colorBgElevated }} style={{ position: 'fixed', top: position.top, left: position.left, zIndex: 9999, boxShadow: '0 4px 16px rgba(0,0,0,.15)', borderRadius: token.borderRadius, background: token.colorBgElevated }}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
onClick={onClose} // Close on any click inside the menu area onClick={onClose}
> >
<Menu <Menu
items={items} items={items}

View File

@@ -106,6 +106,7 @@ export const FileListView: React.FC<FileListViewProps> = ({
dataSource={entries} dataSource={entries}
columns={columns as any} columns={columns as any}
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
onRow={(r) => ({ onRow={(r) => ({
onClick: (e: any) => onRowClick(r, e), onClick: (e: any) => onRowClick(r, e),
onDoubleClick: () => onOpen(r), onDoubleClick: () => onOpen(r),

View File

@@ -1,20 +1,23 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { Tooltip, theme } from 'antd'; import { Tooltip, theme, Button } from 'antd';
import { FolderFilled, PictureOutlined } from '@ant-design/icons'; import { FolderFilled, PictureOutlined, MoreOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../api/client'; import type { VfsEntry } from '../../../api/client';
import { getFileIcon } from './FileIcons'; import { getFileIcon } from './FileIcons';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { useI18n } from '../../../i18n';
interface Props { interface Props {
entries: VfsEntry[]; entries: VfsEntry[];
thumbs: Record<string, string>; thumbs: Record<string, string>;
selectedEntries: string[]; selectedEntries: string[];
path: string; path: string;
mobile?: boolean;
onSelect: (e: VfsEntry, additive?: boolean) => void; onSelect: (e: VfsEntry, additive?: boolean) => void;
onSelectRange: (names: string[]) => void; onSelectRange: (names: string[]) => void;
onOpen: (e: VfsEntry) => void; onOpen: (e: VfsEntry) => void;
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void; onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
onOpenMenu?: (entry: VfsEntry, anchor: HTMLElement) => void;
} }
const formatSize = (size: number) => { const formatSize = (size: number) => {
@@ -24,33 +27,29 @@ const formatSize = (size: number) => {
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB'; return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
}; };
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, path, onSelect, onSelectRange, onOpen, onContextMenu }) => { export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, path, mobile = false, onSelect, onSelectRange, onOpen, onContextMenu, onOpenMenu }) => {
const { token } = theme.useToken(); const { token } = theme.useToken();
const { resolvedMode } = useTheme(); const { resolvedMode } = useTheme();
const { t } = useI18n();
const lightenColor = (hex: string, amount: number) => { const lightenColor = (hex: string, amount: number) => {
const parseHex = (h: string) => { const parseHex = (h: string) => {
const s = h.replace('#', ''); const s = h.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s; const n = s.length === 3 ? s.split('').map((c) => c + c).join('') : s;
const num = parseInt(n, 16); const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return null; if (Number.isNaN(num) || n.length !== 6) return null;
return { return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
};
}; };
const rgb = parseHex(hex); const rgb = parseHex(hex);
if (!rgb) return hex; if (!rgb) return hex;
const mix = (c: number) => Math.round(c + (255 - c) * amount); const mix = (c: number) => Math.round(c + (255 - c) * amount);
const r = mix(rgb.r);
const g = mix(rgb.g);
const b = mix(rgb.b);
const toHex = (v: number) => v.toString(16).padStart(2, '0'); const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`; return `#${toHex(mix(rgb.r))}${toHex(mix(rgb.g))}${toHex(mix(rgb.b))}`;
}; };
const toRgba = (hex: string, alpha: number) => { const toRgba = (hex: string, alpha: number) => {
const s = hex.replace('#', ''); const s = hex.replace('#', '');
const normalized = s.length === 3 ? s.split('').map(c => c + c).join('') : s; const normalized = s.length === 3 ? s.split('').map((c) => c + c).join('') : s;
const num = parseInt(normalized, 16); const num = parseInt(normalized, 16);
if (Number.isNaN(num) || normalized.length !== 6) { if (Number.isNaN(num) || normalized.length !== 6) {
return `rgba(22, 119, 255, ${alpha})`; return `rgba(22, 119, 255, ${alpha})`;
@@ -60,13 +59,15 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
const b = num & 255; const b = num & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`; return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}; };
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({}); const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const startRef = useRef<{ x: number, y: number } | null>(null); const startRef = useRef<{ x: number; y: number } | null>(null);
const [rect, setRect] = useState<{ left: number, top: number, width: number, height: number } | null>(null); const [rect, setRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
const [selecting, setSelecting] = useState(false); const [selecting, setSelecting] = useState(false);
useEffect(() => { useEffect(() => {
if (mobile) return;
const grid = containerRef.current; const grid = containerRef.current;
const scrollContainer = grid?.parentElement; const scrollContainer = grid?.parentElement;
if (!scrollContainer) return; if (!scrollContainer) return;
@@ -82,9 +83,10 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
scrollContainer.addEventListener('mousedown', onBlankMouseDown); scrollContainer.addEventListener('mousedown', onBlankMouseDown);
return () => scrollContainer.removeEventListener('mousedown', onBlankMouseDown); return () => scrollContainer.removeEventListener('mousedown', onBlankMouseDown);
}, []); }, [mobile]);
useEffect(() => { useEffect(() => {
if (mobile) return;
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
if (!startRef.current) return; if (!startRef.current) return;
const cx = ev.clientX; const cx = ev.clientX;
@@ -99,22 +101,19 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
const onUp = () => { const onUp = () => {
if (!startRef.current) return; if (!startRef.current) return;
setSelecting(false); setSelecting(false);
const r = rect; const currentRect = rect;
if (r) { if (currentRect) {
const container = containerRef.current; const sel: string[] = [];
if (container) { entries.forEach((ent) => {
const sel: string[] = []; const el = itemRefs.current[ent.name];
entries.forEach(ent => { if (!el) return;
const el = itemRefs.current[ent.name]; const br = el.getBoundingClientRect();
if (!el) return; const rr = { left: currentRect.left, top: currentRect.top, right: currentRect.left + currentRect.width, bottom: currentRect.top + currentRect.height };
const br = el.getBoundingClientRect(); const br2 = { left: br.left, top: br.top, right: br.right, bottom: br.bottom };
const rr = { left: r.left, top: r.top, right: r.left + r.width, bottom: r.top + r.height }; const intersect = !(br2.left > rr.right || br2.right < rr.left || br2.top > rr.bottom || br2.bottom < rr.top);
const br2 = { left: br.left, top: br.top, right: br.right, bottom: br.bottom }; if (intersect) sel.push(ent.name);
const intersect = !(br2.left > rr.right || br2.right < rr.left || br2.top > rr.bottom || br2.bottom < rr.top); });
if (intersect) sel.push(ent.name); if (sel.length > 0) onSelectRange(sel);
});
if (sel.length > 0) onSelectRange(sel);
}
} }
startRef.current = null; startRef.current = null;
setRect(null); setRect(null);
@@ -129,10 +128,10 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
window.removeEventListener('mousemove', onMove); window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp); window.removeEventListener('mouseup', onUp);
}; };
}, [selecting, rect, entries, onSelectRange]); }, [entries, mobile, onSelectRange, rect, selecting]);
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return; if (mobile || e.button !== 0) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('.fx-grid-item')) { if (target.closest('.fx-grid-item')) {
return; return;
@@ -144,25 +143,48 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
}; };
return ( return (
<div className="fx-grid" style={{ padding: 16 }} ref={containerRef} onMouseDown={handleMouseDown}> <div className="fx-grid" style={{ padding: mobile ? 12 : 16 }} ref={containerRef} onMouseDown={handleMouseDown}>
{entries.map(ent => { {entries.map((ent) => {
const isImg = thumbs[ent.name]; const isImg = thumbs[ent.name];
const ext = ent.name.split('.').pop()?.toLowerCase(); const ext = ent.name.split('.').pop()?.toLowerCase();
const isPictureType = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext || ''); const isPictureType = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext || '');
const isSelected = selectedEntries.includes(ent.name); const isSelected = selectedEntries.includes(ent.name);
return ( return (
<div <div
key={ent.name} key={ent.name}
ref={(el) => { itemRefs.current[ent.name] = el; }} ref={(el) => {
itemRefs.current[ent.name] = el;
}}
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir ? 'dir' : 'file'].join(' ')} className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir ? 'dir' : 'file'].join(' ')}
onClick={(ev) => { onClick={(ev) => {
const additive = ev.ctrlKey || ev.metaKey; if (mobile) {
onSelect(ent, additive); onOpen(ent);
return;
}
onSelect(ent, ev.ctrlKey || ev.metaKey);
}}
onDoubleClick={() => {
if (!mobile) onOpen(ent);
}}
onContextMenu={(e) => {
if (!mobile) onContextMenu(e, ent);
}} }}
onDoubleClick={() => onOpen(ent)}
onContextMenu={(e) => onContextMenu(e, ent)}
style={{ userSelect: 'none' }} style={{ userSelect: 'none' }}
> >
{mobile && onOpenMenu && (
<Button
size="small"
type="text"
icon={<MoreOutlined />}
aria-label={t('More')}
onClick={(e) => {
e.stopPropagation();
onOpenMenu(ent, e.currentTarget);
}}
style={{ position: 'absolute', top: 4, right: 4, zIndex: 2 }}
/>
)}
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}> <div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
{ent.is_dir && ( {ent.is_dir && (
<FolderFilled <FolderFilled
@@ -172,23 +194,19 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
}} }}
/> />
)} )}
{!ent.is_dir && ( {!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} /> : isPictureType ? <PictureOutlined style={{ fontSize: 32, color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : 'var(--ant-color-text-tertiary, #8c8c8c)' }} /> : getFileIcon(ent.name, 32, resolvedMode))}
isImg ? (
<img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} />
) : isPictureType ? (
<PictureOutlined style={{ fontSize: 32, color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : 'var(--ant-color-text-tertiary, #8c8c8c)' }} />
) : (
getFileIcon(ent.name, 32, resolvedMode)
)
)}
{ent.type === 'mount' && <span className="badge">M</span>} {ent.type === 'mount' && <span className="badge">M</span>}
</div> </div>
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect: 'none' }}>{ent.name}</div></Tooltip> <Tooltip title={ent.name}>
<div className="meta ellipsis" style={{ fontSize: 11, color: token.colorTextSecondary, userSelect: 'none' }}>{ent.is_dir ? '目录' : formatSize(ent.size)}</div> <div className="name ellipsis" style={{ userSelect: 'none' }}>{ent.name}</div>
</Tooltip>
<div className="meta ellipsis" style={{ fontSize: 11, color: token.colorTextSecondary, userSelect: 'none' }}>
{ent.is_dir ? t('Folder') : formatSize(ent.size)}
</div>
</div> </div>
) );
})} })}
{rect && ( {!mobile && rect && (
<div <div
style={{ style={{
position: 'fixed', position: 'fixed',
@@ -198,7 +216,7 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, pa
height: rect.height, height: rect.height,
border: '1px dashed var(--ant-color-border, rgba(0,0,0,0.4))', border: '1px dashed var(--ant-color-border, rgba(0,0,0,0.4))',
background: toRgba(String(token.colorPrimary || '#1677ff'), 0.16), background: toRgba(String(token.colorPrimary || '#1677ff'), 0.16),
zIndex: 999 zIndex: 999,
}} }}
/> />
)} )}

View File

@@ -1,6 +1,6 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme, Dropdown } from 'antd'; import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme, Dropdown } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined, MoreOutlined, FileAddOutlined } from '@ant-design/icons';
import { Select } from 'antd'; import { Select } from 'antd';
import { useI18n } from '../../../i18n'; import { useI18n } from '../../../i18n';
import type { ViewMode } from '../types'; import type { ViewMode } from '../types';
@@ -12,10 +12,12 @@ interface HeaderProps {
viewMode: ViewMode; viewMode: ViewMode;
sortBy: string; sortBy: string;
sortOrder: string; sortOrder: string;
isMobile?: boolean;
onGoUp: () => void; onGoUp: () => void;
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
onRefresh: () => void; onRefresh: () => void;
onCreateDir: () => void; onCreateDir: () => void;
onCreateFile: () => void;
onUploadFile: () => void; onUploadFile: () => void;
onUploadDirectory: () => void; onUploadDirectory: () => void;
onSetViewMode: (mode: ViewMode) => void; onSetViewMode: (mode: ViewMode) => void;
@@ -28,10 +30,12 @@ export const Header: React.FC<HeaderProps> = ({
viewMode, viewMode,
sortBy, sortBy,
sortOrder, sortOrder,
isMobile = false,
onGoUp, onGoUp,
onNavigate, onNavigate,
onRefresh, onRefresh,
onCreateDir, onCreateDir,
onCreateFile,
onUploadFile, onUploadFile,
onUploadDirectory, onUploadDirectory,
onSetViewMode, onSetViewMode,
@@ -60,6 +64,7 @@ export const Header: React.FC<HeaderProps> = ({
}; };
const handlePathEdit = () => { const handlePathEdit = () => {
if (isMobile) return;
clearClickTimer(); clearClickTimer();
setEditingPath(true); setEditingPath(true);
setPathInputValue(path); setPathInputValue(path);
@@ -78,10 +83,6 @@ export const Header: React.FC<HeaderProps> = ({
setPathInputValue(''); setPathInputValue('');
}; };
const handleBreadcrumbDoubleClick = () => {
handlePathEdit();
};
const renderBreadcrumb = () => { const renderBreadcrumb = () => {
if (editingPath) { if (editingPath) {
return ( return (
@@ -104,15 +105,15 @@ export const Header: React.FC<HeaderProps> = ({
const segmentPath = '/' + arr.slice(0, index + 1).join('/'); const segmentPath = '/' + arr.slice(0, index + 1).join('/');
return { return {
key: segmentPath, key: segmentPath,
title: <span style={{ cursor: 'pointer' }} onClick={() => scheduleNavigate(segmentPath)}>{segment}</span> title: <span style={{ cursor: 'pointer' }} onClick={() => scheduleNavigate(segmentPath)}>{segment}</span>,
}; };
}) }),
]; ];
return ( return (
<div <div
style={{ style={{
cursor: 'text', cursor: isMobile ? 'default' : 'text',
padding: `${token.paddingXXS}px ${token.paddingXS}px`, padding: `${token.paddingXXS}px ${token.paddingXS}px`,
borderRadius: token.borderRadius, borderRadius: token.borderRadius,
transition: 'background-color 0.2s', transition: 'background-color 0.2s',
@@ -121,74 +122,120 @@ export const Header: React.FC<HeaderProps> = ({
height: pathEditorHeight, height: pathEditorHeight,
boxSizing: 'border-box', boxSizing: 'border-box',
display: 'flex', display: 'flex',
alignItems: 'center' alignItems: 'center',
minWidth: 0,
}} }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = token.colorFillTertiary; }} onMouseEnter={(e) => {
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }} if (!isMobile) e.currentTarget.style.backgroundColor = token.colorFillTertiary;
onDoubleClick={handleBreadcrumbDoubleClick} }}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
onDoubleClick={handlePathEdit}
> >
<Breadcrumb items={breadcrumbItems} separator="/" style={{ fontSize: token.fontSizeSM }} /> <Breadcrumb items={breadcrumbItems} separator="/" style={{ fontSize: token.fontSizeSM }} />
</div> </div>
); );
}; };
const mobileMoreItems = [
{
key: 'new-file',
label: t('New File'),
icon: <FileAddOutlined />,
onClick: onCreateFile,
},
{
key: 'sort',
label: t('Sort By') + `: ${t(sortBy === 'mtime' ? 'Modified Time' : sortBy === 'size' ? 'Size' : 'Name')}`,
children: [
{ key: 'sort-name', label: t('Name'), onClick: () => onSortChange('name', sortOrder) },
{ key: 'sort-size', label: t('Size'), onClick: () => onSortChange('size', sortOrder) },
{ key: 'sort-mtime', label: t('Modified Time'), onClick: () => onSortChange('mtime', sortOrder) },
],
},
{
key: 'sort-order',
label: sortOrder === 'asc' ? t('Ascending') : t('Descending'),
icon: sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />,
onClick: () => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'),
},
];
return ( return (
<Flex align="center" justify="space-between" style={{ padding: '10px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, gap: 12 }}> <Flex vertical={isMobile} gap={isMobile ? 10 : 12} style={{ padding: isMobile ? '10px 12px' : '10px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Flex align="center" gap={8} style={{ flexWrap: 'wrap', flex: 1, overflow: 'hidden' }}> <Flex align="center" gap={8} style={{ minWidth: 0 }}>
<Button size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} disabled={path === '/'} /> <Button size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} disabled={path === '/'} />
<Typography.Text strong>{t('File Manager')}</Typography.Text> {!isMobile && <Typography.Text strong>{t('File Manager')}</Typography.Text>}
<Divider type="vertical" /> {!isMobile && <Divider type="vertical" />}
{renderBreadcrumb()} {renderBreadcrumb()}
</Flex> </Flex>
<Space size={8} wrap>
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button> <Flex align="center" justify="space-between" gap={8} style={{ flexWrap: 'wrap' }}>
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button> <Space size={8} wrap>
<Dropdown.Button <Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading} aria-label={t('Refresh')}>
size="small" {!isMobile && t('Refresh')}
icon={<UploadOutlined />} </Button>
onClick={onUploadFile} <Button size="small" icon={<PlusOutlined />} onClick={onCreateDir} aria-label={t('New Folder')}>
menu={{ {!isMobile && t('New Folder')}
items: [ </Button>
{ key: 'file', label: t('Upload Files') }, <Dropdown.Button
{ key: 'folder', label: t('Upload Folder') }, size="small"
], icon={<UploadOutlined />}
onClick: ({ key }) => { onClick={onUploadFile}
if (key === 'folder') { menu={{
onUploadDirectory(); items: [
} else { { key: 'file', label: t('Upload Files') },
onUploadFile(); { key: 'folder', label: t('Upload Folder') },
} ],
}, onClick: ({ key }) => {
}} if (key === 'folder') {
> onUploadDirectory();
{t('Upload')} } else {
</Dropdown.Button> onUploadFile();
<Select }
size="small" },
value={sortBy} }}
onChange={(val) => onSortChange(val, sortOrder)} >
style={{ width: 80 }} {!isMobile && t('Upload')}
options={[ </Dropdown.Button>
{ value: 'name', label: t('Name') }, {isMobile && (
{ value: 'size', label: t('Size') }, <Dropdown menu={{ items: mobileMoreItems }}>
{ value: 'mtime', label: t('Modified Time') }, <Button size="small" icon={<MoreOutlined />} aria-label={t('More')} />
]} </Dropdown>
/> )}
<Button </Space>
size="small"
icon={sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />} {!isMobile && (
onClick={() => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')} <Space size={8} wrap>
/> <Select
<Segmented size="small"
size="small" value={sortBy}
value={viewMode} onChange={(val) => onSortChange(val, sortOrder)}
onChange={value => onSetViewMode(value as ViewMode)} style={{ width: 112 }}
options={[ options={[
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' }, { value: 'name', label: t('Name') },
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' } { value: 'size', label: t('Size') },
]} { value: 'mtime', label: t('Modified Time') },
/> ]}
</Space> />
<Button
size="small"
icon={sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
onClick={() => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
/>
<Segmented
size="small"
value={viewMode}
onChange={(value) => onSetViewMode(value as ViewMode)}
options={[
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' },
]}
/>
</Space>
)}
</Flex>
</Flex> </Flex>
); );
}; };

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Empty, Flex, Spin, Tag, Typography, theme } from 'antd'; import { Empty, Flex, Spin, Tag, Typography, theme, Button } from 'antd';
import { MoreOutlined } from '@ant-design/icons';
import { useI18n } from '../../../i18n'; import { useI18n } from '../../../i18n';
import type { VfsEntry } from '../../../api/client'; import type { VfsEntry } from '../../../api/client';
import type { ViewMode } from '../types'; import type { ViewMode } from '../types';
@@ -13,10 +14,12 @@ interface SearchResultsViewProps {
items: SearchDisplayItem[]; items: SearchDisplayItem[];
selectedPaths: string[]; selectedPaths: string[];
entrySnapshot: Record<string, VfsEntry>; entrySnapshot: Record<string, VfsEntry>;
mobile?: boolean;
onClearSearch: () => void; onClearSearch: () => void;
onSelect: (fullPath: string, additive: boolean) => void; onSelect: (fullPath: string, additive: boolean) => void;
onOpen: (fullPath: string) => void; onOpen: (fullPath: string) => void;
onContextMenu: (e: React.MouseEvent, fullPath: string) => void; onContextMenu: (e: React.MouseEvent, fullPath: string) => void;
onOpenMenu?: (fullPath: string, anchor: HTMLElement) => void;
} }
export const SearchResultsView: React.FC<SearchResultsViewProps> = ({ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
@@ -27,10 +30,12 @@ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
items, items,
selectedPaths, selectedPaths,
entrySnapshot, entrySnapshot,
mobile = false,
onClearSearch, onClearSearch,
onSelect, onSelect,
onOpen, onOpen,
onContextMenu, onContextMenu,
onOpenMenu,
}) => { }) => {
const { token } = theme.useToken(); const { token } = theme.useToken();
const { t } = useI18n(); const { t } = useI18n();
@@ -75,13 +80,11 @@ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
}; };
return ( return (
<div style={{ padding: 16 }}> <div style={{ padding: mobile ? 12 : 16 }}>
<Flex align="center" justify="space-between" style={{ marginBottom: 12, gap: 12, flexWrap: 'wrap' }}> <Flex align="center" justify="space-between" style={{ marginBottom: 12, gap: 12, flexWrap: 'wrap' }}>
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}> <Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
<Typography.Text strong>{t('Search Results')}</Typography.Text> <Typography.Text strong>{t('Search Results')}</Typography.Text>
<Tag color={mode === 'filename' ? 'green' : 'blue'}> <Tag color={mode === 'filename' ? 'green' : 'blue'}>{mode === 'filename' ? t('Name Search') : t('Smart Search')}</Tag>
{mode === 'filename' ? t('Name Search') : t('Smart Search')}
</Tag>
<Tag closable onClose={(ev) => { ev.preventDefault(); onClearSearch(); }}> <Tag closable onClose={(ev) => { ev.preventDefault(); onClearSearch(); }}>
{query} {query}
</Tag> </Tag>
@@ -97,10 +100,7 @@ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
<Empty description={t('No files found')} image={Empty.PRESENTED_IMAGE_SIMPLE} /> <Empty description={t('No files found')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Flex> </Flex>
) : viewMode === 'grid' ? ( ) : viewMode === 'grid' ? (
<div <div className="fx-grid" style={{ padding: 0, gridTemplateColumns: mobile ? 'repeat(auto-fill, minmax(160px, 1fr))' : 'repeat(auto-fill, minmax(220px, 1fr))' }}>
className="fx-grid"
style={{ padding: 0, gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))' }}
>
{items.map(({ item, fullPath, dir, name }) => { {items.map(({ item, fullPath, dir, name }) => {
const selected = selectedPaths.includes(fullPath); const selected = selectedPaths.includes(fullPath);
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-'; const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
@@ -110,16 +110,37 @@ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
<div <div
key={fullPath} key={fullPath}
className={['fx-grid-item', selected ? 'selected' : '', 'file'].join(' ')} className={['fx-grid-item', selected ? 'selected' : '', 'file'].join(' ')}
onClick={(ev) => onSelect(fullPath, ev.ctrlKey || ev.metaKey)} onClick={(ev) => {
onDoubleClick={() => onOpen(fullPath)} if (mobile) {
onContextMenu={(ev) => onContextMenu(ev, fullPath)} onOpen(fullPath);
return;
}
onSelect(fullPath, ev.ctrlKey || ev.metaKey);
}}
onDoubleClick={() => {
if (!mobile) onOpen(fullPath);
}}
onContextMenu={(ev) => {
if (!mobile) onContextMenu(ev, fullPath);
}}
style={{ userSelect: 'none' }} style={{ userSelect: 'none' }}
> >
{mobile && onOpenMenu && (
<Button
size="small"
type="text"
icon={<MoreOutlined />}
aria-label={t('More')}
onClick={(e) => {
e.stopPropagation();
onOpenMenu(fullPath, e.currentTarget);
}}
style={{ position: 'absolute', top: 4, right: 4, zIndex: 2 }}
/>
)}
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}> <div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
<span className="badge score-badge">{scoreText}</span> <span className="badge score-badge">{scoreText}</span>
{isDir {isDir ? <Typography.Text style={{ fontSize: 32, color: token.colorPrimary }}>📁</Typography.Text> : <Typography.Text style={{ fontSize: 32, color: token.colorTextTertiary }}>📄</Typography.Text>}
? <Typography.Text style={{ fontSize: 32, color: token.colorPrimary }}>📁</Typography.Text>
: <Typography.Text style={{ fontSize: 32, color: token.colorTextTertiary }}>📄</Typography.Text>}
</div> </div>
<div className="name ellipsis">{name}</div> <div className="name ellipsis">{name}</div>
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}> <Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>
@@ -141,45 +162,48 @@ export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
<div <div
key={fullPath} key={fullPath}
className={selected ? 'row-selected' : ''} className={selected ? 'row-selected' : ''}
onClick={(ev) => onSelect(fullPath, ev.ctrlKey || ev.metaKey)} onClick={(ev) => {
onDoubleClick={() => onOpen(fullPath)} if (mobile) {
onContextMenu={(ev) => onContextMenu(ev, fullPath)} onOpen(fullPath);
return;
}
onSelect(fullPath, ev.ctrlKey || ev.metaKey);
}}
onDoubleClick={() => {
if (!mobile) onOpen(fullPath);
}}
onContextMenu={(ev) => {
if (!mobile) onContextMenu(ev, fullPath);
}}
style={{ style={{
padding: '10px 12px', padding: '10px 12px',
borderRadius: token.borderRadius, borderRadius: token.borderRadius,
background: token.colorFillTertiary, background: token.colorFillTertiary,
cursor: 'pointer', cursor: 'pointer',
userSelect: 'none', userSelect: 'none',
position: 'relative',
}} }}
> >
<Flex vertical style={{ gap: 6 }}> {mobile && onOpenMenu && (
<Typography.Text strong className="ellipsis"> <Button
{name} size="small"
</Typography.Text> type="text"
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}> icon={<MoreOutlined />}
{fullPath} aria-label={t('More')}
</Typography.Text> onClick={(e) => {
{snippet ? ( e.stopPropagation();
<Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}> onOpenMenu(fullPath, e.currentTarget);
{snippet} }}
</Typography.Paragraph> style={{ position: 'absolute', top: 6, right: 6 }}
) : null} />
)}
<Flex vertical style={{ gap: 6, paddingRight: mobile ? 28 : 0 }}>
<Typography.Text strong className="ellipsis">{name}</Typography.Text>
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>{fullPath}</Typography.Text>
{snippet ? <Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}>{snippet}</Typography.Paragraph> : null}
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}> <Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
{retrieval ? ( {retrieval ? <Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}>{renderSourceLabel(retrieval)}</Tag> : null}
<Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}> <Tag style={{ marginRight: 0, background: token.colorBgContainer, borderColor: token.colorBorderSecondary, color: token.colorText }}>{scoreText}</Tag>
{renderSourceLabel(retrieval)}
</Tag>
) : null}
<Tag
style={{
marginRight: 0,
background: token.colorBgContainer,
borderColor: token.colorBorderSecondary,
color: token.colorText,
}}
>
{scoreText}
</Tag>
</Flex> </Flex>
</Flex> </Flex>
</div> </div>

View File

@@ -15,6 +15,16 @@ export function useContextMenu() {
setBlankCtxMenu({ x: e.clientX, y: e.clientY }); setBlankCtxMenu({ x: e.clientX, y: e.clientY });
}, []); }, []);
const openContextMenuAt = useCallback((entry: VfsEntry, x: number, y: number) => {
setBlankCtxMenu(null);
setCtxMenu({ entry, x, y });
}, []);
const openBlankContextMenuAt = useCallback((x: number, y: number) => {
setCtxMenu(null);
setBlankCtxMenu({ x, y });
}, []);
const closeContextMenus = useCallback(() => { const closeContextMenus = useCallback(() => {
setCtxMenu(null); setCtxMenu(null);
setBlankCtxMenu(null); setBlankCtxMenu(null);
@@ -25,6 +35,8 @@ export function useContextMenu() {
blankCtxMenu, blankCtxMenu,
openContextMenu, openContextMenu,
openBlankContextMenu, openBlankContextMenu,
openContextMenuAt,
openBlankContextMenuAt,
closeContextMenus, closeContextMenus,
}; };
} }

View File

@@ -251,6 +251,20 @@ export function useFileSearch({
openContextMenu(e, entry); openContextMenu(e, entry);
}, [actionPath, ensureEntry, itemByPath, openContextMenu]); }, [actionPath, ensureEntry, itemByPath, openContextMenu]);
const openResultContextMenuAt = useCallback(async (x: number, y: number, fullPath: string) => {
const info = itemByPath.get(fullPath);
if (!info) return;
setActionPath(info.dir);
setSelectedPaths((prev) => {
if (actionPath !== info.dir) {
return [fullPath];
}
return prev.includes(fullPath) ? prev : [fullPath];
});
const entry = await ensureEntry(info.fullPath, info.name);
openContextMenu({ preventDefault() {}, clientX: x, clientY: y } as React.MouseEvent, entry);
}, [actionPath, ensureEntry, itemByPath, openContextMenu]);
const selectedNames = useMemo(() => { const selectedNames = useMemo(() => {
const names: string[] = []; const names: string[] = [];
for (const p of selectedPaths) { for (const p of selectedPaths) {
@@ -308,7 +322,7 @@ export function useFileSearch({
openResult, openResult,
selectResult, selectResult,
openResultContextMenu, openResultContextMenu,
openResultContextMenuAt,
clearSelection, clearSelection,
}; };
} }

View File

@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
import useResponsive from '../hooks/useResponsive';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -13,6 +14,7 @@ export default function ForgotPasswordPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
const { isMobile } = useResponsive();
const handleSubmit = async (values: { email: string }) => { const handleSubmit = async (values: { email: string }) => {
setSubmitting(true); setSubmitting(true);
@@ -29,12 +31,12 @@ export default function ForgotPasswordPage() {
return ( return (
<div style={{ <div style={{
minHeight: '100vh', minHeight: '100dvh',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))', background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: '48px 16px', padding: isMobile ? '72px 12px 20px' : '48px 16px',
position: 'relative' position: 'relative'
}}> }}>
<div style={{ position: 'absolute', top: 16, right: 16 }}> <div style={{ position: 'absolute', top: 16, right: 16 }}>
@@ -48,7 +50,7 @@ export default function ForgotPasswordPage() {
boxShadow: '0 24px 60px rgba(15,23,42,0.12)', boxShadow: '0 24px 60px rgba(15,23,42,0.12)',
border: '1px solid rgba(99,102,241,0.12)', border: '1px solid rgba(99,102,241,0.12)',
}} }}
styles={{ body: { padding: '40px 36px' } }} styles={{ body: { padding: isMobile ? '24px 18px' : '40px 36px' } }}
> >
<div style={{ textAlign: 'center', marginBottom: 32 }}> <div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ <div style={{

View File

@@ -7,6 +7,7 @@ import { useNavigate } from 'react-router';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
import WeChatModal from '../components/WeChatModal'; import WeChatModal from '../components/WeChatModal';
import useResponsive from '../hooks/useResponsive';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -20,6 +21,7 @@ export default function LoginPage() {
const [wechatModalOpen, setWechatModalOpen] = useState(false); const [wechatModalOpen, setWechatModalOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
const { isMobile } = useResponsive();
const handleSubmit = async () => { const handleSubmit = async () => {
const u = username.trim(); const u = username.trim();
@@ -28,14 +30,12 @@ export default function LoginPage() {
setErr(t('Please enter username and password')); setErr(t('Please enter username and password'));
return; return;
} }
console.debug('[LoginPage] submit ->', { username: u, passwordLength: p.length });
setErr(''); setErr('');
setLoading(true); setLoading(true);
try { try {
await login(u, p); await login(u, p);
navigate('/'); navigate('/');
} catch (e: any) { } catch (e: any) {
console.error('[LoginPage] login failed:', e);
setErr(e.message || t('Login failed')); setErr(e.message || t('Login failed'));
} finally { } finally {
setLoading(false); setLoading(false);
@@ -43,48 +43,60 @@ export default function LoginPage() {
}; };
return ( return (
<div style={{ <div
display: 'flex', style={{
width: '100vw', display: 'flex',
height: '100vh', width: '100vw',
alignItems: 'center', minHeight: '100dvh',
justifyContent: 'center', alignItems: 'center',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))' justifyContent: 'center',
}}> padding: isMobile ? '72px 12px 20px' : '24px',
boxSizing: 'border-box',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
}}
>
<div style={{ position: 'fixed', top: 12, right: 12, zIndex: 1000 }}> <div style={{ position: 'fixed', top: 12, right: 12, zIndex: 1000 }}>
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
<div style={{
display: 'flex', <div
width: '80%', style={{
maxWidth: '1200px', width: '100%',
height: '70%', maxWidth: isMobile ? 420 : 1200,
maxHeight: '700px', minHeight: isMobile ? 'auto' : '70vh',
backgroundColor: 'var(--ant-color-bg-container, #fff)',
borderRadius: '20px',
boxShadow: '0 4px 30px rgba(0, 0, 0, 0.1)',
backdropFilter: 'blur(5px)',
border: '1px solid var(--ant-color-border-secondary, #e5e5e5)',
overflow: 'hidden'
}}>
<div style={{
width: '50%',
display: 'flex', display: 'flex',
alignItems: 'center', flexDirection: isMobile ? 'column' : 'row',
justifyContent: 'center', borderRadius: 20,
padding: '48px' background: 'rgba(255,255,255,0.74)',
}}> backdropFilter: 'blur(16px)',
<div style={{ width: 360 }}> border: '1px solid var(--ant-color-border-secondary, #e5e5e5)',
overflow: 'hidden',
}}
>
<div
style={{
width: isMobile ? '100%' : '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: isMobile ? '24px 18px' : '48px',
}}
>
<div style={{ width: '100%', maxWidth: 360 }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}> <Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: 8 }}>
<img src={status?.logo} alt="Foxel Logo" style={{ width: 32, marginRight: 16 }} /> <img src={status?.logo} alt="Foxel Logo" style={{ width: 32, marginRight: 16 }} />
<Title level={2} style={{ margin: 0, color: 'var(--ant-color-text, #111)' }}>{t('Welcome Back')}</Title> <Title level={2} style={{ margin: 0, color: 'var(--ant-color-text, #111)', textAlign: 'center' }}>
{t('Welcome Back')}
</Title>
</div> </div>
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>{t('Sign in to your Foxel account')}</Text> <Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>
{t('Sign in to your Foxel account')}
</Text>
</div> </div>
{err && <Alert message={err} type="error" showIcon style={{ marginBottom: 24 }} />} {err && <Alert message={err} type="error" showIcon style={{ marginBottom: 8 }} />}
<Form onFinish={handleSubmit} layout="vertical" size="large"> <Form onFinish={handleSubmit} layout="vertical" size="large">
<Form.Item> <Form.Item>
@@ -92,7 +104,7 @@ export default function LoginPage() {
prefix={<UserOutlined />} prefix={<UserOutlined />}
placeholder={t('Username / Email')} placeholder={t('Username / Email')}
value={username} value={username}
onChange={e => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
/> />
</Form.Item> </Form.Item>
@@ -102,7 +114,7 @@ export default function LoginPage() {
prefix={<LockOutlined />} prefix={<LockOutlined />}
placeholder={t('Password')} placeholder={t('Password')}
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
/> />
</Form.Item> </Form.Item>
@@ -114,12 +126,7 @@ export default function LoginPage() {
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button <Button type="primary" htmlType="submit" loading={loading} style={{ width: '100%' }}>
type="primary"
htmlType="submit"
loading={loading}
style={{ width: '100%' }}
>
{t('Sign In')} {t('Sign In')}
</Button> </Button>
</Form.Item> </Form.Item>
@@ -133,58 +140,63 @@ export default function LoginPage() {
</Space> </Space>
</div> </div>
</div> </div>
<div style={{
width: '50%', {!isMobile && (
backgroundColor: 'var(--ant-color-fill-tertiary, #f0f2f5)', <div
backgroundImage: `radial-gradient(var(--ant-color-fill-secondary, #d7d7d7) 1px, transparent 1px)`, style={{
backgroundSize: '16px 16px', width: '50%',
display: 'flex', backgroundColor: 'var(--ant-color-fill-tertiary, #f0f2f5)',
flexDirection: 'column', backgroundImage: 'radial-gradient(var(--ant-color-fill-secondary, #d7d7d7) 1px, transparent 1px)',
alignItems: 'center', backgroundSize: '16px 16px',
justifyContent: 'center', display: 'flex',
padding: '48px' flexDirection: 'column',
}}> alignItems: 'center',
<div style={{ maxWidth: '500px' }}> justifyContent: 'center',
<Title level={3}>{t('Your next-generation file manager')}</Title> padding: '48px',
<Text type="secondary" style={{ fontSize: '16px', lineHeight: '1.8' }}> }}
Foxel 访 >
</Text> <div style={{ maxWidth: 500 }}>
<div style={{ marginTop: '32px' }}> <Title level={3}>{t('Your next-generation file manager')}</Title>
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Text type="secondary" style={{ fontSize: 16, lineHeight: '1.8' }}>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}> Foxel 访
<Space> </Text>
<CloudSyncOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} /> <div style={{ marginTop: 32 }}>
<Text>{t('Cross-platform sync, access anywhere')}</Text> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
</Space> <Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
</Card> <Space>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}> <CloudSyncOutlined style={{ fontSize: 20, color: 'var(--ant-color-primary, #1677ff)' }} />
<Space> <Text>{t('Cross-platform sync, access anywhere')}</Text>
<SearchOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} /> </Space>
<Text>{t('AI-powered search for quick find')}</Text> </Card>
</Space> <Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
</Card> <Space>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}> <SearchOutlined style={{ fontSize: 20, color: 'var(--ant-color-primary, #1677ff)' }} />
<Space> <Text>{t('AI-powered search for quick find')}</Text>
<ShareAltOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} /> </Space>
<Text>{t('Flexible sharing and collaboration')}</Text> </Card>
</Space> <Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
</Card> <Space>
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}> <ShareAltOutlined style={{ fontSize: 20, color: 'var(--ant-color-primary, #1677ff)' }} />
<Space> <Text>{t('Flexible sharing and collaboration')}</Text>
<ApartmentOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} /> </Space>
<Text>{t('Powerful automation to simplify tasks')}</Text> </Card>
</Space> <Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
</Card> <Space>
</Space> <ApartmentOutlined style={{ fontSize: 20, color: 'var(--ant-color-primary, #1677ff)' }} />
</div> <Text>{t('Powerful automation to simplify tasks')}</Text>
<div style={{ marginTop: '48px', textAlign: 'center' }}> </Space>
<Text type="secondary">{t('Join our community:')}</Text> </Card>
<Button type="text" icon={<GithubOutlined />} href="https://github.com/DrizzleTime/Foxel" target="_blank">GitHub</Button> </Space>
<Button type="text" icon={<SendOutlined />} href="https://t.me/+thDsBfyqJxZkNTU1" target="_blank">Telegram</Button> </div>
<Button type="text" icon={<WechatOutlined />} onClick={() => setWechatModalOpen(true)}></Button> <div style={{ marginTop: 48, textAlign: 'center' }}>
<Text type="secondary">{t('Join our community:')}</Text>
<Button type="text" icon={<GithubOutlined />} href="https://github.com/DrizzleTime/Foxel" target="_blank">GitHub</Button>
<Button type="text" icon={<SendOutlined />} href="https://t.me/+thDsBfyqJxZkNTU1" target="_blank">Telegram</Button>
<Button type="text" icon={<WechatOutlined />} onClick={() => setWechatModalOpen(true)}></Button>
</div>
</div> </div>
</div> </div>
</div> )}
</div> </div>
<WeChatModal open={wechatModalOpen} onClose={() => setWechatModalOpen(false)} /> <WeChatModal open={wechatModalOpen} onClose={() => setWechatModalOpen(false)} />
</div> </div>

View File

@@ -161,6 +161,7 @@ const OfflineDownloadPage = memo(function OfflineDownloadPage() {
dataSource={tasks} dataSource={tasks}
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
locale={{ emptyText: t('No offline download tasks') }} locale={{ emptyText: t('No offline download tasks') }}
rowKey="id" rowKey="id"
style={{ marginBottom: 0 }} style={{ marginBottom: 0 }}

View File

@@ -5,6 +5,7 @@ import { useAuth } from '../contexts/AuthContext';
import { useNavigate, Navigate } from 'react-router'; import { useNavigate, Navigate } from 'react-router';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
import useResponsive from '../hooks/useResponsive';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -14,6 +15,7 @@ export default function RegisterPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
const { isMobile } = useResponsive();
if (isAuthenticated) { if (isAuthenticated) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
@@ -39,19 +41,23 @@ export default function RegisterPage() {
}; };
return ( return (
<div style={{ <div
display: 'flex', style={{
width: '100vw', display: 'flex',
height: '100vh', width: '100vw',
alignItems: 'center', minHeight: '100dvh',
justifyContent: 'center', alignItems: 'center',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))' justifyContent: 'center',
}}> padding: isMobile ? '72px 12px 20px' : '24px',
boxSizing: 'border-box',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
}}
>
<div style={{ position: 'fixed', top: 12, right: 12, zIndex: 1000 }}> <div style={{ position: 'fixed', top: 12, right: 12, zIndex: 1000 }}>
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
<Card style={{ width: 420 }}> <Card style={{ width: '100%', maxWidth: 420 }} styles={{ body: { padding: isMobile ? '20px 16px' : '24px' } }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}> <Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Title level={2} style={{ marginBottom: 8 }}>{t('Create Account')}</Title> <Title level={2} style={{ marginBottom: 8 }}>{t('Create Account')}</Title>
@@ -61,11 +67,7 @@ export default function RegisterPage() {
{err && <Alert message={err} type="error" showIcon />} {err && <Alert message={err} type="error" showIcon />}
<Form layout="vertical" size="large" onFinish={onFinish}> <Form layout="vertical" size="large" onFinish={onFinish}>
<Form.Item <Form.Item label={t('Username')} name="username" rules={[{ required: true, message: t('Please input username!') }]}>
label={t('Username')}
name="username"
rules={[{ required: true, message: t('Please input username!') }]}
>
<Input prefix={<UserOutlined />} /> <Input prefix={<UserOutlined />} />
</Form.Item> </Form.Item>
@@ -80,18 +82,11 @@ export default function RegisterPage() {
<Input prefix={<MailOutlined />} /> <Input prefix={<MailOutlined />} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item label={t('Full Name')} name="full_name">
label={t('Full Name')}
name="full_name"
>
<Input prefix={<UserOutlined />} /> <Input prefix={<UserOutlined />} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item label={t('Password')} name="password" rules={[{ required: true, message: t('Please enter password') }]}>
label={t('Password')}
name="password"
rules={[{ required: true, message: t('Please enter password') }]}
>
<Input.Password prefix={<LockOutlined />} /> <Input.Password prefix={<LockOutlined />} />
</Form.Item> </Form.Item>
@@ -133,4 +128,3 @@ export default function RegisterPage() {
</div> </div>
); );
} }

View File

@@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
import useResponsive from '../hooks/useResponsive';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -19,6 +20,7 @@ export default function ResetPasswordPage() {
const [userInfo, setUserInfo] = useState<{ username: string; email: string } | null>(null); const [userInfo, setUserInfo] = useState<{ username: string; email: string } | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const { isMobile } = useResponsive();
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -58,7 +60,7 @@ export default function ResetPasswordPage() {
if (error) { if (error) {
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '16px' }}>
<Result <Result
status="error" status="error"
title={t('Reset failed')} title={t('Reset failed')}
@@ -75,12 +77,12 @@ export default function ResetPasswordPage() {
return ( return (
<div style={{ <div style={{
minHeight: '100vh', minHeight: '100dvh',
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))', background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: '48px 16px', padding: isMobile ? '72px 12px 20px' : '48px 16px',
position: 'relative' position: 'relative'
}}> }}>
<div style={{ position: 'absolute', top: 16, right: 16 }}> <div style={{ position: 'absolute', top: 16, right: 16 }}>
@@ -94,7 +96,7 @@ export default function ResetPasswordPage() {
border: '1px solid rgba(99,102,241,0.14)', border: '1px solid rgba(99,102,241,0.14)',
boxShadow: '0 24px 60px rgba(79,70,229,0.18)', boxShadow: '0 24px 60px rgba(79,70,229,0.18)',
}} }}
bodyStyle={{ padding: '40px 36px' }} styles={{ body: { padding: isMobile ? '24px 18px' : '40px 36px' } }}
> >
<div style={{ textAlign: 'center', marginBottom: 32 }}> <div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ <div style={{

View File

@@ -359,7 +359,7 @@ const SetupPage = () => {
<div style={{ <div style={{
display: 'flex', display: 'flex',
width: '100%', width: '100%',
minHeight: '100vh', minHeight: '100dvh',
alignItems: isMobile ? 'flex-start' : 'center', alignItems: isMobile ? 'flex-start' : 'center',
justifyContent: 'center', justifyContent: 'center',
padding: isMobile ? '64px 12px 24px' : '32px 24px', padding: isMobile ? '64px 12px 24px' : '32px 24px',

View File

@@ -111,7 +111,7 @@ const SharePage = memo(function SharePage() {
<PageCard <PageCard
title={t('My Shares')} title={t('My Shares')}
extra={ extra={
<Space> <Space wrap>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button> <Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Popconfirm title={t('Confirm clear expired shares?')} onConfirm={handleClearExpired}> <Popconfirm title={t('Confirm clear expired shares?')} onConfirm={handleClearExpired}>
<Button danger>{t('Clear expired shares')}</Button> <Button danger>{t('Clear expired shares')}</Button>
@@ -125,6 +125,7 @@ const SharePage = memo(function SharePage() {
columns={columns as any} columns={columns as any}
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
/> />
</PageCard> </PageCard>
); );

View File

@@ -6,6 +6,7 @@ import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOu
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import '../../styles/settings-tabs.css'; import '../../styles/settings-tabs.css';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
import useResponsive from '../../hooks/useResponsive';
import AppearanceSettingsTab from './components/AppearanceSettingsTab'; import AppearanceSettingsTab from './components/AppearanceSettingsTab';
import AppSettingsTab from './components/AppSettingsTab'; import AppSettingsTab from './components/AppSettingsTab';
import AiSettingsTab from './components/AiSettingsTab'; import AiSettingsTab from './components/AiSettingsTab';
@@ -51,6 +52,7 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
); );
const { refreshTheme } = useTheme(); const { refreshTheme } = useTheme();
const { t } = useI18n(); const { t } = useI18n();
const { isMobile } = useResponsive();
useEffect(() => { useEffect(() => {
getAllConfig() getAllConfig()
@@ -132,7 +134,7 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
className="fx-settings-tabs" className="fx-settings-tabs"
activeKey={activeTab} activeKey={activeTab}
onChange={handleTabChange} onChange={handleTabChange}
centered centered={!isMobile}
items={[ items={[
{ {
key: 'appearance', key: 'appearance',

View File

@@ -287,6 +287,7 @@ const TaskQueuePage = memo(function TaskQueuePage() {
columns={columns} columns={columns}
loading={loading} loading={loading}
pagination={{ pageSize: 10 }} pagination={{ pageSize: 10 }}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }} style={{ marginBottom: 0 }}
/> />
</PageCard> </PageCard>

View File

@@ -153,7 +153,7 @@ const TasksPage = memo(function TasksPage() {
<PageCard <PageCard
title={t('Automation Tasks')} title={t('Automation Tasks')}
extra={ extra={
<Space> <Space wrap>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button> <Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Button type="primary" onClick={openCreate}>{t('Create Task')}</Button> <Button type="primary" onClick={openCreate}>{t('Create Task')}</Button>
</Space> </Space>
@@ -165,6 +165,7 @@ const TasksPage = memo(function TasksPage() {
columns={columns as any} columns={columns as any}
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }} style={{ marginBottom: 0 }}
/> />
<Drawer <Drawer

View File

@@ -11,6 +11,7 @@ import {
} from '../../api/roles'; } from '../../api/roles';
import { permissionsApi, type PermissionInfo } from '../../api/permissions'; import { permissionsApi, type PermissionInfo } from '../../api/permissions';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
import useResponsive from '../../hooks/useResponsive';
import { RolesTable } from './components/RolesTable'; import { RolesTable } from './components/RolesTable';
import { RoleEditorDrawer } from './components/RoleEditorDrawer'; import { RoleEditorDrawer } from './components/RoleEditorDrawer';
import { PathRuleEditorDrawer } from './components/PathRuleEditorDrawer'; import { PathRuleEditorDrawer } from './components/PathRuleEditorDrawer';
@@ -23,6 +24,7 @@ type TabKey = 'users' | 'roles';
const UsersPage = memo(function UsersPage() { const UsersPage = memo(function UsersPage() {
const { t } = useI18n(); const { t } = useI18n();
const { isMobile } = useResponsive();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>('users'); const [activeTab, setActiveTab] = useState<TabKey>('users');
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
@@ -462,13 +464,13 @@ const UsersPage = memo(function UsersPage() {
<PageCard <PageCard
title={t('User Management')} title={t('User Management')}
extra={ extra={
<Space> <Space wrap>
<Input.Search <Input.Search
allowClear allowClear
value={searchText} value={searchText}
placeholder={t('Search users or roles')} placeholder={t('Search users or roles')}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
style={{ width: 260 }} style={{ width: isMobile ? '100%' : 260 }}
/> />
<Button onClick={fetchData} loading={loading}>{t('Refresh')}</Button> <Button onClick={fetchData} loading={loading}>{t('Refresh')}</Button>
<Button type="primary" onClick={() => { setActiveTab('users'); openCreateUser(); }}> <Button type="primary" onClick={() => { setActiveTab('users'); openCreateUser(); }}>

View File

@@ -62,8 +62,8 @@ export const RolesTable = memo(function RolesTable({
columns={columns} columns={columns}
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }} style={{ marginBottom: 0 }}
/> />
); );
}); });

View File

@@ -78,8 +78,8 @@ export const UsersTable = memo(function UsersTable({
columns={columns} columns={columns}
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }} style={{ marginBottom: 0 }}
/> />
); );
}); });

View File

@@ -18,40 +18,91 @@ import UsersPage from '../pages/UsersPage/UsersPage.tsx';
import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext'; import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext';
import { AppWindowsLayer } from '../apps/AppWindowsLayer'; import { AppWindowsLayer } from '../apps/AppWindowsLayer';
import AiAgentWidget from '../components/AiAgentWidget'; import AiAgentWidget from '../components/AiAgentWidget';
import useResponsive from '../hooks/useResponsive';
const ShellBody = memo(function ShellBody() { const ShellBody = memo(function ShellBody() {
const params = useParams<{ navKey?: string; '*': string }>(); const params = useParams<{ navKey?: string; '*': string }>();
const navKey = params.navKey ?? 'files'; const navKey = params.navKey ?? 'files';
const subPath = params['*'] ?? ''; const subPath = params['*'] ?? '';
const navigate = useNavigate(); const navigate = useNavigate();
const { isMobile } = useResponsive();
const COLLAPSED_KEY = 'layout.siderCollapsed'; const COLLAPSED_KEY = 'layout.siderCollapsed';
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(COLLAPSED_KEY) === '1'); const [collapsed, setCollapsed] = useState(() => localStorage.getItem(COLLAPSED_KEY) === '1');
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [agentOpen, setAgentOpen] = useState(false); const [agentOpen, setAgentOpen] = useState(false);
useEffect(() => { useEffect(() => {
localStorage.setItem(COLLAPSED_KEY, collapsed ? '1' : '0'); localStorage.setItem(COLLAPSED_KEY, collapsed ? '1' : '0');
}, [collapsed]); }, [collapsed]);
useEffect(() => {
setMobileNavOpen(false);
}, [isMobile, navKey, subPath]);
const { windows, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(); const { windows, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows();
const settingsTab = navKey === 'settings' ? (subPath.split('/')[0] || undefined) : undefined; const settingsTab = navKey === 'settings' ? (subPath.split('/')[0] || undefined) : undefined;
const agentCurrentPath = navKey === 'files' ? ('/' + subPath).replace(/\/+/g, '/').replace(/\/+$/, '') || '/' : null; const agentCurrentPath = navKey === 'files' ? ('/' + subPath).replace(/\/+/g, '/').replace(/\/+$/, '') || '/' : null;
const handleToggleNav = () => {
if (isMobile) {
setMobileNavOpen(true);
return;
}
setCollapsed((value) => !value);
};
return ( return (
<Layout style={{ minHeight: '100vh', background: 'var(--ant-color-bg-layout)' }}> <Layout style={{ minHeight: '100dvh', background: 'var(--ant-color-bg-layout)' }}>
<SideNav {!isMobile && (
collapsed={collapsed} <SideNav
onToggle={() => setCollapsed(c => !c)} collapsed={collapsed}
activeKey={navKey} onToggle={handleToggleNav}
onChange={(key) => { activeKey={navKey}
if (key === 'settings') { onChange={(key) => {
navigate('/settings/appearance', { replace: true }); if (key === 'settings') {
} else { navigate('/settings/appearance', { replace: true });
navigate(`/${key}`); } else {
} navigate(`/${key}`);
}} }
/> }}
<Layout style={{ background: 'var(--ant-color-bg-layout)' }}> />
<TopHeader collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} onOpenAiAgent={() => setAgentOpen(true)} /> )}
<Layout.Content style={{ padding: 16, background: 'var(--ant-color-bg-layout)' }}>
<div style={{ minHeight: 'calc(100vh - 56px - 32px)', background: 'var(--ant-color-bg-layout)' }}> {isMobile && (
<Flex vertical gap={16}> <SideNav
mobile
open={mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
collapsed={false}
onToggle={handleToggleNav}
activeKey={navKey}
onChange={(key) => {
if (key === 'settings') {
navigate('/settings/appearance', { replace: true });
} else {
navigate(`/${key}`);
}
}}
/>
)}
<Layout style={{ background: 'var(--ant-color-bg-layout)', minWidth: 0 }}>
<TopHeader
collapsed={collapsed}
onToggle={handleToggleNav}
onOpenAiAgent={() => setAgentOpen(true)}
showMenuButton={isMobile || collapsed}
/>
<Layout.Content
style={{
padding: isMobile ? 12 : 16,
background: 'var(--ant-color-bg-layout)',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<div style={{ flex: 1, minHeight: 0, background: 'var(--ant-color-bg-layout)' }}>
<Flex vertical gap={16} style={{ minHeight: '100%', height: '100%' }}>
{navKey === 'adapters' && <AdaptersPage />} {navKey === 'adapters' && <AdaptersPage />}
{navKey === 'files' && <FileExplorerPage />} {navKey === 'files' && <FileExplorerPage />}
{navKey === 'share' && <SharePage />} {navKey === 'share' && <SharePage />}
@@ -61,10 +112,7 @@ const ShellBody = memo(function ShellBody() {
{navKey === 'offline' && <OfflineDownloadPage />} {navKey === 'offline' && <OfflineDownloadPage />}
{navKey === 'plugins' && <PluginsPage />} {navKey === 'plugins' && <PluginsPage />}
{navKey === 'settings' && ( {navKey === 'settings' && (
<SystemSettingsPage <SystemSettingsPage tabKey={settingsTab} onTabNavigate={(key, options) => navigate(`/settings/${key}`, options)} />
tabKey={settingsTab}
onTabNavigate={(key, options) => navigate(`/settings/${key}`, options)}
/>
)} )}
{navKey === 'audit' && <AuditLogsPage />} {navKey === 'audit' && <AuditLogsPage />}
{navKey === 'backup' && <BackupPage />} {navKey === 'backup' && <BackupPage />}
@@ -73,7 +121,7 @@ const ShellBody = memo(function ShellBody() {
</div> </div>
</Layout.Content> </Layout.Content>
</Layout> </Layout>
{/* 常驻渲染应用窗口(过滤最小化在内部处理) */}
<AppWindowsLayer <AppWindowsLayer
windows={windows} windows={windows}
onClose={closeWindow} onClose={closeWindow}

View File

@@ -46,3 +46,22 @@ html[data-theme='light'] .fx-settings-tabs .ant-tabs-tab-active .ant-tabs-tab-bt
.fx-settings-tabs .ant-tabs-ink-bar { .fx-settings-tabs .ant-tabs-ink-bar {
background: var(--ant-color-primary) !important; background: var(--ant-color-primary) !important;
} }
@media (max-width: 767px) {
.fx-settings-tabs .ant-tabs-nav {
overflow-x: auto;
}
.fx-settings-tabs .ant-tabs-nav-list {
width: max-content;
min-width: 100%;
flex-wrap: nowrap;
padding: 2px 4px;
}
.fx-settings-tabs .ant-tabs-tab {
flex: 0 0 auto;
justify-content: flex-start;
white-space: nowrap;
}
}