mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-02 21:02:17 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
280bedcf1a | ||
|
|
b03f2619ca | ||
|
|
72403d5861 | ||
|
|
dffcdb7a8b | ||
|
|
19c4394f3d |
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -42,10 +42,10 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push Docker image (multi arch)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db
|
||||
|
||||
|
||||
def include_routers(app: FastAPI):
|
||||
@@ -14,4 +14,5 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(logs.router)
|
||||
app.include_router(share.router)
|
||||
app.include_router(share.public_router)
|
||||
app.include_router(backup.router)
|
||||
app.include_router(backup.router)
|
||||
app.include_router(vector_db.router)
|
||||
19
api/routes/vector_db.py
Normal file
19
api/routes/vector_db.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from services.auth import get_current_active_user
|
||||
from models.database import UserAccount
|
||||
from services.vector_db import VectorDBService
|
||||
from api.response import success
|
||||
|
||||
router = APIRouter(prefix="/api/vector-db", tags=["vector-db"])
|
||||
|
||||
|
||||
@router.post("/clear-all", summary="清空向量数据库")
|
||||
async def clear_vector_db(user: UserAccount = Depends(get_current_active_user)):
|
||||
if user.username != 'admin':
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||
try:
|
||||
service = VectorDBService()
|
||||
service.clear_all_data()
|
||||
return success(msg="向量数据库已清空")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
foxel:
|
||||
image: ghcr.io/drizzletime/foxel:latest
|
||||
#image: ghcr.nju.edu.cn/drizzletime/foxel:latest #国内用户可以用此镜像命令
|
||||
#image: ghcr.nju.edu.cn/drizzletime/foxel:latest # 国内用户可以用此镜像命令
|
||||
container_name: foxel
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Optional, Dict
|
||||
from dotenv import load_dotenv
|
||||
from models.database import Configuration
|
||||
load_dotenv(dotenv_path=".env")
|
||||
VERSION = "v1.1.5"
|
||||
VERSION = "v1.1.6"
|
||||
|
||||
class ConfigCenter:
|
||||
_cache: Dict[str, Any] = {}
|
||||
|
||||
@@ -75,3 +75,9 @@ class VectorDBService:
|
||||
output_fields=["path"]
|
||||
)
|
||||
return [[{'id': r['path'], 'distance': 1.0, 'entity': {'path': r['path']}} for r in results]]
|
||||
|
||||
def clear_all_data(self):
|
||||
"""清空所有集合的内容"""
|
||||
collections = self.client.list_collections()
|
||||
for collection_name in collections:
|
||||
self.client.drop_collection(collection_name)
|
||||
|
||||
@@ -14,9 +14,19 @@ export interface AutomationTask {
|
||||
export type AutomationTaskCreate = Omit<AutomationTask, 'id'>;
|
||||
export type AutomationTaskUpdate = Partial<AutomationTaskCreate>;
|
||||
|
||||
export interface QueuedTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'success' | 'failed';
|
||||
result?: any;
|
||||
error?: string;
|
||||
task_info: Record<string, any>;
|
||||
}
|
||||
|
||||
export const tasksApi = {
|
||||
list: () => request<AutomationTask[]>('/tasks/'),
|
||||
create: (payload: AutomationTaskCreate) => request<AutomationTask>('/tasks/', { method: 'POST', json: payload }),
|
||||
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'),
|
||||
};
|
||||
5
web/src/api/vectorDB.ts
Normal file
5
web/src/api/vectorDB.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import client from './client';
|
||||
|
||||
export const vectorDBApi = {
|
||||
clearAll: () => client('/vector-db/clear-all', { method: 'POST' }),
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Card, message, List, Typography, Button, Empty, Breadcrumb } from 'antd';
|
||||
import { Card, List, Typography, Button, Empty, Breadcrumb } from 'antd';
|
||||
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { shareApi, type ShareInfo } from '../../api/share';
|
||||
import { type VfsEntry } from '../../api/vfs';
|
||||
@@ -11,9 +11,10 @@ interface DirectoryViewerProps {
|
||||
token: string;
|
||||
shareInfo: ShareInfo;
|
||||
password?: string;
|
||||
onFileClick: (entry: VfsEntry, path: string) => void;
|
||||
}
|
||||
|
||||
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password }: DirectoryViewerProps) {
|
||||
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password, onFileClick }: DirectoryViewerProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
@@ -38,11 +39,11 @@ export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo,
|
||||
}, [loadData, currentPath]);
|
||||
|
||||
const handleEntryClick = (entry: VfsEntry) => {
|
||||
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||
if (entry.is_dir) {
|
||||
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||
loadData(newPath);
|
||||
} else {
|
||||
message.info('暂不支持预览');
|
||||
onFileClick(entry, newPath);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { Card, Spin, Button, Typography, Empty } from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import { DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { shareApi, type ShareInfo } from '../../api/share';
|
||||
import { type VfsEntry } from '../../api/vfs';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { VideoViewer } from './VideoViewer';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const isImageViewer = (name: string) => /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name);
|
||||
const isVideoViewable = (name: string) => /\.(mp4|webm|ogg|m4v|mov)$/i.test(name);
|
||||
|
||||
interface FileViewerProps {
|
||||
token: string;
|
||||
shareInfo: ShareInfo;
|
||||
entry: VfsEntry;
|
||||
password?: string;
|
||||
onBack: () => void;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password }: FileViewerProps) {
|
||||
export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password, onBack, path }: FileViewerProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -25,7 +31,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const url = shareApi.downloadUrl(token, entry.name, password);
|
||||
const url = shareApi.downloadUrl(token, path, password);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('无法加载文件');
|
||||
@@ -44,7 +50,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, entry.name, password]);
|
||||
}, [token, entry.name, password, path]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
@@ -53,9 +59,21 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
if (error) {
|
||||
return <Empty description={error} />;
|
||||
}
|
||||
|
||||
const downloadUrl = shareApi.downloadUrl(token, path, password);
|
||||
|
||||
if (isImageViewer(entry.name)) {
|
||||
return <img src={downloadUrl} alt={entry.name} style={{ maxWidth: '100%' }} />;
|
||||
}
|
||||
|
||||
if (isVideoViewable(entry.name)) {
|
||||
return <VideoViewer token={token} entry={entry} password={password} path={path} />;
|
||||
}
|
||||
|
||||
if (entry.name.endsWith('.md')) {
|
||||
return <ReactMarkdown>{content}</ReactMarkdown>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Empty
|
||||
description={
|
||||
@@ -64,7 +82,7 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
href={shareApi.downloadUrl(token, entry.name, password)}
|
||||
href={downloadUrl}
|
||||
download
|
||||
>
|
||||
下载文件
|
||||
@@ -84,10 +102,17 @@ export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, pa
|
||||
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
|
||||
</Text>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
style={{ marginBottom: 16, marginRight: 8 }}
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
style={{ marginBottom: 16 }}
|
||||
icon={<DownloadOutlined />}
|
||||
href={shareApi.downloadUrl(token, entry.name, password)}
|
||||
href={shareApi.downloadUrl(token, path, password)}
|
||||
download
|
||||
>
|
||||
下载
|
||||
|
||||
50
web/src/pages/PublicSharePage/VideoViewer.tsx
Normal file
50
web/src/pages/PublicSharePage/VideoViewer.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Artplayer from 'artplayer';
|
||||
import { shareApi } from '../../api/share';
|
||||
import type { VfsEntry } from '../../api/vfs';
|
||||
|
||||
interface VideoViewerProps {
|
||||
token: string;
|
||||
entry: VfsEntry;
|
||||
password?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const VideoViewer: React.FC<VideoViewerProps> = ({ token, entry, password, path }) => {
|
||||
const artRef = useRef<HTMLDivElement | null>(null);
|
||||
const artInstance = useRef<Artplayer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const videoUrl = shareApi.downloadUrl(token, path, password);
|
||||
|
||||
if (artRef.current) {
|
||||
artInstance.current = new Artplayer({
|
||||
container: artRef.current,
|
||||
url: videoUrl,
|
||||
autoplay: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
pip: true,
|
||||
setting: true,
|
||||
playbackRate: true,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (artInstance.current) {
|
||||
artInstance.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [token, entry.name, password, path]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={artRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '450px',
|
||||
backgroundColor: '#000'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ const PublicSharePage = memo(function PublicSharePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
|
||||
const [entry, setEntry] = useState<VfsEntry | null>(null);
|
||||
const [previewFile, setPreviewFile] = useState<{ entry: VfsEntry, path: string } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [verified, setVerified] = useState(false);
|
||||
@@ -37,7 +37,9 @@ const PublicSharePage = memo(function PublicSharePage() {
|
||||
const listing = await shareApi.listDir(token, '/', currentPassword);
|
||||
if (listing.entries.length === 1) {
|
||||
const singleEntry = listing.entries[0];
|
||||
setEntry(singleEntry);
|
||||
if (!singleEntry.is_dir) {
|
||||
setPreviewFile({ entry: singleEntry, path: '/' + singleEntry.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,11 +101,27 @@ const PublicSharePage = memo(function PublicSharePage() {
|
||||
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description="无法加载分享信息" /></div>;
|
||||
}
|
||||
|
||||
if (entry && !entry.is_dir) {
|
||||
return <FileViewer token={token!} shareInfo={shareInfo} entry={entry} password={password} />;
|
||||
} else {
|
||||
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} />;
|
||||
const handleFileClick = (entry: VfsEntry, path: string) => {
|
||||
setPreviewFile({ entry, path });
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setPreviewFile(null);
|
||||
};
|
||||
|
||||
if (previewFile) {
|
||||
return (
|
||||
<FileViewer
|
||||
token={token!}
|
||||
shareInfo={shareInfo}
|
||||
entry={previewFile.entry}
|
||||
password={password}
|
||||
onBack={handleBack}
|
||||
path={previewFile.path}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} onFileClick={handleFileClick} />;
|
||||
});
|
||||
|
||||
export default PublicSharePage;
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Form, Input, Button, message, Tabs, Space, Card } from 'antd';
|
||||
import { Form, Input, Button, message, Tabs, Space, Card, Select, Modal } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageCard from '../../components/PageCard';
|
||||
import { getAllConfig, setConfig } from '../../api/config';
|
||||
import { AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { vectorDBApi } from '../../api/vectorDB';
|
||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||
|
||||
const APP_CONFIG_KEYS: {key: string, label: string, default?: string}[] = [
|
||||
{ key: 'APP_NAME', label: '应用名称' },
|
||||
@@ -134,6 +135,54 @@ export default function SystemSettingsPage() {
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vector-db',
|
||||
label: (
|
||||
<span>
|
||||
<DatabaseOutlined style={{ marginRight: 8 }} />
|
||||
向量数据库
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Card title="向量数据库设置" style={{ marginTop: 24 }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="数据库类型">
|
||||
<Select
|
||||
size="large"
|
||||
value="Milvus Lite"
|
||||
disabled
|
||||
options={[{ value: 'Milvus Lite', label: 'Milvus Lite' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
danger
|
||||
block
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认清空向量数据库?',
|
||||
content: '此操作将删除所有集合中的所有数据,且不可逆。',
|
||||
okText: '确认清空',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vectorDBApi.clearAll();
|
||||
message.success('向量数据库已清空');
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '清空失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
清空向量库
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select } from 'antd';
|
||||
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select, Modal, Tag } from 'antd';
|
||||
import PageCard from '../components/PageCard';
|
||||
import { tasksApi, type AutomationTask } from '../api/tasks';
|
||||
import { tasksApi, type AutomationTask, type QueuedTask } from '../api/tasks';
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
||||
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
|
||||
|
||||
@@ -12,6 +12,9 @@ const TasksPage = memo(function TasksPage() {
|
||||
const [editing, setEditing] = useState<AutomationTask | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [availableProcessors, setAvailableProcessors] = useState<ProcessorTypeMeta[]>([]);
|
||||
const [queueModalOpen, setQueueModalOpen] = useState(false);
|
||||
const [queuedTasks, setQueuedTasks] = useState<QueuedTask[]>([]);
|
||||
const [queueLoading, setQueueLoading] = useState(false);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -86,11 +89,50 @@ const TasksPage = memo(function TasksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchQueue = async () => {
|
||||
setQueueLoading(true);
|
||||
try {
|
||||
const tasks = await tasksApi.getQueue();
|
||||
setQueuedTasks(tasks);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载队列失败');
|
||||
} finally {
|
||||
setQueueLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openQueueModal = () => {
|
||||
setQueueModalOpen(true);
|
||||
fetchQueue();
|
||||
};
|
||||
|
||||
const toggleEnabled = async (rec: AutomationTask, enabled: boolean) => {
|
||||
setEditing(rec);
|
||||
setLoading(true);
|
||||
try {
|
||||
await tasksApi.update(rec.id, { enabled });
|
||||
message.success('状态已更新');
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '更新失败');
|
||||
} finally {
|
||||
setEditing(null);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '触发事件', dataIndex: 'event', width: 120 },
|
||||
{ title: '处理器', dataIndex: 'processor_type', width: 180 },
|
||||
{ title: '启用', dataIndex: 'enabled', width: 80, render: (v: boolean) => <Switch checked={v} size="small" disabled /> },
|
||||
{
|
||||
title: '启用', dataIndex: 'enabled', width: 80, render: (v: boolean, rec: AutomationTask) => <Switch
|
||||
checked={v}
|
||||
size="small"
|
||||
loading={loading && editing?.id === rec.id}
|
||||
onChange={(checked) => toggleEnabled(rec, checked)}
|
||||
/>
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
@@ -115,6 +157,7 @@ const TasksPage = memo(function TasksPage() {
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={fetchList} loading={loading}>刷新</Button>
|
||||
<Button onClick={openQueueModal}>运行中的任务</Button>
|
||||
<Button type="primary" onClick={openCreate}>新建任务</Button>
|
||||
</Space>
|
||||
}
|
||||
@@ -174,6 +217,40 @@ const TasksPage = memo(function TasksPage() {
|
||||
/>
|
||||
</Form>
|
||||
</Drawer>
|
||||
<Modal
|
||||
title="当前任务队列"
|
||||
open={queueModalOpen}
|
||||
onCancel={() => setQueueModalOpen(false)}
|
||||
width={800}
|
||||
footer={[
|
||||
<Button key="refresh" onClick={fetchQueue} loading={queueLoading}>刷新</Button>,
|
||||
<Button key="close" onClick={() => setQueueModalOpen(false)}>关闭</Button>
|
||||
]}
|
||||
>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
dataSource={queuedTasks}
|
||||
loading={queueLoading}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: 'ID', dataIndex: 'id', width: 120, render: (id) => <Typography.Text style={{ fontSize: 12 }} copyable={{ text: id }}>{id.slice(0, 8)}</Typography.Text> },
|
||||
{ title: '任务名', dataIndex: 'name' },
|
||||
{ title: '参数', dataIndex: 'task_info', render: (info) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{JSON.stringify(info)}</Typography.Text> },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 100, render: (status: QueuedTask['status']) => {
|
||||
const colorMap = {
|
||||
pending: 'default',
|
||||
running: 'processing',
|
||||
success: 'success',
|
||||
failed: 'error'
|
||||
};
|
||||
return <Tag color={colorMap[status]}>{status}</Tag>;
|
||||
}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</PageCard>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user