fix: resolve response format mismatches causing data loading failures in Dashboard, ImBots, Promotion, Knowledge pages

This commit is contained in:
qyyfw committed 2026-07-07 17:17:41 +08:00
1 parent 839be8f97f
commit e9ec429d21
4 files changed
+385 -461

No files matched your search

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