mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-02 04:31:28 +08:00
feat(offline-downloads): implement offline download
This commit is contained in:
@@ -73,4 +73,5 @@ async function request<T = any>(url: string, options: RequestOptions = {}): Prom
|
||||
export { vfsApi, type VfsEntry, type DirListing } from './vfs';
|
||||
export { adaptersApi, type AdapterItem, type AdapterTypeField, type AdapterTypeMeta } from './adapters';
|
||||
export { shareApi, type ShareInfo, type ShareInfoWithPassword } from './share';
|
||||
export { offlineDownloadsApi, type OfflineDownloadTask, type OfflineDownloadCreate, type TaskProgress } from './offlineDownloads';
|
||||
export default request;
|
||||
|
||||
35
web/src/api/offlineDownloads.ts
Normal file
35
web/src/api/offlineDownloads.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import request from './client';
|
||||
|
||||
export interface TaskProgress {
|
||||
stage?: string | null;
|
||||
percent?: number | null;
|
||||
bytes_total?: number | null;
|
||||
bytes_done?: number | null;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface OfflineDownloadTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'success' | 'failed';
|
||||
result?: any;
|
||||
error?: string | null;
|
||||
task_info: Record<string, any>;
|
||||
progress?: TaskProgress | null;
|
||||
meta?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface OfflineDownloadCreate {
|
||||
url: string;
|
||||
dest_dir: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export const offlineDownloadsApi = {
|
||||
create: (payload: OfflineDownloadCreate) => request<{ task_id: string }>('/offline-downloads/', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
}),
|
||||
list: () => request<OfflineDownloadTask[]>('/offline-downloads/'),
|
||||
detail: (taskId: string) => request<OfflineDownloadTask>(`/offline-downloads/${taskId}`),
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import request from './client';
|
||||
import type { TaskProgress } from './offlineDownloads';
|
||||
|
||||
export interface AutomationTask {
|
||||
id: number;
|
||||
@@ -21,6 +22,8 @@ export interface QueuedTask {
|
||||
result?: any;
|
||||
error?: string;
|
||||
task_info: Record<string, any>;
|
||||
progress?: TaskProgress | null;
|
||||
meta?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export const tasksApi = {
|
||||
@@ -29,4 +32,4 @@ export const tasksApi = {
|
||||
update: (id: number, payload: AutomationTaskUpdate) => request<AutomationTask>(`/tasks/${id}`, { method: 'PUT', json: payload }),
|
||||
remove: (id: number) => request<void>(`/tasks/${id}`, { method: 'DELETE' }),
|
||||
getQueue: () => request<QueuedTask[]>('/tasks/queue'),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -83,6 +83,27 @@ export const en = {
|
||||
|
||||
// Offline download
|
||||
'No offline download tasks': 'No offline download tasks',
|
||||
'Create Offline Download': 'Create Offline Download',
|
||||
'Offline Download Tasks': 'Offline Download Tasks',
|
||||
'URL': 'URL',
|
||||
'Please input URL': 'Please input URL',
|
||||
'Destination Folder': 'Destination Folder',
|
||||
'Select destination': 'Select destination',
|
||||
'Filename': 'Filename',
|
||||
'Please input filename': 'Please input filename',
|
||||
'Start Download': 'Start Download',
|
||||
'Stage': 'Stage',
|
||||
'Progress': 'Progress',
|
||||
'Bytes': 'Bytes',
|
||||
'Save Path': 'Save Path',
|
||||
'Queued': 'Queued',
|
||||
'Downloading': 'Downloading',
|
||||
'Transferring': 'Transferring',
|
||||
'Completed': 'Completed',
|
||||
'Pending': 'Pending',
|
||||
'Running': 'Running',
|
||||
'Success': 'Success',
|
||||
'Failed': 'Failed',
|
||||
// Header/File Explorer
|
||||
'Home': 'Home',
|
||||
'File Manager': 'File Manager',
|
||||
@@ -161,7 +182,6 @@ export const en = {
|
||||
'Width': 'Width',
|
||||
'Height': 'Height',
|
||||
'No common EXIF info': 'No common EXIF info',
|
||||
'Bytes': 'Bytes',
|
||||
'File Properties': 'File Properties',
|
||||
'Loading file info...': 'Loading file info...',
|
||||
'Basic Info': 'Basic Info',
|
||||
|
||||
@@ -11,6 +11,28 @@ export const zh = {
|
||||
'Automation': '自动任务',
|
||||
'My Shares': '我的分享',
|
||||
'Offline Downloads': '离线下载',
|
||||
'No offline download tasks': '暂无离线下载任务',
|
||||
'Create Offline Download': '创建离线下载任务',
|
||||
'Offline Download Tasks': '离线下载任务列表',
|
||||
'URL': '下载地址',
|
||||
'Please input URL': '请输入下载地址',
|
||||
'Destination Folder': '保存目录',
|
||||
'Select destination': '请选择保存目录',
|
||||
'Filename': '文件名',
|
||||
'Please input filename': '请输入文件名',
|
||||
'Start Download': '开始下载',
|
||||
'Stage': '阶段',
|
||||
'Progress': '进度',
|
||||
'Bytes': '已传输',
|
||||
'Save Path': '保存路径',
|
||||
'Queued': '排队中',
|
||||
'Downloading': '下载中',
|
||||
'Transferring': '转存中',
|
||||
'Completed': '已完成',
|
||||
'Pending': '等待',
|
||||
'Running': '进行中',
|
||||
'Success': '成功',
|
||||
'Failed': '失败',
|
||||
'Adapters': '存储挂载',
|
||||
'Plugins': '应用中心',
|
||||
'System Settings': '系统设置',
|
||||
@@ -162,7 +184,6 @@ export const zh = {
|
||||
'Width': '宽度',
|
||||
'Height': '高度',
|
||||
'No common EXIF info': '无常见EXIF信息',
|
||||
'Bytes': '字节',
|
||||
'File Properties': '文件属性',
|
||||
'Loading file info...': '加载文件信息...',
|
||||
'Basic Info': '基本信息',
|
||||
|
||||
@@ -1,8 +1,234 @@
|
||||
import { Empty } from 'antd';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Form, Input, Modal, message, Table, Tag, Typography, Space } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { FolderOpenOutlined } from '@ant-design/icons';
|
||||
|
||||
import PathSelectorModal from '../components/PathSelectorModal';
|
||||
import { offlineDownloadsApi, type OfflineDownloadTask } from '../api/client';
|
||||
import { useI18n } from '../i18n';
|
||||
import PageCard from '../components/PageCard';
|
||||
|
||||
export default function OfflineDownloadPage() {
|
||||
const { t } = useI18n();
|
||||
return <Empty description={t('No offline download tasks')} />;
|
||||
interface TableRow extends OfflineDownloadTask {
|
||||
key: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number | null): string {
|
||||
if (bytes === undefined || bytes === null) return '--';
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const idx = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const val = bytes / Math.pow(1024, idx);
|
||||
return `${val.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||
}
|
||||
|
||||
function stageLabel(t: (key: string) => string, stage?: string | null): string {
|
||||
if (!stage) return '--';
|
||||
const map: Record<string, string> = {
|
||||
queued: t('Queued'),
|
||||
downloading: t('Downloading'),
|
||||
transferring: t('Transferring'),
|
||||
completed: t('Completed'),
|
||||
};
|
||||
return map[stage] ?? stage;
|
||||
}
|
||||
|
||||
function statusTag(status: OfflineDownloadTask['status'], t: (key: string) => string) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <Tag color="green">{t('Success')}</Tag>;
|
||||
case 'failed':
|
||||
return <Tag color="red">{t('Failed')}</Tag>;
|
||||
case 'running':
|
||||
return <Tag color="blue">{t('Running')}</Tag>;
|
||||
default:
|
||||
return <Tag color="default">{t('Pending')}</Tag>;
|
||||
}
|
||||
}
|
||||
|
||||
const OfflineDownloadPage = memo(function OfflineDownloadPage() {
|
||||
const { t } = useI18n();
|
||||
const [form] = Form.useForm();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [tasks, setTasks] = useState<TableRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pathModalOpen, setPathModalOpen] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
const loadTasks = useCallback(async (withSpinner = false) => {
|
||||
if (withSpinner) setLoading(true);
|
||||
try {
|
||||
const list = await offlineDownloadsApi.list();
|
||||
setTasks(list.map(item => ({ ...item, key: item.id })));
|
||||
} catch (err: any) {
|
||||
messageApi.error(err?.message || t('Load failed'));
|
||||
} finally {
|
||||
if (withSpinner) setLoading(false);
|
||||
}
|
||||
}, [messageApi, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks(true);
|
||||
const timer = window.setInterval(() => {
|
||||
loadTasks().catch(() => {});
|
||||
}, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadTasks]);
|
||||
|
||||
const handleSelectFolder = useCallback((path: string) => {
|
||||
form.setFieldsValue({ dest_dir: path });
|
||||
setPathModalOpen(false);
|
||||
}, [form]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
const resp = await offlineDownloadsApi.create(values);
|
||||
messageApi.success(`${t('Task submitted')}: ${resp.task_id}`);
|
||||
form.resetFields();
|
||||
await loadTasks(true);
|
||||
setCreateModalOpen(false);
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) return;
|
||||
messageApi.error(err?.message || t('Operation failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [form, loadTasks, messageApi, t]);
|
||||
|
||||
const columns: TableColumnsType<TableRow> = useMemo(() => [
|
||||
{
|
||||
title: t('Filename'),
|
||||
dataIndex: ['meta', 'filename'],
|
||||
render: (_: any, record) => record.meta?.filename || record.task_info?.filename || '--',
|
||||
},
|
||||
{
|
||||
title: t('Stage'),
|
||||
dataIndex: ['progress', 'stage'],
|
||||
render: (_: any, record) => stageLabel(t, record.progress?.stage),
|
||||
},
|
||||
{
|
||||
title: t('Progress'),
|
||||
dataIndex: ['progress', 'percent'],
|
||||
render: (_: any, record) => {
|
||||
const percent = record.progress?.percent;
|
||||
return percent !== undefined && percent !== null ? `${percent.toFixed(1)}%` : '--';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('Bytes'),
|
||||
render: (_: any, record) => {
|
||||
const done = record.progress?.bytes_done;
|
||||
const total = record.progress?.bytes_total;
|
||||
if (done === undefined && total === undefined) return '--';
|
||||
if (total) {
|
||||
return `${formatBytes(done)} / ${formatBytes(total)}`;
|
||||
}
|
||||
return formatBytes(done);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('Status'),
|
||||
dataIndex: 'status',
|
||||
render: (status: TableRow['status'], record) => (
|
||||
<Space>
|
||||
{statusTag(status, t)}
|
||||
{status === 'failed' && record.error ? <Typography.Text type="danger">{record.error}</Typography.Text> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Save Path'),
|
||||
dataIndex: ['meta', 'final_path'],
|
||||
render: (value: string | undefined, record) => value || record.task_info?.dest_dir || '--',
|
||||
},
|
||||
], [t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<PageCard
|
||||
title={t('Offline Downloads')}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => { form.resetFields(); setCreateModalOpen(true); }}>
|
||||
{t('Create Offline Download')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('No offline download tasks') }}
|
||||
rowKey="id"
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
</PageCard>
|
||||
|
||||
<Modal
|
||||
title={t('Create Offline Download')}
|
||||
open={createModalOpen}
|
||||
onCancel={() => { setCreateModalOpen(false); form.resetFields(); }}
|
||||
onOk={handleSubmit}
|
||||
okText={t('Start Download')}
|
||||
okButtonProps={{ loading: submitting }}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="url" label={t('URL')} rules={[{ required: true, message: t('Please input URL') }]}>
|
||||
<Input placeholder="https://example.com/file" />
|
||||
</Form.Item>
|
||||
{(() => {
|
||||
const errors = form.getFieldError('dest_dir');
|
||||
const hasError = errors.length > 0;
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('Destination Folder')}
|
||||
required
|
||||
validateStatus={hasError ? 'error' : undefined}
|
||||
help={hasError ? errors[0] : undefined}
|
||||
>
|
||||
<Input.Group compact style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
name="dest_dir"
|
||||
noStyle
|
||||
rules={[{ required: true, message: t('Select destination') }]}
|
||||
>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder={t('Select destination')}
|
||||
style={{ width: 'calc(100% - 120px)', cursor: 'pointer' }}
|
||||
onClick={() => setPathModalOpen(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<FolderOpenOutlined />}
|
||||
onClick={() => setPathModalOpen(true)}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
{t('Select Folder')}
|
||||
</Button>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
);
|
||||
})()}
|
||||
<Form.Item name="filename" label={t('Filename')} rules={[{ required: true, message: t('Please input filename') }]}>
|
||||
<Input placeholder="example.zip" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<PathSelectorModal
|
||||
open={pathModalOpen}
|
||||
mode="directory"
|
||||
initialPath={form.getFieldValue('dest_dir') || '/'}
|
||||
onOk={handleSelectFolder}
|
||||
onCancel={() => setPathModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default OfflineDownloadPage;
|
||||
|
||||
Reference in New Issue
Block a user