220 lines
7.9 KiB
TypeScript
220 lines
7.9 KiB
TypeScript
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 } 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<TabProps & { data: AttachmentRecord[] }> = ({
|
||
data,
|
||
studentId,
|
||
}) => {
|
||
const { modal } = App.useApp();
|
||
const { hasPermission } = usePermission();
|
||
const canPurgeArchive = hasPermission('archive:purge');
|
||
const [uploading, setUploading] = useState(false);
|
||
const [preview, setPreview] = useState<AttachmentPreview | null>(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<Blob>(`/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<AttachmentRecord> = [
|
||
{
|
||
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) => (
|
||
<Space>
|
||
<Button size="small" icon={<EyeOutlined />} onClick={() => openAttachment(record)}>
|
||
查看
|
||
</Button>
|
||
{hasPermission('student:edit') && record.status !== 'archived' ? (
|
||
<Popconfirm title="确定归档该附件?" onConfirm={() => handleDelete(record.id)}>
|
||
<Button size="small" danger icon={<InboxOutlined />}>
|
||
归档
|
||
</Button>
|
||
</Popconfirm>
|
||
) : null}
|
||
{record.status === 'archived' && canPurgeArchive ? (
|
||
<Button size="small" danger type="link" onClick={() => handlePurge(record)}>
|
||
删除
|
||
</Button>
|
||
) : null}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
{hasPermission('student:edit') ? (
|
||
<Upload.Dragger
|
||
showUploadList={false}
|
||
multiple={false}
|
||
beforeUpload={(file) => {
|
||
if (file.size > 2 * 1024 * 1024) {
|
||
message.error('文件大小不能超过 2MB');
|
||
return Upload.LIST_IGNORE;
|
||
}
|
||
return true;
|
||
}}
|
||
customRequest={async (options) => {
|
||
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);
|
||
}
|
||
}}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<p className="ant-upload-drag-icon">
|
||
<InboxOutlined />
|
||
</p>
|
||
<p className="ant-upload-text">{uploading ? '上传中…' : '点击或拖拽文件到此区域上传'}</p>
|
||
<p className="ant-upload-hint">支持图片 / PDF / Word / Excel 等格式,单个文件不超过 2MB</p>
|
||
</Upload.Dragger>
|
||
) : null}
|
||
<Table<AttachmentRecord> 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 }}
|
||
/>
|
||
<Modal
|
||
title={preview?.name}
|
||
open={!!preview}
|
||
footer={null}
|
||
onCancel={closePreview}
|
||
width={preview?.kind === 'pdf' ? 900 : undefined}
|
||
destroyOnHidden
|
||
>
|
||
{preview?.kind === 'image' ? (
|
||
<img src={preview.url} alt={preview.name} style={{ width: '100%' }} />
|
||
) : preview?.kind === 'pdf' ? (
|
||
<iframe
|
||
src={preview.url}
|
||
title={preview.name}
|
||
sandbox=""
|
||
style={{ width: '100%', height: '70vh', border: 'none' }}
|
||
/>
|
||
) : null}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|