由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - Roles 多组勾选、Schedules 月视图新建、useDirtyGuard 稳定引用 - JinshujuMatchModal success:false、Attendance store 订阅、EditableCell Enter、DynamicChart 空数据、渲染期 ref - AttachmentsTab 附件预览不再 window.open(防存储型 XSS)+ 请求乱序防护 - 考勤 late 计入出勤率与主状态 Reviewed-by: OCR (open-codereview.ai)
209 lines
7.4 KiB
TypeScript
209 lines
7.4 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, 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<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
|
|
showUploadList={false}
|
|
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);
|
|
}
|
|
}}
|
|
>
|
|
<Button icon={<UploadOutlined />} loading={uploading}>
|
|
上传附件
|
|
</Button>
|
|
</Upload>
|
|
) : 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>
|
|
);
|
|
};
|