feat(chunk): add chunk editing and image preview functionality
This commit is contained in:
@@ -24,6 +24,10 @@ import {
|
||||
DialogContentText,
|
||||
Toolbar,
|
||||
FormControlLabel,
|
||||
TextField,
|
||||
Switch,
|
||||
Backdrop,
|
||||
Popover,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Image as ImageIcon,
|
||||
@@ -35,9 +39,13 @@ import {
|
||||
ToggleOff as DisableIcon,
|
||||
SelectAll as SelectAllIcon,
|
||||
Clear as ClearIcon,
|
||||
Edit as EditIcon,
|
||||
Close as CloseIcon,
|
||||
Save as SaveIcon,
|
||||
ZoomIn as ZoomInIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { IChunk } from '@/interfaces/database/knowledge';
|
||||
import type { IChunk, IChunkDetail } from '@/interfaces/database/knowledge';
|
||||
import knowledgeService from '@/services/knowledge_service';
|
||||
|
||||
interface ChunkListResultProps {
|
||||
@@ -54,7 +62,7 @@ interface ChunkListResultProps {
|
||||
}
|
||||
|
||||
function ChunkListResult(props: ChunkListResultProps) {
|
||||
const { doc_id, chunks, total, loading, error, page, pageSize, onPageChange, onRefresh, docName } = props;
|
||||
const { doc_id, chunks, total, loading, page, pageSize, onPageChange, onRefresh } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 选择状态
|
||||
@@ -65,6 +73,19 @@ function ChunkListResult(props: ChunkListResultProps) {
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
// 图片预览状态
|
||||
const [imagePreviewOpen, setImagePreviewOpen] = useState(false);
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState('');
|
||||
const [imageHoverAnchor, setImageHoverAnchor] = useState<HTMLElement | null>(null);
|
||||
const [hoveredImageUrl, setHoveredImageUrl] = useState('');
|
||||
|
||||
// 编辑状态
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingChunk, setEditingChunk] = useState<IChunkDetail | null>(null);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [editAvailable, setEditAvailable] = useState(true);
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
|
||||
// 处理单个选择
|
||||
const handleSelectChunk = useCallback((chunkId: string, checked: boolean) => {
|
||||
setSelectedChunks(prev => {
|
||||
@@ -115,6 +136,84 @@ function ChunkListResult(props: ChunkListResultProps) {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
}, [selectedChunks, handleClearSelection, onRefresh]);
|
||||
|
||||
// 图片hover处理
|
||||
const handleImageHover = useCallback((event: React.MouseEvent<HTMLElement>, imageUrl: string) => {
|
||||
setImageHoverAnchor(event.currentTarget);
|
||||
setHoveredImageUrl(imageUrl);
|
||||
}, []);
|
||||
|
||||
const handleImageHoverClose = useCallback(() => {
|
||||
setImageHoverAnchor(null);
|
||||
setHoveredImageUrl('');
|
||||
}, []);
|
||||
|
||||
// 图片点击放大
|
||||
const handleImageClick = useCallback((imageUrl: string) => {
|
||||
setPreviewImageUrl(imageUrl);
|
||||
setImagePreviewOpen(true);
|
||||
}, []);
|
||||
|
||||
// 编辑chunk
|
||||
const handleEditChunk = useCallback(async (chunk: IChunk) => {
|
||||
try {
|
||||
setEditLoading(true);
|
||||
// 获取chunk详情
|
||||
const chunk_id = chunk.chunk_id || '';
|
||||
const response = await knowledgeService.getChunk({ chunk_id });
|
||||
const chunkDetail: IChunkDetail = response.data.data;
|
||||
|
||||
setEditingChunk(chunkDetail);
|
||||
setEditContent(chunkDetail.content_with_weight || '');
|
||||
|
||||
const available_int = chunk.available_int
|
||||
const available_int_D = chunkDetail.available_int;
|
||||
if (available_int_D === undefined) {
|
||||
setEditAvailable(available_int === 1);
|
||||
} else {
|
||||
setEditAvailable(available_int_D === 1);
|
||||
}
|
||||
|
||||
setEditDialogOpen(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to get chunk detail:', err);
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = useCallback(async () => {
|
||||
if (!editingChunk) return;
|
||||
|
||||
try {
|
||||
setEditLoading(true);
|
||||
await knowledgeService.updateChunk({
|
||||
...editingChunk,
|
||||
chunk_id: editingChunk.id || '',
|
||||
content_with_weight: editContent,
|
||||
available_int: editAvailable ? 1 : 0,
|
||||
});
|
||||
|
||||
setEditDialogOpen(false);
|
||||
setEditingChunk(null);
|
||||
// delay 800 ms
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to update chunk:', err);
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
}, [editingChunk, editContent, editAvailable, onRefresh]);
|
||||
|
||||
// 取消编辑
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditDialogOpen(false);
|
||||
setEditingChunk(null);
|
||||
setEditContent('');
|
||||
setEditAvailable(true);
|
||||
}, []);
|
||||
|
||||
// 删除chunks
|
||||
const handleDeleteChunks = useCallback(async () => {
|
||||
@@ -148,16 +247,6 @@ function ChunkListResult(props: ChunkListResultProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Alert severity="error">
|
||||
{error}
|
||||
</Alert>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chunks || chunks.length === 0) {
|
||||
return (
|
||||
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||
@@ -176,6 +265,7 @@ function ChunkListResult(props: ChunkListResultProps) {
|
||||
const selectedEnabledCount = selectedChunks.filter(id =>
|
||||
chunks.find(chunk => chunk.chunk_id === id)?.available_int === 1
|
||||
).length;
|
||||
|
||||
const selectedDisabledCount = selectedChunks.length - selectedEnabledCount;
|
||||
|
||||
return (
|
||||
@@ -261,7 +351,7 @@ function ChunkListResult(props: ChunkListResultProps) {
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{chunks.map((chunk, index) => (
|
||||
<Grid size={12} key={chunk.chunk_id}>
|
||||
<Grid size={6} key={chunk.chunk_id}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
@@ -279,114 +369,151 @@ function ChunkListResult(props: ChunkListResultProps) {
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
{/* 头部信息 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
{/* 头部操作区域 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Checkbox
|
||||
checked={selectedChunks.includes(chunk.chunk_id)}
|
||||
onChange={(e) => handleSelectChunk(chunk.chunk_id, e.target.checked)}
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: chunk.available_int === 1 ? 'success.main' : 'grey.400',
|
||||
mr: 2,
|
||||
width: 32,
|
||||
height: 32,
|
||||
}}
|
||||
>
|
||||
<TextIcon fontSize="small" />
|
||||
</Avatar>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="subtitle1" fontWeight="medium">
|
||||
Chunk #{((page - 1) * pageSize) + index + 1}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
ID: {chunk.chunk_id}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Chip
|
||||
icon={chunk.available_int === 1 ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
label={chunk.available_int === 1 ? t('chunkPage.enabled') : t('chunkPage.disabled')}
|
||||
size="small"
|
||||
color={chunk.available_int === 1 ? 'success' : 'default'}
|
||||
variant={chunk.available_int === 1 ? 'filled' : 'outlined'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 主要内容区域 - 左右布局 */}
|
||||
<Box sx={{ display: 'flex', gap: 2, minHeight: '120px' }}>
|
||||
{/* 左侧图片区域 */}
|
||||
<Box sx={{
|
||||
width: chunk.image_id ? '120px' : '0px',
|
||||
flexShrink: 0,
|
||||
display: chunk.image_id ? 'block' : 'none'
|
||||
}}>
|
||||
{chunk.image_id && (
|
||||
<Tooltip title={t('chunkPage.containsImage')}>
|
||||
<Avatar sx={{ bgcolor: 'info.main', width: 24, height: 24 }}>
|
||||
<ImageIcon fontSize="small" />
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Chip
|
||||
icon={chunk.available_int === 1 ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
label={chunk.available_int === 1 ? t('chunkPage.enabled') : t('chunkPage.disabled')}
|
||||
size="small"
|
||||
color={chunk.available_int === 1 ? 'success' : 'default'}
|
||||
variant={chunk.available_int === 1 ? 'filled' : 'outlined'}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
{t('chunkPage.contentPreview')}
|
||||
</Typography>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
maxHeight: '150px',
|
||||
overflow: 'auto',
|
||||
backgroundColor: 'grey.50',
|
||||
borderRadius: 1,
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{chunk.content_with_weight || t('chunkPage.noContent')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* 图片显示区域 */}
|
||||
{chunk.image_id && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
{t('chunkPage.relatedImage')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
p: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.300',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={`${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`}
|
||||
alt={t('chunkPage.chunkRelatedImage')}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '200px',
|
||||
width: '100%',
|
||||
height: '120px',
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.300',
|
||||
borderRadius: 1,
|
||||
objectFit: 'contain',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'grey.50',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
'& .image-overlay': {
|
||||
opacity: 1,
|
||||
}
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
onClick={() => handleImageClick(`${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`)}
|
||||
onMouseEnter={(e) => handleImageHover(e, `${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`)}
|
||||
onMouseLeave={handleImageHoverClose}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={`${import.meta.env.VITE_API_BASE_URL}/v1/document/image/${chunk.image_id}`}
|
||||
alt={t('chunkPage.chunkRelatedImage')}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/* 悬浮覆盖层 */}
|
||||
<Box
|
||||
className="image-overlay"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<ZoomInIcon sx={{ color: 'white', fontSize: 32 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 右侧内容区域 */}
|
||||
<Box sx={{ flex: 1, minWidth: 0, position: 'relative' }}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
height: '120px',
|
||||
overflow: 'auto',
|
||||
backgroundColor: 'grey.50',
|
||||
borderRadius: 1,
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.5,
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'grey.100',
|
||||
'& .edit-button': {
|
||||
opacity: 1,
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => handleEditChunk(chunk)}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{chunk.content_with_weight || t('chunkPage.noContent')}
|
||||
</Typography>
|
||||
|
||||
{/* 编辑按钮 */}
|
||||
<IconButton
|
||||
className="edit-button"
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
backgroundColor: 'background.paper',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.light',
|
||||
color: 'primary.contrastText',
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditChunk(chunk);
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 关键词区域 */}
|
||||
{((chunk.important_kwd ?? []).length > 0 || (chunk.question_kwd ?? []).length > 0 || (chunk.tag_kwd ?? []).length > 0) && (
|
||||
@@ -473,6 +600,120 @@ function ChunkListResult(props: ChunkListResultProps) {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* 图片hover预览 */}
|
||||
<Popover
|
||||
open={Boolean(imageHoverAnchor)}
|
||||
anchorEl={imageHoverAnchor}
|
||||
onClose={handleImageHoverClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
sx={{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1, maxWidth: 300, maxHeight: 300 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={hoveredImageUrl}
|
||||
alt="Preview"
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Popover>
|
||||
|
||||
{/* 图片放大预览 */}
|
||||
<Dialog
|
||||
open={imagePreviewOpen}
|
||||
onClose={() => setImagePreviewOpen(false)}
|
||||
maxWidth='md'
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={previewImageUrl}
|
||||
alt="Full size preview"
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '80vh',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<IconButton
|
||||
onClick={() => setImagePreviewOpen(false)}
|
||||
sx={{ color: 'white' }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 编辑chunk对话框 */}
|
||||
<Dialog
|
||||
open={editDialogOpen}
|
||||
onClose={handleCancelEdit}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{t('chunkPage.editChunk')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
label={t('chunkPage.content')}
|
||||
multiline
|
||||
rows={8}
|
||||
fullWidth
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
variant="outlined"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editAvailable}
|
||||
onChange={(e) => setEditAvailable(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('chunkPage.enabled')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelEdit}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveEdit}
|
||||
variant="contained"
|
||||
disabled={editLoading}
|
||||
startIcon={editLoading ? <CircularProgress size={16} /> : <SaveIcon />}
|
||||
>
|
||||
{editLoading ? t('chunkPage.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
|
||||
Reference in New Issue
Block a user