mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 06:22:58 +08:00
feat: add i18n with language switcher and English/Chinese translations
This commit is contained in:
@@ -5,11 +5,14 @@ import { status as getStatus } from './api/config.ts';
|
||||
import type { SystemStatus } from './api/config.ts';
|
||||
import { SystemContext } from './contexts/SystemContext.tsx';
|
||||
import { ThemeProvider } from './contexts/ThemeContext.tsx';
|
||||
import { Spin } from 'antd';
|
||||
import { Spin, ConfigProvider } from 'antd';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import SetupPage from './pages/SetupPage.tsx';
|
||||
import { I18nProvider, useI18n } from './i18n';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
|
||||
function App() {
|
||||
function AppInner() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
useEffect(() => {
|
||||
async function checkInitialization() {
|
||||
@@ -36,22 +39,33 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const { lang } = useI18n();
|
||||
const locale = lang === 'zh' ? zhCN : enUS;
|
||||
|
||||
return (
|
||||
<SystemContext.Provider value={status}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
<ConfigProvider locale={locale}>
|
||||
<SystemContext.Provider value={status}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default function App() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AppInner />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { vfsApi } from '../../api/vfs';
|
||||
import { loadPluginFromUrl, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
|
||||
import type { PluginItem } from '../../api/plugins';
|
||||
import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
export interface PluginAppHostProps extends AppComponentProps {
|
||||
plugin: PluginItem;
|
||||
@@ -14,6 +15,7 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath,
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const onCloseRef = useRef(onRequestClose);
|
||||
onCloseRef.current = onRequestClose;
|
||||
const { t } = useI18n();
|
||||
|
||||
const pluginRef = useRef<RegisteredPlugin | null>(null);
|
||||
|
||||
@@ -36,7 +38,7 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath,
|
||||
host: { close: () => onCloseRef.current() },
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!isDisposed()) setError(e?.message || '插件运行失败');
|
||||
if (!isDisposed()) setError(e?.message || t('Plugin run failed'));
|
||||
}
|
||||
},
|
||||
[plugin.id, plugin.url, filePath],
|
||||
@@ -50,7 +52,7 @@ export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: 12, color: 'red' }}>插件错误: {error}</div>;
|
||||
return <div style={{ padding: 12, color: 'red' }}>{t('Plugin Error')}: {error}</div>;
|
||||
}
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
|
||||
|
||||
20
web/src/components/LanguageSwitcher.tsx
Normal file
20
web/src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Dropdown, Button } from 'antd';
|
||||
import { GlobalOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { memo } from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
const LanguageSwitcher = memo(function LanguageSwitcher() {
|
||||
const { lang, setLang, t } = useI18n();
|
||||
const items = [
|
||||
{ key: 'zh', label: t('Chinese'), icon: lang === 'zh' ? <CheckOutlined /> : undefined, onClick: () => setLang('zh') },
|
||||
{ key: 'en', label: t('English'), icon: lang === 'en' ? <CheckOutlined /> : undefined, onClick: () => setLang('en') },
|
||||
];
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['click']}>
|
||||
<Button icon={<GlobalOutlined />}>{t('Language')}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
export default LanguageSwitcher;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Select, Typography } from 'antd';
|
||||
import type { ProcessorTypeMeta } from '../api/processors';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
interface ProcessorConfigFormProps {
|
||||
processorMeta: ProcessorTypeMeta | undefined;
|
||||
@@ -9,17 +10,18 @@ interface ProcessorConfigFormProps {
|
||||
}
|
||||
|
||||
export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ processorMeta, configPath }) => {
|
||||
const { t } = useI18n();
|
||||
if (!processorMeta) {
|
||||
return <Typography.Text type="secondary">请先选择处理器</Typography.Text>;
|
||||
return <Typography.Text type="secondary">{t('Please select a processor')}</Typography.Text>;
|
||||
}
|
||||
if (!processorMeta.config_schema?.length) {
|
||||
return <Typography.Text type="secondary">该处理器无配置项</Typography.Text>;
|
||||
return <Typography.Text type="secondary">{t('No config fields')}</Typography.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{processorMeta.config_schema.map(field => {
|
||||
const rules = field.required ? [{ required: true, message: `请输入${field.label}` }] : [];
|
||||
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
|
||||
let inputNode: React.ReactNode;
|
||||
|
||||
switch (field.type) {
|
||||
@@ -31,7 +33,7 @@ export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ proces
|
||||
break;
|
||||
case 'select':
|
||||
inputNode = (
|
||||
<Select placeholder={field.placeholder || '请选择'}>
|
||||
<Select placeholder={field.placeholder || t('Please select')}>
|
||||
{field.options?.map((opt: any) => (
|
||||
<Select.Option key={String(opt.value)} value={opt.value}>
|
||||
{opt.label}
|
||||
@@ -48,7 +50,7 @@ export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ proces
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={[...configPath, field.key]}
|
||||
label={field.label}
|
||||
label={t(field.label)}
|
||||
rules={rules}
|
||||
initialValue={field.default}
|
||||
>
|
||||
@@ -58,4 +60,4 @@ export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ proces
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
58
web/src/i18n/index.tsx
Normal file
58
web/src/i18n/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext, useMemo, useState, useEffect } from 'react';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { zh } from './locales/zh';
|
||||
import { en } from './locales/en';
|
||||
|
||||
type Lang = 'zh' | 'en';
|
||||
type Dict = Record<string, string>;
|
||||
|
||||
const dicts: Record<Lang, Dict> = {
|
||||
zh,
|
||||
en,
|
||||
};
|
||||
|
||||
export interface I18nContextValue {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: PropsWithChildren) {
|
||||
const [lang, setLangState] = useState<Lang>(() => (localStorage.getItem('lang') as Lang) || 'zh');
|
||||
|
||||
const setLang = (l: Lang) => {
|
||||
setLangState(l);
|
||||
localStorage.setItem('lang', l);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang;
|
||||
}, [lang]);
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
const dict = dicts[lang] || {};
|
||||
const raw = dict[key] ?? key; // fallback to key (English)
|
||||
return interpolate(raw, params);
|
||||
};
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ lang, setLang, t }), [lang]);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={value}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
|
||||
return ctx;
|
||||
}
|
||||
365
web/src/i18n/locales/en.ts
Normal file
365
web/src/i18n/locales/en.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
export const en = {
|
||||
// General
|
||||
'All Files': 'All Files',
|
||||
'Manage': 'Manage',
|
||||
// 'System' defined above for navigation
|
||||
'Follow System': 'System',
|
||||
'Automation': 'Automation',
|
||||
'My Shares': 'My Shares',
|
||||
'Offline Downloads': 'Offline Downloads',
|
||||
'Adapters': 'Adapters',
|
||||
'Plugins': 'App Center',
|
||||
'System Settings': 'System Settings',
|
||||
'Backup & Restore': 'Backup & Restore',
|
||||
'System Logs': 'System Logs',
|
||||
|
||||
// Top header
|
||||
'Search files / tags / types': 'Search files / tags / types',
|
||||
'Log Out': 'Log Out',
|
||||
'Admin': 'Admin',
|
||||
'Language': 'Language',
|
||||
'Chinese': '中文',
|
||||
'English': 'English',
|
||||
|
||||
// Auth / Login
|
||||
'Welcome Back': 'Welcome Back',
|
||||
'Sign in to your Foxel account': 'Sign in to your Foxel account',
|
||||
'Username / Email': 'Username / Email',
|
||||
'Password': 'Password',
|
||||
'Sign In': 'Sign In',
|
||||
'Please enter username and password': 'Please enter username and password',
|
||||
'Login failed': 'Login failed',
|
||||
'Your next-generation file manager': 'Your next-generation file manager',
|
||||
'Cross-platform sync, access anywhere': 'Cross-platform sync, access anywhere',
|
||||
'AI-powered search for quick find': 'AI-powered search for quick find',
|
||||
'Flexible sharing and collaboration': 'Flexible sharing and collaboration',
|
||||
'Powerful automation to simplify tasks': 'Powerful automation to simplify tasks',
|
||||
'Join our community:': 'Join our community:',
|
||||
|
||||
// Share page
|
||||
'Refresh': 'Refresh',
|
||||
'Copy': 'Copy',
|
||||
// 'Cancel' already defined above
|
||||
'Copied link': 'Link copied',
|
||||
'Share canceled': 'Share canceled',
|
||||
'Cancel failed': 'Cancel failed',
|
||||
'Load failed': 'Load failed',
|
||||
'Are you sure to cancel share?': 'Are you sure to cancel share?',
|
||||
|
||||
'Share Name': 'Share Name',
|
||||
'Share Content': 'Share Content',
|
||||
'Created At': 'Created At',
|
||||
'Expires At': 'Expires At',
|
||||
'Forever': 'Forever',
|
||||
'Access': 'Access',
|
||||
'Public': 'Public',
|
||||
'By Password': 'By Password',
|
||||
|
||||
// Public share page
|
||||
'Password Required': 'Password Required',
|
||||
'Please enter password': 'Please enter password',
|
||||
'Confirm': 'Confirm',
|
||||
'Unable to load share info': 'Unable to load share info',
|
||||
'Share load failed': 'Failed to load share',
|
||||
'Wrong password': 'Wrong password',
|
||||
'Root': 'All Files',
|
||||
'Created on {date}': 'Created on {date}',
|
||||
'Expires on {date}': 'Expires on {date}',
|
||||
'Download File': 'Download File',
|
||||
'Preview not supported for this file type': 'Preview not supported for this file type',
|
||||
'Back': 'Back',
|
||||
'Download': 'Download',
|
||||
|
||||
// Offline download
|
||||
'No offline download tasks': 'No offline download tasks',
|
||||
// Header/File Explorer
|
||||
'Home': 'Home',
|
||||
'File Manager': 'File Manager',
|
||||
'New Folder': 'New Folder',
|
||||
'Upload': 'Upload',
|
||||
'Name': 'Name',
|
||||
'Size': 'Size',
|
||||
'Modified Time': 'Modified Time',
|
||||
'Grid': 'Grid',
|
||||
'List': 'List',
|
||||
'Mount Point': 'Mount Point',
|
||||
|
||||
// Context menu
|
||||
'Upload File': 'Upload File',
|
||||
'Open': 'Open',
|
||||
'Open With': 'Open With',
|
||||
'Default': 'Default',
|
||||
'Rename': 'Rename',
|
||||
'Delete': 'Delete',
|
||||
'Details': 'Details',
|
||||
'Get Direct Link': 'Get Direct Link',
|
||||
|
||||
// Side nav modals
|
||||
'Join Community': 'Join Community',
|
||||
'Scan to join WeChat group': 'Scan to join WeChat group',
|
||||
'If QR expires, add drizzle2001 to join': 'If QR expires, add drizzle2001 to join',
|
||||
'Version Info': 'Version Info',
|
||||
'Current Version': 'Current Version',
|
||||
'Latest Version': 'Latest Version',
|
||||
'New version found: {version}': 'New version found: {version}',
|
||||
'Please update to the latest for features and fixes': 'Please update to the latest for features and fixes',
|
||||
'Open Releases': 'Open Releases',
|
||||
'Changelog': 'Changelog',
|
||||
'Fetching latest version...': 'Fetching latest version...',
|
||||
'Update available': 'Update available',
|
||||
'You are on the latest: {version}': 'You are on the latest: {version}',
|
||||
'Up to date': 'Up to date',
|
||||
|
||||
// Share modal
|
||||
'Share {count} items': 'Share {count} items',
|
||||
'Share link created': 'Share link created',
|
||||
'Create failed': 'Create failed',
|
||||
'Copied to clipboard': 'Copied to clipboard',
|
||||
'Expiration (days)': 'Expiration (days)',
|
||||
'Set 0 or negative for forever': 'Set 0 or negative for forever',
|
||||
'Share link created successfully!': 'Share link created successfully!',
|
||||
'Share Link': 'Share Link',
|
||||
'Share created': 'Share created',
|
||||
'Create Share': 'Create Share',
|
||||
'Done': 'Done',
|
||||
'Create': 'Create',
|
||||
|
||||
// Direct link modal
|
||||
'Failed to generate link': 'Failed to generate link',
|
||||
'Markdown copied to clipboard': 'Markdown copied to clipboard',
|
||||
'Generate a direct link for {name}': 'Generate a direct link for {name}',
|
||||
'1 hour': '1 hour',
|
||||
'1 day': '1 day',
|
||||
'7 days': '7 days',
|
||||
'Generating link...': 'Generating link...',
|
||||
'Link will appear here': 'Link will appear here',
|
||||
'Copy Markdown': 'Copy Markdown',
|
||||
'Close': 'Close',
|
||||
|
||||
// File detail
|
||||
'Camera Make': 'Camera Make',
|
||||
'Camera Model': 'Camera Model',
|
||||
'Capture Time': 'Capture Time',
|
||||
'X Resolution': 'X Resolution',
|
||||
'Y Resolution': 'Y Resolution',
|
||||
'Exposure Time': 'Exposure Time',
|
||||
'Aperture': 'Aperture',
|
||||
'Focal Length': 'Focal Length',
|
||||
'Width': 'Width',
|
||||
'Height': 'Height',
|
||||
'No common EXIF info': 'No common EXIF info',
|
||||
'Bytes': 'Bytes',
|
||||
'File Properties': 'File Properties',
|
||||
'Loading file info...': 'Loading file info...',
|
||||
'Basic Info': 'Basic Info',
|
||||
'Type': 'Type',
|
||||
'Folder': 'Folder',
|
||||
'File': 'File',
|
||||
'Path': 'Path',
|
||||
'Path copied to clipboard': 'Path copied to clipboard',
|
||||
'Copy failed': 'Copy failed',
|
||||
'Permissions': 'Permissions',
|
||||
'EXIF Info': 'EXIF Info',
|
||||
|
||||
// Search dialog
|
||||
'Smart Search': 'Smart Search',
|
||||
'Name Search': 'Name Search',
|
||||
'Search Results': 'Search Results',
|
||||
'No files found': 'No files found',
|
||||
'Relevance': 'Relevance',
|
||||
|
||||
// System settings
|
||||
'Saved successfully': 'Saved successfully',
|
||||
'Save failed': 'Save failed',
|
||||
'Loading...': 'Loading...',
|
||||
'Appearance Settings': 'Appearance Settings',
|
||||
'Theme': 'Theme',
|
||||
'Theme Mode': 'Theme Mode',
|
||||
'Light': 'Light',
|
||||
'Dark': 'Dark',
|
||||
// 'Follow System' used for theme mode
|
||||
'Primary Color': 'Primary Color',
|
||||
'Border Radius': 'Border Radius',
|
||||
'Advanced': 'Advanced',
|
||||
'Override AntD Tokens (JSON)': 'Override AntD Tokens (JSON)',
|
||||
'e.g. {"colorText": "#222"}': 'e.g. {"colorText": "#222"}',
|
||||
'Custom CSS': 'Custom CSS',
|
||||
'Save': 'Save',
|
||||
'App Settings': 'App Settings',
|
||||
'AI Settings': 'AI Settings',
|
||||
'Vision Model': 'Vision Model',
|
||||
'Embedding Model': 'Embedding Model',
|
||||
'Vector Database': 'Vector Database',
|
||||
'Vector Database Settings': 'Vector Database Settings',
|
||||
'Database Type': 'Database Type',
|
||||
'Confirm clear vector database?': 'Confirm clear vector database?',
|
||||
'This will delete all collections irreversibly.': 'This will delete all collections irreversibly.',
|
||||
'Confirm Clear': 'Confirm Clear',
|
||||
// 'Cancel' defined above
|
||||
'Vector database cleared': 'Vector database cleared',
|
||||
'Clear failed': 'Clear failed',
|
||||
'Clear Vector DB': 'Clear Vector DB',
|
||||
'App Name': 'App Name',
|
||||
'Logo URL': 'Logo URL',
|
||||
'App Domain': 'App Domain',
|
||||
'File Domain': 'File Domain',
|
||||
'Vision API URL': 'Vision API URL',
|
||||
'Vision API Key': 'Vision API Key',
|
||||
'Embedding API URL': 'Embedding API URL',
|
||||
'Embedding API Key': 'Embedding API Key',
|
||||
|
||||
// Adapters
|
||||
'Missing required config:': 'Missing required config:',
|
||||
'Updated successfully': 'Updated successfully',
|
||||
'Created successfully': 'Created successfully',
|
||||
'Operation failed': 'Operation failed',
|
||||
'Deleted': 'Deleted',
|
||||
'Delete failed': 'Delete failed',
|
||||
'Status updated': 'Status updated',
|
||||
'Update failed': 'Update failed',
|
||||
'Mount Path': 'Mount Path',
|
||||
'Sub Path': 'Sub Path',
|
||||
'Sub Path (optional)': 'Sub Path (optional)',
|
||||
'Sub directory inside adapter': 'Sub directory inside adapter',
|
||||
'Enabled': 'Enabled',
|
||||
'Actions': 'Actions',
|
||||
'Edit': 'Edit',
|
||||
'Confirm delete?': 'Confirm delete?',
|
||||
'No config fields': 'No config fields',
|
||||
'Please input {label}': 'Please input {label}',
|
||||
'Storage Adapters': 'Storage Adapters',
|
||||
'Create Adapter': 'Create Adapter',
|
||||
'Unique name': 'Unique name',
|
||||
'Select adapter type': 'Select adapter type',
|
||||
'/ or /drive': '/ or /drive',
|
||||
'Adapter Config': 'Adapter Config',
|
||||
|
||||
// Tasks
|
||||
'Automation Tasks': 'Automation Tasks',
|
||||
'Running Tasks': 'Running Tasks',
|
||||
'Create Task': 'Create Task',
|
||||
'Edit Task': 'Edit Task',
|
||||
'Create Automation Task': 'Create Automation Task',
|
||||
'Task Name': 'Task Name',
|
||||
'Trigger Event': 'Trigger Event',
|
||||
'File Written': 'File Written',
|
||||
'File Deleted': 'File Deleted',
|
||||
'Matching Rules': 'Matching Rules',
|
||||
'Path Prefix (optional)': 'Path Prefix (optional)',
|
||||
'Filename Regex (optional)': 'Filename Regex (optional)',
|
||||
'Action': 'Action',
|
||||
'Current Task Queue': 'Current Task Queue',
|
||||
'Params': 'Params',
|
||||
'Status': 'Status',
|
||||
|
||||
// Logs
|
||||
'Confirm clear logs?': 'Confirm clear logs?',
|
||||
'This will delete logs in selected range irreversibly.': 'This will delete logs in selected range irreversibly.',
|
||||
'Cleared {count} logs': 'Cleared {count} logs',
|
||||
'Time': 'Time',
|
||||
'Level': 'Level',
|
||||
'Source': 'Source',
|
||||
'Message': 'Message',
|
||||
'Search source': 'Search source',
|
||||
'Clear': 'Clear',
|
||||
'Log Details': 'Log Details',
|
||||
|
||||
// Backup
|
||||
'Export started, check your downloads.': 'Export started, check your downloads.',
|
||||
'Export failed': 'Export failed',
|
||||
'Confirm import backup?': 'Confirm import backup?',
|
||||
'Are you sure to import from this file?': 'Are you sure to import from this file?',
|
||||
'Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!': 'Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!',
|
||||
'Confirm Import': 'Confirm Import',
|
||||
'Import succeeded! The page will refresh.': 'Import succeeded! The page will refresh.',
|
||||
'Import failed': 'Import failed',
|
||||
'Export': 'Export',
|
||||
'Import': 'Import',
|
||||
'Export all data (adapters, users, tasks, shares) into a JSON file.': 'Export all data (adapters, users, tasks, shares) into a JSON file.',
|
||||
'Keep your backup file safe.': 'Keep your backup file safe.',
|
||||
'Export Backup': 'Export Backup',
|
||||
'Restore data from a previously exported JSON file.': 'Restore data from a previously exported JSON file.',
|
||||
'Warning: This will clear and overwrite existing data.': 'Warning: This will clear and overwrite existing data.',
|
||||
'Choose File and Restore': 'Choose File and Restore',
|
||||
|
||||
// Empty state
|
||||
'No files yet here': 'No files yet here',
|
||||
'This folder is empty': 'This folder is empty',
|
||||
'Start uploading files or create folders to organize your content': 'Start uploading files or create folders to organize your content',
|
||||
'You can create folders or upload files here': 'You can create folders or upload files here',
|
||||
|
||||
// File actions
|
||||
'Please input name': 'Please input name',
|
||||
'Confirm delete {name}?': 'Confirm delete {name}?',
|
||||
'items': 'items',
|
||||
'Downloading folders is not supported': 'Downloading folders is not supported',
|
||||
'Download failed': 'Download failed',
|
||||
'Please select files or folders to share': 'Please select files or folders to share',
|
||||
'Direct links for folders are not supported': 'Direct links for folders are not supported',
|
||||
|
||||
// Processor flow
|
||||
'Processing finished': 'Processing finished',
|
||||
'Processing failed': 'Processing failed',
|
||||
|
||||
// Plugins page
|
||||
'Installed successfully': 'Installed successfully',
|
||||
'Plugin': 'Plugin',
|
||||
'Open Link': 'Open Link',
|
||||
'Link copied': 'Link copied',
|
||||
'Copy Link': 'Copy Link',
|
||||
'Confirm delete this plugin?': 'Confirm delete this plugin?',
|
||||
'Author': 'Author',
|
||||
'Website': 'Website',
|
||||
'Install App': 'Install App',
|
||||
'Search name/author/url/extension': 'Search name/author/url/extension',
|
||||
'No plugins': 'No plugins',
|
||||
'Install': 'Install',
|
||||
'App URL': 'App URL',
|
||||
'Please input a valid URL': 'Please input a valid URL',
|
||||
|
||||
// Setup page
|
||||
'Initialization succeeded! Logging you in...': 'Initialization succeeded! Logging you in...',
|
||||
'Initialization failed, please try later': 'Initialization failed, please try later',
|
||||
'Database Setup': 'Database Setup',
|
||||
'Choose database driver': 'Choose database driver',
|
||||
'Select database and vector database for system data': 'Select database and vector database for system data',
|
||||
'Database Driver': 'Database Driver',
|
||||
'Vector DB Driver': 'Vector DB Driver',
|
||||
'Initialize Mount': 'Initialize Mount',
|
||||
'Configure initial storage': 'Configure initial storage',
|
||||
'Create the first storage mount for your files': 'Create the first storage mount for your files',
|
||||
'Mount Name': 'Mount Name',
|
||||
'Local Storage': 'Local Storage',
|
||||
'Please input mount name!': 'Please input mount name!',
|
||||
'Storage Type': 'Storage Type',
|
||||
'Please input mount path!': 'Please input mount path!',
|
||||
'Root Directory': 'Root Directory',
|
||||
'Please input root directory!': 'Please input root directory!',
|
||||
'e.g., data/ or /var/foxel/data': 'e.g., data/ or /var/foxel/data',
|
||||
'Create Admin': 'Create Admin',
|
||||
'Create admin account': 'Create admin account',
|
||||
'This is the first account with full permissions': 'This is the first account with full permissions',
|
||||
'Username': 'Username',
|
||||
'Full Name': 'Full Name',
|
||||
'Email': 'Email',
|
||||
'Please input a valid email!': 'Please input a valid email!',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm your password!': 'Please confirm your password!',
|
||||
'Passwords do not match!': 'Passwords do not match!',
|
||||
'System Initialization': 'System Initialization',
|
||||
'Previous': 'Previous',
|
||||
'Next': 'Next',
|
||||
'Finish Initialization': 'Finish Initialization',
|
||||
|
||||
// Plugin host
|
||||
'Plugin run failed': 'Plugin run failed',
|
||||
'Plugin Error': 'Plugin Error',
|
||||
'Cannot open file: no available app': 'Cannot open file: no available app',
|
||||
'Error': 'Error',
|
||||
'App "{key}" not found.': 'App "{key}" not found.',
|
||||
'Open with {app}': 'Open with {app}',
|
||||
'Set as default for .{ext}': 'Set as default for .{ext}',
|
||||
'Advanced tokens must be valid JSON': 'Advanced tokens must be valid JSON',
|
||||
} as const;
|
||||
|
||||
export type EnKeys = keyof typeof en;
|
||||
367
web/src/i18n/locales/zh.ts
Normal file
367
web/src/i18n/locales/zh.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { en } from './en';
|
||||
|
||||
// Start from English defaults, then override with Chinese translations we have.
|
||||
export const zh = {
|
||||
...en,
|
||||
|
||||
// General
|
||||
'All Files': '全部文件',
|
||||
'Manage': '管理',
|
||||
'System': '系统',
|
||||
'Automation': '自动化',
|
||||
'My Shares': '我的分享',
|
||||
'Offline Downloads': '离线下载',
|
||||
'Adapters': '存储挂载',
|
||||
'Plugins': '应用中心',
|
||||
'System Settings': '系统设置',
|
||||
'Backup & Restore': '备份恢复',
|
||||
'System Logs': '系统日志',
|
||||
|
||||
// Top header
|
||||
'Search files / tags / types': '搜索文件 / 标签 / 类型',
|
||||
'Log Out': '退出登录',
|
||||
'Admin': '管理员',
|
||||
'Language': '语言',
|
||||
'Chinese': '中文',
|
||||
'English': '英文',
|
||||
|
||||
// Auth / Login
|
||||
'Welcome Back': '欢迎回来',
|
||||
'Sign in to your Foxel account': '登录到您的 Foxel 账户',
|
||||
'Username / Email': '用户名/邮箱',
|
||||
'Password': '密码',
|
||||
'Sign In': '登录',
|
||||
'Please enter username and password': '请输入用户名与密码',
|
||||
'Login failed': '登录失败',
|
||||
'Your next-generation file manager': '您的下一代文件管理系统',
|
||||
'Cross-platform sync, access anywhere': '跨平台同步,随时随地访问',
|
||||
'AI-powered search for quick find': 'AI 驱动的智能搜索,快速定位文件',
|
||||
'Flexible sharing and collaboration': '灵活的分享与协作,提升团队效率',
|
||||
'Powerful automation to simplify tasks': '强大的自动化工作流,简化繁琐任务',
|
||||
'Join our community:': '加入我们的社区:',
|
||||
|
||||
// Share page
|
||||
'Refresh': '刷新',
|
||||
'Copy': '复制',
|
||||
'Cancel': '取消',
|
||||
'Copied link': '链接已复制',
|
||||
'Share canceled': '分享已取消',
|
||||
'Cancel failed': '取消失败',
|
||||
'Load failed': '加载失败',
|
||||
'Are you sure to cancel share?': '确认取消分享?',
|
||||
'Share Name': '分享名称',
|
||||
'Share Content': '分享内容',
|
||||
'Created At': '创建时间',
|
||||
'Expires At': '过期时间',
|
||||
'Forever': '永久有效',
|
||||
'Access': '访问',
|
||||
'Public': '公开',
|
||||
'By Password': '密码',
|
||||
|
||||
// Public share page
|
||||
'Password Required': '需要密码',
|
||||
'Please enter password': '请输入密码',
|
||||
'Confirm': '确认',
|
||||
'Unable to load share info': '无法加载分享信息',
|
||||
'Share load failed': '加载分享失败',
|
||||
'Wrong password': '密码错误',
|
||||
'Root': '全部文件',
|
||||
'Created on {date}': '创建于 {date}',
|
||||
'Expires on {date}': '将于 {date} 过期',
|
||||
'Download File': '下载文件',
|
||||
'Preview not supported for this file type': '暂不支持在线预览此类型文件',
|
||||
'Back': '返回',
|
||||
'Download': '下载',
|
||||
|
||||
// Header/File Explorer
|
||||
'Home': '主页',
|
||||
'File Manager': '文件管理',
|
||||
'New Folder': '新建目录',
|
||||
'Upload': '上传',
|
||||
'Name': '名称',
|
||||
'Size': '大小',
|
||||
'Modified Time': '修改时间',
|
||||
'Grid': '网格',
|
||||
'List': '列表',
|
||||
'Mount Point': '挂载点',
|
||||
|
||||
// Context menu
|
||||
'Upload File': '上传文件',
|
||||
'Open': '打开',
|
||||
'Open With': '打开方式',
|
||||
'Default': '默认',
|
||||
'Rename': '重命名',
|
||||
'Delete': '删除',
|
||||
'Details': '详情',
|
||||
'Get Direct Link': '获取直链',
|
||||
|
||||
// Side nav modals
|
||||
'Join Community': '加入社区',
|
||||
'Scan to join WeChat group': '微信扫码加入交流群',
|
||||
'If QR expires, add drizzle2001 to join': '如二维码失效,请添加 drizzle2001 拉群',
|
||||
'Version Info': '版本信息',
|
||||
'Current Version': '当前版本',
|
||||
'Latest Version': '最新版本',
|
||||
'New version found: {version}': '发现新版本: {version}',
|
||||
'Please update to the latest for features and fixes': '建议尽快更新到最新版本,以获得新功能和安全修复。',
|
||||
'Open Releases': '前往发布页面',
|
||||
'Changelog': '更新日志',
|
||||
'Fetching latest version...': '正在获取最新版本信息...',
|
||||
'Update available': '有更新',
|
||||
'You are on the latest: {version}': '当前为最新版: {version}',
|
||||
'Up to date': '已是最新版',
|
||||
|
||||
// Share modal
|
||||
'Share {count} items': '分享 {count} 个项目',
|
||||
'Share link created': '分享链接已创建',
|
||||
'Create failed': '创建失败',
|
||||
'Copied to clipboard': '已复制到剪贴板',
|
||||
'Expiration (days)': '有效期 (天)',
|
||||
'Set 0 or negative for forever': '设置为 0 或负数表示永久有效',
|
||||
'Share link created successfully!': '分享链接已成功创建!',
|
||||
'Share Link': '分享链接',
|
||||
'Share created': '分享创建成功',
|
||||
'Create Share': '创建分享',
|
||||
'Done': '完成',
|
||||
'Create': '创建',
|
||||
|
||||
// Direct link modal
|
||||
'Failed to generate link': '生成链接失败',
|
||||
'Markdown copied to clipboard': 'Markdown 格式已复制到剪贴板',
|
||||
'Generate a direct link for {name}': '为 {name} 生成一个直接访问链接。',
|
||||
'1 hour': '1 小时',
|
||||
'1 day': '1 天',
|
||||
'7 days': '7 天',
|
||||
'Generating link...': '正在生成链接...',
|
||||
'Link will appear here': '链接将显示在这里',
|
||||
'Copy Markdown': '复制 Markdown',
|
||||
'Close': '关闭',
|
||||
|
||||
// File detail
|
||||
'Camera Make': '设备品牌',
|
||||
'Camera Model': '设备型号',
|
||||
'Capture Time': '拍摄时间',
|
||||
'X Resolution': '水平分辨率',
|
||||
'Y Resolution': '垂直分辨率',
|
||||
'Exposure Time': '曝光时间',
|
||||
'Aperture': '光圈值',
|
||||
'Focal Length': '焦距',
|
||||
'Width': '宽度',
|
||||
'Height': '高度',
|
||||
'No common EXIF info': '无常见EXIF信息',
|
||||
'Bytes': '字节',
|
||||
'File Properties': '文件属性',
|
||||
'Loading file info...': '加载文件信息...',
|
||||
'Basic Info': '基本信息',
|
||||
'Type': '类型',
|
||||
'Folder': '文件夹',
|
||||
'File': '文件',
|
||||
'Path': '路径',
|
||||
'Path copied to clipboard': '路径已复制到剪贴板',
|
||||
'Copy failed': '复制失败',
|
||||
'Permissions': '权限',
|
||||
'EXIF Info': 'EXIF信息',
|
||||
|
||||
// Search dialog
|
||||
'Smart Search': '智能搜索',
|
||||
'Name Search': '名称搜索',
|
||||
'Search Results': '搜索结果',
|
||||
'No files found': '未找到相关文件',
|
||||
'Relevance': '相关度',
|
||||
|
||||
// System settings
|
||||
'Saved successfully': '保存成功',
|
||||
'Save failed': '保存失败',
|
||||
'Loading...': '加载中...',
|
||||
'Appearance Settings': '外观设置',
|
||||
'Theme': '主题',
|
||||
'Theme Mode': '主题模式',
|
||||
'Light': '亮色',
|
||||
'Dark': '暗色',
|
||||
// 'Follow System' used for theme mode
|
||||
'Follow System': '跟随系统',
|
||||
'Primary Color': '主色',
|
||||
'Border Radius': '圆角',
|
||||
'Advanced': '高级',
|
||||
'Override AntD Tokens (JSON)': '覆盖 AntD Token(JSON)',
|
||||
'e.g. {"colorText": "#222"}': '例如:{"colorText": "#222"}',
|
||||
'Custom CSS': '自定义 CSS',
|
||||
'Save': '保存',
|
||||
'App Settings': '应用设置',
|
||||
'AI Settings': 'AI设置',
|
||||
'Vision Model': '视觉模型',
|
||||
'Embedding Model': '嵌入模型',
|
||||
'Vector Database': '向量数据库',
|
||||
'Vector Database Settings': '向量数据库设置',
|
||||
'Database Type': '数据库类型',
|
||||
'Confirm clear vector database?': '确认清空向量数据库?',
|
||||
'This will delete all collections irreversibly.': '此操作将删除所有集合中的所有数据,且不可逆。',
|
||||
'Confirm Clear': '确认清空',
|
||||
// 'Cancel' defined above
|
||||
'Vector database cleared': '向量数据库已清空',
|
||||
'Clear failed': '清空失败',
|
||||
'Clear Vector DB': '清空向量库',
|
||||
'App Name': '应用名称',
|
||||
'Logo URL': 'LOGO地址',
|
||||
'App Domain': '应用域名',
|
||||
'File Domain': '文件域名',
|
||||
'Vision API URL': '视觉模型 API 地址',
|
||||
'Vision API Key': '视觉模型 API Key',
|
||||
'Embedding API URL': '嵌入模型 API 地址',
|
||||
'Embedding API Key': '嵌入模型 API Key',
|
||||
|
||||
// Adapters
|
||||
'Missing required config:': '缺少必填配置:',
|
||||
'Updated successfully': '更新成功',
|
||||
'Created successfully': '创建成功',
|
||||
'Operation failed': '操作失败',
|
||||
'Deleted': '已删除',
|
||||
'Delete failed': '删除失败',
|
||||
'Status updated': '状态已更新',
|
||||
'Update failed': '更新失败',
|
||||
'Mount Path': '挂载路径',
|
||||
'Sub Path': '子路径',
|
||||
'Sub Path (optional)': '子路径(可选)',
|
||||
'Sub directory inside adapter': '适配器内部子目录',
|
||||
'Enabled': '启用',
|
||||
'Actions': '操作',
|
||||
'Edit': '编辑',
|
||||
'Confirm delete?': '确认删除?',
|
||||
'No config fields': '无配置项',
|
||||
'Please input {label}': '请输入{label}',
|
||||
'Storage Adapters': '存储适配器',
|
||||
'Create Adapter': '新建适配器',
|
||||
'Unique name': '唯一名称',
|
||||
'Select adapter type': '选择适配器类型',
|
||||
'/ or /drive': '/或/drive',
|
||||
'Adapter Config': '适配器配置',
|
||||
|
||||
// Tasks
|
||||
'Automation Tasks': '自动化任务',
|
||||
'Running Tasks': '运行中的任务',
|
||||
'Create Task': '新建任务',
|
||||
'Edit Task': '编辑任务',
|
||||
'Create Automation Task': '新建自动化任务',
|
||||
'Task Name': '任务名称',
|
||||
'Trigger Event': '触发事件',
|
||||
'File Written': '文件写入',
|
||||
'File Deleted': '文件删除',
|
||||
'Matching Rules': '匹配规则',
|
||||
'Path Prefix (optional)': '路径前缀 (可选)',
|
||||
'Filename Regex (optional)': '文件名正则 (可选)',
|
||||
'Action': '执行动作',
|
||||
'Current Task Queue': '当前任务队列',
|
||||
'Params': '参数',
|
||||
'Status': '状态',
|
||||
|
||||
// Logs
|
||||
'Confirm clear logs?': '确认清理日志?',
|
||||
'This will delete logs in selected range irreversibly.': '该操作将删除选定时间范围内的所有日志,且不可恢复。',
|
||||
'Cleared {count} logs': '成功清理 {count} 条日志',
|
||||
'Time': '时间',
|
||||
'Level': '级别',
|
||||
'Source': '来源',
|
||||
'Message': '消息',
|
||||
'Search source': '搜索来源',
|
||||
'Clear': '清理',
|
||||
'Log Details': '日志详情',
|
||||
|
||||
// Backup
|
||||
'Export started, check your downloads.': '导出已开始,请检查您的下载。',
|
||||
'Export failed': '导出失败',
|
||||
'Confirm import backup?': '确认导入备份?',
|
||||
'Are you sure to import from this file?': '您确定要从此文件导入数据吗?',
|
||||
'Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!': '警告:此操作将覆盖当前数据库中的所有现有数据,包括用户(含密码)、设置、存储和任务。此操作不可逆!',
|
||||
'Confirm Import': '确认导入',
|
||||
'Import succeeded! The page will refresh.': '导入成功!页面将刷新。',
|
||||
'Import failed': '导入失败',
|
||||
'Export': '导出',
|
||||
'Import': '恢复',
|
||||
'Export all data (adapters, users, tasks, shares) into a JSON file.': '点击按钮将所有数据(包括存储、用户、自动化任务和分享)导出为一个 JSON 文件。',
|
||||
'Keep your backup file safe.': '请妥善保管您的备份文件。',
|
||||
'Export Backup': '导出备份',
|
||||
'Restore data from a previously exported JSON file.': '从之前导出的JSON文件恢复数据。',
|
||||
'Warning: This will clear and overwrite existing data.': '警告:此操作将清除并覆盖现有数据。',
|
||||
'Choose File and Restore': '选择文件并恢复',
|
||||
|
||||
// Empty state
|
||||
'No files yet here': '这里还没有任何文件',
|
||||
'This folder is empty': '此目录为空',
|
||||
'Start uploading files or create folders to organize your content': '开始上传文件或创建新目录来组织您的内容',
|
||||
'You can create folders or upload files here': '您可以在此目录中创建新的文件夹或上传文件',
|
||||
|
||||
// File actions
|
||||
'Please input name': '请输入名称',
|
||||
'Confirm delete {name}?': '确认删除 {name} ?',
|
||||
'items': '项',
|
||||
'Downloading folders is not supported': '暂不支持下载目录',
|
||||
'Download failed': '下载失败',
|
||||
'Please select files or folders to share': '请选择要分享的文件或目录',
|
||||
'Direct links for folders are not supported': '不支持获取目录的直链',
|
||||
|
||||
// Processor flow
|
||||
'Processing finished': '处理完成',
|
||||
'Processing failed': '处理失败',
|
||||
|
||||
// Plugins page
|
||||
'Installed successfully': '安装成功',
|
||||
'Plugin': '插件',
|
||||
'Open Link': '打开链接',
|
||||
'Link copied': '已复制链接',
|
||||
'Copy Link': '复制链接',
|
||||
'Confirm delete this plugin?': '确认删除该插件?',
|
||||
'Author': '作者',
|
||||
'Website': '官网',
|
||||
'Install App': '安装应用',
|
||||
'Search name/author/url/extension': '搜索 名称/作者/链接/扩展名',
|
||||
'No plugins': '暂无插件',
|
||||
'Install': '安装',
|
||||
'App URL': '应用链接',
|
||||
'Please input a valid URL': '请输入合法的 URL',
|
||||
|
||||
// Setup page
|
||||
'Initialization succeeded! Logging you in...': '初始化成功!正在为您登录,请不要刷新。',
|
||||
'Initialization failed, please try later': '初始化失败,请稍后重试',
|
||||
'Database Setup': '数据库设置',
|
||||
'Choose database driver': '选择数据库驱动',
|
||||
'Select database and vector database for system data': '选择用于存储系统数据的数据库和向量数据库。',
|
||||
'Database Driver': '数据库驱动',
|
||||
'Vector DB Driver': '向量数据库驱动',
|
||||
'Initialize Mount': '初始化挂载',
|
||||
'Configure initial storage': '配置初始存储',
|
||||
'Create the first storage mount for your files': '为您的文件创建第一个存储挂载点。',
|
||||
'Mount Name': '挂载名称',
|
||||
'Local Storage': '本地存储',
|
||||
'Please input mount name!': '请输入挂载名称!',
|
||||
'Storage Type': '存储类型',
|
||||
'Please input mount path!': '请输入挂载路径!',
|
||||
'Root Directory': '根目录',
|
||||
'Please input root directory!': '请输入根目录!',
|
||||
'e.g., data/ or /var/foxel/data': '例如: data/ 或 /var/foxel/data',
|
||||
'Create Admin': '创建管理员',
|
||||
'Create admin account': '创建管理员账户',
|
||||
'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。',
|
||||
'Username': '用户名',
|
||||
'Full Name': '昵称',
|
||||
'Email': '邮箱',
|
||||
'Please input a valid email!': '请输入有效的邮箱地址!',
|
||||
'Confirm Password': '确认密码',
|
||||
'Please confirm your password!': '请确认您的密码!',
|
||||
'Passwords do not match!': '两次输入的密码不一致!',
|
||||
'System Initialization': '系统初始化',
|
||||
'Previous': '上一步',
|
||||
'Next': '下一步',
|
||||
'Finish Initialization': '完成初始化',
|
||||
|
||||
// Plugin host
|
||||
'Plugin run failed': '插件运行失败',
|
||||
'Plugin Error': '插件错误',
|
||||
'Cannot open file: no available app': '无法打开该文件:没有可用的应用',
|
||||
'Error': '错误',
|
||||
'App "{key}" not found.': '应用 "{key}" 不存在。',
|
||||
'Open with {app}': '使用 {app} 打开',
|
||||
'Set as default for .{ext}': '设为该类型(.{ext})默认应用',
|
||||
'Advanced tokens must be valid JSON': '高级 Token 需为合法 JSON',
|
||||
} as const;
|
||||
|
||||
export type ZhKeys = keyof typeof zh;
|
||||
@@ -2,6 +2,7 @@ import { Modal, Input, List, Divider, Spin, Select, Space } from 'antd';
|
||||
import { SearchOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { vfsApi, type SearchResultItem } from '../api/vfs';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
|
||||
@@ -10,9 +11,9 @@ interface SearchDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SEARCH_MODES = [
|
||||
{ label: '智能搜索', value: 'vector' },
|
||||
{ label: '名称搜索', value: 'filename' },
|
||||
const SEARCH_MODES = (t: (k: string)=>string) => [
|
||||
{ label: t('Smart Search'), value: 'vector' },
|
||||
{ label: t('Name Search'), value: 'filename' },
|
||||
];
|
||||
|
||||
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
@@ -21,6 +22,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [searchMode, setSearchMode] = useState<'vector' | 'filename'>('vector');
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = async () => {
|
||||
@@ -48,7 +50,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
>
|
||||
<Space.Compact style={{ marginBottom: 0, width: '100%' }}>
|
||||
<Select
|
||||
options={SEARCH_MODES}
|
||||
options={SEARCH_MODES(t)}
|
||||
value={searchMode}
|
||||
onChange={v => setSearchMode(v as 'vector' | 'filename')}
|
||||
style={{
|
||||
@@ -67,7 +69,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索文件 / 标签 / 类型"
|
||||
placeholder={t('Search files / tags / types')}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{
|
||||
@@ -84,14 +86,14 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
</Space.Compact>
|
||||
{searched && (
|
||||
<>
|
||||
<Divider style={{ margin: '12px 0' }}>搜索结果</Divider>
|
||||
<Divider style={{ margin: '12px 0' }}>{t('Search Results')}</Divider>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={results}
|
||||
locale={{ emptyText: '未找到相关文件' }}
|
||||
locale={{ emptyText: t('No files found') }}
|
||||
renderItem={item => {
|
||||
const fullPath = item.path || '';
|
||||
const trimmed = fullPath.replace(/\/+$/, '');
|
||||
@@ -112,7 +114,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
{fullPath}
|
||||
</a>
|
||||
}
|
||||
description={`相关度: ${item.score.toFixed(2)}`}
|
||||
description={`${t('Relevance')}: ${item.score.toFixed(2)}`}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
const { Sider } = Layout;
|
||||
|
||||
export interface SideNavProps {
|
||||
@@ -29,6 +30,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
const status = useSystemStatus();
|
||||
const { token } = theme.useToken();
|
||||
const { resolvedMode } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState<{
|
||||
@@ -122,7 +124,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
color: token.colorTextTertiary,
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>{group.title}</div>
|
||||
>{t(group.title)}</div>
|
||||
)}
|
||||
<Menu
|
||||
mode="inline"
|
||||
@@ -130,7 +132,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
inlineIndent={12}
|
||||
selectedKeys={[activeKey]}
|
||||
onClick={(e) => onChange(e.key)}
|
||||
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: i.label }))}
|
||||
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"
|
||||
/>
|
||||
@@ -162,26 +164,26 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
cursor: 'pointer'
|
||||
}} onClick={showVersionModal}>
|
||||
{hasUpdate ? (
|
||||
<Tooltip title={`发现新版本: ${latestVersion?.version}`} placement={collapsed ? 'right' : 'top'}>
|
||||
<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">
|
||||
{status?.version} - 有更新 [{latestVersion?.version}]
|
||||
{status?.version} - {t('Update available')} [{latestVersion?.version}]
|
||||
</Tag>
|
||||
)}
|
||||
</a>
|
||||
</Tooltip>
|
||||
) : (
|
||||
latestVersion ? (
|
||||
<Tooltip title={`当前为最新版: ${status?.version}`} placement={collapsed ? 'right' : 'top'}>
|
||||
<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">
|
||||
已是最新版
|
||||
{t('Up to date')}
|
||||
</Tag>
|
||||
)}
|
||||
</Tooltip>
|
||||
@@ -221,24 +223,24 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
title="加入社区"
|
||||
title={t('Join Community')}
|
||||
footer={null}
|
||||
width={320}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<img src="https://foxel.cc/image/wechat.png" width={200} alt="wechat" />
|
||||
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>
|
||||
微信扫码加入交流群
|
||||
{t('Scan to join WeChat group')}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: token.colorTextTertiary }}>
|
||||
如二维码失效,请添加 drizzle2001 拉群
|
||||
{t('If QR expires, add drizzle2001 to join')}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isVersionModalOpen}
|
||||
onCancel={() => setIsVersionModalOpen(false)}
|
||||
title="版本信息"
|
||||
title={t('Version Info')}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
@@ -246,18 +248,18 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
{latestVersion ? (
|
||||
<>
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="当前版本">
|
||||
<Descriptions.Item label={t('Current Version')}>
|
||||
<Tag>{status?.version}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最新版本">
|
||||
<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 }}>{`发现新版本: ${latestVersion.version}`}</span>}
|
||||
description={<span style={{ color: token.colorTextSecondary }}>建议尽快更新到最新版本,以获得新功能和安全修复。</span>}
|
||||
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 }}
|
||||
@@ -269,13 +271,13 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
target="_blank"
|
||||
icon={<GithubOutlined />}
|
||||
>
|
||||
前往发布页面
|
||||
{t('Open Releases')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider orientation="left" plain>更新日志</Divider>
|
||||
<Divider orientation="left" plain>{t('Changelog')}</Divider>
|
||||
<div style={{
|
||||
maxHeight: '40vh',
|
||||
overflowY: 'auto',
|
||||
@@ -305,7 +307,7 @@ const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: token.colorTextSecondary }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16 }}>正在获取最新版本信息...</p>
|
||||
<p style={{ marginTop: 16 }}>{t('Fetching latest version...')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ 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';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
@@ -16,6 +18,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
|
||||
const { token } = theme.useToken();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
@@ -37,18 +40,19 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
|
||||
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}>
|
||||
<LanguageSwitcher />
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, onClick: handleLogout }
|
||||
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout }
|
||||
]
|
||||
}}
|
||||
>
|
||||
<Button icon={<UserOutlined />}>管理员</Button>
|
||||
<Button icon={<UserOutlined />}>{t('Admin')}</Button>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
</Header>
|
||||
|
||||
@@ -20,27 +20,27 @@ export const navGroups: NavGroup[] = [
|
||||
key: 'library',
|
||||
title: '',
|
||||
children: [
|
||||
{ key: 'files', icon: React.createElement(FolderOpenOutlined), label: '全部文件' },
|
||||
{ key: 'files', icon: React.createElement(FolderOpenOutlined), label: 'All Files' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'manage',
|
||||
title: '管理',
|
||||
title: 'Manage',
|
||||
children: [
|
||||
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: '自动化' },
|
||||
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: '我的分享' },
|
||||
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: '离线下载' },
|
||||
{ key: 'adapters', icon: React.createElement(ApiOutlined), label: '存储挂载' },
|
||||
{ key: 'plugins', icon: React.createElement(AppstoreOutlined), label: '应用中心' },
|
||||
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
|
||||
{ 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: '系统',
|
||||
title: 'System',
|
||||
children: [
|
||||
{ key: 'settings', icon: React.createElement(SettingOutlined), label: '系统设置' },
|
||||
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: '备份恢复' },
|
||||
{ key: 'logs', icon: React.createElement(BugOutlined), label: '系统日志' }
|
||||
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings' },
|
||||
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
|
||||
{ key: 'logs', icon: React.createElement(BugOutlined), label: 'System Logs' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select } from 'antd';
|
||||
import PageCard from '../components/PageCard';
|
||||
import { adaptersApi, type AdapterItem } from '../api/client';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
|
||||
interface AdapterTypeField {
|
||||
@@ -25,6 +26,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
const [editing, setEditing] = useState<AdapterItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [availableTypes, setAvailableTypes] = useState<AdapterTypeMeta[]>([]);
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -36,7 +38,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
setData(list);
|
||||
setAvailableTypes(types);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -90,7 +92,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
}
|
||||
});
|
||||
if (miss.length) {
|
||||
message.error('缺少必填配置: ' + miss.join(', '));
|
||||
message.error(t('Missing required config:') + ' ' + miss.join(', '));
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
@@ -104,17 +106,17 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
setLoading(true);
|
||||
if (editing) {
|
||||
await adaptersApi.update(editing.id, body as any);
|
||||
message.success('更新成功');
|
||||
message.success(t('Updated successfully'));
|
||||
} else {
|
||||
await adaptersApi.create(body as any);
|
||||
message.success('创建成功');
|
||||
message.success(t('Created successfully'));
|
||||
}
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return; // 表单校验
|
||||
message.error(e.message || '操作失败');
|
||||
message.error(e.message || t('Operation failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -123,10 +125,10 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
const doDelete = async (rec: AdapterItem) => {
|
||||
try {
|
||||
await adaptersApi.remove(rec.id);
|
||||
message.success('已删除');
|
||||
message.success(t('Deleted'));
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败');
|
||||
message.error(e.message || t('Delete failed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -134,22 +136,22 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await adaptersApi.update(rec.id, { ...rec, enabled: checked });
|
||||
message.success('状态已更新');
|
||||
message.success(t('Status updated'));
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '更新失败');
|
||||
message.error(e.message || t('Update failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '类型', dataIndex: 'type', width: 100 },
|
||||
{ title: '挂载路径', dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
|
||||
{ title: '子路径', dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
||||
{ title: t('Name'), dataIndex: 'name' },
|
||||
{ title: t('Type'), dataIndex: 'type', width: 100 },
|
||||
{ title: t('Mount Path'), dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
|
||||
{ title: t('Sub Path'), dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '启用',
|
||||
title: t('Enabled'),
|
||||
dataIndex: 'enabled',
|
||||
width: 80,
|
||||
render: (v: boolean, rec: AdapterItem) => (
|
||||
@@ -162,13 +164,13 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('Actions'),
|
||||
width: 160,
|
||||
render: (_: any, rec: AdapterItem) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEdit(rec)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
<Button size="small" onClick={() => openEdit(rec)}>{t('Edit')}</Button>
|
||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger>{t('Delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
@@ -179,9 +181,9 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
const currentTypeMeta = availableTypes.find(t => t.type === selectedType);
|
||||
|
||||
function renderConfigFields() {
|
||||
if (!currentTypeMeta) return <Typography.Text type="secondary">无配置项</Typography.Text>;
|
||||
if (!currentTypeMeta) return <Typography.Text type="secondary">{t('No config fields')}</Typography.Text>;
|
||||
return currentTypeMeta.config_schema.map(field => {
|
||||
const rules = field.required ? [{ required: true, message: `请输入${field.label}` }] : [];
|
||||
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
|
||||
let inputNode: any = <Input placeholder={field.placeholder} />;
|
||||
if (field.type === 'password') inputNode = <Input.Password placeholder={field.placeholder} />;
|
||||
if (field.type === 'number') inputNode = <Input type="number" placeholder={field.placeholder} />;
|
||||
@@ -189,7 +191,7 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={['config', field.key]}
|
||||
label={field.label}
|
||||
label={t(field.label)}
|
||||
rules={rules}
|
||||
>
|
||||
{inputNode}
|
||||
@@ -200,11 +202,11 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
title="存储适配器"
|
||||
title={t('Storage Adapters')}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={fetchList} loading={loading}>刷新</Button>
|
||||
<Button type="primary" onClick={openCreate}>新建适配器</Button>
|
||||
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button type="primary" onClick={openCreate}>{t('Create Adapter')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -217,15 +219,15 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<Drawer
|
||||
title={editing ? `编辑: ${editing.name}` : '新建适配器'}
|
||||
title={editing ? `${t('Edit')}: ${editing.name}` : t('Create Adapter')}
|
||||
width={480}
|
||||
open={open}
|
||||
onClose={() => { setOpen(false); setEditing(null); }}
|
||||
destroyOnClose
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>取消</Button>
|
||||
<Button type="primary" onClick={submit} loading={loading}>提交</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={submit} loading={loading}>{t('Submit')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -234,12 +236,12 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
layout="vertical"
|
||||
initialValues={{ enabled: true }}
|
||||
>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="唯一名称" />
|
||||
<Form.Item name="name" label={t('Name')} rules={[{ required: true, message: t('Please input {label}', { label: t('Name') }) }]}>
|
||||
<Input placeholder={t('Unique name')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||
<Form.Item name="type" label={t('Type')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder="选择适配器类型"
|
||||
placeholder={t('Select adapter type')}
|
||||
options={availableTypes.map(t => ({ value: t.type, label: `${t.name} (${t.type})` }))}
|
||||
onChange={() => {
|
||||
const t = availableTypes.find(v => v.type === form.getFieldValue('type'));
|
||||
@@ -251,16 +253,16 @@ const AdaptersPage = memo(function AdaptersPage() {
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="path" label="挂载路径" rules={[{ required: true, message: '请输入挂载路径' }]}>
|
||||
<Input placeholder="/或/drive" />
|
||||
<Form.Item name="path" label={t('Mount Path')} rules={[{ required: true, message: t('Please input {label}', { label: t('Mount Path') }) }]}>
|
||||
<Input placeholder={t('/ or /drive')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sub_path" label="子路径(可选)">
|
||||
<Input placeholder="适配器内部子目录" />
|
||||
<Form.Item name="sub_path" label={t('Sub Path (optional)')}>
|
||||
<Input placeholder={t('Sub directory inside adapter')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>适配器配置</Typography.Title>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Adapter Config')}</Typography.Title>
|
||||
{renderConfigFields()}
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Menu, theme } from 'antd';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
|
||||
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined, LinkOutlined
|
||||
@@ -30,13 +31,14 @@ interface ContextMenuProps {
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const { x, y, entry, entries, selectedEntries, processorTypes, onClose, ...actions } = props;
|
||||
|
||||
const getContextMenuItems = () => {
|
||||
if (!entry) { // Blank context menu
|
||||
return [
|
||||
{ key: 'upload', label: '上传文件', icon: <UploadOutlined />, onClick: actions.onUpload },
|
||||
{ key: 'mkdir', label: '新建目录', icon: <PlusOutlined />, onClick: actions.onCreateDir },
|
||||
{ key: 'upload', label: t('Upload File'), icon: <UploadOutlined />, onClick: actions.onUpload },
|
||||
{ key: 'mkdir', label: t('New Folder'), icon: <PlusOutlined />, onClick: actions.onCreateDir },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -61,56 +63,56 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
return [
|
||||
(entry.is_dir || apps.length > 0) ? {
|
||||
key: 'open',
|
||||
label: defaultApp ? `打开 (${defaultApp.name})` : '打开',
|
||||
label: defaultApp ? `${t('Open')} (${defaultApp.name})` : t('Open'),
|
||||
icon: <FolderFilled />,
|
||||
onClick: () => actions.onOpen(entry),
|
||||
} : null,
|
||||
!entry.is_dir && apps.length > 0 ? {
|
||||
key: 'openWith',
|
||||
label: '打开方式',
|
||||
label: t('Open With'),
|
||||
icon: <AppstoreOutlined />,
|
||||
children: apps.map(a => ({
|
||||
key: 'openWith-' + a.key,
|
||||
label: a.name + (a.key === defaultApp?.key ? ' (默认)' : ''),
|
||||
label: a.name + (a.key === defaultApp?.key ? ` (${t('Default')})` : ''),
|
||||
onClick: () => actions.onOpenWith(entry, a.key),
|
||||
})),
|
||||
} : null,
|
||||
!entry.is_dir && processorSubMenu.length > 0 ? {
|
||||
key: 'process',
|
||||
label: '处理器',
|
||||
label: t('Processor'),
|
||||
icon: <AppstoreAddOutlined />,
|
||||
children: processorSubMenu,
|
||||
} : null,
|
||||
{
|
||||
key: 'share',
|
||||
label: '分享',
|
||||
label: t('Share'),
|
||||
icon: <ShareAltOutlined />,
|
||||
onClick: () => actions.onShare(targetEntries),
|
||||
},
|
||||
{
|
||||
key: 'directLink',
|
||||
label: '获取直链',
|
||||
label: t('Get Direct Link'),
|
||||
icon: <LinkOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].is_dir,
|
||||
onClick: () => actions.onGetDirectLink(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: '下载',
|
||||
label: t('Download'),
|
||||
icon: <DownloadOutlined />,
|
||||
disabled: targetEntries.some(t => t.is_dir) || targetEntries.length > 1,
|
||||
onClick: () => actions.onDownload(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'rename',
|
||||
label: '重命名',
|
||||
label: t('Rename'),
|
||||
icon: <EditOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
||||
onClick: () => actions.onRename(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
label: t('Delete'),
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
disabled: targetEntries.some(t => t.type === 'mount'),
|
||||
@@ -118,7 +120,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情',
|
||||
label: t('Details'),
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => actions.onDetail(entry),
|
||||
},
|
||||
@@ -148,4 +150,4 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography, theme } from 'antd';
|
||||
import { FolderOpenOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
interface Props {
|
||||
isRoot: boolean;
|
||||
@@ -8,14 +9,15 @@ interface Props {
|
||||
|
||||
export const EmptyState: React.FC<Props> = ({ isRoot }) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', padding:isRoot? '80px 40px':'60px 40px', minHeight: isRoot? '400px':'300px', color: token.colorTextSecondary }}>
|
||||
<FolderOpenOutlined style={{ fontSize:64, color: token.colorTextQuaternary, marginBottom:16 }} />
|
||||
<Typography.Title level={4} style={{ color: token.colorTextSecondary, marginBottom:8, fontWeight:400 }}>
|
||||
{isRoot ? '这里还没有任何文件' : '此目录为空'}
|
||||
{isRoot ? t('No files yet here') : t('This folder is empty')}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: token.colorTextTertiary, marginBottom:24, textAlign:'center', maxWidth:300, lineHeight:1.5 }}>
|
||||
{isRoot ? '开始上传文件或创建新目录来组织您的内容' : '您可以在此目录中创建新的文件夹或上传文件'}
|
||||
{isRoot ? t('Start uploading files or create folders to organize your content') : t('You can create folders or upload files here')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Modal, Typography, Spin, theme, Card, Descriptions, Divider, Badge, Space, message } from 'antd';
|
||||
import { FileOutlined, FolderOutlined, CameraOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
interface Props {
|
||||
@@ -10,21 +11,24 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const exifFieldMap: Record<string, { label: string; format?: (v: any) => string }> = {
|
||||
'271': { label: '设备品牌' },
|
||||
'272': { label: '设备型号' },
|
||||
'306': { label: '拍摄时间' },
|
||||
'282': { label: '水平分辨率', format: v => `${v} dpi` },
|
||||
'283': { label: '垂直分辨率', format: v => `${v} dpi` },
|
||||
'33434': { label: '曝光时间', format: v => `${v} 秒` },
|
||||
'33437': { label: '光圈值', format: v => `f/${v}` },
|
||||
'34855': { label: 'ISO' },
|
||||
'37377': { label: '焦距', format: v => `${v} mm` },
|
||||
'40962': { label: '宽度', format: v => `${v} px` },
|
||||
'40963': { label: '高度', format: v => `${v} px` },
|
||||
};
|
||||
function getExifFieldMap(t: (k: string)=>string): Record<string, { label: string; format?: (v: any) => string }> {
|
||||
return {
|
||||
'271': { label: t('Camera Make') },
|
||||
'272': { label: t('Camera Model') },
|
||||
'306': { label: t('Capture Time') },
|
||||
'282': { label: t('X Resolution'), format: v => `${v} dpi` },
|
||||
'283': { label: t('Y Resolution'), format: v => `${v} dpi` },
|
||||
'33434': { label: t('Exposure Time'), format: v => `${v} s` },
|
||||
'33437': { label: t('Aperture'), format: v => `f/${v}` },
|
||||
'34855': { label: 'ISO' },
|
||||
'37377': { label: t('Focal Length'), format: v => `${v} mm` },
|
||||
'40962': { label: t('Width'), format: v => `${v} px` },
|
||||
'40963': { label: t('Height'), format: v => `${v} px` },
|
||||
};
|
||||
}
|
||||
|
||||
function renderExif(exif: Record<string, any>) {
|
||||
function renderExif(exif: Record<string, any>, t: (k: string)=>string) {
|
||||
const exifFieldMap = getExifFieldMap(t);
|
||||
const items = Object.entries(exifFieldMap)
|
||||
.filter(([key]) => exif[key] !== undefined)
|
||||
.map(([key, { label, format }]) => ({
|
||||
@@ -37,7 +41,7 @@ function renderExif(exif: Record<string, any>) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 24, color: 'var(--ant-color-text-tertiary, #999)' }}>
|
||||
<InfoCircleOutlined style={{ fontSize: 20, marginBottom: 8 }} />
|
||||
<div>无常见EXIF信息</div>
|
||||
<div>{t('No common EXIF info')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,10 +62,10 @@ function renderExif(exif: Record<string, any>) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number | string): string {
|
||||
function formatFileSize(size: number | string, t: (k: string)=>string): string {
|
||||
if (typeof size !== 'number') return String(size);
|
||||
|
||||
const units = ['字节', 'KB', 'MB', 'GB'];
|
||||
const units = [t('Bytes'), 'KB', 'MB', 'GB'];
|
||||
let index = 0;
|
||||
let fileSize = size;
|
||||
|
||||
@@ -75,13 +79,14 @@ function formatFileSize(size: number | string): string {
|
||||
|
||||
export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose }) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<InfoCircleOutlined style={{ color: token.colorPrimary }} />
|
||||
<span>文件属性</span>
|
||||
<span>{t('File Properties')}</span>
|
||||
{entry && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
|
||||
- {entry.name}
|
||||
@@ -100,7 +105,7 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16, color: token.colorTextSecondary }}>加载文件信息...</div>
|
||||
<div style={{ marginTop: 16, color: token.colorTextSecondary }}>{t('Loading file info...')}</div>
|
||||
</div>
|
||||
) : data ? (
|
||||
data.error ? (
|
||||
@@ -118,7 +123,7 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
title={
|
||||
<Space>
|
||||
{data.is_dir ? <FolderOutlined /> : <FileOutlined />}
|
||||
基本信息
|
||||
{t('Basic Info')}
|
||||
</Space>
|
||||
}
|
||||
style={{ borderRadius: 8, height: 'fit-content' }}
|
||||
@@ -129,36 +134,36 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
items={[
|
||||
{
|
||||
key: 'name',
|
||||
label: '名称',
|
||||
label: t('Name'),
|
||||
children: <Typography.Text strong>{data.name}</Typography.Text>
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: '类型',
|
||||
label: t('Type'),
|
||||
children: (
|
||||
<Badge
|
||||
status={data.is_dir ? 'processing' : 'default'}
|
||||
text={data.type || (data.is_dir ? '文件夹' : '文件')}
|
||||
text={data.type || (data.is_dir ? t('Folder') : t('File'))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
label: '大小',
|
||||
children: formatFileSize(data.size)
|
||||
label: t('Size'),
|
||||
children: formatFileSize(data.size, t)
|
||||
},
|
||||
{
|
||||
key: 'mtime',
|
||||
label: '修改时间',
|
||||
label: t('Modified Time'),
|
||||
children: data.mtime ? (
|
||||
typeof data.mtime === 'number'
|
||||
? new Date(data.mtime * 1000).toLocaleString('zh-CN')
|
||||
? new Date(data.mtime * 1000).toLocaleString()
|
||||
: data.mtime
|
||||
) : '-'
|
||||
},
|
||||
{
|
||||
key: 'path',
|
||||
label: '路径',
|
||||
label: t('Path'),
|
||||
children: (
|
||||
<Typography.Text style={{ display: 'block', marginTop: 4 }}>
|
||||
<a
|
||||
@@ -168,9 +173,9 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(data.path).then(() => {
|
||||
message.success('路径已复制到剪贴板');
|
||||
message.success(t('Path copied to clipboard'));
|
||||
}).catch(() => {
|
||||
message.error('复制失败');
|
||||
message.error(t('Copy failed'));
|
||||
});
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
@@ -179,10 +184,10 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
message[ok ? 'success' : 'error'](ok ? '路径已复制到剪贴板' : '复制失败');
|
||||
message[ok ? 'success' : 'error'](ok ? t('Path copied to clipboard') : t('Copy failed'));
|
||||
}
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
message.error(t('Copy failed'));
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
@@ -214,7 +219,7 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
<>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, color: token.colorTextSecondary }}>权限:</span>
|
||||
<span style={{ fontWeight: 500, color: token.colorTextSecondary }}>{t('Permissions')}:</span>
|
||||
<Typography.Text code>{data.mode.toString(8)}</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
@@ -230,12 +235,12 @@ export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose
|
||||
title={
|
||||
<Space>
|
||||
<CameraOutlined />
|
||||
EXIF信息
|
||||
{t('EXIF Info')}
|
||||
</Space>
|
||||
}
|
||||
style={{ borderRadius: 8, height: 'fit-content' }}
|
||||
>
|
||||
{renderExif(data.exif)}
|
||||
{renderExif(data.exif, t)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { VfsEntry } from '../../../api/client';
|
||||
import { getFileIcon } from './FileIcons';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
interface FileListViewProps {
|
||||
entries: VfsEntry[];
|
||||
@@ -33,6 +34,7 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const { resolvedMode } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const lightenColor = (hex: string, amount: number) => {
|
||||
const s = hex.replace('#', '');
|
||||
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
|
||||
@@ -48,7 +50,7 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '名称',
|
||||
title: t('Name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (_: any, r: VfsEntry) => (
|
||||
@@ -59,14 +61,14 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
getFileIcon(r.name, 16, resolvedMode)
|
||||
)}
|
||||
{r.name}
|
||||
{r.type === 'mount' && <Tooltip title="挂载点"><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
|
||||
{r.type === 'mount' && <Tooltip title={t('Mount Point')}><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ title: '大小', dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
|
||||
{ title: '修改时间', dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
|
||||
{ title: t('Size'), dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
|
||||
{ title: t('Modified Time'), dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
|
||||
{
|
||||
title: '操作',
|
||||
title: t('Actions'),
|
||||
key: 'actions',
|
||||
width: 110,
|
||||
render: (_: any, r: VfsEntry) => {
|
||||
@@ -76,19 +78,19 @@ export const FileListView: React.FC<FileListViewProps> = ({
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
(r.is_dir || apps.length > 0) ? { key: 'open', label: defaultApp ? `打开(${defaultApp.name})` : '打开', icon: <FolderOpenOutlined />, onClick: () => onOpen(r) } : null,
|
||||
(r.is_dir || apps.length > 0) ? { key: 'open', label: defaultApp ? `${t('Open')}(${defaultApp.name})` : t('Open'), icon: <FolderOpenOutlined />, onClick: () => onOpen(r) } : null,
|
||||
!r.is_dir && apps.length > 0 ? {
|
||||
key: 'openWith',
|
||||
label: '打开方式',
|
||||
label: t('Open With'),
|
||||
icon: <AppstoreOutlined />,
|
||||
children: apps.map(a => ({
|
||||
key: 'openWith-' + a.key,
|
||||
label: a.name + (a.key === defaultApp?.key ? ' (默认)' : ''),
|
||||
label: a.name + (a.key === defaultApp?.key ? ` (${t('Default')})` : ''),
|
||||
onClick: () => onOpenWith(r, a.key)
|
||||
}))
|
||||
} : null,
|
||||
{ key: 'rename', label: '重命名', icon: <EditOutlined />, disabled: r.type === 'mount', onClick: () => onRename(r) },
|
||||
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, disabled: r.type === 'mount', onClick: () => onDelete(r) }
|
||||
{ key: 'rename', label: t('Rename'), icon: <EditOutlined />, disabled: r.type === 'mount', onClick: () => onRename(r) },
|
||||
{ key: 'delete', label: t('Delete'), icon: <DeleteOutlined />, danger: true, disabled: r.type === 'mount', onClick: () => onDelete(r) }
|
||||
].filter(Boolean) as any[]
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Select } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { ViewMode } from '../types';
|
||||
|
||||
interface HeaderProps {
|
||||
@@ -35,6 +36,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
onSortChange,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [pathInputValue, setPathInputValue] = useState('');
|
||||
|
||||
@@ -73,7 +75,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ key: 'root', title: <span style={{ cursor: 'pointer' }} onClick={() => onNavigate('/')}>Home</span> },
|
||||
{ key: 'root', title: <span style={{ cursor: 'pointer' }} onClick={() => onNavigate('/')}>{t('Home')}</span> },
|
||||
...path.split('/').filter(Boolean).map((segment, index, arr) => {
|
||||
const segmentPath = '/' + arr.slice(0, index + 1).join('/');
|
||||
return {
|
||||
@@ -99,23 +101,23 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
<Flex align="center" justify="space-between" style={{ padding: '10px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, gap: 12 }}>
|
||||
<Flex align="center" gap={8} style={{ flexWrap: 'wrap', flex: 1, overflow: 'hidden' }}>
|
||||
<Button size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} disabled={path === '/'} />
|
||||
<Typography.Text strong>文件管理</Typography.Text>
|
||||
<Typography.Text strong>{t('File Manager')}</Typography.Text>
|
||||
<Divider type="vertical" />
|
||||
{renderBreadcrumb()}
|
||||
</Flex>
|
||||
<Space size={8} wrap>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>刷新</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>新建目录</Button>
|
||||
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}>上传</Button>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button>
|
||||
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}>{t('Upload')}</Button>
|
||||
<Select
|
||||
size="small"
|
||||
value={sortBy}
|
||||
onChange={(val) => onSortChange(val, sortOrder)}
|
||||
style={{ width: 80 }}
|
||||
options={[
|
||||
{ value: 'name', label: '名称' },
|
||||
{ value: 'size', label: '大小' },
|
||||
{ value: 'mtime', label: '修改时间' },
|
||||
{ value: 'name', label: t('Name') },
|
||||
{ value: 'size', label: t('Size') },
|
||||
{ value: 'mtime', label: t('Modified Time') },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
@@ -128,11 +130,11 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
value={viewMode}
|
||||
onChange={v => onSetViewMode(v as any)}
|
||||
options={[
|
||||
{ label: <Tooltip title="网格"><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
||||
{ label: <Tooltip title="列表"><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
||||
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
||||
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface CreateDirModalProps {
|
||||
open: boolean;
|
||||
@@ -9,6 +10,7 @@ interface CreateDirModalProps {
|
||||
|
||||
export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCancel }) => {
|
||||
const [name, setName] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -22,7 +24,7 @@ export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCa
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新建目录"
|
||||
title={t('New Folder')}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
@@ -30,7 +32,7 @@ export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCa
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
placeholder="目录名称"
|
||||
placeholder={t('Folder Name')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
@@ -38,4 +40,4 @@ export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCa
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Modal, Radio, message, Button, Typography, Input, Space } from 'antd';
|
||||
import { CopyOutlined, FileMarkdownOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import { vfsApi } from '../../../../api/client';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface DirectLinkModalProps {
|
||||
entry: VfsEntry | null;
|
||||
@@ -30,6 +31,7 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expiresIn, setExpiresIn] = useState(3600);
|
||||
const [link, setLink] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (open && entry) {
|
||||
@@ -51,7 +53,7 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
|
||||
}
|
||||
setLink(url);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '生成链接失败');
|
||||
message.error(e.message || t('Failed to generate link'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -59,14 +61,14 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success('已复制到剪贴板');
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
const handleCopyMarkdown = () => {
|
||||
if (!entry || !link) return;
|
||||
const markdownText = generateMarkdownLink(entry.name, link);
|
||||
navigator.clipboard.writeText(markdownText);
|
||||
message.success('Markdown 格式已复制到剪贴板');
|
||||
message.success(t('Markdown copied to clipboard'));
|
||||
};
|
||||
|
||||
const handleExpiresChange = (e: any) => {
|
||||
@@ -75,36 +77,36 @@ export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="获取直链"
|
||||
title={t('Get Direct Link')}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="back" onClick={onCancel}>
|
||||
关闭
|
||||
{t('Close')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Paragraph>
|
||||
为 <strong>{entry?.name}</strong> 生成一个直接访问链接。
|
||||
{t('Generate a direct link for {name}', { name: entry?.name || '' })}
|
||||
</Typography.Paragraph>
|
||||
<Radio.Group value={expiresIn} onChange={handleExpiresChange} style={{ marginBottom: 16 }}>
|
||||
<Radio.Button value={3600}>1 小时</Radio.Button>
|
||||
<Radio.Button value={86400}>1 天</Radio.Button>
|
||||
<Radio.Button value={604800}>7 天</Radio.Button>
|
||||
<Radio.Button value={0}>永久</Radio.Button>
|
||||
<Radio.Button value={3600}>{t('1 hour')}</Radio.Button>
|
||||
<Radio.Button value={86400}>{t('1 day')}</Radio.Button>
|
||||
<Radio.Button value={604800}>{t('7 days')}</Radio.Button>
|
||||
<Radio.Button value={0}>{t('Forever')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={link} disabled={loading} placeholder={loading ? "正在生成链接..." : "链接将显示在这里"} />
|
||||
<Input readOnly value={link} disabled={loading} placeholder={loading ? t('Generating link...') : t('Link will appear here')} />
|
||||
<Space.Compact>
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(link)} disabled={!link || loading}>
|
||||
复制
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
<Button icon={<FileMarkdownOutlined />} onClick={handleCopyMarkdown} disabled={!link || loading}>
|
||||
复制 Markdown
|
||||
{t('Copy Markdown')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form, Select, Input, Checkbox } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import type { ProcessorTypeMeta } from '../../../../api/processors';
|
||||
import { ProcessorConfigForm } from '../../../../components/ProcessorConfigForm';
|
||||
@@ -28,6 +29,7 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
onConfigChange, onSavingPathChange, onOverwriteChange
|
||||
} = props;
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedProcessorMeta = processorTypes.find(pt => pt.type === selectedProcessor);
|
||||
|
||||
@@ -51,7 +53,7 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`使用处理器处理文件${entry ? `: ${entry.name}` : ''}`}
|
||||
title={t('Process file with processor') + (entry ? `: ${entry.name}` : '')}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
@@ -59,11 +61,11 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFormValuesChange}>
|
||||
<Form.Item name="processor_type" label="处理器" required>
|
||||
<Form.Item name="processor_type" label={t('Processor')} required>
|
||||
<Select
|
||||
onChange={onSelectedProcessorChange}
|
||||
options={processorTypes.map(pt => ({ value: pt.type, label: pt.name }))}
|
||||
placeholder="请选择处理器"
|
||||
placeholder={t('Select a processor')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<ProcessorConfigForm
|
||||
@@ -75,15 +77,15 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
<>
|
||||
<Form.Item>
|
||||
<Checkbox checked={overwrite} onChange={e => onOverwriteChange(e.target.checked)}>
|
||||
覆盖原文件
|
||||
{t('Overwrite original file')}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
{!overwrite && (
|
||||
<Form.Item label="保存为新文件">
|
||||
<Form.Item label={t('Save as new file')}>
|
||||
<Input
|
||||
value={savingPath}
|
||||
onChange={e => onSavingPathChange(e.target.value)}
|
||||
placeholder="如 /newfile.jpg,不填则仅返回处理结果"
|
||||
placeholder={t('e.g. /newfile.jpg, leave blank to only return result')}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -92,4 +94,4 @@ export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
|
||||
interface RenameModalProps {
|
||||
@@ -10,6 +11,7 @@ interface RenameModalProps {
|
||||
|
||||
export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel }) => {
|
||||
const [name, setName] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (entry) {
|
||||
@@ -25,7 +27,7 @@ export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="重命名"
|
||||
title={t('Rename')}
|
||||
open={!!entry}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
@@ -33,7 +35,7 @@ export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
placeholder="新的名称"
|
||||
placeholder={t('New Name')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
@@ -41,4 +43,4 @@ export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CopyOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry, ShareInfoWithPassword } from '../../../../api/client';
|
||||
import { shareApi } from '../../../../api/share';
|
||||
import { useSystemStatus } from '../../../../contexts/SystemContext';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface ShareModalProps {
|
||||
entries: VfsEntry[];
|
||||
@@ -15,13 +16,14 @@ interface ShareModalProps {
|
||||
|
||||
export const ShareModal = memo(function ShareModal({ entries, path, open, onOk, onCancel }: ShareModalProps) {
|
||||
const systemStatus = useSystemStatus();
|
||||
const { t } = useI18n();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [accessType, setAccessType] = useState('public');
|
||||
const [createdShare, setCreatedShare] = useState<ShareInfoWithPassword | null>(null);
|
||||
|
||||
const defaultName = entries.length > 1
|
||||
? `分享 ${entries.length} 个项目`
|
||||
? t('Share {count} items', { count: entries.length.toString() })
|
||||
: (entries.length === 1 ? entries[0].name : '');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -54,10 +56,10 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
|
||||
password: values.password,
|
||||
expires_in_days: values.expiresInDays,
|
||||
});
|
||||
message.success('分享链接已创建');
|
||||
message.success(t('Share link created'));
|
||||
setCreatedShare(result);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '创建失败');
|
||||
message.error(e.message || t('Create failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -65,7 +67,7 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success('已复制到剪贴板');
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
const baseUrl = systemStatus?.app_domain || window.location.origin;
|
||||
@@ -73,21 +75,21 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
|
||||
|
||||
const renderForm = () => (
|
||||
<Form form={form} layout="vertical" initialValues={{ name: defaultName, accessType: 'public', expiresInDays: 7 }}>
|
||||
<Form.Item name="name" label="分享名称" rules={[{ required: true }]} >
|
||||
<Form.Item name="name" label={t('Share Name')} rules={[{ required: true }]} >
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="accessType" label="访问权限">
|
||||
<Form.Item name="accessType" label={t('Access')}>
|
||||
<Radio.Group onChange={(e) => setAccessType(e.target.value)}>
|
||||
<Radio value="public">公开</Radio>
|
||||
<Radio value="password">密码访问</Radio>
|
||||
<Radio value="public">{t('Public')}</Radio>
|
||||
<Radio value="password">{t('By Password')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{accessType === 'password' && (
|
||||
<Form.Item name="password" label="访问密码" rules={[{ required: true, message: '请输入密码' }]} >
|
||||
<Form.Item name="password" label={t('Please enter password')} rules={[{ required: true, message: t('Please enter password') }]} >
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="expiresInDays" label="有效期 (天)" help="设置为 0 或负数表示永久有效">
|
||||
<Form.Item name="expiresInDays" label={t('Expiration (days)')} help={t('Set 0 or negative for forever')}>
|
||||
<InputNumber min={-1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -95,44 +97,44 @@ export const ShareModal = memo(function ShareModal({ entries, path, open, onOk,
|
||||
|
||||
const renderSuccess = () => (
|
||||
<div>
|
||||
<Typography.Paragraph>分享链接已成功创建!</Typography.Paragraph>
|
||||
<Typography.Paragraph>{t('Share link created successfully!')}</Typography.Paragraph>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="分享链接">
|
||||
<Form.Item label={t('Share Link')}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={shareUrl} style={{ flex: 1 }} />
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(shareUrl)}>
|
||||
复制
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
{createdShare?.password && (
|
||||
<Form.Item label="访问密码">
|
||||
<Form.Item label={t('Password')}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={createdShare.password} style={{ flex: 1 }} />
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(createdShare.password!)}>
|
||||
复制
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
<Typography.Text type="secondary">
|
||||
有效期至: {createdShare?.expires_at ? new Date(createdShare.expires_at).toLocaleString() : '永久有效'}
|
||||
{t('Expires At')}: {createdShare?.expires_at ? new Date(createdShare.expires_at).toLocaleString() : t('Forever')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={createdShare ? "分享创建成功" : "创建分享"}
|
||||
title={createdShare ? t('Share created') : t('Create Share')}
|
||||
open={open}
|
||||
onOk={createdShare ? onOk : handleOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={loading}
|
||||
destroyOnHidden
|
||||
okText={createdShare ? "完成" : "创建"}
|
||||
okText={createdShare ? t('Done') : t('Create')}
|
||||
>
|
||||
{createdShare ? renderSuccess() : renderForm()}
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
|
||||
import { Modal, Button, List, Progress, Typography, message, Flex } from 'antd';
|
||||
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
import type { UploadFile } from '../../hooks/useUploader';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface UploadModalProps {
|
||||
visible: boolean;
|
||||
@@ -11,6 +12,7 @@ interface UploadModalProps {
|
||||
}
|
||||
|
||||
const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onStartUpload }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const allSuccess = files.every(f => f.status === 'success');
|
||||
|
||||
@@ -22,7 +24,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success('链接已复制到剪贴板');
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
const renderStatus = (file: UploadFile) => {
|
||||
@@ -33,7 +35,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CheckCircleFilled style={{ color: 'var(--ant-color-success, #52c41a)' }} />
|
||||
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}>上传成功</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}>{t('Upload succeeded')}</Typography.Text>
|
||||
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
|
||||
</Flex>
|
||||
);
|
||||
@@ -41,23 +43,23 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CloseCircleFilled style={{ color: 'var(--ant-color-error, #ff4d4f)' }} />
|
||||
<Typography.Text type="danger" title={file.error}>上传失败</Typography.Text>
|
||||
<Typography.Text type="danger" title={file.error}>{t('Upload failed')}</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
default:
|
||||
return <Typography.Text type="secondary">等待上传</Typography.Text>;
|
||||
return <Typography.Text type="secondary">{t('Waiting to upload')}</Typography.Text>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
title="上传文件"
|
||||
title={t('Upload File')}
|
||||
width={600}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose} disabled={!allSuccess && files.some(f => f.status === 'uploading')}>
|
||||
{allSuccess ? '关闭' : '完成'}
|
||||
{allSuccess ? t('Close') : t('Done')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Modal, Checkbox } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import type { AppDescriptor } from '../../../apps/registry';
|
||||
import type { AppWindow } from '../types';
|
||||
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../../../apps/registry';
|
||||
|
||||
export function useAppWindows(path: string) {
|
||||
const { t } = useI18n();
|
||||
const [appWindows, setAppWindows] = useState<AppWindow[]>([]);
|
||||
|
||||
const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor) => {
|
||||
@@ -40,7 +42,7 @@ export function useAppWindows(path: string) {
|
||||
const openFileWithDefaultApp = useCallback((entry: VfsEntry) => {
|
||||
const apps = getAppsForEntry(entry);
|
||||
if (!apps.length) {
|
||||
Modal.error({ title: '无法打开该文件:没有可用的应用' });
|
||||
Modal.error({ title: t('Cannot open file: no available app') });
|
||||
return;
|
||||
}
|
||||
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
|
||||
@@ -50,17 +52,17 @@ export function useAppWindows(path: string) {
|
||||
const confirmOpenWithApp = useCallback((entry: VfsEntry, appKey: string) => {
|
||||
const app = getAppByKey(appKey);
|
||||
if (!app) {
|
||||
Modal.error({ title: '错误', content: `应用 "${appKey}" 不存在。` });
|
||||
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: `使用 ${app.name} 打开`,
|
||||
title: t('Open with {app}', { app: app.name }),
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>文件: {entry.name}</div>
|
||||
<Checkbox onChange={e => setDefault = e.target.checked}>设为该类型(.{ext})默认应用</Checkbox>
|
||||
<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: () => {
|
||||
@@ -92,4 +94,4 @@ export function useAppWindows(path: string) {
|
||||
bringToFront,
|
||||
updateWindow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { message, Modal } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import { vfsApi, type VfsEntry } from '../../../api/client';
|
||||
|
||||
interface FileActionsParams {
|
||||
@@ -11,9 +12,10 @@ interface FileActionsParams {
|
||||
}
|
||||
|
||||
export function useFileActions({ path, refresh, clearSelection, onShare, onGetDirectLink }: FileActionsParams) {
|
||||
const { t } = useI18n();
|
||||
const doCreateDir = useCallback(async (name: string) => {
|
||||
if (!name.trim()) {
|
||||
message.warning('请输入名称');
|
||||
message.warning(t('Please input name'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -26,8 +28,8 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
|
||||
const doDelete = useCallback(async (entries: VfsEntry[]) => {
|
||||
Modal.confirm({
|
||||
title: `确认删除 ${entries.length > 1 ? `${entries.length} 项` : entries[0].name} ?`,
|
||||
content: entries.length > 1 ? <div style={{ maxHeight: 180, overflow: 'auto' }}>{entries.map(it => <div key={it.name}>{it.name}{it.type === 'mount' && ' (挂载点)'}</div>)}</div> : null,
|
||||
title: t('Confirm delete {name}?', { name: entries.length > 1 ? `${entries.length} ${t('items')}` : entries[0].name }),
|
||||
content: entries.length > 1 ? <div style={{ maxHeight: 180, overflow: 'auto' }}>{entries.map(it => <div key={it.name}>{it.name}{it.type === 'mount' && ` (${t('Mount Point')})`}</div>)}</div> : null,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await Promise.all(entries.map(it => vfsApi.deletePath((path === '/' ? '' : path) + '/' + it.name)));
|
||||
@@ -57,7 +59,7 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
|
||||
const doDownload = useCallback(async (entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
message.warning('暂不支持下载目录');
|
||||
message.warning(t('Downloading folders is not supported'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -72,13 +74,13 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '下载失败');
|
||||
message.error(e.message || t('Download failed'));
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
const doShare = useCallback((entries: VfsEntry[]) => {
|
||||
if (entries.length === 0) {
|
||||
message.warning('请选择要分享的文件或目录');
|
||||
message.warning(t('Please select files or folders to share'));
|
||||
return;
|
||||
}
|
||||
onShare(entries);
|
||||
@@ -86,7 +88,7 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
|
||||
const doGetDirectLink = useCallback((entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
message.warning('不支持获取目录的直链');
|
||||
message.warning(t('Direct links for folders are not supported'));
|
||||
return;
|
||||
}
|
||||
onGetDirectLink(entry);
|
||||
@@ -100,4 +102,4 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
||||
doShare,
|
||||
doGetDirectLink,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function useFileExplorer(navKey: string) {
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number, range: [number, number]) => `共 ${total} 项,第 ${range[0]}-${range[1]} 项`,
|
||||
showTotal: (total: number, range: [number, number]) => `${total} ${'items'} ${range[0]}-${range[1]}`,
|
||||
pageSizeOptions: ['20', '50', '100', '200']
|
||||
});
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
@@ -43,7 +43,7 @@ export function useFileExplorer(navKey: string) {
|
||||
}));
|
||||
setProcessorTypes(processors);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
message.error(e.message || 'Load failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -90,4 +90,4 @@ export function useFileExplorer(navKey: string) {
|
||||
refresh,
|
||||
handleSortChange
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../../../api/processors';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
@@ -10,6 +11,7 @@ interface ProcessorParams {
|
||||
}
|
||||
|
||||
export function useProcessor({ path, processorTypes, refresh }: ProcessorParams) {
|
||||
const { t } = useI18n();
|
||||
const [modal, setModal] = useState<{ entry: VfsEntry | null; visible: boolean }>({ entry: null, visible: false });
|
||||
const [selectedProcessor, setSelectedProcessor] = useState<string>('');
|
||||
const [config, setConfig] = useState<any>({});
|
||||
@@ -48,11 +50,11 @@ export function useProcessor({ path, processorTypes, refresh }: ProcessorParams)
|
||||
};
|
||||
|
||||
await processorsApi.process(params);
|
||||
message.success('处理完成');
|
||||
message.success(t('Processing finished'));
|
||||
setModal({ entry: null, visible: false });
|
||||
if (overwrite || savingPath) refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '处理失败');
|
||||
message.error(e.message || t('Processing failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -100,4 +102,4 @@ export function useProcessor({ path, processorTypes, refresh }: ProcessorParams)
|
||||
setProcessorSavingPath: setSavingPath,
|
||||
setProcessorOverwrite: setOverwrite,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'success', progress: 100, permanentLink } : f));
|
||||
} catch (e: any) {
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'error', error: e.message } : f));
|
||||
message.error(`上传失败: ${uploadFile.file.name} - ${e.message}`);
|
||||
message.error(`Upload failed: ${uploadFile.file.name} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,4 +101,4 @@ export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
handleFileDrop,
|
||||
startUpload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { UserOutlined, LockOutlined, GithubOutlined, SendOutlined, WechatOutline
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useSystemStatus } from '../contexts/SystemContext';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -15,12 +17,13 @@ export default function LoginPage() {
|
||||
const [err, setErr] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const u = username.trim();
|
||||
const p = password;
|
||||
if (!u || !p) {
|
||||
setErr('请输入用户名与密码');
|
||||
setErr(t('Please enter username and password'));
|
||||
return;
|
||||
}
|
||||
console.debug('[LoginPage] submit ->', { username: u, passwordLength: p.length });
|
||||
@@ -31,7 +34,7 @@ export default function LoginPage() {
|
||||
navigate('/');
|
||||
} catch (e: any) {
|
||||
console.error('[LoginPage] login failed:', e);
|
||||
setErr(e.message || '登录失败');
|
||||
setErr(e.message || t('Login failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -46,6 +49,9 @@ export default function LoginPage() {
|
||||
justifyContent: 'center',
|
||||
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 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '80%',
|
||||
@@ -71,9 +77,9 @@ export default function LoginPage() {
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: 8 }}>
|
||||
<img src={status?.logo} alt="Foxel Logo" style={{ width: 32, marginRight: 16 }} />
|
||||
<Title level={2} style={{ margin: 0, color: 'var(--ant-color-text, #111)' }}>欢迎回来</Title>
|
||||
<Title level={2} style={{ margin: 0, color: 'var(--ant-color-text, #111)' }}>{t('Welcome Back')}</Title>
|
||||
</div>
|
||||
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>登录到您的 Foxel 账户</Text>
|
||||
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>{t('Sign in to your Foxel account')}</Text>
|
||||
</div>
|
||||
|
||||
{err && <Alert message={err} type="error" showIcon style={{ marginBottom: 24 }} />}
|
||||
@@ -82,7 +88,7 @@ export default function LoginPage() {
|
||||
<Form.Item>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名/邮箱"
|
||||
placeholder={t('Username / Email')}
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
@@ -92,7 +98,7 @@ export default function LoginPage() {
|
||||
<Form.Item>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
placeholder={t('Password')}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
@@ -106,7 +112,7 @@ export default function LoginPage() {
|
||||
loading={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
登录
|
||||
{t('Sign In')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -125,7 +131,7 @@ export default function LoginPage() {
|
||||
padding: '48px'
|
||||
}}>
|
||||
<div style={{ maxWidth: '500px' }}>
|
||||
<Title level={3}>您的下一代文件管理系统</Title>
|
||||
<Title level={3}>{t('Your next-generation file manager')}</Title>
|
||||
<Text type="secondary" style={{ fontSize: '16px', lineHeight: '1.8' }}>
|
||||
Foxel 旨在提供一个安全、高效且智能的文件管理解决方案,帮助您轻松组织、访问和共享您的数字资产。
|
||||
</Text>
|
||||
@@ -134,31 +140,31 @@ export default function LoginPage() {
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
|
||||
<Space>
|
||||
<CloudSyncOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>跨平台同步,随时随地访问</Text>
|
||||
<Text>{t('Cross-platform sync, access anywhere')}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
|
||||
<Space>
|
||||
<SearchOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>AI 驱动的智能搜索,快速定位文件</Text>
|
||||
<Text>{t('AI-powered search for quick find')}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
|
||||
<Space>
|
||||
<ShareAltOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>灵活的分享与协作,提升团队效率</Text>
|
||||
<Text>{t('Flexible sharing and collaboration')}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
|
||||
<Space>
|
||||
<ApartmentOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>强大的自动化工作流,简化繁琐任务</Text>
|
||||
<Text>{t('Powerful automation to simplify tasks')}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ marginTop: '48px', textAlign: 'center' }}>
|
||||
<Text type="secondary">加入我们的社区:</Text>
|
||||
<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 />}>微信</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, message, Tag, Input, Select, Button, Space, Modal, DatePicker } from 'antd';
|
||||
import PageCard from '../components/PageCard';
|
||||
import { logsApi, type LogItem, type PaginatedLogs } from '../api/logs';
|
||||
import { useI18n } from '../i18n';
|
||||
import { format, formatISO } from 'date-fns';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
@@ -20,6 +21,7 @@ const LogsPage = memo(function LogsPage() {
|
||||
end_time: '',
|
||||
});
|
||||
const [selectedLog, setSelectedLog] = useState<LogItem | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -31,7 +33,7 @@ const LogsPage = memo(function LogsPage() {
|
||||
const res = await logsApi.list(params);
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -43,18 +45,18 @@ const LogsPage = memo(function LogsPage() {
|
||||
|
||||
const handleClearLogs = () => {
|
||||
Modal.confirm({
|
||||
title: '确认清理日志?',
|
||||
content: '该操作将删除选定时间范围内的所有日志,且不可恢复。',
|
||||
title: t('Confirm clear logs?'),
|
||||
content: t('This will delete logs in selected range irreversibly.'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const params = { start_time: filters.start_time, end_time: filters.end_time };
|
||||
if (!params.start_time) delete (params as any).start_time;
|
||||
if (!params.end_time) delete (params as any).end_time;
|
||||
const res = await logsApi.clear(params);
|
||||
message.success(`成功清理 ${res.deleted_count} 条日志`);
|
||||
message.success(t('Cleared {count} logs', { count: String(res.deleted_count) }));
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '清理失败');
|
||||
message.error(e.message || t('Clear failed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -62,13 +64,13 @@ const LogsPage = memo(function LogsPage() {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
title: t('Time'),
|
||||
dataIndex: 'timestamp',
|
||||
width: 180,
|
||||
render: (ts: string) => format(new Date(ts), 'yyyy-MM-dd HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
title: t('Level'),
|
||||
dataIndex: 'level',
|
||||
width: 100,
|
||||
render: (level: string) => {
|
||||
@@ -76,20 +78,20 @@ const LogsPage = memo(function LogsPage() {
|
||||
return <Tag color={color}>{level}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '来源', dataIndex: 'source', width: 180 },
|
||||
{ title: '消息', dataIndex: 'message', ellipsis: true },
|
||||
{ title: t('Source'), dataIndex: 'source', width: 180 },
|
||||
{ title: t('Message'), dataIndex: 'message', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
title: t('Actions'),
|
||||
width: 100,
|
||||
render: (_: any, rec: LogItem) => (
|
||||
<Button size="small" onClick={() => setSelectedLog(rec)}>详情</Button>
|
||||
<Button size="small" onClick={() => setSelectedLog(rec)}>{t('Details')}</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
title="系统日志"
|
||||
title={t('System Logs')}
|
||||
extra={
|
||||
<Space>
|
||||
<RangePicker
|
||||
@@ -105,7 +107,7 @@ const LogsPage = memo(function LogsPage() {
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
placeholder="级别"
|
||||
placeholder={t('Level')}
|
||||
allowClear
|
||||
value={filters.level || undefined}
|
||||
onChange={level => setFilters(f => ({ ...f, level: level || '', page: 1 }))}
|
||||
@@ -113,12 +115,12 @@ const LogsPage = memo(function LogsPage() {
|
||||
/>
|
||||
<Input.Search
|
||||
style={{ width: 240 }}
|
||||
placeholder="搜索来源"
|
||||
placeholder={t('Search source')}
|
||||
onSearch={source => setFilters(f => ({ ...f, source, page: 1 }))}
|
||||
allowClear
|
||||
/>
|
||||
<Button onClick={fetchList} loading={loading}>刷新</Button>
|
||||
<Button danger onClick={handleClearLogs}>清理</Button>
|
||||
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button danger onClick={handleClearLogs}>{t('Clear')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -136,7 +138,7 @@ const LogsPage = memo(function LogsPage() {
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="日志详情"
|
||||
title={t('Log Details')}
|
||||
open={!!selectedLog}
|
||||
onCancel={() => setSelectedLog(null)}
|
||||
footer={null}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Empty } from 'antd';
|
||||
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export default function OfflineDownloadPage() {
|
||||
return <Empty description="暂无离线下载任务" />;
|
||||
const { t } = useI18n();
|
||||
return <Empty description={t('No offline download tasks')} />;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GithubOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||
import { loadPluginFromUrl, ensureManifest } from '../plugins/runtime';
|
||||
import { reloadPluginApps } from '../apps/registry';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
const PluginsPage = memo(function PluginsPage() {
|
||||
const [data, setData] = useState<PluginItem[]>([]);
|
||||
@@ -12,6 +13,7 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
const [q, setQ] = useState('');
|
||||
const [form] = Form.useForm<{ url: string }>();
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
|
||||
const reload = async () => {
|
||||
try { setLoading(true); setData(await pluginsApi.list()); } finally { setLoading(false); }
|
||||
@@ -31,7 +33,7 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
form.resetFields();
|
||||
await reload();
|
||||
await reloadPluginApps();
|
||||
message.success('安装成功');
|
||||
message.success(t('Installed successfully'));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
@@ -49,7 +51,7 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
|
||||
const renderCard = (p: PluginItem) => {
|
||||
const icon = p.icon || '/plugins/demo-text-viewer.svg';
|
||||
const name = p.name || `插件 ${p.id}`;
|
||||
const name = p.name || `${t('Plugin')} ${p.id}`;
|
||||
const exts = (p.supported_exts || []).slice(0, 6);
|
||||
const more = (p.supported_exts || []).length - exts.length;
|
||||
const title = (
|
||||
@@ -68,10 +70,10 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
styles={{ body: { padding: 12 } } as any}
|
||||
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
|
||||
actions={[
|
||||
<a key="open" href={p.url} target="_blank" rel="noreferrer">打开链接</a>,
|
||||
<Button key="copy" type="link" size="small" onClick={async () => { try { await navigator.clipboard.writeText(p.url); message.success('已复制链接'); } catch {} }}>复制链接</Button>,
|
||||
<Popconfirm key="del" title="确认删除该插件?" onConfirm={async () => { await pluginsApi.remove(p.id); await reload(); await reloadPluginApps(); }}>
|
||||
<Button type="link" danger size="small">删除</Button>
|
||||
<a key="open" href={p.url} target="_blank" rel="noreferrer">{t('Open Link')}</a>,
|
||||
<Button key="copy" type="link" size="small" onClick={async () => { try { await navigator.clipboard.writeText(p.url); message.success(t('Link copied')); } catch {} }}>{t('Copy Link')}</Button>,
|
||||
<Popconfirm key="del" title={t('Confirm delete this plugin?')} onConfirm={async () => { await pluginsApi.remove(p.id); await reload(); await reloadPluginApps(); }}>
|
||||
<Button type="link" danger size="small">{t('Delete')}</Button>
|
||||
</Popconfirm>
|
||||
]}
|
||||
>
|
||||
@@ -87,7 +89,7 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
{(p.author || p.github || p.website) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
|
||||
{p.author && <span>作者: {p.author}</span>}
|
||||
{p.author && <span>{t('Author')}: {p.author}</span>}
|
||||
<span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
{p.github && (
|
||||
<a href={p.github || undefined} target="_blank" rel="noreferrer" title="GitHub">
|
||||
@@ -95,7 +97,7 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
</a>
|
||||
)}
|
||||
{p.website && (
|
||||
<a href={p.website || undefined} target="_blank" rel="noreferrer" title="官网">
|
||||
<a href={p.website || undefined} target="_blank" rel="noreferrer" title={t('Website')}>
|
||||
<LinkOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
|
||||
</a>
|
||||
)}
|
||||
@@ -111,10 +113,10 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<Button type="primary" onClick={() => setAdding(true)}>安装应用</Button>
|
||||
<Button onClick={reload} loading={loading}>刷新</Button>
|
||||
<Button type="primary" onClick={() => setAdding(true)}>{t('Install App')}</Button>
|
||||
<Button onClick={reload} loading={loading}>{t('Refresh')}</Button>
|
||||
<Input
|
||||
placeholder="搜索 名称/作者/链接/扩展名"
|
||||
placeholder={t('Search name/author/url/extension')}
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
allowClear
|
||||
@@ -131,21 +133,21 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<Empty description="暂无插件" />
|
||||
<Empty description={t('No plugins')} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
|
||||
{filtered.map(renderCard)}
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
title="安装应用"
|
||||
title={t('Install App')}
|
||||
open={adding}
|
||||
onCancel={() => setAdding(false)}
|
||||
onOk={handleAdd}
|
||||
okText="安装"
|
||||
okText={t('Install')}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="url" label="应用链接" rules={[{ required: true }, { type: 'url', message: '请输入合法的 URL' }]}>
|
||||
<Form.Item name="url" label={t('App URL')} rules={[{ required: true }, { type: 'url', message: t('Please input a valid URL') }]}>
|
||||
<Input placeholder="https://example.com/plugin.js" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icon
|
||||
import { shareApi, type ShareInfo } from '../../api/share';
|
||||
import { type VfsEntry } from '../../api/vfs';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -19,6 +20,7 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [error, setError] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const loadData = useCallback(async (p: string) => {
|
||||
setLoading(true);
|
||||
@@ -28,7 +30,7 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
|
||||
setEntries(listing.entries || []);
|
||||
setCurrentPath(p);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载分享失败');
|
||||
setError(e.message || t('Share load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -53,7 +55,7 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
|
||||
|
||||
const renderBreadcrumb = () => {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
const items = [{ title: '全部文件', path: '/' }];
|
||||
const items = [{ title: t('Root'), path: '/' }];
|
||||
parts.forEach((part, i) => {
|
||||
const path = '/' + parts.slice(0, i + 1).join('/');
|
||||
items.push({ title: part, path });
|
||||
@@ -82,8 +84,13 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
|
||||
<Card>
|
||||
<Title level={4}>{shareInfo?.name}</Title>
|
||||
<Text type="secondary">
|
||||
创建于 {shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
|
||||
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
|
||||
{t('Created on {date}', { date: format(parseISO(shareInfo.created_at), 'yyyy-MM-dd') })}
|
||||
{shareInfo?.expires_at ? (
|
||||
<>
|
||||
{' '}
|
||||
{t('Expires on {date}', { date: format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd') })}
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
<div style={{ margin: '16px 0' }}>
|
||||
{renderBreadcrumb()}
|
||||
@@ -108,4 +115,4 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type VfsEntry } from '../../api/vfs';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { VideoViewer } from './VideoViewer';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -25,6 +26,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
const loadFileContent = async () => {
|
||||
@@ -34,12 +36,12 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
const url = shareApi.downloadUrl(token, path, password);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('无法加载文件');
|
||||
throw new Error('Unable to load file');
|
||||
}
|
||||
const text = await response.text();
|
||||
setContent(text);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载文件失败');
|
||||
setError(e.message || 'Failed to load file');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -74,18 +76,18 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
return <ReactMarkdown>{content}</ReactMarkdown>;
|
||||
}
|
||||
|
||||
return (
|
||||
return (
|
||||
<Empty
|
||||
description={
|
||||
<div>
|
||||
<p>暂不支持在线预览此类型文件</p>
|
||||
<p>{t('Preview not supported for this file type')}</p>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
href={downloadUrl}
|
||||
download
|
||||
>
|
||||
下载文件
|
||||
{t('Download File')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -98,8 +100,13 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
<Card>
|
||||
<Title level={4}>{entry.name}</Title>
|
||||
<Text type="secondary">
|
||||
创建于 {shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
|
||||
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
|
||||
{t('Created on {date}', { date: format(parseISO(shareInfo.created_at), 'yyyy-MM-dd') })}
|
||||
{shareInfo?.expires_at ? (
|
||||
<>
|
||||
{' '}
|
||||
{t('Expires on {date}', { date: format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd') })}
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
@@ -107,7 +114,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
{t('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
style={{ marginBottom: 16 }}
|
||||
@@ -115,7 +122,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
href={shareApi.downloadUrl(token, path, password)}
|
||||
download
|
||||
>
|
||||
下载
|
||||
{t('Download')}
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
@@ -124,4 +131,4 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { shareApi, type ShareInfo } from '../../api/share';
|
||||
import { type VfsEntry } from '../../api/vfs';
|
||||
import { DirectoryViewer } from './DirectoryViewer';
|
||||
import { FileViewer } from './FileViewer';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
const PublicSharePage = memo(function PublicSharePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
@@ -14,6 +15,7 @@ const PublicSharePage = memo(function PublicSharePage() {
|
||||
const [error, setError] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [verified, setVerified] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const loadData = useCallback(async (pwd?: string) => {
|
||||
if (!token) return;
|
||||
@@ -44,7 +46,7 @@ const PublicSharePage = memo(function PublicSharePage() {
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载分享失败');
|
||||
setError(e.message || t('Share load failed'));
|
||||
if (e.message === '需要密码') {
|
||||
setVerified(false);
|
||||
}
|
||||
@@ -66,7 +68,7 @@ const PublicSharePage = memo(function PublicSharePage() {
|
||||
setError('');
|
||||
loadData(values.password_input);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '密码错误');
|
||||
message.error(e.message || t('Wrong password'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,14 +83,14 @@ const PublicSharePage = memo(function PublicSharePage() {
|
||||
if (shareInfo?.access_type === 'password' && !verified) {
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 400, margin: '100px auto' }}>
|
||||
<Card title="需要密码">
|
||||
<Card title={t('Password Required')}>
|
||||
<Form onFinish={handlePasswordSubmit}>
|
||||
<Form.Item name="password_input" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password placeholder="请输入密码" />
|
||||
<Form.Item name="password_input" rules={[{ required: true, message: t('Please enter password') }]}>
|
||||
<Input.Password placeholder={t('Please enter password')} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
确认
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -98,7 +100,7 @@ const PublicSharePage = memo(function PublicSharePage() {
|
||||
}
|
||||
|
||||
if (!shareInfo) {
|
||||
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description="无法加载分享信息" /></div>;
|
||||
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={t('Unable to load share info')} /></div>;
|
||||
}
|
||||
|
||||
const handleFileClick = (entry: VfsEntry, path: string) => {
|
||||
@@ -124,4 +126,4 @@ const PublicSharePage = memo(function PublicSharePage() {
|
||||
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} onFileClick={handleFileClick} />;
|
||||
});
|
||||
|
||||
export default PublicSharePage;
|
||||
export default PublicSharePage;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Form, Input, Button, Card, message, Steps, Select, Space, Typography }
|
||||
import { UserOutlined, LockOutlined, HddOutlined } from '@ant-design/icons';
|
||||
import { adaptersApi } from '../api/adapters';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
@@ -12,12 +14,13 @@ const SetupPage = () => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [form] = Form.useForm();
|
||||
const { login, register } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const onFinish = async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(values.username, values.password, values.email, values.full_name);
|
||||
await login(values.username, values.password);
|
||||
message.success('初始化成功!正在为您登录,请不要刷新。');
|
||||
message.success(t('Initialization succeeded! Logging you in...'));
|
||||
setTimeout(async () => {
|
||||
await adaptersApi.create({
|
||||
name: values.adapter_name,
|
||||
@@ -33,7 +36,7 @@ const SetupPage = () => {
|
||||
}, 2000);
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
message.error(error.response?.data?.msg || '初始化失败,请稍后重试');
|
||||
message.error(error.response?.data?.msg || t('Initialization failed, please try later'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -57,13 +60,13 @@ const SetupPage = () => {
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '数据库设置',
|
||||
title: t('Database Setup'),
|
||||
content: (
|
||||
<>
|
||||
<Title level={4}>选择数据库驱动</Title>
|
||||
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>选择用于存储系统数据的数据库和向量数据库。</Text>
|
||||
<Title level={4}>{t('Choose database driver')}</Title>
|
||||
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>{t('Select database and vector database for system data')}</Text>
|
||||
<Form.Item
|
||||
label="数据库驱动"
|
||||
label={t('Database Driver')}
|
||||
name="db_driver"
|
||||
initialValue="sqlite"
|
||||
rules={[{ required: true }]}
|
||||
@@ -71,7 +74,7 @@ const SetupPage = () => {
|
||||
<Select size="large" prefix={<HddOutlined />} disabled options={[{ label: 'SQLite', value: 'sqlite' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="向量数据库驱动"
|
||||
label={t('Vector DB Driver')}
|
||||
name="vector_db_driver"
|
||||
initialValue="milvus"
|
||||
rules={[{ required: true }]}
|
||||
@@ -82,96 +85,96 @@ const SetupPage = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '初始化挂载',
|
||||
title: t('Initialize Mount'),
|
||||
content: (
|
||||
<>
|
||||
<Title level={4}>配置初始存储</Title>
|
||||
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>为您的文件创建第一个存储挂载点。</Text>
|
||||
<Title level={4}>{t('Configure initial storage')}</Title>
|
||||
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>{t('Create the first storage mount for your files')}</Text>
|
||||
<Form.Item
|
||||
label="挂载名称"
|
||||
label={t('Mount Name')}
|
||||
name="adapter_name"
|
||||
initialValue="本地存储"
|
||||
rules={[{ required: true, message: '请输入挂载名称!' }]}
|
||||
initialValue={t('Local Storage')}
|
||||
rules={[{ required: true, message: t('Please input mount name!') }]}
|
||||
>
|
||||
<Input size="large" prefix={<HddOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="存储类型"
|
||||
label={t('Storage Type')}
|
||||
name="adapter_type"
|
||||
initialValue="local"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select size="large" disabled options={[{ label: '本地存储', value: 'local' }]} />
|
||||
<Select size="large" disabled options={[{ label: t('Local Storage'), value: 'local' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="挂载路径"
|
||||
label={t('Mount Path')}
|
||||
name="path"
|
||||
initialValue="/local"
|
||||
rules={[{ required: true, message: '请输入挂载路径!' }]}
|
||||
rules={[{ required: true, message: t('Please input mount path!') }]}
|
||||
>
|
||||
<Input size="large" prefix={<HddOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="根目录"
|
||||
label={t('Root Directory')}
|
||||
name="root_dir"
|
||||
initialValue="data/mount"
|
||||
rules={[{ required: true, message: '请输入根目录!' }]}
|
||||
rules={[{ required: true, message: t('Please input root directory!') }]}
|
||||
>
|
||||
<Input size="large" placeholder="例如: data/ 或 /var/foxel/data" />
|
||||
<Input size="large" placeholder={t('e.g., data/ or /var/foxel/data')} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '创建管理员',
|
||||
title: t('Create Admin'),
|
||||
content: (
|
||||
<>
|
||||
<Title level={4}>创建管理员账户</Title>
|
||||
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>这是系统的第一个账户,将拥有最高权限。</Text>
|
||||
<Title level={4}>{t('Create admin account')}</Title>
|
||||
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>{t('This is the first account with full permissions')}</Text>
|
||||
<Form.Item
|
||||
label="用户名"
|
||||
label={t('Username')}
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名!' }]}
|
||||
rules={[{ required: true, message: t('Please input username!') }]}
|
||||
>
|
||||
<Input size="large" prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="昵称"
|
||||
label={t('Full Name')}
|
||||
name="full_name"
|
||||
>
|
||||
<Input size="large" prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
label={t('Email')}
|
||||
name="email"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址!' }]}
|
||||
rules={[{ type: 'email', message: t('Please input a valid email!') }]}
|
||||
>
|
||||
<Input size="large" prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="密码"
|
||||
label={t('Password')}
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码!' }]}
|
||||
rules={[{ required: true, message: t('Please enter password') }]}
|
||||
>
|
||||
<Input.Password size="large" prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="确认密码"
|
||||
label={t('Confirm Password')}
|
||||
name="confirm"
|
||||
dependencies={['password']}
|
||||
hasFeedback
|
||||
rules={[
|
||||
{ required: true, message: '请确认您的密码!' },
|
||||
{ required: true, message: t('Please confirm your password!') },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致!'));
|
||||
return Promise.reject(new Error(t('Passwords do not match!')));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
@@ -192,10 +195,13 @@ const SetupPage = () => {
|
||||
justifyContent: 'center',
|
||||
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 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card style={{ width: 'clamp(400px, 40vw, 600px)', padding: '24px 16px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<img src="/logo.svg" alt="Foxel Logo" style={{ width: 48, marginBottom: 16 }} />
|
||||
<Title level={2}>系统初始化</Title>
|
||||
<Title level={2}>{t('System Initialization')}</Title>
|
||||
</div>
|
||||
<Steps current={currentStep} style={{ marginBottom: 32 }}>
|
||||
{steps.map(item => (
|
||||
@@ -215,17 +221,17 @@ const SetupPage = () => {
|
||||
<Space>
|
||||
{currentStep > 0 && (
|
||||
<Button style={{ margin: '0 8px' }} onClick={() => prev()}>
|
||||
上一步
|
||||
{t('Previous')}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < steps.length - 1 && (
|
||||
<Button type="primary" onClick={() => next()}>
|
||||
下一步
|
||||
{t('Next')}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === steps.length - 1 && (
|
||||
<Button type="primary" htmlType="submit" loading={loading} onClick={() => form.submit()}>
|
||||
完成初始化
|
||||
{t('Finish Initialization')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
@@ -5,9 +5,11 @@ import { shareApi, type ShareInfo } from '../api/share';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { LinkOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { useSystemStatus } from '../contexts/SystemContext';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
const SharePage = memo(function SharePage() {
|
||||
const systemStatus = useSystemStatus();
|
||||
const { t } = useI18n();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<ShareInfo[]>([]);
|
||||
|
||||
@@ -17,7 +19,7 @@ const SharePage = memo(function SharePage() {
|
||||
const list = await shareApi.list();
|
||||
setData(list);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -29,22 +31,22 @@ const SharePage = memo(function SharePage() {
|
||||
const baseUrl = systemStatus?.app_domain || window.location.origin;
|
||||
const shareUrl = new URL(`/share/${rec.token}`, baseUrl).href;
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
message.success('链接已复制');
|
||||
message.success(t('Copied link'));
|
||||
};
|
||||
|
||||
const doDelete = async (rec: ShareInfo) => {
|
||||
try {
|
||||
await shareApi.remove(rec.id);
|
||||
message.success('分享已取消');
|
||||
message.success(t('Share canceled'));
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '取消失败');
|
||||
message.error(e.message || t('Cancel failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '分享名称',
|
||||
title: t('Share Name'),
|
||||
dataIndex: 'name',
|
||||
render: (name: string, rec: ShareInfo) => (
|
||||
<a href={`/share/${rec.token}`} target="_blank" rel="noopener noreferrer">
|
||||
@@ -54,7 +56,7 @@ const SharePage = memo(function SharePage() {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '分享内容',
|
||||
title: t('Share Content'),
|
||||
dataIndex: 'paths',
|
||||
ellipsis: true,
|
||||
render: (paths: string[]) => (
|
||||
@@ -64,31 +66,31 @@ const SharePage = memo(function SharePage() {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
title: t('Created At'),
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (v: string) => format(parseISO(v), 'yyyy-MM-dd HH:mm')
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
title: t('Expires At'),
|
||||
dataIndex: 'expires_at',
|
||||
width: 180,
|
||||
render: (v?: string) => v ? <Tag color="orange">{format(parseISO(v), 'yyyy-MM-dd HH:mm')}</Tag> : <Tag>永久有效</Tag>
|
||||
render: (v?: string) => v ? <Tag color="orange">{format(parseISO(v), 'yyyy-MM-dd HH:mm')}</Tag> : <Tag>{t('Forever')}</Tag>
|
||||
},
|
||||
{
|
||||
title: '访问',
|
||||
title: t('Access'),
|
||||
dataIndex: 'access_type',
|
||||
width: 100,
|
||||
render: (v: 'public' | 'password') => v === 'password' ? <Tag color="red">密码</Tag> : <Tag color="green">公开</Tag>
|
||||
render: (v: 'public' | 'password') => v === 'password' ? <Tag color="red">{t('By Password')}</Tag> : <Tag color="green">{t('Public')}</Tag>
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: '',
|
||||
width: 160,
|
||||
render: (_: any, rec: ShareInfo) => (
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => doCopy(rec)}>复制</Button>
|
||||
<Popconfirm title="确认取消分享?" onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>取消</Button>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => doCopy(rec)}>{t('Copy')}</Button>
|
||||
<Popconfirm title={t('Are you sure to cancel share?')} onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>{t('Cancel')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
@@ -97,8 +99,8 @@ const SharePage = memo(function SharePage() {
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
title="我的分享"
|
||||
extra={<Button onClick={fetchList} loading={loading}>刷新</Button>}
|
||||
title={t('My Shares')}
|
||||
extra={<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
|
||||
@@ -3,19 +3,21 @@ import { Button, Typography, Upload, message, Modal } from 'antd';
|
||||
import PageCard from '../../components/PageCard';
|
||||
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { backupApi } from '../../api/backup';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
const BackupPage = memo(function BackupPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await backupApi.export();
|
||||
message.success('导出已开始,请检查您的下载。');
|
||||
message.success(t('Export started, check your downloads.'));
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '导出失败');
|
||||
message.error(e.message || t('Export failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -23,24 +25,24 @@ const BackupPage = memo(function BackupPage() {
|
||||
|
||||
const handleImport = (file: File) => {
|
||||
Modal.confirm({
|
||||
title: '确认导入备份?',
|
||||
title: t('Confirm import backup?'),
|
||||
content: (
|
||||
<Typography>
|
||||
<Paragraph>您确定要从此文件导入数据吗?</Paragraph>
|
||||
<Paragraph strong>警告:此操作将覆盖当前数据库中的所有现有数据,包括用户(含密码)、设置、存储和任务。此操作不可逆!</Paragraph>
|
||||
<Paragraph>{t('Are you sure to import from this file?')}</Paragraph>
|
||||
<Paragraph strong>{t('Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!')}</Paragraph>
|
||||
</Typography>
|
||||
),
|
||||
okText: '确认导入',
|
||||
okText: t('Confirm Import'),
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
cancelText: t('Cancel'),
|
||||
onOk: async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await backupApi.import(file);
|
||||
message.success(response.message || '导入成功!页面将刷新。');
|
||||
message.success(response.message || t('Import succeeded! The page will refresh.'));
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '导入失败');
|
||||
message.error(e.message || t('Import failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -50,33 +52,33 @@ const BackupPage = memo(function BackupPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageCard title="备份和恢复">
|
||||
<PageCard title={t('Backup & Restore')}>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
<PageCard title="导出" style={{ flex: 1 }}>
|
||||
<PageCard title={t('Export')} style={{ flex: 1 }}>
|
||||
<Paragraph>
|
||||
点击下面的按钮将所有数据(包括存储、用户、自动化任务和分享)导出为一个 JSON 文件。
|
||||
<Text strong>请妥善保管您的备份文件。</Text>
|
||||
{t('Export all data (adapters, users, tasks, shares) into a JSON file.')}
|
||||
<Text strong>{t('Keep your backup file safe.')}</Text>
|
||||
</Paragraph>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExport}
|
||||
loading={loading}
|
||||
>
|
||||
导出备份
|
||||
{t('Export Backup')}
|
||||
</Button>
|
||||
</PageCard>
|
||||
<PageCard title="恢复" style={{ flex: 1 }}>
|
||||
<PageCard title={t('Import')} style={{ flex: 1 }}>
|
||||
<Paragraph>
|
||||
从之前导出的JSON文件恢复数据。
|
||||
<Text strong type="danger">警告:此操作将清除并覆盖现有数据。</Text>
|
||||
{t('Restore data from a previously exported JSON file.')}
|
||||
<Text strong type="danger">{t('Warning: This will clear and overwrite existing data.')}</Text>
|
||||
</Paragraph>
|
||||
<Upload
|
||||
beforeUpload={handleImport}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={loading}>
|
||||
选择文件并恢复
|
||||
{t('Choose File and Restore')}
|
||||
</Button>
|
||||
</Upload>
|
||||
</PageCard>
|
||||
@@ -85,4 +87,4 @@ const BackupPage = memo(function BackupPage() {
|
||||
);
|
||||
});
|
||||
|
||||
export default BackupPage;
|
||||
export default BackupPage;
|
||||
|
||||
@@ -6,24 +6,25 @@ import { vectorDBApi } from '../../api/vectorDB';
|
||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import '../../styles/settings-tabs.css';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
const APP_CONFIG_KEYS: {key: string, label: string, default?: string}[] = [
|
||||
{ key: 'APP_NAME', label: '应用名称' },
|
||||
{ key: 'APP_LOGO', label: 'LOGO地址' },
|
||||
{ key: 'APP_DOMAIN', label: '应用域名' },
|
||||
{ key: 'FILE_DOMAIN', label: '文件域名' },
|
||||
{ key: 'APP_NAME', label: 'App Name' },
|
||||
{ key: 'APP_LOGO', label: 'Logo URL' },
|
||||
{ key: 'APP_DOMAIN', label: 'App Domain' },
|
||||
{ key: 'FILE_DOMAIN', label: 'File Domain' },
|
||||
];
|
||||
|
||||
const VISION_CONFIG_KEYS = [
|
||||
{ key: 'AI_VISION_API_URL', label: '视觉模型 API 地址' },
|
||||
{ key: 'AI_VISION_MODEL', label: '视觉模型', default: 'Qwen/Qwen2.5-VL-32B-Instruct' },
|
||||
{ key: 'AI_VISION_API_KEY', label: '视觉模型 API Key' },
|
||||
{ key: 'AI_VISION_API_URL', label: 'Vision API URL' },
|
||||
{ key: 'AI_VISION_MODEL', label: 'Vision Model', default: 'Qwen/Qwen2.5-VL-32B-Instruct' },
|
||||
{ key: 'AI_VISION_API_KEY', label: 'Vision API Key' },
|
||||
];
|
||||
|
||||
const EMBED_CONFIG_KEYS = [
|
||||
{ key: 'AI_EMBED_API_URL', label: '嵌入模型 API 地址' },
|
||||
{ key: 'AI_EMBED_MODEL', label: '嵌入模型', default: 'Qwen/Qwen3-Embedding-8B' },
|
||||
{ key: 'AI_EMBED_API_KEY', label: '嵌入模型 API Key' },
|
||||
{ key: 'AI_EMBED_API_URL', label: 'Embedding API URL' },
|
||||
{ key: 'AI_EMBED_MODEL', label: 'Embedding Model', default: 'Qwen/Qwen3-Embedding-8B' },
|
||||
{ key: 'AI_EMBED_API_KEY', label: 'Embedding API Key' },
|
||||
];
|
||||
|
||||
const ALL_AI_KEYS = [...VISION_CONFIG_KEYS, ...EMBED_CONFIG_KEYS];
|
||||
@@ -42,6 +43,7 @@ export default function SystemSettingsPage() {
|
||||
const [config, setConfigState] = useState<Record<string, string> | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('appearance');
|
||||
const { refreshTheme, previewTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
getAllConfig().then((data) => setConfigState(data as Record<string, string>));
|
||||
@@ -53,14 +55,14 @@ export default function SystemSettingsPage() {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await setConfig(key, String(value ?? ''));
|
||||
}
|
||||
message.success('保存成功');
|
||||
message.success(t('Saved successfully'));
|
||||
setConfigState({ ...config, ...values });
|
||||
// trigger theme refresh if related keys changed
|
||||
if (Object.keys(values).some(k => Object.values(THEME_KEYS).includes(k))) {
|
||||
await refreshTheme();
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
message.error(e.message || t('Save failed'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -73,12 +75,12 @@ export default function SystemSettingsPage() {
|
||||
}, [activeTab]);
|
||||
|
||||
if (!config) {
|
||||
return <PageCard title='系统设置'><div>加载中...</div></PageCard>;
|
||||
return <PageCard title={t('System Settings')}><div>{t('Loading...')}</div></PageCard>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
title='系统设置'
|
||||
title={t('System Settings')}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={32}>
|
||||
<Tabs
|
||||
@@ -93,7 +95,7 @@ export default function SystemSettingsPage() {
|
||||
label: (
|
||||
<span>
|
||||
<SkinOutlined style={{ marginRight: 8 }} />
|
||||
外观设置
|
||||
{t('Appearance Settings')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
@@ -130,39 +132,39 @@ export default function SystemSettingsPage() {
|
||||
// Validate JSON if provided
|
||||
if (vals[THEME_KEYS.TOKENS]) {
|
||||
try { JSON.parse(vals[THEME_KEYS.TOKENS]); }
|
||||
catch { return message.error('高级 Token 需为合法 JSON'); }
|
||||
catch { return message.error(t('Advanced tokens must be valid JSON')); }
|
||||
}
|
||||
await handleSave(vals);
|
||||
}}
|
||||
style={{ marginTop: 24 }}
|
||||
key={'appearance-' + JSON.stringify(config)}
|
||||
>
|
||||
<Card title="主题">
|
||||
<Form.Item name={THEME_KEYS.MODE} label="主题模式">
|
||||
<Card title={t('Theme')}>
|
||||
<Form.Item name={THEME_KEYS.MODE} label={t('Theme Mode')}>
|
||||
<Radio.Group buttonStyle="solid">
|
||||
<Radio.Button value="light">亮色</Radio.Button>
|
||||
<Radio.Button value="dark">暗色</Radio.Button>
|
||||
<Radio.Button value="system">跟随系统</Radio.Button>
|
||||
<Radio.Button value="light">{t('Light')}</Radio.Button>
|
||||
<Radio.Button value="dark">{t('Dark')}</Radio.Button>
|
||||
<Radio.Button value="system">{t('Follow System')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item name={THEME_KEYS.PRIMARY} label="主色">
|
||||
<Form.Item name={THEME_KEYS.PRIMARY} label={t('Primary Color')}>
|
||||
<Input type="color" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name={THEME_KEYS.RADIUS} label="圆角">
|
||||
<Form.Item name={THEME_KEYS.RADIUS} label={t('Border Radius')}>
|
||||
<InputNumber min={0} max={24} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
<Card title="高级" style={{ marginTop: 24 }}>
|
||||
<Form.Item name={THEME_KEYS.TOKENS} label="覆盖 AntD Token(JSON)" tooltip="例如:{ "colorText": "#222" }">
|
||||
<Card title={t('Advanced')} style={{ marginTop: 24 }}>
|
||||
<Form.Item name={THEME_KEYS.TOKENS} label={t('Override AntD Tokens (JSON)')} tooltip={t('e.g. {"colorText": "#222"}') }>
|
||||
<Input.TextArea autoSize={{ minRows: 4 }} placeholder='{ "colorText": "#222" }' />
|
||||
</Form.Item>
|
||||
<Form.Item name={THEME_KEYS.CSS} label="自定义 CSS">
|
||||
<Input.TextArea autoSize={{ minRows: 6 }} placeholder={":root{ }\n/* 支持任意 CSS */"} />
|
||||
<Form.Item name={THEME_KEYS.CSS} label={t('Custom CSS')}>
|
||||
<Input.TextArea autoSize={{ minRows: 6 }} placeholder={":root{ }\n/* CSS */"} />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
<Form.Item style={{ marginTop: 24 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
保存
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -173,7 +175,7 @@ export default function SystemSettingsPage() {
|
||||
label: (
|
||||
<span>
|
||||
<AppstoreOutlined style={{ marginRight: 8 }} />
|
||||
应用设置
|
||||
{t('App Settings')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
@@ -187,13 +189,13 @@ export default function SystemSettingsPage() {
|
||||
key={JSON.stringify(config)}
|
||||
>
|
||||
{APP_CONFIG_KEYS.map(({ key, label }) => (
|
||||
<Form.Item key={key} name={key} label={label}>
|
||||
<Form.Item key={key} name={key} label={t(label)}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
保存
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -204,7 +206,7 @@ export default function SystemSettingsPage() {
|
||||
label: (
|
||||
<span>
|
||||
<RobotOutlined style={{ marginRight: 8 }} />
|
||||
AI设置
|
||||
{t('AI Settings')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
@@ -217,23 +219,23 @@ export default function SystemSettingsPage() {
|
||||
style={{ marginTop: 24 }}
|
||||
key={JSON.stringify(config)}
|
||||
>
|
||||
<Card title="视觉模型" style={{ marginBottom: 24 }}>
|
||||
<Card title={t('Vision Model')} style={{ marginBottom: 24 }}>
|
||||
{VISION_CONFIG_KEYS.map(({ key, label }) => (
|
||||
<Form.Item key={key} name={key} label={label}>
|
||||
<Form.Item key={key} name={key} label={t(label)}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Card>
|
||||
<Card title="嵌入模型">
|
||||
<Card title={t('Embedding Model')}>
|
||||
{EMBED_CONFIG_KEYS.map(({ key, label }) => (
|
||||
<Form.Item key={key} name={key} label={label}>
|
||||
<Form.Item key={key} name={key} label={t(label)}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Card>
|
||||
<Form.Item style={{ marginTop: 24 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
保存
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -244,16 +246,16 @@ export default function SystemSettingsPage() {
|
||||
label: (
|
||||
<span>
|
||||
<DatabaseOutlined style={{ marginRight: 8 }} />
|
||||
向量数据库
|
||||
{t('Vector Database')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Card title="向量数据库设置" style={{ marginTop: 24 }}>
|
||||
<Card title={t('Vector Database Settings')} style={{ marginTop: 24 }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="数据库类型">
|
||||
<Form.Item label={t('Database Type')}>
|
||||
<Select
|
||||
size="large"
|
||||
value="Milvus Lite"
|
||||
value={'Milvus Lite'}
|
||||
disabled
|
||||
options={[{ value: 'Milvus Lite', label: 'Milvus Lite' }]}
|
||||
/>
|
||||
@@ -264,23 +266,23 @@ export default function SystemSettingsPage() {
|
||||
block
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认清空向量数据库?',
|
||||
content: '此操作将删除所有集合中的所有数据,且不可逆。',
|
||||
okText: '确认清空',
|
||||
title: t('Confirm clear vector database?'),
|
||||
content: t('This will delete all collections irreversibly.'),
|
||||
okText: t('Confirm Clear'),
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
cancelText: t('Cancel'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vectorDBApi.clearAll();
|
||||
message.success('向量数据库已清空');
|
||||
message.success(t('Vector database cleared'));
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '清空失败');
|
||||
message.error(e.message || t('Clear failed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
清空向量库
|
||||
{t('Clear Vector DB')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -4,6 +4,7 @@ import PageCard from '../components/PageCard';
|
||||
import { tasksApi, type AutomationTask, type QueuedTask } from '../api/tasks';
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
||||
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
const TasksPage = memo(function TasksPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -15,6 +16,7 @@ const TasksPage = memo(function TasksPage() {
|
||||
const [queueModalOpen, setQueueModalOpen] = useState(false);
|
||||
const [queuedTasks, setQueuedTasks] = useState<QueuedTask[]>([]);
|
||||
const [queueLoading, setQueueLoading] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -122,11 +124,11 @@ const TasksPage = memo(function TasksPage() {
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '触发事件', dataIndex: 'event', width: 120 },
|
||||
{ title: '处理器', dataIndex: 'processor_type', width: 180 },
|
||||
{ title: t('Name'), dataIndex: 'name' },
|
||||
{ title: t('Trigger Event'), dataIndex: 'event', width: 120 },
|
||||
{ title: t('Processor'), dataIndex: 'processor_type', width: 180 },
|
||||
{
|
||||
title: '启用', dataIndex: 'enabled', width: 80, render: (v: boolean, rec: AutomationTask) => <Switch
|
||||
title: t('Enabled'), dataIndex: 'enabled', width: 80, render: (v: boolean, rec: AutomationTask) => <Switch
|
||||
checked={v}
|
||||
size="small"
|
||||
loading={loading && editing?.id === rec.id}
|
||||
@@ -134,13 +136,13 @@ const TasksPage = memo(function TasksPage() {
|
||||
/>
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('Actions'),
|
||||
width: 160,
|
||||
render: (_: any, rec: AutomationTask) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEdit(rec)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
<Button size="small" onClick={() => openEdit(rec)}>{t('Edit')}</Button>
|
||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger>{t('Delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
@@ -153,12 +155,12 @@ const TasksPage = memo(function TasksPage() {
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
title="自动化任务"
|
||||
title={t('Automation Tasks')}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={fetchList} loading={loading}>刷新</Button>
|
||||
<Button onClick={openQueueModal}>运行中的任务</Button>
|
||||
<Button type="primary" onClick={openCreate}>新建任务</Button>
|
||||
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button onClick={openQueueModal}>{t('Running Tasks')}</Button>
|
||||
<Button type="primary" onClick={openCreate}>{t('Create Task')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -171,42 +173,42 @@ const TasksPage = memo(function TasksPage() {
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<Drawer
|
||||
title={editing ? `编辑任务: ${editing.name}` : '新建自动化任务'}
|
||||
title={editing ? `${t('Edit Task')}: ${editing.name}` : t('Create Automation Task')}
|
||||
width={480}
|
||||
open={open}
|
||||
onClose={() => { setOpen(false); setEditing(null); }}
|
||||
destroyOnClose
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>取消</Button>
|
||||
<Button type="primary" onClick={submit} loading={loading}>提交</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={submit} loading={loading}>{t('Submit')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="任务名称" rules={[{ required: true }]}>
|
||||
<Form.Item name="name" label={t('Task Name')} rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="event" label="触发事件" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ value: 'file_written', label: '文件写入' },
|
||||
{ value: 'file_deleted', label: '文件删除' },
|
||||
<Form.Item name="event" label={t('Trigger Event')} rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ value: 'file_written', label: t('File Written') },
|
||||
{ value: 'file_deleted', label: t('File Deleted') },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>匹配规则</Typography.Title>
|
||||
<Form.Item name="path_pattern" label="路径前缀 (可选)">
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Matching Rules')}</Typography.Title>
|
||||
<Form.Item name="path_pattern" label={t('Path Prefix (optional)')}>
|
||||
<Input placeholder="/images/screenshots" />
|
||||
</Form.Item>
|
||||
<Form.Item name="filename_regex" label="文件名正则 (可选)">
|
||||
<Form.Item name="filename_regex" label={t('Filename Regex (optional)')}>
|
||||
<Input placeholder=".*\.png$" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>执行动作</Typography.Title>
|
||||
<Form.Item name="processor_type" label="处理器" rules={[{ required: true }]}>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Action')}</Typography.Title>
|
||||
<Form.Item name="processor_type" label={t('Processor')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder="选择一个处理器"
|
||||
placeholder={t('Select a processor')}
|
||||
options={availableProcessors.map(p => ({ value: p.type, label: `${p.name} (${p.type})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -218,13 +220,13 @@ const TasksPage = memo(function TasksPage() {
|
||||
</Form>
|
||||
</Drawer>
|
||||
<Modal
|
||||
title="当前任务队列"
|
||||
title={t('Current Task Queue')}
|
||||
open={queueModalOpen}
|
||||
onCancel={() => setQueueModalOpen(false)}
|
||||
width={800}
|
||||
footer={[
|
||||
<Button key="refresh" onClick={fetchQueue} loading={queueLoading}>刷新</Button>,
|
||||
<Button key="close" onClick={() => setQueueModalOpen(false)}>关闭</Button>
|
||||
<Button key="refresh" onClick={fetchQueue} loading={queueLoading}>{t('Refresh')}</Button>,
|
||||
<Button key="close" onClick={() => setQueueModalOpen(false)}>{t('Close')}</Button>
|
||||
]}
|
||||
>
|
||||
<Table
|
||||
@@ -235,10 +237,10 @@ const TasksPage = memo(function TasksPage() {
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: 'ID', dataIndex: 'id', width: 120, render: (id) => <Typography.Text style={{ fontSize: 12 }} copyable={{ text: id }}>{id.slice(0, 8)}</Typography.Text> },
|
||||
{ title: '任务名', dataIndex: 'name' },
|
||||
{ title: '参数', dataIndex: 'task_info', render: (info) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{JSON.stringify(info)}</Typography.Text> },
|
||||
{ title: t('Task Name'), dataIndex: 'name' },
|
||||
{ title: t('Params'), dataIndex: 'task_info', render: (info) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{JSON.stringify(info)}</Typography.Text> },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 100, render: (status: QueuedTask['status']) => {
|
||||
title: t('Status'), dataIndex: 'status', width: 100, render: (status: QueuedTask['status']) => {
|
||||
const colorMap = {
|
||||
pending: 'default',
|
||||
running: 'processing',
|
||||
|
||||
Reference in New Issue
Block a user