mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-11 18:10:10 +08:00
502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
Button,
|
|
Card,
|
|
Empty,
|
|
Flex,
|
|
Form,
|
|
Input,
|
|
message,
|
|
Modal,
|
|
Space,
|
|
Spin,
|
|
Switch,
|
|
Tabs,
|
|
Tag,
|
|
Typography,
|
|
theme,
|
|
} from 'antd';
|
|
import Editor from '@monaco-editor/react';
|
|
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
|
|
import PathSelectorModal, { type PathSelectorMode } from '../components/PathSelectorModal';
|
|
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
|
import { useI18n } from '../i18n';
|
|
|
|
const { Text } = Typography;
|
|
|
|
type TabKey = 'editor' | 'runner';
|
|
|
|
const ProcessorsPage = memo(function ProcessorsPage() {
|
|
const { t } = useI18n();
|
|
const { token } = theme.useToken();
|
|
const [messageApi, contextHolder] = message.useMessage();
|
|
const [processors, setProcessors] = useState<ProcessorTypeMeta[]>([]);
|
|
const [loadingList, setLoadingList] = useState(false);
|
|
const [selectedType, setSelectedType] = useState<string>('');
|
|
const [source, setSource] = useState('');
|
|
const [initialSource, setInitialSource] = useState('');
|
|
const [modulePath, setModulePath] = useState('');
|
|
const [sourceLoading, setSourceLoading] = useState(false);
|
|
const [savingSource, setSavingSource] = useState(false);
|
|
const [reloading, setReloading] = useState(false);
|
|
const [form] = Form.useForm();
|
|
const [running, setRunning] = useState(false);
|
|
const [isDirectory, setIsDirectory] = useState(false);
|
|
const [pathModalOpen, setPathModalOpen] = useState(false);
|
|
const [pathModalMode, setPathModalMode] = useState<PathSelectorMode>('file');
|
|
const [pathModalField, setPathModalField] = useState<'path' | 'save_to'>('path');
|
|
const [activeTab, setActiveTab] = useState<TabKey>('editor');
|
|
|
|
const isDirty = source !== initialSource;
|
|
|
|
const selectedProcessorMeta = useMemo(
|
|
() => processors.find(p => p.type === selectedType),
|
|
[processors, selectedType]
|
|
);
|
|
|
|
const loadList = useCallback(async () => {
|
|
setLoadingList(true);
|
|
try {
|
|
const list = await processorsApi.list();
|
|
setProcessors(list);
|
|
} catch (err: any) {
|
|
messageApi.error(err?.message || t('Load failed'));
|
|
} finally {
|
|
setLoadingList(false);
|
|
}
|
|
}, [messageApi, t]);
|
|
|
|
useEffect(() => {
|
|
loadList();
|
|
}, [loadList]);
|
|
|
|
useEffect(() => {
|
|
if (!processors.length) {
|
|
setSelectedType('');
|
|
return;
|
|
}
|
|
if (!selectedType) {
|
|
setSelectedType(processors[0].type);
|
|
} else if (!processors.some(p => p.type === selectedType)) {
|
|
setSelectedType(processors[0].type);
|
|
}
|
|
}, [processors, selectedType]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedType) {
|
|
setSource('');
|
|
setInitialSource('');
|
|
setModulePath('');
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
setSource('');
|
|
setInitialSource('');
|
|
setModulePath('');
|
|
setSourceLoading(true);
|
|
processorsApi.getSource(selectedType)
|
|
.then(resp => {
|
|
if (controller.signal.aborted) return;
|
|
setSource(resp.source ?? '');
|
|
setInitialSource(resp.source ?? '');
|
|
setModulePath(resp.module_path ?? '');
|
|
})
|
|
.catch((err: any) => {
|
|
if (controller.signal.aborted) return;
|
|
messageApi.error(err?.message || t('Load failed'));
|
|
setSource('');
|
|
setInitialSource('');
|
|
setModulePath('');
|
|
})
|
|
.finally(() => {
|
|
if (!controller.signal.aborted) {
|
|
setSourceLoading(false);
|
|
}
|
|
});
|
|
return () => controller.abort();
|
|
}, [messageApi, selectedType, t]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedProcessorMeta) {
|
|
form.resetFields();
|
|
setIsDirectory(false);
|
|
return;
|
|
}
|
|
form.resetFields();
|
|
const defaults: Record<string, any> = {};
|
|
selectedProcessorMeta.config_schema?.forEach(field => {
|
|
if (field.default !== undefined) {
|
|
defaults[field.key] = field.default;
|
|
}
|
|
});
|
|
form.setFieldsValue({
|
|
path: '',
|
|
overwrite: !!selectedProcessorMeta.produces_file,
|
|
save_to: undefined,
|
|
config: defaults,
|
|
});
|
|
setIsDirectory(false);
|
|
}, [selectedProcessorMeta, form]);
|
|
|
|
const overwriteValue = Form.useWatch('overwrite', form) ?? false;
|
|
|
|
useEffect(() => {
|
|
if (overwriteValue) {
|
|
form.setFieldsValue({ save_to: undefined });
|
|
}
|
|
}, [overwriteValue, form]);
|
|
|
|
useEffect(() => {
|
|
if (isDirectory) {
|
|
form.setFieldsValue({ overwrite: true, save_to: undefined });
|
|
}
|
|
}, [isDirectory, form]);
|
|
|
|
const handleSelectProcessor = useCallback((type: string) => {
|
|
if (type === selectedType) return;
|
|
if (isDirty) {
|
|
Modal.confirm({
|
|
title: t('Unsaved changes'),
|
|
content: t('Switching processor will discard unsaved changes. Continue?'),
|
|
okText: t('Confirm'),
|
|
cancelText: t('Cancel'),
|
|
onOk: () => {
|
|
setSelectedType(type);
|
|
setActiveTab('editor');
|
|
},
|
|
});
|
|
} else {
|
|
setSelectedType(type);
|
|
setActiveTab('editor');
|
|
}
|
|
}, [isDirty, selectedType, t]);
|
|
|
|
const handleSaveSource = useCallback(async () => {
|
|
if (!selectedType) return;
|
|
try {
|
|
setSavingSource(true);
|
|
await processorsApi.updateSource(selectedType, source);
|
|
setInitialSource(source);
|
|
messageApi.success(t('Source saved'));
|
|
} catch (err: any) {
|
|
messageApi.error(err?.message || t('Operation failed'));
|
|
} finally {
|
|
setSavingSource(false);
|
|
}
|
|
}, [messageApi, selectedType, source, t]);
|
|
|
|
const handleReloadProcessors = useCallback(async () => {
|
|
try {
|
|
setReloading(true);
|
|
await processorsApi.reload();
|
|
messageApi.success(t('Processors reloaded'));
|
|
await loadList();
|
|
} catch (err: any) {
|
|
messageApi.error(err?.message || t('Operation failed'));
|
|
} finally {
|
|
setReloading(false);
|
|
}
|
|
}, [loadList, messageApi, t]);
|
|
|
|
const openPathSelector = useCallback((field: 'path' | 'save_to', mode: PathSelectorMode) => {
|
|
setPathModalField(field);
|
|
setPathModalMode(mode);
|
|
setPathModalOpen(true);
|
|
}, []);
|
|
|
|
const handlePathSelected = useCallback((selectedPath: string) => {
|
|
if (pathModalField === 'path') {
|
|
form.setFieldsValue({ path: selectedPath });
|
|
setIsDirectory(pathModalMode === 'directory');
|
|
} else {
|
|
form.setFieldsValue({ save_to: selectedPath });
|
|
}
|
|
setPathModalOpen(false);
|
|
}, [form, pathModalField, pathModalMode]);
|
|
|
|
const handleRun = useCallback(async () => {
|
|
if (!selectedType) {
|
|
messageApi.warning(t('Please select a processor'));
|
|
return;
|
|
}
|
|
try {
|
|
const values = await form.validateFields();
|
|
const schema = selectedProcessorMeta?.config_schema || [];
|
|
const finalConfig: Record<string, any> = {};
|
|
schema.forEach(field => {
|
|
const value = values.config?.[field.key];
|
|
if (value === undefined) {
|
|
finalConfig[field.key] = field.default;
|
|
} else {
|
|
finalConfig[field.key] = value;
|
|
}
|
|
});
|
|
setRunning(true);
|
|
const payload: any = {
|
|
path: values.path,
|
|
processor_type: selectedType,
|
|
config: finalConfig,
|
|
overwrite: !!values.overwrite,
|
|
};
|
|
if (values.save_to && !values.overwrite) {
|
|
payload.save_to = values.save_to;
|
|
}
|
|
const resp = await processorsApi.process(payload);
|
|
messageApi.success(`${t('Task submitted')}: ${resp.task_id}`);
|
|
} catch (err: any) {
|
|
if (err?.errorFields) {
|
|
return;
|
|
}
|
|
messageApi.error(err?.message || t('Operation failed'));
|
|
} finally {
|
|
setRunning(false);
|
|
}
|
|
}, [form, messageApi, selectedProcessorMeta, selectedType, t]);
|
|
|
|
const selectedConfigPath = pathModalField === 'path'
|
|
? (selectedType ? form.getFieldValue('path') : undefined) || '/'
|
|
: (selectedType ? form.getFieldValue('save_to') : undefined) || '/';
|
|
|
|
const renderProcessorList = () => {
|
|
if (loadingList) {
|
|
return (
|
|
<Flex align="center" justify="center" style={{ height: '100%' }}>
|
|
<Spin />
|
|
</Flex>
|
|
);
|
|
}
|
|
if (!processors.length) {
|
|
return (
|
|
<Flex align="center" justify="center" style={{ height: '100%' }}>
|
|
<Empty description={t('No data')} />
|
|
</Flex>
|
|
);
|
|
}
|
|
return (
|
|
<div style={{ padding: 8, overflowY: 'auto', height: '100%' }}>
|
|
{processors.map(item => {
|
|
const selected = item.type === selectedType;
|
|
const onClick = () => handleSelectProcessor(item.type);
|
|
return (
|
|
<div
|
|
key={item.type}
|
|
onClick={onClick}
|
|
style={{
|
|
border: `1px solid ${selected ? token.colorPrimary : token.colorBorderSecondary}`,
|
|
background: selected ? token.colorPrimaryBg : token.colorBgContainer,
|
|
borderRadius: 10,
|
|
padding: 12,
|
|
marginBottom: 8,
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s ease',
|
|
}}
|
|
>
|
|
<Flex justify="space-between" align="center">
|
|
<Space size={8} align="center">
|
|
<span
|
|
style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: '50%',
|
|
background: selected ? token.colorPrimary : token.colorBorderSecondary,
|
|
display: 'inline-block',
|
|
}}
|
|
/>
|
|
<Text strong>{item.name}</Text>
|
|
</Space>
|
|
<Tag color={selected ? token.colorPrimary : token.colorBorderSecondary}>{item.type}</Tag>
|
|
</Flex>
|
|
<Space direction="vertical" size={6} style={{ marginTop: 8 }}>
|
|
<div>
|
|
<Text type="secondary" style={{ marginRight: 8 }}>{t('Supported Extensions')}:</Text>
|
|
{item.supported_exts?.length ? (
|
|
<Space wrap size={[4, 4]}>
|
|
{item.supported_exts.map(ext => (
|
|
<Tag key={ext}>{ext}</Tag>
|
|
))}
|
|
</Space>
|
|
) : (
|
|
<Tag>{t('All')}</Tag>
|
|
)}
|
|
</div>
|
|
<Text type="secondary">
|
|
{t('Produces File')}: {item.produces_file ? t('Yes') : t('No')}
|
|
</Text>
|
|
</Space>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const tabs = [
|
|
{
|
|
key: 'editor',
|
|
label: t('Source Editor'),
|
|
children: selectedType ? (
|
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
<div style={{ padding: '8px 12px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
|
{modulePath ? (
|
|
<Space size={8}>
|
|
<Text type="secondary">{t('Module Path')}:</Text>
|
|
<Text code>{modulePath}</Text>
|
|
</Space>
|
|
) : (
|
|
<Text type="secondary">{t('No module path')}</Text>
|
|
)}
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0 }}>
|
|
{sourceLoading ? (
|
|
<Flex align="center" justify="center" style={{ height: '100%' }}>
|
|
<Spin />
|
|
</Flex>
|
|
) : (
|
|
<Editor
|
|
language="python"
|
|
value={source}
|
|
onChange={(val) => setSource(val ?? '')}
|
|
height="100%"
|
|
options={{
|
|
automaticLayout: true,
|
|
minimap: { enabled: false },
|
|
fontSize: 13,
|
|
scrollBeyondLastLine: false,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Empty style={{ marginTop: 64 }} description={t('Select a processor')} />
|
|
),
|
|
},
|
|
{
|
|
key: 'runner',
|
|
label: t('Run Processor'),
|
|
forceRender: true,
|
|
children: (
|
|
<Form form={form} layout="vertical" disabled={!selectedType} style={{ padding: '12px 0' }}>
|
|
{selectedType ? (
|
|
<>
|
|
{isDirectory && (
|
|
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
|
{t('Directory processing always overwrites original files')}
|
|
</Text>
|
|
)}
|
|
<Form.Item
|
|
label={t('Target Path')}
|
|
required
|
|
>
|
|
<Flex gap={8} align="center">
|
|
<div style={{ flex: 1 }}>
|
|
<Form.Item
|
|
name="path"
|
|
rules={[{ required: true, message: t('Please select a path') }]}
|
|
noStyle
|
|
>
|
|
<Input placeholder={t('Select a path')} />
|
|
</Form.Item>
|
|
</div>
|
|
<Button onClick={() => openPathSelector('path', 'file')}>{t('Select File')}</Button>
|
|
<Button onClick={() => openPathSelector('path', 'directory')}>{t('Select Directory')}</Button>
|
|
</Flex>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="overwrite"
|
|
label={t('Overwrite original')}
|
|
valuePropName="checked"
|
|
>
|
|
<Switch disabled={isDirectory} />
|
|
</Form.Item>
|
|
|
|
{selectedProcessorMeta?.produces_file && !overwriteValue && (
|
|
<Form.Item label={t('Save To')}>
|
|
<Flex gap={8} align="center">
|
|
<div style={{ flex: 1 }}>
|
|
<Form.Item name="save_to" noStyle>
|
|
<Input placeholder={t('Optional output path')} />
|
|
</Form.Item>
|
|
</div>
|
|
<Button onClick={() => openPathSelector('save_to', 'any')}>{t('Select')}</Button>
|
|
</Flex>
|
|
</Form.Item>
|
|
)}
|
|
|
|
<ProcessorConfigForm
|
|
processorMeta={selectedProcessorMeta}
|
|
form={form}
|
|
configPath={['config']}
|
|
/>
|
|
|
|
<Form.Item>
|
|
<Button type="primary" onClick={handleRun} loading={running} disabled={!selectedType}>
|
|
{t('Run')}
|
|
</Button>
|
|
</Form.Item>
|
|
</>
|
|
) : (
|
|
<Empty style={{ marginTop: 64 }} description={t('Select a processor')} />
|
|
)}
|
|
</Form>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
{contextHolder}
|
|
<Flex gap={16} style={{ height: 'calc(100vh - 88px)' }}>
|
|
<Card
|
|
style={{ flex: '0 0 320px', minWidth: 280, display: 'flex', flexDirection: 'column' }}
|
|
title={t('Processor List')}
|
|
extra={
|
|
<Space size={8}>
|
|
<Button size="small" onClick={loadList} loading={loadingList}>{t('Refresh')}</Button>
|
|
<Button size="small" onClick={handleReloadProcessors} loading={reloading}>{t('Reload')}</Button>
|
|
</Space>
|
|
}
|
|
styles={{ body: { padding: 0, flex: 1, display: 'flex' } }}
|
|
>
|
|
{renderProcessorList()}
|
|
</Card>
|
|
|
|
<Card
|
|
style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}
|
|
title={selectedProcessorMeta ? `${selectedProcessorMeta.name} (${selectedProcessorMeta.type})` : t('Select a processor')}
|
|
extra={
|
|
<Space size={8}>
|
|
<Button size="small" onClick={handleSaveSource} loading={savingSource} disabled={!selectedType || !isDirty}>
|
|
{t('Save')}
|
|
</Button>
|
|
<Button size="small" onClick={handleReloadProcessors} loading={reloading} disabled={!selectedType}>
|
|
{t('Reload')}
|
|
</Button>
|
|
</Space>
|
|
}
|
|
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
|
|
>
|
|
<Tabs
|
|
activeKey={activeTab}
|
|
onChange={key => setActiveTab(key as TabKey)}
|
|
items={tabs as any}
|
|
className="processors-tabs"
|
|
tabBarGutter={32}
|
|
/>
|
|
</Card>
|
|
</Flex>
|
|
|
|
<PathSelectorModal
|
|
open={pathModalOpen}
|
|
mode={pathModalMode}
|
|
initialPath={selectedConfigPath}
|
|
onOk={handlePathSelected}
|
|
onCancel={() => setPathModalOpen(false)}
|
|
/>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export default ProcessorsPage;
|