Files
DataMate/frontend/src/pages/DataAnnotation/Annotate/LabelStudioTextEditor.tsx
Jerry Yan 6b0042cb66 refactor(annotation): 简化任务选择逻辑并移除未使用的状态管理
- 移除了 resolveSegmentSummary 函数调用以简化完成状态判断
- 删除了未使用的 segmentStats 相关引用和缓存清理代码
- 简化了重置模式下的状态更新逻辑
2026-02-04 18:23:49 +08:00

1032 lines
34 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Card, List, Spin, Typography, Tag, Empty } from "antd";
import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { useNavigate, useParams } from "react-router";
import {
getEditorProjectInfoUsingGet,
getEditorTaskUsingGet,
listEditorTasksUsingGet,
upsertEditorAnnotationUsingPut,
} from "../annotation.api";
import { AnnotationResultStatus } from "../annotation.model";
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;
annotationStatus?: AnnotationResultStatus | null;
};
type LsfMessage = {
type?: string;
payload?: unknown;
};
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;
totalSegments?: number;
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;
};
const LSF_IFRAME_SRC = "/lsf/lsf.html";
const TASK_PAGE_START = 0;
const TASK_PAGE_SIZE = 200;
const NO_ANNOTATION_LABEL = "无标注";
const NOT_APPLICABLE_LABEL = "不适用";
const NO_ANNOTATION_CONFIRM_TITLE = "没有标注任何内容";
const NO_ANNOTATION_CONFIRM_OK_TEXT = "设为无标注并保存";
const NOT_APPLICABLE_CONFIRM_TEXT = "设为不适用并保存";
const NO_ANNOTATION_CONFIRM_CANCEL_TEXT = "继续标注";
const SAVE_AND_NEXT_LABEL = "保存并跳转到下一段/下一条";
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 isSaveShortcut = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) return false;
const key = event.key;
const code = event.code;
const isS = key === "s" || key === "S" || code === "KeyS";
if (!isS) return false;
if (!(event.ctrlKey || event.metaKey)) return false;
if (event.shiftKey || event.altKey) return false;
return true;
};
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 isAnnotationResultEmpty = (annotation?: Record<string, unknown>) => {
if (!annotation) return true;
if (!("result" in annotation)) return true;
const result = (annotation as { result?: unknown }).result;
if (!Array.isArray(result)) return false;
return result.length === 0;
};
const resolveTaskStatusMeta = (item: EditorTaskListItem) => {
if (!item.hasAnnotation) {
return { text: "未标注", type: "secondary" as const };
}
if (item.annotationStatus === AnnotationResultStatus.NO_ANNOTATION) {
return { text: NO_ANNOTATION_LABEL, type: "warning" as const };
}
if (item.annotationStatus === AnnotationResultStatus.NOT_APPLICABLE) {
return { text: NOT_APPLICABLE_LABEL, type: "warning" as const };
}
if (item.annotationStatus === AnnotationResultStatus.IN_PROGRESS) {
return { text: "标注中", type: "warning" as const };
}
return { text: "已标注", type: "success" as const };
};
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 "";
if (isAnnotationResultEmpty(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 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 [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 [segmented, setSegmented] = useState(false);
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0);
const [segmentTotal, setSegmentTotal] = 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 confirmEmptyAnnotationStatus = useCallback(() => {
return new Promise<AnnotationResultStatus | null>((resolve) => {
let resolved = false;
let modalInstance: { destroy: () => void } | null = null;
const settle = (value: AnnotationResultStatus | null) => {
if (resolved) return;
resolved = true;
resolve(value);
if (modalInstance) modalInstance.destroy();
};
const handleNotApplicable = () => settle(AnnotationResultStatus.NOT_APPLICABLE);
modalInstance = modal.confirm({
title: NO_ANNOTATION_CONFIRM_TITLE,
content: (
<div className="flex flex-col gap-2">
<Typography.Text></Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
<Button type="link" style={{ padding: 0, height: "auto" }} onClick={handleNotApplicable}>
{NOT_APPLICABLE_CONFIRM_TEXT}
</Button>
</div>
),
okText: NO_ANNOTATION_CONFIRM_OK_TEXT,
cancelText: NO_ANNOTATION_CONFIRM_CANCEL_TEXT,
onOk: () => settle(AnnotationResultStatus.NO_ANNOTATION),
onCancel: () => settle(null),
});
});
}, [modal]);
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 isCompleted = (item: EditorTaskListItem) => {
return item.hasAnnotation;
};
const defaultFileId =
items.find((item) => !isCompleted(item))?.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 isSegmented = !!data?.segmented;
const segmentIndex = isSegmented
? resolveSegmentIndex(data.currentSegmentIndex) ?? 0
: undefined;
if (isSegmented) {
setSegmented(true);
setCurrentSegmentIndex(segmentIndex ?? 0);
const totalSegments = Number(data?.totalSegments ?? 0);
setSegmentTotal(Number.isFinite(totalSegments) && totalSegments > 0 ? totalSegments : 0);
} else {
setSegmented(false);
setCurrentSegmentIndex(0);
setSegmentTotal(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 && segmentTotal > 0) {
const baseIndex = Math.max(segmentIndex ?? currentSegmentIndex, 0);
const nextSegmentIndex = baseIndex + 1;
if (nextSegmentIndex < segmentTotal) {
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,
segmentTotal,
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;
const annotationRecord = annotation as Record<string, unknown>;
const currentTask = tasks.find((item) => item.fileId === String(fileId));
const currentStatus = currentTask?.annotationStatus;
let resolvedStatus: AnnotationResultStatus;
if (isAnnotationResultEmpty(annotationRecord)) {
if (
currentStatus === AnnotationResultStatus.NO_ANNOTATION ||
currentStatus === AnnotationResultStatus.NOT_APPLICABLE
) {
resolvedStatus = currentStatus;
} else {
const selectedStatus = await confirmEmptyAnnotationStatus();
if (!selectedStatus) return false;
resolvedStatus = selectedStatus;
}
} else {
resolvedStatus = AnnotationResultStatus.ANNOTATED;
}
setSaving(true);
try {
const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), {
annotation,
segmentIndex,
annotationStatus: resolvedStatus,
})) as ApiResponse<UpsertAnnotationResponse>;
const updatedAt = resp?.data?.updatedAt;
message.success("标注已保存");
setTasks((prev) =>
prev.map((item) =>
item.fileId === String(fileId)
? {
...item,
hasAnnotation: true,
annotationStatus: resolvedStatus,
annotationUpdatedAt: updatedAt || item.annotationUpdatedAt,
}
: item
)
);
const snapshotKey = buildSnapshotKey(String(fileId), segmentIndex);
const snapshot = buildAnnotationSnapshot(isRecord(annotation) ? annotation : undefined);
savedSnapshotsRef.current[snapshotKey] = snapshot;
if (options?.autoAdvance) {
await advanceAfterSave(String(fileId), segmentIndex);
}
return true;
} catch (e) {
console.error(e);
message.error("保存失败");
return false;
} finally {
setSaving(false);
}
}, [
advanceAfterSave,
confirmEmptyAnnotationStatus,
currentSegmentIndex,
message,
projectId,
segmented,
selectedFileId,
tasks,
]);
const requestExport = useCallback((autoAdvance: boolean) => {
if (!selectedFileId) {
message.warning("请先选择文件");
return;
}
pendingAutoAdvanceRef.current = autoAdvance;
postToIframe("LS_EXPORT", {});
}, [message, postToIframe, selectedFileId]);
useEffect(() => {
const handleSaveShortcut = (event: KeyboardEvent) => {
if (!isSaveShortcut(event) || event.repeat) return;
if (saving || loadingTaskDetail) return;
if (!iframeReady || !lsReady) return;
event.preventDefault();
event.stopPropagation();
requestExport(false);
};
window.addEventListener("keydown", handleSaveShortcut);
return () => window.removeEventListener("keydown", handleSaveShortcut);
}, [iframeReady, loadingTaskDetail, lsReady, requestExport, 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);
setCurrentSegmentIndex(0);
setSegmentTotal(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]);
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_SAVE_AND_NEXT") {
pendingAutoAdvanceRef.current = false;
saveFromExport(payload, { autoAdvance: true });
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 saveDisabled =
!iframeReady || !selectedFileId || saving || loadingTaskDetail;
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="grid grid-cols-[1fr_auto_1fr] items-center 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 justify-center">
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
disabled={saveDisabled}
onClick={() => requestExport(true)}
>
{SAVE_AND_NEXT_LABEL}
</Button>
</div>
<div className="flex items-center gap-2 justify-end">
<Button
icon={<ReloadOutlined />}
loading={loadingTasks}
onClick={() => loadTasks({ mode: "reset" })}
>
</Button>
<Button
icon={<SaveOutlined />}
loading={saving}
disabled={saveDisabled}
onClick={() => requestExport(false)}
>
</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 flex items-center justify-between gap-2">
<span></span>
</div>
<div className="flex-1 min-h-0 overflow-auto">
<List
loading={loadingTasks}
size="small"
dataSource={tasks}
loadMore={loadMoreNode}
renderItem={(item) => {
const statusMeta = resolveTaskStatusMeta(item);
return (
<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">
<div className="flex items-center gap-2">
<Typography.Text type={statusMeta.type} style={{ fontSize: 11 }}>
{statusMeta.text}
</Typography.Text>
</div>
{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 }}>
{segmentTotal > 0 ? currentSegmentIndex + 1 : 0} / {segmentTotal}
</Tag>
</div>
<div className="flex-1 min-h-0 overflow-auto px-2 py-2">
{segmentTotal > 0 ? (
<div className="py-6">
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
使/
</Typography.Text>
</div>
) : (
<div className="py-6">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无分段"
/>
</div>
)}
</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>
);
}