Files
gongxue-base/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx

220 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};