1211 lines
44 KiB
TypeScript
1211 lines
44 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { knowledgeAPI } from '@/api';
|
||
import './KnowledgePage.css';
|
||
|
||
// ===== SVG Icons =====
|
||
const UploadIcon = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<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"/>
|
||
</svg>
|
||
);
|
||
|
||
const ChevronDownIcon = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="6 9 12 15 18 9"/>
|
||
</svg>
|
||
);
|
||
|
||
const FileIcon = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||
</svg>
|
||
);
|
||
|
||
const FolderIcon = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||
</svg>
|
||
);
|
||
|
||
const GraphIcon = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="6" cy="6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/><circle cx="18" cy="8" r="1.5" fill="currentColor"/>
|
||
<line x1="6" y1="7.5" x2="12" y2="10.5"/><line x1="12" y1="13.5" x2="18" y2="9.5"/>
|
||
<circle cx="9" cy="18" r="1.5" fill="currentColor"/>
|
||
<line x1="18" y1="9.5" x2="9" y2="16.5"/><line x1="6" y1="7.5" x2="9" y2="16.5"/>
|
||
</svg>
|
||
);
|
||
|
||
const ProcessingIcon = () => (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="spin-icon">
|
||
<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>
|
||
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/>
|
||
<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>
|
||
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>
|
||
</svg>
|
||
);
|
||
|
||
const CheckIcon = () => (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
);
|
||
|
||
const CloseIcon = () => (
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
);
|
||
|
||
const AddIcon = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||
</svg>
|
||
);
|
||
|
||
const TrashIcon = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>
|
||
</svg>
|
||
);
|
||
|
||
const AlertTriangleIcon = () => (
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||
</svg>
|
||
);
|
||
|
||
// ===== Helpers =====
|
||
function formatFileSize(bytes: number): string {
|
||
if (!bytes || bytes === 0) return '-';
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
}
|
||
|
||
function formatDate(dateStr: string): string {
|
||
if (!dateStr) return '-';
|
||
const d = new Date(dateStr);
|
||
return d.toLocaleDateString('zh-CN', {
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function getTypeLabel(fileType: string): string {
|
||
const types: Record<string, string> = {
|
||
pdf: 'PDF', docx: 'Word', doc: 'Word', xlsx: 'Excel', xls: 'Excel',
|
||
csv: 'CSV', md: 'Markdown', txt: '文本', pptx: 'PPT', ppt: 'PPT',
|
||
};
|
||
return types[fileType] || fileType.toUpperCase();
|
||
}
|
||
|
||
// ===== Types =====
|
||
interface GraphNode {
|
||
id: string;
|
||
label: string;
|
||
type: string;
|
||
size: number;
|
||
group: string;
|
||
}
|
||
|
||
interface GraphEdge {
|
||
source: string;
|
||
target: string;
|
||
label?: string;
|
||
weight: number;
|
||
}
|
||
|
||
interface GraphData {
|
||
nodes: GraphNode[];
|
||
edges: GraphEdge[];
|
||
stats: {
|
||
total_nodes: number;
|
||
total_edges: number;
|
||
document_count: number;
|
||
entity_types?: Record<string, number>;
|
||
};
|
||
}
|
||
|
||
interface KnowledgeBase {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
document_count: number;
|
||
knowledge_graph_count?: number;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
interface KnowledgeDoc {
|
||
id: string;
|
||
base_id?: string;
|
||
knowledge_base_id?: string;
|
||
filename: string;
|
||
file_type: string;
|
||
file_size: number;
|
||
status: string;
|
||
chunk_count: number;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
// ===== Color helpers for graph nodes =====
|
||
const GROUP_COLORS: Record<string, string> = {
|
||
document: '#4A90D9',
|
||
concept: '#7B68EE',
|
||
entity: '#50C878',
|
||
attribute: '#FF6347',
|
||
relation: '#FFD700',
|
||
};
|
||
|
||
// ===== Knowledge Graph Visualization =====
|
||
const KnowledgeGraphView: React.FC<{ graphData: GraphData; onClose: () => void }> = ({ graphData, onClose }) => {
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const animationRef = useRef<number>(0);
|
||
|
||
interface LayoutNode extends GraphNode {
|
||
x: number;
|
||
y: number;
|
||
vx: number;
|
||
vy: number;
|
||
}
|
||
|
||
const layoutNodesRef = useRef<LayoutNode[]>([]);
|
||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null);
|
||
const dragStartRef = useRef<{ x: number; y: number; nodeX: number; nodeY: number } | null>(null);
|
||
const hoveredNodeRef = useRef<string | null>(null);
|
||
const [tooltip, setTooltip] = useState<{ x: number; y: number; label: string; type: string; group: string } | null>(null);
|
||
|
||
const initLayout = useCallback(() => {
|
||
const { nodes, edges } = graphData;
|
||
const centerX = 400;
|
||
const centerY = 300;
|
||
|
||
const layoutNodes: LayoutNode[] = nodes.map((node, i) => {
|
||
if (node.type === 'document') {
|
||
const angle = (2 * Math.PI * i) / Math.max(nodes.filter(n => n.type === 'document').length, 1);
|
||
const radius = 120;
|
||
return { ...node, x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle), vx: 0, vy: 0 };
|
||
}
|
||
return { ...node, x: centerX + (Math.random() - 0.5) * 400, y: centerY + (Math.random() - 0.5) * 400, vx: 0, vy: 0 };
|
||
});
|
||
|
||
// Force simulation
|
||
const nodeMap = new Map<string, LayoutNode>();
|
||
layoutNodes.forEach(n => nodeMap.set(n.id, n));
|
||
|
||
const simulationSteps = 100;
|
||
for (let step = 0; step < simulationSteps; step++) {
|
||
const alpha = 1 - step / simulationSteps;
|
||
const repulsionStrength = 500 * alpha;
|
||
const attractionStrength = 0.01 * alpha;
|
||
const centeringStrength = 0.005 * alpha;
|
||
|
||
for (let i = 0; i < layoutNodes.length; i++) {
|
||
for (let j = i + 1; j < layoutNodes.length; j++) {
|
||
const dx = layoutNodes[j].x - layoutNodes[i].x;
|
||
const dy = layoutNodes[j].y - layoutNodes[i].y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const force = repulsionStrength / (dist * dist);
|
||
const fx = (dx / dist) * force;
|
||
const fy = (dy / dist) * force;
|
||
layoutNodes[i].vx -= fx;
|
||
layoutNodes[i].vy -= fy;
|
||
layoutNodes[j].vx += fx;
|
||
layoutNodes[j].vy += fy;
|
||
}
|
||
}
|
||
|
||
edges.forEach(edge => {
|
||
const src = nodeMap.get(edge.source);
|
||
const tgt = nodeMap.get(edge.target);
|
||
if (src && tgt) {
|
||
const dx = tgt.x - src.x;
|
||
const dy = tgt.y - src.y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const force = dist * attractionStrength;
|
||
const fx = (dx / dist) * force;
|
||
const fy = (dy / dist) * force;
|
||
src.vx += fx;
|
||
src.vy += fy;
|
||
tgt.vx -= fx;
|
||
tgt.vy -= fy;
|
||
}
|
||
});
|
||
|
||
layoutNodes.forEach(n => {
|
||
n.vx += (centerX - n.x) * centeringStrength;
|
||
n.vy += (centerY - n.y) * centeringStrength;
|
||
});
|
||
|
||
layoutNodes.forEach(n => {
|
||
n.vx *= 0.6;
|
||
n.vy *= 0.6;
|
||
n.x += n.vx;
|
||
n.y += n.vy;
|
||
n.vx = 0;
|
||
n.vy = 0;
|
||
});
|
||
}
|
||
|
||
layoutNodesRef.current = layoutNodes;
|
||
}, [graphData]);
|
||
|
||
const draw = useCallback(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const rect = canvas.getBoundingClientRect();
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
ctx.scale(dpr, dpr);
|
||
|
||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||
|
||
const { edges } = graphData;
|
||
const nodes = layoutNodesRef.current;
|
||
const nodeMap = new Map<string, LayoutNode>();
|
||
nodes.forEach(n => nodeMap.set(n.id, n));
|
||
|
||
edges.forEach(edge => {
|
||
const src = nodeMap.get(edge.source);
|
||
const tgt = nodeMap.get(edge.target);
|
||
if (!src || !tgt) return;
|
||
ctx.beginPath();
|
||
ctx.moveTo(src.x, src.y);
|
||
ctx.lineTo(tgt.x, tgt.y);
|
||
ctx.strokeStyle = 'rgba(148, 163, 184, 0.5)';
|
||
ctx.lineWidth = Math.max(0.5, edge.weight * 2);
|
||
ctx.stroke();
|
||
|
||
if (edge.label && edge.label.length < 20) {
|
||
const mx = (src.x + tgt.x) / 2;
|
||
const my = (src.y + tgt.y) / 2;
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.font = '9px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(edge.label, mx, my - 4);
|
||
}
|
||
});
|
||
|
||
// 预计算选中节点的邻居集合
|
||
const highlightedNeighbors = new Set<string>();
|
||
if (selectedNodeId) {
|
||
highlightedNeighbors.add(selectedNodeId);
|
||
edges.forEach(edge => {
|
||
if (edge.source === selectedNodeId) highlightedNeighbors.add(edge.target);
|
||
if (edge.target === selectedNodeId) highlightedNeighbors.add(edge.source);
|
||
});
|
||
}
|
||
|
||
nodes.forEach(node => {
|
||
const r = Math.max(6, node.size * 1.2);
|
||
const color = GROUP_COLORS[node.group] || '#A9A9A9';
|
||
const isSelected = node.id === selectedNodeId;
|
||
const isNeighbor = selectedNodeId && highlightedNeighbors.has(node.id) && !isSelected;
|
||
const isDimmed = selectedNodeId && !highlightedNeighbors.has(node.id);
|
||
const isHovered = hoveredNodeRef.current === node.id;
|
||
|
||
// 高亮光晕
|
||
if (isSelected || isHovered) {
|
||
ctx.beginPath();
|
||
ctx.arc(node.x, node.y, r + 10, 0, 2 * Math.PI);
|
||
ctx.fillStyle = isSelected ? color + '50' : color + '25';
|
||
ctx.fill();
|
||
}
|
||
|
||
if (isNeighbor) {
|
||
ctx.beginPath();
|
||
ctx.arc(node.x, node.y, r + 5, 0, 2 * Math.PI);
|
||
ctx.fillStyle = color + '20';
|
||
ctx.fill();
|
||
}
|
||
|
||
// 节点本体
|
||
const alpha = isDimmed ? '40' : '';
|
||
ctx.beginPath();
|
||
ctx.arc(node.x, node.y, r, 0, 2 * Math.PI);
|
||
ctx.fillStyle = isSelected ? color : (isDimmed ? color + '30' : color);
|
||
ctx.fill();
|
||
|
||
if (isSelected) {
|
||
ctx.strokeStyle = '#FFD700';
|
||
ctx.lineWidth = 3;
|
||
} else if (isHovered) {
|
||
ctx.strokeStyle = '#fff';
|
||
ctx.lineWidth = 3;
|
||
} else {
|
||
ctx.strokeStyle = '#fff';
|
||
ctx.lineWidth = 1.5;
|
||
}
|
||
ctx.stroke();
|
||
|
||
const label = node.label.length > 8 ? node.label.slice(0, 8) + '..' : node.label;
|
||
ctx.fillStyle = isDimmed ? 'rgba(148,163,184,0.3)' : '#e2e8f0';
|
||
ctx.font = `${isSelected ? 'bold ' : ''}${Math.max(10, node.size * 0.8)}px sans-serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(label, node.x, node.y + r + 12);
|
||
});
|
||
|
||
const legendX = 16;
|
||
let legendY = 16;
|
||
ctx.fillStyle = 'rgba(15, 23, 42, 0.85)';
|
||
ctx.fillRect(legendX - 8, legendY - 8, 110, 160);
|
||
ctx.font = 'bold 12px sans-serif';
|
||
ctx.fillStyle = '#e2e8f0';
|
||
ctx.fillText('节点类型', legendX + 4, legendY + 8);
|
||
legendY += 24;
|
||
Object.entries(GROUP_COLORS).forEach(([group, color]) => {
|
||
ctx.beginPath();
|
||
ctx.arc(legendX + 4, legendY, 6, 0, 2 * Math.PI);
|
||
ctx.fillStyle = color;
|
||
ctx.fill();
|
||
ctx.fillStyle = '#cbd5e1';
|
||
ctx.font = '11px sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(group === 'document' ? '文档' : group === 'concept' ? '概念' : group === 'entity' ? '实体' : group === 'attribute' ? '属性' : '关系', legendX + 16, legendY + 4);
|
||
legendY += 22;
|
||
});
|
||
}, [graphData, selectedNodeId]);
|
||
|
||
// 鼠标事件:拖拽节点 + 点击高亮
|
||
const getCanvasPos = useCallback((e: React.MouseEvent<HTMLCanvasElement>): { x: number; y: number } => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return { x: 0, y: 0 };
|
||
const rect = canvas.getBoundingClientRect();
|
||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
}, []);
|
||
|
||
const findNodeAt = useCallback((canvasX: number, canvasY: number): LayoutNode | null => {
|
||
const nodes = layoutNodesRef.current;
|
||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||
const node = nodes[i];
|
||
const r = Math.max(6, node.size * 1.2) + 6;
|
||
const dx = canvasX - node.x;
|
||
const dy = canvasY - node.y;
|
||
if (dx * dx + dy * dy <= r * r) return node;
|
||
}
|
||
return null;
|
||
}, []);
|
||
|
||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
const pos = getCanvasPos(e);
|
||
const node = findNodeAt(pos.x, pos.y);
|
||
if (node) {
|
||
setDraggedNodeId(node.id);
|
||
dragStartRef.current = { x: pos.x, y: pos.y, nodeX: node.x, nodeY: node.y };
|
||
}
|
||
}, [getCanvasPos, findNodeAt]);
|
||
|
||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
const pos = getCanvasPos(e);
|
||
if (draggedNodeId && dragStartRef.current) {
|
||
const dx = pos.x - dragStartRef.current.x;
|
||
const dy = pos.y - dragStartRef.current.y;
|
||
const nodes = layoutNodesRef.current;
|
||
const node = nodes.find(n => n.id === draggedNodeId);
|
||
if (node) {
|
||
node.x = dragStartRef.current.nodeX + dx;
|
||
node.y = dragStartRef.current.nodeY + dy;
|
||
}
|
||
setTooltip(null);
|
||
return;
|
||
}
|
||
const hoveredNode = findNodeAt(pos.x, pos.y);
|
||
hoveredNodeRef.current = hoveredNode?.id || null;
|
||
if (hoveredNode) {
|
||
setTooltip({ x: pos.x, y: pos.y, label: hoveredNode.label, type: hoveredNode.type, group: hoveredNode.group });
|
||
} else {
|
||
setTooltip(null);
|
||
}
|
||
}, [getCanvasPos, draggedNodeId, findNodeAt]);
|
||
|
||
const handleMouseUp = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
if (draggedNodeId && dragStartRef.current) {
|
||
const pos = getCanvasPos(e);
|
||
const dx = pos.x - dragStartRef.current.x;
|
||
const dy = pos.y - dragStartRef.current.y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||
if (dist < 4) {
|
||
// 几乎没移动 = 点击
|
||
setSelectedNodeId(prev => prev === draggedNodeId ? null : draggedNodeId);
|
||
}
|
||
}
|
||
setDraggedNodeId(null);
|
||
dragStartRef.current = null;
|
||
}, [draggedNodeId, getCanvasPos]);
|
||
|
||
const handleCanvasClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
const pos = getCanvasPos(e);
|
||
const node = findNodeAt(pos.x, pos.y);
|
||
if (!node) {
|
||
setSelectedNodeId(null);
|
||
}
|
||
}, [getCanvasPos, findNodeAt]);
|
||
|
||
useEffect(() => {
|
||
if (layoutNodesRef.current.length === 0) {
|
||
initLayout();
|
||
}
|
||
|
||
const animate = () => {
|
||
draw();
|
||
animationRef.current = requestAnimationFrame(animate);
|
||
};
|
||
animate();
|
||
return () => cancelAnimationFrame(animationRef.current);
|
||
}, [initLayout, draw]);
|
||
|
||
return (
|
||
<div className="graph-overlay" onClick={onClose}>
|
||
<div className="graph-modal" onClick={e => e.stopPropagation()}>
|
||
<div className="graph-modal-header">
|
||
<h2>知识图谱可视化</h2>
|
||
<div className="graph-stats">
|
||
<span>{graphData.stats.total_nodes} 节点</span>
|
||
<span>{graphData.stats.total_edges} 边</span>
|
||
<span>{graphData.stats.document_count} 文档</span>
|
||
</div>
|
||
<button className="graph-close-btn" onClick={onClose}><CloseIcon /></button>
|
||
</div>
|
||
<div className="graph-canvas-container" style={{position:'relative'}}>
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="graph-canvas"
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onClick={handleCanvasClick}
|
||
style={{ cursor: draggedNodeId ? 'grabbing' : hoveredNodeRef.current ? 'pointer' : 'grab' }}
|
||
/>
|
||
{tooltip && !draggedNodeId && (
|
||
<div style={{
|
||
position:'absolute',
|
||
left: tooltip.x + 16,
|
||
top: tooltip.y - 10,
|
||
background:'rgba(15,23,42,0.95)',
|
||
color:'#e2e8f0',
|
||
padding:'6px 12px',
|
||
borderRadius:'6px',
|
||
fontSize:'12px',
|
||
pointerEvents:'none',
|
||
whiteSpace:'nowrap',
|
||
zIndex: 100,
|
||
border:'1px solid rgba(148,163,184,0.3)',
|
||
}}>
|
||
<div style={{fontWeight:'bold',marginBottom:'2px'}}>{tooltip.label}</div>
|
||
<div style={{fontSize:'10px',color:'#94a3b8'}}>
|
||
类型: {tooltip.group === 'document' ? '文档' : tooltip.group === 'concept' ? '概念' : tooltip.group === 'entity' ? '实体' : tooltip.group === 'attribute' ? '属性' : tooltip.group === 'relation' ? '关系' : tooltip.group}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{selectedNodeId && (() => {
|
||
const selNode = layoutNodesRef.current.find(n => n.id === selectedNodeId);
|
||
if (!selNode) return null;
|
||
const neighbors = graphData.edges
|
||
.filter(e => e.source === selectedNodeId || e.target === selectedNodeId)
|
||
.map(e => {
|
||
const neighborId = e.source === selectedNodeId ? e.target : e.source;
|
||
const node = layoutNodesRef.current.find(n => n.id === neighborId);
|
||
return { label: e.label || '', nodeName: node?.label || neighborId };
|
||
});
|
||
return (
|
||
<div style={{
|
||
position:'absolute',
|
||
right: 12,
|
||
top: 12,
|
||
background:'rgba(15,23,42,0.95)',
|
||
color:'#e2e8f0',
|
||
padding:'16px',
|
||
borderRadius:'8px',
|
||
fontSize:'13px',
|
||
minWidth:'220px',
|
||
maxWidth:'300px',
|
||
zIndex: 100,
|
||
border:'1px solid rgba(148,163,184,0.3)',
|
||
}}>
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:'12px'}}>
|
||
<span style={{fontWeight:'bold',fontSize:'15px'}}>{selNode.label}</span>
|
||
<button
|
||
onClick={() => setSelectedNodeId(null)}
|
||
style={{background:'none',border:'none',color:'#94a3b8',cursor:'pointer',fontSize:'16px',padding:'0 4px'}}
|
||
>×</button>
|
||
</div>
|
||
<div style={{marginBottom:'8px',fontSize:'12px'}}>
|
||
<span style={{display:'inline-block',width:'8px',height:'8px',borderRadius:'50%',background:GROUP_COLORS[selNode.group]||'#A9A9A9',marginRight:'6px'}}></span>
|
||
类型: {selNode.group === 'document' ? '文档' : selNode.group === 'concept' ? '概念' : selNode.group === 'entity' ? '实体' : selNode.group === 'attribute' ? '属性' : selNode.group === 'relation' ? '关系' : selNode.group}
|
||
</div>
|
||
{neighbors.length > 0 && (
|
||
<div style={{marginTop:'12px',borderTop:'1px solid rgba(148,163,184,0.2)',paddingTop:'8px'}}>
|
||
<div style={{fontSize:'12px',color:'#94a3b8',marginBottom:'6px'}}>
|
||
关联节点 ({neighbors.length})
|
||
</div>
|
||
{neighbors.map((n, i) => (
|
||
<div key={i} style={{fontSize:'11px',padding:'2px 0',color:'#cbd5e1'}}>
|
||
<span style={{color:'#64748b',marginRight:'4px'}}>{n.label ? `[${n.label}]` : ''}</span>
|
||
{n.nodeName}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ===== Modal for creating knowledge base =====
|
||
const CreateKBModal: React.FC<{ onClose: () => void; onCreate: (name: string, desc: string) => Promise<void> }> = ({ onClose, onCreate }) => {
|
||
const [name, setName] = useState('');
|
||
const [desc, setDesc] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const handleSubmit = async () => {
|
||
if (!name.trim()) return;
|
||
setLoading(true);
|
||
try {
|
||
await onCreate(name.trim(), desc.trim());
|
||
onClose();
|
||
} catch (e) {
|
||
alert('创建失败:' + (e as Error).message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={onClose}>
|
||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>创建知识库</h3>
|
||
<button className="modal-close" onClick={onClose}><CloseIcon /></button>
|
||
</div>
|
||
<div className="modal-body">
|
||
<label>知识库名称</label>
|
||
<input type="text" placeholder="请输入知识库名称" value={name} onChange={e => setName(e.target.value)} autoFocus />
|
||
<label>描述(可选)</label>
|
||
<textarea placeholder="请输入知识库描述" value={desc} onChange={e => setDesc(e.target.value)} rows={3} />
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn-cancel" onClick={onClose}>取消</button>
|
||
<button className="btn-primary" onClick={handleSubmit} disabled={loading || !name.trim()}>
|
||
{loading ? '创建中...' : '创建'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ===== Modal for uploading document with progress =====
|
||
const UploadDocModal: React.FC<{
|
||
kbId: string;
|
||
kbName: string;
|
||
onClose: () => void;
|
||
onUpload: (baseId: string, filename: string, fileType: string, fileSize: number) => Promise<void>;
|
||
}> = ({ kbId, kbName, onClose, onUpload }) => {
|
||
const [filename, setFilename] = useState('');
|
||
const [fileType, setFileType] = useState('pdf');
|
||
const [fileSize, setFileSize] = useState(102400);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [progress, setProgress] = useState(0);
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
|
||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
setSelectedFile(file);
|
||
setFilename(file.name);
|
||
const ext = file.name.split('.').pop()?.toLowerCase() || 'txt';
|
||
setFileType(ext);
|
||
setFileSize(file.size);
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!filename.trim() || uploading) return;
|
||
setUploading(true);
|
||
setProgress(0);
|
||
|
||
const interval = setInterval(() => {
|
||
setProgress(prev => {
|
||
if (prev >= 90) return prev;
|
||
return prev + Math.random() * 15 + 5;
|
||
});
|
||
}, 150);
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
if (selectedFile) {
|
||
formData.append('file', selectedFile);
|
||
}
|
||
formData.append('filename', filename.trim());
|
||
formData.append('file_type', fileType);
|
||
formData.append('file_size', String(fileSize));
|
||
|
||
const token = localStorage.getItem('aiflow_token');
|
||
const res = await fetch(`/app/api/knowledge/bases/${kbId}/documents`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
body: formData,
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || data.code !== 0) {
|
||
throw new Error(data.message || data.detail || '上传失败');
|
||
}
|
||
clearInterval(interval);
|
||
setProgress(100);
|
||
// 上传成功后刷新该知识库的文档列表,无需用户手动刷新页面
|
||
try {
|
||
await onUpload(kbId, filename.trim(), fileType, fileSize);
|
||
} catch (e) {
|
||
console.error('刷新文档列表失败:', e);
|
||
}
|
||
setTimeout(() => {
|
||
onClose();
|
||
}, 800);
|
||
} catch (e) {
|
||
clearInterval(interval);
|
||
setProgress(0);
|
||
alert('上传失败:' + (e as Error).message);
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const fileTypes = ['pdf', 'docx', 'doc', 'xlsx', 'xls', 'csv', 'md', 'txt', 'pptx'];
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={uploading ? undefined : onClose}>
|
||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>上传文档到「{kbName}」</h3>
|
||
<button className="modal-close" onClick={onClose} disabled={uploading}><CloseIcon /></button>
|
||
</div>
|
||
<div className="modal-body">
|
||
<label>选择文件</label>
|
||
<input type="file" onChange={handleFileSelect} disabled={uploading} />
|
||
<label>文件名</label>
|
||
<input type="text" placeholder="文件名" value={filename} onChange={e => setFilename(e.target.value)} disabled={uploading} />
|
||
<label>文件类型</label>
|
||
<select value={fileType} onChange={e => setFileType(e.target.value)} disabled={uploading}>
|
||
{fileTypes.map(ft => <option key={ft} value={ft}>{ft.toUpperCase()}</option>)}
|
||
</select>
|
||
|
||
{/* Upload Progress Bar */}
|
||
{uploading && (
|
||
<div className="upload-progress-section">
|
||
<div className="upload-progress-bar-track">
|
||
<div
|
||
className="upload-progress-bar-fill"
|
||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||
/>
|
||
</div>
|
||
<span className="upload-progress-text">{Math.min(Math.round(progress), 100)}%</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn-cancel" onClick={onClose} disabled={uploading}>取消</button>
|
||
<button className="btn-primary" onClick={handleSubmit} disabled={uploading || !filename.trim()}>
|
||
{uploading ? `上传中 ${Math.min(Math.round(progress), 100)}%` : '上传'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ===== Modal for confirming knowledge base deletion =====
|
||
const DeleteKBModal: React.FC<{
|
||
kbName: string;
|
||
docCount: number;
|
||
loading: boolean;
|
||
onConfirm: () => void;
|
||
onClose: () => void;
|
||
}> = ({ kbName, docCount, loading, onConfirm, onClose }) => {
|
||
return (
|
||
<div className="modal-overlay" onClick={loading ? undefined : onClose}>
|
||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>删除知识库</h3>
|
||
<button className="modal-close" onClick={onClose} disabled={loading}><CloseIcon /></button>
|
||
</div>
|
||
<div className="modal-body">
|
||
<div className="delete-warning">
|
||
<AlertTriangleIcon />
|
||
<div className="delete-warning-text">
|
||
<p className="delete-warning-title">确定要删除知识库「{kbName}」吗?</p>
|
||
{docCount > 0 && (
|
||
<p className="delete-warning-detail">
|
||
该知识库包含 {docCount} 个文档,删除后不可恢复。
|
||
</p>
|
||
)}
|
||
{docCount === 0 && (
|
||
<p className="delete-warning-detail">此操作不可撤销。</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn-cancel" onClick={onClose} disabled={loading}>取消</button>
|
||
<button className="btn-danger" onClick={onConfirm} disabled={loading}>
|
||
{loading ? '删除中...' : '确认删除'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ===== Document Row - backend-driven status display =====
|
||
const DocRow: React.FC<{
|
||
doc: KnowledgeDoc;
|
||
onDelete: () => void;
|
||
}> = ({ doc, onDelete }) => {
|
||
const isCompleted = doc.status === 'completed';
|
||
const isProcessing = doc.status === 'processing';
|
||
const isFailed = doc.status === 'failed';
|
||
|
||
return (
|
||
<tr className="doc-table-row">
|
||
<td className="doc-table-cell doc-filename-cell">
|
||
<FileIcon />
|
||
<span className="doc-filename-text">{doc.filename}</span>
|
||
</td>
|
||
<td className="doc-table-cell">
|
||
<span className={`doc-type-badge ${doc.file_type}`}>{getTypeLabel(doc.file_type)}</span>
|
||
</td>
|
||
<td className="doc-table-cell">{formatFileSize(doc.file_size)}</td>
|
||
<td className="doc-table-cell">
|
||
<div className="doc-status-cell">
|
||
{isProcessing ? (
|
||
<span className="doc-status-text processing">
|
||
<ProcessingIcon />
|
||
处理中...
|
||
</span>
|
||
) : isCompleted ? (
|
||
<span className="doc-status-badge completed">
|
||
<CheckIcon />
|
||
已完成
|
||
</span>
|
||
) : isFailed ? (
|
||
<span className="doc-status-badge failed">
|
||
失败
|
||
</span>
|
||
) : (
|
||
<span className={`doc-status-badge ${doc.status}`}>
|
||
{doc.status || '未知'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="doc-table-cell doc-actions-cell">
|
||
<button
|
||
className="doc-delete-btn"
|
||
onClick={onDelete}
|
||
title="删除文档"
|
||
>
|
||
<TrashIcon />
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
};
|
||
|
||
// ===== Main KnowledgePage =====
|
||
const KnowledgePage: React.FC = () => {
|
||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||
const [documents, setDocuments] = useState<Record<string, KnowledgeDoc[]>>({});
|
||
const [expandedKb, setExpandedKb] = useState<string | null>(null);
|
||
const [buildingKb, setBuildingKb] = useState<string | null>(null);
|
||
const buildingKbRef = useRef<string | null>(null);
|
||
const [buildMessage, setBuildMessage] = useState<string | null>(null);
|
||
const [graphData, setGraphData] = useState<GraphData | null>(null);
|
||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||
const [uploadTargetKb, setUploadTargetKb] = useState<KnowledgeBase | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [storageUsed, setStorageUsed] = useState(0);
|
||
const [storageLimit, setStorageLimit] = useState(0);
|
||
const [membershipLevel, setMembershipLevel] = useState(0);
|
||
|
||
// Delete KB modal state
|
||
const [deleteTargetKb, setDeleteTargetKb] = useState<KnowledgeBase | null>(null);
|
||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||
|
||
// Load knowledge bases
|
||
const loadKnowledgeBases = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await knowledgeAPI.getBases();
|
||
if (res.code === 0) {
|
||
const data: any = res.data;
|
||
if (data && Array.isArray(data.bases)) {
|
||
setKnowledgeBases(data.bases);
|
||
setStorageUsed(data.storage_used || 0);
|
||
setStorageLimit(data.storage_limit || 1073741824);
|
||
setMembershipLevel(data.membership_level || 0);
|
||
} else if (Array.isArray(data)) {
|
||
// Legacy format fallback
|
||
setKnowledgeBases(data);
|
||
} else {
|
||
setKnowledgeBases([]);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
setError('加载知识库失败:' + (e as Error).message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadKnowledgeBases();
|
||
}, [loadKnowledgeBases]);
|
||
|
||
// Load documents for a specific knowledge base
|
||
const loadDocuments = useCallback(async (baseId: string) => {
|
||
try {
|
||
const res = await knowledgeAPI.getDocuments(baseId);
|
||
if (res.code === 0) {
|
||
setDocuments(prev => ({ ...prev, [baseId]: res.data || [] }));
|
||
}
|
||
} catch (e) {
|
||
console.error('加载文档失败:', e);
|
||
}
|
||
}, []);
|
||
|
||
// Toggle expand
|
||
const toggleExpand = async (kbId: string) => {
|
||
if (expandedKb === kbId) {
|
||
setExpandedKb(null);
|
||
return;
|
||
}
|
||
setExpandedKb(kbId);
|
||
if (!documents[kbId]) {
|
||
await loadDocuments(kbId);
|
||
}
|
||
};
|
||
|
||
// Create knowledge base
|
||
const handleCreateKB = async (name: string, desc: string) => {
|
||
const res = await knowledgeAPI.createBase({ name, description: desc || undefined });
|
||
if (res.code === 0) {
|
||
await loadKnowledgeBases();
|
||
}
|
||
};
|
||
|
||
// Upload document - 实际文件上传由 UploadDocModal 内的 fetch 完成,
|
||
// 此回调仅用于在上传成功后刷新文档列表与知识库统计,避免重复上传产生重复文档。
|
||
const handleUploadDocument = async (baseId: string, _filename?: string, _fileType?: string, _fileSize?: number) => {
|
||
await loadDocuments(baseId);
|
||
await loadKnowledgeBases();
|
||
// 不在这里清理 uploadTargetKb,由 UploadDocModal 内部控制关闭时机
|
||
// (显示100%进度条800ms后再关闭)
|
||
};
|
||
|
||
// Delete document
|
||
const handleDeleteDocument = async (baseId: string, docId: string) => {
|
||
try {
|
||
const res = await knowledgeAPI.deleteDocument(baseId, docId);
|
||
if (res.code === 0) {
|
||
await loadDocuments(baseId);
|
||
await loadKnowledgeBases();
|
||
}
|
||
} catch (e) {
|
||
alert('删除失败:' + (e as Error).message);
|
||
}
|
||
};
|
||
|
||
// Delete knowledge base - opens confirmation modal
|
||
const handleDeleteKBClick = (kb: KnowledgeBase) => {
|
||
setDeleteTargetKb(kb);
|
||
};
|
||
|
||
const handleDeleteKBConfirm = async () => {
|
||
if (!deleteTargetKb) return;
|
||
setDeleteLoading(true);
|
||
try {
|
||
const res = await knowledgeAPI.deleteBase(deleteTargetKb.id);
|
||
if (res.code === 0) {
|
||
if (expandedKb === deleteTargetKb.id) setExpandedKb(null);
|
||
setDocuments(prev => { const next = { ...prev }; delete next[deleteTargetKb.id]; return next; });
|
||
await loadKnowledgeBases();
|
||
}
|
||
} catch (e) {
|
||
alert('删除失败:' + (e as Error).message);
|
||
} finally {
|
||
setDeleteLoading(false);
|
||
setDeleteTargetKb(null);
|
||
}
|
||
};
|
||
|
||
// Build knowledge graph
|
||
const handleBuildGraph = async (kbId: string) => {
|
||
// Prevent re-entry if already building
|
||
if (buildingKbRef.current) return;
|
||
buildingKbRef.current = kbId;
|
||
setBuildingKb(kbId);
|
||
setBuildMessage(null);
|
||
try {
|
||
const res = await knowledgeAPI.buildGraph(kbId);
|
||
if (res.code === 0) {
|
||
setGraphData(res.data);
|
||
await loadKnowledgeBases();
|
||
if (documents[kbId]) {
|
||
await loadDocuments(kbId);
|
||
}
|
||
} else {
|
||
setBuildMessage('图谱构建失败:' + (res.message || '未知错误,code=' + res.code));
|
||
}
|
||
} catch (e: any) {
|
||
let errMsg = '图谱构建失败';
|
||
if (e.message) {
|
||
errMsg += ':' + e.message;
|
||
} else if (e.detail) {
|
||
errMsg += ':' + e.detail;
|
||
} else {
|
||
errMsg += ':' + String(e);
|
||
}
|
||
setBuildMessage(errMsg);
|
||
} finally {
|
||
// Delay clearing building state to make the "构建中..." state visible
|
||
setTimeout(() => {
|
||
setBuildingKb(null);
|
||
buildingKbRef.current = null;
|
||
}, 600);
|
||
}
|
||
};
|
||
|
||
// Get graph for knowledge base
|
||
const handleViewGraph = async (kbId: string) => {
|
||
try {
|
||
const res = await knowledgeAPI.getGraph(kbId);
|
||
if (res.code === 0) {
|
||
setGraphData(res.data);
|
||
}
|
||
} catch (e) {
|
||
alert('获取图谱失败:' + (e as Error).message);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="knowledge-page">
|
||
<div className="knowledge-header">
|
||
<div className="knowledge-header-left">
|
||
<h1 className="knowledge-title">我的知识库</h1>
|
||
<span className="knowledge-count">{knowledgeBases.length} 个知识库</span>
|
||
</div>
|
||
<div className="knowledge-header-right">
|
||
{/* Storage Space Info */}
|
||
<div className="storage-info">
|
||
<span className="storage-label">存储空间</span>
|
||
<div className="storage-bar-wrapper">
|
||
<div className="storage-bar-track">
|
||
<div
|
||
className="storage-bar-fill"
|
||
style={{ width: `${storageLimit > 0 ? Math.min((storageUsed / storageLimit) * 100, 100) : 0}%` }}
|
||
/>
|
||
</div>
|
||
<span className="storage-text">
|
||
{formatFileSize(storageUsed)} / {formatFileSize(storageLimit)}
|
||
</span>
|
||
</div>
|
||
<span className="storage-tier">
|
||
{membershipLevel === 0 ? 'Free' : membershipLevel === 2 ? 'Pro' : membershipLevel === 3 ? 'Enterprise' : 'Free'}
|
||
</span>
|
||
</div>
|
||
<button className="knowledge-upload-btn" onClick={() => setShowCreateModal(true)}>
|
||
<AddIcon />
|
||
<span>创建知识库</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="knowledge-error">
|
||
<span>{error}</span>
|
||
<button onClick={loadKnowledgeBases}>重试</button>
|
||
</div>
|
||
)}
|
||
|
||
{loading && knowledgeBases.length === 0 && (
|
||
<div className="knowledge-loading">加载中...</div>
|
||
)}
|
||
|
||
{!loading && knowledgeBases.length === 0 && !error && (
|
||
<div className="knowledge-empty">
|
||
<FolderIcon />
|
||
<h3>暂无知识库</h3>
|
||
<p>点击右上角"创建知识库"按钮开始使用</p>
|
||
<button className="btn-primary" onClick={() => setShowCreateModal(true)}>创建知识库</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="knowledge-list">
|
||
{knowledgeBases.map(kb => {
|
||
const docs = documents[kb.id] || [];
|
||
const isExpanded = expandedKb === kb.id;
|
||
const isBuilding = buildingKb === kb.id;
|
||
const hasGraph = (kb.knowledge_graph_count || 0) > 0;
|
||
|
||
return (
|
||
<div key={kb.id} className={`knowledge-card ${isExpanded ? 'expanded' : ''}`}>
|
||
<div className="knowledge-card-main" onClick={() => toggleExpand(kb.id)}>
|
||
<div className="knowledge-card-icon">
|
||
<FolderIcon />
|
||
</div>
|
||
<div className="knowledge-card-info">
|
||
<h3 className="knowledge-card-name">{kb.name}</h3>
|
||
<p className="knowledge-card-desc">{kb.description || '暂无描述'}</p>
|
||
</div>
|
||
<div className="knowledge-card-meta">
|
||
<span className="knowledge-meta-item">
|
||
<FileIcon />
|
||
{kb.document_count || 0} 份文档
|
||
</span>
|
||
{hasGraph && (
|
||
<span className="knowledge-meta-item graph-indicator">
|
||
<GraphIcon />
|
||
{kb.knowledge_graph_count} 节点
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className={`knowledge-expand-indicator ${isExpanded ? 'open' : ''}`}>
|
||
<ChevronDownIcon />
|
||
</div>
|
||
</div>
|
||
|
||
{isExpanded && (
|
||
<div className="knowledge-card-detail">
|
||
<div className="knowledge-docs">
|
||
<div className="knowledge-docs-header">
|
||
<span className="knowledge-docs-title">
|
||
文档列表 ({docs.length})
|
||
</span>
|
||
<div className="knowledge-docs-actions">
|
||
<button
|
||
className={`knowledge-graph-btn ${isBuilding ? 'building' : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); handleBuildGraph(kb.id); }}
|
||
disabled={isBuilding}
|
||
>
|
||
<GraphIcon />
|
||
{isBuilding ? '构建中...' : '构建知识图谱'}
|
||
</button>
|
||
{hasGraph && !isBuilding && (
|
||
<button
|
||
className="knowledge-graph-btn view-graph"
|
||
onClick={(e) => { e.stopPropagation(); handleViewGraph(kb.id); }}
|
||
>
|
||
<GraphIcon />
|
||
查看图谱
|
||
</button>
|
||
)}
|
||
<button
|
||
className="knowledge-doc-upload-btn"
|
||
onClick={(e) => { e.stopPropagation(); setUploadTargetKb(kb); }}
|
||
>
|
||
<UploadIcon />
|
||
上传文档
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Build graph inline message */}
|
||
{buildMessage && (
|
||
<div className="build-message">{buildMessage}</div>
|
||
)}
|
||
|
||
{/* Table-form document list */}
|
||
<div className="knowledge-docs-table-wrapper">
|
||
{docs.length > 0 ? (
|
||
<table className="knowledge-docs-table">
|
||
<thead>
|
||
<tr className="doc-table-header-row">
|
||
<th className="doc-table-th">文件名</th>
|
||
<th className="doc-table-th">类型</th>
|
||
<th className="doc-table-th">大小</th>
|
||
<th className="doc-table-th">状态</th>
|
||
<th className="doc-table-th">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{docs.map(doc => (
|
||
<DocRow
|
||
key={doc.id}
|
||
doc={doc}
|
||
onDelete={() => handleDeleteDocument(kb.id, doc.id)}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<div className="knowledge-empty-docs">
|
||
暂未上传文档,请点击"上传文档"按钮添加
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Expandable detail sidebar removed - footer spans full width */}
|
||
<div className="knowledge-card-footer">
|
||
<span className="knowledge-update-time">更新于 {formatDate(kb.updated_at)}</span>
|
||
<button
|
||
className="knowledge-delete-btn"
|
||
onClick={(e) => { e.stopPropagation(); handleDeleteKBClick(kb); }}
|
||
>
|
||
删除知识库
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Create Knowledge Base Modal */}
|
||
{showCreateModal && (
|
||
<CreateKBModal onClose={() => setShowCreateModal(false)} onCreate={handleCreateKB} />
|
||
)}
|
||
|
||
{/* Upload Document Modal */}
|
||
{uploadTargetKb && (
|
||
<UploadDocModal
|
||
kbId={uploadTargetKb.id}
|
||
kbName={uploadTargetKb.name}
|
||
onClose={() => setUploadTargetKb(null)}
|
||
onUpload={handleUploadDocument}
|
||
/>
|
||
)}
|
||
|
||
{/* Delete Knowledge Base Confirmation Modal */}
|
||
{deleteTargetKb && (
|
||
<DeleteKBModal
|
||
kbName={deleteTargetKb.name}
|
||
docCount={deleteTargetKb.document_count || 0}
|
||
loading={deleteLoading}
|
||
onConfirm={handleDeleteKBConfirm}
|
||
onClose={() => { if (!deleteLoading) setDeleteTargetKb(null); }}
|
||
/>
|
||
)}
|
||
|
||
{/* Knowledge Graph Visualization */}
|
||
{graphData && (
|
||
<KnowledgeGraphView graphData={graphData} onClose={() => setGraphData(null)} />
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default KnowledgePage;
|