Files.tsx 10 KB

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