fix: 恢复 rsync 误删的近期开发(agents/skills/mcp/knowledge/workflow/tenant/billing/storage/privacy/promotion/achievement/automation/code-exec/developer/distribution/im-bot/portal/casdoor 等后端模块 + 对应前端页面路由, 补回 main.py 挂载与 App.tsx 路由

This commit is contained in:
qyyfw committed 2026-07-11 11:40:39 +08:00
1 parent d95b847fee
commit e8e5ffa1a9
65 files changed
+35624 -339

No files matched your search

+538
View File
@@ -0,0 +1,538 @@
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;