340 lines
8.3 KiB
TypeScript
340 lines
8.3 KiB
TypeScript
// app/components/FileTree/FileTree.tsx
|
|
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { Tree, Input, Button, Dropdown, Modal, Form, message } from 'antd'
|
|
import type { DataNode, TreeProps } from 'antd/es/tree'
|
|
import { Key } from 'react'
|
|
import {
|
|
FileOutlined,
|
|
FolderOutlined,
|
|
PlusOutlined,
|
|
FolderOpenOutlined,
|
|
LoadingOutlined,
|
|
} from '@ant-design/icons'
|
|
import { useRouter } from 'next/navigation'
|
|
import { FileService } from '@/app/lib/services/file'
|
|
|
|
interface FileNode {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
children?: FileNode[]
|
|
parentKey?: string
|
|
}
|
|
|
|
interface FileModalProps {
|
|
type: 'new-doc' | 'new-folder' | 'rename'
|
|
title: string
|
|
open: boolean
|
|
initialValue?: string
|
|
onOk: (value: string) => void
|
|
onCancel: () => void
|
|
loading?: boolean
|
|
}
|
|
|
|
// 文件操作模态框组件
|
|
const FileModal: React.FC<FileModalProps> = ({
|
|
type,
|
|
title,
|
|
open,
|
|
initialValue = '',
|
|
onOk,
|
|
onCancel,
|
|
loading,
|
|
}) => {
|
|
const [form] = Form.useForm()
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
form.setFieldsValue({ name: initialValue })
|
|
}
|
|
}, [open, initialValue, form])
|
|
|
|
const handleOk = async () => {
|
|
try {
|
|
const values = await form.validateFields()
|
|
onOk(values.name)
|
|
} catch (error) {
|
|
// 表单验证错误
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
title={title}
|
|
open={open}
|
|
onOk={handleOk}
|
|
onCancel={onCancel}
|
|
confirmLoading={loading}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item
|
|
name="name"
|
|
label="名称"
|
|
rules={[
|
|
{ required: true, message: '请输入名称' },
|
|
{
|
|
pattern: /^[^\\/:*?"<>|]+$/,
|
|
message: '名称不能包含特殊字符 \\ / : * ? " < > |'
|
|
}
|
|
]}
|
|
>
|
|
<Input
|
|
autoFocus
|
|
placeholder={type === 'new-folder' ? '新建文件夹' : '新建文档'}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
export default function FileTree() {
|
|
const router = useRouter()
|
|
const [treeData, setTreeData] = useState<DataNode[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [searchValue, setSearchValue] = useState('')
|
|
const [selectedKeys, setSelectedKeys] = useState<Key[]>([])
|
|
const [expandedKeys, setExpandedKeys] = useState<Key[]>([])
|
|
|
|
// 模态框状态
|
|
const [modal, setModal] = useState<{
|
|
type: 'new-doc' | 'new-folder' | 'rename'
|
|
open: boolean
|
|
title: string
|
|
parentId?: string
|
|
targetId?: string
|
|
initialValue?: string
|
|
}>({
|
|
type: 'new-doc',
|
|
open: false,
|
|
title: '',
|
|
})
|
|
|
|
// 加载文件树数据
|
|
const loadFileTree = async () => {
|
|
try {
|
|
setLoading(true)
|
|
const files = await FileService.getFileTree()
|
|
const formattedData = formatFileTree(files)
|
|
setTreeData(formattedData)
|
|
} catch (error) {
|
|
message.error('加载文件失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadFileTree()
|
|
}, [])
|
|
|
|
// 格式化文件树数据
|
|
const formatFileTree = (files: FileNode[]): DataNode[] => {
|
|
return files.map(file => ({
|
|
key: file.id,
|
|
title: file.name,
|
|
isLeaf: file.type === 'file',
|
|
children: file.children ? formatFileTree(file.children) : undefined
|
|
}))
|
|
}
|
|
|
|
// 处理新建文档
|
|
const handleNewDocument = (parentId: string) => {
|
|
setModal({
|
|
type: 'new-doc',
|
|
open: true,
|
|
title: '新建文档',
|
|
parentId,
|
|
})
|
|
}
|
|
|
|
// 处理新建文件夹
|
|
const handleNewFolder = (parentId: string) => {
|
|
setModal({
|
|
type: 'new-folder',
|
|
open: true,
|
|
title: '新建文件夹',
|
|
parentId,
|
|
})
|
|
}
|
|
|
|
// 处理重命名
|
|
const handleRename = (targetId: string, initialValue: string) => {
|
|
setModal({
|
|
type: 'rename',
|
|
open: true,
|
|
title: '重命名',
|
|
targetId,
|
|
initialValue,
|
|
})
|
|
}
|
|
|
|
// 处理删除
|
|
const handleDelete = async (id: string) => {
|
|
Modal.confirm({
|
|
title: '确认删除',
|
|
content: '删除后无法恢复,是否继续?',
|
|
okText: '确认',
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
try {
|
|
await FileService.deleteFile(id)
|
|
message.success('删除成功')
|
|
loadFileTree()
|
|
} catch (error) {
|
|
message.error('删除失败'+error)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// 处理模态框确认
|
|
const handleModalOk = async (name: string) => {
|
|
try {
|
|
setLoading(true)
|
|
if (modal.type === 'rename' && modal.targetId) {
|
|
await FileService.updateFile({
|
|
id: modal.targetId,
|
|
name,
|
|
})
|
|
message.success('重命名成功')
|
|
} else if (modal.parentId) {
|
|
await FileService.createFile({
|
|
name,
|
|
type: modal.type === 'new-folder' ? 'folder' : 'file',
|
|
parentId: modal.parentId,
|
|
})
|
|
message.success(modal.type === 'new-folder' ? '文件夹创建成功' : '文档创建成功')
|
|
}
|
|
loadFileTree()
|
|
setModal({ ...modal, open: false })
|
|
} catch (error) {
|
|
message.error('操作失败' + error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// 处理模态框取消
|
|
const handleModalCancel = () => {
|
|
setModal({ ...modal, open: false })
|
|
}
|
|
|
|
// 获取右键菜单
|
|
const getContextMenu = (node: DataNode) => ({
|
|
items: [
|
|
{
|
|
key: 'new-doc',
|
|
label: '新建文档',
|
|
icon: <FileOutlined />,
|
|
onClick: () => handleNewDocument(node.key as string),
|
|
},
|
|
{
|
|
key: 'new-folder',
|
|
label: '新建文件夹',
|
|
icon: <FolderOutlined />,
|
|
onClick: () => handleNewFolder(node.key as string),
|
|
},
|
|
{
|
|
type: 'divider',
|
|
},
|
|
{
|
|
key: 'rename',
|
|
label: '重命名',
|
|
onClick: () => handleRename(node.key as string, node.title as string),
|
|
},
|
|
{
|
|
key: 'delete',
|
|
label: '删除',
|
|
danger: true,
|
|
onClick: () => handleDelete(node.key as string),
|
|
},
|
|
],
|
|
})
|
|
|
|
// 处理树节点选择
|
|
const onSelect: TreeProps['onSelect'] = (selectedKeys, info) => {
|
|
setSelectedKeys(selectedKeys as string[])
|
|
const key = selectedKeys[0]
|
|
if (info.node.isLeaf) {
|
|
router.push(`/document/${key}`)
|
|
}
|
|
}
|
|
|
|
// 处理树节点展开/收起
|
|
const onExpand = (expandedKeys: Key[]) => {
|
|
setExpandedKeys(expandedKeys)
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
<div className="p-2 flex items-center gap-2 border-b">
|
|
<Input
|
|
placeholder="搜索文件..."
|
|
value={searchValue}
|
|
onChange={(e) => setSearchValue(e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Dropdown
|
|
menu={{
|
|
items: [
|
|
{
|
|
key: 'new-doc',
|
|
label: '新建文档',
|
|
icon: <FileOutlined />,
|
|
onClick: () => handleNewDocument('root'),
|
|
},
|
|
{
|
|
key: 'new-folder',
|
|
label: '新建文件夹',
|
|
icon: <FolderOutlined />,
|
|
onClick: () => handleNewFolder('root'),
|
|
},
|
|
],
|
|
}}
|
|
>
|
|
<Button type="text" icon={<PlusOutlined />} />
|
|
</Dropdown>
|
|
</div>
|
|
<div className="flex-1 overflow-auto p-2">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<LoadingOutlined className="text-2xl" />
|
|
</div>
|
|
) : (
|
|
<Tree
|
|
treeData={treeData}
|
|
onSelect={onSelect}
|
|
onExpand={onExpand}
|
|
expandedKeys={expandedKeys}
|
|
selectedKeys={selectedKeys}
|
|
showIcon
|
|
icon={(node) =>
|
|
node.isLeaf ? <FileOutlined /> :
|
|
node.expanded ? <FolderOpenOutlined /> : <FolderOutlined />
|
|
}
|
|
titleRender={(nodeData) => (
|
|
<Dropdown
|
|
menu={getContextMenu(nodeData)}
|
|
trigger={['contextMenu']}
|
|
>
|
|
<span className="w-full inline-block">{nodeData.title}</span>
|
|
</Dropdown>
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
<FileModal
|
|
type={modal.type}
|
|
title={modal.title}
|
|
open={modal.open}
|
|
initialValue={modal.initialValue}
|
|
onOk={handleModalOk}
|
|
onCancel={handleModalCancel}
|
|
loading={loading}
|
|
/>
|
|
</div>
|
|
)
|
|
} |