mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 15:47:00 +08:00
feat(window): Add app window management with minimize, restore, and icon support
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React, { useRef, useEffect, useCallback } from 'react';
|
import React, { useRef, useEffect, useCallback } from 'react';
|
||||||
import { Space, Button } from 'antd';
|
import { Space, Button } from 'antd';
|
||||||
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined } from '@ant-design/icons';
|
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons';
|
||||||
import type { AppDescriptor, AppComponentProps } from './types';
|
import type { AppDescriptor, AppComponentProps } from './types';
|
||||||
import type { VfsEntry } from '../api/client';
|
import type { VfsEntry } from '../api/client';
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ export interface AppWindowItem {
|
|||||||
entry: VfsEntry;
|
entry: VfsEntry;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
maximized: boolean;
|
maximized: boolean;
|
||||||
|
minimized: boolean;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -187,9 +188,11 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const visibleWindows = windows.filter(w => !w.minimized);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{windows.map((w, idx) => {
|
{visibleWindows.map((w, idx) => {
|
||||||
const AppComp = w.app.component as React.FC<AppComponentProps>;
|
const AppComp = w.app.component as React.FC<AppComponentProps>;
|
||||||
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
|
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
|
||||||
if (!useSystemWindow) {
|
if (!useSystemWindow) {
|
||||||
@@ -291,6 +294,21 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
|||||||
{w.app.name} - {w.entry.name}
|
{w.app.name} - {w.entry.name}
|
||||||
</span>
|
</span>
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
aria-label="最小化"
|
||||||
|
icon={<MinusOutlined />}
|
||||||
|
onClick={() => onUpdateWindow(w.id, { minimized: true })}
|
||||||
|
style={{
|
||||||
|
color: 'var(--ant-color-text-secondary, #555)',
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ImageViewerApp } from './ImageViewer.tsx';
|
|||||||
export const descriptor: AppDescriptor = {
|
export const descriptor: AppDescriptor = {
|
||||||
key: 'image-viewer',
|
key: 'image-viewer',
|
||||||
name: '图片查看器',
|
name: '图片查看器',
|
||||||
|
iconUrl: 'https://api.iconify.design/mdi:image.svg',
|
||||||
supported: (entry) => {
|
supported: (entry) => {
|
||||||
if (entry.is_dir) return false;
|
if (entry.is_dir) return false;
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { OfficeViewerApp } from './OfficeViewer.tsx';
|
|||||||
export const descriptor: AppDescriptor = {
|
export const descriptor: AppDescriptor = {
|
||||||
key: 'office-viewer',
|
key: 'office-viewer',
|
||||||
name: 'Office 文档查看器',
|
name: 'Office 文档查看器',
|
||||||
|
iconUrl: 'https://api.iconify.design/mdi:file-word-box.svg',
|
||||||
supported: (entry) => {
|
supported: (entry) => {
|
||||||
if (entry.is_dir) return false;
|
if (entry.is_dir) return false;
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PdfViewerApp } from './PdfViewer';
|
|||||||
export const descriptor: AppDescriptor = {
|
export const descriptor: AppDescriptor = {
|
||||||
key: 'pdf-viewer',
|
key: 'pdf-viewer',
|
||||||
name: 'PDF 查看器',
|
name: 'PDF 查看器',
|
||||||
|
iconUrl: 'https://api.iconify.design/mdi:file-pdf-box.svg',
|
||||||
supported: (entry) => {
|
supported: (entry) => {
|
||||||
if (entry.is_dir) return false;
|
if (entry.is_dir) return false;
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||||
@@ -13,4 +14,3 @@ export const descriptor: AppDescriptor = {
|
|||||||
default: true,
|
default: true,
|
||||||
defaultBounds: { width: 1024, height: 768, x: 160, y: 100 },
|
defaultBounds: { width: 1024, height: 768, x: 160, y: 100 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TextEditorApp } from './TextEditor.tsx';
|
|||||||
export const descriptor: AppDescriptor = {
|
export const descriptor: AppDescriptor = {
|
||||||
key: 'text-editor',
|
key: 'text-editor',
|
||||||
name: '文本编辑器',
|
name: '文本编辑器',
|
||||||
|
iconUrl: 'https://api.iconify.design/mdi:file-document-outline.svg',
|
||||||
supported: (entry) => {
|
supported: (entry) => {
|
||||||
if (entry.is_dir) return false;
|
if (entry.is_dir) return false;
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { VideoPlayerApp } from './VideoPlayer.tsx';
|
|||||||
export const descriptor: AppDescriptor = {
|
export const descriptor: AppDescriptor = {
|
||||||
key: 'video-player',
|
key: 'video-player',
|
||||||
name: '视频播放器',
|
name: '视频播放器',
|
||||||
|
iconUrl: 'https://api.iconify.design/mdi:video.svg',
|
||||||
supported: (entry) => {
|
supported: (entry) => {
|
||||||
if (entry.is_dir) return false;
|
if (entry.is_dir) return false;
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function registerPluginAsApp(p: PluginItem) {
|
|||||||
name: p.name || `插件 ${p.id}`,
|
name: p.name || `插件 ${p.id}`,
|
||||||
supported,
|
supported,
|
||||||
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
|
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
|
||||||
|
iconUrl: p.icon || undefined,
|
||||||
default: false,
|
default: false,
|
||||||
defaultBounds: p.default_bounds || undefined,
|
defaultBounds: p.default_bounds || undefined,
|
||||||
defaultMaximized: p.default_maximized || undefined,
|
defaultMaximized: p.default_maximized || undefined,
|
||||||
@@ -88,6 +89,7 @@ export async function reloadPluginApps() {
|
|||||||
existing.name = p.name || `插件 ${p.id}`;
|
existing.name = p.name || `插件 ${p.id}`;
|
||||||
existing.defaultBounds = p.default_bounds || undefined;
|
existing.defaultBounds = p.default_bounds || undefined;
|
||||||
existing.defaultMaximized = p.default_maximized || undefined;
|
existing.defaultMaximized = p.default_maximized || undefined;
|
||||||
|
existing.iconUrl = p.icon || existing.iconUrl;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface AppDescriptor {
|
|||||||
name: string;
|
name: string;
|
||||||
supported: (entry: VfsEntry) => boolean;
|
supported: (entry: VfsEntry) => boolean;
|
||||||
component: React.ComponentType<AppComponentProps>;
|
component: React.ComponentType<AppComponentProps>;
|
||||||
|
iconUrl?: string;
|
||||||
default?: boolean;
|
default?: boolean;
|
||||||
defaultMaximized?: boolean;
|
defaultMaximized?: boolean;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||||
|
import { Modal, Checkbox } from 'antd';
|
||||||
|
import type { VfsEntry } from '../api/client';
|
||||||
|
import type { AppDescriptor } from '../apps/registry';
|
||||||
|
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../apps/registry';
|
||||||
|
import { useI18n } from '../i18n';
|
||||||
|
|
||||||
|
export interface AppWindowItem {
|
||||||
|
id: string;
|
||||||
|
app: AppDescriptor;
|
||||||
|
entry: VfsEntry;
|
||||||
|
filePath: string;
|
||||||
|
maximized: boolean;
|
||||||
|
minimized: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppWindowsContextValue {
|
||||||
|
windows: AppWindowItem[];
|
||||||
|
openWithApp: (entry: VfsEntry, app: AppDescriptor, currentPath: string) => void;
|
||||||
|
openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void;
|
||||||
|
confirmOpenWithApp: (entry: VfsEntry, appKey: string, currentPath: string) => void;
|
||||||
|
closeWindow: (id: string) => void;
|
||||||
|
toggleMax: (id: string) => void;
|
||||||
|
bringToFront: (id: string) => void;
|
||||||
|
updateWindow: (id: string, patch: Partial<Omit<AppWindowItem, 'id' | 'app' | 'entry' | 'filePath'>>) => void;
|
||||||
|
minimizeWindow: (id: string) => void;
|
||||||
|
restoreWindow: (id: string) => void;
|
||||||
|
toggleMinimize: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppWindowsContext = createContext<AppWindowsContextValue | null>(null);
|
||||||
|
|
||||||
|
export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [windows, setWindows] = useState<AppWindowItem[]>([]);
|
||||||
|
|
||||||
|
const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor, currentPath: string) => {
|
||||||
|
const fullPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||||
|
setWindows(ws => {
|
||||||
|
const idx = ws.length;
|
||||||
|
const bounds = app.defaultBounds || {};
|
||||||
|
const baseX = bounds.x ?? (160 + idx * 32);
|
||||||
|
const baseY = bounds.y ?? (100 + idx * 28);
|
||||||
|
const baseW = bounds.width ?? 640;
|
||||||
|
const baseH = bounds.height ?? 480;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const finalW = Math.min(baseW, vw - 40);
|
||||||
|
const finalH = Math.min(baseH, vh - 60);
|
||||||
|
const finalX = Math.min(Math.max(0, baseX), vw - finalW - 8);
|
||||||
|
const finalY = Math.min(Math.max(48, baseY), vh - finalH - 8);
|
||||||
|
return [
|
||||||
|
...ws,
|
||||||
|
{
|
||||||
|
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
|
||||||
|
app,
|
||||||
|
entry,
|
||||||
|
filePath: fullPath,
|
||||||
|
maximized: !!app.defaultMaximized,
|
||||||
|
minimized: false,
|
||||||
|
x: finalX,
|
||||||
|
y: finalY,
|
||||||
|
width: finalW,
|
||||||
|
height: finalH,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openFileWithDefaultApp = useCallback((entry: VfsEntry, currentPath: string) => {
|
||||||
|
const apps = getAppsForEntry(entry);
|
||||||
|
if (!apps.length) {
|
||||||
|
Modal.error({ title: t('Cannot open file: no available app') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
|
||||||
|
openWithApp(entry, defaultApp, currentPath);
|
||||||
|
}, [openWithApp, t]);
|
||||||
|
|
||||||
|
const confirmOpenWithApp = useCallback((entry: VfsEntry, appKey: string, currentPath: string) => {
|
||||||
|
const app = getAppByKey(appKey);
|
||||||
|
if (!app) {
|
||||||
|
Modal.error({ title: t('Error'), content: t('App "{key}" not found.', { key: appKey }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||||
|
let setDefault = false;
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('Open with {app}', { app: app.name }),
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8 }}>{t('File')}: {entry.name}</div>
|
||||||
|
<Checkbox onChange={e => (setDefault = e.target.checked)}>
|
||||||
|
{t('Set as default for .{ext}', { ext })}
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onOk: () => {
|
||||||
|
if (setDefault && ext) {
|
||||||
|
localStorage.setItem(`app.default.${ext}`, app.key);
|
||||||
|
}
|
||||||
|
openWithApp(entry, app, currentPath);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [openWithApp, t]);
|
||||||
|
|
||||||
|
const closeWindow = (id: string) => setWindows(ws => ws.filter(w => w.id !== id));
|
||||||
|
const toggleMax = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, maximized: !w.maximized } : w)));
|
||||||
|
const bringToFront = (id: string) => setWindows(ws => {
|
||||||
|
const target = ws.find(w => w.id === id);
|
||||||
|
if (!target) return ws;
|
||||||
|
return [...ws.filter(w => w.id !== id), target];
|
||||||
|
});
|
||||||
|
const updateWindow = (
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Omit<AppWindowItem, 'id' | 'app' | 'entry' | 'filePath'>>,
|
||||||
|
) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, ...patch } : w)));
|
||||||
|
|
||||||
|
const minimizeWindow = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: true } : w)));
|
||||||
|
const restoreWindow = (id: string) => setWindows(ws => {
|
||||||
|
const target = ws.find(w => w.id === id);
|
||||||
|
if (!target) return ws;
|
||||||
|
const restored = { ...target, minimized: false };
|
||||||
|
return [...ws.filter(w => w.id !== id), restored];
|
||||||
|
});
|
||||||
|
const toggleMinimize = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: !w.minimized } : w)));
|
||||||
|
|
||||||
|
const value = useMemo<AppWindowsContextValue>(() => ({
|
||||||
|
windows,
|
||||||
|
openWithApp,
|
||||||
|
openFileWithDefaultApp,
|
||||||
|
confirmOpenWithApp,
|
||||||
|
closeWindow,
|
||||||
|
toggleMax,
|
||||||
|
bringToFront,
|
||||||
|
updateWindow,
|
||||||
|
minimizeWindow,
|
||||||
|
restoreWindow,
|
||||||
|
toggleMinimize,
|
||||||
|
}), [windows, openWithApp, openFileWithDefaultApp, confirmOpenWithApp]);
|
||||||
|
|
||||||
|
return <AppWindowsContext.Provider value={value}>{children}</AppWindowsContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAppWindows() {
|
||||||
|
const ctx = useContext(AppWindowsContext);
|
||||||
|
if (!ctx) throw new Error('useAppWindows must be used within AppWindowsProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ import { getLatestVersion } from '../api/config.ts';
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { useI18n } from '../i18n';
|
import { useI18n } from '../i18n';
|
||||||
|
import { useAppWindows } from '../contexts/AppWindowsContext';
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
|
|
||||||
export interface SideNavProps {
|
export interface SideNavProps {
|
||||||
@@ -54,6 +55,16 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasUpdate = latestVersion && latestVersion.version !== status?.version;
|
const hasUpdate = latestVersion && latestVersion.version !== status?.version;
|
||||||
|
const { windows, restoreWindow } = useAppWindows();
|
||||||
|
const minimized = windows.filter(w => w.minimized);
|
||||||
|
const DEFAULT_APP_ICON =
|
||||||
|
'data:image/svg+xml;utf8,' +
|
||||||
|
encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="4" ry="4" fill="currentColor" />
|
||||||
|
<rect x="7" y="7" width="10" height="10" rx="2" ry="2" fill="#fff"/>
|
||||||
|
</svg>`
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sider
|
<Sider
|
||||||
@@ -153,6 +164,35 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
|||||||
borderTop: `1px solid ${token.colorBorderSecondary}`
|
borderTop: `1px solid ${token.colorBorderSecondary}`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 最小化应用 Dock */}
|
||||||
|
{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 => {
|
||||||
|
const src = w.app.iconUrl || DEFAULT_APP_ICON;
|
||||||
|
return (
|
||||||
|
<Tooltip key={w.id} title={`${w.app.name} - ${w.entry.name}`} 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 style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: token.colorTextSecondary,
|
color: token.colorTextSecondary,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { memo, useEffect, useRef, useState } from 'react';
|
import { memo, useEffect, useRef, useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { theme, Pagination } from 'antd';
|
import { theme, Pagination } from 'antd';
|
||||||
import { AppWindowsLayer } from '../../apps/AppWindowsLayer';
|
|
||||||
import { useFileExplorer } from './hooks/useFileExplorer';
|
import { useFileExplorer } from './hooks/useFileExplorer';
|
||||||
import { useFileSelection } from './hooks/useFileSelection';
|
import { useFileSelection } from './hooks/useFileSelection';
|
||||||
import { useFileActions } from './hooks/useFileActions.tsx';
|
import { useFileActions } from './hooks/useFileActions.tsx';
|
||||||
import { useAppWindows } from './hooks/useAppWindows.tsx';
|
import { useAppWindows } from '../../contexts/AppWindowsContext';
|
||||||
import { useContextMenu } from './hooks/useContextMenu';
|
import { useContextMenu } from './hooks/useContextMenu';
|
||||||
import { useProcessor } from './hooks/useProcessor';
|
import { useProcessor } from './hooks/useProcessor';
|
||||||
import { useThumbnails } from './hooks/useThumbnails';
|
import { useThumbnails } from './hooks/useThumbnails';
|
||||||
@@ -37,7 +36,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 { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
||||||
const { appWindows, openFileWithDefaultApp, confirmOpenWithApp, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(path);
|
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||||
const uploader = useUploader(path, refresh);
|
const uploader = useUploader(path, refresh);
|
||||||
const { handleFileDrop } = uploader;
|
const { handleFileDrop } = uploader;
|
||||||
@@ -65,8 +64,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const next = (path === '/' ? '' : path) + '/' + entry.name;
|
const next = (path === '/' ? '' : path) + '/' + entry.name;
|
||||||
navigateTo(next.replace(/\/+/g, '/'));
|
navigateTo(next.replace(/\/+/g, '/'));
|
||||||
} else {
|
} else {
|
||||||
openFileWithDefaultApp(entry);
|
openFileWithDefaultApp(entry, path);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDetail = async (entry: VfsEntry) => {
|
const openDetail = async (entry: VfsEntry) => {
|
||||||
@@ -172,7 +171,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
|
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
|
||||||
onSelectionChange={setSelectedEntries}
|
onSelectionChange={setSelectedEntries}
|
||||||
onOpen={handleOpenEntry}
|
onOpen={handleOpenEntry}
|
||||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey)}
|
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
|
||||||
onRename={setRenaming}
|
onRename={setRenaming}
|
||||||
onDelete={(entry) => doDelete([entry])}
|
onDelete={(entry) => doDelete([entry])}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
@@ -232,7 +231,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
processorTypes={processorTypes}
|
processorTypes={processorTypes}
|
||||||
onClose={closeContextMenus}
|
onClose={closeContextMenus}
|
||||||
onOpen={handleOpenEntry}
|
onOpen={handleOpenEntry}
|
||||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey)}
|
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
|
||||||
onDownload={doDownload}
|
onDownload={doDownload}
|
||||||
onRename={setRenaming}
|
onRename={setRenaming}
|
||||||
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
||||||
@@ -253,7 +252,6 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
onClose={uploader.closeModal}
|
onClose={uploader.closeModal}
|
||||||
onStartUpload={uploader.startUpload}
|
onStartUpload={uploader.startUpload}
|
||||||
/>
|
/>
|
||||||
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
|
|
||||||
<DropzoneOverlay visible={isDragging} />
|
<DropzoneOverlay visible={isDragging} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -113,7 +113,10 @@ const PluginsPage = memo(function PluginsPage() {
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography.Paragraph style={{ marginBottom: 8 }} ellipsis={{ rows: 2 }}>
|
<Typography.Paragraph
|
||||||
|
style={{ marginBottom: 8, minHeight: 44, lineHeight: '22px' }}
|
||||||
|
ellipsis={{ rows: 2 }}
|
||||||
|
>
|
||||||
{p.description || '(暂无描述)'}
|
{p.description || '(暂无描述)'}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
@@ -205,7 +208,10 @@ const PluginsPage = memo(function PluginsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Typography.Paragraph style={{ marginBottom: 8 }} ellipsis={{ rows: 2 }}>
|
<Typography.Paragraph
|
||||||
|
style={{ marginBottom: 8, minHeight: 44, lineHeight: '22px' }}
|
||||||
|
ellipsis={{ rows: 2 }}
|
||||||
|
>
|
||||||
{item.description || '(暂无描述)'}
|
{item.description || '(暂无描述)'}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
@@ -304,6 +310,14 @@ const PluginsPage = memo(function PluginsPage() {
|
|||||||
{ value: 'downloads', label: t('Downloads') },
|
{ value: 'downloads', label: t('Downloads') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
href="https://center.foxel.cc"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Foxel Center
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{repoLoading ? (
|
{repoLoading ? (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.t
|
|||||||
import LogsPage from '../pages/LogsPage.tsx';
|
import LogsPage from '../pages/LogsPage.tsx';
|
||||||
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
|
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
|
||||||
import PluginsPage from '../pages/PluginsPage.tsx';
|
import PluginsPage from '../pages/PluginsPage.tsx';
|
||||||
|
import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext';
|
||||||
|
import { AppWindowsLayer } from '../apps/AppWindowsLayer';
|
||||||
|
|
||||||
const LayoutShell = memo(function LayoutShell() {
|
const ShellBody = memo(function ShellBody() {
|
||||||
const { navKey = 'files' } = useParams();
|
const { navKey = 'files' } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const { windows, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows();
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh', background: 'var(--ant-color-bg-layout)' }}>
|
<Layout style={{ minHeight: '100vh', background: 'var(--ant-color-bg-layout)' }}>
|
||||||
<SideNav
|
<SideNav
|
||||||
@@ -43,8 +46,24 @@ const LayoutShell = memo(function LayoutShell() {
|
|||||||
</div>
|
</div>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
{/* 常驻渲染应用窗口(过滤最小化在内部处理) */}
|
||||||
|
<AppWindowsLayer
|
||||||
|
windows={windows}
|
||||||
|
onClose={closeWindow}
|
||||||
|
onToggleMax={toggleMax}
|
||||||
|
onBringToFront={bringToFront}
|
||||||
|
onUpdateWindow={updateWindow}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const LayoutShell = memo(function LayoutShell() {
|
||||||
|
return (
|
||||||
|
<AppWindowsProvider>
|
||||||
|
<ShellBody />
|
||||||
|
</AppWindowsProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default LayoutShell;
|
export default LayoutShell;
|
||||||
|
|||||||
Reference in New Issue
Block a user