feat(FilesPage): 添加批量删除功能

This commit is contained in:
Teo 2025-06-14 21:52:25 +08:00
parent 7d70425c75
commit 91892ea619
6 changed files with 135 additions and 50 deletions

View File

@ -470,7 +470,7 @@
"count": "files",
"created_at": "Created At",
"delete": "Delete",
"delete.content": "Deleting a file will delete its reference from all messages. Are you sure you want to delete this file?",
"delete.content": "Deleting a file will delete its reference from all messages. Are you sure you want to delete {{count}} files?",
"delete.paintings.warning": "Image contains this file, deletion is not possible",
"delete.title": "Delete File",
"document": "Document",
@ -482,7 +482,9 @@
"size": "Size",
"text": "Text",
"title": "Files",
"type": "Type"
"type": "Type",
"batch_operation": "Batch Operation",
"batch_delete": "Batch Delete"
},
"gpustack": {
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
@ -1730,6 +1732,7 @@
"theme.light": "Light",
"theme.title": "Theme",
"theme.color_primary": "Primary Color",
"theme.window.style.opaque": "Opaque Window",
"theme.window.style.title": "Window Style",
"theme.window.style.transparent": "Transparent Window",
"title": "Settings",

View File

@ -470,7 +470,7 @@
"count": "ファイル",
"created_at": "作成日",
"delete": "削除",
"delete.content": "ファイルを削除すると、ファイルがすべてのメッセージで参照されることを削除します。このファイルを削除してもよろしいですか?",
"delete.content": "ファイルを削除すると、ファイルがすべてのメッセージで参照されることを削除します。この{{count}}ファイルを削除してもよろしいですか?",
"delete.paintings.warning": "画像に含まれているため、削除できません",
"delete.title": "ファイルを削除",
"document": "ドキュメント",
@ -482,7 +482,9 @@
"size": "サイズ",
"text": "テキスト",
"title": "ファイル",
"type": "タイプ"
"type": "タイプ",
"batch_operation": "一括操作",
"batch_delete": "一括削除"
},
"gpustack": {
"keep_alive_time.description": "モデルがメモリに保持される時間デフォルト5分",
@ -1718,6 +1720,7 @@
"theme.light": "ライト",
"theme.title": "テーマ",
"theme.color_primary": "テーマ色",
"theme.window.style.opaque": "不透明ウィンドウ",
"theme.window.style.title": "ウィンドウスタイル",
"theme.window.style.transparent": "透明ウィンドウ",
"title": "設定",

View File

@ -470,7 +470,7 @@
"count": "файлов",
"created_at": "Дата создания",
"delete": "Удалить",
"delete.content": "Удаление файла удалит его из всех сообщений, вы уверены, что хотите удалить этот файл?",
"delete.content": "Удаление файла удалит его из всех сообщений, вы уверены, что хотите удалить этот {{count}} файл",
"delete.paintings.warning": "В изображениях содержится этот файл, удаление невозможно",
"delete.title": "Удалить файл",
"document": "Документ",
@ -482,7 +482,9 @@
"size": "Размер",
"text": "Текст",
"title": "Файлы",
"type": "Тип"
"type": "Тип",
"batch_operation": "Пакетная операция",
"batch_delete": "Пакетное удаление"
},
"gpustack": {
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
@ -1718,6 +1720,7 @@
"theme.light": "Светлая",
"theme.title": "Тема",
"theme.color_primary": "Цвет темы",
"theme.window.style.opaque": "Непрозрачное окно",
"theme.window.style.title": "Стиль окна",
"theme.window.style.transparent": "Прозрачное окно",
"title": "Настройки",

View File

@ -470,7 +470,7 @@
"count": "个文件",
"created_at": "创建时间",
"delete": "删除",
"delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除文件吗?",
"delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除这{{count}}个文件吗?",
"delete.paintings.warning": "绘图中包含该图片,暂时无法删除",
"delete.title": "删除文件",
"document": "文档",
@ -482,7 +482,9 @@
"size": "大小",
"text": "文本",
"title": "文件",
"type": "类型"
"type": "类型",
"batch_operation": "批量操作",
"batch_delete": "批量删除"
},
"gpustack": {
"keep_alive_time.description": "模型在内存中保持的时间默认5分钟",
@ -1730,6 +1732,7 @@
"theme.light": "浅色",
"theme.title": "主题",
"theme.color_primary": "主题颜色",
"theme.window.style.opaque": "不透明窗口",
"theme.window.style.title": "窗口样式",
"theme.window.style.transparent": "透明窗口",
"title": "设置",

View File

@ -470,7 +470,7 @@
"count": "個檔案",
"created_at": "建立時間",
"delete": "刪除",
"delete.content": "刪除檔案會刪除檔案在所有訊息中的引用,確定要刪除檔案嗎?",
"delete.content": "刪除檔案會刪除檔案在所有訊息中的引用,確定要刪除這{{count}}個檔案嗎?",
"delete.paintings.warning": "繪圖中包含該圖片,暫時無法刪除",
"delete.title": "刪除檔案",
"document": "文件",
@ -482,7 +482,9 @@
"size": "大小",
"text": "文字",
"title": "檔案",
"type": "類型"
"type": "類型",
"batch_operation": "批量操作",
"batch_delete": "批量刪除"
},
"gpustack": {
"keep_alive_time.description": "模型在記憶體中保持的時間(預設為 5 分鐘)",

View File

@ -15,11 +15,11 @@ import store from '@renderer/store'
import { FileType, FileTypes } from '@renderer/types'
import { Message } from '@renderer/types/newMessage'
import { formatFileSize } from '@renderer/utils'
import { Button, Empty, Flex, Popconfirm } from 'antd'
import { Button, Checkbox, Dropdown, Empty, Flex, Popconfirm } from 'antd'
import dayjs from 'dayjs'
import { useLiveQuery } from 'dexie-react-hooks'
import { File as FileIcon, FileImage, FileText, FileType as FileTypeIcon } from 'lucide-react'
import { FC, useState } from 'react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -33,6 +33,11 @@ const FilesPage: FC = () => {
const [fileType, setFileType] = useState<string>('document')
const [sortField, setSortField] = useState<SortField>('created_at')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([])
useEffect(() => {
setSelectedFileIds([])
}, [fileType])
const tempFilesSort = (files: FileType[]) => {
return files.sort((a, b) => {
@ -153,6 +158,8 @@ const FilesPage: FC = () => {
})
Logger.log(`Deleted ${blockIdsToDelete.length} blocks and updated relevant topic messages for file ${fileId}.`)
setSelectedFileIds((prev) => prev.filter((id) => id !== fileId))
} catch (error) {
Logger.error(`Error updating topics or deleting blocks for file ${fileId}:`, error)
window.modal.error({ content: t('files.delete.db_error'), centered: true }) // 提示数据库操作失败
@ -170,6 +177,46 @@ const FilesPage: FC = () => {
}
}
const handleBatchDelete = async () => {
const selectedFiles = await Promise.all(selectedFileIds.map((id) => FileManager.getFile(id)))
const validFiles = selectedFiles.filter((file): file is FileType => file !== null && file !== undefined)
const paintings = await store.getState().paintings.paintings
const paintingsFiles = paintings.flatMap((p) => p.files)
const filesInPaintings = validFiles.filter((file) => paintingsFiles.some((p) => p.id === file.id))
if (filesInPaintings.length > 0) {
window.modal.warning({
content: t('files.delete.paintings.warning', { count: filesInPaintings.length }),
centered: true
})
return
}
for (const fileId of selectedFileIds) {
await handleDelete(fileId)
}
setSelectedFileIds([])
}
const handleSelectFile = (fileId: string, checked: boolean) => {
if (checked) {
setSelectedFileIds((prev) => [...prev, fileId])
} else {
setSelectedFileIds((prev) => prev.filter((id) => id !== fileId))
}
}
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedFileIds(sortedFiles.map((file) => file.id))
} else {
setSelectedFileIds([])
}
}
const dataSource = sortedFiles?.map((file) => {
return {
key: file.id,
@ -186,13 +233,20 @@ const FilesPage: FC = () => {
<Button type="text" icon={<EditOutlined />} onClick={() => handleRename(file.id)} />
<Popconfirm
title={t('files.delete.title')}
description={t('files.delete.content')}
description={t('files.delete.content', { count: 1 })}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onConfirm={() => handleDelete(file.id)}
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
{fileType !== 'image' && (
<Checkbox
checked={selectedFileIds.includes(file.id)}
onChange={(e) => handleSelectFile(file.id, e.target.checked)}
style={{ margin: '0 8px' }}
/>
)}
</Flex>
)
}
@ -224,22 +278,59 @@ const FilesPage: FC = () => {
</SideNav>
<MainContent>
<SortContainer>
{['created_at', 'size', 'name'].map((field) => (
<SortButton
key={field}
active={sortField === field}
onClick={() => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field as 'created_at' | 'size' | 'name')
setSortOrder('desc')
}
}}>
{t(`files.${field}`)}
{sortField === field && (sortOrder === 'desc' ? <SortDescendingOutlined /> : <SortAscendingOutlined />)}
</SortButton>
))}
<Flex gap={8} align="center">
{['created_at', 'size', 'name'].map((field) => (
<Button
color="default"
key={field}
variant={sortField === field ? 'filled' : 'text'}
onClick={() => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field as 'created_at' | 'size' | 'name')
setSortOrder('desc')
}
}}>
{t(`files.${field}`)}
{sortField === field &&
(sortOrder === 'desc' ? <SortDescendingOutlined /> : <SortAscendingOutlined />)}
</Button>
))}
</Flex>
{fileType !== 'image' && (
<Dropdown.Button
style={{ width: 'auto' }}
menu={{
items: [
{
key: 'delete',
disabled: selectedFileIds.length === 0,
danger: true,
label: (
<Popconfirm
disabled={selectedFileIds.length === 0}
title={t('files.delete.title')}
description={t('files.delete.content', { count: selectedFileIds.length })}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onConfirm={handleBatchDelete}
icon={<ExclamationCircleOutlined />}>
{t('files.batch_delete')} ({selectedFileIds.length})
</Popconfirm>
)
}
]
}}
trigger={['click']}>
<Checkbox
indeterminate={selectedFileIds.length > 0 && selectedFileIds.length < sortedFiles.length}
checked={selectedFileIds.length === sortedFiles.length}
onChange={(e) => handleSelectAll(e.target.checked)}>
{t('files.batch_operation')}
</Checkbox>
</Dropdown.Button>
)}
</SortContainer>
{dataSource && dataSource?.length > 0 ? (
<FileList id={fileType} list={dataSource} files={sortedFiles} />
@ -268,6 +359,7 @@ const MainContent = styled.div`
const SortContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 16px;
border-bottom: 0.5px solid var(--color-border);
@ -315,25 +407,4 @@ const SideNav = styled.div`
}
`
const SortButton = styled(Button)<{ active?: boolean }>`
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
height: 30px;
border-radius: var(--list-item-border-radius);
border: 0.5px solid ${(props) => (props.active ? 'var(--color-border)' : 'transparent')};
background-color: ${(props) => (props.active ? 'var(--color-background-soft)' : 'transparent')};
color: ${(props) => (props.active ? 'var(--color-text)' : 'var(--color-text-secondary)')};
&:hover {
background-color: var(--color-background-soft);
color: var(--color-text);
}
.anticon {
font-size: 12px;
}
`
export default FilesPage