Browse Source

✨ feat(files): 添加文件管理功能

- 新增MinioUploader组件,支持文件上传到MinIO存储
- 实现文件管理页面,支持文件预览、下载、编辑和删除
- 优化File实体,添加fullUrl属性用于获取完整文件URL

✨ feat(file-entity): 增强文件实体功能

- 添加fullUrl计算属性,自动生成完整的文件访问URL
- 引入环境变量配置MINIO连接参数,提高部署灵活性
- 导出FileType类型,便于前后端类型统一
yourname 4 months ago
parent
commit
bef6f4bd97

+ 260 - 0
src/client/admin-shadcn/components/MinioUploader.tsx

@@ -0,0 +1,260 @@
+import React, { useState, useCallback } from 'react';
+import { Upload, Progress, message, Tag, Space, Typography, Button } from 'antd';
+import { UploadOutlined, CloseOutlined, CheckCircleOutlined, SyncOutlined } from '@ant-design/icons';
+import { App } from 'antd';
+import type { UploadFile, UploadProps } from 'antd';
+import type { RcFile } from 'rc-upload/lib/interface';
+import type { UploadFileStatus } from 'antd/es/upload/interface';
+import type { UploadRequestOption } from 'rc-upload/lib/interface';
+import { uploadMinIOWithPolicy, MinioProgressEvent } from '@/client/utils/minio';
+
+interface MinioUploaderProps {
+  /** 上传路径 */
+  uploadPath: string;
+  /** 允许的文件类型,如['image/*', '.pdf'] */
+  accept?: string;
+  /** 最大文件大小(MB) */
+  maxSize?: number;
+  /** 是否允许多文件上传 */
+  multiple?: boolean;
+  /** 上传成功回调 */
+  onUploadSuccess?: (fileKey: string, fileUrl: string, file: File) => void;
+  /** 上传失败回调 */
+  onUploadError?: (error: Error, file: File) => void;
+  /** 自定义上传按钮文本 */
+  buttonText?: string;
+  /** 自定义提示文本 */
+  tipText?: string;
+}
+
+
+const MinioUploader: React.FC<MinioUploaderProps> = ({
+  uploadPath = '/',
+  accept,
+  maxSize = 500, // 默认最大500MB
+  multiple = false,
+  onUploadSuccess,
+  onUploadError,
+  buttonText = '点击或拖拽上传文件',
+  tipText = '支持单文件或多文件上传,单个文件大小不超过500MB'
+}) => {
+  const { message: antdMessage } = App.useApp();
+  const [fileList, setFileList] = useState<UploadFile[]>([]);
+  const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
+
+  // 处理上传进度
+  const handleProgress = useCallback((uid: string, event: MinioProgressEvent) => {
+    setFileList(prev => 
+      prev.map(item => {
+        if (item.uid === uid) {
+          return {
+            ...item,
+            status: event.stage === 'error' ? ('error' as UploadFileStatus) : ('uploading' as UploadFileStatus),
+            percent: event.progress,
+            error: event.stage === 'error' ? event.message : undefined
+          };
+        }
+        return item;
+      })
+    );
+  }, []);
+
+  // 处理上传成功
+  const handleComplete = useCallback((uid: string, result: { fileKey: string; fileUrl: string }, file: File) => {
+    setFileList(prev => 
+      prev.map(item => {
+        if (item.uid === uid) {
+          return {
+            ...item,
+            status: 'success' as UploadFileStatus,
+            percent: 100,
+            response: { fileKey: result.fileKey },
+            url: result.fileUrl,
+          };
+        }
+        return item;
+      })
+    );
+    
+    setUploadingFiles(prev => {
+      const newSet = new Set(prev);
+      newSet.delete(uid);
+      return newSet;
+    });
+    
+    antdMessage.success(`文件 "${file.name}" 上传成功`);
+    onUploadSuccess?.(result.fileKey, result.fileUrl, file);
+  }, [antdMessage, onUploadSuccess]);
+
+  // 处理上传失败
+  const handleError = useCallback((uid: string, error: Error, file: File) => {
+    setFileList(prev => 
+      prev.map(item => {
+        if (item.uid === uid) {
+          return {
+            ...item,
+            status: 'error' as UploadFileStatus,
+            percent: 0,
+            error: error.message || '上传失败'
+          };
+        }
+        return item;
+      })
+    );
+    
+    setUploadingFiles(prev => {
+      const newSet = new Set(prev);
+      newSet.delete(uid);
+      return newSet;
+    });
+    
+    antdMessage.error(`文件 "${file.name}" 上传失败: ${error.message}`);
+    onUploadError?.(error, file);
+  }, [antdMessage, onUploadError]);
+
+  // 自定义上传逻辑
+  const customRequest = async (options: UploadRequestOption) => {
+    const { file, onSuccess, onError } = options;
+    const rcFile = file as RcFile;
+    const uid = rcFile.uid;
+    
+    // 添加到文件列表
+    setFileList(prev => [
+      ...prev.filter(item => item.uid !== uid),
+      {
+        uid,
+        name: rcFile.name,
+        size: rcFile.size,
+        type: rcFile.type,
+        lastModified: rcFile.lastModified,
+        lastModifiedDate: new Date(rcFile.lastModified),
+        status: 'uploading' as UploadFileStatus,
+        percent: 0,
+      }
+    ]);
+    
+    // 添加到上传中集合
+    setUploadingFiles(prev => new Set(prev).add(uid));
+    
+    try {
+      // 调用minio上传方法
+      const result = await uploadMinIOWithPolicy(
+        uploadPath,
+        options.file as unknown as File,
+        rcFile.name,
+        {
+          onProgress: (event) => handleProgress(uid, event),
+          signal: new AbortController().signal
+        }
+      );
+      
+      handleComplete(uid, result, rcFile as unknown as File);
+      onSuccess?.({}, rcFile);
+    } catch (error) {
+      handleError(uid, error instanceof Error ? error : new Error('未知错误'), rcFile as unknown as File);
+      onError?.(error instanceof Error ? error : new Error('未知错误'));
+    }
+  };
+
+  // 处理文件移除
+  const handleRemove = (uid: string) => {
+    setFileList(prev => prev.filter(item => item.uid !== uid));
+  };
+
+  // 验证文件大小
+  const beforeUpload = (file: File) => {
+    const fileSizeMB = file.size / (1024 * 1024);
+    if (fileSizeMB > maxSize!) {
+      message.error(`文件 "${file.name}" 大小超过 ${maxSize}MB 限制`);
+      return false;
+    }
+    return true;
+  };
+
+  // 渲染上传状态
+  const renderUploadStatus = (item: UploadFile) => {
+    switch (item.status) {
+      case 'uploading':
+        return (
+          <Space>
+            <SyncOutlined spin size={12} />
+            <span>{item.percent}%</span>
+          </Space>
+        );
+      case 'done':
+        return (
+          <Space>
+            <CheckCircleOutlined style={{ color: '#52c41a' }} size={12} />
+            <Tag color="success">上传成功</Tag>
+          </Space>
+        );
+      case 'error':
+        return (
+          <Space>
+            <CloseOutlined style={{ color: '#ff4d4f' }} size={12} />
+            <Tag color="error">{item.error || '上传失败'}</Tag>
+          </Space>
+        );
+      default:
+        return null;
+    }
+  };
+
+  return (
+    <div className="minio-uploader">
+      <Upload.Dragger
+        name="files"
+        accept={accept}
+        multiple={multiple}
+        customRequest={customRequest}
+        beforeUpload={beforeUpload}
+        showUploadList={false}
+        disabled={uploadingFiles.size > 0 && !multiple}
+      >
+        <div className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-gray-300 rounded-md transition-all hover:border-primary">
+          <UploadOutlined style={{ fontSize: 24, color: '#1890ff' }} />
+          <Typography.Text className="mt-2">{buttonText}</Typography.Text>
+          <Typography.Text type="secondary" className="mt-1">
+            {tipText}
+          </Typography.Text>
+        </div>
+      </Upload.Dragger>
+
+      {/* 上传进度列表 */}
+      {fileList.length > 0 && (
+        <div className="mt-4 space-y-3">
+          {fileList.map(item => (
+            <div key={item.uid} className="flex items-center p-3 border rounded-md">
+              <div className="flex-1 min-w-0">
+                <div className="flex justify-between items-center mb-1">
+                  <Typography.Text ellipsis className="max-w-xs">
+                    {item.name}
+                  </Typography.Text>
+                  <div className="flex items-center space-x-2">
+                    {renderUploadStatus(item)}
+                    <Button
+                      type="text"
+                      size="small"
+                      icon={<CloseOutlined />}
+                      onClick={() => handleRemove(item.uid)}
+                      disabled={item.status === 'uploading'}
+                    />
+                  </div>
+                </div>
+                {item.status === 'uploading' && (
+                  <Progress 
+                    percent={item.percent}
+                    size="small" 
+                    status={item.percent === 100 ? 'success' : undefined}
+                  />
+                )}
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default MinioUploader;

+ 423 - 0
src/client/admin-shadcn/pages/Files.tsx

@@ -0,0 +1,423 @@
+import React, { useState, useEffect } from 'react';
+import { Table, Button, Space, Input, Modal, Form, Select, DatePicker, Upload, Popconfirm, Image } from 'antd';
+import { App } from 'antd';
+import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, UploadOutlined, DownloadOutlined, EyeOutlined } from '@ant-design/icons';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { fileClient } from '@/client/api';
+import type { InferResponseType, InferRequestType } from 'hono/client';
+import dayjs from 'dayjs';
+import { uploadMinIOWithPolicy } from '@/client/utils/minio';
+
+// 定义类型
+type FileItem = InferResponseType<typeof fileClient.$get, 200>['data'][0];
+type FileListResponse = InferResponseType<typeof fileClient.$get, 200>;
+type UpdateFileRequest = InferRequestType<typeof fileClient[':id']['$put']>['json'];
+
+export const FilesPage: React.FC = () => {
+  const { message } = App.useApp();
+  const [form] = Form.useForm();
+  const [modalVisible, setModalVisible] = useState(false);
+  const [editingKey, setEditingKey] = useState<number | null>(null);
+  const [searchText, setSearchText] = useState('');
+  const [pagination, setPagination] = useState({
+    current: 1,
+    pageSize: 10,
+    total: 0,
+  });
+
+  const queryClient = useQueryClient();
+  
+  
+  // 获取文件列表数据
+  const fetchFiles = async ({ page, pageSize }: { page: number; pageSize: number }): Promise<FileListResponse> => {
+    const response = await fileClient.$get({ query: { page, pageSize, keyword: searchText } });
+    if (!response.ok) throw new Error('Failed to fetch files');
+    return await response.json() as FileListResponse;
+  };
+
+  // 获取文件下载URL
+  const getFileUrl = async (fileId: number) => {
+    try {
+      const response = await fileClient[':id']['url'].$get({ param: { id: fileId } });
+      if (!response.ok) throw new Error('获取文件URL失败');
+      const data = await response.json();
+      return data.url;
+    } catch (error) {
+      message.error('获取文件URL失败');
+      return null;
+    }
+  };
+
+  // 获取文件下载URL
+  const getFileDownloadUrl = async (fileId: number) => {
+    try {
+      const response = await fileClient[':id']['download'].$get({ param: { id: fileId } });
+      if (!response.ok) throw new Error('获取文件下载URL失败');
+      const data = await response.json();
+      return data;
+    } catch (error) {
+      message.error('获取文件下载URL失败');
+      return null;
+    }
+  };
+
+  // 处理文件下载
+  const handleDownload = async (record: FileItem) => {
+    const result = await getFileDownloadUrl(record.id);
+    if (result?.url) {
+      const a = document.createElement('a');
+      a.href = result.url;
+      a.download = result.filename || record.name;
+      document.body.appendChild(a);
+      a.click();
+      document.body.removeChild(a);
+    }
+  };
+
+  // 处理文件预览
+  const handlePreview = async (record: FileItem) => {
+    const url = await getFileUrl(record.id);
+    if (url) {
+      if (record.type.startsWith('image/')) {
+        window.open(url, '_blank');
+      } else if (record.type.startsWith('video/')) {
+        window.open(url, '_blank');
+      } else {
+        message.warning('该文件类型不支持预览');
+      }
+    }
+  };
+
+  // 检查是否为可预览的文件类型
+  const isPreviewable = (fileType: string) => {
+    return fileType.startsWith('image/') || fileType.startsWith('video/');
+  };
+  
+  const { data, isLoading: loading, error: filesError } = useQuery({
+    queryKey: ['files', pagination.current, pagination.pageSize, searchText],
+    queryFn: () => fetchFiles({ page: pagination.current, pageSize: pagination.pageSize }),
+  });
+
+  // 错误处理
+  if (filesError) {
+    message.error(`获取文件列表失败: ${filesError instanceof Error ? filesError.message : '未知错误'}`);
+  }
+  
+  // 从API响应获取分页数据
+  const tablePagination = data?.pagination || pagination;
+  
+  // 搜索
+  const handleSearch = () => {
+    setPagination({ ...pagination, current: 1 });
+  };
+  
+  // 分页变化
+  const handleTableChange = (newPagination: any) => {
+    setPagination(newPagination);
+  };
+  
+  // 显示编辑弹窗
+  const showModal = (record: FileItem) => {
+    setModalVisible(true);
+    setEditingKey(record.id);
+    form.setFieldsValue({
+      name: record.name,
+      description: record.description,
+      type: record.type,
+      size: record.size,
+    });
+  };
+
+  // 关闭弹窗
+  const handleCancel = () => {
+    setModalVisible(false);
+    form.resetFields();
+  };
+  
+  // 更新文件记录
+  const updateFile = useMutation({
+    mutationFn: ({ id, data }: { id: number; data: UpdateFileRequest }) =>
+      fileClient[':id'].$put({ param: { id }, json: data }),
+    onSuccess: () => {
+      message.success('文件记录更新成功');
+      queryClient.invalidateQueries({ queryKey: ['files'] });
+      setModalVisible(false);
+    },
+    onError: (error: Error) => {
+      message.error(`操作失败: ${error instanceof Error ? error.message : '未知错误'}`);
+    }
+  });
+  
+  // 删除文件记录
+  const deleteFile = useMutation({
+    mutationFn: (id: number) => fileClient[':id'].$delete({ param: { id } }),
+    onSuccess: () => {
+      message.success('文件记录删除成功');
+      queryClient.invalidateQueries({ queryKey: ['files'] });
+    },
+    onError: (error: Error) => {
+      message.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
+    }
+  });
+  
+  // 直接上传文件
+  const handleDirectUpload = async () => {
+    const input = document.createElement('input');
+    input.type = 'file';
+    input.multiple = false;
+    
+    input.onchange = async (e) => {
+      const file = (e.target as HTMLInputElement).files?.[0];
+      if (!file) return;
+      
+      try {
+        message.loading('正在上传文件...');
+        await uploadMinIOWithPolicy('/files', file, file.name);
+        message.success('文件上传成功');
+        queryClient.invalidateQueries({ queryKey: ['files'] });
+      } catch (error) {
+        message.error(`上传失败: ${error instanceof Error ? error.message : '未知错误'}`);
+      }
+    };
+    
+    input.click();
+  };
+  
+  // 提交表单(仅用于编辑已上传文件)
+  const handleSubmit = async () => {
+    try {
+      const values = await form.validateFields();
+      
+      const payload = {
+        name: values.name,
+        description: values.description,
+      };
+      
+      if (editingKey) {
+        await updateFile.mutateAsync({ id: editingKey, data: payload });
+      }
+    } catch (error) {
+      message.error('表单验证失败,请检查输入');
+    }
+  };
+  
+  // 表格列定义
+  const columns = [
+    {
+      title: '文件ID',
+      dataIndex: 'id',
+      key: 'id',
+      width: 80,
+      align: 'center' as const,
+    },
+    {
+      title: '文件名称',
+      dataIndex: 'name',
+      key: 'name',
+      width: 300,
+      ellipsis: true,
+      render: (name: string, record: FileItem) => (
+        <div className="flex items-center">
+          <span className="flex-1">{name}</span>
+        </div>
+      ),
+    },
+    {
+      title: '文件类型',
+      dataIndex: 'type',
+      key: 'type',
+      width: 120,
+      render: (type: string) => (
+        <span className="inline-block px-2 py-1 text-xs bg-blue-50 text-blue-700 rounded-full">
+          {type}
+        </span>
+      ),
+    },
+    {
+      title: '文件大小',
+      dataIndex: 'size',
+      key: 'size',
+      width: 120,
+      render: (size: number) => (
+        <span className="text-sm">
+          {size ? `${(size / 1024).toFixed(2)} KB` : '-'}
+        </span>
+      ),
+    },
+    {
+      title: '上传时间',
+      dataIndex: 'uploadTime',
+      key: 'uploadTime',
+      width: 180,
+      render: (time: string) => (
+        <span className="text-sm text-gray-600">
+          {time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-'}
+        </span>
+      ),
+    },
+    {
+      title: '上传用户',
+      dataIndex: 'uploadUser',
+      key: 'uploadUser',
+      width: 120,
+      render: (uploadUser?: { username: string; nickname?: string }) => (
+        <span className="text-sm">
+          {uploadUser ? (uploadUser.nickname || uploadUser.username) : '-'}
+        </span>
+      ),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 200,
+      fixed: 'right' as const,
+      render: (_: any, record: FileItem) => (
+        <Space size="small">
+          <Button
+            type="text"
+            icon={<EyeOutlined />}
+            onClick={() => handlePreview(record)}
+            className="text-green-600 hover:text-green-800 hover:bg-green-50"
+            disabled={!isPreviewable(record.type)}
+            title={isPreviewable(record.type) ? '预览文件' : '该文件类型不支持预览'}
+          />
+          <Button
+            type="text"
+            icon={<DownloadOutlined />}
+            onClick={() => handleDownload(record)}
+            className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
+            title="下载文件"
+          />
+          <Button
+            type="text"
+            icon={<EditOutlined />}
+            onClick={() => showModal(record)}
+            className="text-purple-600 hover:text-purple-800 hover:bg-purple-50"
+            title="编辑文件信息"
+          />
+          <Popconfirm
+            title="确认删除"
+            description={`确定要删除文件"${record.name}"吗?此操作不可恢复。`}
+            onConfirm={() => deleteFile.mutate(record.id)}
+            okText="确认"
+            cancelText="取消"
+            okButtonProps={{ danger: true }}
+          >
+            <Button
+              type="text"
+              danger
+              icon={<DeleteOutlined />}
+              className="hover:bg-red-50"
+              title="删除文件"
+            >
+              删除
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+  
+  return (
+    <div className="p-6">
+      <div className="mb-6 flex justify-between items-center">
+        <h2 className="text-2xl font-bold text-gray-900">文件管理</h2>
+        <Button
+          type="primary"
+          icon={<UploadOutlined />}
+          onClick={handleDirectUpload}
+          className="h-10 flex items-center"
+        >
+          上传文件
+        </Button>
+      </div>
+      
+      <div className="mb-6">
+        <div className="flex items-center gap-4">
+          <Input
+            placeholder="搜索文件名称或类型"
+            prefix={<SearchOutlined />}
+            value={searchText}
+            onChange={(e) => setSearchText(e.target.value)}
+            onPressEnter={handleSearch}
+            className="w-80 h-10"
+            allowClear
+          />
+          <Button
+            type="default"
+            onClick={handleSearch}
+            className="h-10"
+          >
+            搜索
+          </Button>
+        </div>
+      </div>
+      
+      <div className="bg-white rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
+        <Table
+          columns={columns}
+          dataSource={data?.data || []}
+          rowKey="id"
+          loading={loading}
+          pagination={{
+            ...tablePagination,
+            showSizeChanger: true,
+            showQuickJumper: true,
+            showTotal: (total, range) =>
+              `显示 ${range[0]}-${range[1]} 条,共 ${total} 条`,
+          }}
+          onChange={handleTableChange}
+          bordered={false}
+          scroll={{ x: 'max-content' }}
+          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"
+          rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
+        />
+      </div>
+      
+      <Modal
+        title="编辑文件信息"
+        open={modalVisible}
+        onCancel={handleCancel}
+        footer={[
+          <Button key="cancel" onClick={handleCancel}>
+            取消
+          </Button>,
+          <Button
+            key="submit"
+            type="primary"
+            onClick={handleSubmit}
+            loading={updateFile.isPending}
+          >
+            确定
+          </Button>,
+        ]}
+        width={600}
+        centered
+        destroyOnClose
+        maskClosable={false}
+      >
+        <Form form={form} layout="vertical">
+          <Form.Item name="name" label="文件名称">
+            <Input className="h-10" />
+          </Form.Item>
+          
+          <Form.Item name="description" label="文件描述">
+            <Input.TextArea
+              rows={4}
+              placeholder="请输入文件描述"
+              className="rounded-md"
+            />
+          </Form.Item>
+          
+          <Form.Item name="type" label="文件类型" hidden>
+            <Input />
+          </Form.Item>
+          
+          <Form.Item name="size" label="文件大小" hidden>
+            <Input />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};

+ 19 - 2
src/server/modules/files/file.entity.ts

@@ -1,6 +1,8 @@
 import { Entity, PrimaryGeneratedColumn, Column, Index, ManyToOne, JoinColumn } from 'typeorm';
 import { Entity, PrimaryGeneratedColumn, Column, Index, ManyToOne, JoinColumn } from 'typeorm';
 import { z } from '@hono/zod-openapi';
 import { z } from '@hono/zod-openapi';
-import { UserEntity, UserSchema } from '@/server/modules/users/user.entity';
+import { UserEntity } from '@/server/modules/users/user.entity';
+import process from 'node:process';
+import { UserSchema } from '../users/user.schema';
 
 
 @Entity('file')
 @Entity('file')
 export class File {
 export class File {
@@ -19,6 +21,15 @@ export class File {
   @Column({ name: 'path', type: 'varchar', length: 512, comment: '文件存储路径' })
   @Column({ name: 'path', type: 'varchar', length: 512, comment: '文件存储路径' })
   path!: string;
   path!: string;
 
 
+  // 获取完整的文件URL(包含MINIO_HOST前缀)
+  get fullUrl(): string {
+    const protocol = process.env.MINIO_USE_SSL !== 'false' ? 'https' : 'http';
+    const port = process.env.MINIO_PORT ? `:${process.env.MINIO_PORT}` : '';
+    const host = process.env.MINIO_HOST || 'localhost';
+    const bucketName = process.env.MINIO_BUCKET_NAME || 'd8dai';
+    return `${protocol}://${host}${port}/${bucketName}/${this.path}`;
+  }
+
   @Column({ name: 'description', type: 'text', nullable: true, comment: '文件描述' })
   @Column({ name: 'description', type: 'text', nullable: true, comment: '文件描述' })
   description!: string | null;
   description!: string | null;
 
 
@@ -68,6 +79,10 @@ export const FileSchema = z.object({
     description: '文件存储路径',
     description: '文件存储路径',
     example: '/uploads/documents/2023/project-plan.pdf'
     example: '/uploads/documents/2023/project-plan.pdf'
   }),
   }),
+  fullUrl: z.string().url().openapi({
+    description: '完整文件访问URL',
+    example: 'https://minio.example.com/d8dai/uploads/documents/2023/project-plan.pdf'
+  }),
   description: z.string().nullable().openapi({
   description: z.string().nullable().openapi({
     description: '文件描述',
     description: '文件描述',
     example: '2023年度项目计划书'
     example: '2023年度项目计划书'
@@ -155,4 +170,6 @@ export const UpdateFileDto = z.object({
     description: '最后更新时间',
     description: '最后更新时间',
     example: '2023-01-16T14:20:00Z' 
     example: '2023-01-16T14:20:00Z' 
   })
   })
-});
+});
+
+export type FileType = z.infer<typeof FileSchema>