feat: add i18n with language switcher and English/Chinese translations

This commit is contained in:
shiyu
2025-09-09 16:50:43 +08:00
parent 59c017a05b
commit db453ef09b
40 changed files with 1381 additions and 469 deletions

View File

@@ -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>
);
};
};

View File

@@ -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>
);

View File

@@ -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>
)}

View File

@@ -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[]
}}
>

View File

@@ -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>
);
};
};

View File

@@ -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>
);
};
};

View File

@@ -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>
);
});
});

View File

@@ -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>
);
};
};

View File

@@ -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>
);
};
};

View File

@@ -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>
);
});
});

View File

@@ -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>,
]}
>

View File

@@ -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,
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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,
};
}
}