first-update
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user