FileSelector.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import React, { useState } from 'react';
  2. import { useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { Button } from '@/client/components/ui/button';
  4. import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
  5. import { Card, CardContent } from '@/client/components/ui/card';
  6. import { Badge } from '@/client/components/ui/badge';
  7. import { toast } from 'sonner';
  8. import { fileClient } from '@/client/api';
  9. import type { FileType } from '@/server/modules/files/file.schema';
  10. import MinioUploader from '@/client/admin-shadcn/components/MinioUploader';
  11. import { Check, Upload } from 'lucide-react';
  12. // 定义重载的 props 类型
  13. interface SingleFileSelectorProps {
  14. visible: boolean;
  15. onCancel: () => void;
  16. onSelect: (file: FileType) => void;
  17. accept?: string;
  18. maxSize?: number;
  19. uploadPath?: string;
  20. uploadButtonText?: string;
  21. multiple?: false;
  22. }
  23. interface MultipleFileSelectorProps {
  24. visible: boolean;
  25. onCancel: () => void;
  26. onSelect: (files: FileType[]) => void;
  27. accept?: string;
  28. maxSize?: number;
  29. uploadPath?: string;
  30. uploadButtonText?: string;
  31. multiple: true;
  32. }
  33. type FileSelectorProps = SingleFileSelectorProps | MultipleFileSelectorProps;
  34. const FileSelector: React.FC<FileSelectorProps> = ({
  35. visible,
  36. onCancel,
  37. onSelect,
  38. accept = 'image/*',
  39. maxSize = 5,
  40. uploadPath = '/uploads',
  41. uploadButtonText = '上传新文件',
  42. multiple = false,
  43. ...props
  44. }) => {
  45. const queryClient = useQueryClient();
  46. const [selectedFiles, setSelectedFiles] = useState<FileType[]>([]);
  47. // 获取文件列表
  48. const { data: filesData, isLoading } = useQuery({
  49. queryKey: ['files-for-selection', accept] as const,
  50. queryFn: async () => {
  51. const response = await fileClient.$get({
  52. query: {
  53. page: 1,
  54. pageSize: 50,
  55. keyword: accept.startsWith('image/') ? 'image' : undefined
  56. }
  57. });
  58. if (response.status !== 200) throw new Error('获取文件列表失败');
  59. return response.json();
  60. },
  61. enabled: visible
  62. });
  63. // 重置选择状态
  64. React.useEffect(() => {
  65. if (!visible) {
  66. setSelectedFiles([]);
  67. }
  68. }, [visible]);
  69. const handleSelectFile = (file: FileType) => {
  70. if (!file) {
  71. console.error('No file provided to handleSelectFile');
  72. return;
  73. }
  74. if (multiple) {
  75. // 多选模式
  76. const newSelection = selectedFiles.some(f => f.id === file.id)
  77. ? selectedFiles.filter(f => f.id !== file.id)
  78. : [...selectedFiles, file];
  79. setSelectedFiles(newSelection);
  80. } else {
  81. // 单选模式
  82. setSelectedFiles([file]);
  83. }
  84. };
  85. const handleConfirm = () => {
  86. if (selectedFiles.length === 0) {
  87. toast.warning('请先选择文件');
  88. return;
  89. }
  90. if (multiple) {
  91. // 多选模式
  92. (onSelect as MultipleFileSelectorProps['onSelect'])(selectedFiles);
  93. } else {
  94. // 单选模式
  95. (onSelect as SingleFileSelectorProps['onSelect'])(selectedFiles[0]);
  96. }
  97. onCancel();
  98. };
  99. const handleUploadSuccess = (fileKey: string, fileUrl: string, file: any) => {
  100. toast.success('文件上传成功!请从列表中选择新上传的文件');
  101. // 刷新文件列表
  102. queryClient.invalidateQueries({ queryKey: ['files-for-selection', accept] });
  103. };
  104. const filteredFiles = Array.isArray(filesData?.data)
  105. ? filesData.data.filter((f: any) => !accept || f?.type?.startsWith(accept.replace('*', '')))
  106. : [];
  107. const isFileSelected = (file: FileType) => {
  108. return selectedFiles.some(f => f.id === file.id);
  109. };
  110. const getSelectionText = () => {
  111. if (multiple) {
  112. return selectedFiles.length > 0 ? `已选择 ${selectedFiles.length} 个文件` : '请选择文件';
  113. }
  114. return selectedFiles.length > 0 ? `已选择: ${selectedFiles[0].name}` : '请选择文件';
  115. };
  116. // 格式化文件大小
  117. const formatFileSize = (bytes: number) => {
  118. if (bytes === 0) return '0 Bytes';
  119. const k = 1024;
  120. const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  121. const i = Math.floor(Math.log(bytes) / Math.log(k));
  122. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  123. };
  124. // 获取文件类型的显示标签
  125. const getFileTypeBadge = (type: string) => {
  126. if (type.startsWith('image/')) {
  127. return { text: '图片', color: 'bg-blue-100 text-blue-800' };
  128. } else if (type.startsWith('video/')) {
  129. return { text: '视频', color: 'bg-red-100 text-red-800' };
  130. } else if (type.startsWith('audio/')) {
  131. return { text: '音频', color: 'bg-purple-100 text-purple-800' };
  132. } else if (type.includes('pdf')) {
  133. return { text: 'PDF', color: 'bg-red-100 text-red-800' };
  134. } else if (type.includes('word')) {
  135. return { text: '文档', color: 'bg-blue-100 text-blue-800' };
  136. } else if (type.includes('excel') || type.includes('sheet')) {
  137. return { text: '表格', color: 'bg-green-100 text-green-800' };
  138. } else {
  139. return { text: '文件', color: 'bg-gray-100 text-gray-800' };
  140. }
  141. };
  142. return (
  143. <Dialog open={visible} onOpenChange={onCancel}>
  144. <DialogContent className="max-w-4xl max-h-[80vh]">
  145. <DialogHeader>
  146. <DialogTitle>选择文件</DialogTitle>
  147. <DialogDescription>
  148. 从已有文件中选择,或上传新文件
  149. </DialogDescription>
  150. </DialogHeader>
  151. <div className="space-y-4">
  152. {/* 上传区域 */}
  153. <Card>
  154. <CardContent className="pt-6">
  155. <MinioUploader
  156. uploadPath={uploadPath}
  157. accept={accept}
  158. maxSize={maxSize}
  159. onUploadSuccess={handleUploadSuccess}
  160. buttonText={uploadButtonText}
  161. />
  162. <p className="text-sm text-gray-500 mt-2">
  163. 上传后请从下方列表中选择文件
  164. </p>
  165. </CardContent>
  166. </Card>
  167. {/* 选择状态显示 */}
  168. <Card>
  169. <CardContent className="pt-6">
  170. <div className="bg-green-50 border border-green-200 rounded-md p-3">
  171. <p className="text-sm text-green-700">{getSelectionText()}</p>
  172. </div>
  173. </CardContent>
  174. </Card>
  175. {/* 文件列表 */}
  176. <div className="space-y-2 max-h-96 overflow-y-auto">
  177. {isLoading ? (
  178. <Card>
  179. <CardContent className="text-center py-8">
  180. <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
  181. <p className="text-gray-500 mt-2">加载中...</p>
  182. </CardContent>
  183. </Card>
  184. ) : filteredFiles.length > 0 ? (
  185. <div className="grid gap-3">
  186. {filteredFiles.map((file) => {
  187. const typeBadge = getFileTypeBadge(file.type);
  188. const isSelected = isFileSelected(file);
  189. return (
  190. <Card
  191. key={file.id}
  192. className={`cursor-pointer transition-all hover:shadow-md ${
  193. isSelected ? 'ring-2 ring-primary' : ''
  194. }`}
  195. onClick={() => handleSelectFile(file)}
  196. >
  197. <CardContent className="p-4">
  198. <div className="flex items-center space-x-4">
  199. {/* 文件预览图 */}
  200. <div className="relative">
  201. {file.type.startsWith('image/') ? (
  202. <img
  203. src={file.fullUrl}
  204. alt={file.name}
  205. className="w-16 h-16 object-cover rounded-md"
  206. />
  207. ) : (
  208. <div className="w-16 h-16 bg-gray-100 rounded-md flex items-center justify-center">
  209. <Upload className="h-8 w-8 text-gray-400" />
  210. </div>
  211. )}
  212. {isSelected && (
  213. <div className="absolute -top-2 -right-2 bg-primary text-white rounded-full p-1">
  214. <Check className="h-3 w-3" />
  215. </div>
  216. )}
  217. </div>
  218. {/* 文件信息 */}
  219. <div className="flex-1 min-w-0">
  220. <h4 className="text-sm font-medium truncate">{file.name}</h4>
  221. <div className="flex items-center space-x-2 mt-1">
  222. <Badge className={`${typeBadge.color} text-xs`}>
  223. {typeBadge.text}
  224. </Badge>
  225. <span className="text-xs text-gray-500">ID: {file.id}</span>
  226. <span className="text-xs text-gray-500">
  227. {formatFileSize(file.size || 0)}
  228. </span>
  229. </div>
  230. </div>
  231. {/* 选择按钮 */}
  232. <Button
  233. type="button"
  234. variant={isSelected ? "default" : "outline"}
  235. size="sm"
  236. onClick={(e) => {
  237. e.stopPropagation();
  238. handleSelectFile(file);
  239. }}
  240. >
  241. {isSelected ? '已选择' : '选择'}
  242. </Button>
  243. </div>
  244. </CardContent>
  245. </Card>
  246. );
  247. })}
  248. </div>
  249. ) : (
  250. <Card>
  251. <CardContent className="text-center py-8">
  252. <Upload className="h-12 w-12 mx-auto text-gray-400 mb-4" />
  253. <p className="text-gray-600">暂无符合条件的文件</p>
  254. <p className="text-sm text-gray-500 mt-2">请先上传文件或调整筛选条件</p>
  255. </CardContent>
  256. </Card>
  257. )}
  258. </div>
  259. </div>
  260. <DialogFooter>
  261. <Button type="button" variant="outline" onClick={onCancel}>
  262. 取消
  263. </Button>
  264. <Button
  265. type="button"
  266. onClick={handleConfirm}
  267. disabled={selectedFiles.length === 0}
  268. >
  269. 确认选择
  270. </Button>
  271. </DialogFooter>
  272. </DialogContent>
  273. </Dialog>
  274. );
  275. };
  276. // 类型守卫函数
  277. function isMultipleSelector(props: FileSelectorProps): props is MultipleFileSelectorProps {
  278. return props.multiple === true;
  279. }
  280. function isSingleSelector(props: FileSelectorProps): props is SingleFileSelectorProps {
  281. return props.multiple === false || props.multiple === undefined;
  282. }
  283. export default FileSelector;