refactor: sync running-container source back to repo for reproducible docker compose build; remove dead code (orphan pipeline.py + 104 stale files from iterative dev) and drop unused app/ mirror dir
This commit is contained in:
1 parent
1346fbbd4b
commit
5d7f32db0a
138 files changed
+12846
-84248
No files matched your search
@@ -1,538 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface KnowledgeBaseItem {
|
||||
id: string;
|
||||
name: string;
|
||||
user_id: string;
|
||||
owner_name?: string;
|
||||
description?: string;
|
||||
directory_path: string;
|
||||
document_count: number;
|
||||
knowledge_graph_count: number;
|
||||
is_official: boolean;
|
||||
status: string;
|
||||
external_service_id?: string;
|
||||
external_id?: string;
|
||||
external_sync_status: string;
|
||||
external_config?: any;
|
||||
total_storage_bytes?: number;
|
||||
total_storage_mb?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
doc_stats?: Record<string, { count: number; size: number }>;
|
||||
graph_stats?: { nodes: number; edges: number };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
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 = () => {
|
||||
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);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterOfficial, setFilterOfficial] = useState('');
|
||||
|
||||
// External Services
|
||||
const [extServices, setExtServices] = useState<ExternalService[]>([]);
|
||||
const [showExtModal, setShowExtModal] = useState(false);
|
||||
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 });
|
||||
|
||||
// Detail drawer
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [selectedKB, setSelectedKB] = useState<KnowledgeBaseItem | null>(null);
|
||||
const [detailDocs, setDetailDocs] = useState<any[]>([]);
|
||||
const [detailDocTotal, setDetailDocTotal] = useState(0);
|
||||
|
||||
// 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();
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchKBs(); }, [page, keyword, filterStatus, filterOfficial]);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try { const res = await adminAPI.getAdminKnowledgeStats(); if (res.code === 0) setStats(res.data); } catch (e) {}
|
||||
};
|
||||
|
||||
const fetchKBs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: pageSize };
|
||||
if (keyword) params.keyword = keyword;
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterOfficial) params.is_official = filterOfficial === 'true';
|
||||
const res = await adminAPI.getAdminKnowledge(params);
|
||||
if (res.code === 0) {
|
||||
setKnowledgeBases(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
}
|
||||
} catch (e) {}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchExtServices = async () => {
|
||||
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.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.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.code === 0) { fetchKBs(); fetchStats(); } } catch (e) {}
|
||||
};
|
||||
|
||||
const handleToggleKB = async (id: string, isActive: boolean) => {
|
||||
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);
|
||||
try {
|
||||
const res = await adminAPI.getAdminKnowledgeDetail(kb.id);
|
||||
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) {}
|
||||
};
|
||||
|
||||
// Document upload
|
||||
const handleFileUpload = async (kbId: string, file: File) => {
|
||||
setUploading(true);
|
||||
setUploadMsg(`正在上传 ${file.name}...`);
|
||||
try {
|
||||
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: any) {
|
||||
setUploadMsg(`❌ 上传失败: ${e.message || '网络错误'}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setTimeout(() => setUploadMsg(''), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Document preview
|
||||
const handlePreviewDoc = async (kbId: string, doc: any) => {
|
||||
setPreviewDoc(doc);
|
||||
setPreviewContent('加载中...');
|
||||
try {
|
||||
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: any) {
|
||||
setPreviewContent(`加载失败: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDoc = async (kbId: string, docId: string) => {
|
||||
if (!confirm('确认删除该文档?')) return;
|
||||
try { const res = await adminAPI.deleteAdminKnowledgeDoc(kbId, docId); if (res.code === 0) { handleViewDetail(selectedKB!); fetchStats(); } } catch (e) {}
|
||||
};
|
||||
|
||||
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 handleDeleteExtService = async (id: string) => {
|
||||
if (!confirm('确认删除该外部知识库服务配置?')) return;
|
||||
try { const res = await adminAPI.deleteAdminKnowledgeExtService(id); if (res.code === 0) fetchExtServices(); } catch (e) {}
|
||||
};
|
||||
|
||||
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) {}
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const map: Record<string, { color: string; text: string }> = {
|
||||
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: '#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: '#9CA3AF', text: '未同步' }, syncing: { color: '#3B82F6', text: '同步中' },
|
||||
synced: { color: '#16A34A', text: '已同步' }, error: { color: '#DC2626', text: '同步错误' },
|
||||
};
|
||||
const s = map[status] || { color: '#9CA3AF', text: status };
|
||||
return <span style={{ fontSize: 11, color: s.color, fontWeight: 500 }}>{s.text}</span>;
|
||||
};
|
||||
|
||||
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={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
<h1 className="admin-page-title">知识库管理</h1>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<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: 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>
|
||||
</select>
|
||||
<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={() => { setEditingKB(null); setKBForm({ name: '', description: '', is_official: false }); setShowModal(true); }}
|
||||
style={btnStyle('#6C63FF')}>+ 创建知识库</button>
|
||||
<button onClick={() => setShowExtModal(true)} style={btnStyle('#8B5CF6')}>外部服务配置</button>
|
||||
</div>
|
||||
|
||||
{/* 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: 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 Modal */}
|
||||
{showModal && (
|
||||
<div style={overlayStyle} onClick={() => setShowModal(false)}>
|
||||
<div style={modalStyle} onClick={e => e.stopPropagation()}>
|
||||
<h3>{editingKB ? '编辑知识库' : '创建知识库'}</h3>
|
||||
<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: 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: 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 })} />
|
||||
官方平台知识库
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Detail Drawer */}
|
||||
{showDetail && selectedKB && (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</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={overlayStyle} onClick={() => setShowExtModal(false)}>
|
||||
<div style={{ ...modalStyle, maxWidth: 650 }} onClick={e => e.stopPropagation()}>
|
||||
<h3>外部知识库服务配置</h3>
|
||||
{extServices.length > 0 && (
|
||||
<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>{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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<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={{ 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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
Reference in new issue
Block a user