mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-06 18:22:44 +08:00
feat(web): add first-pass mobile responsive support
This commit is contained in:
@@ -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 || ''}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
14
web/src/hooks/useResponsive.ts
Normal file
14
web/src/hooks/useResponsive.ts
Normal 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;
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(); }}>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user