Initial commit

This commit is contained in:
shiyu
2026-02-09 13:19:28 +08:00
commit 17d13999b0
300 changed files with 48565 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
import { Modal, Input, Flex, Segmented } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { InputRef } from 'antd/es/input/Input';
import { useI18n } from '../i18n';
import { useLocation, useNavigate } from 'react-router';
interface SearchDialogProps {
open: boolean;
onClose: () => void;
}
type SearchMode = 'vector' | 'filename';
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
const [search, setSearch] = useState('');
const [searchMode, setSearchMode] = useState<SearchMode>('vector');
const { t } = useI18n();
const navigate = useNavigate();
const location = useLocation();
const isOnFiles = location.pathname.startsWith('/files');
const inputRef = useRef<InputRef | null>(null);
useEffect(() => {
if (!open) return;
if (!isOnFiles) {
setSearch('');
setSearchMode('vector');
return;
}
const params = new URLSearchParams(location.search);
setSearch(params.get('q') || '');
setSearchMode(params.get('mode') === 'filename' ? 'filename' : 'vector');
}, [open, isOnFiles, location.search]);
const handleClose = useCallback(() => {
setSearch('');
setSearchMode('vector');
onClose();
}, [onClose]);
return (
<Modal
open={open}
onCancel={handleClose}
afterOpenChange={(nextOpen) => {
if (!nextOpen) return;
window.setTimeout(() => inputRef.current?.focus(), 0);
}}
footer={null}
width={720}
centered
title={null}
closable={false}
destroyOnHidden
styles={{
body: {
padding: '12px 16px 16px',
maxHeight: '70vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
gap: 12,
},
}}
>
<Flex vertical style={{ gap: 12, flex: 1, minHeight: 0 }}>
<Flex align="center" style={{ width: '100%', gap: 12, flexWrap: 'wrap' }}>
<Segmented
options={[
{ label: t('Smart Search'), value: 'vector' },
{ label: t('Name Search'), value: 'filename' },
]}
value={searchMode}
onChange={(value) => setSearchMode(value as SearchMode)}
style={{
minWidth: 160,
height: 40,
borderRadius: 20,
display: 'flex',
alignItems: 'center',
}}
size="large"
/>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder={t('Search files / tags / types')}
value={search}
onChange={e => setSearch(e.target.value)}
size="large"
style={{ flex: 1, minWidth: 240 }}
styles={{
root: {
borderRadius: 20,
},
}}
ref={inputRef}
onPressEnter={() => {
const trimmed = search.trim();
if (!trimmed) {
if (isOnFiles) {
navigate(location.pathname);
}
handleClose();
return;
}
const params = new URLSearchParams();
params.set('q', trimmed);
params.set('mode', searchMode);
if (searchMode === 'filename') {
params.set('page', '1');
}
const targetPath = isOnFiles ? location.pathname : '/files';
navigate(`${targetPath}?${params.toString()}`);
handleClose();
}}
/>
</Flex>
</Flex>
</Modal>
);
};
export default SearchDialog;

360
web/src/layout/SideNav.tsx Normal file
View File

@@ -0,0 +1,360 @@
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd';
import { navGroups } from './nav.ts';
import type { NavItem, NavGroup } from './nav.ts';
import { memo, useEffect, useState, useMemo } from 'react';
import { useSystemStatus } from '../contexts/SystemContext.tsx';
import {
CheckCircleOutlined,
FileTextOutlined,
GithubOutlined,
MenuFoldOutlined,
SendOutlined,
WechatOutlined,
WarningOutlined
} from '@ant-design/icons';
import '../styles/sider-menu.css';
import { getLatestVersion } from '../api/config.ts';
import ReactMarkdown from 'react-markdown';
import { useTheme } from '../contexts/ThemeContext';
import { useI18n } from '../i18n';
import { useAppWindows } from '../contexts/AppWindowsContext';
import WeChatModal from '../components/WeChatModal';
import { useAuth } from '../contexts/AuthContext';
const { Sider } = Layout;
export interface SideNavProps {
collapsed: boolean;
onToggle(): void;
activeKey: string;
onChange(key: string): void;
}
const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle }: SideNavProps) {
const status = useSystemStatus();
const { token } = theme.useToken();
const { resolvedMode } = useTheme();
const { t } = useI18n();
const { user } = useAuth();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState<{
version: string;
body: string;
} | null>(null);
// 根据用户权限过滤导航项
const filteredNavGroups = useMemo(() => {
const isAdmin = user?.is_admin ?? false;
return navGroups
.map(group => ({
...group,
children: group.children.filter(item => !item.adminOnly || isAdmin)
}))
.filter(group => group.children.length > 0);
}, [user]);
useEffect(() => {
getLatestVersion().then(resp => {
if (resp.latest_version && resp.body) {
setLatestVersion({
version: resp.latest_version,
body: resp.body
});
}
});
}, []);
const showVersionModal = () => {
setIsVersionModalOpen(true);
};
const hasUpdate = latestVersion && latestVersion.version !== status?.version;
const { windows, restoreWindow } = useAppWindows();
const minimized = windows.filter(w => w.minimized);
const 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 (
<>
<Sider
collapsedWidth={60}
collapsible
trigger={null}
collapsed={collapsed}
width={208}
style={{
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between',
padding: '0 14px',
fontWeight: 600,
fontSize: 18,
letterSpacing: .5,
flexShrink: 0
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<img
src={status?.logo}
alt="Foxel"
style={{
width: 24,
height: 24,
objectFit: 'contain',
marginRight: collapsed ? 0 : 8,
...(resolvedMode === 'dark'
? { filter: 'brightness(0) invert(1)' }
: (status?.logo?.endsWith('.svg') ? { filter: 'brightness(0) saturate(100%)' } : {}))
}}
/>
{!collapsed && (
<span style={{ fontWeight: 700, color: resolvedMode === 'dark' ? '#fff' : token.colorText }}>
{status?.title}
</span>
)}
</div>
{/* 展开时显示收缩按钮 */}
{!collapsed && (
<Button
type="text"
icon={<MenuFoldOutlined />}
onClick={onToggle}
style={{ fontSize: 18 }}
/>
)}
</div>
{/* 分组渲染 */}
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 4px 8px' }}>
{filteredNavGroups.map((group: NavGroup) => (
<div key={group.key} style={{ marginBottom: 12 }}>
{group.title && (
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: .5,
padding: '6px 10px 4px',
color: token.colorTextTertiary,
textTransform: 'uppercase'
}}
>{t(group.title)}</div>
)}
<Menu
mode="inline"
selectable
inlineIndent={12}
selectedKeys={[activeKey]}
onClick={(e) => onChange(e.key)}
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: t(i.label) }))}
style={{ borderInline: 'none', background: 'transparent' }}
className="sider-menu-group foxel-sider-menu"
/>
</div>
))}
</div>
<div
style={{
bottom: '10px',
position: 'absolute',
width: '100%',
padding: '12px 8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
flexShrink: 0,
borderTop: `1px solid ${token.colorBorderSecondary}`
}}
>
{/* 最小化应用 Dock */}
{!collapsed && minimized.length > 0 && (
<div
style={{
width: '100%',
display: 'flex',
flexDirection: collapsed ? 'column' : 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
flexWrap: collapsed ? 'nowrap' : 'wrap',
maxHeight: collapsed ? 160 : undefined,
overflowY: collapsed ? 'auto' : 'visible',
}}
>
{minimized.map(w => {
const src = w.app.iconUrl || DEFAULT_APP_ICON;
const title = w.kind === 'file' ? `${w.app.name} - ${w.entry.name}` : w.app.name;
return (
<Tooltip key={w.id} title={title} placement={collapsed ? 'right' : 'top'}>
<Button
shape="circle"
onClick={() => restoreWindow(w.id)}
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
/>
</Tooltip>
);
})}
</div>
)}
<div style={{
fontSize: 12,
color: token.colorTextSecondary,
textAlign: 'center',
height: 22,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer'
}} onClick={showVersionModal}>
{hasUpdate ? (
<Tooltip title={t('New version found: {version}', { version: latestVersion?.version || '' })} placement={collapsed ? 'right' : 'top'}>
<a rel="noopener noreferrer"
style={{ textDecoration: 'none' }}>
{collapsed ? (
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<WarningOutlined />} color="warning">
{t('Update available')} [{latestVersion?.version}]
</Tag>
)}
</a>
</Tooltip>
) : (
latestVersion ? (
<Tooltip title={t('You are on the latest: {version}', { version: status?.version || '' })} placement={collapsed ? 'right' : 'top'}>
{collapsed ? (
<Tag icon={<CheckCircleOutlined />} color="success" style={{ marginInlineEnd: 0 }} />
) : (
<Tag icon={<CheckCircleOutlined />} color="success">
{status?.version}
</Tag>
)}
</Tooltip>
) : (
collapsed ? null : <Tag>{status?.version}</Tag>
)
)}
</div>
{!collapsed && (
<div style={{ display: 'flex', flexDirection: 'row', gap: 8 }}>
<Button
shape="circle"
icon={<GithubOutlined />}
href="https://github.com/DrizzleTime/Foxel"
target="_blank"
/>
<Button
shape="circle"
icon={<WechatOutlined />}
onClick={() => setIsModalOpen(true)}
/>
<Button
shape="circle"
icon={<SendOutlined />}
href="https://t.me/+thDsBfyqJxZkNTU1"
target="_blank"
/>
<Button
shape="circle"
icon={<FileTextOutlined />}
href="https://foxel.cc"
target="_blank"
/>
</div>
)}
</div>
</Sider>
<WeChatModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
<Modal
open={isVersionModalOpen}
onCancel={() => setIsVersionModalOpen(false)}
title={t('Version Info')}
footer={null}
width={600}
>
<div style={{ paddingTop: 12 }}>
{latestVersion ? (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label={t('Current Version')}>
<Tag>{status?.version}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('Latest Version')}>
<Tag color={hasUpdate ? 'orange' : 'green'}>{latestVersion.version}</Tag>
</Descriptions.Item>
</Descriptions>
{hasUpdate && (
<Alert
message={<span style={{ color: token.colorText }}>{t('New version found: {version}', { version: latestVersion.version })}</span>}
description={<span style={{ color: token.colorTextSecondary }}>{t('Please update to the latest for features and fixes')}</span>}
type="info"
showIcon
style={{ marginTop: 24, marginBottom: 24, background: token.colorInfoBg, borderColor: token.colorInfoBorder }}
action={
<Button
size="small"
type="primary"
href="https://github.com/DrizzleTime/Foxel/releases"
target="_blank"
icon={<GithubOutlined />}
>
{t('Open Releases')}
</Button>
}
/>
)}
<Divider titlePlacement="left" plain>{t('Changelog')}</Divider>
<div style={{
maxHeight: '40vh',
overflowY: 'auto',
padding: '8px 16px',
background: token.colorFillAlter,
borderRadius: token.borderRadiusLG,
border: `1px solid ${token.colorBorderSecondary}`
}}>
<ReactMarkdown
components={{
h3: ({ ...props }) => <h3 style={{
fontSize: 16,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
paddingBottom: 8,
marginTop: 24,
marginBottom: 16,
color: token.colorTextHeading
}} {...props} />,
ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />,
li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />,
p: ({ ...props }) => <p style={{ marginBottom: 8 }} {...props} />,
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />
}}
>{latestVersion.body}</ReactMarkdown>
</div>
</>
) : (
<div style={{ textAlign: 'center', padding: '40px 0', color: token.colorTextSecondary }}>
<Spin size="large" />
<p style={{ marginTop: 16 }}>{t('Fetching latest version...')}</p>
</div>
)}
</div>
</Modal>
</>
);
});
export default SideNav;

View File

@@ -0,0 +1,103 @@
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip } from 'antd';
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined } from '@ant-design/icons';
import { memo, useState } from 'react';
import SearchDialog from './SearchDialog.tsx';
import { authApi } from '../api/auth.ts';
import { useNavigate } from 'react-router';
import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher';
import { useAuth } from '../contexts/AuthContext';
import ProfileModal from '../components/ProfileModal';
import NoticesModal from '../components/NoticesModal';
import { useSystemStatus } from '../contexts/SystemContext';
const { Header } = Layout;
export interface TopHeaderProps {
collapsed: boolean;
onToggle(): void;
onOpenAiAgent(): void;
}
const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }: TopHeaderProps) {
const { token } = theme.useToken();
const [searchOpen, setSearchOpen] = useState(false);
const navigate = useNavigate();
const { t } = useI18n();
const { user } = useAuth();
const [profileOpen, setProfileOpen] = useState(false);
const [noticesOpen, setNoticesOpen] = useState(false);
const status = useSystemStatus();
const handleLogout = () => {
authApi.logout();
navigate('/login', { replace: true });
};
const openProfile = () => setProfileOpen(true);
return (
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16, backdropFilter: 'saturate(180%) blur(8px)' }}>
{collapsed && (
<Button
type="text"
icon={<MenuUnfoldOutlined />}
onClick={onToggle}
style={{ fontSize: 18, marginRight: 8 }}
/>
)}
<Button
icon={<SearchOutlined />}
style={{ maxWidth: 420 }}
onClick={() => setSearchOpen(true)}
>
{t('Search files / tags / types')}
</Button>
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
<Tooltip title={t('Notices')}>
<Button
type="text"
icon={<BellOutlined />}
aria-label={t('Notices')}
onClick={() => setNoticesOpen(true)}
style={{ paddingInline: 8, height: 40 }}
/>
</Tooltip>
<Tooltip title={t('AI Agent')}>
<Button
type="text"
icon={<RobotOutlined />}
aria-label={t('AI Agent')}
onClick={onOpenAiAgent}
style={{ paddingInline: 8, height: 40 }}
/>
</Tooltip>
<LanguageSwitcher />
<Dropdown
menu={{
items: [
{ key: 'profile', label: t('Profile'), icon: <UserOutlined />, onClick: openProfile },
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout }
]
}}
>
<Button type="text" style={{ paddingInline: 8, height: 40 }}>
<Flex align="center" gap={8}>
<Avatar size={28} src={user?.gravatar_url}>
{(user?.full_name || user?.username || 'A').charAt(0).toUpperCase()}
</Avatar>
<Typography.Text style={{ maxWidth: 160 }} ellipsis>
{user?.full_name || user?.username || t('Admin')}
</Typography.Text>
</Flex>
</Button>
</Dropdown>
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} />
</Flex>
</Header>
);
});
export default TopHeader;

54
web/src/layout/nav.ts Normal file
View File

@@ -0,0 +1,54 @@
import React from 'react';
import {
FolderOpenOutlined,
ApiOutlined,
ShareAltOutlined,
CloudDownloadOutlined,
SettingOutlined,
RobotOutlined,
BugOutlined,
DatabaseOutlined,
AppstoreOutlined,
CodeOutlined,
ClockCircleOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
export interface NavItem { key: string; icon: ReactNode; label: string; adminOnly?: boolean; }
export interface NavGroup { key: string; title?: string; children: NavItem[]; }
export const navGroups: NavGroup[] = [
{
key: 'library',
title: '',
children: [
{ key: 'files', icon: React.createElement(FolderOpenOutlined), label: 'All Files' },
]
},
{
key: 'manage',
title: 'Manage',
children: [
{ key: 'processors', icon: React.createElement(CodeOutlined), label: 'Processors' },
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
{ key: 'task-queue', icon: React.createElement(ClockCircleOutlined), label: 'Task Queue' },
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' },
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: 'Offline Downloads' },
{ key: 'adapters', icon: React.createElement(ApiOutlined), label: 'Adapters' },
{ key: 'plugins', icon: React.createElement(AppstoreOutlined), label: 'Plugins' },
]
},
{
key: 'system',
title: 'System',
children: [
{ key: 'users', icon: React.createElement(UserOutlined), label: 'User Management', adminOnly: true },
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings', adminOnly: true },
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
{ key: 'audit', icon: React.createElement(BugOutlined), label: 'Audit Logs' }
]
}
];
export const primaryNav: NavItem[] = navGroups.flatMap(g => g.children);