Add download functionality for equipment attachments

- Introduced a new endpoint `GET /equipment/{id}/attachment/download` in the API for downloading equipment attachment files.
- Implemented the `downloadEquipmentAttachmentFile` function in the client to handle file downloads via the API, ensuring proper token management and blob handling.
- Updated the EquipmentAttachmentInput, EquipmentList, and EquipmentShow components to utilize the new download link, enhancing user experience by allowing direct downloads without exposing the MinIO URL.
- Added a new EquipmentAttachmentLink component to encapsulate the download link logic and improve code reusability.
This commit is contained in:
Первов Артем
2026-04-21 01:26:00 +03:00
parent eb36c04a4b
commit 4584a0d581
10 changed files with 184 additions and 11 deletions

View File

@@ -3,6 +3,7 @@ import { useNotify, useRecordContext, useRefresh } from 'react-admin';
import { useCallback, useState, type ChangeEvent } from 'react';
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
import { resolveApiBaseUrl } from '../../lib/apiBase';
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
export type FileAttachmentValue = {
objectKey?: string;
@@ -83,11 +84,12 @@ export function EquipmentAttachmentInput() {
<Typography component="label" variant="body2">
Вложение (файл в MinIO)
</Typography>
{att?.downloadUrl ? (
<Typography variant="body2">
<a href={att.downloadUrl} target="_blank" rel="noreferrer">
{att.originalFileName || 'Скачать файл'}
</a>
{att?.objectKey ? (
<Typography variant="body2" component="div">
<EquipmentAttachmentLink
equipmentId={String(record.id)}
fileName={att.originalFileName || 'Скачать файл'}
/>
{att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null}
</Typography>
) : (

View File

@@ -0,0 +1,31 @@
import { Link } from '@mui/material';
import { useNotify } from 'react-admin';
import { downloadEquipmentAttachmentFile } from './attachmentDownload';
type Props = {
equipmentId: string;
/** Текст ссылки и имя при сохранении в браузере */
fileName: string;
};
export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) {
const notify = useNotify();
const label = fileName.trim() || 'Скачать';
return (
<Link
component="button"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void downloadEquipmentAttachmentFile(equipmentId, label).catch(() =>
notify('Не удалось скачать файл', { type: 'warning' }),
);
}}
sx={{ cursor: 'pointer', textAlign: 'left', verticalAlign: 'inherit' }}
>
{label}
</Link>
);
}

View File

@@ -11,6 +11,7 @@ import {
TextInput,
TopToolbar,
} from 'react-admin';
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
import { equipmentStatusChoices } from './shared';
const equipmentFilters = [
@@ -37,7 +38,19 @@ export function EquipmentList() {
<SelectField source="status" choices={equipmentStatusChoices} />
<FunctionField
label="Файл"
render={(record: { attachment?: { objectKey?: string } | null }) => (record?.attachment?.objectKey ? '✓' : '—')}
render={(record: {
id: string;
attachment?: { objectKey?: string; originalFileName?: string | null } | null;
}) =>
record?.attachment?.objectKey ? (
<EquipmentAttachmentLink
equipmentId={record.id}
fileName={record.attachment.originalFileName ?? 'файл'}
/>
) : (
'—'
)
}
/>
</Datagrid>
</List>

View File

@@ -1,4 +1,5 @@
import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
import { equipmentStatusChoices } from './shared';
export function EquipmentShow() {
@@ -13,11 +14,15 @@ export function EquipmentShow() {
<SelectField source="status" choices={equipmentStatusChoices} />
<FunctionField
label="Вложение"
render={(record: { attachment?: { downloadUrl?: string | null; originalFileName?: string | null } | null }) =>
record?.attachment?.downloadUrl ? (
<a href={record.attachment.downloadUrl} target="_blank" rel="noreferrer">
{record.attachment.originalFileName || 'Скачать'}
</a>
render={(record: {
id: string;
attachment?: { objectKey?: string | null; originalFileName?: string | null } | null;
}) =>
record?.attachment?.objectKey ? (
<EquipmentAttachmentLink
equipmentId={record.id}
fileName={record.attachment.originalFileName ?? 'файл'}
/>
) : (
'—'
)

View File

@@ -0,0 +1,26 @@
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
import { resolveApiBaseUrl } from '../../lib/apiBase';
/** Скачивание через API (JWT + Content-Disposition), без открытия URL MinIO в браузере. */
export async function downloadEquipmentAttachmentFile(equipmentId: string, suggestedFileName: string): Promise<void> {
await ensureFreshToken();
const token = getAccessToken();
const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/attachment/download`;
const response = await fetch(url, {
method: 'GET',
headers: token ? { Authorization: `Bearer ${token}`, Accept: '*/*' } : { Accept: '*/*' },
});
if (!response.ok) {
throw new Error(`Download failed: ${response.status}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = suggestedFileName || 'download';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
}