pages_file_library.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. import React, { useState, useEffect } from 'react';
  2. import {
  3. Button, Table, Space, Form, Input, Select,
  4. message, Modal, Card, Typography, Tag, Popconfirm,
  5. Tabs, Image, Upload, Descriptions
  6. } from 'antd';
  7. import {
  8. UploadOutlined,
  9. FileImageOutlined,
  10. FileExcelOutlined,
  11. FileWordOutlined,
  12. FilePdfOutlined,
  13. FileOutlined,
  14. } from '@ant-design/icons';
  15. import { useQuery } from '@tanstack/react-query';
  16. import dayjs from 'dayjs';
  17. import { uploadMinIOWithPolicy, uploadOSSWithPolicy } from '@d8d-appcontainer/api';
  18. import type { MinioUploadPolicy, OSSUploadPolicy } from '@d8d-appcontainer/types';
  19. import { FileAPI } from './api/index.ts';
  20. import type { FileLibrary, FileCategory } from '../share/types.ts';
  21. import { OssType } from '../share/types.ts';
  22. const { Title } = Typography;
  23. // 文件库管理页面
  24. export const FileLibraryPage = () => {
  25. const [loading, setLoading] = useState(false);
  26. const [fileList, setFileList] = useState<FileLibrary[]>([]);
  27. const [categories, setCategories] = useState<FileCategory[]>([]);
  28. const [pagination, setPagination] = useState({
  29. current: 1,
  30. pageSize: 10,
  31. total: 0
  32. });
  33. const [searchParams, setSearchParams] = useState({
  34. fileType: '',
  35. keyword: ''
  36. });
  37. const [uploadModalVisible, setUploadModalVisible] = useState(false);
  38. const [fileDetailModalVisible, setFileDetailModalVisible] = useState(false);
  39. const [currentFile, setCurrentFile] = useState<FileLibrary | null>(null);
  40. const [uploadLoading, setUploadLoading] = useState(false);
  41. const [form] = Form.useForm();
  42. const [categoryForm] = Form.useForm();
  43. const [categoryModalVisible, setCategoryModalVisible] = useState(false);
  44. const [currentCategory, setCurrentCategory] = useState<FileCategory | null>(null);
  45. // 获取文件图标
  46. const getFileIcon = (fileType: string) => {
  47. if (fileType.includes('image')) {
  48. return <FileImageOutlined style={{ fontSize: '24px', color: '#1890ff' }} />;
  49. } else if (fileType.includes('pdf')) {
  50. return <FilePdfOutlined style={{ fontSize: '24px', color: '#ff4d4f' }} />;
  51. } else if (fileType.includes('excel') || fileType.includes('sheet')) {
  52. return <FileExcelOutlined style={{ fontSize: '24px', color: '#52c41a' }} />;
  53. } else if (fileType.includes('word') || fileType.includes('document')) {
  54. return <FileWordOutlined style={{ fontSize: '24px', color: '#2f54eb' }} />;
  55. } else {
  56. return <FileOutlined style={{ fontSize: '24px', color: '#faad14' }} />;
  57. }
  58. };
  59. // 加载文件列表
  60. const fetchFileList = async () => {
  61. setLoading(true);
  62. try {
  63. const response = await FileAPI.getFileList({
  64. page: pagination.current,
  65. pageSize: pagination.pageSize,
  66. ...searchParams
  67. });
  68. if (response && response.data) {
  69. setFileList(response.data.list);
  70. setPagination({
  71. ...pagination,
  72. total: response.data.pagination.total
  73. });
  74. }
  75. } catch (error) {
  76. console.error('获取文件列表失败:', error);
  77. message.error('获取文件列表失败');
  78. } finally {
  79. setLoading(false);
  80. }
  81. };
  82. // 加载文件分类
  83. const fetchCategories = async () => {
  84. try {
  85. const response = await FileAPI.getCategories();
  86. if (response && response.data) {
  87. setCategories(response.data);
  88. }
  89. } catch (error) {
  90. console.error('获取文件分类失败:', error);
  91. message.error('获取文件分类失败');
  92. }
  93. };
  94. // 组件挂载时加载数据
  95. useEffect(() => {
  96. fetchFileList();
  97. fetchCategories();
  98. }, [pagination.current, pagination.pageSize, searchParams]);
  99. // 上传文件
  100. const handleUpload = async (file: File) => {
  101. try {
  102. setUploadLoading(true);
  103. // 1. 获取上传策略
  104. const policyResponse = await FileAPI.getUploadPolicy(file.name);
  105. if (!policyResponse || !policyResponse.data) {
  106. throw new Error('获取上传策略失败');
  107. }
  108. const policy = policyResponse.data;
  109. // 2. 上传文件至 MinIO
  110. const uploadProgress = {
  111. progress: 0,
  112. completed: false,
  113. error: null as Error | null
  114. };
  115. const callbacks = {
  116. onProgress: (event: { progress: number }) => {
  117. uploadProgress.progress = event.progress;
  118. },
  119. onComplete: () => {
  120. uploadProgress.completed = true;
  121. },
  122. onError: (err: Error) => {
  123. uploadProgress.error = err;
  124. }
  125. };
  126. const uploadUrl = window.CONFIG?.OSS_TYPE === OssType.MINIO ? await uploadMinIOWithPolicy(
  127. policy as MinioUploadPolicy,
  128. file,
  129. file.name,
  130. callbacks
  131. ) : await uploadOSSWithPolicy(
  132. policy as OSSUploadPolicy,
  133. file,
  134. file.name,
  135. callbacks
  136. );
  137. if (!uploadUrl || uploadProgress.error) {
  138. throw uploadProgress.error || new Error('上传文件失败');
  139. }
  140. // 3. 保存文件信息到文件库
  141. const fileValues = form.getFieldsValue();
  142. const fileData = {
  143. file_name: file.name,
  144. file_path: uploadUrl,
  145. file_type: file.type,
  146. file_size: file.size,
  147. category_id: fileValues.category_id ? Number(fileValues.category_id) : undefined,
  148. tags: fileValues.tags,
  149. description: fileValues.description
  150. };
  151. const saveResponse = await FileAPI.saveFileInfo(fileData);
  152. if (saveResponse && saveResponse.data) {
  153. message.success('文件上传成功');
  154. setUploadModalVisible(false);
  155. form.resetFields();
  156. fetchFileList();
  157. }
  158. } catch (error) {
  159. console.error('上传文件失败:', error);
  160. message.error('上传文件失败: ' + (error instanceof Error ? error.message : '未知错误'));
  161. } finally {
  162. setUploadLoading(false);
  163. }
  164. };
  165. // 处理文件上传
  166. const uploadProps = {
  167. name: 'file',
  168. multiple: false,
  169. showUploadList: false,
  170. beforeUpload: (file: File) => {
  171. const isLt10M = file.size / 1024 / 1024 < 10;
  172. if (!isLt10M) {
  173. message.error('文件大小不能超过10MB!');
  174. return false;
  175. }
  176. handleUpload(file);
  177. return false;
  178. }
  179. };
  180. // 查看文件详情
  181. const viewFileDetail = async (id: number) => {
  182. try {
  183. const response = await FileAPI.getFileInfo(id);
  184. if (response && response.data) {
  185. setCurrentFile(response.data);
  186. setFileDetailModalVisible(true);
  187. }
  188. } catch (error) {
  189. console.error('获取文件详情失败:', error);
  190. message.error('获取文件详情失败');
  191. }
  192. };
  193. // 下载文件
  194. const downloadFile = async (file: FileLibrary) => {
  195. try {
  196. // 更新下载计数
  197. await FileAPI.updateDownloadCount(file.id);
  198. // 创建一个暂时的a标签用于下载
  199. const link = document.createElement('a');
  200. link.href = file.file_path;
  201. link.target = '_blank';
  202. link.download = file.file_name;
  203. document.body.appendChild(link);
  204. link.click();
  205. document.body.removeChild(link);
  206. message.success('下载已开始');
  207. } catch (error) {
  208. console.error('下载文件失败:', error);
  209. message.error('下载文件失败');
  210. }
  211. };
  212. // 删除文件
  213. const handleDeleteFile = async (id: number) => {
  214. try {
  215. await FileAPI.deleteFile(id);
  216. message.success('文件删除成功');
  217. fetchFileList();
  218. } catch (error) {
  219. console.error('删除文件失败:', error);
  220. message.error('删除文件失败');
  221. }
  222. };
  223. // 处理搜索
  224. const handleSearch = (values: any) => {
  225. setSearchParams(values);
  226. setPagination({
  227. ...pagination,
  228. current: 1
  229. });
  230. };
  231. // 处理表格分页变化
  232. const handleTableChange = (newPagination: any) => {
  233. setPagination({
  234. ...pagination,
  235. current: newPagination.current,
  236. pageSize: newPagination.pageSize
  237. });
  238. };
  239. // 添加或更新分类
  240. const handleCategorySave = async () => {
  241. try {
  242. const values = await categoryForm.validateFields();
  243. if (currentCategory) {
  244. // 更新分类
  245. await FileAPI.updateCategory(currentCategory.id, values);
  246. message.success('分类更新成功');
  247. } else {
  248. // 创建分类
  249. await FileAPI.createCategory(values);
  250. message.success('分类创建成功');
  251. }
  252. setCategoryModalVisible(false);
  253. categoryForm.resetFields();
  254. setCurrentCategory(null);
  255. fetchCategories();
  256. } catch (error) {
  257. console.error('保存分类失败:', error);
  258. message.error('保存分类失败');
  259. }
  260. };
  261. // 编辑分类
  262. const handleEditCategory = (category: FileCategory) => {
  263. setCurrentCategory(category);
  264. categoryForm.setFieldsValue(category);
  265. setCategoryModalVisible(true);
  266. };
  267. // 删除分类
  268. const handleDeleteCategory = async (id: number) => {
  269. try {
  270. await FileAPI.deleteCategory(id);
  271. message.success('分类删除成功');
  272. fetchCategories();
  273. } catch (error) {
  274. console.error('删除分类失败:', error);
  275. message.error('删除分类失败');
  276. }
  277. };
  278. // 文件表格列配置
  279. const columns = [
  280. {
  281. title: '文件名',
  282. key: 'file_name',
  283. render: (text: string, record: FileLibrary) => (
  284. <Space>
  285. {getFileIcon(record.file_type)}
  286. <a onClick={() => viewFileDetail(record.id)}>
  287. {record.original_filename || record.file_name}
  288. </a>
  289. </Space>
  290. )
  291. },
  292. {
  293. title: '文件类型',
  294. dataIndex: 'file_type',
  295. key: 'file_type',
  296. width: 120,
  297. render: (text: string) => text.split('/').pop()
  298. },
  299. {
  300. title: '大小',
  301. dataIndex: 'file_size',
  302. key: 'file_size',
  303. width: 100,
  304. render: (size: number) => {
  305. if (size < 1024) {
  306. return `${size} B`;
  307. } else if (size < 1024 * 1024) {
  308. return `${(size / 1024).toFixed(2)} KB`;
  309. } else {
  310. return `${(size / 1024 / 1024).toFixed(2)} MB`;
  311. }
  312. }
  313. },
  314. {
  315. title: '分类',
  316. dataIndex: 'category_id',
  317. key: 'category_id',
  318. width: 120
  319. },
  320. {
  321. title: '上传者',
  322. dataIndex: 'uploader_name',
  323. key: 'uploader_name',
  324. width: 120
  325. },
  326. {
  327. title: '下载次数',
  328. dataIndex: 'download_count',
  329. key: 'download_count',
  330. width: 120
  331. },
  332. {
  333. title: '上传时间',
  334. dataIndex: 'created_at',
  335. key: 'created_at',
  336. width: 180,
  337. render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss')
  338. },
  339. {
  340. title: '操作',
  341. key: 'action',
  342. width: 180,
  343. render: (_: any, record: FileLibrary) => (
  344. <Space size="middle">
  345. <Button type="link" onClick={() => downloadFile(record)}>
  346. 下载
  347. </Button>
  348. <Popconfirm
  349. title="确定要删除这个文件吗?"
  350. onConfirm={() => handleDeleteFile(record.id)}
  351. okText="确定"
  352. cancelText="取消"
  353. >
  354. <Button type="link" danger>删除</Button>
  355. </Popconfirm>
  356. </Space>
  357. )
  358. }
  359. ];
  360. // 分类表格列配置
  361. const categoryColumns = [
  362. {
  363. title: '分类名称',
  364. dataIndex: 'name',
  365. key: 'name'
  366. },
  367. {
  368. title: '分类编码',
  369. dataIndex: 'code',
  370. key: 'code'
  371. },
  372. {
  373. title: '描述',
  374. dataIndex: 'description',
  375. key: 'description'
  376. },
  377. {
  378. title: '操作',
  379. key: 'action',
  380. render: (_: any, record: FileCategory) => (
  381. <Space size="middle">
  382. <Button type="link" onClick={() => handleEditCategory(record)}>
  383. 编辑
  384. </Button>
  385. <Popconfirm
  386. title="确定要删除这个分类吗?"
  387. onConfirm={() => handleDeleteCategory(record.id)}
  388. okText="确定"
  389. cancelText="取消"
  390. >
  391. <Button type="link" danger>删除</Button>
  392. </Popconfirm>
  393. </Space>
  394. )
  395. }
  396. ];
  397. return (
  398. <div>
  399. <Title level={2}>文件库管理</Title>
  400. <Card>
  401. <Tabs defaultActiveKey="files">
  402. <Tabs.TabPane tab="文件管理" key="files">
  403. {/* 搜索表单 */}
  404. <Form layout="inline" onFinish={handleSearch} style={{ marginBottom: 16 }}>
  405. <Form.Item name="keyword" label="关键词">
  406. <Input placeholder="文件名/描述/标签" allowClear />
  407. </Form.Item>
  408. <Form.Item name="category_id" label="分类">
  409. <Select placeholder="选择分类" allowClear style={{ width: 160 }}>
  410. {categories.map(category => (
  411. <Select.Option key={category.id} value={category.id}>
  412. {category.name}
  413. </Select.Option>
  414. ))}
  415. </Select>
  416. </Form.Item>
  417. <Form.Item name="fileType" label="文件类型">
  418. <Select placeholder="选择文件类型" allowClear style={{ width: 160 }}>
  419. <Select.Option value="image">图片</Select.Option>
  420. <Select.Option value="document">文档</Select.Option>
  421. <Select.Option value="application">应用</Select.Option>
  422. <Select.Option value="audio">音频</Select.Option>
  423. <Select.Option value="video">视频</Select.Option>
  424. </Select>
  425. </Form.Item>
  426. <Form.Item>
  427. <Button type="primary" htmlType="submit">
  428. 搜索
  429. </Button>
  430. </Form.Item>
  431. <Button
  432. type="primary"
  433. onClick={() => setUploadModalVisible(true)}
  434. icon={<UploadOutlined />}
  435. style={{ marginLeft: 16 }}
  436. >
  437. 上传文件
  438. </Button>
  439. </Form>
  440. {/* 文件列表 */}
  441. <Table
  442. columns={columns}
  443. dataSource={fileList}
  444. rowKey="id"
  445. loading={loading}
  446. pagination={{
  447. current: pagination.current,
  448. pageSize: pagination.pageSize,
  449. total: pagination.total,
  450. showSizeChanger: true,
  451. showQuickJumper: true,
  452. showTotal: (total) => `共 ${total} 条记录`
  453. }}
  454. onChange={handleTableChange}
  455. />
  456. </Tabs.TabPane>
  457. <Tabs.TabPane tab="分类管理" key="categories">
  458. <div style={{ marginBottom: 16 }}>
  459. <Button
  460. type="primary"
  461. onClick={() => {
  462. setCurrentCategory(null);
  463. categoryForm.resetFields();
  464. setCategoryModalVisible(true);
  465. }}
  466. >
  467. 添加分类
  468. </Button>
  469. </div>
  470. <Table
  471. columns={categoryColumns}
  472. dataSource={categories}
  473. rowKey="id"
  474. pagination={{ pageSize: 10 }}
  475. />
  476. </Tabs.TabPane>
  477. </Tabs>
  478. </Card>
  479. {/* 上传文件弹窗 */}
  480. <Modal
  481. title="上传文件"
  482. open={uploadModalVisible}
  483. onCancel={() => setUploadModalVisible(false)}
  484. footer={null}
  485. >
  486. <Form form={form} layout="vertical">
  487. <Form.Item
  488. name="file"
  489. label="文件"
  490. rules={[{ required: true, message: '请选择要上传的文件' }]}
  491. >
  492. <Upload {...uploadProps}>
  493. <Button icon={<UploadOutlined />} loading={uploadLoading}>
  494. 选择文件
  495. </Button>
  496. <div style={{ marginTop: 8 }}>
  497. 支持任意类型文件,单个文件不超过10MB
  498. </div>
  499. </Upload>
  500. </Form.Item>
  501. <Form.Item
  502. name="category_id"
  503. label="分类"
  504. >
  505. <Select placeholder="选择分类" allowClear>
  506. {categories.map(category => (
  507. <Select.Option key={category.id} value={category.id}>
  508. {category.name}
  509. </Select.Option>
  510. ))}
  511. </Select>
  512. </Form.Item>
  513. <Form.Item
  514. name="tags"
  515. label="标签"
  516. >
  517. <Input placeholder="多个标签用逗号分隔" />
  518. </Form.Item>
  519. <Form.Item
  520. name="description"
  521. label="描述"
  522. >
  523. <Input.TextArea rows={4} placeholder="文件描述..." />
  524. </Form.Item>
  525. </Form>
  526. </Modal>
  527. {/* 文件详情弹窗 */}
  528. <Modal
  529. title="文件详情"
  530. open={fileDetailModalVisible}
  531. onCancel={() => setFileDetailModalVisible(false)}
  532. footer={[
  533. <Button key="close" onClick={() => setFileDetailModalVisible(false)}>
  534. 关闭
  535. </Button>,
  536. <Button
  537. key="download"
  538. type="primary"
  539. onClick={() => currentFile && downloadFile(currentFile)}
  540. >
  541. 下载
  542. </Button>
  543. ]}
  544. width={700}
  545. >
  546. {currentFile && (
  547. <Descriptions bordered column={2}>
  548. <Descriptions.Item label="系统文件名" span={2}>
  549. {currentFile.file_name}
  550. </Descriptions.Item>
  551. {currentFile.original_filename && (
  552. <Descriptions.Item label="原始文件名" span={2}>
  553. {currentFile.original_filename}
  554. </Descriptions.Item>
  555. )}
  556. <Descriptions.Item label="文件类型">
  557. {currentFile.file_type}
  558. </Descriptions.Item>
  559. <Descriptions.Item label="文件大小">
  560. {currentFile.file_size < 1024 * 1024
  561. ? `${(currentFile.file_size / 1024).toFixed(2)} KB`
  562. : `${(currentFile.file_size / 1024 / 1024).toFixed(2)} MB`}
  563. </Descriptions.Item>
  564. <Descriptions.Item label="上传者">
  565. {currentFile.uploader_name}
  566. </Descriptions.Item>
  567. <Descriptions.Item label="上传时间">
  568. {dayjs(currentFile.created_at).format('YYYY-MM-DD HH:mm:ss')}
  569. </Descriptions.Item>
  570. <Descriptions.Item label="分类">
  571. {currentFile.category_id}
  572. </Descriptions.Item>
  573. <Descriptions.Item label="下载次数">
  574. {currentFile.download_count}
  575. </Descriptions.Item>
  576. <Descriptions.Item label="标签" span={2}>
  577. {currentFile.tags?.split(',').map(tag => (
  578. <Tag key={tag}>{tag}</Tag>
  579. ))}
  580. </Descriptions.Item>
  581. <Descriptions.Item label="描述" span={2}>
  582. {currentFile.description}
  583. </Descriptions.Item>
  584. {currentFile.file_type.startsWith('image/') && (
  585. <Descriptions.Item label="预览" span={2}>
  586. <Image src={currentFile.file_path} style={{ maxWidth: '100%' }} />
  587. </Descriptions.Item>
  588. )}
  589. </Descriptions>
  590. )}
  591. </Modal>
  592. {/* 分类管理弹窗 */}
  593. <Modal
  594. title={currentCategory ? "编辑分类" : "添加分类"}
  595. open={categoryModalVisible}
  596. onOk={handleCategorySave}
  597. onCancel={() => {
  598. setCategoryModalVisible(false);
  599. categoryForm.resetFields();
  600. setCurrentCategory(null);
  601. }}
  602. >
  603. <Form form={categoryForm} layout="vertical">
  604. <Form.Item
  605. name="name"
  606. label="分类名称"
  607. rules={[{ required: true, message: '请输入分类名称' }]}
  608. >
  609. <Input placeholder="请输入分类名称" />
  610. </Form.Item>
  611. <Form.Item
  612. name="code"
  613. label="分类编码"
  614. rules={[{ required: true, message: '请输入分类编码' }]}
  615. >
  616. <Input placeholder="请输入分类编码" />
  617. </Form.Item>
  618. <Form.Item
  619. name="description"
  620. label="分类描述"
  621. >
  622. <Input.TextArea rows={4} placeholder="分类描述..." />
  623. </Form.Item>
  624. </Form>
  625. </Modal>
  626. </div>
  627. );
  628. };