first-update

This commit is contained in:
2026-03-17 14:36:31 +08:00
parent 72f08aee7c
commit 4eddf05e79
516 changed files with 115270 additions and 1 deletions

View File

@@ -0,0 +1,139 @@
'use client';
import {
Container,
Box,
Typography,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Paper
} from '@mui/material';
import ConversationHeader from '@/components/conversations/ConversationHeader';
import ConversationMetadata from '@/components/conversations/ConversationMetadata';
import ConversationContent from '@/components/conversations/ConversationContent';
import ConversationRatingSection from '@/components/conversations/ConversationRatingSection';
import useConversationDetails from './useConversationDetails';
import { useTranslation } from 'react-i18next';
/**
* 多轮对话详情页面
*/
export default function ConversationDetailPage({ params }) {
const { projectId, conversationId } = params;
const { t } = useTranslation();
// 使用自定义Hook管理状态和逻辑
const {
conversation,
messages,
loading,
editMode,
saving,
editData,
setEditData,
deleteDialogOpen,
setDeleteDialogOpen,
handleEdit,
handleSave,
handleCancel,
handleDelete,
handleNavigate,
updateMessageContent
} = useConversationDetails(projectId, conversationId);
// 加载状态
if (loading) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '70vh' }}>
<Alert severity="info">{t('datasets.loadingDataset')}</Alert>
</Box>
</Container>
);
}
// 无数据状态
if (!conversation) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Alert severity="error">{t('datasets.conversationNotFound')}</Alert>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{/* 顶部导航栏 */}
<ConversationHeader
projectId={projectId}
conversationId={conversationId}
conversation={conversation}
editMode={editMode}
saving={saving}
onEdit={handleEdit}
onSave={handleSave}
onCancel={handleCancel}
onDelete={() => setDeleteDialogOpen(true)}
onNavigate={handleNavigate}
/>
{/* 主要布局:左右分栏 */}
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
{/* 左侧主要内容区域 */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Paper sx={{ p: 3 }}>
{/* 对话内容 */}
<ConversationContent
messages={editMode ? editData.messages : messages}
editMode={editMode}
onMessageChange={updateMessageContent}
conversation={conversation}
/>
</Paper>
</Box>
{/* 右侧固定侧边栏 */}
<Box
sx={{
width: 360,
position: 'sticky',
top: 24,
maxHeight: 'calc(100vh - 48px)',
overflowY: 'auto'
}}
>
{/* 元数据展示 */}
<ConversationMetadata conversation={conversation} />
{/* 评分、标签、备注区域 */}
<ConversationRatingSection
conversation={conversation}
projectId={projectId}
onUpdate={() => {
// 更新成功后刷新数据,保持页面状态同步
// 这里可以调用 useConversationDetails 的刷新逻辑
}}
/>
</Box>
</Box>
{/* 删除确认对话框 */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>{t('datasets.confirmDelete')}</DialogTitle>
<DialogContent>
<Typography>{t('datasets.confirmDeleteConversation')}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>{t('common.cancel')}</Button>
<Button color="error" onClick={handleDelete}>
{t('common.delete')}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
/**
* 多轮对话详情页面的状态管理Hook
*/
export default function useConversationDetails(projectId, conversationId) {
const { t } = useTranslation();
const router = useRouter();
// 基础状态
const [conversation, setConversation] = useState(null);
const [messages, setMessages] = useState([]);
const [loading, setLoading] = useState(true);
// 编辑状态
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [editData, setEditData] = useState({
score: 0,
tags: '',
note: '',
confirmed: false,
messages: []
});
// 对话框状态
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// 获取对话详情
const fetchConversation = async () => {
try {
setLoading(true);
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`);
if (!response.ok) {
if (response.status === 404) {
toast.error(t('datasets.conversationNotFound'));
router.push(`/projects/${projectId}/multi-turn`);
return;
}
throw new Error(t('datasets.fetchDataFailed'));
}
const data = await response.json();
setConversation(data);
// 解析对话消息
let parsedMessages = [];
try {
parsedMessages = JSON.parse(data.rawMessages || '[]');
setMessages(parsedMessages);
} catch (error) {
console.error('解析对话消息失败:', error);
setMessages([]);
}
// 设置编辑数据
setEditData({
score: data.score || 0,
tags: data.tags || '',
note: data.note || '',
confirmed: data.confirmed || false,
messages: parsedMessages
});
} catch (error) {
console.error('获取对话详情失败:', error);
toast.error(error.message || t('datasets.fetchDataFailed'));
} finally {
setLoading(false);
}
};
// 保存编辑
const handleSave = async () => {
try {
setSaving(true);
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
score: editData.score,
tags: editData.tags,
note: editData.note,
confirmed: editData.confirmed,
messages: editData.messages
})
});
if (!response.ok) {
throw new Error(t('datasets.saveFailed'));
}
// 更新本地状态
setConversation({ ...conversation, ...editData });
setMessages(editData.messages);
setEditMode(false);
toast.success(t('datasets.saveSuccess'));
} catch (error) {
console.error('保存失败:', error);
toast.error(error.message || t('datasets.saveFailed'));
} finally {
setSaving(false);
}
};
// 开始编辑
const handleEdit = () => {
setEditMode(true);
};
// 取消编辑
const handleCancel = () => {
// 恢复到原始数据
setEditData({
score: conversation.score || 0,
tags: conversation.tags || '',
note: conversation.note || '',
confirmed: conversation.confirmed || false,
messages: messages
});
setEditMode(false);
};
// 删除对话
const handleDelete = async () => {
try {
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(t('datasets.deleteFailed'));
}
toast.success(t('datasets.deleteSuccess'));
router.push(`/projects/${projectId}/multi-turn`);
} catch (error) {
console.error('删除失败:', error);
toast.error(error.message || t('datasets.deleteFailed'));
}
};
// 更新消息内容
const updateMessageContent = (index, newContent) => {
const updatedMessages = [...editData.messages];
updatedMessages[index] = { ...updatedMessages[index], content: newContent };
setEditData({ ...editData, messages: updatedMessages });
};
// 翻页导航
const handleNavigate = async direction => {
try {
const response = await fetch(
`/api/projects/${projectId}/dataset-conversations/${conversationId}?operateType=${direction}`
);
if (!response.ok) {
throw new Error('获取导航数据失败');
}
const data = await response.json();
if (data) {
router.push(`/projects/${projectId}/multi-turn/${data.id}`);
} else {
toast.warning(`已经是${direction === 'next' ? '最后' : '第'}一条对话了`);
}
} catch (error) {
console.error('导航失败:', error);
toast.error(error.message || '导航失败');
}
};
// 初始化
useEffect(() => {
fetchConversation();
}, [projectId, conversationId]);
return {
// 数据状态
conversation,
messages,
loading,
// 编辑状态
editMode,
saving,
editData,
setEditData,
// 对话框状态
deleteDialogOpen,
setDeleteDialogOpen,
// 操作方法
handleEdit,
handleSave,
handleCancel,
handleDelete,
handleNavigate,
updateMessageContent,
fetchConversation
};
}

View File

@@ -0,0 +1,313 @@
'use client';
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
IconButton,
Tooltip,
Typography,
Chip,
CircularProgress,
Checkbox
} from '@mui/material';
import { Delete as DeleteIcon, Visibility as ViewIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import RatingChip from './RatingChip';
const QUESTION_TOOLTIP_THRESHOLD = 80;
const SCENARIO_TOOLTIP_THRESHOLD = 120;
const ConversationTable = ({
conversations,
loading,
total,
page,
rowsPerPage,
onPageChange,
onRowsPerPageChange,
onView,
onDelete,
selectedIds = [],
onSelectionChange,
isAllSelected = false,
onSelectAll
}) => {
const { t } = useTranslation();
const [expandedRows, setExpandedRows] = useState({});
const columnWidths = {
checkbox: 52,
question: 280,
scenario: 340,
rounds: 90,
model: 120,
rating: 100,
createdAt: 110,
actions: 92
};
const shouldShowTooltip = (value, threshold) => (value || '').length > threshold;
const handleSelectOne = conversationId => {
if (selectedIds.includes(conversationId)) {
onSelectionChange(selectedIds.filter(id => id !== conversationId));
} else {
onSelectionChange([...selectedIds, conversationId]);
}
};
const handleSelectAll = () => {
if (isAllSelected) {
onSelectionChange([]);
onSelectAll(false);
} else {
const currentPageIds = conversations.map(conv => conv.id);
onSelectionChange(currentPageIds);
onSelectAll(true);
}
};
const isIndeterminate = selectedIds.length > 0 && !isAllSelected;
const toggleRowExpanded = conversationId => {
setExpandedRows(prev => ({ ...prev, [conversationId]: !prev[conversationId] }));
};
return (
<TableContainer component={Paper} elevation={0} sx={{ overflowX: 'auto' }}>
<Table
sx={{
tableLayout: 'fixed',
width: '100%',
minWidth: 1184,
'& .MuiTableCell-root': {
px: 1.25,
py: 1
}
}}
>
<TableHead>
<TableRow sx={{ bgcolor: 'action.hover' }}>
<TableCell padding="checkbox" sx={{ width: columnWidths.checkbox, py: 1.25 }}>
<Checkbox indeterminate={isIndeterminate} checked={isAllSelected} onChange={handleSelectAll} />
</TableCell>
<TableCell sx={{ width: columnWidths.question, minWidth: columnWidths.question, py: 1.25 }}>
{t('datasets.firstQuestion')}
</TableCell>
<TableCell sx={{ width: columnWidths.scenario, minWidth: columnWidths.scenario, py: 1.25 }}>
{t('datasets.conversationScenario')}
</TableCell>
<TableCell sx={{ width: columnWidths.rounds, py: 1.25 }}>{t('datasets.conversationRounds')}</TableCell>
<TableCell sx={{ width: columnWidths.model, py: 1.25 }}>{t('datasets.modelUsed')}</TableCell>
<TableCell sx={{ width: columnWidths.rating, py: 1.25 }}>{t('datasets.rating')}</TableCell>
<TableCell sx={{ width: columnWidths.createdAt, py: 1.25 }}>{t('datasets.createTime')}</TableCell>
<TableCell
align="center"
sx={{
width: columnWidths.actions,
py: 1.25,
position: 'sticky',
right: 0,
zIndex: 3,
bgcolor: 'background.paper',
borderLeft: 1,
borderColor: 'divider'
}}
>
{t('common.actions')}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
<CircularProgress size={40} />
</TableCell>
</TableRow>
) : conversations.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
<Typography variant="body1" color="text.secondary">
{t('datasets.noConversations')}
</Typography>
</TableCell>
</TableRow>
) : (
conversations.map(conversation => {
const questionText = conversation.question || '';
const scenarioText = conversation.scenario || '';
const isExpanded = Boolean(expandedRows[conversation.id]);
const canToggleExpand =
questionText.length > QUESTION_TOOLTIP_THRESHOLD || scenarioText.length > SCENARIO_TOOLTIP_THRESHOLD;
const questionContent = (
<Typography
variant="body2"
sx={{
display: '-webkit-box',
WebkitLineClamp: isExpanded ? 'unset' : 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
whiteSpace: 'normal',
overflowWrap: 'break-word',
wordBreak: 'normal',
lineHeight: 1.5
}}
>
{questionText}
</Typography>
);
const scenarioContent = (
<Paper
variant="outlined"
sx={{
px: 1,
py: 0.5,
maxWidth: '100%',
borderColor: scenarioText ? 'primary.main' : 'divider',
backgroundColor: scenarioText ? 'action.selected' : 'background.default'
}}
>
<Typography
variant="caption"
sx={{
display: '-webkit-box',
WebkitLineClamp: isExpanded ? 'unset' : 1,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
whiteSpace: 'normal',
overflowWrap: 'break-word',
wordBreak: 'normal',
lineHeight: 1.45
}}
>
{scenarioText || t('datasets.notSet')}
</Typography>
</Paper>
);
return (
<TableRow key={conversation.id} hover>
<TableCell padding="checkbox" sx={{ verticalAlign: 'top' }}>
<Checkbox
checked={selectedIds.includes(conversation.id)}
onChange={() => handleSelectOne(conversation.id)}
/>
</TableCell>
<TableCell sx={{ verticalAlign: 'top' }}>
{shouldShowTooltip(questionText, QUESTION_TOOLTIP_THRESHOLD) ? (
<Tooltip title={questionText} placement="top-start">
{questionContent}
</Tooltip>
) : (
questionContent
)}
{conversation.confirmed && (
<Chip
label={t('datasets.confirmed')}
size="small"
color="success"
variant="outlined"
sx={{ mt: 0.5, fontSize: '0.7rem' }}
/>
)}
{canToggleExpand && (
<Typography
variant="caption"
color="primary.main"
sx={{ display: 'block', mt: 0.5, cursor: 'pointer', userSelect: 'none' }}
onClick={() => toggleRowExpanded(conversation.id)}
>
{isExpanded ? t('common.collapse') : t('common.expand')}
</Typography>
)}
</TableCell>
<TableCell sx={{ verticalAlign: 'top' }}>
{shouldShowTooltip(scenarioText, SCENARIO_TOOLTIP_THRESHOLD) ? (
<Tooltip title={scenarioText} placement="top-start">
{scenarioContent}
</Tooltip>
) : (
scenarioContent
)}
</TableCell>
<TableCell sx={{ verticalAlign: 'top' }}>
<Typography variant="body2">
{conversation.turnCount}/{conversation.maxTurns}
</Typography>
</TableCell>
<TableCell sx={{ verticalAlign: 'top' }}>
<Chip
label={conversation.model}
size="small"
variant="outlined"
color="info"
sx={{
maxWidth: '100%',
'& .MuiChip-label': {
overflow: 'hidden',
textOverflow: 'ellipsis'
}
}}
/>
</TableCell>
<TableCell sx={{ verticalAlign: 'top' }}>
<RatingChip score={conversation.score || 0} />
</TableCell>
<TableCell sx={{ verticalAlign: 'top' }}>
<Typography variant="caption">{new Date(conversation.createAt).toLocaleDateString()}</Typography>
</TableCell>
<TableCell
align="center"
sx={{
verticalAlign: 'top',
position: 'sticky',
right: 0,
zIndex: 2,
bgcolor: 'background.paper',
borderLeft: 1,
borderColor: 'divider'
}}
>
<Tooltip title={t('datasets.viewDetails')}>
<IconButton size="small" color="primary" onClick={() => onView(conversation.id)}>
<ViewIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton size="small" color="error" onClick={() => onDelete(conversation.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(event, newPage) => onPageChange(newPage)}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[20, 50, 100]}
onRowsPerPageChange={event => {
onRowsPerPageChange(parseInt(event.target.value, 10));
}}
labelRowsPerPage={t('datasets.rowsPerPage')}
/>
</TableContainer>
);
};
export default ConversationTable;

View File

@@ -0,0 +1,104 @@
'use client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
FormControl,
InputLabel,
Select,
MenuItem
} from '@mui/material';
import { useTranslation } from 'react-i18next';
/**
* 筛选对话框组件
* @param {boolean} open - 对话框开启状态
* @param {function} onClose - 关闭回调
* @param {object} filters - 筛选条件
* @param {function} onFiltersChange - 筛选条件变化回调
* @param {function} onReset - 重置回调
* @param {function} onApply - 应用回调
*/
const FilterDialog = ({ open, onClose, filters, onFiltersChange, onReset, onApply }) => {
const { t } = useTranslation();
const handleFilterChange = (field, value) => {
onFiltersChange({ ...filters, [field]: value });
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('datasets.filtersTitle')}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField
label={t('settings.multiTurnRoleA')}
value={filters.roleA}
onChange={e => handleFilterChange('roleA', e.target.value)}
fullWidth
/>
<TextField
label={t('settings.multiTurnRoleB')}
value={filters.roleB}
onChange={e => handleFilterChange('roleB', e.target.value)}
fullWidth
/>
<TextField
label={t('datasets.conversationScenario')}
value={filters.scenario}
onChange={e => handleFilterChange('scenario', e.target.value)}
fullWidth
/>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label={t('datasets.minScore')}
type="number"
inputProps={{ min: 0, max: 5, step: 0.1 }}
value={filters.scoreMin}
onChange={e => handleFilterChange('scoreMin', e.target.value)}
fullWidth
/>
<TextField
label={t('datasets.maxScore')}
type="number"
inputProps={{ min: 0, max: 5, step: 0.1 }}
value={filters.scoreMax}
onChange={e => handleFilterChange('scoreMax', e.target.value)}
fullWidth
/>
</Box>
<FormControl fullWidth>
<InputLabel>{t('datasets.filterConfirmationStatus')}</InputLabel>
<Select
value={filters.confirmed}
onChange={e => handleFilterChange('confirmed', e.target.value)}
label={t('datasets.filterConfirmationStatus')}
>
<MenuItem value="">{t('datasetSquare.categories.all')}</MenuItem>
<MenuItem value="true">{t('datasets.confirmed')}</MenuItem>
<MenuItem value="false">{t('datasets.unconfirmed')}</MenuItem>
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onReset} sx={{ borderRadius: 2 }}>
{t('datasets.resetFilters')}
</Button>
<Button onClick={onClose} sx={{ borderRadius: 2 }}>
{t('common.cancel')}
</Button>
<Button variant="contained" onClick={onApply} sx={{ borderRadius: 2 }}>
{t('datasets.applyFilters')}
</Button>
</DialogActions>
</Dialog>
);
};
export default FilterDialog;

View File

@@ -0,0 +1,33 @@
'use client';
import { Chip } from '@mui/material';
import { Star as StarIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { getRatingConfigI18n, formatScore } from '@/components/datasets/utils/ratingUtils';
/**
* 评分展示组件
* @param {number} score - 评分值
*/
const RatingChip = ({ score }) => {
const { t } = useTranslation();
const config = getRatingConfigI18n(score, t);
return (
<Chip
icon={<StarIcon sx={{ fontSize: '14px !important' }} />}
label={`${formatScore(score)} ${config.label}`}
size="small"
sx={{
backgroundColor: config.backgroundColor,
color: config.color,
fontWeight: 'medium',
'& .MuiChip-icon': {
color: config.color
}
}}
/>
);
};
export default RatingChip;

View File

@@ -0,0 +1,92 @@
'use client';
import { Box, Paper, Button, IconButton, InputBase, CircularProgress } from '@mui/material';
import {
Search as SearchIcon,
FilterList as FilterIcon,
Download as DownloadIcon,
Delete as DeleteIcon
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
/**
* 搜索栏组件
* @param {string} searchKeyword - 搜索关键词
* @param {function} onSearchChange - 搜索关键词变化回调
* @param {function} onSearch - 搜索回调
* @param {function} onFilterClick - 筛选按钮点击回调
* @param {function} onExportClick - 导出按钮点击回调
* @param {boolean} exportLoading - 导出加载状态
* @param {number} selectedCount - 选中的项目数量
* @param {function} onBatchDelete - 批量删除回调
* @param {boolean} batchDeleteLoading - 批量删除加载状态
*/
const SearchBar = ({
searchKeyword,
onSearchChange,
onSearch,
onFilterClick,
onExportClick,
exportLoading = false,
selectedCount = 0,
onBatchDelete,
batchDeleteLoading = false
}) => {
const { t } = useTranslation();
return (
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Paper
component="form"
sx={{
p: '2px 4px',
display: 'flex',
alignItems: 'center',
width: 400,
borderRadius: 2
}}
>
<IconButton sx={{ p: '10px' }} aria-label="search">
<SearchIcon />
</IconButton>
<InputBase
sx={{ ml: 1, flex: 1 }}
placeholder={t('datasets.searchPlaceholder')}
value={searchKeyword}
onChange={e => onSearchChange(e.target.value)}
onKeyPress={e => e.key === 'Enter' && onSearch()}
/>
</Paper>
<Button variant="outlined" startIcon={<FilterIcon />} onClick={onFilterClick} sx={{ borderRadius: 2 }}>
{t('datasets.moreFilters')}
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
{selectedCount > 0 && (
<Button
variant="outlined"
color="error"
startIcon={batchDeleteLoading ? <CircularProgress size={16} /> : <DeleteIcon />}
onClick={onBatchDelete}
disabled={batchDeleteLoading}
sx={{ borderRadius: 2 }}
>
{batchDeleteLoading ? t('datasets.deleting') : `${t('datasets.batchDelete')} (${selectedCount})`}
</Button>
)}
<Button
variant="outlined"
startIcon={exportLoading ? <CircularProgress size={16} /> : <DownloadIcon />}
onClick={onExportClick}
disabled={exportLoading}
sx={{ borderRadius: 2 }}
>
{exportLoading ? t('datasets.exporting') : t('exportDialog.export')}
</Button>
</Box>
</Box>
);
};
export default SearchBar;

View File

@@ -0,0 +1,346 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
/**
* Multi-turn dataset data hook
* @param {string} projectId
*/
export const useMultiTurnData = projectId => {
const { t } = useTranslation();
const router = useRouter();
const [conversations, setConversations] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(20);
const [total, setTotal] = useState(0);
const [searchKeyword, setSearchKeyword] = useState('');
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState([]);
const [isAllSelected, setIsAllSelected] = useState(false);
const [batchDeleteLoading, setBatchDeleteLoading] = useState(false);
const [filters, setFilters] = useState({
roleA: '',
roleB: '',
scenario: '',
scoreMin: '',
scoreMax: '',
confirmed: ''
});
const abortRef = useRef(null);
const buildQuery = ({ pageIndex, keyword, filterValues }) => {
const params = new URLSearchParams({
page: String(pageIndex + 1),
pageSize: String(rowsPerPage)
});
if (keyword) params.append('keyword', keyword);
if (filterValues.roleA) params.append('roleA', filterValues.roleA);
if (filterValues.roleB) params.append('roleB', filterValues.roleB);
if (filterValues.scenario) params.append('scenario', filterValues.scenario);
if (filterValues.scoreMin) params.append('scoreMin', filterValues.scoreMin);
if (filterValues.scoreMax) params.append('scoreMax', filterValues.scoreMax);
if (filterValues.confirmed) params.append('confirmed', filterValues.confirmed);
return params;
};
const fetchConversations = async (newPage = page, options = {}) => {
const keyword = options.keyword ?? searchKeyword;
const filterValues = options.filterValues ?? filters;
const showLoading = options.showLoading ?? true;
try {
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
if (showLoading) {
setLoading(true);
}
const params = buildQuery({ pageIndex: newPage, keyword, filterValues });
const response = await fetch(`/api/projects/${projectId}/dataset-conversations?${params.toString()}`, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(t('datasets.fetchDataFailed'));
}
const data = await response.json();
setConversations(data.data || []);
setTotal(data.total || 0);
} catch (error) {
if (error?.name === 'AbortError') return;
console.error('Failed to fetch multi-turn dataset list:', error);
toast.error(error.message || t('datasets.fetchDataFailed'));
} finally {
if (showLoading) {
setLoading(false);
}
if (abortRef.current === controller) {
abortRef.current = null;
}
}
};
const handleExport = async () => {
try {
setExportLoading(true);
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/export`);
if (!response.ok) {
throw new Error(t('datasets.exportFailed'));
}
const data = await response.json();
const dataStr = JSON.stringify(data, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `multi-turn-conversations-${projectId}-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(t('datasets.exportSuccess'));
} catch (error) {
console.error('Export failed:', error);
toast.error(error.message || t('datasets.exportFailed'));
} finally {
setExportLoading(false);
}
};
const fetchAllConversationIds = async () => {
try {
const params = new URLSearchParams({ getAllIds: 'true' });
if (searchKeyword) params.append('keyword', searchKeyword);
if (filters.roleA) params.append('roleA', filters.roleA);
if (filters.roleB) params.append('roleB', filters.roleB);
if (filters.scenario) params.append('scenario', filters.scenario);
if (filters.scoreMin) params.append('scoreMin', filters.scoreMin);
if (filters.scoreMax) params.append('scoreMax', filters.scoreMax);
if (filters.confirmed) params.append('confirmed', filters.confirmed);
const response = await fetch(`/api/projects/${projectId}/dataset-conversations?${params.toString()}`);
if (!response.ok) {
throw new Error(t('datasets.fetchDataFailed'));
}
const data = await response.json();
return data.allConversationIds || [];
} catch (error) {
console.error('Failed to fetch all conversation IDs:', error);
toast.error(error.message || t('datasets.fetchDataFailed'));
return [];
}
};
const handleDelete = async conversationId => {
if (!confirm(t('datasets.confirmDeleteConversation'))) {
return;
}
try {
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(t('datasets.deleteFailed'));
}
toast.success(t('datasets.deleteSuccess'));
fetchConversations();
} catch (error) {
console.error('Delete failed:', error);
toast.error(error.message || t('datasets.deleteFailed'));
}
};
const deleteConversationsConcurrently = async (conversationIds, concurrency = 10) => {
const results = [];
const errors = [];
for (let i = 0; i < conversationIds.length; i += concurrency) {
const batch = conversationIds.slice(i, i + concurrency);
const promises = batch.map(async id => {
try {
const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Delete conversation ${id} failed`);
}
return { id, success: true };
} catch (error) {
errors.push({ id, error: error.message });
return { id, success: false, error: error.message };
}
});
const batchResults = await Promise.all(promises);
results.push(...batchResults);
}
return { results, errors };
};
const handleBatchDelete = async () => {
let idsToDelete = selectedIds;
if (isAllSelected) {
idsToDelete = await fetchAllConversationIds();
if (idsToDelete.length === 0) {
toast.error(t('datasets.noDataToDelete'));
return;
}
}
if (idsToDelete.length === 0) {
toast.error(t('datasets.pleaseSelectData'));
return;
}
if (!confirm(t('common.confirmDelete', { count: idsToDelete.length }))) {
return;
}
try {
setBatchDeleteLoading(true);
const { results, errors } = await deleteConversationsConcurrently(idsToDelete);
const successCount = results.filter(r => r.success).length;
const failCount = errors.length;
if (failCount === 0) {
toast.success(t('common.deleteSuccess', { count: successCount }));
} else {
toast.warning(t('datasets.batchDeletePartialSuccess', { success: successCount, fail: failCount }));
}
setSelectedIds([]);
setIsAllSelected(false);
fetchConversations();
} catch (error) {
console.error('Batch delete failed:', error);
toast.error(error.message || t('datasets.batchDeleteFailed'));
} finally {
setBatchDeleteLoading(false);
}
};
const handleSelectionChange = newSelectedIds => {
setSelectedIds(newSelectedIds);
if (newSelectedIds.length === 0) {
setIsAllSelected(false);
}
};
const handleSelectAll = selectAll => {
setIsAllSelected(selectAll);
if (!selectAll) {
setSelectedIds([]);
}
};
const handleView = conversationId => {
router.push(`/projects/${projectId}/multi-turn/${conversationId}`);
};
const applyFilters = () => {
setPage(0);
setFilterDialogOpen(false);
fetchConversations(0, { keyword: searchKeyword, filterValues: filters });
};
const resetFilters = () => {
const clearedFilters = {
roleA: '',
roleB: '',
scenario: '',
scoreMin: '',
scoreMax: '',
confirmed: ''
};
setFilters(clearedFilters);
setSearchKeyword('');
setPage(0);
fetchConversations(0, { keyword: '', filterValues: clearedFilters });
};
const handleSearch = () => {
setPage(0);
fetchConversations(0, { keyword: searchKeyword, filterValues: filters });
};
const handlePageChange = newPage => {
setPage(newPage);
};
const handleRowsPerPageChange = newRowsPerPage => {
setRowsPerPage(newRowsPerPage);
setPage(0);
};
useEffect(() => {
fetchConversations(page, { showLoading: true });
}, [projectId, page, rowsPerPage]);
useEffect(() => {
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
}, []);
return {
conversations,
loading,
page,
rowsPerPage,
total,
searchKeyword,
filterDialogOpen,
exportLoading,
filters,
selectedIds,
isAllSelected,
batchDeleteLoading,
setSearchKeyword,
setFilterDialogOpen,
setFilters,
fetchConversations,
handleExport,
handleDelete,
handleView,
applyFilters,
resetFilters,
handleSearch,
handlePageChange,
handleRowsPerPageChange,
handleBatchDelete,
handleSelectionChange,
handleSelectAll
};
};

View File

@@ -0,0 +1,106 @@
'use client';
import { Container, Typography, Box, Card, useTheme, alpha } from '@mui/material';
import { Chat as ChatIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
// 导入拆分后的组件
import SearchBar from './components/SearchBar';
import ConversationTable from './components/ConversationTable';
import FilterDialog from './components/FilterDialog';
import { useMultiTurnData } from './hooks/useMultiTurnData';
export default function MultiTurnDatasetPage({ params }) {
const { t } = useTranslation();
const theme = useTheme();
const { projectId } = params;
// 使用自定义Hook管理状态和逻辑
const {
conversations,
loading,
page,
rowsPerPage,
total,
searchKeyword,
filterDialogOpen,
exportLoading,
filters,
selectedIds,
isAllSelected,
batchDeleteLoading,
setSearchKeyword,
setFilterDialogOpen,
setFilters,
fetchConversations,
handleExport,
handleDelete,
handleView,
applyFilters,
resetFilters,
handleSearch,
handlePageChange,
handleRowsPerPageChange,
handleBatchDelete,
handleSelectionChange,
handleSelectAll
} = useMultiTurnData(projectId);
return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 6 }}>
<Card
elevation={0}
sx={{
p: 2,
mb: 3,
backgroundColor: alpha(theme.palette.primary.light, 0.05),
borderRadius: 2
}}
>
{/* <Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<ChatIcon sx={{ mr: 2, fontSize: 32, color: 'primary.main' }} />
<Typography variant="h4" component="h1" sx={{ fontWeight: 'bold' }}>
{t('datasets.multiTurnConversations')}
</Typography>
</Box> */}
<SearchBar
searchKeyword={searchKeyword}
onSearchChange={setSearchKeyword}
onSearch={handleSearch}
onFilterClick={() => setFilterDialogOpen(true)}
onExportClick={handleExport}
exportLoading={exportLoading}
selectedCount={isAllSelected ? total : selectedIds.length}
onBatchDelete={handleBatchDelete}
batchDeleteLoading={batchDeleteLoading}
/>
</Card>
<ConversationTable
conversations={conversations}
loading={loading}
page={page}
rowsPerPage={rowsPerPage}
total={total}
onView={handleView}
onDelete={handleDelete}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
selectedIds={selectedIds}
onSelectionChange={handleSelectionChange}
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
/>
<FilterDialog
open={filterDialogOpen}
onClose={() => setFilterDialogOpen(false)}
filters={filters}
onFiltersChange={setFilters}
onReset={resetFilters}
onApply={applyFilters}
/>
</Container>
);
}