feat(frontend): 增强Synthesis Data Detail页面UX体验 (#163)

* fix(chart): update Helm chart helpers and values for improved configuration

* feat(SynthesisTaskTab): enhance task table with tooltip support and improved column widths

* feat(CreateTask, SynthFileTask): improve task creation and detail view with enhanced payload handling and UI updates

* feat(SynthFileTask): enhance file display with progress tracking and delete action

* feat(SynthFileTask): enhance file display with progress tracking and delete action

* feat(SynthDataDetail): add delete action for chunks with confirmation prompt

* feat(SynthDataDetail): update edit and delete buttons to icon-only format

* feat(SynthDataDetail): add confirmation modals for chunk and synthesis data deletion
This commit is contained in:
Dallas98
2025-12-11 21:02:44 +08:00
committed by GitHub
parent 8f529952f6
commit ec87e4f204
7 changed files with 580 additions and 370 deletions

View File

@@ -2,7 +2,7 @@
Expand the name of the chart. Expand the name of the chart.
*/}} */}}
{{- define "label-studio.name" -}} {{- define "label-studio.name" -}}
{{- default .Chart.name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} {{- default .Values.nameOverride .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}} {{- end -}}
{{/* {{/*
@@ -12,7 +12,7 @@ Create a default fully qualified app name.
{{- if .Values.fullnameOverride -}} {{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}} {{- else -}}
{{- $name := default .Chart.name .Values.nameOverride -}} {{- $name := default .Values.nameOverride .Chart.Name -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}} {{- end -}}
{{- end -}} {{- end -}}
@@ -21,6 +21,5 @@ Create a default fully qualified app name.
Create chart name and version as used by the chart label. Create chart name and version as used by the chart label.
*/}} */}}
{{- define "label-studio.chart" -}} {{- define "label-studio.chart" -}}
{{- printf "%s-%s" .Chart.name .Chart.version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}} {{- end -}}

View File

@@ -1,5 +1,6 @@
# Default values for label-studio Helm chart. # Default values for label-studio Helm chart.
# This mirrors the configuration from deployment/docker/label-studio/docker-compose.yml # This mirrors the configuration from deployment/docker/label-studio/docker-compose.yml
fullnameOverride: label-studio
replicaCount: 1 replicaCount: 1
@@ -24,9 +25,9 @@ postgres:
size: 10Gi size: 10Gi
service: service:
type: ClusterIP type: NodePort
port: 8000 port: 8000
nodePort: null nodePort: 30001
# Corresponds to docker-compose port mapping 30001:8000 # Corresponds to docker-compose port mapping 30001:8000
ingress: ingress:
@@ -54,7 +55,7 @@ env:
POSTGRE_USER: "postgres" POSTGRE_USER: "postgres"
POSTGRE_PASSWORD: "" POSTGRE_PASSWORD: ""
POSTGRE_PORT: 5432 POSTGRE_PORT: 5432
POSTGRE_HOST: "db" POSTGRE_HOST: "label-studio-postgres"
LABEL_STUDIO_HOST: "" # can be overridden LABEL_STUDIO_HOST: "" # can be overridden
LOCAL_FILES_SERVING_ENABLED: "true" LOCAL_FILES_SERVING_ENABLED: "true"
LOCAL_FILES_DOCUMENT_ROOT: "/label-studio/local" LOCAL_FILES_DOCUMENT_ROOT: "/label-studio/local"
@@ -75,5 +76,6 @@ persistence:
# If not set and persistence.enabled=true, a PVC will be created automatically. # If not set and persistence.enabled=true, a PVC will be created automatically.
datasetVolume: datasetVolume:
enabled: true enabled: true
claimName: "" # if empty, uses same PVC as persistence or creates a dedicated one claimName: datamate-dataset-pvc # if empty, uses same PVC as persistence or creates a dedicated one

View File

@@ -146,9 +146,8 @@ export default function SynthesisTaskCreate() {
} }
// 构造后端要求的参数格式 // 构造后端要求的参数格式
const payload = { const payload: Record<string, unknown> = {
name: values.name || form.getFieldValue("name"), // 必选,确保传递 name: values.name || form.getFieldValue("name"),
description: values.description ?? "", // 可选,始终传递
model_id: selectedModel, model_id: selectedModel,
source_file_id: selectedFiles, source_file_id: selectedFiles,
text_split_config: { text_split_config: {
@@ -161,10 +160,14 @@ export default function SynthesisTaskCreate() {
synthesis_type: taskType === "qa" ? "QA" : "COT", synthesis_type: taskType === "qa" ? "QA" : "COT",
}; };
// 只有在有真实内容时携带 description,避免强制传空字符串
const desc = values.description ?? form.getFieldValue("description");
if (typeof desc === "string" && desc.trim().length > 0) {
payload.description = desc.trim();
}
setSubmitting(true); setSubmitting(true);
const res = (await createSynthesisTaskUsingPost( const res = (await createSynthesisTaskUsingPost(payload)) as CreateTaskApiResponse;
payload as unknown as Record<string, unknown>
)) as CreateTaskApiResponse;
const ok = const ok =
res?.success === true || res?.success === true ||

View File

@@ -1,6 +1,20 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router"; import { useLocation, useNavigate, useParams, Link } from "react-router";
import { Badge, Button, Empty, List, Pagination, Spin, Typography, Popconfirm, message, Dropdown, Input } from "antd"; import {
Badge,
Empty,
List,
Pagination,
Spin,
Typography,
Popconfirm,
message,
Dropdown,
Input,
Breadcrumb,
Button,
Tag,
} from "antd";
import type { PaginationProps } from "antd"; import type { PaginationProps } from "antd";
import { MoreOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons"; import { MoreOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons";
import { import {
@@ -69,6 +83,8 @@ export default function SynthDataDetail() {
const [chunkLoading, setChunkLoading] = useState(false); const [chunkLoading, setChunkLoading] = useState(false);
const [dataLoading, setDataLoading] = useState(false); const [dataLoading, setDataLoading] = useState(false);
const [synthDataList, setSynthDataList] = useState<SynthesisDataItem[]>([]); const [synthDataList, setSynthDataList] = useState<SynthesisDataItem[]>([]);
const [chunkConfirmVisibleId, setChunkConfirmVisibleId] = useState<string | null>(null);
const [dataConfirmVisibleId, setDataConfirmVisibleId] = useState<string | null>(null);
// 加载任务信息(用于顶部展示) // 加载任务信息(用于顶部展示)
useEffect(() => { useEffect(() => {
@@ -237,291 +253,296 @@ export default function SynthDataDetail() {
} }
}; };
const breadItems = [
{
title: <Link to="/data/synthesis/task"></Link>,
},
{
title: state.taskId ? (
<Link to={`/data/synthesis/task/${state.taskId}`}>{taskInfo?.name || "任务详情"}</Link>
) : (
taskInfo?.name || "任务详情"
),
},
{
title: state.fileName || "文件详情",
},
];
const showChunkConfirm = (id: string) => setChunkConfirmVisibleId(id);
const hideChunkConfirm = () => setChunkConfirmVisibleId(null);
const showDataConfirm = (id: string) => setDataConfirmVisibleId(id);
const hideDataConfirm = () => setDataConfirmVisibleId(null);
return ( return (
<div className="p-4 bg-white rounded-lg h-full flex flex-col overflow-hidden"> <>
{/* 顶部信息和返回 */} <Breadcrumb items={breadItems} />
<div className="flex items-center justify-between mb-4"> {/* 全局删除确认遮罩:Chunk */}
<div className="space-y-1"> {chunkConfirmVisibleId && (
<div className="flex items-center gap-2"> <div className="fixed inset-0 z-40 flex items-center justify-center bg-black/30">
<Title level={4} style={{ margin: 0 }}> <div className="bg-white rounded-lg px-6 py-4 shadow-lg min-w-[320px] max-w-[420px]">
<div className="text-sm font-medium mb-2"> Chunk </div>
</Title> <div className="text-xs text-gray-500 mb-4 break-all">
{state.fileName && ( ID: {chunkConfirmVisibleId}
<Text type="secondary" className="!text-xs">
{state.fileName}
</Text>
)}
</div>
{taskInfo && (
<div className="text-xs text-gray-500 flex gap-4">
<span>
{taskInfo.name}
</span>
<span>
{taskInfo.synthesis_type === "QA"
? "问答对生成"
: taskInfo.synthesis_type === "COT"
? "链式推理生成"
: taskInfo.synthesis_type}
</span>
<span>
{formatDateTime(taskInfo.created_at)}
</span>
<span>ID{taskInfo.model_id}</span>
</div> </div>
)} <div className="flex justify-end gap-2 text-sm">
</div> <Button size="small" onClick={hideChunkConfirm}>
<Button onClick={() => navigate(-1)}></Button>
</div> </Button>
<Button
{/* 主体左右布局 */}
<div className="flex flex-1 min-h-0 gap-4">
{/* 左侧 Chunk 列表:占比 2/5 */}
<div className="basis-2/5 max-w-[40%] border rounded-lg flex flex-col overflow-hidden">
<div className="px-3 py-2 border-b text-sm font-medium bg-gray-50">
Chunk
</div>
<div className="flex-1 overflow-auto">
{chunkLoading ? (
<div className="h-full flex items-center justify-center">
<Spin />
</div>
) : chunks.length === 0 ? (
<Empty description="暂无 Chunk" style={{ marginTop: 40 }} />
) : (
<List
size="small" size="small"
dataSource={chunks} type="primary"
renderItem={(item) => { danger
const active = item.id === selectedChunkId; onClick={async () => {
return ( setSelectedChunkId(chunkConfirmVisibleId);
<List.Item await handleDeleteCurrentChunk();
className={ hideChunkConfirm();
"px-3 py-2 !border-0 " +
(active ? "bg-blue-50" : "hover:bg-gray-50")
}
>
<div className="flex flex-col gap-1 w-full">
<div
className="flex items-center justify-between text-xs cursor-pointer"
onClick={() => setSelectedChunkId(item.id)}
>
<span className="font-medium">Chunk #{item.chunk_index}</span>
<Badge
color={active ? "blue" : "default"}
text={active ? "当前" : ""}
/>
</div>
<div
className="text-xs text-gray-600 whitespace-pre-wrap break-words cursor-pointer"
onClick={() => setSelectedChunkId(item.id)}
>
{item.chunk_content}
</div>
<div className="flex justify-end mt-1">
<Dropdown
menu={{
items: [
{
key: "delete-chunk",
danger: true,
label: (
<Popconfirm
title="确认删除该 Chunk 及其合成数据?"
onConfirm={() => {
setSelectedChunkId(item.id);
handleDeleteCurrentChunk();
}}
okText="删除"
cancelText="取消"
>
<span className="flex items-center gap-1">
<DeleteOutlined />
Chunk
</span>
</Popconfirm>
),
},
],
}}
trigger={["click"]}
>
<Button
size="small"
type="text"
shape="circle"
icon={<MoreOutlined />}
/>
</Dropdown>
</div>
</div>
</List.Item>
);
}} }}
/> >
)}
</div> </Button>
<div className="border-t px-2 py-1 flex justify-end bg-white"> </div>
<Pagination
size="small"
current={chunkPagination.page}
pageSize={chunkPagination.size}
total={chunkPagination.total}
onChange={handleChunkPageChange}
showSizeChanger
showTotal={(total) => `${total}`}
/>
</div> </div>
</div> </div>
)}
{/* 右侧合成数据展示:占比 3/5 */} {/* 全局删除确认遮罩:合成数据 */}
<div className="basis-3/5 max-w-[60%] border rounded-lg flex flex-col min-w-0 overflow-hidden"> {dataConfirmVisibleId && (
<div className="px-3 py-2 border-b flex items-center justify-between bg-gray-50 text-sm font-medium"> <div className="fixed inset-0 z-40 flex items-center justify-center bg-black/30">
<span></span> <div className="bg-white rounded-lg px-6 py-4 shadow-lg min-w-[320px] max-w-[480px]">
{currentChunk && ( <div className="text-sm font-medium mb-2"></div>
<span className="text-xs text-gray-500"> <div className="text-xs text-gray-500 mb-4 break-all">
Chunk #{currentChunk.chunk_index} ID: {dataConfirmVisibleId}
</span> </div>
)} <div className="flex justify-end gap-2 text-sm">
<Button size="small" onClick={hideDataConfirm}>
</Button>
<Button
size="small"
type="primary"
danger
onClick={async () => {
await handleDeleteSingleSynthesisData(dataConfirmVisibleId);
hideDataConfirm();
}}
>
</Button>
</div>
</div> </div>
<div className="flex-1 overflow-auto p-3"> </div>
{dataLoading ? ( )}
<div className="h-full flex items-center justify-center">
<Spin /> <div className="mt-4 flex flex-1 flex-col overflow-hidden rounded-lg bg-transparent">
<div className="flex flex-1 min-h-0 gap-4">
{/* 左侧 Chunk 列表 */}
<div className="basis-2/5 max-w-[40%] flex flex-col min-w-0">
<div className="rounded-lg border border-gray-100 bg-white shadow-sm flex flex-col overflow-hidden h-full">
<div className="px-4 py-3 border-b border-gray-100 text-sm font-medium bg-gray-50/80 flex items-center justify-between">
<span>Chunk </span>
{chunkPagination.total ? (
<span className="text-xs text-gray-400"> {chunkPagination.total} </span>
) : null}
</div> </div>
) : !selectedChunkId ? ( <div className="flex-1 overflow-auto">
<Empty description="请选择左侧 Chunk" style={{ marginTop: 40 }} /> {chunkLoading ? (
) : synthDataList.length === 0 ? ( <div className="h-full flex items-center justify-center">
<Empty description="该 Chunk 暂无合成数据" style={{ marginTop: 40 }} /> <Spin size="small" />
) : ( </div>
<div className="space-y-4"> ) : chunks.length === 0 ? (
{synthDataList.map((item, index) => { <Empty description="暂无 Chunk" style={{ marginTop: 40 }} />
const isEditing = editingId === item.id; ) : (
return ( <List
<div size="small"
key={item.id || index} className="!border-0"
className="border border-gray-100 rounded-md p-3 bg-white shadow-sm/50 relative" dataSource={chunks}
> renderItem={(item) => {
<div className="mb-2 text-xs text-gray-500 flex justify-between"> const active = item.id === selectedChunkId;
<span> {index + 1}</span> return (
<span>ID{item.id}</span> <List.Item
</div> className={
"!border-0 px-4 py-3 transition-colors rounded-none " +
{/* 右下角更多操作按钮:编辑 & 删除 */} (active
<div className="absolute bottom-2 right-2 flex gap-1"> ? "bg-blue-200 hover:bg-blue-300"
<Dropdown : "hover:bg-blue-50")
menu={{ }
items: [ onClick={() => setSelectedChunkId(item.id)}
{
key: "edit-data",
label: (
<span className="flex items-center gap-1">
<EditOutlined />
</span>
),
onClick: (info) => {
info.domEvent.stopPropagation();
startEdit(item);
},
},
{
key: "delete-data",
danger: true,
label: (
<Popconfirm
title="确认删除该条合成数据?"
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteSingleSynthesisData(item.id);
}}
okText="删除"
cancelText="取消"
>
<span className="flex items-center gap-1">
<DeleteOutlined />
</span>
</Popconfirm>
),
},
],
}}
trigger={["click"]}
> >
<Button <div className="flex flex-col gap-1 w-full">
size="small" <div className="flex items-center justify-between text-[12px] text-gray-500">
type="text" <div className="flex items-center gap-2">
shape="circle" <span className="text-xs font-medium text-gray-800">
icon={<MoreOutlined />} Chunk #{item.chunk_index}
onClick={(e) => e.stopPropagation()} </span>
/>
</Dropdown>
</div>
{/* 表格形式的 key-value 展示 + 可编辑 value */}
<div className="w-full border border-gray-100 rounded-md overflow-hidden mt-2">
{getDataEntries(item.data).map(([key, value], rowIdx) => {
const displayValue =
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
? String(value)
: JSON.stringify(value, null, 2);
return (
<div
key={key + rowIdx}
className={
"grid grid-cols-[120px,1fr] text-xs " +
(rowIdx % 2 === 0 ? "bg-gray-50/60" : "bg-white")
}
>
<div className="px-3 py-2 border-r border-gray-100 font-medium text-gray-600 break-words">
{key}
</div> </div>
<div className="px-3 py-2 text-gray-700 whitespace-pre-wrap break-words"> <div className="flex items-center gap-2">
{isEditing ? ( {/* 右侧显示 Chunk ID,完整展示 */}
<Input.TextArea <span className="text-[11px] text-gray-400" title={item.id}>
value={editingMap[key] ?? displayValue} ID: {item.id}
onChange={(e) => { </span>
const v = e.target.value; <Button
setEditingMap((prev) => ({ ...prev, [key]: v })); type="text"
}} size="small"
autoSize={{ minRows: 1, maxRows: 4 }} shape="circle"
/> danger
) : ( icon={<DeleteOutlined className="text-[12px]" />}
displayValue onClick={(e) => {
)} e.stopPropagation();
showChunkConfirm(item.id);
}}
/>
</div> </div>
</div> </div>
); <div className="text-xs text-gray-600 whitespace-pre-wrap break-words leading-relaxed">
})} {item.chunk_content}
</div> </div>
</div>
{isEditing && ( </List.Item>
<div className="flex justify-end gap-2 mt-2"> );
<Button size="small" onClick={cancelEdit}> }}
/>
</Button> )}
<Button
size="small"
type="primary"
onClick={() => handleSaveEdit(item)}
>
</Button>
</div>
)}
</div>
);
})}
</div> </div>
)} {chunkPagination.total ? (
<div className="border-t border-gray-100 px-3 py-2 flex justify-end bg-white">
<Pagination
size="small"
current={chunkPagination.page}
pageSize={chunkPagination.size}
total={chunkPagination.total}
onChange={handleChunkPageChange}
showSizeChanger
showTotal={(total) => `${total}`}
/>
</div>
) : null}
</div>
</div>
{/* 右侧合成数据展示 */}
<div className="basis-3/5 max-w-[60%] flex flex-col min-w-0">
<div className="rounded-lg border border-gray-100 bg-white shadow-sm flex flex-col overflow-hidden h-full">
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-gray-50/80 text-sm font-medium">
<span></span>
{currentChunk && (
<Tag color="blue" className="text-xs px-2 py-0.5 m-0 rounded-full border-none bg-blue-100 text-blue-200">
Chunk #{currentChunk.chunk_index}
</Tag>
)}
</div>
<div className="flex-1 overflow-auto p-4">
{dataLoading ? (
<div className="h-full flex items-center justify-center">
<Spin size="small" />
</div>
) : !selectedChunkId ? (
<Empty description="请选择左侧 Chunk" style={{ marginTop: 40 }} />
) : synthDataList.length === 0 ? (
<Empty description="该 Chunk 暂无合成数据" style={{ marginTop: 40 }} />
) : (
<div className="space-y-4">
{synthDataList.map((item, index) => {
const isEditing = editingId === item.id;
return (
<div
key={item.id || index}
className="border border-gray-100 rounded-md p-3 bg-white hover:bg-blue-50/80 transition-colors"
>
<div className="mb-2 text-[12px] text-gray-500 flex justify-between items-center">
<span> {index + 1}</span>
<div className="flex items-center gap-2">
<span title={item.id}>ID{item.id}</span>
{!isEditing && (
<>
<Button
type="text"
size="small"
shape="circle"
icon={<EditOutlined className="text-[13px]" />}
onClick={() => startEdit(item)}
/>
<Button
type="text"
size="small"
shape="circle"
danger
icon={<DeleteOutlined className="text-[13px]" />}
onClick={(e) => {
e.stopPropagation();
showDataConfirm(item.id);
}}
/>
</>
)}
</div>
</div>
{/* key-value 展示区域:不再截断,完整展示 */}
<div className="border border-gray-100 rounded-md overflow-hidden">
{getDataEntries(item.data).map(([key, value], rowIdx) => {
const displayValue =
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
? String(value)
: JSON.stringify(value, null, 2);
return (
<div
key={key + rowIdx}
className={
"grid grid-cols-[120px,1fr] text-[12px] " +
(rowIdx % 2 === 0 ? "bg-white" : "bg-gray-50/80")
}
>
<div className="px-3 py-2 border-r border-gray-100 font-medium text-gray-600 break-words">
{key}
</div>
<div className="px-3 py-2 text-gray-700 whitespace-pre-wrap break-words">
{isEditing ? (
<Input.TextArea
value={editingMap[key] ?? displayValue}
onChange={(e) => {
const v = e.target.value;
setEditingMap((prev) => ({ ...prev, [key]: v }));
}}
autoSize={{ minRows: 1, maxRows: 6 }}
/>
) : (
displayValue
)}
</div>
</div>
);
})}
</div>
{isEditing && (
<div className="flex justify-end gap-2 mt-2">
<Button size="small" onClick={cancelEdit}>
</Button>
<Button
size="small"
type="primary"
onClick={() => handleSaveEdit(item)}
>
</Button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </>
); );
} }

View File

@@ -1,10 +1,19 @@
import { App } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
import { Table, Badge, Button } from "antd"; import { DeleteOutlined, ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; import { Badge, Breadcrumb, Button, Table, Tabs, Progress, Tooltip } from "antd";
import { querySynthesisFileTasksUsingGet, querySynthesisTaskByIdUsingGet } from "@/pages/SynthesisTask/synthesis-api";
import type { BadgeProps } from "antd"; import type { BadgeProps } from "antd";
import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
import DetailHeader from "@/components/DetailHeader";
import {
querySynthesisFileTasksUsingGet,
querySynthesisTaskByIdUsingGet,
deleteSynthesisTaskByIdUsingDelete,
} from "@/pages/SynthesisTask/synthesis-api";
import { formatDateTime } from "@/utils/unit"; import { formatDateTime } from "@/utils/unit";
import { Folder, Sparkles, Trash2 } from "lucide-react";
interface SynthesisFileTaskItem { interface SynthesisFileTaskItem {
id: string; id: string;
@@ -33,12 +42,18 @@ interface SynthesisTaskInfo {
synthesis_type: string; synthesis_type: string;
status: string; status: string;
created_at: string; created_at: string;
updated_at?: string;
model_id: string; model_id: string;
total_files?: number;
total_synthesis_data?: number;
description?: string;
} }
export default function SynthFileTask() { export default function SynthFileTask() {
const { id: taskId = "" } = useParams(); const { id: taskId = "" } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { message } = App.useApp();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<SynthesisFileTaskItem[]>([]); const [data, setData] = useState<SynthesisFileTaskItem[]>([]);
const [pagination, setPagination] = useState<TablePaginationConfig>({ const [pagination, setPagination] = useState<TablePaginationConfig>({
@@ -47,17 +62,34 @@ export default function SynthFileTask() {
total: 0, total: 0,
}); });
const [taskInfo, setTaskInfo] = useState<SynthesisTaskInfo | null>(null); const [taskInfo, setTaskInfo] = useState<SynthesisTaskInfo | null>(null);
const [activeTab, setActiveTab] = useState("files");
// 查询总任务详情 // 查询总任务详情
useEffect(() => { const fetchTaskDetail = async () => {
if (!taskId) return; if (!taskId) return;
querySynthesisTaskByIdUsingGet(taskId).then((res) => { try {
setTaskInfo(res?.data?.data || null); const res = await querySynthesisTaskByIdUsingGet(taskId);
}); const raw = res?.data?.data ?? res?.data;
if (!raw) return;
setTaskInfo(raw);
} catch {
message.error("获取合成任务详情失败");
navigate("/data/synthesis/task");
}
};
useEffect(() => {
fetchTaskDetail();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]); }, [taskId]);
const fetchData = async (page = 1, pageSize = 10) => { const fetchData = async (page = 1, pageSize = 10, withTopLoading = false) => {
if (!taskId) return; if (!taskId) return;
if (withTopLoading) {
window.dispatchEvent(new Event("loading:show"));
}
setLoading(true); setLoading(true);
try { try {
const res = await querySynthesisFileTasksUsingGet(taskId, { const res = await querySynthesisFileTasksUsingGet(taskId, {
@@ -80,30 +112,41 @@ export default function SynthFileTask() {
}); });
} finally { } finally {
setLoading(false); setLoading(false);
if (withTopLoading) {
window.dispatchEvent(new Event("loading:hide"));
}
} }
}; };
useEffect(() => { useEffect(() => {
fetchData(1, pagination.pageSize || 10); // 首次进入或任务切换时,不触发顶部 loading,只用表格自带的 loading
fetchData(1, pagination.pageSize || 10, false);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]); }, [taskId]);
const handleTableChange = (pag: TablePaginationConfig) => { const handleTableChange = (pag: TablePaginationConfig) => {
fetchData(pag.current || 1, pag.pageSize || 10); // 分页切换时,也只用表格 loading,不闪顶部条
fetchData(pag.current || 1, pag.pageSize || 10, false);
}; };
const columns: ColumnsType<SynthesisFileTaskItem> = [ const columns: ColumnsType<SynthesisFileTaskItem> = [
{ {
title: "文件", title: "文件",
dataIndex: "file_name", key: "file",
key: "file_name", render: (_text, record) => (
render: (text: string, record) => ( <div className="flex items-center gap-2">
<Button <Folder className="w-4 h-4 text-blue-500" />
type="link" <Button
onClick={() => navigate(`/data/synthesis/task/file/${record.id}/detail`, { state: { fileName: record.file_name, taskId } })} type="link"
> onClick={() =>
{text} navigate(`/data/synthesis/task/file/${record.id}/detail`, {
</Button> state: { fileName: record.file_name, taskId },
})
}
>
{record.file_name}
</Button>
</div>
), ),
}, },
{ {
@@ -113,13 +156,13 @@ export default function SynthFileTask() {
render: (status?: string) => { render: (status?: string) => {
let badgeStatus: BadgeProps["status"] = "default"; let badgeStatus: BadgeProps["status"] = "default";
let text = status || "未知"; let text = status || "未知";
if (status === "pending" || status === "processing") { if (status === "pending" || status === "PROCESSING" || status === "processing") {
badgeStatus = "processing"; badgeStatus = "processing";
text = "处理中"; text = "处理中";
} else if (status === "completed") { } else if (status === "COMPLETED" || status === "completed") {
badgeStatus = "success"; badgeStatus = "success";
text = "已完成"; text = "已完成";
} else if (status === "failed") { } else if (status === "FAILED" || status === "failed") {
badgeStatus = "error"; badgeStatus = "error";
text = "失败"; text = "失败";
} }
@@ -127,19 +170,28 @@ export default function SynthFileTask() {
}, },
}, },
{ {
title: "切片进度", title: "切片总数",
key: "chunks", dataIndex: "total_chunks",
render: (_text, record) => ( key: "total_chunks",
<span>
{record.processed_chunks}/{record.total_chunks}
</span>
),
}, },
{ {
title: "目标文件路径", title: "处理进度",
dataIndex: "target_file_location", key: "progress",
key: "target_file_location", render: (_text, record) => {
ellipsis: true, const total = record.total_chunks || 0;
const processed = record.processed_chunks || 0;
const percent = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
return (
<div style={{ minWidth: 160 }}>
<Progress
percent={percent}
size="small"
status={percent === 100 ? "success" : undefined}
format={() => `${processed}/${total}`}
/>
</div>
);
},
}, },
{ {
title: "创建时间", title: "创建时间",
@@ -153,42 +205,138 @@ export default function SynthFileTask() {
key: "updated_at", key: "updated_at",
render: (val?: string) => (val ? formatDateTime(val) : "-"), render: (val?: string) => (val ? formatDateTime(val) : "-"),
}, },
{
title: "操作",
key: "actions",
render: () => (
<Tooltip title="删除">
<Button
type="text"
danger
disabled
icon={<Trash2 className="w-4 h-4" />}
/>
</Tooltip>
),
},
];
const handleRefresh = async () => {
// 刷新按钮:明确触发一次顶部 loading,让用户看到“闪一下”的效果
window.dispatchEvent(new Event("loading:show"));
try {
await fetchTaskDetail();
await fetchData(pagination.current || 1, pagination.pageSize || 10, false);
} finally {
window.dispatchEvent(new Event("loading:hide"));
}
};
const handleDelete = async () => {
if (!taskId) return;
try {
await deleteSynthesisTaskByIdUsingDelete(taskId);
message.success("合成任务已删除");
navigate("/data/synthesis/task");
} catch {
message.error("删除合成任务失败");
}
};
// 头部统计与操作
const headerData: Record<string, unknown> = taskInfo
? {
name: taskInfo.name,
id: taskInfo.id,
icon: <Sparkles className="w-8 h-8" />,
description: taskInfo.description,
createdAt: taskInfo.created_at ? formatDateTime(taskInfo.created_at) : "--",
}
: {};
const statistics = [
{
key: "type",
icon: <Sparkles className="w-4 h-4 text-blue-500" />,
label: "类型",
value:
taskInfo?.synthesis_type === "QA"
? "问答对生成"
: taskInfo?.synthesis_type === "COT"
? "链式推理生成"
: taskInfo?.synthesis_type || "--",
},
{
key: "fileCount",
icon: <Folder className="w-4 h-4 text-purple-500" />,
label: "文件数",
value: taskInfo?.total_files ?? "--",
},
];
const operations = [
{
key: "refresh",
label: "刷新",
icon: <ReloadOutlined className="w-4 h-4" />,
onClick: handleRefresh,
},
{
key: "delete",
label: "删除任务",
icon: <DeleteOutlined className="w-4 h-4" />,
danger: true,
confirm: {
title: "确认删除该合成任务?",
description: "删除后将无法恢复,请谨慎操作。",
okText: "确认删除",
cancelText: "取消",
onConfirm: handleDelete,
placement: "top",
overlayStyle: {
marginTop: 40,
},
},
},
];
const tabList = [
{
key: "files",
label: "处理文件",
},
];
const breadItems = [
{
title: <Link to="/data/synthesis/task"></Link>,
},
{
title: taskInfo?.name || "任务详情",
},
]; ];
return ( return (
<div className="p-4 bg-white rounded-lg h-full flex flex-col"> <>
{/* 顶部任务信息和返回按钮 */} <Breadcrumb items={breadItems} />
<div className="flex items-center justify-between mb-4"> <div className="mb-4 mt-4">
<div className="space-y-1"> <DetailHeader data={headerData} statistics={statistics} operations={operations} />
{taskInfo && ( </div>
<> <div className="flex-overflow-auto p-6 pt-2 bg-white rounded-md shadow">
<div className="text-lg font-medium flex items-center gap-2"> <Tabs activeKey={activeTab} items={tabList} onChange={setActiveTab} />
<span>{taskInfo.name}</span> <div className="h-full flex-1 overflow-auto">
<span className="text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200"> {activeTab === "files" && (
{taskInfo.synthesis_type === "QA" ? "问答对生成" : taskInfo.synthesis_type === "COT" ? "链式推理生成" : taskInfo.synthesis_type} <Table<SynthesisFileTaskItem>
</span> rowKey="id"
<span className="text-xs px-2 py-0.5 rounded bg-gray-50 text-gray-700 border border-gray-200"> loading={loading}
{taskInfo.status === "pending" ? "等待中" : taskInfo.status === "completed" ? "已完成" : taskInfo.status === "failed" ? "失败" : taskInfo.status} dataSource={data}
</span> columns={columns}
</div> pagination={pagination}
<div className="text-xs text-gray-500 flex gap-4"> onChange={handleTableChange}
<span>{formatDateTime(taskInfo.created_at)}</span> />
<span>ID{taskInfo.model_id}</span>
</div>
</>
)} )}
</div> </div>
<Button type="default" onClick={() => navigate("/data/synthesis/task")}></Button>
</div> </div>
{/* 文件任务表格 */} </>
<Table<SynthesisFileTaskItem>
rowKey="id"
loading={loading}
dataSource={data}
columns={columns}
pagination={pagination}
onChange={handleTableChange}
/>
</div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
import { Card, Button, Table, Modal, message, Tooltip, Form, Input, Select } from "antd"; import { Card, Button, Table, Modal, message, Tooltip, Form, Input, Select } from "antd";
import { import {
Plus, Plus,
@@ -50,11 +50,6 @@ interface SynthesisTask {
updated_by?: string; updated_by?: string;
} }
interface SynthesisDataItem {
id: string;
[key: string]: any;
}
export default function SynthesisTaskTab() { export default function SynthesisTaskTab() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -74,11 +69,6 @@ export default function SynthesisTaskTab() {
const [evalForm] = Form.useForm(); const [evalForm] = Form.useForm();
// 合成数据相关状态
const [activeChunkId, setActiveChunkId] = useState<string | null>(null);
const [synthesisData, setSynthesisData] = useState<SynthesisDataItem[]>([]);
const [selectedDataIds, setSelectedDataIds] = useState<string[]>([]);
// 获取任务列表 // 获取任务列表
const loadTasks = async () => { const loadTasks = async () => {
setLoading(true); setLoading(true);
@@ -118,7 +108,16 @@ export default function SynthesisTaskTab() {
}; };
// 表格列 // 表格列
const ellipsisStyle = {
maxWidth: 100,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "inline-block",
verticalAlign: "middle",
};
const taskColumns = [ const taskColumns = [
{ {
title: ( title: (
<Button <Button
@@ -145,6 +144,7 @@ export default function SynthesisTaskTab() {
dataIndex: "name", dataIndex: "name",
key: "name", key: "name",
fixed: "left" as const, fixed: "left" as const,
width: 160,
render: (_: unknown, task: SynthesisTask) => ( render: (_: unknown, task: SynthesisTask) => (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center shadow-sm"> <div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center shadow-sm">
@@ -152,34 +152,63 @@ export default function SynthesisTaskTab() {
{task.synthesis_type?.toUpperCase()?.slice(0, 1) || "T"} {task.synthesis_type?.toUpperCase()?.slice(0, 1) || "T"}
</span> </span>
</div> </div>
<div> <Tooltip title={task.name} placement="top">
<Link to={`/data/synthesis/task/${task.id}`}>{task.name}</Link> <div style={{ ...ellipsisStyle, maxWidth: 100 }}>
</div> <Link to={`/data/synthesis/task/${task.id}`}>{task.name}</Link>
</div>
</Tooltip>
</div> </div>
), ),
}, },
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 140,
render: (id: string) => (
<Tooltip title={id} placement="top">
<span style={{ ...ellipsisStyle, maxWidth: 140 }}>{id}</span>
</Tooltip>
),
},
{ {
title: "类型", title: "类型",
dataIndex: "synthesis_type", dataIndex: "synthesis_type",
key: "synthesis_type", key: "synthesis_type",
render: (type: string) => typeMap[type] || type, width: 100,
render: (type: string) => (
<Tooltip title={typeMap[type] || type} placement="top">
<span style={{ ...ellipsisStyle, maxWidth: 100 }}>{typeMap[type] || type}</span>
</Tooltip>
),
}, },
{ {
title: "文件数", title: "文件数",
dataIndex: "total_files", dataIndex: "total_files",
key: "total_files", key: "total_files",
render: (num: number, task: SynthesisTask) => <span>{num ?? (task.source_file_id?.length ?? 0)}</span>, width: 70,
render: (num: number, task: SynthesisTask) => (
<Tooltip title={num ?? (task.source_file_id?.length ?? 0)} placement="top">
<span style={{ ...ellipsisStyle, maxWidth: 70 }}>{num ?? (task.source_file_id?.length ?? 0)}</span>
</Tooltip>
),
}, },
{ {
title: "创建时间", title: "创建时间",
dataIndex: "created_at", dataIndex: "created_at",
key: "created_at", key: "created_at",
render: (val: string) => formatDateTime(val), width: 200,
render: (val: string) => (
<Tooltip title={formatDateTime(val)} placement="top">
<span style={{ ...ellipsisStyle, maxWidth: 200 }}>{formatDateTime(val)}</span>
</Tooltip>
),
}, },
{ {
title: "操作", title: "操作",
key: "actions", key: "actions",
fixed: "right" as const, fixed: "right" as const,
width: 120,
render: (_: unknown, task: SynthesisTask) => ( render: (_: unknown, task: SynthesisTask) => (
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<Tooltip title="查看详情"> <Tooltip title="查看详情">

View File

@@ -2,7 +2,7 @@ from datetime import datetime
from enum import Enum from enum import Enum
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
class TextSplitConfig(BaseModel): class TextSplitConfig(BaseModel):
@@ -27,13 +27,21 @@ class SynthesisType(Enum):
class CreateSynthesisTaskRequest(BaseModel): class CreateSynthesisTaskRequest(BaseModel):
"""创建数据合成任务请求""" """创建数据合成任务请求"""
name: str = Field(..., description="合成任务名称") name: str = Field(..., description="合成任务名称")
description: str = Field(None, description="合成任务描述") description: Optional[str] = Field(None, description="合成任务描述")
model_id: str = Field(..., description="模型ID") model_id: str = Field(..., description="模型ID")
source_file_id: list[str] = Field(..., description="原始文件ID列表") source_file_id: list[str] = Field(..., description="原始文件ID列表")
text_split_config: TextSplitConfig = Field(None, description="文本切片配置") text_split_config: TextSplitConfig = Field(None, description="文本切片配置")
synthesis_config: SynthesisConfig = Field(..., description="合成配置") synthesis_config: SynthesisConfig = Field(..., description="合成配置")
synthesis_type: SynthesisType = Field(..., description="合成类型") synthesis_type: SynthesisType = Field(..., description="合成类型")
@field_validator("description")
@classmethod
def empty_string_to_none(cls, v: Optional[str]) -> Optional[str]:
"""前端如果传入空字符串,将其统一转换为 None,避免存库时看起来像有描述但实际上为空。"""
if isinstance(v, str) and v.strip() == "":
return None
return v
class DataSynthesisTaskItem(BaseModel): class DataSynthesisTaskItem(BaseModel):
"""数据合成任务列表/详情项""" """数据合成任务列表/详情项"""