feat(annotation): 添加标注状态管理功能

- 引入 AnnotationResultStatus 枚举类型区分已标注和无标注状态
- 在前端组件中实现空标注检测和确认对话框逻辑
- 添加数据库表字段 annotation_status 存储标注状态
- 扩展后端服务验证和处理标注状态逻辑
- 更新 API 接口支持标注状态参数传递
- 改进任务列表显示逻辑以反映不同标注状态
- 实现分段模式下的标注结果检查机制
This commit is contained in:
2026-01-31 13:23:38 +08:00
parent 52a2a73a8e
commit f4fc574687
6 changed files with 194 additions and 44 deletions

View File

@@ -9,6 +9,7 @@ import {
listEditorTasksUsingGet,
upsertEditorAnnotationUsingPut,
} from "../annotation.api";
import { AnnotationResultStatus } from "../annotation.model";
type EditorProjectInfo = {
projectId: string;
@@ -26,6 +27,7 @@ type EditorTaskListItem = {
fileType?: string | null;
hasAnnotation: boolean;
annotationUpdatedAt?: string | null;
annotationStatus?: AnnotationResultStatus | null;
};
type LsfMessage = {
@@ -88,6 +90,10 @@ type SwitchDecision = "save" | "discard" | "cancel";
const LSF_IFRAME_SRC = "/lsf/lsf.html";
const TASK_PAGE_START = 0;
const TASK_PAGE_SIZE = 200;
const NO_ANNOTATION_LABEL = "无标注/不适用";
const NO_ANNOTATION_CONFIRM_TITLE = "没有标注任何内容";
const NO_ANNOTATION_CONFIRM_OK_TEXT = "设为无标注并保存";
const NO_ANNOTATION_CONFIRM_CANCEL_TEXT = "继续标注";
type NormalizedTaskList = {
items: EditorTaskListItem[];
@@ -119,6 +125,24 @@ const resolvePayloadMessage = (payload: unknown) => {
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 };
}
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;
@@ -247,6 +271,30 @@ export default function LabelStudioTextEditor() {
win.postMessage({ type, payload }, origin);
}, [origin]);
const confirmNoAnnotation = useCallback(() => {
return new Promise<boolean>((resolve) => {
let resolved = false;
const settle = (value: boolean) => {
if (resolved) return;
resolved = true;
resolve(value);
};
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>
</div>
),
okText: NO_ANNOTATION_CONFIRM_OK_TEXT,
cancelText: NO_ANNOTATION_CONFIRM_CANCEL_TEXT,
onOk: () => settle(true),
onCancel: () => settle(false),
});
});
}, [modal]);
const loadProject = useCallback(async () => {
setLoadingProject(true);
try {
@@ -539,11 +587,27 @@ export default function LabelStudioTextEditor() {
? currentSegmentIndex
: undefined;
const annotationRecord = annotation as Record<string, unknown>;
let resolvedStatus: AnnotationResultStatus;
if (isAnnotationResultEmpty(annotationRecord)) {
const currentStatus = tasks.find((item) => item.fileId === String(fileId))?.annotationStatus;
if (currentStatus === AnnotationResultStatus.NO_ANNOTATION) {
resolvedStatus = AnnotationResultStatus.NO_ANNOTATION;
} else {
const confirmed = await confirmNoAnnotation();
if (!confirmed) return false;
resolvedStatus = AnnotationResultStatus.NO_ANNOTATION;
}
} 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("标注已保存");
@@ -553,6 +617,7 @@ export default function LabelStudioTextEditor() {
? {
...item,
hasAnnotation: true,
annotationStatus: resolvedStatus,
annotationUpdatedAt: updatedAt || item.annotationUpdatedAt,
}
: item
@@ -586,11 +651,13 @@ export default function LabelStudioTextEditor() {
}
}, [
advanceAfterSave,
confirmNoAnnotation,
currentSegmentIndex,
message,
projectId,
segmented,
selectedFileId,
tasks,
]);
const requestExportForCheck = useCallback(() => {
@@ -1016,37 +1083,37 @@ export default function LabelStudioTextEditor() {
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 ? "已标注" : "未标注"}
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>
{item.annotationUpdatedAt && (
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{item.annotationUpdatedAt}
<div className="flex items-center justify-between">
<Typography.Text type={statusMeta.type} style={{ fontSize: 11 }}>
{statusMeta.text}
</Typography.Text>
)}
{item.annotationUpdatedAt && (
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{item.annotationUpdatedAt}
</Typography.Text>
)}
</div>
</div>
</div>
</List.Item>
)}
</List.Item>
);
}}
/>
</div>
{segmented && (

View File

@@ -8,6 +8,11 @@ export enum AnnotationTaskStatus {
SKIPPED = "skipped",
}
export enum AnnotationResultStatus {
ANNOTATED = "ANNOTATED",
NO_ANNOTATION = "NO_ANNOTATION",
}
export interface AnnotationTask {
id: string;
name: string;
@@ -52,7 +57,7 @@ export interface ObjectDefinition {
export interface TemplateConfiguration {
labels: LabelDefinition[];
objects: ObjectDefinition[];
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
}
export interface AnnotationTemplate {