From 155f3a144ddf69505132527fe604a8465eb7a8eb Mon Sep 17 00:00:00 2001 From: shiyu Date: Mon, 15 Sep 2025 14:14:10 +0800 Subject: [PATCH] feat(ui): add path selector modal --- web/src/components/PathSelectorModal.tsx | 143 +++++++++++++++++++++++ web/src/i18n/locales/en.ts | 10 ++ web/src/i18n/locales/zh.ts | 10 ++ web/src/pages/TasksPage.tsx | 15 ++- 4 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 web/src/components/PathSelectorModal.tsx diff --git a/web/src/components/PathSelectorModal.tsx b/web/src/components/PathSelectorModal.tsx new file mode 100644 index 0000000..8b25236 --- /dev/null +++ b/web/src/components/PathSelectorModal.tsx @@ -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(normalizePath(initialPath)); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [selected, setSelected] = useState(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 ( + + + {t('Current')} + + + {mode !== 'file' && ( + + )} + + + { + const isSelected = selected === item.name && !item.is_dir; + return ( + { + 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 }} + > + + {item.is_dir ? : getFileIcon(item.name)} + {item.name} + + + ); + }} + /> + + ); +}); + +export default PathSelectorModal; + diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index e306017..2a320c9 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -312,6 +312,16 @@ export const en = { 'Processing finished': 'Processing finished', '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 'Installed successfully': 'Installed successfully', 'Plugin': 'Plugin', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 66932d2..1132d0e 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -314,6 +314,16 @@ export const zh = { 'Processing finished': '处理完成', 'Processing failed': '处理失败', + // Path selector + 'Select File': '选择文件', + 'Select Path': '选择路径', + 'Select Folder': '选择目录', + 'Select': '选择', + 'Current': '当前', + 'Up': '上一级', + 'Select Current Folder': '选择当前目录', + 'Please select a file': '请选择一个文件', + // Plugins page 'Installed successfully': '安装成功', 'Plugin': '插件', diff --git a/web/src/pages/TasksPage.tsx b/web/src/pages/TasksPage.tsx index 5f01c08..434db40 100644 --- a/web/src/pages/TasksPage.tsx +++ b/web/src/pages/TasksPage.tsx @@ -5,6 +5,7 @@ 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'; +import PathSelectorModal from '../components/PathSelectorModal'; const TasksPage = memo(function TasksPage() { const [loading, setLoading] = useState(false); @@ -17,6 +18,7 @@ const TasksPage = memo(function TasksPage() { const [queuedTasks, setQueuedTasks] = useState([]); const [queueLoading, setQueueLoading] = useState(false); const { t } = useI18n(); + const [pathPickerOpen, setPathPickerOpen] = useState(false); const fetchList = useCallback(async () => { setLoading(true); @@ -151,6 +153,7 @@ const TasksPage = memo(function TasksPage() { const selectedProcessor = Form.useWatch('processor_type', form); const currentProcessorMeta = availableProcessors.find(p => p.type === selectedProcessor); + const watchedPathPattern = Form.useWatch('path_pattern', form); return ( @@ -197,7 +200,10 @@ const TasksPage = memo(function TasksPage() { {t('Matching Rules')} - + setPathPickerOpen(true)}>{t('Select')}} + /> @@ -219,6 +225,13 @@ const TasksPage = memo(function TasksPage() { /> + setPathPickerOpen(false)} + onOk={(p) => { form.setFieldsValue({ path_pattern: p }); setPathPickerOpen(false); }} + />