Files.tsx 12 KB

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