mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-11 09:59:50 +08:00
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
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, type AdapterTypeMeta, type AdapterUsage } from '../api/client';
|
|
import { useI18n } from '../i18n';
|
|
|
|
const formatBytes = (bytes?: number | null) => {
|
|
if (bytes === null || bytes === undefined) return '-';
|
|
if (bytes === 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
const value = bytes / (1024 ** index);
|
|
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
|
};
|
|
|
|
const formatUsage = (usage?: AdapterUsage) => {
|
|
if (!usage?.supported || usage.used_bytes === null || usage.used_bytes === undefined) return '-';
|
|
const used = formatBytes(usage.used_bytes);
|
|
if (usage.total_bytes === null || usage.total_bytes === undefined) return used;
|
|
return `${used} / ${formatBytes(usage.total_bytes)}`;
|
|
};
|
|
|
|
const AdaptersPage = memo(function AdaptersPage() {
|
|
const [loading, setLoading] = useState(false);
|
|
const [data, setData] = useState<AdapterItem[]>([]);
|
|
const [usageMap, setUsageMap] = useState<Record<number, AdapterUsage>>({});
|
|
const [open, setOpen] = useState(false);
|
|
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);
|
|
try {
|
|
const [list, types, usages] = await Promise.all([
|
|
adaptersApi.list(),
|
|
adaptersApi.available(),
|
|
adaptersApi.usage()
|
|
]);
|
|
setData(list);
|
|
setAvailableTypes(types);
|
|
setUsageMap(Object.fromEntries(usages.map(item => [item.id, item])));
|
|
} catch (e: any) {
|
|
message.error(e.message || t('Load failed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [t]);
|
|
|
|
useEffect(() => { fetchList(); }, [fetchList]);
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
const defaultType = availableTypes[0]?.type || 'local';
|
|
const typeMeta = availableTypes.find(t => t.type === defaultType);
|
|
const cfgDefaults: Record<string, any> = {};
|
|
typeMeta?.config_schema.forEach(f => {
|
|
if (f.default !== undefined) cfgDefaults[f.key] = f.default;
|
|
});
|
|
form.setFieldsValue({
|
|
name: '',
|
|
type: defaultType,
|
|
path: '/',
|
|
sub_path: '',
|
|
enabled: true,
|
|
config: cfgDefaults
|
|
});
|
|
setOpen(true);
|
|
};
|
|
|
|
const openEdit = (rec: AdapterItem) => {
|
|
setEditing(rec);
|
|
form.resetFields();
|
|
form.setFieldsValue({
|
|
name: rec.name,
|
|
type: rec.type,
|
|
path: rec.path || '/',
|
|
sub_path: rec.sub_path || '',
|
|
enabled: rec.enabled,
|
|
config: rec.config || {}
|
|
});
|
|
setOpen(true);
|
|
};
|
|
|
|
const submit = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
const cfg = values.config || {};
|
|
const typeMeta = availableTypes.find(t => t.type === values.type);
|
|
const miss: string[] = [];
|
|
typeMeta?.config_schema.forEach(f => {
|
|
if (f.required && (cfg[f.key] === undefined || cfg[f.key] === null || cfg[f.key] === '')) {
|
|
miss.push(f.label || f.key);
|
|
}
|
|
});
|
|
if (miss.length) {
|
|
message.error(t('Missing required config:') + ' ' + miss.join(', '));
|
|
return;
|
|
}
|
|
const body = {
|
|
name: values.name.trim(),
|
|
type: values.type,
|
|
path: values.path || '/',
|
|
sub_path: values.sub_path?.trim() || null,
|
|
enabled: values.enabled,
|
|
config: cfg
|
|
};
|
|
setLoading(true);
|
|
if (editing) {
|
|
await adaptersApi.update(editing.id, body as any);
|
|
message.success(t('Updated successfully'));
|
|
} else {
|
|
await adaptersApi.create(body as any);
|
|
message.success(t('Created successfully'));
|
|
}
|
|
setOpen(false);
|
|
setEditing(null);
|
|
fetchList();
|
|
} catch (e: any) {
|
|
if (e?.errorFields) return; // 表单校验
|
|
message.error(e.message || t('Operation failed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const doDelete = async (rec: AdapterItem) => {
|
|
try {
|
|
await adaptersApi.remove(rec.id);
|
|
message.success(t('Deleted'));
|
|
fetchList();
|
|
} catch (e: any) {
|
|
message.error(e.message || t('Delete failed'));
|
|
}
|
|
};
|
|
|
|
const handleToggleEnabled = async (rec: AdapterItem, checked: boolean) => {
|
|
try {
|
|
setLoading(true);
|
|
await adaptersApi.update(rec.id, { ...rec, enabled: checked });
|
|
message.success(t('Status updated'));
|
|
fetchList();
|
|
} catch (e: any) {
|
|
message.error(e.message || t('Update failed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const renderTypeLabel = useCallback((type?: string) => {
|
|
if (!type) return '-';
|
|
const key = `adapter.type.${type}`;
|
|
const label = t(key);
|
|
return label === key ? type : label;
|
|
}, [t]);
|
|
|
|
const columns = [
|
|
{ title: t('Name'), dataIndex: 'name' },
|
|
{ title: t('Type'), dataIndex: 'type', width: 140, render: (value: string) => renderTypeLabel(value) },
|
|
{ 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: t('Capacity Usage'),
|
|
width: 180,
|
|
render: (_: any, rec: AdapterItem) => {
|
|
return formatUsage(usageMap[rec.id]);
|
|
}
|
|
},
|
|
{
|
|
title: t('Enabled'),
|
|
dataIndex: 'enabled',
|
|
width: 80,
|
|
render: (v: boolean, rec: AdapterItem) => (
|
|
<Switch
|
|
checked={v}
|
|
size="small"
|
|
loading={loading}
|
|
onChange={checked => handleToggleEnabled(rec, checked)}
|
|
/>
|
|
)
|
|
},
|
|
{
|
|
title: t('Actions'),
|
|
width: 160,
|
|
render: (_: any, rec: AdapterItem) => (
|
|
<Space size="small">
|
|
<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>
|
|
)
|
|
}
|
|
];
|
|
|
|
const selectedType = Form.useWatch('type', form);
|
|
const currentTypeMeta = availableTypes.find(t => t.type === selectedType);
|
|
|
|
function renderConfigFields() {
|
|
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: t('Please input {label}', { label: field.label }) }] : [];
|
|
let inputNode: any = <Input placeholder={field.placeholder} />;
|
|
let valuePropName: string | undefined;
|
|
if (field.type === 'password') inputNode = <Input.Password placeholder={field.placeholder} />;
|
|
if (field.type === 'number') inputNode = <Input type="number" placeholder={field.placeholder} />;
|
|
if (field.type === 'select') {
|
|
inputNode = (
|
|
<Select
|
|
placeholder={field.placeholder}
|
|
options={(field.options || []).map(option => ({ value: option, label: t(option) }))}
|
|
/>
|
|
);
|
|
}
|
|
if (field.type === 'boolean') {
|
|
inputNode = <Switch />;
|
|
valuePropName = 'checked';
|
|
}
|
|
return (
|
|
<Form.Item
|
|
key={field.key}
|
|
name={['config', field.key]}
|
|
label={t(field.label)}
|
|
rules={rules}
|
|
valuePropName={valuePropName}
|
|
>
|
|
{inputNode}
|
|
</Form.Item>
|
|
);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<PageCard
|
|
title={t('Storage Adapters')}
|
|
extra={
|
|
<Space wrap>
|
|
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
|
|
<Button type="primary" onClick={openCreate}>{t('Create Adapter')}</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
<Table
|
|
rowKey="id"
|
|
dataSource={data}
|
|
columns={columns as any}
|
|
loading={loading}
|
|
pagination={false}
|
|
scroll={{ x: 'max-content' }}
|
|
style={{ marginBottom: 0 }}
|
|
/>
|
|
<Drawer
|
|
title={editing ? `${t('Edit')}: ${editing.name}` : t('Create Adapter')}
|
|
width={480}
|
|
open={open}
|
|
onClose={() => { setOpen(false); setEditing(null); }}
|
|
destroyOnHidden
|
|
extra={
|
|
<Space>
|
|
<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"
|
|
initialValues={{ enabled: true }}
|
|
>
|
|
<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={t('Type')} rules={[{ required: true }]}>
|
|
<Select
|
|
placeholder={t('Select adapter type')}
|
|
options={availableTypes.map(t => ({ value: t.type, label: renderTypeLabel(t.type) }))}
|
|
onChange={(value) => {
|
|
const t = availableTypes.find(v => v.type === value);
|
|
const cfgDefaults: Record<string, any> = {};
|
|
t?.config_schema.forEach(f => {
|
|
if (f.default !== undefined) cfgDefaults[f.key] = f.default;
|
|
});
|
|
form.setFieldsValue({ config: cfgDefaults });
|
|
}}
|
|
/>
|
|
</Form.Item>
|
|
<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={t('Sub Path (optional)')}>
|
|
<Input placeholder={t('Sub directory inside adapter')} />
|
|
</Form.Item>
|
|
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Adapter Config')}</Typography.Title>
|
|
{renderConfigFields()}
|
|
</Form>
|
|
</Drawer>
|
|
</PageCard>
|
|
);
|
|
});
|
|
|
|
export default AdaptersPage;
|