You've already forked DataMate
- 在 EditorProjectInfo 中增加 datasetType 字段 - 移除前端硬编码的源文档扩展名列表 - 添加 isTextProject 判断逻辑 - 实现 prefetch 和 loadTasks 中的源文档排除参数 - 在后端接口中添加 excludeSourceDocuments 参数 - 实现源文档类型的数据库查询过滤逻辑 - 优化任务列表加载性能,避免不必要的源文档加载
1119 lines
36 KiB
TypeScript
1119 lines
36 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { App, Button, Card, List, Spin, Typography, Tag, Switch, Tree, Empty } from "antd";
|
|
import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined, CheckOutlined } from "@ant-design/icons";
|
|
import { useNavigate, useParams } from "react-router";
|
|
|
|
import {
|
|
getEditorProjectInfoUsingGet,
|
|
getEditorTaskUsingGet,
|
|
listEditorTasksUsingGet,
|
|
upsertEditorAnnotationUsingPut,
|
|
} from "../annotation.api";
|
|
|
|
type EditorProjectInfo = {
|
|
projectId: string;
|
|
datasetId: string;
|
|
datasetType?: string | null;
|
|
templateId?: string | null;
|
|
labelConfig?: string | null;
|
|
supported: boolean;
|
|
unsupportedReason?: string | null;
|
|
};
|
|
|
|
type EditorTaskListItem = {
|
|
fileId: string;
|
|
fileName: string;
|
|
fileType?: string | null;
|
|
hasAnnotation: boolean;
|
|
annotationUpdatedAt?: string | null;
|
|
};
|
|
|
|
type LsfMessage = {
|
|
type?: string;
|
|
payload?: unknown;
|
|
};
|
|
|
|
type SegmentInfo = {
|
|
idx: number;
|
|
text: string;
|
|
start: number;
|
|
end: number;
|
|
hasAnnotation: boolean;
|
|
lineIndex: number;
|
|
chunkIndex: number;
|
|
};
|
|
|
|
type ApiResponse<T> = {
|
|
code?: number;
|
|
message?: string;
|
|
data?: T;
|
|
};
|
|
|
|
type EditorTaskPayload = {
|
|
id?: number | string;
|
|
data?: Record<string, unknown>;
|
|
annotations?: unknown[];
|
|
};
|
|
|
|
type EditorTaskResponse = {
|
|
task?: EditorTaskPayload;
|
|
segmented?: boolean;
|
|
segments?: SegmentInfo[];
|
|
currentSegmentIndex?: number;
|
|
};
|
|
|
|
type EditorTaskListResponse = {
|
|
content?: EditorTaskListItem[];
|
|
totalElements?: number;
|
|
totalPages?: number;
|
|
page?: number;
|
|
size?: number;
|
|
};
|
|
|
|
type UpsertAnnotationResponse = {
|
|
annotationId?: string;
|
|
updatedAt?: string;
|
|
};
|
|
|
|
type ExportPayload = {
|
|
taskId?: number | string | null;
|
|
fileId?: string | null;
|
|
segmentIndex?: number | string | null;
|
|
annotation?: Record<string, unknown>;
|
|
requestId?: string | null;
|
|
};
|
|
|
|
type SwitchDecision = "save" | "discard" | "cancel";
|
|
|
|
const LSF_IFRAME_SRC = "/lsf/lsf.html";
|
|
const TASK_PAGE_START = 0;
|
|
const TASK_PAGE_SIZE = 200;
|
|
|
|
type NormalizedTaskList = {
|
|
items: EditorTaskListItem[];
|
|
page: number;
|
|
size: number;
|
|
total: number;
|
|
totalPages: number;
|
|
};
|
|
|
|
const resolveSegmentIndex = (value: unknown) => {
|
|
if (value === null || value === undefined) return undefined;
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
};
|
|
|
|
const normalizePayload = (payload: unknown): ExportPayload | undefined => {
|
|
if (!payload || typeof payload !== "object") return undefined;
|
|
return payload as ExportPayload;
|
|
};
|
|
|
|
const resolvePayloadMessage = (payload: unknown) => {
|
|
if (!payload || typeof payload !== "object") return undefined;
|
|
if ("message" in payload && typeof (payload as { message?: unknown }).message === "string") {
|
|
return (payload as { message?: string }).message;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === "object" && !Array.isArray(value);
|
|
|
|
const normalizeSnapshotValue = (value: unknown, seen: WeakSet<object>): unknown => {
|
|
if (!value || typeof value !== "object") return value;
|
|
const obj = value as object;
|
|
if (seen.has(obj)) return undefined;
|
|
seen.add(obj);
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => normalizeSnapshotValue(item, seen));
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const sorted: Record<string, unknown> = {};
|
|
Object.keys(record)
|
|
.sort()
|
|
.forEach((key) => {
|
|
sorted[key] = normalizeSnapshotValue(record[key], seen);
|
|
});
|
|
return sorted;
|
|
};
|
|
|
|
const stableStringify = (value: unknown) => {
|
|
const normalized = normalizeSnapshotValue(value, new WeakSet<object>());
|
|
return JSON.stringify(normalized);
|
|
};
|
|
|
|
const buildAnnotationSnapshot = (annotation?: Record<string, unknown>) => {
|
|
if (!annotation) return "";
|
|
const cleaned: Record<string, unknown> = { ...annotation };
|
|
delete cleaned.updated_at;
|
|
delete cleaned.updatedAt;
|
|
delete cleaned.created_at;
|
|
delete cleaned.createdAt;
|
|
return stableStringify(cleaned);
|
|
};
|
|
|
|
const buildSnapshotKey = (fileId: string, segmentIndex?: number) =>
|
|
`${fileId}::${segmentIndex ?? "full"}`;
|
|
|
|
const mergeTaskItems = (base: EditorTaskListItem[], next: EditorTaskListItem[]) => {
|
|
if (next.length === 0) return base;
|
|
const seen = new Set(base.map((item) => item.fileId));
|
|
const merged = [...base];
|
|
next.forEach((item) => {
|
|
if (seen.has(item.fileId)) return;
|
|
seen.add(item.fileId);
|
|
merged.push(item);
|
|
});
|
|
return merged;
|
|
};
|
|
|
|
const normalizeTaskListResponse = (
|
|
response: ApiResponse<EditorTaskListResponse> | null | undefined,
|
|
fallbackPage: number,
|
|
): NormalizedTaskList => {
|
|
const content = response?.data?.content;
|
|
const items = Array.isArray(content) ? content : [];
|
|
const size = response?.data?.size ?? TASK_PAGE_SIZE;
|
|
const total = response?.data?.totalElements ?? items.length;
|
|
const totalPages =
|
|
response?.data?.totalPages ?? (size > 0 ? Math.ceil(total / size) : 0);
|
|
const page = response?.data?.page ?? fallbackPage;
|
|
return {
|
|
items,
|
|
page,
|
|
size,
|
|
total,
|
|
totalPages,
|
|
};
|
|
};
|
|
|
|
export default function LabelStudioTextEditor() {
|
|
const { projectId = "" } = useParams();
|
|
const navigate = useNavigate();
|
|
const { message, modal } = App.useApp();
|
|
|
|
const origin = useMemo(() => window.location.origin, []);
|
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
const initSeqRef = useRef(0);
|
|
const expectedTaskIdRef = useRef<number | null>(null);
|
|
const prefetchSeqRef = useRef(0);
|
|
const exportCheckRef = useRef<{
|
|
requestId: string;
|
|
resolve: (payload?: ExportPayload) => void;
|
|
timer?: number;
|
|
} | null>(null);
|
|
const exportCheckSeqRef = useRef(0);
|
|
const savedSnapshotsRef = useRef<Record<string, string>>({});
|
|
const pendingAutoAdvanceRef = useRef(false);
|
|
|
|
const [loadingProject, setLoadingProject] = useState(true);
|
|
const [loadingTasks, setLoadingTasks] = useState(false);
|
|
const [loadingTaskDetail, setLoadingTaskDetail] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [segmentSwitching, setSegmentSwitching] = useState(false);
|
|
|
|
const [iframeReady, setIframeReady] = useState(false);
|
|
const [lsReady, setLsReady] = useState(false);
|
|
const [project, setProject] = useState<EditorProjectInfo | null>(null);
|
|
const [tasks, setTasks] = useState<EditorTaskListItem[]>([]);
|
|
const [taskPage, setTaskPage] = useState(TASK_PAGE_START);
|
|
const [taskTotal, setTaskTotal] = useState(0);
|
|
const [taskTotalPages, setTaskTotalPages] = useState(0);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [prefetching, setPrefetching] = useState(false);
|
|
const [selectedFileId, setSelectedFileId] = useState<string>("");
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
const [autoSaveOnSwitch, setAutoSaveOnSwitch] = useState(false);
|
|
|
|
// 分段相关状态
|
|
const [segmented, setSegmented] = useState(false);
|
|
const [segments, setSegments] = useState<SegmentInfo[]>([]);
|
|
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0);
|
|
const isTextProject = useMemo(
|
|
() => (project?.datasetType || "").toUpperCase() === "TEXT",
|
|
[project?.datasetType],
|
|
);
|
|
|
|
const focusIframe = useCallback(() => {
|
|
const iframe = iframeRef.current;
|
|
if (!iframe) return;
|
|
iframe.focus();
|
|
iframe.contentWindow?.focus?.();
|
|
}, []);
|
|
|
|
const postToIframe = useCallback((type: string, payload?: unknown) => {
|
|
const win = iframeRef.current?.contentWindow;
|
|
if (!win) return;
|
|
win.postMessage({ type, payload }, origin);
|
|
}, [origin]);
|
|
|
|
const loadProject = useCallback(async () => {
|
|
setLoadingProject(true);
|
|
try {
|
|
const resp = (await getEditorProjectInfoUsingGet(projectId)) as ApiResponse<EditorProjectInfo>;
|
|
const data = resp?.data;
|
|
if (!data?.projectId) {
|
|
message.error("获取标注项目信息失败");
|
|
setProject(null);
|
|
return;
|
|
}
|
|
setProject(data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
message.error("获取标注项目信息失败");
|
|
setProject(null);
|
|
} finally {
|
|
setLoadingProject(false);
|
|
}
|
|
}, [message, projectId]);
|
|
|
|
const updateTaskSelection = useCallback((items: EditorTaskListItem[]) => {
|
|
const defaultFileId =
|
|
items.find((item) => !item.hasAnnotation)?.fileId || items[0]?.fileId || "";
|
|
setSelectedFileId((prev) => {
|
|
if (prev && items.some((item) => item.fileId === prev)) return prev;
|
|
return defaultFileId;
|
|
});
|
|
}, []);
|
|
|
|
const startPrefetchTasks = useCallback((startPage: number, totalPages: number) => {
|
|
if (!projectId) return;
|
|
if (startPage >= totalPages) {
|
|
setPrefetching(false);
|
|
return;
|
|
}
|
|
const seq = ++prefetchSeqRef.current;
|
|
setPrefetching(true);
|
|
const run = async () => {
|
|
for (let page = startPage; page < totalPages; page += 1) {
|
|
if (prefetchSeqRef.current !== seq) return;
|
|
try {
|
|
const params = {
|
|
page,
|
|
size: TASK_PAGE_SIZE,
|
|
...(isTextProject ? { excludeSourceDocuments: true } : {}),
|
|
};
|
|
const resp = (await listEditorTasksUsingGet(projectId, {
|
|
...params,
|
|
})) as ApiResponse<EditorTaskListResponse>;
|
|
const normalized = normalizeTaskListResponse(resp, page);
|
|
setTasks((prev) => mergeTaskItems(prev, normalized.items));
|
|
setTaskPage((prev) => Math.max(prev, normalized.page));
|
|
setTaskTotal(normalized.total);
|
|
setTaskTotalPages(normalized.totalPages);
|
|
} catch (e) {
|
|
console.error(e);
|
|
break;
|
|
}
|
|
}
|
|
if (prefetchSeqRef.current === seq) {
|
|
setPrefetching(false);
|
|
}
|
|
};
|
|
void run();
|
|
}, [isTextProject, projectId]);
|
|
|
|
const loadTasks = useCallback(async (options?: {
|
|
mode?: "reset" | "append";
|
|
silent?: boolean;
|
|
}) => {
|
|
if (!projectId) return;
|
|
const mode = options?.mode ?? "reset";
|
|
const silent = options?.silent ?? false;
|
|
if (mode === "append" && taskTotalPages > 0 && taskPage + 1 >= taskTotalPages) {
|
|
return;
|
|
}
|
|
if (mode === "reset") {
|
|
prefetchSeqRef.current += 1;
|
|
setPrefetching(false);
|
|
}
|
|
if (mode === "append") {
|
|
setLoadingMore(true);
|
|
} else if (!silent) {
|
|
setLoadingTasks(true);
|
|
}
|
|
try {
|
|
const nextPage = mode === "append" ? taskPage + 1 : TASK_PAGE_START;
|
|
const params = {
|
|
page: nextPage,
|
|
size: TASK_PAGE_SIZE,
|
|
...(isTextProject ? { excludeSourceDocuments: true } : {}),
|
|
};
|
|
const resp = (await listEditorTasksUsingGet(projectId, {
|
|
...params,
|
|
})) as ApiResponse<EditorTaskListResponse>;
|
|
const normalized = normalizeTaskListResponse(resp, nextPage);
|
|
if (mode === "append") {
|
|
setTasks((prev) => mergeTaskItems(prev, normalized.items));
|
|
setTaskPage((prev) => Math.max(prev, normalized.page));
|
|
} else {
|
|
setTasks(normalized.items);
|
|
setTaskPage(normalized.page);
|
|
updateTaskSelection(normalized.items);
|
|
}
|
|
setTaskTotal(normalized.total);
|
|
setTaskTotalPages(normalized.totalPages);
|
|
startPrefetchTasks(normalized.page + 1, normalized.totalPages);
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (!silent && mode !== "append") message.error("获取文件列表失败");
|
|
if (mode === "reset") {
|
|
setTasks([]);
|
|
setTaskPage(TASK_PAGE_START);
|
|
setTaskTotal(0);
|
|
setTaskTotalPages(0);
|
|
}
|
|
} finally {
|
|
if (mode === "append") {
|
|
setLoadingMore(false);
|
|
} else if (!silent) {
|
|
setLoadingTasks(false);
|
|
}
|
|
}
|
|
}, [
|
|
isTextProject,
|
|
message,
|
|
projectId,
|
|
startPrefetchTasks,
|
|
taskPage,
|
|
taskTotalPages,
|
|
updateTaskSelection,
|
|
]);
|
|
|
|
const initEditorForFile = useCallback(async (fileId: string, segmentIdx?: number) => {
|
|
if (!project?.supported) return;
|
|
if (!project?.labelConfig) {
|
|
message.error("该项目未绑定标注模板,无法加载编辑器");
|
|
return;
|
|
}
|
|
if (!iframeReady) return;
|
|
|
|
const seq = ++initSeqRef.current;
|
|
setLoadingTaskDetail(true);
|
|
setLsReady(false);
|
|
expectedTaskIdRef.current = null;
|
|
|
|
try {
|
|
const resp = (await getEditorTaskUsingGet(projectId, fileId, {
|
|
segmentIndex: segmentIdx,
|
|
})) as ApiResponse<EditorTaskResponse>;
|
|
const data = resp?.data;
|
|
const task = data?.task;
|
|
if (!task) {
|
|
message.error("获取任务详情失败");
|
|
return;
|
|
}
|
|
if (seq !== initSeqRef.current) return;
|
|
|
|
// 更新分段状态
|
|
const segmentIndex = data?.segmented
|
|
? resolveSegmentIndex(data.currentSegmentIndex) ?? 0
|
|
: undefined;
|
|
if (data?.segmented) {
|
|
setSegmented(true);
|
|
setSegments(data.segments || []);
|
|
setCurrentSegmentIndex(segmentIndex ?? 0);
|
|
} else {
|
|
setSegmented(false);
|
|
setSegments([]);
|
|
setCurrentSegmentIndex(0);
|
|
}
|
|
|
|
const taskData = {
|
|
...(task?.data || {}),
|
|
file_id: fileId,
|
|
fileId: fileId,
|
|
};
|
|
if (data?.segmented) {
|
|
const normalizedIndex = segmentIndex ?? 0;
|
|
taskData.segment_index = normalizedIndex;
|
|
taskData.segmentIndex = normalizedIndex;
|
|
}
|
|
const taskForIframe = {
|
|
...task,
|
|
data: taskData,
|
|
};
|
|
|
|
const annotations = Array.isArray(taskForIframe.annotations) ? taskForIframe.annotations : [];
|
|
const initialAnnotation = annotations.length > 0 && isRecord(annotations[0]) ? annotations[0] : undefined;
|
|
savedSnapshotsRef.current[buildSnapshotKey(fileId, segmentIndex)] =
|
|
buildAnnotationSnapshot(initialAnnotation);
|
|
|
|
expectedTaskIdRef.current = Number(taskForIframe?.id) || null;
|
|
postToIframe("LS_INIT", {
|
|
labelConfig: project.labelConfig,
|
|
task: taskForIframe,
|
|
user: { id: "datamate" },
|
|
// 完整的 Label Studio 原生界面配置
|
|
interfaces: [
|
|
// 核心面板
|
|
"panel", // 导航面板(undo/redo/reset)
|
|
"update", // 更新按钮
|
|
"submit", // 提交按钮
|
|
"controls", // 控制面板
|
|
// 侧边栏(包含 Outliner 和 Details)
|
|
"side-column",
|
|
// 标注管理
|
|
"annotations:tabs",
|
|
"annotations:menu",
|
|
"annotations:current",
|
|
"annotations:add-new",
|
|
"annotations:delete",
|
|
"annotations:view-all",
|
|
// 预测
|
|
"predictions:tabs",
|
|
"predictions:menu",
|
|
// 其他
|
|
"auto-annotation",
|
|
"edit-history",
|
|
],
|
|
selectedAnnotationIndex: 0,
|
|
allowCreateEmptyAnnotation: true,
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
message.error("加载编辑器失败");
|
|
} finally {
|
|
if (seq === initSeqRef.current) setLoadingTaskDetail(false);
|
|
}
|
|
}, [iframeReady, message, postToIframe, project, projectId]);
|
|
|
|
const advanceAfterSave = useCallback(async (fileId: string, segmentIndex?: number) => {
|
|
if (!fileId) return;
|
|
if (segmented && segments.length > 0) {
|
|
const sortedSegmentIndices = segments
|
|
.map((seg) => seg.idx)
|
|
.sort((a, b) => a - b);
|
|
const baseIndex = segmentIndex ?? currentSegmentIndex;
|
|
const currentPos = sortedSegmentIndices.indexOf(baseIndex);
|
|
const nextSegmentIndex =
|
|
currentPos >= 0 ? sortedSegmentIndices[currentPos + 1] : sortedSegmentIndices[0];
|
|
if (nextSegmentIndex !== undefined) {
|
|
await initEditorForFile(fileId, nextSegmentIndex);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (tasks.length === 0) {
|
|
message.info("暂无可跳转的数据");
|
|
return;
|
|
}
|
|
const currentFileIndex = tasks.findIndex((item) => item.fileId === fileId);
|
|
const nextTask = currentFileIndex >= 0 ? tasks[currentFileIndex + 1] : tasks[0];
|
|
if (nextTask?.fileId) {
|
|
setSelectedFileId(nextTask.fileId);
|
|
return;
|
|
}
|
|
message.info("已是最后一个数据");
|
|
}, [
|
|
currentSegmentIndex,
|
|
initEditorForFile,
|
|
message,
|
|
segmented,
|
|
segments,
|
|
tasks,
|
|
]);
|
|
|
|
const saveFromExport = useCallback(async (
|
|
payload?: ExportPayload | null,
|
|
options?: { autoAdvance?: boolean }
|
|
) => {
|
|
const payloadTaskId = payload?.taskId;
|
|
if (expectedTaskIdRef.current && payloadTaskId) {
|
|
if (Number(payloadTaskId) !== expectedTaskIdRef.current) {
|
|
message.warning("已忽略过期的保存请求");
|
|
return false;
|
|
}
|
|
}
|
|
const fileId = payload?.fileId || selectedFileId;
|
|
const annotation = payload?.annotation;
|
|
if (!fileId || !annotation || typeof annotation !== "object") {
|
|
message.error("导出标注失败:缺少 fileId/annotation");
|
|
return false;
|
|
}
|
|
const payloadSegmentIndex = resolveSegmentIndex(payload?.segmentIndex);
|
|
const segmentIndex =
|
|
payloadSegmentIndex !== undefined
|
|
? payloadSegmentIndex
|
|
: segmented
|
|
? currentSegmentIndex
|
|
: undefined;
|
|
|
|
setSaving(true);
|
|
try {
|
|
const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), {
|
|
annotation,
|
|
segmentIndex,
|
|
})) as ApiResponse<UpsertAnnotationResponse>;
|
|
const updatedAt = resp?.data?.updatedAt;
|
|
message.success("标注已保存");
|
|
setTasks((prev) =>
|
|
prev.map((item) =>
|
|
item.fileId === String(fileId)
|
|
? {
|
|
...item,
|
|
hasAnnotation: true,
|
|
annotationUpdatedAt: updatedAt || item.annotationUpdatedAt,
|
|
}
|
|
: item
|
|
)
|
|
);
|
|
|
|
const snapshotKey = buildSnapshotKey(String(fileId), segmentIndex);
|
|
const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined);
|
|
savedSnapshotsRef.current[snapshotKey] = snapshot;
|
|
|
|
// 分段模式下更新当前段落的标注状态
|
|
if (segmented && segmentIndex !== undefined) {
|
|
setSegments((prev) =>
|
|
prev.map((seg) =>
|
|
seg.idx === segmentIndex
|
|
? { ...seg, hasAnnotation: true }
|
|
: seg
|
|
)
|
|
);
|
|
}
|
|
if (options?.autoAdvance) {
|
|
await advanceAfterSave(String(fileId), segmentIndex);
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
console.error(e);
|
|
message.error("保存失败");
|
|
return false;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [
|
|
advanceAfterSave,
|
|
currentSegmentIndex,
|
|
message,
|
|
projectId,
|
|
segmented,
|
|
selectedFileId,
|
|
]);
|
|
|
|
const requestExportForCheck = useCallback(() => {
|
|
if (!iframeReady || !lsReady) return Promise.resolve(undefined);
|
|
if (exportCheckRef.current) {
|
|
if (exportCheckRef.current.timer) {
|
|
window.clearTimeout(exportCheckRef.current.timer);
|
|
}
|
|
exportCheckRef.current.resolve(undefined);
|
|
exportCheckRef.current = null;
|
|
}
|
|
const requestId = `check_${Date.now()}_${++exportCheckSeqRef.current}`;
|
|
return new Promise<ExportPayload | undefined>((resolve) => {
|
|
const timer = window.setTimeout(() => {
|
|
if (exportCheckRef.current?.requestId === requestId) {
|
|
exportCheckRef.current = null;
|
|
}
|
|
resolve(undefined);
|
|
}, 3000);
|
|
exportCheckRef.current = {
|
|
requestId,
|
|
resolve,
|
|
timer,
|
|
};
|
|
postToIframe("LS_EXPORT_CHECK", { requestId });
|
|
});
|
|
}, [iframeReady, lsReady, postToIframe]);
|
|
|
|
const confirmSaveBeforeSwitch = useCallback(() => {
|
|
return new Promise<SwitchDecision>((resolve) => {
|
|
let resolved = false;
|
|
let modalInstance: { destroy: () => void } | null = null;
|
|
const settle = (decision: SwitchDecision) => {
|
|
if (resolved) return;
|
|
resolved = true;
|
|
resolve(decision);
|
|
};
|
|
const handleDiscard = () => {
|
|
if (modalInstance) modalInstance.destroy();
|
|
settle("discard");
|
|
};
|
|
modalInstance = modal.confirm({
|
|
title: "当前段落有未保存标注",
|
|
content: (
|
|
<div className="flex flex-col gap-2">
|
|
<Typography.Text>切换段落前请先保存当前标注。</Typography.Text>
|
|
<Button type="link" danger style={{ padding: 0, height: "auto" }} onClick={handleDiscard}>
|
|
放弃未保存并切换
|
|
</Button>
|
|
</div>
|
|
),
|
|
okText: "保存并切换",
|
|
cancelText: "取消",
|
|
onOk: () => settle("save"),
|
|
onCancel: () => settle("cancel"),
|
|
});
|
|
});
|
|
}, [modal]);
|
|
|
|
const requestExport = () => {
|
|
if (!selectedFileId) {
|
|
message.warning("请先选择文件");
|
|
return;
|
|
}
|
|
pendingAutoAdvanceRef.current = true;
|
|
postToIframe("LS_EXPORT", {});
|
|
};
|
|
|
|
// 段落切换处理
|
|
const handleSegmentChange = useCallback(async (newIndex: number) => {
|
|
if (newIndex === currentSegmentIndex) return;
|
|
if (segmentSwitching || saving || loadingTaskDetail) return;
|
|
if (!iframeReady || !lsReady) {
|
|
message.warning("编辑器未就绪,无法切换段落");
|
|
return;
|
|
}
|
|
|
|
setSegmentSwitching(true);
|
|
try {
|
|
const payload = await requestExportForCheck();
|
|
if (!payload) {
|
|
message.warning("无法读取当前标注,已取消切换");
|
|
return;
|
|
}
|
|
|
|
const payloadTaskId = payload.taskId;
|
|
if (expectedTaskIdRef.current && payloadTaskId) {
|
|
if (Number(payloadTaskId) !== expectedTaskIdRef.current) {
|
|
message.warning("已忽略过期的标注数据");
|
|
return;
|
|
}
|
|
}
|
|
|
|
const payloadFileId = payload.fileId || selectedFileId;
|
|
const payloadSegmentIndex = resolveSegmentIndex(payload.segmentIndex);
|
|
const resolvedSegmentIndex =
|
|
payloadSegmentIndex !== undefined
|
|
? payloadSegmentIndex
|
|
: segmented
|
|
? currentSegmentIndex
|
|
: undefined;
|
|
const annotation = isRecord(payload.annotation) ? payload.annotation : undefined;
|
|
const snapshotKey = payloadFileId
|
|
? buildSnapshotKey(String(payloadFileId), resolvedSegmentIndex)
|
|
: undefined;
|
|
const latestSnapshot = buildAnnotationSnapshot(annotation);
|
|
const lastSnapshot = snapshotKey ? savedSnapshotsRef.current[snapshotKey] : undefined;
|
|
const hasUnsavedChange = snapshotKey !== undefined && lastSnapshot !== undefined && latestSnapshot !== lastSnapshot;
|
|
|
|
if (hasUnsavedChange) {
|
|
if (autoSaveOnSwitch) {
|
|
const saved = await saveFromExport(payload);
|
|
if (!saved) return;
|
|
} else {
|
|
const decision = await confirmSaveBeforeSwitch();
|
|
if (decision === "cancel") return;
|
|
if (decision === "save") {
|
|
const saved = await saveFromExport(payload);
|
|
if (!saved) return;
|
|
}
|
|
}
|
|
}
|
|
|
|
await initEditorForFile(selectedFileId, newIndex);
|
|
} finally {
|
|
setSegmentSwitching(false);
|
|
}
|
|
}, [
|
|
autoSaveOnSwitch,
|
|
confirmSaveBeforeSwitch,
|
|
currentSegmentIndex,
|
|
iframeReady,
|
|
initEditorForFile,
|
|
loadingTaskDetail,
|
|
lsReady,
|
|
message,
|
|
requestExportForCheck,
|
|
saveFromExport,
|
|
segmented,
|
|
selectedFileId,
|
|
segmentSwitching,
|
|
saving,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setIframeReady(false);
|
|
setProject(null);
|
|
setTasks([]);
|
|
setTaskPage(TASK_PAGE_START);
|
|
setTaskTotal(0);
|
|
setTaskTotalPages(0);
|
|
setLoadingMore(false);
|
|
prefetchSeqRef.current += 1;
|
|
setPrefetching(false);
|
|
setSelectedFileId("");
|
|
initSeqRef.current = 0;
|
|
setLsReady(false);
|
|
expectedTaskIdRef.current = null;
|
|
// 重置分段状态
|
|
setSegmented(false);
|
|
setSegments([]);
|
|
setCurrentSegmentIndex(0);
|
|
savedSnapshotsRef.current = {};
|
|
if (exportCheckRef.current?.timer) {
|
|
window.clearTimeout(exportCheckRef.current.timer);
|
|
}
|
|
exportCheckRef.current = null;
|
|
|
|
if (projectId) loadProject();
|
|
}, [projectId, loadProject]);
|
|
|
|
useEffect(() => {
|
|
if (!project?.supported) return;
|
|
loadTasks({ mode: "reset" });
|
|
}, [project?.supported, loadTasks]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedFileId) return;
|
|
initEditorForFile(selectedFileId);
|
|
}, [selectedFileId, iframeReady, initEditorForFile]);
|
|
|
|
useEffect(() => {
|
|
if (!iframeReady) return;
|
|
focusIframe();
|
|
}, [focusIframe, iframeReady]);
|
|
|
|
useEffect(() => {
|
|
if (!lsReady) return;
|
|
focusIframe();
|
|
}, [focusIframe, lsReady]);
|
|
|
|
useEffect(() => {
|
|
if (!lsReady) return;
|
|
const handleWindowFocus = () => {
|
|
focusIframe();
|
|
};
|
|
window.addEventListener("focus", handleWindowFocus);
|
|
return () => window.removeEventListener("focus", handleWindowFocus);
|
|
}, [focusIframe, lsReady]);
|
|
|
|
const segmentTreeData = useMemo(() => {
|
|
if (!segmented || segments.length === 0) return [];
|
|
const lineMap = new Map<number, SegmentInfo[]>();
|
|
segments.forEach((seg) => {
|
|
const list = lineMap.get(seg.lineIndex) || [];
|
|
list.push(seg);
|
|
lineMap.set(seg.lineIndex, list);
|
|
});
|
|
return Array.from(lineMap.entries())
|
|
.sort((a, b) => a[0] - b[0])
|
|
.map(([lineIndex, lineSegments]) => ({
|
|
key: `line-${lineIndex}`,
|
|
title: `第${lineIndex + 1}行`,
|
|
selectable: false,
|
|
children: lineSegments
|
|
.sort((a, b) => a.chunkIndex - b.chunkIndex)
|
|
.map((seg) => ({
|
|
key: `seg-${seg.idx}`,
|
|
title: (
|
|
<span className="flex items-center gap-1">
|
|
<span>{`片${seg.chunkIndex + 1}`}</span>
|
|
{seg.hasAnnotation && (
|
|
<CheckOutlined style={{ fontSize: 10, color: "#52c41a" }} />
|
|
)}
|
|
</span>
|
|
),
|
|
})),
|
|
}));
|
|
}, [segmented, segments]);
|
|
|
|
const segmentLineKeys = useMemo(
|
|
() => segmentTreeData.map((item) => String(item.key)),
|
|
[segmentTreeData]
|
|
);
|
|
|
|
const handleSegmentSelect = useCallback((keys: Array<string | number>) => {
|
|
const [first] = keys;
|
|
if (first === undefined || first === null) return;
|
|
const key = String(first);
|
|
if (!key.startsWith("seg-")) return;
|
|
const nextIndex = Number(key.replace("seg-", ""));
|
|
if (!Number.isFinite(nextIndex)) return;
|
|
handleSegmentChange(nextIndex);
|
|
}, [handleSegmentChange]);
|
|
|
|
useEffect(() => {
|
|
const handler = (event: MessageEvent<LsfMessage>) => {
|
|
if (event.origin !== origin) return;
|
|
const msg = event.data || {};
|
|
if (!msg?.type) return;
|
|
|
|
if (msg.type === "LS_IFRAME_READY") {
|
|
setIframeReady(true);
|
|
return;
|
|
}
|
|
|
|
const payload = normalizePayload(msg.payload);
|
|
|
|
if (msg.type === "LS_READY") {
|
|
const readyTaskId = payload?.taskId;
|
|
if (expectedTaskIdRef.current && readyTaskId) {
|
|
if (Number(readyTaskId) !== expectedTaskIdRef.current) return;
|
|
}
|
|
setLsReady(true);
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "LS_EXPORT_RESULT") {
|
|
const shouldAutoAdvance = pendingAutoAdvanceRef.current;
|
|
pendingAutoAdvanceRef.current = false;
|
|
saveFromExport(payload, { autoAdvance: shouldAutoAdvance });
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "LS_EXPORT_CHECK_RESULT") {
|
|
const pending = exportCheckRef.current;
|
|
if (!pending) return;
|
|
const requestId = payload?.requestId;
|
|
if (requestId && requestId !== pending.requestId) return;
|
|
if (pending.timer) {
|
|
window.clearTimeout(pending.timer);
|
|
}
|
|
exportCheckRef.current = null;
|
|
pending.resolve(payload);
|
|
return;
|
|
}
|
|
|
|
// 兼容 iframe 内部在 submit 时直接上报(若启用)
|
|
if (msg.type === "LS_SUBMIT") {
|
|
saveFromExport(payload, { autoAdvance: false });
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "LS_ERROR") {
|
|
const payloadMessage = resolvePayloadMessage(msg.payload);
|
|
message.error(payloadMessage || "编辑器发生错误");
|
|
setLsReady(false);
|
|
pendingAutoAdvanceRef.current = false;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handler);
|
|
return () => window.removeEventListener("message", handler);
|
|
}, [message, origin, saveFromExport]);
|
|
|
|
const canLoadMore = taskTotalPages > 0 && taskPage + 1 < taskTotalPages;
|
|
const loadMoreNode = canLoadMore ? (
|
|
<div className="p-2 text-center">
|
|
<Button
|
|
size="small"
|
|
loading={loadingMore}
|
|
disabled={loadingTasks || prefetching}
|
|
onClick={() => loadTasks({ mode: "append" })}
|
|
>
|
|
加载更多
|
|
</Button>
|
|
{prefetching && (
|
|
<Typography.Text type="secondary" style={{ fontSize: 10, marginLeft: 8 }}>
|
|
后台加载中...
|
|
</Typography.Text>
|
|
)}
|
|
{taskTotal > 0 && (
|
|
<Typography.Text type="secondary" style={{ fontSize: 10, marginLeft: 8 }}>
|
|
已加载 {tasks.length}/{taskTotal}
|
|
</Typography.Text>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
|
|
if (loadingProject) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center">
|
|
<Spin />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!project) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center">
|
|
<Card>
|
|
<Typography.Text>未找到标注项目</Typography.Text>
|
|
<div className="mt-4 flex justify-end">
|
|
<Button onClick={() => navigate("/data/annotation")}>返回</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!project.supported) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center">
|
|
<Card style={{ maxWidth: 640 }}>
|
|
<Typography.Title level={4}>暂不支持该数据类型</Typography.Title>
|
|
<Typography.Paragraph type="secondary">
|
|
{project.unsupportedReason || "当前仅支持 TEXT/IMAGE 项目的内嵌编辑器。"}
|
|
</Typography.Paragraph>
|
|
<div className="flex justify-end gap-2">
|
|
<Button onClick={() => navigate("/data/annotation")}>返回</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
{/* 顶部工具栏 */}
|
|
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-white">
|
|
<div className="flex items-center gap-2">
|
|
<Button icon={<LeftOutlined />} onClick={() => navigate("/data/annotation")}>
|
|
返回
|
|
</Button>
|
|
<Button
|
|
icon={sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
title={sidebarCollapsed ? "展开文件列表" : "收起文件列表"}
|
|
/>
|
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
标注编辑器
|
|
</Typography.Title>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
icon={<ReloadOutlined />}
|
|
loading={loadingTasks}
|
|
onClick={() => loadTasks({ mode: "reset" })}
|
|
>
|
|
刷新
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
icon={<SaveOutlined />}
|
|
loading={saving}
|
|
disabled={!iframeReady || !selectedFileId}
|
|
onClick={requestExport}
|
|
>
|
|
保存
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 主体区域 */}
|
|
<div className="flex flex-1 min-h-0">
|
|
{/* 左侧文件列表 - 可折叠 */}
|
|
<div
|
|
className="border-r border-gray-200 bg-gray-50 flex flex-col transition-all duration-200 min-h-0"
|
|
style={{ width: sidebarCollapsed ? 0 : 240, overflow: "hidden" }}
|
|
>
|
|
<div className="px-3 py-2 border-b border-gray-200 bg-white font-medium text-sm">
|
|
文件列表
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-auto">
|
|
<List
|
|
loading={loadingTasks}
|
|
size="small"
|
|
dataSource={tasks}
|
|
loadMore={loadMoreNode}
|
|
renderItem={(item) => (
|
|
<List.Item
|
|
key={item.fileId}
|
|
className="cursor-pointer hover:bg-blue-50"
|
|
style={{
|
|
background: item.fileId === selectedFileId ? "#e6f4ff" : undefined,
|
|
padding: "8px 12px",
|
|
borderBottom: "1px solid #f0f0f0",
|
|
}}
|
|
onClick={() => setSelectedFileId(item.fileId)}
|
|
>
|
|
<div className="flex flex-col w-full gap-1">
|
|
<Typography.Text ellipsis style={{ fontSize: 13 }}>
|
|
{item.fileName}
|
|
</Typography.Text>
|
|
<div className="flex items-center justify-between">
|
|
<Typography.Text
|
|
type={item.hasAnnotation ? "success" : "secondary"}
|
|
style={{ fontSize: 11 }}
|
|
>
|
|
{item.hasAnnotation ? "已标注" : "未标注"}
|
|
</Typography.Text>
|
|
{item.annotationUpdatedAt && (
|
|
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
|
|
{item.annotationUpdatedAt}
|
|
</Typography.Text>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
</div>
|
|
{segmented && (
|
|
<div className="flex-1 border-t border-gray-200 bg-white flex flex-col min-h-0">
|
|
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50 font-medium text-sm flex items-center justify-between">
|
|
<span>段落/分段</span>
|
|
<Tag color="blue" style={{ margin: 0 }}>
|
|
{currentSegmentIndex + 1} / {segments.length}
|
|
</Tag>
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-auto px-2 py-2">
|
|
{segments.length > 0 ? (
|
|
<Tree
|
|
showLine
|
|
blockNode
|
|
selectedKeys={
|
|
segmented ? [`seg-${currentSegmentIndex}`] : []
|
|
}
|
|
expandedKeys={segmentLineKeys}
|
|
onSelect={handleSegmentSelect}
|
|
treeData={segmentTreeData}
|
|
/>
|
|
) : (
|
|
<div className="py-6">
|
|
<Empty
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
description="暂无分段"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="px-3 py-2 border-t border-gray-200 flex items-center justify-between">
|
|
<Typography.Text style={{ fontSize: 12 }}>
|
|
切段自动保存
|
|
</Typography.Text>
|
|
<Switch
|
|
size="small"
|
|
checked={autoSaveOnSwitch}
|
|
onChange={(checked) => setAutoSaveOnSwitch(checked)}
|
|
disabled={segmentSwitching || saving || loadingTaskDetail || !lsReady}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 右侧编辑器 - Label Studio iframe */}
|
|
<div className="flex-1 flex flex-col min-h-0">
|
|
{/* 编辑器区域 */}
|
|
<div className="flex-1 relative" onMouseDown={focusIframe}>
|
|
{(!iframeReady || loadingTaskDetail || (selectedFileId && !lsReady)) && (
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/80">
|
|
<Spin
|
|
tip={
|
|
!iframeReady
|
|
? "编辑器资源加载中..."
|
|
: loadingTaskDetail
|
|
? "任务数据加载中..."
|
|
: "编辑器初始化中..."
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
<iframe
|
|
ref={iframeRef}
|
|
title="Label Studio Frontend"
|
|
src={LSF_IFRAME_SRC}
|
|
className="w-full h-full border-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|