You've already forked DataMate
feat(kg): 实现 Phase 3.2 Human-in-the-loop 编辑
核心功能: - 实体/关系编辑表单(创建/更新/删除) - 批量操作(批量删除节点/边) - 审核流程(提交审核 → 待审核列表 → 通过/拒绝) - 编辑模式切换(查看/编辑模式) - 权限控制(knowledgeGraphWrite 权限) 新增文件(后端,9 个): - EditReview.java - 审核记录领域模型(Neo4j 节点) - EditReviewRepository.java - 审核记录仓储(CRUD + 分页查询) - EditReviewService.java - 审核业务服务(提交/通过/拒绝,通过时自动执行变更) - EditReviewController.java - REST API(POST submit, POST approve/reject, GET pending) - DTOs: SubmitReviewRequest, EditReviewVO, ReviewActionRequest, BatchDeleteRequest - EditReviewServiceTest.java - 单元测试(21 tests) - EditReviewControllerTest.java - 集成测试(10 tests) 新增文件(前端,3 个): - EntityEditForm.tsx - 实体创建/编辑表单(Modal,支持名称/类型/描述/别名/置信度) - RelationEditForm.tsx - 关系创建/编辑表单(Modal,支持源/目标实体搜索、关系类型、权重/置信度) - ReviewPanel.tsx - 审核面板(待审核列表,通过/拒绝操作,拒绝带备注) 修改文件(后端,7 个): - GraphEntityService.java - 新增 batchDeleteEntities(),updateEntity 支持 confidence - GraphRelationService.java - 新增 batchDeleteRelations() - GraphEntityController.java - 删除批量删除端点(改为审核流程) - GraphRelationController.java - 删除批量删除端点(改为审核流程) - UpdateEntityRequest.java - 添加 confidence 字段 - KnowledgeGraphErrorCode.java - 新增 REVIEW_NOT_FOUND、REVIEW_ALREADY_PROCESSED - PermissionRuleMatcher.java - 添加 /api/knowledge-graph/** 写操作权限规则 修改文件(前端,8 个): - knowledge-graph.model.ts - 新增 EditReviewVO、ReviewOperationType、ReviewStatus 类型 - knowledge-graph.api.ts - BASE 改为 /api/knowledge-graph(走网关权限链),新增审核相关 API,删除批量删除直删方法 - vite.config.ts - 更新 dev proxy 路径 - NodeDetail.tsx - 新增 editMode 属性,编辑模式下显示编辑/删除按钮 - RelationDetail.tsx - 新增 editMode 属性,编辑模式下显示编辑/删除按钮 - KnowledgeGraphPage.tsx - 新增编辑模式开关(需要 knowledgeGraphWrite 权限)、创建实体/关系工具栏按钮、审核 Tab、批量操作 - GraphCanvas.tsx - 支持多选(editMode 时)、onSelectionChange 回调 - graphConfig.ts - 支持 multiSelect 参数 审核流程: - 所有编辑操作(创建/更新/删除/批量删除)都通过 submitReview 提交审核 - 审核通过后,EditReviewService.applyChange() 自动执行变更 - 批量删除端点已删除,只能通过审核流程 权限控制: - API 路径从 /knowledge-graph 改为 /api/knowledge-graph,走网关权限链 - 编辑模式开关需要 knowledgeGraphWrite 权限 - PermissionRuleMatcher 添加 /api/knowledge-graph/** 写操作规则 Bug 修复(Codex 审查后修复): - P0: 权限绕过(API 路径改为 /api/knowledge-graph) - P1: 审核流程未接入(所有编辑操作改为 submitReview) - P1: 批量删除绕过审核(删除直删端点,改为审核流程) - P1: confidence 字段丢失(UpdateEntityRequest 添加 confidence) - P2: 审核提交校验不足(添加跨字段校验器) - P2: 批量删除安全(添加 @Size(max=100) 限制,收集失败 ID) - P2: 前端错误处理(分开处理表单校验和 API 失败) 测试结果: - 后端: 311 tests pass ✅ (280 → 311, +31 new) - 前端: eslint clean ✅, tsc clean ✅, vite build success ✅
This commit is contained in:
@@ -1,19 +1,26 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Card, Input, Select, Button, Tag, Space, Empty, Tabs, message } from "antd";
|
||||
import { Network, RotateCcw } from "lucide-react";
|
||||
import { Card, Input, Select, Button, Tag, Space, Empty, Tabs, Switch, message, Popconfirm } from "antd";
|
||||
import { Network, RotateCcw, Plus, Link2, Trash2 } from "lucide-react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { hasPermission, PermissionCodes } from "@/auth/permissions";
|
||||
import GraphCanvas from "../components/GraphCanvas";
|
||||
import SearchPanel from "../components/SearchPanel";
|
||||
import QueryBuilder from "../components/QueryBuilder";
|
||||
import NodeDetail from "../components/NodeDetail";
|
||||
import RelationDetail from "../components/RelationDetail";
|
||||
import EntityEditForm from "../components/EntityEditForm";
|
||||
import RelationEditForm from "../components/RelationEditForm";
|
||||
import ReviewPanel from "../components/ReviewPanel";
|
||||
import useGraphData from "../hooks/useGraphData";
|
||||
import useGraphLayout, { LAYOUT_OPTIONS } from "../hooks/useGraphLayout";
|
||||
import type { GraphEntity, RelationVO } from "../knowledge-graph.model";
|
||||
import {
|
||||
ENTITY_TYPE_COLORS,
|
||||
DEFAULT_ENTITY_COLOR,
|
||||
ENTITY_TYPE_LABELS,
|
||||
} from "../knowledge-graph.const";
|
||||
import * as api from "../knowledge-graph.api";
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -22,6 +29,10 @@ export default function KnowledgeGraphPage() {
|
||||
const [graphId, setGraphId] = useState(() => params.get("graphId") ?? "");
|
||||
const [graphIdInput, setGraphIdInput] = useState(() => params.get("graphId") ?? "");
|
||||
|
||||
// Permission check
|
||||
const permissions = useAppSelector((state) => state.auth.permissions);
|
||||
const canWrite = hasPermission(permissions, PermissionCodes.knowledgeGraphWrite);
|
||||
|
||||
const {
|
||||
graphData,
|
||||
loading,
|
||||
@@ -38,12 +49,26 @@ export default function KnowledgeGraphPage() {
|
||||
|
||||
const { layoutType, setLayoutType } = useGraphLayout();
|
||||
|
||||
// Edit mode (only allowed with write permission)
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
// Detail panel state
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [nodeDetailOpen, setNodeDetailOpen] = useState(false);
|
||||
const [relationDetailOpen, setRelationDetailOpen] = useState(false);
|
||||
|
||||
// Edit form state
|
||||
const [entityFormOpen, setEntityFormOpen] = useState(false);
|
||||
const [editingEntity, setEditingEntity] = useState<GraphEntity | null>(null);
|
||||
const [relationFormOpen, setRelationFormOpen] = useState(false);
|
||||
const [editingRelation, setEditingRelation] = useState<RelationVO | null>(null);
|
||||
const [defaultRelationSourceId, setDefaultRelationSourceId] = useState<string | undefined>();
|
||||
|
||||
// Batch selection state
|
||||
const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]);
|
||||
const [selectedEdgeIds, setSelectedEdgeIds] = useState<string[]>([]);
|
||||
|
||||
// Load graph when graphId changes
|
||||
useEffect(() => {
|
||||
if (graphId && UUID_REGEX.test(graphId)) {
|
||||
@@ -110,7 +135,6 @@ export default function KnowledgeGraphPage() {
|
||||
const handleSearchResultClick = useCallback(
|
||||
(entityId: string) => {
|
||||
handleNodeClick(entityId);
|
||||
// If the entity is not in the current graph, expand it
|
||||
if (!graphData.nodes.find((n) => n.id === entityId) && graphId) {
|
||||
expandNode(graphId, entityId);
|
||||
}
|
||||
@@ -124,9 +148,123 @@ export default function KnowledgeGraphPage() {
|
||||
setNodeDetailOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSelectionChange = useCallback((nodeIds: string[], edgeIds: string[]) => {
|
||||
setSelectedNodeIds(nodeIds);
|
||||
setSelectedEdgeIds(edgeIds);
|
||||
}, []);
|
||||
|
||||
// ---- Edit handlers ----
|
||||
|
||||
const refreshGraph = useCallback(() => {
|
||||
if (graphId) {
|
||||
loadInitialData(graphId);
|
||||
}
|
||||
}, [graphId, loadInitialData]);
|
||||
|
||||
const handleEditEntity = useCallback((entity: GraphEntity) => {
|
||||
setEditingEntity(entity);
|
||||
setEntityFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateEntity = useCallback(() => {
|
||||
setEditingEntity(null);
|
||||
setEntityFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteEntity = useCallback(
|
||||
async (entityId: string) => {
|
||||
if (!graphId) return;
|
||||
try {
|
||||
await api.submitReview(graphId, {
|
||||
operationType: "DELETE_ENTITY",
|
||||
entityId,
|
||||
});
|
||||
message.success("实体删除已提交审核");
|
||||
setNodeDetailOpen(false);
|
||||
setSelectedNodeId(null);
|
||||
refreshGraph();
|
||||
} catch {
|
||||
message.error("提交实体删除审核失败");
|
||||
}
|
||||
},
|
||||
[graphId, refreshGraph]
|
||||
);
|
||||
|
||||
const handleEditRelation = useCallback((relation: RelationVO) => {
|
||||
setEditingRelation(relation);
|
||||
setDefaultRelationSourceId(undefined);
|
||||
setRelationFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateRelation = useCallback((sourceEntityId?: string) => {
|
||||
setEditingRelation(null);
|
||||
setDefaultRelationSourceId(sourceEntityId);
|
||||
setRelationFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteRelation = useCallback(
|
||||
async (relationId: string) => {
|
||||
if (!graphId) return;
|
||||
try {
|
||||
await api.submitReview(graphId, {
|
||||
operationType: "DELETE_RELATION",
|
||||
relationId,
|
||||
});
|
||||
message.success("关系删除已提交审核");
|
||||
setRelationDetailOpen(false);
|
||||
setSelectedEdgeId(null);
|
||||
refreshGraph();
|
||||
} catch {
|
||||
message.error("提交关系删除审核失败");
|
||||
}
|
||||
},
|
||||
[graphId, refreshGraph]
|
||||
);
|
||||
|
||||
const handleEntityFormSuccess = useCallback(() => {
|
||||
refreshGraph();
|
||||
}, [refreshGraph]);
|
||||
|
||||
const handleRelationFormSuccess = useCallback(() => {
|
||||
refreshGraph();
|
||||
}, [refreshGraph]);
|
||||
|
||||
// ---- Batch operations ----
|
||||
|
||||
const handleBatchDeleteNodes = useCallback(async () => {
|
||||
if (!graphId || selectedNodeIds.length === 0) return;
|
||||
try {
|
||||
await api.submitReview(graphId, {
|
||||
operationType: "BATCH_DELETE_ENTITY",
|
||||
payload: JSON.stringify({ ids: selectedNodeIds }),
|
||||
});
|
||||
message.success("批量删除实体已提交审核");
|
||||
setSelectedNodeIds([]);
|
||||
refreshGraph();
|
||||
} catch {
|
||||
message.error("提交批量删除实体审核失败");
|
||||
}
|
||||
}, [graphId, selectedNodeIds, refreshGraph]);
|
||||
|
||||
const handleBatchDeleteEdges = useCallback(async () => {
|
||||
if (!graphId || selectedEdgeIds.length === 0) return;
|
||||
try {
|
||||
await api.submitReview(graphId, {
|
||||
operationType: "BATCH_DELETE_RELATION",
|
||||
payload: JSON.stringify({ ids: selectedEdgeIds }),
|
||||
});
|
||||
message.success("批量删除关系已提交审核");
|
||||
setSelectedEdgeIds([]);
|
||||
refreshGraph();
|
||||
} catch {
|
||||
message.error("提交批量删除关系审核失败");
|
||||
}
|
||||
}, [graphId, selectedEdgeIds, refreshGraph]);
|
||||
|
||||
const hasGraph = graphId && UUID_REGEX.test(graphId);
|
||||
const nodeCount = graphData.nodes.length;
|
||||
const edgeCount = graphData.edges.length;
|
||||
const hasBatchSelection = editMode && (selectedNodeIds.length > 1 || selectedEdgeIds.length > 1);
|
||||
|
||||
// Collect unique entity types in current graph for legend
|
||||
const entityTypes = [...new Set(graphData.nodes.map((n) => n.data.type))].sort();
|
||||
@@ -139,6 +277,16 @@ export default function KnowledgeGraphPage() {
|
||||
<Network className="w-5 h-5" />
|
||||
知识图谱浏览器
|
||||
</h1>
|
||||
{hasGraph && canWrite && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">编辑模式</span>
|
||||
<Switch
|
||||
checked={editMode}
|
||||
onChange={setEditMode}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Graph ID Input + Controls */}
|
||||
@@ -176,6 +324,62 @@ export default function KnowledgeGraphPage() {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Edit mode toolbar */}
|
||||
{hasGraph && editMode && (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus className="w-3.5 h-3.5" />}
|
||||
onClick={handleCreateEntity}
|
||||
>
|
||||
创建实体
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Link2 className="w-3.5 h-3.5" />}
|
||||
onClick={() => handleCreateRelation()}
|
||||
>
|
||||
创建关系
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Batch operations toolbar */}
|
||||
{hasBatchSelection && (
|
||||
<>
|
||||
{selectedNodeIds.length > 1 && (
|
||||
<Popconfirm
|
||||
title={`确认批量删除 ${selectedNodeIds.length} 个实体?`}
|
||||
description="删除后关联的关系也会被移除"
|
||||
onConfirm={handleBatchDeleteNodes}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
>
|
||||
批量删除实体 ({selectedNodeIds.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{selectedEdgeIds.length > 1 && (
|
||||
<Popconfirm
|
||||
title={`确认批量删除 ${selectedEdgeIds.length} 条关系?`}
|
||||
onConfirm={handleBatchDeleteEdges}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
>
|
||||
批量删除关系 ({selectedEdgeIds.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
@@ -223,6 +427,11 @@ export default function KnowledgeGraphPage() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "review",
|
||||
label: "审核",
|
||||
children: <ReviewPanel graphId={graphId} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
@@ -236,10 +445,12 @@ export default function KnowledgeGraphPage() {
|
||||
loading={loading}
|
||||
layoutType={layoutType}
|
||||
highlightedNodeIds={highlightedNodeIds}
|
||||
editMode={editMode}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onCanvasClick={handleCanvasClick}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
@@ -257,17 +468,41 @@ export default function KnowledgeGraphPage() {
|
||||
graphId={graphId}
|
||||
entityId={selectedNodeId}
|
||||
open={nodeDetailOpen}
|
||||
editMode={editMode}
|
||||
onClose={() => setNodeDetailOpen(false)}
|
||||
onExpandNode={handleExpandNode}
|
||||
onRelationClick={handleRelationClick}
|
||||
onEntityNavigate={handleEntityNavigate}
|
||||
onEditEntity={handleEditEntity}
|
||||
onDeleteEntity={handleDeleteEntity}
|
||||
onCreateRelation={handleCreateRelation}
|
||||
/>
|
||||
<RelationDetail
|
||||
graphId={graphId}
|
||||
relationId={selectedEdgeId}
|
||||
open={relationDetailOpen}
|
||||
editMode={editMode}
|
||||
onClose={() => setRelationDetailOpen(false)}
|
||||
onEntityNavigate={handleEntityNavigate}
|
||||
onEditRelation={handleEditRelation}
|
||||
onDeleteRelation={handleDeleteRelation}
|
||||
/>
|
||||
|
||||
{/* Edit forms */}
|
||||
<EntityEditForm
|
||||
graphId={graphId}
|
||||
entity={editingEntity}
|
||||
open={entityFormOpen}
|
||||
onClose={() => setEntityFormOpen(false)}
|
||||
onSuccess={handleEntityFormSuccess}
|
||||
/>
|
||||
<RelationEditForm
|
||||
graphId={graphId}
|
||||
relation={editingRelation}
|
||||
open={relationFormOpen}
|
||||
onClose={() => setRelationFormOpen(false)}
|
||||
onSuccess={handleRelationFormSuccess}
|
||||
defaultSourceId={defaultRelationSourceId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
143
frontend/src/pages/KnowledgeGraph/components/EntityEditForm.tsx
Normal file
143
frontend/src/pages/KnowledgeGraph/components/EntityEditForm.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect } from "react";
|
||||
import { Modal, Form, Input, Select, InputNumber, message } from "antd";
|
||||
import type { GraphEntity } from "../knowledge-graph.model";
|
||||
import { ENTITY_TYPES, ENTITY_TYPE_LABELS } from "../knowledge-graph.const";
|
||||
import * as api from "../knowledge-graph.api";
|
||||
|
||||
interface EntityEditFormProps {
|
||||
graphId: string;
|
||||
entity?: GraphEntity | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function EntityEditForm({
|
||||
graphId,
|
||||
entity,
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: EntityEditFormProps) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!entity;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && entity) {
|
||||
form.setFieldsValue({
|
||||
name: entity.name,
|
||||
type: entity.type,
|
||||
description: entity.description ?? "",
|
||||
aliases: entity.aliases?.join(", ") ?? "",
|
||||
confidence: entity.confidence ?? 1.0,
|
||||
});
|
||||
} else if (open) {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, entity, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
let values;
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch {
|
||||
return; // Form validation failed — Antd shows inline errors
|
||||
}
|
||||
|
||||
const parsedAliases = values.aliases
|
||||
? values.aliases
|
||||
.split(",")
|
||||
.map((a: string) => a.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
try {
|
||||
if (isEdit && entity) {
|
||||
const payload = JSON.stringify({
|
||||
name: values.name,
|
||||
description: values.description || undefined,
|
||||
aliases: parsedAliases.length > 0 ? parsedAliases : undefined,
|
||||
properties: entity.properties,
|
||||
confidence: values.confidence,
|
||||
});
|
||||
await api.submitReview(graphId, {
|
||||
operationType: "UPDATE_ENTITY",
|
||||
entityId: entity.id,
|
||||
payload,
|
||||
});
|
||||
message.success("实体更新已提交审核");
|
||||
} else {
|
||||
const payload = JSON.stringify({
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
description: values.description || undefined,
|
||||
aliases: parsedAliases.length > 0 ? parsedAliases : undefined,
|
||||
properties: {},
|
||||
confidence: values.confidence,
|
||||
});
|
||||
await api.submitReview(graphId, {
|
||||
operationType: "CREATE_ENTITY",
|
||||
payload,
|
||||
});
|
||||
message.success("实体创建已提交审核");
|
||||
}
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch {
|
||||
message.error(isEdit ? "提交实体更新审核失败" : "提交实体创建审核失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? "编辑实体" : "创建实体"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleSubmit}
|
||||
okText={isEdit ? "提交审核" : "提交审核"}
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-4">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: "请输入实体名称" }]}
|
||||
>
|
||||
<Input placeholder="输入实体名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="类型"
|
||||
rules={[{ required: true, message: "请选择实体类型" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择实体类型"
|
||||
disabled={isEdit}
|
||||
options={ENTITY_TYPES.map((t) => ({
|
||||
label: ENTITY_TYPE_LABELS[t] ?? t,
|
||||
value: t,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} placeholder="输入实体描述(可选)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="aliases"
|
||||
label="别名"
|
||||
tooltip="多个别名用逗号分隔"
|
||||
>
|
||||
<Input placeholder="别名1, 别名2, ..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="confidence" label="置信度">
|
||||
<InputNumber min={0} max={1} step={0.1} className="w-full" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,12 @@ interface GraphCanvasProps {
|
||||
loading?: boolean;
|
||||
layoutType: LayoutType;
|
||||
highlightedNodeIds?: Set<string>;
|
||||
editMode?: boolean;
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
onEdgeClick?: (edgeId: string) => void;
|
||||
onNodeDoubleClick?: (nodeId: string) => void;
|
||||
onCanvasClick?: () => void;
|
||||
onSelectionChange?: (nodeIds: string[], edgeIds: string[]) => void;
|
||||
}
|
||||
|
||||
function GraphCanvas({
|
||||
@@ -21,10 +23,12 @@ function GraphCanvas({
|
||||
loading = false,
|
||||
layoutType,
|
||||
highlightedNodeIds,
|
||||
editMode = false,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onNodeDoubleClick,
|
||||
onCanvasClick,
|
||||
onSelectionChange,
|
||||
}: GraphCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
@@ -33,7 +37,7 @@ function GraphCanvas({
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const options = createGraphOptions(containerRef.current);
|
||||
const options = createGraphOptions(containerRef.current, editMode);
|
||||
const graph = new Graph(options);
|
||||
graphRef.current = graph;
|
||||
|
||||
@@ -43,7 +47,8 @@ function GraphCanvas({
|
||||
graphRef.current = null;
|
||||
graph.destroy();
|
||||
};
|
||||
}, []);
|
||||
// editMode is intentionally included so the graph re-creates with correct multi-select setting
|
||||
}, [editMode]);
|
||||
|
||||
// Update data (with large-graph performance optimization)
|
||||
useEffect(() => {
|
||||
@@ -120,6 +125,25 @@ function GraphCanvas({
|
||||
});
|
||||
}, [highlightedNodeIds, data]);
|
||||
|
||||
// Helper: query selected elements from graph and notify parent
|
||||
const emitSelectionChange = useCallback(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || !onSelectionChange) return;
|
||||
// Defer to next tick so G6 internal state has settled
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const selectedNodes = graph.getElementDataByState("node", "selected");
|
||||
const selectedEdges = graph.getElementDataByState("edge", "selected");
|
||||
onSelectionChange(
|
||||
selectedNodes.map((n: { id: string }) => n.id),
|
||||
selectedEdges.map((e: { id: string }) => e.id)
|
||||
);
|
||||
} catch {
|
||||
// graph may be destroyed
|
||||
}
|
||||
}, 0);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
// Bind events
|
||||
useEffect(() => {
|
||||
const graph = graphRef.current;
|
||||
@@ -127,15 +151,18 @@ function GraphCanvas({
|
||||
|
||||
const handleNodeClick = (event: { target: { id: string } }) => {
|
||||
onNodeClick?.(event.target.id);
|
||||
emitSelectionChange();
|
||||
};
|
||||
const handleEdgeClick = (event: { target: { id: string } }) => {
|
||||
onEdgeClick?.(event.target.id);
|
||||
emitSelectionChange();
|
||||
};
|
||||
const handleNodeDblClick = (event: { target: { id: string } }) => {
|
||||
onNodeDoubleClick?.(event.target.id);
|
||||
};
|
||||
const handleCanvasClick = () => {
|
||||
onCanvasClick?.();
|
||||
emitSelectionChange();
|
||||
};
|
||||
|
||||
graph.on("node:click", handleNodeClick);
|
||||
@@ -149,7 +176,7 @@ function GraphCanvas({
|
||||
graph.off("node:dblclick", handleNodeDblClick);
|
||||
graph.off("canvas:click", handleCanvasClick);
|
||||
};
|
||||
}, [onNodeClick, onEdgeClick, onNodeDoubleClick, onCanvasClick]);
|
||||
}, [onNodeClick, onEdgeClick, onNodeDoubleClick, onCanvasClick, emitSelectionChange]);
|
||||
|
||||
// Fit view helper
|
||||
const handleFitView = useCallback(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Drawer, Descriptions, Tag, List, Button, Spin, Empty, message } from "antd";
|
||||
import { Expand } from "lucide-react";
|
||||
import { Drawer, Descriptions, Tag, List, Button, Spin, Empty, Popconfirm, Space, message } from "antd";
|
||||
import { Expand, Pencil, Trash2 } from "lucide-react";
|
||||
import type { GraphEntity, RelationVO, PagedResponse } from "../knowledge-graph.model";
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
@@ -14,20 +14,28 @@ interface NodeDetailProps {
|
||||
graphId: string;
|
||||
entityId: string | null;
|
||||
open: boolean;
|
||||
editMode?: boolean;
|
||||
onClose: () => void;
|
||||
onExpandNode: (entityId: string) => void;
|
||||
onRelationClick: (relationId: string) => void;
|
||||
onEntityNavigate: (entityId: string) => void;
|
||||
onEditEntity?: (entity: GraphEntity) => void;
|
||||
onDeleteEntity?: (entityId: string) => void;
|
||||
onCreateRelation?: (sourceEntityId: string) => void;
|
||||
}
|
||||
|
||||
export default function NodeDetail({
|
||||
graphId,
|
||||
entityId,
|
||||
open,
|
||||
editMode = false,
|
||||
onClose,
|
||||
onExpandNode,
|
||||
onRelationClick,
|
||||
onEntityNavigate,
|
||||
onEditEntity,
|
||||
onDeleteEntity,
|
||||
onCreateRelation,
|
||||
}: NodeDetailProps) {
|
||||
const [entity, setEntity] = useState<GraphEntity | null>(null);
|
||||
const [relations, setRelations] = useState<RelationVO[]>([]);
|
||||
@@ -58,6 +66,12 @@ export default function NodeDetail({
|
||||
});
|
||||
}, [graphId, entityId, open]);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (entityId) {
|
||||
onDeleteEntity?.(entityId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
@@ -75,14 +89,42 @@ export default function NodeDetail({
|
||||
width={420}
|
||||
extra={
|
||||
entityId && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<Expand className="w-3 h-3" />}
|
||||
onClick={() => onExpandNode(entityId)}
|
||||
>
|
||||
展开邻居
|
||||
</Button>
|
||||
<Space>
|
||||
{editMode && entity && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<Pencil className="w-3 h-3" />}
|
||||
onClick={() => onEditEntity?.(entity)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除此实体?"
|
||||
description="删除后关联的关系也会被移除"
|
||||
onConfirm={handleDelete}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<Trash2 className="w-3 h-3" />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<Expand className="w-3 h-3" />}
|
||||
onClick={() => onExpandNode(entityId)}
|
||||
>
|
||||
展开邻居
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -130,7 +172,18 @@ export default function NodeDetail({
|
||||
</>
|
||||
)}
|
||||
|
||||
<h4 className="font-medium text-sm">关系列表 ({relations.length})</h4>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm">关系列表 ({relations.length})</h4>
|
||||
{editMode && entityId && (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => onCreateRelation?.(entityId)}
|
||||
>
|
||||
+ 添加关系
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{relations.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Drawer, Descriptions, Tag, Spin, Empty, message } from "antd";
|
||||
import { Drawer, Descriptions, Tag, Spin, Empty, Button, Popconfirm, Space, message } from "antd";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { RelationVO } from "../knowledge-graph.model";
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
@@ -13,16 +14,22 @@ interface RelationDetailProps {
|
||||
graphId: string;
|
||||
relationId: string | null;
|
||||
open: boolean;
|
||||
editMode?: boolean;
|
||||
onClose: () => void;
|
||||
onEntityNavigate: (entityId: string) => void;
|
||||
onEditRelation?: (relation: RelationVO) => void;
|
||||
onDeleteRelation?: (relationId: string) => void;
|
||||
}
|
||||
|
||||
export default function RelationDetail({
|
||||
graphId,
|
||||
relationId,
|
||||
open,
|
||||
editMode = false,
|
||||
onClose,
|
||||
onEntityNavigate,
|
||||
onEditRelation,
|
||||
onDeleteRelation,
|
||||
}: RelationDetailProps) {
|
||||
const [relation, setRelation] = useState<RelationVO | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -42,8 +49,46 @@ export default function RelationDetail({
|
||||
.finally(() => setLoading(false));
|
||||
}, [graphId, relationId, open]);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (relationId) {
|
||||
onDeleteRelation?.(relationId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer title="关系详情" open={open} onClose={onClose} width={400}>
|
||||
<Drawer
|
||||
title="关系详情"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={400}
|
||||
extra={
|
||||
editMode && relation && (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<Pencil className="w-3 h-3" />}
|
||||
onClick={() => onEditRelation?.(relation)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除此关系?"
|
||||
onConfirm={handleDelete}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<Trash2 className="w-3 h-3" />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{relation ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Modal, Form, Select, InputNumber, message, Spin } from "antd";
|
||||
import type { RelationVO, GraphEntity } from "../knowledge-graph.model";
|
||||
import { RELATION_TYPES, RELATION_TYPE_LABELS } from "../knowledge-graph.const";
|
||||
import * as api from "../knowledge-graph.api";
|
||||
|
||||
interface RelationEditFormProps {
|
||||
graphId: string;
|
||||
relation?: RelationVO | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
/** Pre-fill source entity when creating from a node context */
|
||||
defaultSourceId?: string;
|
||||
}
|
||||
|
||||
export default function RelationEditForm({
|
||||
graphId,
|
||||
relation,
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
defaultSourceId,
|
||||
}: RelationEditFormProps) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!relation;
|
||||
const [entityOptions, setEntityOptions] = useState<
|
||||
{ label: string; value: string }[]
|
||||
>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && relation) {
|
||||
form.setFieldsValue({
|
||||
relationType: relation.relationType,
|
||||
sourceEntityId: relation.sourceEntityId,
|
||||
targetEntityId: relation.targetEntityId,
|
||||
weight: relation.weight,
|
||||
confidence: relation.confidence,
|
||||
});
|
||||
} else if (open) {
|
||||
form.resetFields();
|
||||
if (defaultSourceId) {
|
||||
form.setFieldsValue({ sourceEntityId: defaultSourceId });
|
||||
}
|
||||
}
|
||||
}, [open, relation, form, defaultSourceId]);
|
||||
|
||||
const searchEntities = useCallback(
|
||||
async (keyword: string) => {
|
||||
if (!keyword.trim() || !graphId) return;
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const result = await api.listEntitiesPaged(graphId, {
|
||||
keyword,
|
||||
page: 0,
|
||||
size: 20,
|
||||
});
|
||||
setEntityOptions(
|
||||
result.content.map((e: GraphEntity) => ({
|
||||
label: `${e.name} (${e.type})`,
|
||||
value: e.id,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
},
|
||||
[graphId]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
let values;
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch {
|
||||
return; // Form validation failed — Antd shows inline errors
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit && relation) {
|
||||
const payload = JSON.stringify({
|
||||
relationType: values.relationType,
|
||||
weight: values.weight,
|
||||
confidence: values.confidence,
|
||||
});
|
||||
await api.submitReview(graphId, {
|
||||
operationType: "UPDATE_RELATION",
|
||||
relationId: relation.id,
|
||||
payload,
|
||||
});
|
||||
message.success("关系更新已提交审核");
|
||||
} else {
|
||||
const payload = JSON.stringify({
|
||||
sourceEntityId: values.sourceEntityId,
|
||||
targetEntityId: values.targetEntityId,
|
||||
relationType: values.relationType,
|
||||
weight: values.weight,
|
||||
confidence: values.confidence,
|
||||
});
|
||||
await api.submitReview(graphId, {
|
||||
operationType: "CREATE_RELATION",
|
||||
payload,
|
||||
});
|
||||
message.success("关系创建已提交审核");
|
||||
}
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch {
|
||||
message.error(isEdit ? "提交关系更新审核失败" : "提交关系创建审核失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? "编辑关系" : "创建关系"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleSubmit}
|
||||
okText="提交审核"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-4">
|
||||
<Form.Item
|
||||
name="sourceEntityId"
|
||||
label="源实体"
|
||||
rules={[{ required: true, message: "请选择源实体" }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="搜索并选择源实体"
|
||||
disabled={isEdit}
|
||||
filterOption={false}
|
||||
onSearch={searchEntities}
|
||||
options={entityOptions}
|
||||
notFoundContent={searchLoading ? <Spin size="small" /> : null}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="targetEntityId"
|
||||
label="目标实体"
|
||||
rules={[{ required: true, message: "请选择目标实体" }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="搜索并选择目标实体"
|
||||
disabled={isEdit}
|
||||
filterOption={false}
|
||||
onSearch={searchEntities}
|
||||
options={entityOptions}
|
||||
notFoundContent={searchLoading ? <Spin size="small" /> : null}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="relationType"
|
||||
label="关系类型"
|
||||
rules={[{ required: true, message: "请选择关系类型" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择关系类型"
|
||||
options={RELATION_TYPES.map((t) => ({
|
||||
label: RELATION_TYPE_LABELS[t] ?? t,
|
||||
value: t,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="weight" label="权重">
|
||||
<InputNumber min={0} max={1} step={0.1} className="w-full" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="confidence" label="置信度">
|
||||
<InputNumber min={0} max={1} step={0.1} className="w-full" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
206
frontend/src/pages/KnowledgeGraph/components/ReviewPanel.tsx
Normal file
206
frontend/src/pages/KnowledgeGraph/components/ReviewPanel.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { List, Tag, Button, Empty, Spin, Popconfirm, Input, message } from "antd";
|
||||
import { Check, X } from "lucide-react";
|
||||
import type { EditReviewVO, PagedResponse } from "../knowledge-graph.model";
|
||||
import * as api from "../knowledge-graph.api";
|
||||
|
||||
const OPERATION_LABELS: Record<string, string> = {
|
||||
CREATE_ENTITY: "创建实体",
|
||||
UPDATE_ENTITY: "更新实体",
|
||||
DELETE_ENTITY: "删除实体",
|
||||
CREATE_RELATION: "创建关系",
|
||||
UPDATE_RELATION: "更新关系",
|
||||
DELETE_RELATION: "删除关系",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: "orange",
|
||||
APPROVED: "green",
|
||||
REJECTED: "red",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "待审核",
|
||||
APPROVED: "已通过",
|
||||
REJECTED: "已拒绝",
|
||||
};
|
||||
|
||||
interface ReviewPanelProps {
|
||||
graphId: string;
|
||||
}
|
||||
|
||||
export default function ReviewPanel({ graphId }: ReviewPanelProps) {
|
||||
const [reviews, setReviews] = useState<EditReviewVO[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const loadReviews = useCallback(async () => {
|
||||
if (!graphId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result: PagedResponse<EditReviewVO> = await api.listPendingReviews(
|
||||
graphId,
|
||||
{ page: 0, size: 50 }
|
||||
);
|
||||
setReviews(result.content);
|
||||
setTotal(result.totalElements);
|
||||
} catch {
|
||||
message.error("加载审核列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [graphId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadReviews();
|
||||
}, [loadReviews]);
|
||||
|
||||
const handleApprove = useCallback(
|
||||
async (reviewId: string) => {
|
||||
try {
|
||||
await api.approveReview(graphId, reviewId);
|
||||
message.success("审核通过");
|
||||
loadReviews();
|
||||
} catch {
|
||||
message.error("审核操作失败");
|
||||
}
|
||||
},
|
||||
[graphId, loadReviews]
|
||||
);
|
||||
|
||||
const handleReject = useCallback(
|
||||
async (reviewId: string, comment: string) => {
|
||||
try {
|
||||
await api.rejectReview(graphId, reviewId, { comment });
|
||||
message.success("已拒绝");
|
||||
loadReviews();
|
||||
} catch {
|
||||
message.error("审核操作失败");
|
||||
}
|
||||
},
|
||||
[graphId, loadReviews]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
待审核: {total}
|
||||
</span>
|
||||
<Button size="small" onClick={loadReviews}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{reviews.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={reviews}
|
||||
renderItem={(review) => (
|
||||
<ReviewItem
|
||||
review={review}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description="暂无待审核项"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewItem({
|
||||
review,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
review: EditReviewVO;
|
||||
onApprove: (id: string) => void;
|
||||
onReject: (id: string, comment: string) => void;
|
||||
}) {
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
|
||||
const payload = review.payload ? tryParsePayload(review.payload) : null;
|
||||
|
||||
return (
|
||||
<List.Item className="!px-2">
|
||||
<div className="flex flex-col gap-1.5 w-full">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tag color={STATUS_COLORS[review.status] ?? "default"}>
|
||||
{STATUS_LABELS[review.status] ?? review.status}
|
||||
</Tag>
|
||||
<span className="text-xs font-medium">
|
||||
{OPERATION_LABELS[review.operationType] ?? review.operationType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{payload && (
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{payload.name && <span>名称: {payload.name} </span>}
|
||||
{payload.relationType && <span>类型: {payload.relationType}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-400">
|
||||
{review.submittedBy && <span>提交人: {review.submittedBy}</span>}
|
||||
{review.createdAt && <span className="ml-2">{review.createdAt}</span>}
|
||||
</div>
|
||||
|
||||
{review.status === "PENDING" && (
|
||||
<div className="flex gap-1.5 mt-1">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<Check className="w-3 h-3" />}
|
||||
onClick={() => onApprove(review.id)}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="拒绝审核"
|
||||
description={
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="拒绝原因(可选)"
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.target.value)}
|
||||
/>
|
||||
}
|
||||
onConfirm={() => {
|
||||
onReject(review.id, rejectComment);
|
||||
setRejectComment("");
|
||||
}}
|
||||
okText="确认拒绝"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<X className="w-3 h-3" />}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function tryParsePayload(
|
||||
payload: string
|
||||
): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { ENTITY_TYPE_COLORS, DEFAULT_ENTITY_COLOR } from "./knowledge-graph.cons
|
||||
export const LARGE_GRAPH_THRESHOLD = 200;
|
||||
|
||||
/** Create the G6 v5 graph options. */
|
||||
export function createGraphOptions(container: HTMLElement) {
|
||||
export function createGraphOptions(container: HTMLElement, multiSelect = false) {
|
||||
return {
|
||||
container,
|
||||
autoFit: "view" as const,
|
||||
@@ -99,7 +99,7 @@ export function createGraphOptions(container: HTMLElement) {
|
||||
"drag-element",
|
||||
{
|
||||
type: "click-select" as const,
|
||||
multiple: false,
|
||||
multiple: multiSelect,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -7,9 +7,10 @@ import type {
|
||||
PagedResponse,
|
||||
PathVO,
|
||||
AllPathsVO,
|
||||
EditReviewVO,
|
||||
} from "./knowledge-graph.model";
|
||||
|
||||
const BASE = "/knowledge-graph";
|
||||
const BASE = "/api/knowledge-graph";
|
||||
|
||||
// ---- Entity ----
|
||||
|
||||
@@ -33,7 +34,7 @@ export function listEntitiesPaged(
|
||||
|
||||
export function createEntity(
|
||||
graphId: string,
|
||||
data: { name: string; type: string; description?: string; properties?: Record<string, unknown> }
|
||||
data: { name: string; type: string; description?: string; aliases?: string[]; properties?: Record<string, unknown>; confidence?: number }
|
||||
): Promise<GraphEntity> {
|
||||
return post(`${BASE}/${graphId}/entities`, data);
|
||||
}
|
||||
@@ -41,7 +42,7 @@ export function createEntity(
|
||||
export function updateEntity(
|
||||
graphId: string,
|
||||
entityId: string,
|
||||
data: { name?: string; type?: string; description?: string; properties?: Record<string, unknown> }
|
||||
data: { name?: string; description?: string; aliases?: string[]; properties?: Record<string, unknown>; confidence?: number }
|
||||
): Promise<GraphEntity> {
|
||||
return put(`${BASE}/${graphId}/entities/${entityId}`, data);
|
||||
}
|
||||
@@ -146,3 +147,47 @@ export function getEntityNeighbors(
|
||||
): Promise<GraphEntity[]> {
|
||||
return get(`${BASE}/${graphId}/entities/${entityId}/neighbors`, params ?? null);
|
||||
}
|
||||
|
||||
// ---- Review ----
|
||||
|
||||
export function submitReview(
|
||||
graphId: string,
|
||||
data: {
|
||||
operationType: string;
|
||||
entityId?: string;
|
||||
relationId?: string;
|
||||
payload?: string;
|
||||
}
|
||||
): Promise<EditReviewVO> {
|
||||
return post(`${BASE}/${graphId}/review/submit`, data);
|
||||
}
|
||||
|
||||
export function approveReview(
|
||||
graphId: string,
|
||||
reviewId: string,
|
||||
data?: { comment?: string }
|
||||
): Promise<EditReviewVO> {
|
||||
return post(`${BASE}/${graphId}/review/${reviewId}/approve`, data ?? {});
|
||||
}
|
||||
|
||||
export function rejectReview(
|
||||
graphId: string,
|
||||
reviewId: string,
|
||||
data?: { comment?: string }
|
||||
): Promise<EditReviewVO> {
|
||||
return post(`${BASE}/${graphId}/review/${reviewId}/reject`, data ?? {});
|
||||
}
|
||||
|
||||
export function listPendingReviews(
|
||||
graphId: string,
|
||||
params?: { page?: number; size?: number }
|
||||
): Promise<PagedResponse<EditReviewVO>> {
|
||||
return get(`${BASE}/${graphId}/review/pending`, params ?? null);
|
||||
}
|
||||
|
||||
export function listReviews(
|
||||
graphId: string,
|
||||
params?: { status?: string; page?: number; size?: number }
|
||||
): Promise<PagedResponse<EditReviewVO>> {
|
||||
return get(`${BASE}/${graphId}/review`, params ?? null);
|
||||
}
|
||||
|
||||
@@ -79,3 +79,30 @@ export interface AllPathsVO {
|
||||
paths: PathVO[];
|
||||
pathCount: number;
|
||||
}
|
||||
|
||||
// ---- Edit Review ----
|
||||
|
||||
export type ReviewOperationType =
|
||||
| "CREATE_ENTITY"
|
||||
| "UPDATE_ENTITY"
|
||||
| "DELETE_ENTITY"
|
||||
| "CREATE_RELATION"
|
||||
| "UPDATE_RELATION"
|
||||
| "DELETE_RELATION";
|
||||
|
||||
export type ReviewStatus = "PENDING" | "APPROVED" | "REJECTED";
|
||||
|
||||
export interface EditReviewVO {
|
||||
id: string;
|
||||
graphId: string;
|
||||
operationType: ReviewOperationType;
|
||||
entityId?: string;
|
||||
relationId?: string;
|
||||
payload?: string;
|
||||
status: ReviewStatus;
|
||||
submittedBy?: string;
|
||||
reviewedBy?: string;
|
||||
reviewComment?: string;
|
||||
createdAt?: string;
|
||||
reviewedAt?: string;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ export default defineConfig({
|
||||
// "Origin, X-Requested-With, Content-Type, Accept",
|
||||
// },
|
||||
proxy: {
|
||||
"^/knowledge-graph": {
|
||||
"/api/knowledge-graph": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
"^/api": {
|
||||
target: "http://localhost:8080", // 本地后端服务地址
|
||||
|
||||
Reference in New Issue
Block a user