You've already forked DataMate
feat(annotation): 实现自定义数据标注结果面板
实现功能: - 替换 Label Studio 自带的侧边栏,使用自定义结果面板 - 支持通用区域关系标注(任意标注区域之间建立关系) - 实时同步 Label Studio 的标注变更 - 双向联动:点击面板区域可高亮 LS 内对应区域,反之亦然 - 快捷标注关系:关系拾取模式、CRUD 操作、自动切换 Tab - 保存联动:自动合并面板关系到标注结果,判断是否已标注 技术实现: - 新增 5 个组件: - annotation-result.types.ts: TypeScript 类型定义 - RegionList.tsx: 区域列表组件 - RelationEditor.tsx: 关系编辑弹窗 - RelationList.tsx: 关系列表组件 - AnnotationResultPanel.tsx: 主面板组件(300px,可折叠,Tabs 切换) - 修改 2 个文件: - lsf.html: 消息协议扩展、防抖广播、区域选择监听、事件绑定/解绑 - LabelStudioTextEditor.tsx: 移除 LS 侧边栏、集成自定义面板、消息处理、taskId 校验 关键设计: - 单向读取 + 保存时合并:避免复杂的双向同步 - _source: 'panel' 标记:区分面板创建的关系和 LS 原生关系 - 150ms 防抖广播:避免消息洪泛 - 幂等事件绑定:避免监听器累积 - taskId 校验:防止跨任务消息混乱 代码审查: - 经过 3 轮 codex 审查,所有问题已修复 - 构建成功,Lint 检查通过 - 事件绑定/解绑结构清晰,幂等性处理合理 - 跨任务消息校验与状态更新路径一致性明显提升
This commit is contained in:
@@ -59,6 +59,151 @@
|
|||||||
|
|
||||||
let lsInstance = null;
|
let lsInstance = null;
|
||||||
let currentTask = null;
|
let currentTask = null;
|
||||||
|
let broadcastTimer = null;
|
||||||
|
let lastSelectedRegionId = null;
|
||||||
|
let regionSelectionBound = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build display-friendly region info from a serialized result item.
|
||||||
|
*/
|
||||||
|
function buildRegionDisplay(item) {
|
||||||
|
if (!item || typeof item !== "object") return { displayText: "", displayLabel: "" };
|
||||||
|
var val = item.value || {};
|
||||||
|
var displayLabel = "";
|
||||||
|
var displayText = "";
|
||||||
|
|
||||||
|
// Extract label
|
||||||
|
if (Array.isArray(val.labels) && val.labels.length > 0) {
|
||||||
|
displayLabel = val.labels.join(", ");
|
||||||
|
} else if (Array.isArray(val.choices) && val.choices.length > 0) {
|
||||||
|
displayLabel = val.choices.join(", ");
|
||||||
|
} else if (Array.isArray(val.taxonomy) && val.taxonomy.length > 0) {
|
||||||
|
displayLabel = val.taxonomy.map(function(t) { return Array.isArray(t) ? t.join("/") : String(t); }).join(", ");
|
||||||
|
} else if (val.rating !== undefined) {
|
||||||
|
displayLabel = "Rating: " + val.rating;
|
||||||
|
} else {
|
||||||
|
displayLabel = item.type || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text
|
||||||
|
if (typeof val.text === "string") {
|
||||||
|
displayText = val.text.length > 80 ? val.text.substring(0, 80) + "..." : val.text;
|
||||||
|
} else if (typeof val.textarea === "string") {
|
||||||
|
displayText = val.textarea.length > 80 ? val.textarea.substring(0, 80) + "..." : val.textarea;
|
||||||
|
} else if (Array.isArray(val.text)) {
|
||||||
|
displayText = val.text.join(" ").substring(0, 80);
|
||||||
|
} else if (val.x !== undefined && val.y !== undefined) {
|
||||||
|
displayText = "(" + Math.round(val.x) + ", " + Math.round(val.y) + ")";
|
||||||
|
if (val.width !== undefined) displayText += " " + Math.round(val.width) + "x" + Math.round(val.height);
|
||||||
|
} else if (val.start !== undefined && val.end !== undefined) {
|
||||||
|
displayText = "[" + val.start + ":" + val.end + "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { displayText: displayText, displayLabel: displayLabel };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and broadcast region selection changes.
|
||||||
|
* LS 1.7.1 has no onSelectRegion callback, so we poll the store.
|
||||||
|
*/
|
||||||
|
function checkRegionSelection() {
|
||||||
|
try {
|
||||||
|
if (!lsInstance) return;
|
||||||
|
var store = pickAnnotationStore(lsInstance);
|
||||||
|
var annotation = resolveSelectedAnnotation(store);
|
||||||
|
if (!annotation) return;
|
||||||
|
// LS stores the selected region on the annotation object
|
||||||
|
var selected = annotation.highlightedNode || null;
|
||||||
|
var regionId = (selected && selected.id) ? String(selected.id) : null;
|
||||||
|
var taskId = typeof currentTask?.id === "number" ? currentTask.id : Number(currentTask?.id) || null;
|
||||||
|
if (regionId !== lastSelectedRegionId) {
|
||||||
|
lastSelectedRegionId = regionId;
|
||||||
|
postToParent("LS_REGION_SELECTED", { regionId: regionId, taskId: taskId });
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLsClick() {
|
||||||
|
setTimeout(checkRegionSelection, 50);
|
||||||
|
}
|
||||||
|
function handleLsMouseup() {
|
||||||
|
setTimeout(checkRegionSelection, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRegionSelectionListeners() {
|
||||||
|
if (regionSelectionBound) return;
|
||||||
|
var lsRoot = document.getElementById("label-studio");
|
||||||
|
if (!lsRoot) return;
|
||||||
|
lsRoot.addEventListener("click", handleLsClick);
|
||||||
|
lsRoot.addEventListener("mouseup", handleLsMouseup);
|
||||||
|
regionSelectionBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindRegionSelectionListeners() {
|
||||||
|
var lsRoot = document.getElementById("label-studio");
|
||||||
|
if (lsRoot) {
|
||||||
|
lsRoot.removeEventListener("click", handleLsClick);
|
||||||
|
lsRoot.removeEventListener("mouseup", handleLsMouseup);
|
||||||
|
}
|
||||||
|
regionSelectionBound = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast current annotation state to parent (debounced).
|
||||||
|
*/
|
||||||
|
function broadcastAnnotationState() {
|
||||||
|
if (broadcastTimer) clearTimeout(broadcastTimer);
|
||||||
|
broadcastTimer = setTimeout(function() {
|
||||||
|
broadcastTimer = null;
|
||||||
|
try {
|
||||||
|
if (!lsInstance) return;
|
||||||
|
var store = pickAnnotationStore(lsInstance);
|
||||||
|
if (!store) return;
|
||||||
|
var selected = resolveSelectedAnnotation(store);
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
var serialized = null;
|
||||||
|
if (typeof selected.serializeAnnotation === "function") {
|
||||||
|
serialized = selected.serializeAnnotation();
|
||||||
|
} else if (typeof selected.serialize === "function") {
|
||||||
|
serialized = selected.serialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = [];
|
||||||
|
if (Array.isArray(serialized)) {
|
||||||
|
result = serialized;
|
||||||
|
} else if (serialized && typeof serialized === "object") {
|
||||||
|
result = Array.isArray(serialized.result) ? serialized.result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskId = typeof currentTask?.id === "number" ? currentTask.id : Number(currentTask?.id) || null;
|
||||||
|
|
||||||
|
// Build region info (exclude relations)
|
||||||
|
var regions = [];
|
||||||
|
for (var i = 0; i < result.length; i++) {
|
||||||
|
var item = result[i];
|
||||||
|
if (item && item.type !== "relation") {
|
||||||
|
var display = buildRegionDisplay(item);
|
||||||
|
regions.push({
|
||||||
|
id: item.id || ("r" + i),
|
||||||
|
type: item.type || "",
|
||||||
|
from_name: item.from_name || "",
|
||||||
|
to_name: item.to_name || "",
|
||||||
|
value: item.value || {},
|
||||||
|
displayText: display.displayText,
|
||||||
|
displayLabel: display.displayLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
postToParent("LS_ANNOTATION_CHANGED", {
|
||||||
|
taskId: taskId,
|
||||||
|
result: result,
|
||||||
|
regions: regions,
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
function postToParent(type, payload) {
|
function postToParent(type, payload) {
|
||||||
window.parent.postMessage({ type, payload }, ORIGIN);
|
window.parent.postMessage({ type, payload }, ORIGIN);
|
||||||
@@ -78,6 +223,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function destroyLabelStudio() {
|
function destroyLabelStudio() {
|
||||||
|
unbindRegionSelectionListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (lsInstance && typeof lsInstance.destroy === "function") {
|
if (lsInstance && typeof lsInstance.destroy === "function") {
|
||||||
lsInstance.destroy();
|
lsInstance.destroy();
|
||||||
@@ -86,6 +233,7 @@
|
|||||||
|
|
||||||
lsInstance = null;
|
lsInstance = null;
|
||||||
currentTask = null;
|
currentTask = null;
|
||||||
|
lastSelectedRegionId = null;
|
||||||
|
|
||||||
const root = document.getElementById("label-studio");
|
const root = document.getElementById("label-studio");
|
||||||
if (root) root.innerHTML = "";
|
if (root) root.innerHTML = "";
|
||||||
@@ -359,9 +507,15 @@
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Broadcast initial annotation state to parent panel
|
||||||
|
broadcastAnnotationState();
|
||||||
|
|
||||||
|
// Bind region selection listeners (idempotent - only binds once)
|
||||||
|
bindRegionSelectionListeners();
|
||||||
|
|
||||||
postToParent("LS_READY", { taskId: task?.id || null });
|
postToParent("LS_READY", { taskId: task?.id || null });
|
||||||
},
|
},
|
||||||
// 让内嵌编辑器的“提交/保存”按钮也能触发父页面保存
|
// 让内嵌编辑器的"提交/保存"按钮也能触发父页面保存
|
||||||
onSubmitAnnotation: function () {
|
onSubmitAnnotation: function () {
|
||||||
try {
|
try {
|
||||||
const raw = exportSelectedAnnotation();
|
const raw = exportSelectedAnnotation();
|
||||||
@@ -370,6 +524,23 @@
|
|||||||
postToParent("LS_ERROR", { message: e?.message || String(e) });
|
postToParent("LS_ERROR", { message: e?.message || String(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Annotation change callbacks -> broadcast to parent panel
|
||||||
|
onUpdateAnnotation: function () {
|
||||||
|
broadcastAnnotationState();
|
||||||
|
setTimeout(checkRegionSelection, 50);
|
||||||
|
},
|
||||||
|
onEntityCreate: function () {
|
||||||
|
broadcastAnnotationState();
|
||||||
|
setTimeout(checkRegionSelection, 50);
|
||||||
|
},
|
||||||
|
onEntityDelete: function () {
|
||||||
|
broadcastAnnotationState();
|
||||||
|
setTimeout(checkRegionSelection, 50);
|
||||||
|
},
|
||||||
|
onSelectAnnotation: function () {
|
||||||
|
broadcastAnnotationState();
|
||||||
|
setTimeout(checkRegionSelection, 50);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +586,26 @@
|
|||||||
postToParent("LS_PONG", {});
|
postToParent("LS_PONG", {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === "LS_SELECT_REGION") {
|
||||||
|
var regionId = msg.payload && msg.payload.regionId;
|
||||||
|
if (regionId && lsInstance) {
|
||||||
|
var regionStore = pickAnnotationStore(lsInstance);
|
||||||
|
var annotation = resolveSelectedAnnotation(regionStore);
|
||||||
|
if (annotation && annotation.regionStore) {
|
||||||
|
var regions = annotation.regionStore.regions || [];
|
||||||
|
for (var ri = 0; ri < regions.length; ri++) {
|
||||||
|
if (String(regions[ri].id) === String(regionId)) {
|
||||||
|
if (typeof regions[ri].selectRegion === "function") {
|
||||||
|
regions[ri].selectRegion();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
postToParent("LS_ERROR", { message: e?.message || String(e) });
|
postToParent("LS_ERROR", { message: e?.message || String(e) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import {
|
|||||||
type UseNewVersionResponse,
|
type UseNewVersionResponse,
|
||||||
} from "../annotation.api";
|
} from "../annotation.api";
|
||||||
import { AnnotationResultStatus } from "../annotation.model";
|
import { AnnotationResultStatus } from "../annotation.model";
|
||||||
|
import AnnotationResultPanel from "./components/AnnotationResultPanel";
|
||||||
|
import type {
|
||||||
|
LSResultItem,
|
||||||
|
LSRegionInfo,
|
||||||
|
LSAnnotationChangedPayload,
|
||||||
|
PanelRelation,
|
||||||
|
} from "./annotation-result.types";
|
||||||
|
|
||||||
type EditorProjectInfo = {
|
type EditorProjectInfo = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -225,6 +232,34 @@ const normalizeTaskListResponse = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 合并面板管理的关系到标注 annotation 中 */
|
||||||
|
const mergePanelRelations = (
|
||||||
|
annotation: Record<string, unknown>,
|
||||||
|
relations: PanelRelation[],
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const result = Array.isArray(annotation.result) ? [...(annotation.result as LSResultItem[])] : [];
|
||||||
|
// 始终移除旧的面板关系(即使 relations 为空也要清除,否则删光关系后保存不生效)
|
||||||
|
const filtered = result.filter(
|
||||||
|
(item) => !(item.type === "relation" && item._source === "panel"),
|
||||||
|
);
|
||||||
|
// 添加当前面板关系
|
||||||
|
for (const rel of relations) {
|
||||||
|
filtered.push({
|
||||||
|
id: rel.id,
|
||||||
|
from_name: "relation",
|
||||||
|
to_name: "relation",
|
||||||
|
type: "relation",
|
||||||
|
from_id: rel.fromRegionId,
|
||||||
|
to_id: rel.toRegionId,
|
||||||
|
direction: rel.direction,
|
||||||
|
labels: rel.labels,
|
||||||
|
value: {},
|
||||||
|
_source: "panel",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ...annotation, result: filtered };
|
||||||
|
};
|
||||||
|
|
||||||
export default function LabelStudioTextEditor() {
|
export default function LabelStudioTextEditor() {
|
||||||
const { projectId = "" } = useParams();
|
const { projectId = "" } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -280,6 +315,13 @@ export default function LabelStudioTextEditor() {
|
|||||||
const [, setCheckingFileVersion] = useState(false);
|
const [, setCheckingFileVersion] = useState(false);
|
||||||
const [usingNewVersion, setUsingNewVersion] = useState(false);
|
const [usingNewVersion, setUsingNewVersion] = useState(false);
|
||||||
|
|
||||||
|
// 自定义标注结果面板状态
|
||||||
|
const [annotationResult, setAnnotationResult] = useState<LSResultItem[]>([]);
|
||||||
|
const [lsRegions, setLsRegions] = useState<LSRegionInfo[]>([]);
|
||||||
|
const [selectedRegionId, setSelectedRegionId] = useState<string | null>(null);
|
||||||
|
const [panelRelations, setPanelRelations] = useState<PanelRelation[]>([]);
|
||||||
|
const [resultPanelCollapsed, setResultPanelCollapsed] = useState(false);
|
||||||
|
|
||||||
const focusIframe = useCallback(() => {
|
const focusIframe = useCallback(() => {
|
||||||
const iframe = iframeRef.current;
|
const iframe = iframeRef.current;
|
||||||
if (!iframe) return;
|
if (!iframe) return;
|
||||||
@@ -293,6 +335,22 @@ export default function LabelStudioTextEditor() {
|
|||||||
win.postMessage({ type, payload }, origin);
|
win.postMessage({ type, payload }, origin);
|
||||||
}, [origin]);
|
}, [origin]);
|
||||||
|
|
||||||
|
const handleSelectRegionInIframe = useCallback((regionId: string) => {
|
||||||
|
postToIframe("LS_SELECT_REGION", { regionId });
|
||||||
|
setSelectedRegionId(regionId);
|
||||||
|
}, [postToIframe]);
|
||||||
|
|
||||||
|
// 从 labelConfig 解析可用关系标签
|
||||||
|
const availableRelationLabels = useMemo(() => {
|
||||||
|
if (!project?.labelConfig) return [];
|
||||||
|
const labels: string[] = [];
|
||||||
|
const matches = project.labelConfig.matchAll(/<Relation\s[^>]*value="([^"]+)"/gi);
|
||||||
|
for (const m of matches) {
|
||||||
|
if (m[1]) labels.push(m[1]);
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}, [project?.labelConfig]);
|
||||||
|
|
||||||
const confirmEmptyAnnotationStatus = useCallback(() => {
|
const confirmEmptyAnnotationStatus = useCallback(() => {
|
||||||
return new Promise<AnnotationResultStatus | null>((resolve) => {
|
return new Promise<AnnotationResultStatus | null>((resolve) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
@@ -520,6 +578,25 @@ export default function LabelStudioTextEditor() {
|
|||||||
savedSnapshotsRef.current[buildSnapshotKey(fileId, segmentIndex)] =
|
savedSnapshotsRef.current[buildSnapshotKey(fileId, segmentIndex)] =
|
||||||
buildAnnotationSnapshot(initialAnnotation);
|
buildAnnotationSnapshot(initialAnnotation);
|
||||||
|
|
||||||
|
// 重置自定义面板状态
|
||||||
|
setAnnotationResult([]);
|
||||||
|
setLsRegions([]);
|
||||||
|
setSelectedRegionId(null);
|
||||||
|
// 从已有标注中提取面板管理的关系
|
||||||
|
const existingResult = Array.isArray(initialAnnotation?.result) ? initialAnnotation.result as LSResultItem[] : [];
|
||||||
|
setPanelRelations(
|
||||||
|
existingResult
|
||||||
|
.filter((item) => item.type === "relation" && item._source === "panel" && item.from_id && item.to_id)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
fromRegionId: item.from_id!,
|
||||||
|
toRegionId: item.to_id!,
|
||||||
|
direction: item.direction || "right",
|
||||||
|
labels: item.labels || [],
|
||||||
|
source: "panel" as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
expectedTaskIdRef.current = Number(taskForIframe?.id) || null;
|
expectedTaskIdRef.current = Number(taskForIframe?.id) || null;
|
||||||
postToIframe("LS_INIT", {
|
postToIframe("LS_INIT", {
|
||||||
labelConfig: project.labelConfig,
|
labelConfig: project.labelConfig,
|
||||||
@@ -532,8 +609,8 @@ export default function LabelStudioTextEditor() {
|
|||||||
"update", // 更新按钮
|
"update", // 更新按钮
|
||||||
"submit", // 提交按钮
|
"submit", // 提交按钮
|
||||||
"controls", // 控制面板
|
"controls", // 控制面板
|
||||||
// 侧边栏(包含 Outliner 和 Details)
|
// 侧边栏已由自定义面板替代,不再启用
|
||||||
"side-column",
|
// "side-column",
|
||||||
// 标注管理
|
// 标注管理
|
||||||
"annotations:tabs",
|
"annotations:tabs",
|
||||||
"annotations:menu",
|
"annotations:menu",
|
||||||
@@ -716,7 +793,9 @@ export default function LabelStudioTextEditor() {
|
|||||||
const currentTask = tasks.find((item) => item.fileId === String(fileId));
|
const currentTask = tasks.find((item) => item.fileId === String(fileId));
|
||||||
const currentStatus = currentTask?.annotationStatus;
|
const currentStatus = currentTask?.annotationStatus;
|
||||||
let resolvedStatus: AnnotationResultStatus;
|
let resolvedStatus: AnnotationResultStatus;
|
||||||
if (isAnnotationResultEmpty(annotationRecord)) {
|
// 判断是否为空标注:LS 结果为空且面板关系也为空
|
||||||
|
const isEmpty = isAnnotationResultEmpty(annotationRecord) && panelRelations.length === 0;
|
||||||
|
if (isEmpty) {
|
||||||
if (
|
if (
|
||||||
currentStatus === AnnotationResultStatus.NO_ANNOTATION ||
|
currentStatus === AnnotationResultStatus.NO_ANNOTATION ||
|
||||||
currentStatus === AnnotationResultStatus.NOT_APPLICABLE
|
currentStatus === AnnotationResultStatus.NOT_APPLICABLE
|
||||||
@@ -733,8 +812,11 @@ export default function LabelStudioTextEditor() {
|
|||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
// 合并面板管理的关系到标注结果中
|
||||||
|
const mergedAnnotation = mergePanelRelations(annotationRecord, panelRelations);
|
||||||
|
|
||||||
const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), {
|
const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), {
|
||||||
annotation,
|
annotation: mergedAnnotation,
|
||||||
segmentIndex,
|
segmentIndex,
|
||||||
annotationStatus: resolvedStatus,
|
annotationStatus: resolvedStatus,
|
||||||
})) as ApiResponse<UpsertAnnotationResponse>;
|
})) as ApiResponse<UpsertAnnotationResponse>;
|
||||||
@@ -773,6 +855,7 @@ export default function LabelStudioTextEditor() {
|
|||||||
confirmEmptyAnnotationStatus,
|
confirmEmptyAnnotationStatus,
|
||||||
currentSegmentIndex,
|
currentSegmentIndex,
|
||||||
message,
|
message,
|
||||||
|
panelRelations,
|
||||||
projectId,
|
projectId,
|
||||||
segmented,
|
segmented,
|
||||||
selectedFileId,
|
selectedFileId,
|
||||||
@@ -819,6 +902,11 @@ export default function LabelStudioTextEditor() {
|
|||||||
setSegmented(false);
|
setSegmented(false);
|
||||||
setCurrentSegmentIndex(0);
|
setCurrentSegmentIndex(0);
|
||||||
setSegmentTotal(0);
|
setSegmentTotal(0);
|
||||||
|
// 重置自定义面板状态
|
||||||
|
setAnnotationResult([]);
|
||||||
|
setLsRegions([]);
|
||||||
|
setSelectedRegionId(null);
|
||||||
|
setPanelRelations([]);
|
||||||
savedSnapshotsRef.current = {};
|
savedSnapshotsRef.current = {};
|
||||||
if (exportCheckRef.current?.timer) {
|
if (exportCheckRef.current?.timer) {
|
||||||
window.clearTimeout(exportCheckRef.current.timer);
|
window.clearTimeout(exportCheckRef.current.timer);
|
||||||
@@ -911,6 +999,31 @@ export default function LabelStudioTextEditor() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义面板:标注变更同步
|
||||||
|
if (msg.type === "LS_ANNOTATION_CHANGED") {
|
||||||
|
const changed = msg.payload as LSAnnotationChangedPayload | undefined;
|
||||||
|
if (changed) {
|
||||||
|
// 校验 taskId:只要当前有预期 taskId,就要求消息携带且匹配,否则丢弃
|
||||||
|
if (expectedTaskIdRef.current) {
|
||||||
|
if (!changed.taskId || Number(changed.taskId) !== expectedTaskIdRef.current) return;
|
||||||
|
}
|
||||||
|
setAnnotationResult(changed.result || []);
|
||||||
|
setLsRegions(changed.regions || []);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义面板:区域选中同步
|
||||||
|
if (msg.type === "LS_REGION_SELECTED") {
|
||||||
|
const regionPayload = msg.payload as { regionId?: string | null; taskId?: number | null } | undefined;
|
||||||
|
// 校验 taskId,防止旧任务的延迟消息影响当前面板
|
||||||
|
if (expectedTaskIdRef.current) {
|
||||||
|
if (!regionPayload?.taskId || Number(regionPayload.taskId) !== expectedTaskIdRef.current) return;
|
||||||
|
}
|
||||||
|
setSelectedRegionId(regionPayload?.regionId || null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === "LS_ERROR") {
|
if (msg.type === "LS_ERROR") {
|
||||||
const payloadMessage = resolvePayloadMessage(msg.payload);
|
const payloadMessage = resolvePayloadMessage(msg.payload);
|
||||||
message.error(payloadMessage || "编辑器发生错误");
|
message.error(payloadMessage || "编辑器发生错误");
|
||||||
@@ -1058,6 +1171,11 @@ export default function LabelStudioTextEditor() {
|
|||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={resultPanelCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
onClick={() => setResultPanelCollapsed(!resultPanelCollapsed)}
|
||||||
|
title={resultPanelCollapsed ? "展开标注面板" : "收起标注面板"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1177,6 +1295,18 @@ export default function LabelStudioTextEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧自定义标注结果面板 */}
|
||||||
|
<AnnotationResultPanel
|
||||||
|
annotationResult={annotationResult}
|
||||||
|
lsRegions={lsRegions}
|
||||||
|
selectedRegionId={selectedRegionId}
|
||||||
|
onSelectRegion={handleSelectRegionInIframe}
|
||||||
|
availableRelationLabels={availableRelationLabels}
|
||||||
|
panelRelations={panelRelations}
|
||||||
|
onPanelRelationsChange={setPanelRelations}
|
||||||
|
collapsed={resultPanelCollapsed}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Label Studio annotation result types and custom panel types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A single item in Label Studio's annotation result[] array. */
|
||||||
|
export interface LSResultItem {
|
||||||
|
id: string;
|
||||||
|
from_name: string;
|
||||||
|
to_name: string;
|
||||||
|
type: string;
|
||||||
|
value: Record<string, unknown>;
|
||||||
|
|
||||||
|
// Relation-specific fields (type === "relation")
|
||||||
|
from_id?: string;
|
||||||
|
to_id?: string;
|
||||||
|
direction?: "right" | "left" | "bi";
|
||||||
|
labels?: string[];
|
||||||
|
|
||||||
|
// Meta fields
|
||||||
|
origin?: string;
|
||||||
|
score?: number;
|
||||||
|
readonly?: boolean;
|
||||||
|
/** Marker for panel-created relations */
|
||||||
|
_source?: "panel";
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Region info sent from iframe for display in the panel. */
|
||||||
|
export interface LSRegionInfo {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
from_name: string;
|
||||||
|
to_name: string;
|
||||||
|
value: Record<string, unknown>;
|
||||||
|
displayText: string;
|
||||||
|
displayLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Region representation for the panel UI. */
|
||||||
|
export interface PanelRegion {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
fromName: string;
|
||||||
|
toName: string;
|
||||||
|
displayText: string;
|
||||||
|
displayLabel: string;
|
||||||
|
value: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relation representation for the panel UI. */
|
||||||
|
export interface PanelRelation {
|
||||||
|
id: string;
|
||||||
|
fromRegionId: string;
|
||||||
|
toRegionId: string;
|
||||||
|
direction: "right" | "left" | "bi";
|
||||||
|
labels: string[];
|
||||||
|
source: "ls" | "panel";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload of LS_ANNOTATION_CHANGED message (iframe -> parent). */
|
||||||
|
export interface LSAnnotationChangedPayload {
|
||||||
|
taskId: number | null;
|
||||||
|
result: LSResultItem[];
|
||||||
|
regions: LSRegionInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload of LS_SELECT_REGION message (parent -> iframe). */
|
||||||
|
export interface LSSelectRegionPayload {
|
||||||
|
regionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload of LS_REGION_SELECTED message (iframe -> parent). */
|
||||||
|
export interface LSRegionSelectedPayload {
|
||||||
|
regionId: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Badge, Tabs, Typography, Alert } from "antd";
|
||||||
|
import RegionList from "./RegionList";
|
||||||
|
import RelationList from "./RelationList";
|
||||||
|
import RelationEditor from "./RelationEditor";
|
||||||
|
import type {
|
||||||
|
LSResultItem,
|
||||||
|
LSRegionInfo,
|
||||||
|
PanelRegion,
|
||||||
|
PanelRelation,
|
||||||
|
} from "../annotation-result.types";
|
||||||
|
|
||||||
|
interface AnnotationResultPanelProps {
|
||||||
|
annotationResult: LSResultItem[];
|
||||||
|
lsRegions: LSRegionInfo[];
|
||||||
|
selectedRegionId: string | null;
|
||||||
|
onSelectRegion: (regionId: string) => void;
|
||||||
|
availableRelationLabels: string[];
|
||||||
|
panelRelations: PanelRelation[];
|
||||||
|
onPanelRelationsChange: (relations: PanelRelation[]) => void;
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let relationIdCounter = 0;
|
||||||
|
const genRelationId = () => `panel_rel_${Date.now()}_${++relationIdCounter}`;
|
||||||
|
|
||||||
|
/** Convert LSRegionInfo from iframe to PanelRegion for display. */
|
||||||
|
const toPanelRegion = (info: LSRegionInfo): PanelRegion => ({
|
||||||
|
id: info.id,
|
||||||
|
type: info.type,
|
||||||
|
fromName: info.from_name,
|
||||||
|
toName: info.to_name,
|
||||||
|
displayText: info.displayText,
|
||||||
|
displayLabel: info.displayLabel,
|
||||||
|
value: info.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Extract LS-native relations from result array (excluding panel-managed ones). */
|
||||||
|
const extractLsRelations = (result: LSResultItem[], panelRelationIds: Set<string>): PanelRelation[] =>
|
||||||
|
result
|
||||||
|
.filter((item) =>
|
||||||
|
item.type === "relation" &&
|
||||||
|
item.from_id &&
|
||||||
|
item.to_id &&
|
||||||
|
item._source !== "panel" &&
|
||||||
|
!panelRelationIds.has(item.id),
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
fromRegionId: item.from_id!,
|
||||||
|
toRegionId: item.to_id!,
|
||||||
|
direction: item.direction || "right",
|
||||||
|
labels: item.labels || [],
|
||||||
|
source: "ls" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function AnnotationResultPanel({
|
||||||
|
annotationResult,
|
||||||
|
lsRegions,
|
||||||
|
selectedRegionId,
|
||||||
|
onSelectRegion,
|
||||||
|
availableRelationLabels,
|
||||||
|
panelRelations,
|
||||||
|
onPanelRelationsChange,
|
||||||
|
collapsed,
|
||||||
|
}: AnnotationResultPanelProps) {
|
||||||
|
const [relationPickMode, setRelationPickMode] = useState(false);
|
||||||
|
const [pickedRegionIds, setPickedRegionIds] = useState<string[]>([]);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editingRelation, setEditingRelation] = useState<PanelRelation | null>(null);
|
||||||
|
const [activeTabKey, setActiveTabKey] = useState("regions");
|
||||||
|
|
||||||
|
const regions = useMemo(
|
||||||
|
() => lsRegions.map(toPanelRegion),
|
||||||
|
[lsRegions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const panelRelationIds = useMemo(
|
||||||
|
() => new Set(panelRelations.map((r) => r.id)),
|
||||||
|
[panelRelations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const lsRelations = useMemo(
|
||||||
|
() => extractLsRelations(annotationResult, panelRelationIds),
|
||||||
|
[annotationResult, panelRelationIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const allRelations = useMemo(
|
||||||
|
() => [...lsRelations, ...panelRelations],
|
||||||
|
[lsRelations, panelRelations],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Relation pick mode ---
|
||||||
|
const handleStartAddRelation = useCallback(() => {
|
||||||
|
if (regions.length < 2) {
|
||||||
|
// Not enough regions, open editor directly
|
||||||
|
setEditingRelation(null);
|
||||||
|
setEditorOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRelationPickMode(true);
|
||||||
|
setPickedRegionIds([]);
|
||||||
|
// 自动切到区域 Tab,让用户看到拾取提示
|
||||||
|
setActiveTabKey("regions");
|
||||||
|
}, [regions.length]);
|
||||||
|
|
||||||
|
const handlePickRegion = useCallback(
|
||||||
|
(regionId: string) => {
|
||||||
|
setPickedRegionIds((prev) => {
|
||||||
|
if (prev.includes(regionId)) {
|
||||||
|
return prev.filter((id) => id !== regionId);
|
||||||
|
}
|
||||||
|
const next = [...prev, regionId];
|
||||||
|
if (next.length >= 2) {
|
||||||
|
// Two regions picked, open editor
|
||||||
|
setTimeout(() => {
|
||||||
|
setRelationPickMode(false);
|
||||||
|
setEditingRelation(null);
|
||||||
|
setEditorOpen(true);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
return next.slice(0, 2);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCancelPick = useCallback(() => {
|
||||||
|
setRelationPickMode(false);
|
||||||
|
setPickedRegionIds([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Relation CRUD ---
|
||||||
|
const handleConfirmRelation = useCallback(
|
||||||
|
(data: { fromRegionId: string; toRegionId: string; direction: "right" | "left" | "bi"; labels: string[] }) => {
|
||||||
|
if (editingRelation) {
|
||||||
|
onPanelRelationsChange(
|
||||||
|
panelRelations.map((r) =>
|
||||||
|
r.id === editingRelation.id
|
||||||
|
? { ...r, ...data }
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onPanelRelationsChange([
|
||||||
|
...panelRelations,
|
||||||
|
{
|
||||||
|
id: genRelationId(),
|
||||||
|
...data,
|
||||||
|
source: "panel",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setEditorOpen(false);
|
||||||
|
setEditingRelation(null);
|
||||||
|
setPickedRegionIds([]);
|
||||||
|
},
|
||||||
|
[editingRelation, onPanelRelationsChange, panelRelations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteRelation = useCallback(
|
||||||
|
(relationId: string) => {
|
||||||
|
onPanelRelationsChange(panelRelations.filter((r) => r.id !== relationId));
|
||||||
|
},
|
||||||
|
[onPanelRelationsChange, panelRelations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditRelation = useCallback((relation: PanelRelation) => {
|
||||||
|
setEditingRelation(relation);
|
||||||
|
setEditorOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditorCancel = useCallback(() => {
|
||||||
|
setEditorOpen(false);
|
||||||
|
setEditingRelation(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (collapsed) return null;
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
key: "regions",
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
区域 <Badge count={regions.length} size="small" color="#999" style={{ marginLeft: 4 }} />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div className="overflow-auto" style={{ maxHeight: "calc(100vh - 200px)" }}>
|
||||||
|
{relationPickMode && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={handleCancelPick}
|
||||||
|
message="请依次点击两个区域来建立关系"
|
||||||
|
className="mb-2 mx-1"
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<RegionList
|
||||||
|
regions={regions}
|
||||||
|
selectedRegionId={selectedRegionId}
|
||||||
|
onSelectRegion={onSelectRegion}
|
||||||
|
relationPickMode={relationPickMode}
|
||||||
|
pickedRegionIds={pickedRegionIds}
|
||||||
|
onPickRegion={handlePickRegion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "relations",
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
关系 <Badge count={allRelations.length} size="small" color="#999" style={{ marginLeft: 4 }} />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div className="overflow-auto" style={{ maxHeight: "calc(100vh - 200px)" }}>
|
||||||
|
<RelationList
|
||||||
|
relations={allRelations}
|
||||||
|
regions={regions}
|
||||||
|
onDeleteRelation={handleDeleteRelation}
|
||||||
|
onEditRelation={handleEditRelation}
|
||||||
|
onStartAddRelation={handleStartAddRelation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="border-l border-gray-200 bg-white flex flex-col transition-all duration-200 min-h-0"
|
||||||
|
style={{ width: 300 }}
|
||||||
|
>
|
||||||
|
<div className="px-3 py-2 border-b border-gray-200 font-medium text-sm flex items-center justify-between">
|
||||||
|
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||||
|
标注结果
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{regions.length} 区域 / {allRelations.length} 关系
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTabKey}
|
||||||
|
onChange={setActiveTabKey}
|
||||||
|
size="small"
|
||||||
|
className="px-1"
|
||||||
|
items={tabItems}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RelationEditor
|
||||||
|
open={editorOpen}
|
||||||
|
mode={editingRelation ? "edit" : "add"}
|
||||||
|
regions={regions}
|
||||||
|
initialFromRegionId={editingRelation?.fromRegionId || pickedRegionIds[0]}
|
||||||
|
initialToRegionId={editingRelation?.toRegionId || pickedRegionIds[1]}
|
||||||
|
initialDirection={editingRelation?.direction}
|
||||||
|
initialLabels={editingRelation?.labels}
|
||||||
|
availableRelationLabels={availableRelationLabels}
|
||||||
|
onConfirm={handleConfirmRelation}
|
||||||
|
onCancel={handleEditorCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Empty, Tag, Typography } from "antd";
|
||||||
|
import { AimOutlined } from "@ant-design/icons";
|
||||||
|
import type { PanelRegion } from "../annotation-result.types";
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
labels: "blue",
|
||||||
|
rectanglelabels: "green",
|
||||||
|
polygonlabels: "purple",
|
||||||
|
brushlabels: "orange",
|
||||||
|
ellipselabels: "cyan",
|
||||||
|
keypointlabels: "magenta",
|
||||||
|
choices: "geekblue",
|
||||||
|
textarea: "gold",
|
||||||
|
taxonomy: "lime",
|
||||||
|
rating: "volcano",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RegionListProps {
|
||||||
|
regions: PanelRegion[];
|
||||||
|
selectedRegionId: string | null;
|
||||||
|
onSelectRegion: (regionId: string) => void;
|
||||||
|
relationPickMode: boolean;
|
||||||
|
pickedRegionIds: string[];
|
||||||
|
onPickRegion?: (regionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegionList({
|
||||||
|
regions,
|
||||||
|
selectedRegionId,
|
||||||
|
onSelectRegion,
|
||||||
|
relationPickMode,
|
||||||
|
pickedRegionIds,
|
||||||
|
onPickRegion,
|
||||||
|
}: RegionListProps) {
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, PanelRegion[]>();
|
||||||
|
for (const r of regions) {
|
||||||
|
const key = r.fromName;
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(r);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [regions]);
|
||||||
|
|
||||||
|
if (regions.length === 0) {
|
||||||
|
return (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="暂无标注区域"
|
||||||
|
className="py-6"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (regionId: string) => {
|
||||||
|
if (relationPickMode && onPickRegion) {
|
||||||
|
onPickRegion(regionId);
|
||||||
|
} else {
|
||||||
|
onSelectRegion(regionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Array.from(grouped.entries()).map(([fromName, items]) => (
|
||||||
|
<div key={fromName}>
|
||||||
|
{grouped.size > 1 && (
|
||||||
|
<div className="px-2 py-1 text-xs text-gray-400 font-medium uppercase">
|
||||||
|
{fromName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.map((region) => {
|
||||||
|
const isSelected = region.id === selectedRegionId;
|
||||||
|
const isPicked = pickedRegionIds.includes(region.id);
|
||||||
|
const color = TYPE_COLORS[region.type] || "default";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={region.id}
|
||||||
|
className={[
|
||||||
|
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors",
|
||||||
|
isSelected ? "bg-blue-50 border border-blue-200" : "hover:bg-gray-50 border border-transparent",
|
||||||
|
relationPickMode ? "ring-1 ring-inset ring-orange-200" : "",
|
||||||
|
isPicked ? "bg-orange-50 border-orange-300" : "",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={() => handleClick(region.id)}
|
||||||
|
>
|
||||||
|
{relationPickMode && (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center",
|
||||||
|
isPicked ? "border-orange-500 bg-orange-500" : "border-gray-300",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{isPicked && (
|
||||||
|
<span className="text-white text-[10px] font-bold">
|
||||||
|
{pickedRegionIds.indexOf(region.id) + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!relationPickMode && isSelected && (
|
||||||
|
<AimOutlined className="text-blue-500 flex-shrink-0" style={{ fontSize: 12 }} />
|
||||||
|
)}
|
||||||
|
<Tag color={color} className="m-0 text-xs" style={{ maxWidth: 80 }}>
|
||||||
|
<Typography.Text ellipsis style={{ fontSize: 11, maxWidth: 64, color: "inherit" }}>
|
||||||
|
{region.displayLabel || region.type}
|
||||||
|
</Typography.Text>
|
||||||
|
</Tag>
|
||||||
|
<Typography.Text
|
||||||
|
ellipsis
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
style={{ fontSize: 12, color: "#555" }}
|
||||||
|
>
|
||||||
|
{region.displayText || `#${region.id.slice(0, 6)}`}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Form, Modal, Radio, Select, Typography } from "antd";
|
||||||
|
import { SwapRightOutlined, SwapLeftOutlined, SwapOutlined } from "@ant-design/icons";
|
||||||
|
import type { PanelRegion } from "../annotation-result.types";
|
||||||
|
|
||||||
|
interface RelationEditorProps {
|
||||||
|
open: boolean;
|
||||||
|
mode: "add" | "edit";
|
||||||
|
regions: PanelRegion[];
|
||||||
|
initialFromRegionId?: string;
|
||||||
|
initialToRegionId?: string;
|
||||||
|
initialDirection?: "right" | "left" | "bi";
|
||||||
|
initialLabels?: string[];
|
||||||
|
availableRelationLabels: string[];
|
||||||
|
onConfirm: (data: {
|
||||||
|
fromRegionId: string;
|
||||||
|
toRegionId: string;
|
||||||
|
direction: "right" | "left" | "bi";
|
||||||
|
labels: string[];
|
||||||
|
}) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RelationEditor({
|
||||||
|
open,
|
||||||
|
mode,
|
||||||
|
regions,
|
||||||
|
initialFromRegionId,
|
||||||
|
initialToRegionId,
|
||||||
|
initialDirection,
|
||||||
|
initialLabels,
|
||||||
|
availableRelationLabels,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: RelationEditorProps) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
fromRegionId: initialFromRegionId || undefined,
|
||||||
|
toRegionId: initialToRegionId || undefined,
|
||||||
|
direction: initialDirection || "right",
|
||||||
|
labels: initialLabels || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, form, initialFromRegionId, initialToRegionId, initialDirection, initialLabels]);
|
||||||
|
|
||||||
|
const regionOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
regions.map((r) => ({
|
||||||
|
value: r.id,
|
||||||
|
label: `${r.displayLabel || r.type} - ${r.displayText || r.id.slice(0, 8)}`,
|
||||||
|
})),
|
||||||
|
[regions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
onConfirm({
|
||||||
|
fromRegionId: values.fromRegionId,
|
||||||
|
toRegionId: values.toRegionId,
|
||||||
|
direction: values.direction,
|
||||||
|
labels: values.labels || [],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// validation failed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={mode === "add" ? "添加关系" : "编辑关系"}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={onCancel}
|
||||||
|
destroyOnClose
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
|
<Form.Item
|
||||||
|
name="fromRegionId"
|
||||||
|
label="源区域"
|
||||||
|
rules={[{ required: true, message: "请选择源区域" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="选择源区域"
|
||||||
|
options={regionOptions}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="direction"
|
||||||
|
label="方向"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio.Button value="right">
|
||||||
|
<SwapRightOutlined /> 正向
|
||||||
|
</Radio.Button>
|
||||||
|
<Radio.Button value="left">
|
||||||
|
<SwapLeftOutlined /> 反向
|
||||||
|
</Radio.Button>
|
||||||
|
<Radio.Button value="bi">
|
||||||
|
<SwapOutlined /> 双向
|
||||||
|
</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="toRegionId"
|
||||||
|
label="目标区域"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "请选择目标区域" },
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (!value || getFieldValue("fromRegionId") !== value) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error("目标区域不能与源区域相同"));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="选择目标区域"
|
||||||
|
options={regionOptions}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="labels" label="关系标签">
|
||||||
|
{availableRelationLabels.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
placeholder="选择或输入关系标签"
|
||||||
|
options={availableRelationLabels.map((l) => ({ value: l, label: l }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Select mode="tags" placeholder="输入关系标签(回车确认)" />
|
||||||
|
<Typography.Text type="secondary" className="text-xs">
|
||||||
|
当前模板未配置预定义关系标签,可手动输入
|
||||||
|
</Typography.Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Button, Empty, Popconfirm, Tag, Typography } from "antd";
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
SwapRightOutlined,
|
||||||
|
SwapLeftOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import type { PanelRegion, PanelRelation } from "../annotation-result.types";
|
||||||
|
|
||||||
|
interface RelationListProps {
|
||||||
|
relations: PanelRelation[];
|
||||||
|
regions: PanelRegion[];
|
||||||
|
onDeleteRelation: (relationId: string) => void;
|
||||||
|
onEditRelation: (relation: PanelRelation) => void;
|
||||||
|
onStartAddRelation: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionIcon = (dir: string) => {
|
||||||
|
if (dir === "left") return <SwapLeftOutlined />;
|
||||||
|
if (dir === "bi") return <SwapOutlined />;
|
||||||
|
return <SwapRightOutlined />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findRegion = (regions: PanelRegion[], id: string) =>
|
||||||
|
regions.find((r) => r.id === id);
|
||||||
|
|
||||||
|
export default function RelationList({
|
||||||
|
relations,
|
||||||
|
regions,
|
||||||
|
onDeleteRelation,
|
||||||
|
onEditRelation,
|
||||||
|
onStartAddRelation,
|
||||||
|
}: RelationListProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between px-2 py-1.5">
|
||||||
|
<Typography.Text className="text-xs text-gray-500">
|
||||||
|
{relations.length} 个关系
|
||||||
|
</Typography.Text>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onStartAddRelation}
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{relations.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="暂无关系"
|
||||||
|
className="py-6"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onStartAddRelation}
|
||||||
|
>
|
||||||
|
添加关系
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{relations.map((rel) => {
|
||||||
|
const fromRegion = findRegion(regions, rel.fromRegionId);
|
||||||
|
const toRegion = findRegion(regions, rel.toRegionId);
|
||||||
|
const editable = rel.source === "panel";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rel.id}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1.5 rounded hover:bg-gray-50 group"
|
||||||
|
>
|
||||||
|
{/* From region */}
|
||||||
|
<Tag color="blue" className="m-0 text-xs flex-shrink-0" style={{ maxWidth: 80 }}>
|
||||||
|
<Typography.Text ellipsis style={{ fontSize: 11, maxWidth: 64, color: "inherit" }}>
|
||||||
|
{fromRegion?.displayLabel || rel.fromRegionId.slice(0, 6)}
|
||||||
|
</Typography.Text>
|
||||||
|
</Tag>
|
||||||
|
|
||||||
|
{/* Direction */}
|
||||||
|
<span className="text-gray-400 flex-shrink-0">
|
||||||
|
{directionIcon(rel.direction)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* To region */}
|
||||||
|
<Tag color="green" className="m-0 text-xs flex-shrink-0" style={{ maxWidth: 80 }}>
|
||||||
|
<Typography.Text ellipsis style={{ fontSize: 11, maxWidth: 64, color: "inherit" }}>
|
||||||
|
{toRegion?.displayLabel || rel.toRegionId.slice(0, 6)}
|
||||||
|
</Typography.Text>
|
||||||
|
</Tag>
|
||||||
|
|
||||||
|
{/* Relation labels */}
|
||||||
|
<div className="flex-1 flex items-center gap-0.5 overflow-hidden">
|
||||||
|
{rel.labels.map((label) => (
|
||||||
|
<Tag key={label} className="m-0 text-xs" style={{ fontSize: 10 }}>
|
||||||
|
{label}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{editable && (
|
||||||
|
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined style={{ fontSize: 12 }} />}
|
||||||
|
onClick={() => onEditRelation(rel)}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除该关系?"
|
||||||
|
onConfirm={() => onDeleteRelation(rel.id)}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined style={{ fontSize: 12 }} />}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!editable && (
|
||||||
|
<Tag className="m-0 flex-shrink-0" style={{ fontSize: 9 }}>LS</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user