import React, { useRef, useState } from 'react'; import { App, Button, Modal, Popconfirm, Space, Table, Upload } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { EyeOutlined, InboxOutlined, UploadOutlined } from '@ant-design/icons'; import api from '../../api'; import { message } from '../../ui/app-message'; import { useApiMutation } from '../../hooks/useApiMutation'; import { usePermission } from '../../hooks/usePermission'; import { getErrorMessage } from '../../utils/error'; import { ATTACHMENT_CATEGORY_OPTIONS, formatFileSize } from './shared'; import type { AttachmentRecord, TabProps } from './shared'; type AttachmentPreview = { url: string; name: string; kind: 'image' | 'pdf'; }; /** 根据文件扩展名决定安全展示方式:图片/PDF 内联预览,其余一律下载 */ function getAttachmentKind(fileName: string, mimeType?: string): 'image' | 'pdf' | 'download' { const ext = fileName.split('.').pop()?.toLowerCase() ?? ''; if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'].includes(ext)) return 'image'; if (ext === 'pdf' || mimeType === 'application/pdf') return 'pdf'; return 'download'; } export const AttachmentsTab: React.FC = ({ data, studentId, }) => { const { modal } = App.useApp(); const { hasPermission } = usePermission(); const canPurgeArchive = hasPermission('archive:purge'); const [uploading, setUploading] = useState(false); const [preview, setPreview] = useState(null); // 预览请求序号:快速点不同行「查看」时,慢的旧响应回来直接丢弃,避免覆盖新预览 const previewSeqRef = useRef(0); const deleteAttachmentMutation = useApiMutation( async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`), { invalidate: [['archive', studentId]] }, ); const purgeAttachmentMutation = useApiMutation( async (id: number) => api.delete(`/archive/attachments/${id}/permanent`), { invalidate: [['archive', studentId]] }, ); const uploadAttachmentMutation = useApiMutation( async (formData: FormData) => api.post(`/archive/${studentId}/attachments`, formData), { invalidate: [['archive', studentId]] }, ); const closePreview = () => { previewSeqRef.current += 1; // 关闭后仍在途的旧响应也不再落地 if (preview?.url) URL.revokeObjectURL(preview.url); setPreview(null); }; const openAttachment = async (record: AttachmentRecord) => { const seq = ++previewSeqRef.current; try { const blob = await api.get(`/archive/${studentId}/attachments/${record.id}`, { responseType: 'blob', }); if (seq !== previewSeqRef.current) return; // 已有更新的查看请求,丢弃本次慢响应 const kind = getAttachmentKind(record.fileName, record.mimeType); const url = URL.createObjectURL(blob); if (kind === 'download') { // 非内联类型通过 download 属性触发下载,避免以页面同源打开可执行内容 const a = document.createElement('a'); a.href = url; a.download = record.fileName || 'attachment'; document.body.appendChild(a); a.click(); a.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 60_000); } else { if (preview?.url) URL.revokeObjectURL(preview.url); setPreview({ url, name: record.fileName || 'attachment', kind }); } } catch (e: unknown) { message.error(getErrorMessage(e, '查看失败')); } }; const handleDelete = async (attachmentId: number) => { try { await deleteAttachmentMutation.mutateAsync(attachmentId); message.success('已归档'); } catch { // 错误提示由 useApiMutation 统一处理 } }; const handlePurge = (record: AttachmentRecord) => { modal.confirm({ title: `永久删除附件「${record.fileName}」?`, content: '删除后不可恢复,磁盘上的附件文件将被清除。确定继续?', okText: '永久删除', okButtonProps: { danger: true }, cancelText: '取消', onOk: async () => { try { await purgeAttachmentMutation.mutateAsync(record.id); message.success('已永久删除(不可恢复)'); } catch { // 错误提示由 useApiMutation 统一处理 } }, }); }; const columns: ColumnsType = [ { title: '类别', dataIndex: 'category', render: (v: string) => ATTACHMENT_CATEGORY_OPTIONS.find((o) => o.value === v)?.label || v, }, { title: '文件名', dataIndex: 'fileName' }, { title: '大小', dataIndex: 'fileSize', render: formatFileSize }, { title: '操作', render: (_: unknown, record: AttachmentRecord) => ( {hasPermission('student:edit') && record.status !== 'archived' ? ( handleDelete(record.id)}> ) : null} {record.status === 'archived' && canPurgeArchive ? ( ) : null} ), }, ]; return (
{hasPermission('student:edit') ? ( { const formData = new FormData(); formData.append( 'file', options.file instanceof File ? options.file : new File([options.file as Blob], 'attachment'), ); setUploading(true); try { await uploadAttachmentMutation.mutateAsync(formData); message.success('上传成功'); options.onSuccess?.({}); } catch (e) { options.onError?.(e instanceof Error ? e : new Error('上传失败')); } finally { setUploading(false); } }} > ) : null} scroll={{ x: 'max-content' }} columns={columns} dataSource={data} rowKey="id" rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50], }} style={{ marginTop: 16 }} /> {preview?.kind === 'image' ? ( {preview.name} ) : preview?.kind === 'pdf' ? (