fix: resolve response format mismatches causing data loading failures in Dashboard, ImBots, Promotion, Knowledge pages
This commit is contained in:
1 parent
839be8f97f
commit
e9ec429d21
4 files changed
+385
-461
No files matched your search
@@ -131,13 +131,24 @@ const Dashboard: React.FC = () => {
|
||||
const [statsRes, dailyRes, logsRes, auditRes] = await Promise.all([
|
||||
adminAPI.getStats() as any,
|
||||
(adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: [] }),
|
||||
adminAPI.getRecentLogs(),
|
||||
adminAPI.getRecentAudit(),
|
||||
adminAPI.getRecentLogs() as any,
|
||||
adminAPI.getRecentAudit() as any,
|
||||
]);
|
||||
if (statsRes.data) setStats(statsRes.data);
|
||||
if (dailyRes.data) setDailyStats(dailyRes.data || []);
|
||||
if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []);
|
||||
if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []);
|
||||
// Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself
|
||||
const statsData = statsRes?.data || statsRes;
|
||||
if (statsData && typeof statsData === 'object') setStats(statsData);
|
||||
|
||||
// Daily stats: backend returns {code, data: {days, data: [...]}} — access nested .data.data
|
||||
const dailyData = dailyRes?.data?.data || (Array.isArray(dailyRes?.data) ? dailyRes.data : []) || [];
|
||||
setDailyStats(Array.isArray(dailyData) ? dailyData : []);
|
||||
|
||||
// Recent logs: backend returns raw list (no wrapper) — use the response directly if it's an array
|
||||
const logsData = Array.isArray(logsRes) ? logsRes : (Array.isArray(logsRes?.data) ? logsRes.data : []);
|
||||
setOperationLogs(logsData);
|
||||
|
||||
// Recent audit: same as logs
|
||||
const auditData = Array.isArray(auditRes) ? auditRes : (Array.isArray(auditRes?.data) ? auditRes.data : []);
|
||||
setAuditLogs(auditData);
|
||||
} catch (err) {
|
||||
console.error('Dashboard数据加载失败:', err);
|
||||
} finally {
|
||||
|
||||
@@ -97,9 +97,9 @@ const ImBots: React.FC = () => {
|
||||
// Load agents for dropdowns
|
||||
const loadAgents = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getAIApps('?type=agent&page_size=100');
|
||||
if (res.data?.success) {
|
||||
setAgents(res.data.data?.items || []);
|
||||
const res = await adminAPI.getAIApps('?type=agent&page_size=100') as any;
|
||||
if (res?.data) {
|
||||
setAgents(res.data?.items || []);
|
||||
}
|
||||
} catch (e) {
|
||||
// silently fail
|
||||
@@ -110,7 +110,7 @@ const ImBots: React.FC = () => {
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getImBotStats();
|
||||
if (res.data?.success) setStats(res.data.data);
|
||||
if ((res as any)?.success) setStats(res.data);
|
||||
} catch (e) {
|
||||
console.error('Stats error:', e);
|
||||
}
|
||||
@@ -120,7 +120,7 @@ const ImBots: React.FC = () => {
|
||||
const loadDashboard = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getImBotDashboard();
|
||||
if (res.data?.success) setDashboard(res.data.data);
|
||||
if ((res as any)?.success) setDashboard(res.data);
|
||||
} catch (e) {
|
||||
console.error('Dashboard error:', e);
|
||||
}
|
||||
@@ -134,9 +134,9 @@ const ImBots: React.FC = () => {
|
||||
if (filterPlatform) endpoint += `&platform=${filterPlatform}`;
|
||||
if (filterStatus) endpoint += `&status=${filterStatus}`;
|
||||
const res = await adminAPI.getImBotChannels(endpoint);
|
||||
if (res.data?.success) {
|
||||
setChannels(res.data.data?.items || []);
|
||||
setChannelsTotal(res.data.data?.total || 0);
|
||||
if ((res as any)?.success) {
|
||||
setChannels(res.data?.items || []);
|
||||
setChannelsTotal(res.data?.total || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Failed to load channels', 'error');
|
||||
@@ -149,7 +149,7 @@ const ImBots: React.FC = () => {
|
||||
const loadRoutes = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getImBotRoutes();
|
||||
if (res.data?.success) setRoutes(res.data.data || []);
|
||||
if ((res as any)?.success) setRoutes(res.data || []);
|
||||
} catch (e) {
|
||||
showToast('Failed to load routes', 'error');
|
||||
}
|
||||
@@ -159,8 +159,8 @@ const ImBots: React.FC = () => {
|
||||
const loadLangbot = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getLangbotConfig();
|
||||
if (res.data?.success) {
|
||||
const configs = res.data.data || [];
|
||||
if ((res as any)?.success) {
|
||||
const configs = res.data || [];
|
||||
setLangbotConfig(configs);
|
||||
if (configs.length > 0) {
|
||||
setLangbotForm({ ...langbotForm, ...configs[0] });
|
||||
@@ -175,9 +175,9 @@ const ImBots: React.FC = () => {
|
||||
const loadMessages = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getImBotMessages(`?page=${messagesPage}&page_size=50`);
|
||||
if (res.data?.success) {
|
||||
setMessages(res.data.data?.items || []);
|
||||
setMessagesTotal(res.data.data?.total || 0);
|
||||
if ((res as any)?.success) {
|
||||
setMessages(res.data?.items || []);
|
||||
setMessagesTotal(res.data?.total || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Failed to load messages', 'error');
|
||||
@@ -188,7 +188,7 @@ const ImBots: React.FC = () => {
|
||||
const loadPlatforms = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getImBotPlatforms();
|
||||
if (res.data?.success) setPlatforms(res.data.data || []);
|
||||
if ((res as any)?.success) setPlatforms(res.data || []);
|
||||
} catch (e) {
|
||||
// silently fail
|
||||
}
|
||||
@@ -222,8 +222,8 @@ const ImBots: React.FC = () => {
|
||||
const handleTestChannel = async (channelId: string) => {
|
||||
try {
|
||||
const res = await adminAPI.testImBotChannel(channelId);
|
||||
if (res.data?.success) {
|
||||
const data = res.data.data;
|
||||
if ((res as any)?.success) {
|
||||
const data = res.data;
|
||||
if (data.passed) {
|
||||
showToast('Channel test passed!');
|
||||
} else {
|
||||
@@ -256,8 +256,8 @@ const ImBots: React.FC = () => {
|
||||
const handleViewChannel = async (channelId: string) => {
|
||||
try {
|
||||
const res = await adminAPI.getImBotChannelDetail(channelId);
|
||||
if (res.data?.success) {
|
||||
setSelectedChannel(res.data.data);
|
||||
if ((res as any)?.success) {
|
||||
setSelectedChannel(res.data);
|
||||
setShowChannelDetail(true);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -269,8 +269,8 @@ const ImBots: React.FC = () => {
|
||||
const handleGetWebhookUrl = async (platform: string) => {
|
||||
try {
|
||||
const res = await adminAPI.getImBotWebhookUrl(platform);
|
||||
if (res.data?.success) {
|
||||
setWebhookInfo(res.data.data);
|
||||
if ((res as any)?.success) {
|
||||
setWebhookInfo(res.data);
|
||||
setShowWebhookModal(true);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -328,8 +328,8 @@ const ImBots: React.FC = () => {
|
||||
if (setupForm.agent_id) params.append('agent_id', setupForm.agent_id);
|
||||
if (setupForm.credentials) params.append('credentials', setupForm.credentials);
|
||||
const res = await adminAPI.imBotQuickSetup('?' + params.toString());
|
||||
if (res.data?.success) {
|
||||
const data = res.data.data;
|
||||
if ((res as any)?.success) {
|
||||
const data = res.data;
|
||||
setWebhookInfo({
|
||||
platform: data.platform,
|
||||
platform_name: data.platform_name,
|
||||
@@ -381,8 +381,8 @@ const ImBots: React.FC = () => {
|
||||
});
|
||||
if (langbotForm.api_key) params.append('api_key', langbotForm.api_key);
|
||||
const res = await adminAPI.testLangbot('?' + params.toString());
|
||||
if (res.data?.success) {
|
||||
const data = res.data.data;
|
||||
if ((res as any)?.success) {
|
||||
const data = res.data;
|
||||
if (data.reachable) {
|
||||
showToast(`LangBot reachable! Status: ${data.status_code}`);
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface KnowledgeBaseItem {
|
||||
@@ -25,44 +25,40 @@ interface KnowledgeBaseItem {
|
||||
}
|
||||
|
||||
interface ExternalService {
|
||||
id: string;
|
||||
name: string;
|
||||
service_type: string;
|
||||
api_url: string;
|
||||
api_key?: string;
|
||||
config?: any;
|
||||
is_enabled: boolean;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string; name: string; service_type: string; api_url: string;
|
||||
api_key?: string; config?: any; is_enabled: boolean; status: string;
|
||||
created_at?: string; updated_at?: string;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_knowledge_bases: number;
|
||||
total_documents: number;
|
||||
total_storage_bytes: number;
|
||||
total_storage_display: string;
|
||||
official_kb: number;
|
||||
user_kb: number;
|
||||
active_kb: number;
|
||||
kb_with_graph: number;
|
||||
external_kb: number;
|
||||
recent_kb_7d: number;
|
||||
total_graph_nodes: number;
|
||||
total_graph_edges: number;
|
||||
total_knowledge_bases: number; total_documents: number;
|
||||
total_storage_bytes: number; total_storage_display: string;
|
||||
official_kb: number; user_kb: number; active_kb: number;
|
||||
kb_with_graph: number; external_kb: number; recent_kb_7d: number;
|
||||
total_graph_nodes: number; total_graph_edges: number;
|
||||
doc_status_distribution: Record<string, number>;
|
||||
file_type_distribution: Record<string, number>;
|
||||
}
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes >= 1024 * 1024 * 1024) return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
||||
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
};
|
||||
|
||||
const Knowledge: React.FC = () => {
|
||||
// Data
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseItem[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(15);
|
||||
|
||||
// Filters
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterOfficial, setFilterOfficial] = useState('');
|
||||
@@ -70,22 +66,12 @@ const Knowledge: React.FC = () => {
|
||||
// External Services
|
||||
const [extServices, setExtServices] = useState<ExternalService[]>([]);
|
||||
const [showExtModal, setShowExtModal] = useState(false);
|
||||
const [editingExtService, setEditingExtService] = useState<ExternalService | null>(null);
|
||||
const [extForm, setExtForm] = useState({
|
||||
name: '',
|
||||
service_type: 'fastgpt',
|
||||
api_url: '',
|
||||
api_key: '',
|
||||
});
|
||||
const [extForm, setExtForm] = useState({ name: '', service_type: 'fastgpt', api_url: '', api_key: '' });
|
||||
|
||||
// KB Modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingKB, setEditingKB] = useState<KnowledgeBaseItem | null>(null);
|
||||
const [kbForm, setKBForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
is_official: false,
|
||||
});
|
||||
const [kbForm, setKBForm] = useState({ name: '', description: '', is_official: false });
|
||||
|
||||
// Detail drawer
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
@@ -93,24 +79,25 @@ const Knowledge: React.FC = () => {
|
||||
const [detailDocs, setDetailDocs] = useState<any[]>([]);
|
||||
const [detailDocTotal, setDetailDocTotal] = useState(0);
|
||||
|
||||
// Loading
|
||||
// Document preview
|
||||
const [previewDoc, setPreviewDoc] = useState<any | null>(null);
|
||||
const [previewContent, setPreviewContent] = useState<string>('');
|
||||
|
||||
// File upload
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadMsg, setUploadMsg] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
fetchKBs();
|
||||
fetchExtServices();
|
||||
fetchStats(); fetchKBs(); fetchExtServices();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchKBs();
|
||||
}, [page, keyword, filterStatus, filterOfficial]);
|
||||
useEffect(() => { fetchKBs(); }, [page, keyword, filterStatus, filterOfficial]);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await adminAPI.getAdminKnowledgeStats();
|
||||
if (res.data?.code === 0) setStats(res.data.data);
|
||||
} catch (e) { console.error(e); }
|
||||
try { const res = await adminAPI.getAdminKnowledgeStats(); if (res.code === 0) setStats(res.data); } catch (e) {}
|
||||
};
|
||||
|
||||
const fetchKBs = async () => {
|
||||
@@ -121,341 +108,259 @@ const Knowledge: React.FC = () => {
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterOfficial) params.is_official = filterOfficial === 'true';
|
||||
const res = await adminAPI.getAdminKnowledge(params);
|
||||
if (res.data?.code === 0) {
|
||||
setKnowledgeBases(res.data.data.items || []);
|
||||
setTotal(res.data.data.total || 0);
|
||||
if (res.code === 0) {
|
||||
setKnowledgeBases(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e) {}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchExtServices = async () => {
|
||||
try {
|
||||
const res = await adminAPI.getAdminKnowledgeExtServices();
|
||||
if (res.data?.code === 0) setExtServices(res.data.data.items || []);
|
||||
} catch (e) { console.error(e); }
|
||||
try { const res = await adminAPI.getAdminKnowledgeExtServices(); if (res.code === 0) setExtServices(res.data.items || []); } catch (e) {}
|
||||
};
|
||||
|
||||
const handleCreateKB = async () => {
|
||||
try {
|
||||
const res = await adminAPI.createAdminKnowledge(kbForm);
|
||||
if (res.data?.code === 0) {
|
||||
setShowModal(false);
|
||||
setKBForm({ name: '', description: '', is_official: false });
|
||||
fetchKBs();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
if (res.code === 0) { setShowModal(false); setKBForm({ name: '', description: '', is_official: false }); fetchKBs(); fetchStats(); }
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const handleUpdateKB = async () => {
|
||||
if (!editingKB) return;
|
||||
try {
|
||||
const res = await adminAPI.updateAdminKnowledge(editingKB.id, kbForm);
|
||||
if (res.data?.code === 0) {
|
||||
setShowModal(false);
|
||||
fetchKBs();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
if (res.code === 0) { setShowModal(false); fetchKBs(); fetchStats(); }
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const handleDeleteKB = async (id: string) => {
|
||||
if (!confirm('确认删除该知识库及其所有文档?此操作不可恢复!')) return;
|
||||
try {
|
||||
const res = await adminAPI.deleteAdminKnowledge(id);
|
||||
if (res.data?.code === 0) {
|
||||
fetchKBs();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
try { const res = await adminAPI.deleteAdminKnowledge(id); if (res.code === 0) { fetchKBs(); fetchStats(); } } catch (e) {}
|
||||
};
|
||||
|
||||
const handleToggleKB = async (id: string, isActive: boolean) => {
|
||||
try {
|
||||
const res = await adminAPI.toggleAdminKnowledge(id, isActive);
|
||||
if (res.data?.code === 0) {
|
||||
fetchKBs();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
try { const res = await adminAPI.toggleAdminKnowledge(id, isActive); if (res.code === 0) { fetchKBs(); fetchStats(); } } catch (e) {}
|
||||
};
|
||||
|
||||
const handleViewDetail = async (kb: KnowledgeBaseItem) => {
|
||||
setSelectedKB(kb);
|
||||
setShowDetail(true);
|
||||
setSelectedKB(kb); setShowDetail(true);
|
||||
try {
|
||||
const res = await adminAPI.getAdminKnowledgeDetail(kb.id);
|
||||
if (res.data?.code === 0) {
|
||||
setSelectedKB(res.data.data);
|
||||
}
|
||||
const docRes = await adminAPI.getAdminKnowledgeDocs(kb.id, { page: 1, page_size: 20 });
|
||||
if (docRes.data?.code === 0) {
|
||||
setDetailDocs(docRes.data.data.items || []);
|
||||
setDetailDocTotal(docRes.data.data.total || 0);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
if (res.code === 0) setSelectedKB(res.data);
|
||||
const docRes = await adminAPI.getAdminKnowledgeDocs(kb.id, { page: 1, page_size: 50 });
|
||||
if (docRes.code === 0) { setDetailDocs(docRes.data.items || []); setDetailDocTotal(docRes.data.total || 0); }
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const handleCreateExtService = async () => {
|
||||
// Document upload
|
||||
const handleFileUpload = async (kbId: string, file: File) => {
|
||||
setUploading(true);
|
||||
setUploadMsg(`正在上传 ${file.name}...`);
|
||||
try {
|
||||
const res = await adminAPI.createAdminKnowledgeExtService(extForm);
|
||||
if (res.data?.code === 0) {
|
||||
setShowExtModal(false);
|
||||
setExtForm({ name: '', service_type: 'fastgpt', api_url: '', api_key: '' });
|
||||
fetchExtServices();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// Use raw fetch since adminAPI may not support FormData
|
||||
const token = localStorage.getItem('aiflow_token');
|
||||
const res = await fetch(`/app/api/admin/knowledge/${kbId}/documents/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
setUploadMsg(`✅ ${file.name} 上传成功!`);
|
||||
handleViewDetail(selectedKB!);
|
||||
fetchStats();
|
||||
} else {
|
||||
setUploadMsg(`❌ 上传失败: ${data.message || '未知错误'}`);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e: any) {
|
||||
setUploadMsg(`❌ 上传失败: ${e.message || '网络错误'}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setTimeout(() => setUploadMsg(''), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteExtService = async (id: string) => {
|
||||
if (!confirm('确认删除该外部知识库服务配置?')) return;
|
||||
// Document preview
|
||||
const handlePreviewDoc = async (kbId: string, doc: any) => {
|
||||
setPreviewDoc(doc);
|
||||
setPreviewContent('加载中...');
|
||||
try {
|
||||
const res = await adminAPI.deleteAdminKnowledgeExtService(id);
|
||||
if (res.data?.code === 0) fetchExtServices();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleCheckExtStatus = async (id: string) => {
|
||||
try {
|
||||
const res = await adminAPI.checkAdminKnowledgeExtStatus(id);
|
||||
if (res.data?.code === 0) {
|
||||
alert(`连接状态: ${res.data.data.connected ? '✅ 已连接' : '❌ 未连接'}\n详情: ${res.data.data.detail || '无'}`);
|
||||
fetchExtServices();
|
||||
const token = localStorage.getItem('aiflow_token');
|
||||
const res = await fetch(`/app/api/admin/knowledge/${kbId}/documents/${doc.id}/content`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
setPreviewContent(data.data?.content || data.data?.text || '(文档内容为空)');
|
||||
} else {
|
||||
setPreviewContent(data.message || '无法加载文档内容');
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e: any) {
|
||||
setPreviewContent(`加载失败: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDoc = async (kbId: string, docId: string) => {
|
||||
if (!confirm('确认删除该文档?')) return;
|
||||
try {
|
||||
const res = await adminAPI.deleteAdminKnowledgeDoc(kbId, docId);
|
||||
if (res.data?.code === 0) {
|
||||
handleViewDetail(selectedKB!);
|
||||
fetchStats();
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
try { const res = await adminAPI.deleteAdminKnowledgeDoc(kbId, docId); if (res.code === 0) { handleViewDetail(selectedKB!); fetchStats(); } } catch (e) {}
|
||||
};
|
||||
|
||||
const openEditModal = (kb: KnowledgeBaseItem) => {
|
||||
setEditingKB(kb);
|
||||
setKBForm({
|
||||
name: kb.name,
|
||||
description: kb.description || '',
|
||||
is_official: kb.is_official,
|
||||
});
|
||||
setShowModal(true);
|
||||
const handleCreateExtService = async () => {
|
||||
try { const res = await adminAPI.createAdminKnowledgeExtService(extForm); if (res.code === 0) { setShowExtModal(false); setExtForm({ name: '', service_type: 'fastgpt', api_url: '', api_key: '' }); fetchExtServices(); } } catch (e) {}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingKB(null);
|
||||
setKBForm({ name: '', description: '', is_official: false });
|
||||
setShowModal(true);
|
||||
const handleDeleteExtService = async (id: string) => {
|
||||
if (!confirm('确认删除该外部知识库服务配置?')) return;
|
||||
try { const res = await adminAPI.deleteAdminKnowledgeExtService(id); if (res.code === 0) fetchExtServices(); } catch (e) {}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const handleCheckExtStatus = async (id: string) => {
|
||||
try { const res = await adminAPI.checkAdminKnowledgeExtStatus(id); if (res.code === 0) { alert(`连接状态: ${res.data.connected ? '已连接' : '未连接'}\n详情: ${res.data.detail || '无'}`); fetchExtServices(); } } catch (e) {}
|
||||
};
|
||||
|
||||
// Status badge
|
||||
const statusBadge = (status: string) => {
|
||||
const map: Record<string, { color: string; text: string }> = {
|
||||
active: { color: '#28a745', text: '活跃' },
|
||||
inactive: { color: '#6c757d', text: '禁用' },
|
||||
archived: { color: '#ffc107', text: '归档' },
|
||||
published: { color: '#28a745', text: '已发布' },
|
||||
pending: { color: '#ffc107', text: '待审核' },
|
||||
active: { color: '#16A34A', text: '活跃' }, inactive: { color: '#6B7280', text: '禁用' },
|
||||
archived: { color: '#F59E0B', text: '归档' }, published: { color: '#16A34A', text: '已发布' },
|
||||
pending: { color: '#F59E0B', text: '待审核' }, completed: { color: '#16A34A', text: '完成' },
|
||||
processing: { color: '#3B82F6', text: '处理中' }, failed: { color: '#DC2626', text: '失败' },
|
||||
uploading: { color: '#3B82F6', text: '上传中' },
|
||||
};
|
||||
const s = map[status] || { color: '#6c757d', text: status };
|
||||
return <span style={{ color: s.color, fontWeight: 600 }}>{s.text}</span>;
|
||||
const s = map[status] || { color: '#6B7280', text: status };
|
||||
return <span style={{ fontSize: 12, padding: '2px 8px', background: s.color + '18', color: s.color, borderRadius: 4, fontWeight: 500 }}>{s.text}</span>;
|
||||
};
|
||||
|
||||
const syncStatusBadge = (status: string) => {
|
||||
const map: Record<string, { color: string; text: string }> = {
|
||||
none: { color: '#adb5bd', text: '未同步' },
|
||||
syncing: { color: '#17a2b8', text: '同步中' },
|
||||
synced: { color: '#28a745', text: '已同步' },
|
||||
error: { color: '#dc3545', text: '同步错误' },
|
||||
none: { color: '#9CA3AF', text: '未同步' }, syncing: { color: '#3B82F6', text: '同步中' },
|
||||
synced: { color: '#16A34A', text: '已同步' }, error: { color: '#DC2626', text: '同步错误' },
|
||||
};
|
||||
const s = map[status] || { color: '#adb5bd', text: status };
|
||||
return <span style={{ color: s.color, fontSize: '0.85em' }}>{s.text}</span>;
|
||||
const s = map[status] || { color: '#9CA3AF', text: status };
|
||||
return <span style={{ fontSize: 11, color: s.color, fontWeight: 500 }}>{s.text}</span>;
|
||||
};
|
||||
|
||||
const extTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = { fastgpt: 'FastGPT', dify: 'Dify', ragflow: 'RAGFlow' };
|
||||
return map[type] || type;
|
||||
};
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const statCardDefs = stats ? [
|
||||
{ label: '知识库总数', value: stats.total_knowledge_bases, color: '#6C63FF' },
|
||||
{ label: '文档总数', value: stats.total_documents, color: '#219B5E' },
|
||||
{ label: '存储占用', value: stats.total_storage_display, color: '#E8922C' },
|
||||
{ label: '官方知识库', value: stats.official_kb, color: '#8B5CF6' },
|
||||
{ label: '含图谱KB', value: stats.kb_with_graph, color: '#3B82F6' },
|
||||
{ label: '外部集成', value: stats.external_kb, color: '#EC4899' },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1400px' }}>
|
||||
<h2 style={{ marginBottom: '24px', fontSize: '1.5em' }}>📚 知识库管理</h2>
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
<h1 className="admin-page-title">知识库管理</h1>
|
||||
|
||||
{/* Stats Dashboard */}
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '12px', marginBottom: '24px' }}>
|
||||
<div style={{ background: '#fff', border: '1px solid #e0e0e0', borderRadius: '8px', padding: '16px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2em', fontWeight: 700, color: '#1890ff' }}>{stats.total_knowledge_bases}</div>
|
||||
<div style={{ fontSize: '0.85em', color: '#666' }}>知识库总数</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', border: '1px solid #e0e0e0', borderRadius: '8px', padding: '16px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2em', fontWeight: 700, color: '#52c41a' }}>{stats.total_documents}</div>
|
||||
<div style={{ fontSize: '0.85em', color: '#666' }}>文档总数</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', border: '1px solid #e0e0e0', borderRadius: '8px', padding: '16px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '1.3em', fontWeight: 700, color: '#faad14' }}>{stats.total_storage_display}</div>
|
||||
<div style={{ fontSize: '0.85em', color: '#666' }}>存储占用</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', border: '1px solid #e0e0e0', borderRadius: '8px', padding: '16px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2em', fontWeight: 700, color: '#722ed1' }}>{stats.official_kb}</div>
|
||||
<div style={{ fontSize: '0.85em', color: '#666' }}>官方知识库</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', border: '1px solid #e0e0e0', borderRadius: '8px', padding: '16px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2em', fontWeight: 700, color: '#13c2c2' }}>{stats.kb_with_graph}</div>
|
||||
<div style={{ fontSize: '0.85em', color: '#666' }}>含图谱KB</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', border: '1px solid #e0e0e0', borderRadius: '8px', padding: '16px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2em', fontWeight: 700, color: '#eb2f96' }}>{stats.external_kb}</div>
|
||||
<div style={{ fontSize: '0.85em', color: '#666' }}>外部集成</div>
|
||||
</div>
|
||||
<div className="admin-stats" style={{ marginBottom: 'var(--space-xl)' }}>
|
||||
{statCardDefs.map((s, i) => (
|
||||
<div key={i} className="admin-stat-card" style={{ padding: 'var(--space-lg)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: s.color, fontFamily: 'var(--font-mono)' }}>
|
||||
{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 4 }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索知识库名称/描述..."
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
style={{ padding: '8px 12px', border: '1px solid #d9d9d9', borderRadius: '4px', width: '200px' }}
|
||||
/>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}
|
||||
style={{ padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px' }}>
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input type="text" placeholder="搜索知识库..." value={keyword} onChange={e => setKeyword(e.target.value)}
|
||||
style={inputStyle} />
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} style={selectStyle}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">活跃</option>
|
||||
<option value="inactive">禁用</option>
|
||||
<option value="archived">归档</option>
|
||||
</select>
|
||||
<select value={filterOfficial} onChange={e => setFilterOfficial(e.target.value)}
|
||||
style={{ padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px' }}>
|
||||
<select value={filterOfficial} onChange={e => setFilterOfficial(e.target.value)} style={selectStyle}>
|
||||
<option value="">全部来源</option>
|
||||
<option value="true">官方</option>
|
||||
<option value="false">用户</option>
|
||||
</select>
|
||||
<button onClick={openCreateModal}
|
||||
style={{ padding: '8px 16px', background: '#1890ff', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
|
||||
+ 创建知识库
|
||||
</button>
|
||||
<button onClick={() => setShowExtModal(true)}
|
||||
style={{ padding: '8px 16px', background: '#722ed1', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
|
||||
🔗 外部服务配置
|
||||
</button>
|
||||
<button onClick={() => { setEditingKB(null); setKBForm({ name: '', description: '', is_official: false }); setShowModal(true); }}
|
||||
style={btnStyle('#6C63FF')}>+ 创建知识库</button>
|
||||
<button onClick={() => setShowExtModal(true)} style={btnStyle('#8B5CF6')}>外部服务配置</button>
|
||||
</div>
|
||||
|
||||
{/* Knowledge Base Table */}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: '8px' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f5f5f5', borderBottom: '2px solid #e0e0e0' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>名称</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>来源</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>文档数</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>存储</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>状态</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>同步</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>创建时间</th>
|
||||
<th style={{ padding: '12px', textAlign: 'center' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', padding: '24px' }}>加载中...</td></tr>
|
||||
) : knowledgeBases.length === 0 ? (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', padding: '24px', color: '#999' }}>暂无知识库数据</td></tr>
|
||||
) : knowledgeBases.map(kb => (
|
||||
<tr key={kb.id} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<div style={{ fontWeight: 600 }}>{kb.name}</div>
|
||||
<div style={{ fontSize: '0.8em', color: '#999' }}>{kb.description?.substring(0, 50)}</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{kb.is_official ? (
|
||||
<span style={{ color: '#722ed1', fontWeight: 600 }}>⭐ 官方</span>
|
||||
) : (
|
||||
<span style={{ color: '#666' }}>👤 {kb.owner_name || '用户'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>{kb.document_count}</td>
|
||||
<td style={{ padding: '12px' }}>{kb.total_storage_mb || 0} MB</td>
|
||||
<td style={{ padding: '12px' }}>{statusBadge(kb.status)}</td>
|
||||
<td style={{ padding: '12px' }}>{syncStatusBadge(kb.external_sync_status)}</td>
|
||||
<td style={{ padding: '12px', fontSize: '0.85em' }}>{kb.created_at?.substring(0, 10)}</td>
|
||||
<td style={{ padding: '12px', textAlign: 'center' }}>
|
||||
<button onClick={() => handleViewDetail(kb)}
|
||||
style={{ padding: '4px 8px', margin: '2px', background: '#1890ff', color: '#fff', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.85em' }}>
|
||||
详情
|
||||
</button>
|
||||
<button onClick={() => openEditModal(kb)}
|
||||
style={{ padding: '4px 8px', margin: '2px', background: '#faad14', color: '#fff', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.85em' }}>
|
||||
编辑
|
||||
</button>
|
||||
<button onClick={() => handleToggleKB(kb.id, kb.status !== 'active')}
|
||||
style={{ padding: '4px 8px', margin: '2px', background: kb.status === 'active' ? '#ff4d4f' : '#52c41a', color: '#fff', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.85em' }}>
|
||||
{kb.status === 'active' ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteKB(kb.id)}
|
||||
style={{ padding: '4px 8px', margin: '2px', background: '#ff4d4f', color: '#fff', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.85em' }}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Table */}
|
||||
<div className="admin-card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table" style={{ marginBottom: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th><th>来源</th><th>文档数</th><th>存储</th><th>状态</th><th>同步</th><th>创建时间</th><th style={{ textAlign: 'center' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 40 }}>加载中...</td></tr>
|
||||
) : knowledgeBases.length === 0 ? (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 40, color: '#9CA3AF' }}>暂无知识库数据</td></tr>
|
||||
) : knowledgeBases.map(kb => (
|
||||
<tr key={kb.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{kb.name}</div>
|
||||
{kb.description && <div style={{ fontSize: 11, color: '#9CA3AF', marginTop: 2 }}>{kb.description.substring(0, 60)}</div>}
|
||||
</td>
|
||||
<td>{kb.is_official ? <span style={{ color: '#8B5CF6', fontWeight: 600 }}>官方</span> : <span style={{ color: '#6B7280' }}>{kb.owner_name || '用户'}</span>}</td>
|
||||
<td style={{ fontWeight: 600 }}>{kb.document_count}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{kb.total_storage_mb || 0} MB</td>
|
||||
<td>{statusBadge(kb.status)}</td>
|
||||
<td>{syncStatusBadge(kb.external_sync_status)}</td>
|
||||
<td style={{ fontSize: 12, color: '#9CA3AF' }}>{kb.created_at?.substring(0, 10)}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<button onClick={() => handleViewDetail(kb)} style={btnStyle('#3B82F6', 'small')}>详情</button>
|
||||
<button onClick={() => { setEditingKB(kb); setKBForm({ name: kb.name, description: kb.description || '', is_official: kb.is_official }); setShowModal(true); }} style={{ ...btnStyle('#F59E0B', 'small'), marginLeft: 4 }}>编辑</button>
|
||||
<button onClick={() => handleToggleKB(kb.id, kb.status !== 'active')} style={{ ...btnStyle(kb.status === 'active' ? '#DC2626' : '#16A34A', 'small'), marginLeft: 4 }}>{kb.status === 'active' ? '禁用' : '启用'}</button>
|
||||
<button onClick={() => handleDeleteKB(kb.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
|
||||
<button disabled={page <= 1} onClick={() => setPage(page - 1)}
|
||||
style={{ padding: '6px 12px', border: '1px solid #d9d9d9', borderRadius: '4px', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>
|
||||
上一页
|
||||
</button>
|
||||
<span style={{ padding: '6px 12px' }}>第 {page} / {totalPages} 页 (共 {total} 条)</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}
|
||||
style={{ padding: '6px 12px', border: '1px solid #d9d9d9', borderRadius: '4px', cursor: page >= totalPages ? 'not-allowed' : 'pointer' }}>
|
||||
下一页
|
||||
</button>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} style={btnStyle(page <= 1 ? '#D1D5DB' : '#6C63FF', 'small')}>上一页</button>
|
||||
<span style={{ fontSize: 13, color: '#6B7280' }}>第 {page}/{totalPages} 页 (共 {total} 条)</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)} style={btnStyle(page >= totalPages ? '#D1D5DB' : '#6C63FF', 'small')}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KB Create/Edit Modal */}
|
||||
{/* KB Modal */}
|
||||
{showModal && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<div style={{ background: '#fff', borderRadius: '8px', padding: '24px', width: '480px', maxWidth: '90vw' }}>
|
||||
<div style={overlayStyle} onClick={() => setShowModal(false)}>
|
||||
<div style={modalStyle} onClick={e => e.stopPropagation()}>
|
||||
<h3>{editingKB ? '编辑知识库' : '创建知识库'}</h3>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 600 }}>名称</label>
|
||||
<input value={kbForm.name} onChange={e => setKBForm({ ...kbForm, name: e.target.value })}
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px' }} />
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>名称</label>
|
||||
<input value={kbForm.name} onChange={e => setKBForm({ ...kbForm, name: e.target.value })} style={fullInput} />
|
||||
</div>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 600 }}>描述</label>
|
||||
<textarea value={kbForm.description} onChange={e => setKBForm({ ...kbForm, description: e.target.value })}
|
||||
rows={3} style={{ width: '100%', padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px' }} />
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>描述</label>
|
||||
<textarea value={kbForm.description} onChange={e => setKBForm({ ...kbForm, description: e.target.value })} rows={3} style={fullInput} />
|
||||
</div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={kbForm.is_official} onChange={e => setKBForm({ ...kbForm, is_official: e.target.checked })} />
|
||||
<span style={{ marginLeft: '8px' }}>官方平台知识库</span>
|
||||
官方平台知识库
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button onClick={editingKB ? handleUpdateKB : handleCreateKB}
|
||||
style={{ padding: '8px 24px', background: '#1890ff', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
|
||||
{editingKB ? '保存' : '创建'}
|
||||
</button>
|
||||
<button onClick={() => setShowModal(false)}
|
||||
style={{ padding: '8px 24px', background: '#f5f5f5', border: '1px solid #d9d9d9', borderRadius: '4px', cursor: 'pointer' }}>
|
||||
取消
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button onClick={editingKB ? handleUpdateKB : handleCreateKB} style={btnStyle('#6C63FF')}>{editingKB ? '保存' : '创建'}</button>
|
||||
<button onClick={() => setShowModal(false)} style={btnStyle('#6B7280')}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,109 +368,131 @@ const Knowledge: React.FC = () => {
|
||||
|
||||
{/* Detail Drawer */}
|
||||
{showDetail && selectedKB && (
|
||||
<div style={{ position: 'fixed', top: 0, right: 0, width: '480px', height: '100%', background: '#fff', boxShadow: '-2px 0 8px rgba(0,0,0,0.1)', zIndex: 1000, padding: '24px', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<h3>{selectedKB.name}</h3>
|
||||
<button onClick={() => setShowDetail(false)} style={{ background: 'none', border: 'none', fontSize: '1.5em', cursor: 'pointer' }}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px', padding: '12px', background: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<div>来源: {selectedKB.is_official ? '⭐ 官方' : `👤 ${selectedKB.owner_name || '用户'}`}</div>
|
||||
<div>描述: {selectedKB.description || '无'}</div>
|
||||
<div>状态: {statusBadge(selectedKB.status)}</div>
|
||||
<div>文档数: {selectedKB.document_count}</div>
|
||||
<div>存储: {selectedKB.total_storage_mb || 0} MB</div>
|
||||
</div>
|
||||
|
||||
{/* Graph Stats */}
|
||||
{selectedKB.graph_stats && (
|
||||
<div style={{ marginBottom: '16px', padding: '12px', background: '#e6f7ff', borderRadius: '8px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0' }}>知识图谱</h4>
|
||||
<div>节点数: {selectedKB.graph_stats.nodes}</div>
|
||||
<div>边数: {selectedKB.graph_stats.edges}</div>
|
||||
<div style={overlayStyle} onClick={() => setShowDetail(false)}>
|
||||
<div style={{ ...drawerStyle }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>{selectedKB.name}</h3>
|
||||
<button onClick={() => setShowDetail(false)} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#9CA3AF' }}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Integration */}
|
||||
{selectedKB.external_service_id && (
|
||||
<div style={{ marginBottom: '16px', padding: '12px', background: '#f9f0ff', borderRadius: '8px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0' }}>外部集成</h4>
|
||||
<div>同步状态: {syncStatusBadge(selectedKB.external_sync_status)}</div>
|
||||
<div>外部ID: {selectedKB.external_id || '未关联'}</div>
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#F9FAFB', borderRadius: 8, fontSize: 13 }}>
|
||||
<div>来源: {selectedKB.is_official ? '官方' : selectedKB.owner_name || '用户'}</div>
|
||||
{selectedKB.description && <div style={{ marginTop: 4 }}>描述: {selectedKB.description}</div>}
|
||||
<div style={{ marginTop: 4 }}>状态: {statusBadge(selectedKB.status)} | 文档: {selectedKB.document_count} | 存储: {selectedKB.total_storage_mb || 0} MB</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents */}
|
||||
<h4 style={{ margin: '16px 0 8px 0' }}>文档列表 ({detailDocTotal})</h4>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f5f5f5' }}>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>文件名</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>类型</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>大小</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>状态</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailDocs.map(doc => (
|
||||
<tr key={doc.id} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<td style={{ padding: '8px' }}>{doc.filename}</td>
|
||||
<td style={{ padding: '8px' }}>{doc.file_type}</td>
|
||||
<td style={{ padding: '8px' }}>{doc.file_size > 1024 * 1024 ? `${(doc.file_size / 1024 / 1024).toFixed(1)} MB` : `${(doc.file_size / 1024).toFixed(1)} KB`}</td>
|
||||
<td style={{ padding: '8px' }}>{statusBadge(doc.status)}</td>
|
||||
<td style={{ padding: '8px', textAlign: 'center' }}>
|
||||
<button onClick={() => handleDeleteDoc(selectedKB.id, doc.id)}
|
||||
style={{ padding: '4px 8px', background: '#ff4d4f', color: '#fff', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.85em' }}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{detailDocs.length === 0 && (
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', padding: '12px', color: '#999' }}>暂无文档</td></tr>
|
||||
{/* Upload */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input type="file" ref={fileInputRef} style={{ display: 'none' }}
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileUpload(selectedKB.id, f); e.target.value = ''; }}
|
||||
accept=".pdf,.docx,.doc,.txt,.md,.csv,.json,.xml" />
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
|
||||
style={btnStyle(uploading ? '#D1D5DB' : '#219B5E')}>
|
||||
<SVGIcon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></SVGIcon>
|
||||
{' '}{uploading ? '上传中...' : '上传文档'}
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>支持 PDF / DOCX / TXT / MD / CSV / JSON</span>
|
||||
</div>
|
||||
{uploadMsg && (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: uploadMsg.startsWith('✅') ? '#16A34A' : '#DC2626', padding: '6px 12px', background: uploadMsg.startsWith('✅') ? '#DCFCE7' : '#FEE2E2', borderRadius: 6 }}>
|
||||
{uploadMsg}
|
||||
</div>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Graph Stats */}
|
||||
{selectedKB.graph_stats && (selectedKB.graph_stats.nodes > 0 || selectedKB.graph_stats.edges > 0) && (
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#EEF2FF', borderRadius: 8 }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>知识图谱</h4>
|
||||
<div style={{ fontSize: 13 }}>节点数: {selectedKB.graph_stats.nodes} | 边数: {selectedKB.graph_stats.edges}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Integration */}
|
||||
{selectedKB.external_service_id && (
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#F5F3FF', borderRadius: 8, fontSize: 13 }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>外部集成</h4>
|
||||
<div>同步状态: {syncStatusBadge(selectedKB.external_sync_status)}</div>
|
||||
<div>外部ID: {selectedKB.external_id || '未关联'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents */}
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>文档列表 ({detailDocTotal})</h4>
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
|
||||
<table className="admin-table" style={{ marginBottom: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th><th>类型</th><th>大小</th><th>状态</th><th style={{ textAlign: 'center' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailDocs.map(doc => (
|
||||
<tr key={doc.id}>
|
||||
<td style={{ fontSize: 12 }}>{doc.filename || doc.file_name}</td>
|
||||
<td style={{ fontSize: 11 }}><span style={{ padding: '1px 6px', background: '#F3F4F6', borderRadius: 3 }}>{doc.file_type || '-'}</span></td>
|
||||
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)' }}>{formatSize(doc.file_size || 0)}</td>
|
||||
<td>{statusBadge(doc.status)}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<button onClick={() => handlePreviewDoc(selectedKB.id, doc)} style={btnStyle('#3B82F6', 'small')}>预览</button>
|
||||
<button onClick={() => handleDeleteDoc(selectedKB.id, doc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{detailDocs.length === 0 && (
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', padding: 24, color: '#9CA3AF' }}>暂无文档,请上传</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Preview Modal */}
|
||||
{previewDoc && (
|
||||
<div style={overlayStyle} onClick={() => { setPreviewDoc(null); setPreviewContent(''); }}>
|
||||
<div style={{ ...drawerStyle, maxWidth: '80vw' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>{previewDoc.filename || previewDoc.file_name}</h3>
|
||||
<button onClick={() => { setPreviewDoc(null); setPreviewContent(''); }} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#9CA3AF' }}>✕</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6B7280', marginBottom: 12 }}>
|
||||
类型: {previewDoc.file_type || '-'} | 大小: {formatSize(previewDoc.file_size || 0)}
|
||||
</div>
|
||||
<div style={{
|
||||
maxHeight: '60vh', overflowY: 'auto', background: '#F9FAFB',
|
||||
borderRadius: 8, padding: 16, fontSize: 13, lineHeight: 1.7,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
fontFamily: 'var(--font-mono)', color: '#374151',
|
||||
border: '1px solid #E5E7EB',
|
||||
}}>
|
||||
{previewContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Service Modal */}
|
||||
{showExtModal && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<div style={{ background: '#fff', borderRadius: '8px', padding: '24px', width: '600px', maxWidth: '90vw' }}>
|
||||
<h3>🔗 外部知识库服务配置</h3>
|
||||
|
||||
{/* Existing Services */}
|
||||
<div style={overlayStyle} onClick={() => setShowExtModal(false)}>
|
||||
<div style={{ ...modalStyle, maxWidth: 650 }} onClick={e => e.stopPropagation()}>
|
||||
<h3>外部知识库服务配置</h3>
|
||||
{extServices.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4>已配置的服务</h4>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f5f5f5' }}>
|
||||
<th style={{ padding: '8px' }}>名称</th>
|
||||
<th style={{ padding: '8px' }}>类型</th>
|
||||
<th style={{ padding: '8px' }}>URL</th>
|
||||
<th style={{ padding: '8px' }}>状态</th>
|
||||
<th style={{ padding: '8px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<table className="admin-table" style={{ marginBottom: 0 }}>
|
||||
<thead><tr><th>名称</th><th>类型</th><th>URL</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{extServices.map(svc => (
|
||||
<tr key={svc.id}>
|
||||
<td style={{ padding: '8px' }}>{svc.name}</td>
|
||||
<td style={{ padding: '8px' }}>{extTypeLabel(svc.service_type)}</td>
|
||||
<td style={{ padding: '8px', fontSize: '0.8em' }}>{svc.api_url}</td>
|
||||
<td style={{ padding: '8px' }}>{statusBadge(svc.status)}</td>
|
||||
<td style={{ padding: '8px' }}>
|
||||
<button onClick={() => handleCheckExtStatus(svc.id)}
|
||||
style={{ padding: '4px 8px', background: '#1890ff', color: '#fff', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.85em' }}>
|
||||
检测
|
||||
</button>
|
||||
<button onClick={() => handleDeleteExtService(svc.id)}
|
||||
style={{ padding: '4px 8px', background: '#ff4d4f', color: '#fff', border: 'none', borderRadius: '3px', cursor: 'pointer', fontSize: '0.85em' }}>
|
||||
删除
|
||||
</button>
|
||||
<td>{svc.name}</td>
|
||||
<td><span style={{ fontSize: 12, padding: '2px 6px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 3 }}>{svc.service_type === 'fastgpt' ? 'FastGPT' : svc.service_type === 'dify' ? 'Dify' : 'RAGFlow'}</span></td>
|
||||
<td style={{ fontSize: 11, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>{svc.api_url}</td>
|
||||
<td>{statusBadge(svc.status)}</td>
|
||||
<td>
|
||||
<button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button>
|
||||
<button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -573,46 +500,17 @@ const Knowledge: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Service Form */}
|
||||
<div style={{ borderTop: '1px solid #e0e0e0', paddingTop: '16px' }}>
|
||||
<h4>添加新服务</h4>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 600 }}>服务名称</label>
|
||||
<input value={extForm.name} onChange={e => setExtForm({ ...extForm, name: e.target.value })}
|
||||
placeholder="如: 生产环境 FastGPT"
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px' }} />
|
||||
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 16 }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: 14 }}>添加新服务</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div><label style={labelStyle}>名称</label><input value={extForm.name} onChange={e => setExtForm({ ...extForm, name: e.target.value })} placeholder="如: 生产环境 FastGPT" style={fullInput} /></div>
|
||||
<div><label style={labelStyle}>类型</label><select value={extForm.service_type} onChange={e => setExtForm({ ...extForm, service_type: e.target.value })} style={fullInput}><option value="fastgpt">FastGPT</option><option value="dify">Dify</option><option value="ragflow">RAGFlow</option></select></div>
|
||||
</div>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 600 }}>服务类型</label>
|
||||
<select value={extForm.service_type} onChange={e => setExtForm({ ...extForm, service_type: e.target.value })}
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px' }}>
|
||||
<option value="fastgpt">FastGPT - 开源 AI 知识库平台</option>
|
||||
<option value="dify">Dify - LLM 应用开发平台</option>
|
||||
<option value="ragflow">RAGFlow - 基于深度文档理解的 RAG 引擎</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 600 }}>API URL</label>
|
||||
<input value={extForm.api_url} onChange={e => setExtForm({ ...extForm, api_url: e.target.value })}
|
||||
placeholder="https://your-fastgpt-instance.com/api/v1"
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px' }} />
|
||||
</div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 600 }}>API Key</label>
|
||||
<input type="password" value={extForm.api_key} onChange={e => setExtForm({ ...extForm, api_key: e.target.value })}
|
||||
placeholder="Bearer Token 或 API Key"
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #d9d9d9', borderRadius: '4px' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button onClick={handleCreateExtService}
|
||||
style={{ padding: '8px 24px', background: '#722ed1', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
|
||||
添加服务
|
||||
</button>
|
||||
<button onClick={() => setShowExtModal(false)}
|
||||
style={{ padding: '8px 24px', background: '#f5f5f5', border: '1px solid #d9d9d9', borderRadius: '4px', cursor: 'pointer' }}>
|
||||
关闭
|
||||
</button>
|
||||
<div style={{ marginTop: 12 }}><label style={labelStyle}>API URL</label><input value={extForm.api_url} onChange={e => setExtForm({ ...extForm, api_url: e.target.value })} placeholder="https://your-instance.com/api/v1" style={fullInput} /></div>
|
||||
<div style={{ marginTop: 12 }}><label style={labelStyle}>API Key</label><input type="password" value={extForm.api_key} onChange={e => setExtForm({ ...extForm, api_key: e.target.value })} placeholder="Bearer Token 或 API Key" style={fullInput} /></div>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
|
||||
<button onClick={handleCreateExtService} style={btnStyle('#8B5CF6')}>添加服务</button>
|
||||
<button onClick={() => setShowExtModal(false)} style={btnStyle('#6B7280')}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -622,4 +520,19 @@ const Knowledge: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Shared styles
|
||||
const inputStyle: React.CSSProperties = { padding: '8px 12px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, width: 200, boxSizing: 'border-box' };
|
||||
const selectStyle: React.CSSProperties = { padding: '8px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, background: '#fff' };
|
||||
const fullInput: React.CSSProperties = { width: '100%', padding: '8px 12px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' };
|
||||
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' };
|
||||
const overlayStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
|
||||
const modalStyle: React.CSSProperties = { background: '#fff', borderRadius: 12, padding: 24, width: 500, maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto' };
|
||||
const drawerStyle: React.CSSProperties = { background: '#fff', borderRadius: 12, padding: 24, width: 560, maxWidth: '90vw', maxHeight: '85vh', overflow: 'auto' };
|
||||
|
||||
const btnStyle = (color: string, size: 'normal' | 'small' = 'normal'): React.CSSProperties => ({
|
||||
background: color, color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||
fontSize: size === 'small' ? 12 : 14, padding: size === 'small' ? '4px 10px' : '8px 16px',
|
||||
fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export default Knowledge;
|
||||
@@ -102,7 +102,7 @@ const Promotion: React.FC = () => {
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getPromotionStats();
|
||||
if (res.success) {
|
||||
if (res.data) {
|
||||
setStats(res.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -115,7 +115,7 @@ const Promotion: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getPromotions({ page, page_size: 20 });
|
||||
if (res.success) {
|
||||
if (res.data) {
|
||||
setPromotions(res.data.items);
|
||||
setTotalPromos(res.data.total);
|
||||
setPromoPage(page);
|
||||
@@ -132,7 +132,7 @@ const Promotion: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getReferralLinks({ page, page_size: 20 });
|
||||
if (res.success) {
|
||||
if (res.data) {
|
||||
setReferralLinks(res.data.items);
|
||||
setTotalLinks(res.data.total);
|
||||
setLinkPage(page);
|
||||
@@ -149,7 +149,7 @@ const Promotion: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getReferralRewards({ page, page_size: 20 });
|
||||
if (res.success) {
|
||||
if (res.data) {
|
||||
setRewards(res.data.items);
|
||||
setTotalRewards(res.data.total);
|
||||
setRewardPage(page);
|
||||
@@ -165,8 +165,8 @@ const Promotion: React.FC = () => {
|
||||
const loadLeaderboard = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getPromotionLeaderboard({ limit: 50 });
|
||||
if (res.success) {
|
||||
setLeaderboard(res.data.leaderboard);
|
||||
if (res.data) {
|
||||
setLeaderboard(res.data.items || res.data.leaderboard || []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load leaderboard:', err);
|
||||
@@ -813,7 +813,7 @@ const LinkModal: React.FC<LinkModalProps> = ({ onSave, onCancel }) => {
|
||||
useEffect(() => {
|
||||
adminAPI.getPromotions({ status: 'active', page: 1, page_size: 100 })
|
||||
.then(res => {
|
||||
if (res.success) setPromotions(res.data.items);
|
||||
if (res.data) setPromotions(res.data.items);
|
||||
})
|
||||
.catch(err => console.error('Failed to load active promotions:', err));
|
||||
}, []);
|
||||
|
||||
Reference in new issue
Block a user