Files.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. import React, { useState, useEffect } from 'react';
  2. import { Table, Button, Space, Input, Modal, Form, Select, DatePicker, Upload, Popconfirm, Image } from 'antd';
  3. import { App } from 'antd';
  4. import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, UploadOutlined, DownloadOutlined, EyeOutlined } from '@ant-design/icons';
  5. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  6. import { fileClient } from '@/client/api';
  7. import type { InferResponseType, InferRequestType } from 'hono/client';
  8. import dayjs from 'dayjs';
  9. import { uploadMinIOWithPolicy } from '@/client/utils/minio';
  10. // 定义类型
  11. type FileItem = InferResponseType<typeof fileClient.$get, 200>['data'][0];
  12. type FileListResponse = InferResponseType<typeof fileClient.$get, 200>;
  13. type UpdateFileRequest = InferRequestType<typeof fileClient[':id']['$put']>['json'];
  14. export const FilesPage: React.FC = () => {
  15. const { message } = App.useApp();
  16. const [form] = Form.useForm();
  17. const [modalVisible, setModalVisible] = useState(false);
  18. const [editingKey, setEditingKey] = useState<number | null>(null);
  19. const [searchText, setSearchText] = useState('');
  20. const [pagination, setPagination] = useState({
  21. current: 1,
  22. pageSize: 10,
  23. total: 0,
  24. });
  25. const queryClient = useQueryClient();
  26. // 获取文件列表数据
  27. const fetchFiles = async ({ page, pageSize }: { page: number; pageSize: number }): Promise<FileListResponse> => {
  28. const response = await fileClient.$get({ query: { page, pageSize, keyword: searchText } });
  29. if (!response.ok) throw new Error('Failed to fetch files');
  30. return await response.json() as FileListResponse;
  31. };
  32. // 获取文件下载URL
  33. const getFileUrl = async (fileId: number) => {
  34. try {
  35. const response = await fileClient[':id']['url'].$get({ param: { id: fileId } });
  36. if (!response.ok) throw new Error('获取文件URL失败');
  37. const data = await response.json();
  38. return data.url;
  39. } catch (error) {
  40. message.error('获取文件URL失败');
  41. return null;
  42. }
  43. };
  44. // 获取文件下载URL
  45. const getFileDownloadUrl = async (fileId: number) => {
  46. try {
  47. const response = await fileClient[':id']['download'].$get({ param: { id: fileId } });
  48. if (!response.ok) throw new Error('获取文件下载URL失败');
  49. const data = await response.json();
  50. return data;
  51. } catch (error) {
  52. message.error('获取文件下载URL失败');
  53. return null;
  54. }
  55. };
  56. // 处理文件下载
  57. const handleDownload = async (record: FileItem) => {
  58. const result = await getFileDownloadUrl(record.id);
  59. if (result?.url) {
  60. const a = document.createElement('a');
  61. a.href = result.url;
  62. a.download = result.filename || record.name;
  63. document.body.appendChild(a);
  64. a.click();
  65. document.body.removeChild(a);
  66. }
  67. };
  68. // 处理文件预览
  69. const handlePreview = async (record: FileItem) => {
  70. const url = await getFileUrl(record.id);
  71. if (url) {
  72. if (record.type.startsWith('image/')) {
  73. window.open(url, '_blank');
  74. } else if (record.type.startsWith('video/')) {
  75. window.open(url, '_blank');
  76. } else {
  77. message.warning('该文件类型不支持预览');
  78. }
  79. }
  80. };
  81. // 检查是否为可预览的文件类型
  82. const isPreviewable = (fileType: string) => {
  83. return fileType.startsWith('image/') || fileType.startsWith('video/');
  84. };
  85. const { data, isLoading: loading, error: filesError } = useQuery({
  86. queryKey: ['files', pagination.current, pagination.pageSize, searchText],
  87. queryFn: () => fetchFiles({ page: pagination.current, pageSize: pagination.pageSize }),
  88. });
  89. // 错误处理
  90. if (filesError) {
  91. message.error(`获取文件列表失败: ${filesError instanceof Error ? filesError.message : '未知错误'}`);
  92. }
  93. // 从API响应获取分页数据
  94. const tablePagination = data?.pagination || pagination;
  95. // 搜索
  96. const handleSearch = () => {
  97. setPagination({ ...pagination, current: 1 });
  98. };
  99. // 分页变化
  100. const handleTableChange = (newPagination: any) => {
  101. setPagination(newPagination);
  102. };
  103. // 显示编辑弹窗
  104. const showModal = (record: FileItem) => {
  105. setModalVisible(true);
  106. setEditingKey(record.id);
  107. form.setFieldsValue({
  108. name: record.name,
  109. description: record.description,
  110. type: record.type,
  111. size: record.size,
  112. });
  113. };
  114. // 关闭弹窗
  115. const handleCancel = () => {
  116. setModalVisible(false);
  117. form.resetFields();
  118. };
  119. // 更新文件记录
  120. const updateFile = useMutation({
  121. mutationFn: ({ id, data }: { id: number; data: UpdateFileRequest }) =>
  122. fileClient[':id'].$put({ param: { id }, json: data }),
  123. onSuccess: () => {
  124. message.success('文件记录更新成功');
  125. queryClient.invalidateQueries({ queryKey: ['files'] });
  126. setModalVisible(false);
  127. },
  128. onError: (error: Error) => {
  129. message.error(`操作失败: ${error instanceof Error ? error.message : '未知错误'}`);
  130. }
  131. });
  132. // 删除文件记录
  133. const deleteFile = useMutation({
  134. mutationFn: (id: number) => fileClient[':id'].$delete({ param: { id } }),
  135. onSuccess: () => {
  136. message.success('文件记录删除成功');
  137. queryClient.invalidateQueries({ queryKey: ['files'] });
  138. },
  139. onError: (error: Error) => {
  140. message.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
  141. }
  142. });
  143. // 直接上传文件
  144. const handleDirectUpload = async () => {
  145. const input = document.createElement('input');
  146. input.type = 'file';
  147. input.multiple = false;
  148. input.onchange = async (e) => {
  149. const file = (e.target as HTMLInputElement).files?.[0];
  150. if (!file) return;
  151. try {
  152. message.loading('正在上传文件...');
  153. await uploadMinIOWithPolicy('/files', file, file.name);
  154. message.success('文件上传成功');
  155. queryClient.invalidateQueries({ queryKey: ['files'] });
  156. } catch (error) {
  157. message.error(`上传失败: ${error instanceof Error ? error.message : '未知错误'}`);
  158. }
  159. };
  160. input.click();
  161. };
  162. // 提交表单(仅用于编辑已上传文件)
  163. const handleSubmit = async () => {
  164. try {
  165. const values = await form.validateFields();
  166. const payload = {
  167. name: values.name,
  168. description: values.description,
  169. };
  170. if (editingKey) {
  171. await updateFile.mutateAsync({ id: editingKey, data: payload });
  172. }
  173. } catch (error) {
  174. message.error('表单验证失败,请检查输入');
  175. }
  176. };
  177. // 表格列定义
  178. const columns = [
  179. {
  180. title: '文件ID',
  181. dataIndex: 'id',
  182. key: 'id',
  183. width: 80,
  184. align: 'center' as const,
  185. },
  186. {
  187. title: '文件名称',
  188. dataIndex: 'name',
  189. key: 'name',
  190. width: 300,
  191. ellipsis: true,
  192. render: (name: string, record: FileItem) => (
  193. <div className="flex items-center">
  194. <span className="flex-1">{name}</span>
  195. </div>
  196. ),
  197. },
  198. {
  199. title: '文件类型',
  200. dataIndex: 'type',
  201. key: 'type',
  202. width: 120,
  203. render: (type: string) => (
  204. <span className="inline-block px-2 py-1 text-xs bg-blue-50 text-blue-700 rounded-full">
  205. {type}
  206. </span>
  207. ),
  208. },
  209. {
  210. title: '文件大小',
  211. dataIndex: 'size',
  212. key: 'size',
  213. width: 120,
  214. render: (size: number) => (
  215. <span className="text-sm">
  216. {size ? `${(size / 1024).toFixed(2)} KB` : '-'}
  217. </span>
  218. ),
  219. },
  220. {
  221. title: '上传时间',
  222. dataIndex: 'uploadTime',
  223. key: 'uploadTime',
  224. width: 180,
  225. render: (time: string) => (
  226. <span className="text-sm text-gray-600">
  227. {time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-'}
  228. </span>
  229. ),
  230. },
  231. {
  232. title: '上传用户',
  233. dataIndex: 'uploadUser',
  234. key: 'uploadUser',
  235. width: 120,
  236. render: (uploadUser?: { username: string; nickname?: string }) => (
  237. <span className="text-sm">
  238. {uploadUser ? (uploadUser.nickname || uploadUser.username) : '-'}
  239. </span>
  240. ),
  241. },
  242. {
  243. title: '操作',
  244. key: 'action',
  245. width: 200,
  246. fixed: 'right' as const,
  247. render: (_: any, record: FileItem) => (
  248. <Space size="small">
  249. <Button
  250. type="text"
  251. icon={<EyeOutlined />}
  252. onClick={() => handlePreview(record)}
  253. className="text-green-600 hover:text-green-800 hover:bg-green-50"
  254. disabled={!isPreviewable(record.type)}
  255. title={isPreviewable(record.type) ? '预览文件' : '该文件类型不支持预览'}
  256. />
  257. <Button
  258. type="text"
  259. icon={<DownloadOutlined />}
  260. onClick={() => handleDownload(record)}
  261. className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
  262. title="下载文件"
  263. />
  264. <Button
  265. type="text"
  266. icon={<EditOutlined />}
  267. onClick={() => showModal(record)}
  268. className="text-purple-600 hover:text-purple-800 hover:bg-purple-50"
  269. title="编辑文件信息"
  270. />
  271. <Popconfirm
  272. title="确认删除"
  273. description={`确定要删除文件"${record.name}"吗?此操作不可恢复。`}
  274. onConfirm={() => deleteFile.mutate(record.id)}
  275. okText="确认"
  276. cancelText="取消"
  277. okButtonProps={{ danger: true }}
  278. >
  279. <Button
  280. type="text"
  281. danger
  282. icon={<DeleteOutlined />}
  283. className="hover:bg-red-50"
  284. title="删除文件"
  285. >
  286. 删除
  287. </Button>
  288. </Popconfirm>
  289. </Space>
  290. ),
  291. },
  292. ];
  293. return (
  294. <div className="p-6">
  295. <div className="mb-6 flex justify-between items-center">
  296. <h2 className="text-2xl font-bold text-gray-900">文件管理</h2>
  297. <Button
  298. type="primary"
  299. icon={<UploadOutlined />}
  300. onClick={handleDirectUpload}
  301. className="h-10 flex items-center"
  302. >
  303. 上传文件
  304. </Button>
  305. </div>
  306. <div className="mb-6">
  307. <div className="flex items-center gap-4">
  308. <Input
  309. placeholder="搜索文件名称或类型"
  310. prefix={<SearchOutlined />}
  311. value={searchText}
  312. onChange={(e) => setSearchText(e.target.value)}
  313. onPressEnter={handleSearch}
  314. className="w-80 h-10"
  315. allowClear
  316. />
  317. <Button
  318. type="default"
  319. onClick={handleSearch}
  320. className="h-10"
  321. >
  322. 搜索
  323. </Button>
  324. </div>
  325. </div>
  326. <div className="bg-white rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
  327. <Table
  328. columns={columns}
  329. dataSource={data?.data || []}
  330. rowKey="id"
  331. loading={loading}
  332. pagination={{
  333. ...tablePagination,
  334. showSizeChanger: true,
  335. showQuickJumper: true,
  336. showTotal: (total, range) =>
  337. `显示 ${range[0]}-${range[1]} 条,共 ${total} 条`,
  338. }}
  339. onChange={handleTableChange}
  340. bordered={false}
  341. scroll={{ x: 'max-content' }}
  342. className="[&_.ant-table]:!rounded-lg [&_.ant-table-thead>tr>th]:!bg-gray-50 [&_.ant-table-thead>tr>th]:!font-semibold [&_.ant-table-thead>tr>th]:!text-gray-700 [&_.ant-table-thead>tr>th]:!border-b-2 [&_.ant-table-thead>tr>th]:!border-gray-200"
  343. rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
  344. />
  345. </div>
  346. <Modal
  347. title="编辑文件信息"
  348. open={modalVisible}
  349. onCancel={handleCancel}
  350. footer={[
  351. <Button key="cancel" onClick={handleCancel}>
  352. 取消
  353. </Button>,
  354. <Button
  355. key="submit"
  356. type="primary"
  357. onClick={handleSubmit}
  358. loading={updateFile.isPending}
  359. >
  360. 确定
  361. </Button>,
  362. ]}
  363. width={600}
  364. centered
  365. destroyOnClose
  366. maskClosable={false}
  367. >
  368. <Form form={form} layout="vertical">
  369. <Form.Item name="name" label="文件名称">
  370. <Input className="h-10" />
  371. </Form.Item>
  372. <Form.Item name="description" label="文件描述">
  373. <Input.TextArea
  374. rows={4}
  375. placeholder="请输入文件描述"
  376. className="rounded-md"
  377. />
  378. </Form.Item>
  379. <Form.Item name="type" label="文件类型" hidden>
  380. <Input />
  381. </Form.Item>
  382. <Form.Item name="size" label="文件大小" hidden>
  383. <Input />
  384. </Form.Item>
  385. </Form>
  386. </Modal>
  387. </div>
  388. );
  389. };