Root-cause fix for mixed CRLF/LF in the repo (no .gitattributes previously). Adds '* text=auto eol=lf' and explicit binary rules so all future checkouts are LF. Renormalizes all tracked text files (no logical content change). Backup tags: image qihuaiflow:backup-lineend-20260725-121632, git backup-lineend-pre-20260725-121632.
683 lines
36 KiB
TypeScript
683 lines
36 KiB
TypeScript
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: 'Dify 知识库', service_type: 'dify', api_url: 'https://dify.qihuyun.com/v1', api_key: '', dataset_name: 'qihuai' });
|
||
const [extMsg, setExtMsg] = useState('');
|
||
const [ingesting, setIngesting] = useState(false);
|
||
const [ingestResult, setIngestResult] = useState<any>(null);
|
||
|
||
// AnySearch global config
|
||
const [anysearchForm, setAnysearchForm] = useState<{ api_key: string; is_enabled: boolean }>({ api_key: '', is_enabled: false });
|
||
const [anysearchLoading, setAnysearchLoading] = useState(false);
|
||
const [anysearchMsg, setAnysearchMsg] = useState('');
|
||
|
||
// 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 fetchAnySearchConfig = async () => {
|
||
try {
|
||
const res = await adminAPI.getAdminAnySearchConfig();
|
||
if (res.code === 0) {
|
||
const d = res.data || {};
|
||
setAnysearchForm({ api_key: d.api_key || '', is_enabled: !!d.is_enabled });
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
|
||
const handleSaveAnySearch = async () => {
|
||
setAnysearchLoading(true); setAnysearchMsg('');
|
||
try {
|
||
const res = await adminAPI.saveAdminAnySearchConfig(anysearchForm);
|
||
if (res.code === 0) {
|
||
const conn = res.data?.connection || {};
|
||
setAnysearchMsg(conn.connected ? '✅ 已保存并连接成功' : `⚠️ 已保存但连接失败:${conn.detail || '未知错误'}`);
|
||
} else {
|
||
setAnysearchMsg(`❌ 保存失败:${res.message || '未知错误'}`);
|
||
}
|
||
} catch (e: any) {
|
||
setAnysearchMsg(`❌ 保存失败:${e?.message || '网络错误'}`);
|
||
} finally {
|
||
setAnysearchLoading(false);
|
||
}
|
||
};
|
||
|
||
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 payload = {
|
||
...extForm,
|
||
config: { dataset_name: extForm.dataset_name || 'qihuai' },
|
||
};
|
||
const res = await adminAPI.createAdminKnowledgeExtService(payload);
|
||
if (res.code === 0) {
|
||
setShowExtModal(false);
|
||
setExtForm({ name: 'Dify 知识库', service_type: 'dify', api_url: 'https://dify.qihuyun.com/v1', api_key: '', dataset_name: 'qihuai' });
|
||
setExtMsg('');
|
||
fetchExtServices();
|
||
}
|
||
} catch (e: any) { setExtMsg(`添加失败: ${e?.message || '未知错误'}`); }
|
||
};
|
||
|
||
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) {
|
||
const d = res.data || {};
|
||
const statusText = d.connected ? (d.usable ? '可通讯且可用 ✅' : '可通讯但不可用 ⚠️') : '不可通讯 ❌';
|
||
alert(`连接状态: ${statusText}\n\n详情: ${d.detail || '无'}`);
|
||
fetchExtServices();
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
|
||
const handleIngestPlatform = async () => {
|
||
if (!confirm('将把平台「AI聊天回答 + 用户上传文档」中有价值的内容批量入库到 Dify 知识库(量化后可被 AI 聊天检索复用)。确认执行?')) return;
|
||
setIngesting(true); setIngestResult(null);
|
||
try {
|
||
const token = localStorage.getItem('aiflow_token');
|
||
const res = await fetch(`/app/api/admin/knowledge/external/dify/ingest`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
});
|
||
const data = await res.json();
|
||
if (data.code === 0) {
|
||
setIngestResult(data.data);
|
||
setExtMsg(`✅ ${data.message}`);
|
||
} else {
|
||
setExtMsg(`❌ ${data.message || '入库失败'}`);
|
||
}
|
||
} catch (e: any) {
|
||
setExtMsg(`❌ 入库失败: ${e?.message || '网络错误'}`);
|
||
} finally {
|
||
setIngesting(false);
|
||
}
|
||
};
|
||
|
||
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); fetchAnySearchConfig(); }} 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, width: 720, maxWidth: '92vw' }} onClick={e => e.stopPropagation()}>
|
||
<h3 style={{ margin: '0 0 20px 0', fontSize: 18 }}>外部知识库服务配置</h3>
|
||
|
||
{/* Section 1: Dify 外部服务列表 */}
|
||
<div style={{ marginBottom: 24 }}>
|
||
<h4 style={{ margin: '0 0 12px 0', fontSize: 14, color: '#374151', fontWeight: 600 }}>已接入外部服务</h4>
|
||
{extServices.length > 0 ? (
|
||
<div style={{ border: '1px solid #E5E7EB', borderRadius: 8, overflow: 'hidden' }}>
|
||
<table className="admin-table" style={{ marginBottom: 0, width: '100%' }}>
|
||
<thead><tr><th style={{ textAlign: 'left' }}>名称</th><th style={{ textAlign: 'left' }}>类型</th><th style={{ textAlign: 'left' }}>URL</th><th style={{ textAlign: 'left' }}>状态</th><th style={{ textAlign: 'center', width: 120 }}>操作</th></tr></thead>
|
||
<tbody>
|
||
{extServices.map(svc => (
|
||
<tr key={svc.id}>
|
||
<td>{svc.name}</td>
|
||
<td><span style={{ fontSize: 12, padding: '2px 8px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 4 }}>Dify</span></td>
|
||
<td style={{ fontSize: 12, maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{svc.api_url}</td>
|
||
<td>{statusBadge(svc.status)}</td>
|
||
<td style={{ textAlign: 'center' }}>
|
||
<button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button>
|
||
<button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 6 }}>删除</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div style={{ padding: 16, background: '#F9FAFB', borderRadius: 8, color: '#6B7280', fontSize: 13 }}>暂无外部服务,可在下方添加 Dify 知识库。</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Section 2: Dify 配置表单 */}
|
||
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 20, marginBottom: 24 }}>
|
||
<h4 style={{ margin: '0 0 16px 0', fontSize: 14, color: '#374151', fontWeight: 600 }}>配置 Dify 知识库</h4>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||
<div><label style={labelStyle}>名称</label><input value={extForm.name} onChange={e => setExtForm({ ...extForm, name: e.target.value })} placeholder="如: 生产环境 Dify" style={fullInput} /></div>
|
||
<div><label style={labelStyle}>知识库名称(Dataset)</label><input value={extForm.dataset_name} onChange={e => setExtForm({ ...extForm, dataset_name: e.target.value })} placeholder="如: qihuai" style={fullInput} /></div>
|
||
</div>
|
||
<div style={{ marginTop: 16 }}><label style={labelStyle}>API URL</label><input value={extForm.api_url} onChange={e => setExtForm({ ...extForm, api_url: e.target.value })} placeholder="https://dify.qihuyun.com/v1" style={fullInput} /></div>
|
||
<div style={{ marginTop: 16 }}><label style={labelStyle}>API Key(dataset 密钥)</label><input type="password" value={extForm.api_key} onChange={e => setExtForm({ ...extForm, api_key: e.target.value })} placeholder="dataset-xxxx" style={fullInput} /></div>
|
||
|
||
{extMsg && (
|
||
<div style={{ marginTop: 16, fontSize: 13, color: extMsg.startsWith('✅') ? '#16A34A' : '#DC2626', padding: '8px 12px', background: extMsg.startsWith('✅') ? '#DCFCE7' : '#FEE2E2', borderRadius: 6 }}>
|
||
{extMsg}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: 12, marginTop: 16, alignItems: 'center' }}>
|
||
<button onClick={handleCreateExtService} style={btnStyle('#8B5CF6')}>保存并测试连接</button>
|
||
<button onClick={handleIngestPlatform} disabled={ingesting} style={btnStyle(ingesting ? '#D1D5DB' : '#219B5E')}>
|
||
{ingesting ? '入库中...' : '入库平台内容'}
|
||
</button>
|
||
</div>
|
||
|
||
{ingestResult && (
|
||
<div style={{ marginTop: 16, fontSize: 13, padding: 12, background: '#F0FDF4', border: '1px solid #BBF7D0', borderRadius: 8 }}>
|
||
<div style={{ fontWeight: 600, marginBottom: 6 }}>入库结果</div>
|
||
<div>AI 聊天回答:<b>{ingestResult.chat_answers}</b> 条</div>
|
||
<div>用户上传文档:<b>{ingestResult.user_docs}</b> 条</div>
|
||
<div>跳过(过短/空):{ingestResult.skipped} 条 · 失败:{ingestResult.failed} 条</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Section 3: AnySearch 全局固定配置 */}
|
||
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 20, marginBottom: 8 }}>
|
||
<h4 style={{ margin: '0 0 16px 0', fontSize: 14, color: '#374151', fontWeight: 600 }}>AnySearch 联网检索(全局配置)</h4>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 16, alignItems: 'end' }}>
|
||
<div>
|
||
<label style={labelStyle}>API Key</label>
|
||
<input
|
||
type="password"
|
||
value={anysearchForm.api_key}
|
||
onChange={e => setAnysearchForm({ ...anysearchForm, api_key: e.target.value })}
|
||
placeholder="as_sk_xxxx"
|
||
style={fullInput}
|
||
/>
|
||
</div>
|
||
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0', fontWeight: 500, fontSize: 14, color: '#374151', whiteSpace: 'nowrap' }}>
|
||
<input type="checkbox" checked={anysearchForm.is_enabled} onChange={e => setAnysearchForm({ ...anysearchForm, is_enabled: e.target.checked })} />
|
||
启用联网检索
|
||
</label>
|
||
</div>
|
||
|
||
{anysearchMsg && (
|
||
<div style={{ marginTop: 16, fontSize: 13, color: anysearchMsg.startsWith('✅') ? '#16A34A' : (anysearchMsg.startsWith('⚠️') ? '#D97706' : '#DC2626'), padding: '8px 12px', background: anysearchMsg.startsWith('✅') ? '#DCFCE7' : (anysearchMsg.startsWith('⚠️') ? '#FEF3C7' : '#FEE2E2'), borderRadius: 6 }}>
|
||
{anysearchMsg}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: 12, marginTop: 16, alignItems: 'center' }}>
|
||
<button onClick={handleSaveAnySearch} disabled={anysearchLoading} style={btnStyle(anysearchLoading ? '#D1D5DB' : '#8B5CF6')}>
|
||
{anysearchLoading ? '保存中...' : '保存并测试连接'}
|
||
</button>
|
||
<span style={{ fontSize: 12, color: '#6B7280' }}>配置对平台所有用户生效,AI 聊天将自动注入实时联网结果。</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div style={{ borderTop: '1px solid #E5E7EB', marginTop: 24, paddingTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||
<button onClick={() => setShowExtModal(false)} style={btnStyle('#6B7280')}>关闭</button>
|
||
</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;
|