mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-04 23:29:00 +08:00
feat(ui): add path selector modal
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
import { memo, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Modal, Button, List, Typography, Space, Input, message } from 'antd';
|
||||||
|
import { FolderOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||||
|
import { useI18n } from '../i18n';
|
||||||
|
import { vfsApi, type VfsEntry } from '../api/client';
|
||||||
|
import { getFileIcon } from '../pages/FileExplorerPage/components/FileIcons';
|
||||||
|
|
||||||
|
export type PathSelectorMode = 'directory' | 'file' | 'any';
|
||||||
|
|
||||||
|
interface PathSelectorModalProps {
|
||||||
|
open: boolean;
|
||||||
|
mode?: PathSelectorMode;
|
||||||
|
initialPath?: string;
|
||||||
|
onOk: (path: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(p: string): string {
|
||||||
|
if (!p) return '/';
|
||||||
|
const s = ('/' + p).replace(/\/+/, '/');
|
||||||
|
return s.replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(dir: string, name: string): string {
|
||||||
|
const base = normalizePath(dir);
|
||||||
|
if (base === '/') return `/${name}`;
|
||||||
|
return `${base}/${name}`.replace(/\/+/, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const PathSelectorModal = memo(function PathSelectorModal({ open, mode = 'directory', initialPath = '/', onOk, onCancel }: PathSelectorModalProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [path, setPath] = useState<string>(normalizePath(initialPath));
|
||||||
|
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null); // selected file name within current folder
|
||||||
|
|
||||||
|
const title = useMemo(() => {
|
||||||
|
if (mode === 'file') return t('Select File');
|
||||||
|
if (mode === 'any') return t('Select Path');
|
||||||
|
return t('Select Folder');
|
||||||
|
}, [mode, t]);
|
||||||
|
|
||||||
|
const load = async (p: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const listing = await vfsApi.list(p, 1, 500, 'name', 'asc');
|
||||||
|
setEntries(listing.entries);
|
||||||
|
setPath(listing.path || p);
|
||||||
|
setSelected(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('Load failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
load(normalizePath(initialPath));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, initialPath]);
|
||||||
|
|
||||||
|
const canOk = useMemo(() => {
|
||||||
|
if (mode === 'file') return !!selected;
|
||||||
|
return true;
|
||||||
|
}, [mode, selected]);
|
||||||
|
|
||||||
|
const handleOk = () => {
|
||||||
|
if (mode === 'directory') {
|
||||||
|
onOk(normalizePath(path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === 'file') {
|
||||||
|
if (!selected) {
|
||||||
|
message.warning(t('Please select a file'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOk(joinPath(path, selected));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// any
|
||||||
|
if (selected) onOk(joinPath(path, selected));
|
||||||
|
else onOk(normalizePath(path));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goUp = () => {
|
||||||
|
const cur = normalizePath(path);
|
||||||
|
if (cur === '/') return;
|
||||||
|
const parent = cur.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||||||
|
load(parent);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={handleOk}
|
||||||
|
okButtonProps={{ disabled: !canOk }}
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
<Space style={{ width: '100%', marginBottom: 12 }} align="center">
|
||||||
|
<Typography.Text type="secondary">{t('Current')}</Typography.Text>
|
||||||
|
<Input value={path} readOnly />
|
||||||
|
<Button onClick={goUp} icon={<ArrowUpOutlined />} disabled={path === '/'}>{t('Up')}</Button>
|
||||||
|
{mode !== 'file' && (
|
||||||
|
<Button type="primary" onClick={() => onOk(normalizePath(path))}>{t('Select Current Folder')}</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<List
|
||||||
|
bordered
|
||||||
|
loading={loading}
|
||||||
|
dataSource={entries}
|
||||||
|
style={{ maxHeight: 420, overflow: 'auto' }}
|
||||||
|
renderItem={(item) => {
|
||||||
|
const isSelected = selected === item.name && !item.is_dir;
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
onClick={() => {
|
||||||
|
if (item.is_dir) {
|
||||||
|
load(joinPath(path, item.name));
|
||||||
|
} else {
|
||||||
|
setSelected((prev) => (prev === item.name ? null : item.name));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer', background: isSelected ? 'rgba(22,119,255,0.08)' : undefined }}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
{item.is_dir ? <FolderOutlined /> : getFileIcon(item.name)}
|
||||||
|
<Typography.Text strong={item.is_dir}>{item.name}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PathSelectorModal;
|
||||||
|
|
||||||
@@ -312,6 +312,16 @@ export const en = {
|
|||||||
'Processing finished': 'Processing finished',
|
'Processing finished': 'Processing finished',
|
||||||
'Processing failed': 'Processing failed',
|
'Processing failed': 'Processing failed',
|
||||||
|
|
||||||
|
// Path selector
|
||||||
|
'Select File': 'Select File',
|
||||||
|
'Select Path': 'Select Path',
|
||||||
|
'Select Folder': 'Select Folder',
|
||||||
|
'Select': 'Select',
|
||||||
|
'Current': 'Current',
|
||||||
|
'Up': 'Up',
|
||||||
|
'Select Current Folder': 'Select Current Folder',
|
||||||
|
'Please select a file': 'Please select a file',
|
||||||
|
|
||||||
// Plugins page
|
// Plugins page
|
||||||
'Installed successfully': 'Installed successfully',
|
'Installed successfully': 'Installed successfully',
|
||||||
'Plugin': 'Plugin',
|
'Plugin': 'Plugin',
|
||||||
|
|||||||
@@ -314,6 +314,16 @@ export const zh = {
|
|||||||
'Processing finished': '处理完成',
|
'Processing finished': '处理完成',
|
||||||
'Processing failed': '处理失败',
|
'Processing failed': '处理失败',
|
||||||
|
|
||||||
|
// Path selector
|
||||||
|
'Select File': '选择文件',
|
||||||
|
'Select Path': '选择路径',
|
||||||
|
'Select Folder': '选择目录',
|
||||||
|
'Select': '选择',
|
||||||
|
'Current': '当前',
|
||||||
|
'Up': '上一级',
|
||||||
|
'Select Current Folder': '选择当前目录',
|
||||||
|
'Please select a file': '请选择一个文件',
|
||||||
|
|
||||||
// Plugins page
|
// Plugins page
|
||||||
'Installed successfully': '安装成功',
|
'Installed successfully': '安装成功',
|
||||||
'Plugin': '插件',
|
'Plugin': '插件',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { tasksApi, type AutomationTask, type QueuedTask } from '../api/tasks';
|
|||||||
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
||||||
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
|
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
|
||||||
import { useI18n } from '../i18n';
|
import { useI18n } from '../i18n';
|
||||||
|
import PathSelectorModal from '../components/PathSelectorModal';
|
||||||
|
|
||||||
const TasksPage = memo(function TasksPage() {
|
const TasksPage = memo(function TasksPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -17,6 +18,7 @@ const TasksPage = memo(function TasksPage() {
|
|||||||
const [queuedTasks, setQueuedTasks] = useState<QueuedTask[]>([]);
|
const [queuedTasks, setQueuedTasks] = useState<QueuedTask[]>([]);
|
||||||
const [queueLoading, setQueueLoading] = useState(false);
|
const [queueLoading, setQueueLoading] = useState(false);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const [pathPickerOpen, setPathPickerOpen] = useState(false);
|
||||||
|
|
||||||
const fetchList = useCallback(async () => {
|
const fetchList = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -151,6 +153,7 @@ const TasksPage = memo(function TasksPage() {
|
|||||||
|
|
||||||
const selectedProcessor = Form.useWatch('processor_type', form);
|
const selectedProcessor = Form.useWatch('processor_type', form);
|
||||||
const currentProcessorMeta = availableProcessors.find(p => p.type === selectedProcessor);
|
const currentProcessorMeta = availableProcessors.find(p => p.type === selectedProcessor);
|
||||||
|
const watchedPathPattern = Form.useWatch('path_pattern', form);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -197,7 +200,10 @@ const TasksPage = memo(function TasksPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Matching Rules')}</Typography.Title>
|
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Matching Rules')}</Typography.Title>
|
||||||
<Form.Item name="path_pattern" label={t('Path Prefix (optional)')}>
|
<Form.Item name="path_pattern" label={t('Path Prefix (optional)')}>
|
||||||
<Input placeholder="/images/screenshots" />
|
<Input
|
||||||
|
placeholder="/images/screenshots"
|
||||||
|
addonAfter={<Button size="small" onClick={() => setPathPickerOpen(true)}>{t('Select')}</Button>}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="filename_regex" label={t('Filename Regex (optional)')}>
|
<Form.Item name="filename_regex" label={t('Filename Regex (optional)')}>
|
||||||
<Input placeholder=".*\.png$" />
|
<Input placeholder=".*\.png$" />
|
||||||
@@ -219,6 +225,13 @@ const TasksPage = memo(function TasksPage() {
|
|||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
<PathSelectorModal
|
||||||
|
open={pathPickerOpen}
|
||||||
|
mode="directory"
|
||||||
|
initialPath={watchedPathPattern || '/'}
|
||||||
|
onCancel={() => setPathPickerOpen(false)}
|
||||||
|
onOk={(p) => { form.setFieldsValue({ path_pattern: p }); setPathPickerOpen(false); }}
|
||||||
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title={t('Current Task Queue')}
|
title={t('Current Task Queue')}
|
||||||
open={queueModalOpen}
|
open={queueModalOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user