From 066bd672732c3ae8c069e780b65555972a1c5c67 Mon Sep 17 00:00:00 2001 From: shiyu Date: Mon, 9 Mar 2026 11:44:44 +0800 Subject: [PATCH] feat(web): add first-pass mobile responsive support --- web/src/apps/AppWindowsLayer.tsx | 99 ++-- web/src/components/PageCard.tsx | 22 +- web/src/global.css | 55 ++- web/src/hooks/useResponsive.ts | 14 + web/src/layout/SideNav.tsx | 454 ++++++++++-------- web/src/layout/TopHeader.tsx | 44 +- web/src/pages/AdaptersPage.tsx | 3 +- web/src/pages/AuditLogsPage.tsx | 4 +- .../FileExplorerPage/FileExplorerPage.tsx | 41 +- .../components/ContextMenu.tsx | 31 +- .../components/FileListView.tsx | 1 + .../FileExplorerPage/components/GridView.tsx | 130 ++--- .../FileExplorerPage/components/Header.tsx | 177 ++++--- .../components/SearchResultsView.tsx | 114 +++-- .../FileExplorerPage/hooks/useContextMenu.ts | 14 +- .../FileExplorerPage/hooks/useFileSearch.ts | 16 +- web/src/pages/ForgotPasswordPage.tsx | 8 +- web/src/pages/LoginPage.tsx | 196 ++++---- web/src/pages/OfflineDownloadPage.tsx | 1 + web/src/pages/RegisterPage.tsx | 42 +- web/src/pages/ResetPasswordPage.tsx | 10 +- web/src/pages/SetupPage.tsx | 2 +- web/src/pages/SharePage.tsx | 3 +- .../SystemSettingsPage/SystemSettingsPage.tsx | 4 +- web/src/pages/TaskQueuePage.tsx | 1 + web/src/pages/TasksPage.tsx | 3 +- web/src/pages/UsersPage/UsersPage.tsx | 6 +- .../pages/UsersPage/components/RolesTable.tsx | 2 +- .../pages/UsersPage/components/UsersTable.tsx | 2 +- web/src/router/LayoutShell.tsx | 94 +++- web/src/styles/settings-tabs.css | 19 + 31 files changed, 1010 insertions(+), 602 deletions(-) create mode 100644 web/src/hooks/useResponsive.ts diff --git a/web/src/apps/AppWindowsLayer.tsx b/web/src/apps/AppWindowsLayer.tsx index fb49d86..0ca5174 100644 --- a/web/src/apps/AppWindowsLayer.tsx +++ b/web/src/apps/AppWindowsLayer.tsx @@ -3,6 +3,7 @@ import { Space, Button } from 'antd'; import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons'; import type { AppDescriptor, AppComponentProps, AppOpenComponentProps } from './types'; import type { VfsEntry } from '../api/client'; +import useResponsive from '../hooks/useResponsive'; export interface AppWindowItem { id: string; @@ -29,6 +30,7 @@ interface AppWindowsLayerProps { } export const AppWindowsLayer: React.FC = ({ windows, onClose, onToggleMax, onBringToFront, onUpdateWindow }) => { + const { isMobile } = useResponsive(); const dragRef = useRef<{ id: string; startX: number; @@ -124,6 +126,7 @@ export const AppWindowsLayer: React.FC = ({ windows, onClo }, [onMouseMove, onMouseUp]); const startDrag = (e: React.MouseEvent, w: AppWindowItem) => { + if (isMobile) return; if (e.detail === 2) return; if (w.maximized) return; if ((e.target as HTMLElement).closest('button')) return; @@ -141,6 +144,7 @@ export const AppWindowsLayer: React.FC = ({ windows, onClo const startResize = (e: React.MouseEvent, w: AppWindowItem, dir: string) => { e.stopPropagation(); + if (isMobile) return; if (w.maximized) return; onBringToFront(w.id); resizeRef.current = { @@ -202,6 +206,7 @@ export const AppWindowsLayer: React.FC = ({ windows, onClo const ContentComp = (isFileWindow ? FileComp : OpenComp) as React.FC | undefined; const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true const titleText = isFileWindow ? `${w.app.name} - ${w.entry?.name || ''}` : w.app.name; + const effectiveMaximized = isMobile || w.maximized; if (!ContentComp) { return null; @@ -215,10 +220,10 @@ export const AppWindowsLayer: React.FC = ({ windows, onClo onMouseDown={() => onBringToFront(w.id)} style={{ position: 'fixed', - top: w.maximized ? 0 : w.y, - left: w.maximized ? 0 : w.x, - width: w.maximized ? '100vw' : w.width, - height: w.maximized ? '100vh' : w.height, + top: effectiveMaximized ? 0 : w.y, + left: effectiveMaximized ? 0 : w.x, + width: effectiveMaximized ? '100vw' : w.width, + height: effectiveMaximized ? '100dvh' : w.height, background: 'transparent', border: 'none', borderRadius: 0, @@ -259,14 +264,14 @@ export const AppWindowsLayer: React.FC = ({ windows, onClo onMouseDown={() => onBringToFront(w.id)} style={{ position: 'fixed', - top: w.maximized ? 0 : w.y, - left: w.maximized ? 0 : w.x, - width: w.maximized ? '100vw' : w.width, - height: w.maximized ? '100vh' : w.height, + top: effectiveMaximized ? 0 : w.y, + left: effectiveMaximized ? 0 : w.x, + width: effectiveMaximized ? '100vw' : w.width, + height: effectiveMaximized ? '100dvh' : w.height, 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))', - borderRadius: w.maximized ? 0 : 12, - boxShadow: w.maximized + borderRadius: effectiveMaximized ? 0 : 12, + boxShadow: effectiveMaximized ? 'none' : interacting ? '0 20px 50px -12px rgba(0,0,0,0.35)' @@ -282,9 +287,11 @@ export const AppWindowsLayer: React.FC = ({ windows, onClo >
startDrag(e, w)} - onDoubleClick={() => onToggleMax(w.id)} + onDoubleClick={() => { + if (!isMobile) onToggleMax(w.id); + }} style={{ - height: 40, + height: isMobile ? 48 : 40, display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -296,7 +303,7 @@ export const AppWindowsLayer: React.FC = ({ windows, onClo fontWeight: 600, letterSpacing: .2, userSelect: 'none', - cursor: w.maximized ? 'default' : 'grab' + cursor: effectiveMaximized ? 'default' : 'grab' }} > = ({ windows, onClo {titleText} -
- {/* 分组渲染 */} -
- {filteredNavGroups.map((group: NavGroup) => ( -
- {group.title && ( -
{t(group.title)}
- )} - 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" - /> -
- ))} -
-
- {/* 最小化应用 Dock */} - {!collapsed && minimized.length > 0 && ( -
- {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 ( - -
+ {status?.title} + )} -
+ {showCollapseButton && !bodyCollapsed && ( +
+ +
+ {filteredNavGroups.map((group: NavGroup) => ( +
+ {!!group.title && !bodyCollapsed && ( +
+ {t(group.title)} +
+ )} + 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" + /> +
+ ))} +
+ +
+ {!bodyCollapsed && minimized.length > 0 && ( +
+ {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 ( + +
+ )} + +
- {hasUpdate ? ( - - - {collapsed ? ( - } color="warning" style={{ marginInlineEnd: 0 }} /> - ) : ( - } color="warning"> - {t('Update available')} [{latestVersion?.version}] - - )} - - - ) : ( - latestVersion ? ( - - {collapsed ? ( - } color="success" style={{ marginInlineEnd: 0 }} /> - ) : ( - } color="success"> - {status?.version} - - )} - + cursor: 'pointer', + }} + onClick={() => setIsVersionModalOpen(true)} + > + {hasUpdate ? ( + + + {bodyCollapsed ? ( + } color="warning" style={{ marginInlineEnd: 0 }} /> + ) : ( + } color="warning"> + {t('Update available')} [{latestVersion?.version}] + + )} + + + ) : latestVersion ? ( + + {bodyCollapsed ? ( + } color="success" style={{ marginInlineEnd: 0 }} /> ) : ( - collapsed ? null : {status?.version} - ) - )} -
- {!collapsed && ( -
-
+ } color="success"> + {status?.version} + + )} + + ) : ( + !bodyCollapsed && {status?.version} )} -
- + + {!bodyCollapsed && ( +
+
+ )} +
+ + ); + + return ( + <> + {mobile ? ( + + {renderNavBody(false, false)} + + ) : ( + + {renderNavBody(currentCollapsed, true)} + + )} + setIsModalOpen(false)} /> )} - {t('Changelog')} -
+ + {t('Changelog')} + +

, + h3: ({ ...props }) => ( +

+ ), ul: ({ ...props }) =>

) : ( diff --git a/web/src/layout/TopHeader.tsx b/web/src/layout/TopHeader.tsx index 510cf7b..64f7785 100644 --- a/web/src/layout/TopHeader.tsx +++ b/web/src/layout/TopHeader.tsx @@ -10,6 +10,7 @@ import { useAuth } from '../contexts/AuthContext'; import ProfileModal from '../components/ProfileModal'; import NoticesModal from '../components/NoticesModal'; import { useSystemStatus } from '../contexts/SystemContext'; +import useResponsive from '../hooks/useResponsive'; const { Header } = Layout; @@ -17,9 +18,10 @@ export interface TopHeaderProps { collapsed: boolean; onToggle(): void; onOpenAiAgent(): void; + showMenuButton?: boolean; } -const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }: TopHeaderProps) { +const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent, showMenuButton }: TopHeaderProps) { const { token } = theme.useToken(); const [searchOpen, setSearchOpen] = useState(false); const navigate = useNavigate(); @@ -28,6 +30,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent } const [profileOpen, setProfileOpen] = useState(false); const [noticesOpen, setNoticesOpen] = useState(false); const status = useSystemStatus(); + const { isMobile } = useResponsive(); const handleLogout = () => { authApi.logout(); @@ -37,24 +40,39 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent } const openProfile = () => setProfileOpen(true); return ( -
- {collapsed && ( +
+ {showMenuButton && ( setSearchOpen(false)} /> - + + diff --git a/web/src/pages/AdaptersPage.tsx b/web/src/pages/AdaptersPage.tsx index 82a430a..9659147 100644 --- a/web/src/pages/AdaptersPage.tsx +++ b/web/src/pages/AdaptersPage.tsx @@ -202,7 +202,7 @@ const AdaptersPage = memo(function AdaptersPage() { + @@ -214,6 +214,7 @@ const AdaptersPage = memo(function AdaptersPage() { columns={columns as any} loading={loading} pagination={false} + scroll={{ x: 'max-content' }} style={{ marginBottom: 0 }} /> { }; const AuditLogsPage = memo(function AuditLogsPage() { + const { isMobile } = useResponsive(); const [loading, setLoading] = useState(false); const [data, setData] = useState(null); const [filters, setFilters] = useState<{ @@ -264,7 +266,7 @@ const AuditLogsPage = memo(function AuditLogsPage() { {selectedLog && ( ('grid'); const [isDragging, setIsDragging] = 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 { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection(); 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 { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader; const { thumbs } = useThumbnails(entries, path); @@ -91,6 +93,7 @@ const FileExplorerPage = memo(function FileExplorerPage() { openResult: openSearchResult, selectResult: selectSearchResult, openResultContextMenu: openSearchContextMenu, + openResultContextMenuAt: openSearchContextMenuAt, clearSelection: clearSearchSelection, } = fileSearch; @@ -103,6 +106,12 @@ const FileExplorerPage = memo(function FileExplorerPage() { load(routePath, 1, pagination.pageSize, sortBy, sortOrder); }, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]); + useEffect(() => { + if (isMobile && viewMode !== 'grid') { + setViewMode('grid'); + } + }, [isMobile, viewMode]); + const effectiveRefresh = useCallback(() => { if (isSearching) { refreshSearch(); @@ -230,13 +239,32 @@ const FileExplorerPage = memo(function FileExplorerPage() { 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 (
setCreatingDir(true)} + onCreateFile={() => setCreatingFile(true)} onUploadFile={openFilePicker} onUploadDirectory={openDirectoryPicker} onSetViewMode={setViewMode} @@ -279,7 +309,7 @@ const FileExplorerPage = memo(function FileExplorerPage() { onChange={handleDirectoryInputChange} /> -
+
{isSearching ? ( { void openSearchResult(fullPath); }} onContextMenu={(e, fullPath) => { void openSearchContextMenu(e, fullPath); }} + onOpenMenu={openSearchMenuFromAnchor} /> ) : showSkeleton && loading && (entries.length === 0 || path !== routePath) ? ( @@ -304,10 +336,12 @@ const FileExplorerPage = memo(function FileExplorerPage() { thumbs={thumbs} selectedEntries={selectedEntries} path={path} + mobile={isMobile} onSelect={handleSelect} onSelectRange={handleSelectRange} onOpen={handleOpenEntry} onContextMenu={openContextMenu} + onOpenMenu={openEntryMenuFromAnchor} /> ) : ( = (props) => { const { token } = theme.useToken(); 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(null); const [position, setPosition] = useState({ left: x, top: y }); @@ -244,12 +245,36 @@ export const ContextMenu: React.FC = (props) => { } }, [position.left, position.top, items.length]); + if (mobile) { + return ( + + { + const handler = handlerMap.get(String(key)); + if (handler) handler(); + onClose(); + }} + style={{ borderRadius: token.borderRadius, background: 'transparent', border: 'none' }} + /> + + ); + } + return (
e.preventDefault()} - onClick={onClose} // Close on any click inside the menu area + onClick={onClose} > = ({ dataSource={entries} columns={columns as any} pagination={false} + scroll={{ x: 'max-content' }} onRow={(r) => ({ onClick: (e: any) => onRowClick(r, e), onDoubleClick: () => onOpen(r), diff --git a/web/src/pages/FileExplorerPage/components/GridView.tsx b/web/src/pages/FileExplorerPage/components/GridView.tsx index a6c72a7..91f1597 100644 --- a/web/src/pages/FileExplorerPage/components/GridView.tsx +++ b/web/src/pages/FileExplorerPage/components/GridView.tsx @@ -1,20 +1,23 @@ import React, { useRef, useState, useEffect } from 'react'; -import { Tooltip, theme } from 'antd'; -import { FolderFilled, PictureOutlined } from '@ant-design/icons'; +import { Tooltip, theme, Button } from 'antd'; +import { FolderFilled, PictureOutlined, MoreOutlined } from '@ant-design/icons'; import type { VfsEntry } from '../../../api/client'; import { getFileIcon } from './FileIcons'; import { EmptyState } from './EmptyState'; import { useTheme } from '../../../contexts/ThemeContext'; +import { useI18n } from '../../../i18n'; interface Props { entries: VfsEntry[]; thumbs: Record; selectedEntries: string[]; path: string; + mobile?: boolean; onSelect: (e: VfsEntry, additive?: boolean) => void; onSelectRange: (names: string[]) => void; onOpen: (e: VfsEntry) => void; onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void; + onOpenMenu?: (entry: VfsEntry, anchor: HTMLElement) => void; } const formatSize = (size: number) => { @@ -24,33 +27,29 @@ const formatSize = (size: number) => { return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB'; }; -export const GridView: React.FC = ({ entries, thumbs, selectedEntries, path, onSelect, onSelectRange, onOpen, onContextMenu }) => { +export const GridView: React.FC = ({ entries, thumbs, selectedEntries, path, mobile = false, onSelect, onSelectRange, onOpen, onContextMenu, onOpenMenu }) => { const { token } = theme.useToken(); const { resolvedMode } = useTheme(); + const { t } = useI18n(); + const lightenColor = (hex: string, amount: number) => { const parseHex = (h: string) => { 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); if (Number.isNaN(num) || n.length !== 6) return null; - return { - r: (num >> 16) & 255, - g: (num >> 8) & 255, - b: num & 255, - }; + return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 }; }; const rgb = parseHex(hex); if (!rgb) return hex; 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'); - 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 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); if (Number.isNaN(num) || normalized.length !== 6) { return `rgba(22, 119, 255, ${alpha})`; @@ -60,13 +59,15 @@ export const GridView: React.FC = ({ entries, thumbs, selectedEntries, pa const b = num & 255; return `rgba(${r}, ${g}, ${b}, ${alpha})`; }; + const containerRef = useRef(null); const itemRefs = useRef>({}); - const startRef = useRef<{ x: number, y: number } | null>(null); - const [rect, setRect] = useState<{ left: number, top: number, width: number, height: 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 [selecting, setSelecting] = useState(false); useEffect(() => { + if (mobile) return; const grid = containerRef.current; const scrollContainer = grid?.parentElement; if (!scrollContainer) return; @@ -82,9 +83,10 @@ export const GridView: React.FC = ({ entries, thumbs, selectedEntries, pa scrollContainer.addEventListener('mousedown', onBlankMouseDown); return () => scrollContainer.removeEventListener('mousedown', onBlankMouseDown); - }, []); + }, [mobile]); useEffect(() => { + if (mobile) return; const onMove = (ev: MouseEvent) => { if (!startRef.current) return; const cx = ev.clientX; @@ -99,22 +101,19 @@ export const GridView: React.FC = ({ entries, thumbs, selectedEntries, pa const onUp = () => { if (!startRef.current) return; setSelecting(false); - const r = rect; - if (r) { - const container = containerRef.current; - if (container) { - const sel: string[] = []; - entries.forEach(ent => { - const el = itemRefs.current[ent.name]; - if (!el) return; - const br = el.getBoundingClientRect(); - const rr = { left: r.left, top: r.top, right: r.left + r.width, bottom: r.top + r.height }; - const br2 = { left: br.left, top: br.top, right: br.right, bottom: br.bottom }; - 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); - } + const currentRect = rect; + if (currentRect) { + const sel: string[] = []; + entries.forEach((ent) => { + const el = itemRefs.current[ent.name]; + if (!el) return; + const br = el.getBoundingClientRect(); + const rr = { left: currentRect.left, top: currentRect.top, right: currentRect.left + currentRect.width, bottom: currentRect.top + currentRect.height }; + const br2 = { left: br.left, top: br.top, right: br.right, bottom: br.bottom }; + 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); } startRef.current = null; setRect(null); @@ -129,10 +128,10 @@ export const GridView: React.FC = ({ entries, thumbs, selectedEntries, pa window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; - }, [selecting, rect, entries, onSelectRange]); + }, [entries, mobile, onSelectRange, rect, selecting]); const handleMouseDown = (e: React.MouseEvent) => { - if (e.button !== 0) return; + if (mobile || e.button !== 0) return; const target = e.target as HTMLElement; if (target.closest('.fx-grid-item')) { return; @@ -144,25 +143,48 @@ export const GridView: React.FC = ({ entries, thumbs, selectedEntries, pa }; return ( -
- {entries.map(ent => { +
+ {entries.map((ent) => { const isImg = thumbs[ent.name]; const ext = ent.name.split('.').pop()?.toLowerCase(); const isPictureType = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext || ''); const isSelected = selectedEntries.includes(ent.name); + return (
{ itemRefs.current[ent.name] = el; }} + ref={(el) => { + itemRefs.current[ent.name] = el; + }} className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir ? 'dir' : 'file'].join(' ')} onClick={(ev) => { - const additive = ev.ctrlKey || ev.metaKey; - onSelect(ent, additive); + if (mobile) { + 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' }} > + {mobile && onOpenMenu && ( +
- ) + ); })} - {rect && ( + {!mobile && rect && (
= ({ entries, thumbs, selectedEntries, pa height: rect.height, border: '1px dashed var(--ant-color-border, rgba(0,0,0,0.4))', background: toRgba(String(token.colorPrimary || '#1677ff'), 0.16), - zIndex: 999 + zIndex: 999, }} /> )} diff --git a/web/src/pages/FileExplorerPage/components/Header.tsx b/web/src/pages/FileExplorerPage/components/Header.tsx index ee63d53..1b18004 100644 --- a/web/src/pages/FileExplorerPage/components/Header.tsx +++ b/web/src/pages/FileExplorerPage/components/Header.tsx @@ -1,6 +1,6 @@ import React, { useRef, useState } from 'react'; 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 { useI18n } from '../../../i18n'; import type { ViewMode } from '../types'; @@ -12,10 +12,12 @@ interface HeaderProps { viewMode: ViewMode; sortBy: string; sortOrder: string; + isMobile?: boolean; onGoUp: () => void; onNavigate: (path: string) => void; onRefresh: () => void; onCreateDir: () => void; + onCreateFile: () => void; onUploadFile: () => void; onUploadDirectory: () => void; onSetViewMode: (mode: ViewMode) => void; @@ -28,10 +30,12 @@ export const Header: React.FC = ({ viewMode, sortBy, sortOrder, + isMobile = false, onGoUp, onNavigate, onRefresh, onCreateDir, + onCreateFile, onUploadFile, onUploadDirectory, onSetViewMode, @@ -60,6 +64,7 @@ export const Header: React.FC = ({ }; const handlePathEdit = () => { + if (isMobile) return; clearClickTimer(); setEditingPath(true); setPathInputValue(path); @@ -78,10 +83,6 @@ export const Header: React.FC = ({ setPathInputValue(''); }; - const handleBreadcrumbDoubleClick = () => { - handlePathEdit(); - }; - const renderBreadcrumb = () => { if (editingPath) { return ( @@ -104,15 +105,15 @@ export const Header: React.FC = ({ const segmentPath = '/' + arr.slice(0, index + 1).join('/'); return { key: segmentPath, - title: scheduleNavigate(segmentPath)}>{segment} + title: scheduleNavigate(segmentPath)}>{segment}, }; - }) + }), ]; return (
= ({ height: pathEditorHeight, boxSizing: 'border-box', display: 'flex', - alignItems: 'center' + alignItems: 'center', + minWidth: 0, }} - onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = token.colorFillTertiary; }} - onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }} - onDoubleClick={handleBreadcrumbDoubleClick} + onMouseEnter={(e) => { + if (!isMobile) e.currentTarget.style.backgroundColor = token.colorFillTertiary; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + onDoubleClick={handlePathEdit} >
); }; + const mobileMoreItems = [ + { + key: 'new-file', + label: t('New File'), + icon: , + 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' ? : , + onClick: () => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'), + }, + ]; + return ( - - + + - - } - onClick={onUploadFile} - menu={{ - items: [ - { key: 'file', label: t('Upload Files') }, - { key: 'folder', label: t('Upload Folder') }, - ], - onClick: ({ key }) => { - if (key === 'folder') { - onUploadDirectory(); - } else { - onUploadFile(); - } - }, - }} - > - {t('Upload')} - - onSortChange(val, sortOrder)} + style={{ width: 112 }} + options={[ + { value: 'name', label: t('Name') }, + { value: 'size', label: t('Size') }, + { value: 'mtime', label: t('Modified Time') }, + ]} + /> +
diff --git a/web/src/pages/FileExplorerPage/hooks/useContextMenu.ts b/web/src/pages/FileExplorerPage/hooks/useContextMenu.ts index 6f65cea..4088ca5 100644 --- a/web/src/pages/FileExplorerPage/hooks/useContextMenu.ts +++ b/web/src/pages/FileExplorerPage/hooks/useContextMenu.ts @@ -15,6 +15,16 @@ export function useContextMenu() { 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(() => { setCtxMenu(null); setBlankCtxMenu(null); @@ -25,6 +35,8 @@ export function useContextMenu() { blankCtxMenu, openContextMenu, openBlankContextMenu, + openContextMenuAt, + openBlankContextMenuAt, closeContextMenus, }; -} \ No newline at end of file +} diff --git a/web/src/pages/FileExplorerPage/hooks/useFileSearch.ts b/web/src/pages/FileExplorerPage/hooks/useFileSearch.ts index 490916a..5a7aa3c 100644 --- a/web/src/pages/FileExplorerPage/hooks/useFileSearch.ts +++ b/web/src/pages/FileExplorerPage/hooks/useFileSearch.ts @@ -251,6 +251,20 @@ export function useFileSearch({ openContextMenu(e, entry); }, [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 names: string[] = []; for (const p of selectedPaths) { @@ -308,7 +322,7 @@ export function useFileSearch({ openResult, selectResult, openResultContextMenu, + openResultContextMenuAt, clearSelection, }; } - diff --git a/web/src/pages/ForgotPasswordPage.tsx b/web/src/pages/ForgotPasswordPage.tsx index 6da6822..d977494 100644 --- a/web/src/pages/ForgotPasswordPage.tsx +++ b/web/src/pages/ForgotPasswordPage.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router'; import { authApi } from '../api/auth'; import { useI18n } from '../i18n'; import LanguageSwitcher from '../components/LanguageSwitcher'; +import useResponsive from '../hooks/useResponsive'; const { Title, Text } = Typography; @@ -13,6 +14,7 @@ export default function ForgotPasswordPage() { const navigate = useNavigate(); const [submitting, setSubmitting] = useState(false); const [sent, setSent] = useState(false); + const { isMobile } = useResponsive(); const handleSubmit = async (values: { email: string }) => { setSubmitting(true); @@ -29,12 +31,12 @@ export default function ForgotPasswordPage() { return (
@@ -48,7 +50,7 @@ export default function ForgotPasswordPage() { boxShadow: '0 24px 60px rgba(15,23,42,0.12)', border: '1px solid rgba(99,102,241,0.12)', }} - styles={{ body: { padding: '40px 36px' } }} + styles={{ body: { padding: isMobile ? '24px 18px' : '40px 36px' } }} >
{ const u = username.trim(); @@ -28,14 +30,12 @@ export default function LoginPage() { setErr(t('Please enter username and password')); return; } - console.debug('[LoginPage] submit ->', { username: u, passwordLength: p.length }); setErr(''); setLoading(true); try { await login(u, p); navigate('/'); } catch (e: any) { - console.error('[LoginPage] login failed:', e); setErr(e.message || t('Login failed')); } finally { setLoading(false); @@ -43,48 +43,60 @@ export default function LoginPage() { }; return ( -
+
-
-
-
+ flexDirection: isMobile ? 'column' : 'row', + borderRadius: 20, + background: 'rgba(255,255,255,0.74)', + backdropFilter: 'blur(16px)', + border: '1px solid var(--ant-color-border-secondary, #e5e5e5)', + overflow: 'hidden', + }} + > +
+
-
+
Foxel Logo - {t('Welcome Back')} + + {t('Welcome Back')} +
- {t('Sign in to your Foxel account')} + + {t('Sign in to your Foxel account')} +
- {err && } + {err && }
@@ -92,7 +104,7 @@ export default function LoginPage() { prefix={} placeholder={t('Username / Email')} value={username} - onChange={e => setUsername(e.target.value)} + onChange={(e) => setUsername(e.target.value)} required /> @@ -102,7 +114,7 @@ export default function LoginPage() { prefix={} placeholder={t('Password')} value={password} - onChange={e => setPassword(e.target.value)} + onChange={(e) => setPassword(e.target.value)} required /> @@ -114,12 +126,7 @@ export default function LoginPage() { - @@ -133,58 +140,63 @@ export default function LoginPage() {
-
-
- {t('Your next-generation file manager')} - - Foxel 旨在提供一个安全、高效且智能的文件管理解决方案,帮助您轻松组织、访问和共享您的数字资产。 - -
- - - - - {t('Cross-platform sync, access anywhere')} - - - - - - {t('AI-powered search for quick find')} - - - - - - {t('Flexible sharing and collaboration')} - - - - - - {t('Powerful automation to simplify tasks')} - - - -
-
- {t('Join our community:')} - - - + + {!isMobile && ( +
+
+ {t('Your next-generation file manager')} + + Foxel 旨在提供一个安全、高效且智能的文件管理解决方案,帮助您轻松组织、访问和共享您的数字资产。 + +
+ + + + + {t('Cross-platform sync, access anywhere')} + + + + + + {t('AI-powered search for quick find')} + + + + + + {t('Flexible sharing and collaboration')} + + + + + + {t('Powerful automation to simplify tasks')} + + + +
+
+ {t('Join our community:')} + + + +
-
+ )}
setWechatModalOpen(false)} />
diff --git a/web/src/pages/OfflineDownloadPage.tsx b/web/src/pages/OfflineDownloadPage.tsx index 57f0024..30b5e5c 100644 --- a/web/src/pages/OfflineDownloadPage.tsx +++ b/web/src/pages/OfflineDownloadPage.tsx @@ -161,6 +161,7 @@ const OfflineDownloadPage = memo(function OfflineDownloadPage() { dataSource={tasks} loading={loading} pagination={false} + scroll={{ x: 'max-content' }} locale={{ emptyText: t('No offline download tasks') }} rowKey="id" style={{ marginBottom: 0 }} diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx index b4843b1..8313c21 100644 --- a/web/src/pages/RegisterPage.tsx +++ b/web/src/pages/RegisterPage.tsx @@ -5,6 +5,7 @@ import { useAuth } from '../contexts/AuthContext'; import { useNavigate, Navigate } from 'react-router'; import { useI18n } from '../i18n'; import LanguageSwitcher from '../components/LanguageSwitcher'; +import useResponsive from '../hooks/useResponsive'; const { Title, Text } = Typography; @@ -14,6 +15,7 @@ export default function RegisterPage() { const [loading, setLoading] = useState(false); const navigate = useNavigate(); const { t } = useI18n(); + const { isMobile } = useResponsive(); if (isAuthenticated) { return ; @@ -39,19 +41,23 @@ export default function RegisterPage() { }; return ( -
+
- +
{t('Create Account')} @@ -61,11 +67,7 @@ export default function RegisterPage() { {err && } - + } /> @@ -80,18 +82,11 @@ export default function RegisterPage() { } /> - + } /> - + } /> @@ -133,4 +128,3 @@ export default function RegisterPage() {
); } - diff --git a/web/src/pages/ResetPasswordPage.tsx b/web/src/pages/ResetPasswordPage.tsx index 0cf56a4..2e2e263 100644 --- a/web/src/pages/ResetPasswordPage.tsx +++ b/web/src/pages/ResetPasswordPage.tsx @@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router'; import { authApi } from '../api/auth'; import { useI18n } from '../i18n'; import LanguageSwitcher from '../components/LanguageSwitcher'; +import useResponsive from '../hooks/useResponsive'; const { Title, Text } = Typography; @@ -19,6 +20,7 @@ export default function ResetPasswordPage() { const [userInfo, setUserInfo] = useState<{ username: string; email: string } | null>(null); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(false); + const { isMobile } = useResponsive(); useEffect(() => { if (!token) { @@ -58,7 +60,7 @@ export default function ResetPasswordPage() { if (error) { return ( -
+
@@ -94,7 +96,7 @@ export default function ResetPasswordPage() { border: '1px solid rgba(99,102,241,0.14)', boxShadow: '0 24px 60px rgba(79,70,229,0.18)', }} - bodyStyle={{ padding: '40px 36px' }} + styles={{ body: { padding: isMobile ? '24px 18px' : '40px 36px' } }} >
{
+ @@ -125,6 +125,7 @@ const SharePage = memo(function SharePage() { columns={columns as any} loading={loading} pagination={false} + scroll={{ x: 'max-content' }} /> ); diff --git a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx index 9add5ab..73fd0b3 100644 --- a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx +++ b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx @@ -6,6 +6,7 @@ import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOu import { useTheme } from '../../contexts/ThemeContext'; import '../../styles/settings-tabs.css'; import { useI18n } from '../../i18n'; +import useResponsive from '../../hooks/useResponsive'; import AppearanceSettingsTab from './components/AppearanceSettingsTab'; import AppSettingsTab from './components/AppSettingsTab'; import AiSettingsTab from './components/AiSettingsTab'; @@ -51,6 +52,7 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett ); const { refreshTheme } = useTheme(); const { t } = useI18n(); + const { isMobile } = useResponsive(); useEffect(() => { getAllConfig() @@ -132,7 +134,7 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett className="fx-settings-tabs" activeKey={activeTab} onChange={handleTabChange} - centered + centered={!isMobile} items={[ { key: 'appearance', diff --git a/web/src/pages/TaskQueuePage.tsx b/web/src/pages/TaskQueuePage.tsx index 7176cbd..3ccef24 100644 --- a/web/src/pages/TaskQueuePage.tsx +++ b/web/src/pages/TaskQueuePage.tsx @@ -287,6 +287,7 @@ const TaskQueuePage = memo(function TaskQueuePage() { columns={columns} loading={loading} pagination={{ pageSize: 10 }} + scroll={{ x: 'max-content' }} style={{ marginBottom: 0 }} /> diff --git a/web/src/pages/TasksPage.tsx b/web/src/pages/TasksPage.tsx index 5f21c10..86fc9c5 100644 --- a/web/src/pages/TasksPage.tsx +++ b/web/src/pages/TasksPage.tsx @@ -153,7 +153,7 @@ const TasksPage = memo(function TasksPage() { + @@ -165,6 +165,7 @@ const TasksPage = memo(function TasksPage() { columns={columns as any} loading={loading} pagination={false} + scroll={{ x: 'max-content' }} style={{ marginBottom: 0 }} /> ('users'); const [searchText, setSearchText] = useState(''); @@ -462,13 +464,13 @@ const UsersPage = memo(function UsersPage() { + setSearchText(e.target.value)} - style={{ width: 260 }} + style={{ width: isMobile ? '100%' : 260 }} />