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:
@@ -788,6 +788,20 @@
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "downloadEquipmentAttachment",
|
||||
"label": "GET /equipment/{id}/attachment/download",
|
||||
"method": "GET",
|
||||
"path": "/equipment/{id}/attachment/download",
|
||||
"description": "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)",
|
||||
"attributes": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
31
client/src/resources/equipment/EquipmentAttachmentLink.tsx
Normal file
31
client/src/resources/equipment/EquipmentAttachmentLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? 'файл'}
|
||||
/>
|
||||
) : (
|
||||
'—'
|
||||
)
|
||||
|
||||
26
client/src/resources/equipment/attachmentDownload.ts
Normal file
26
client/src/resources/equipment/attachmentDownload.ts
Normal 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);
|
||||
}
|
||||
@@ -361,6 +361,14 @@ api API.Equipment {
|
||||
type uuid;
|
||||
}
|
||||
}
|
||||
|
||||
endpoint downloadEquipmentAttachment {
|
||||
label "GET /equipment/{id}/attachment/download";
|
||||
description "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)";
|
||||
attribute id {
|
||||
type uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
api API.EquipmentStatusChange {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@@ -51,6 +52,19 @@ export class EquipmentController {
|
||||
return this.equipmentService.removeAttachment(id);
|
||||
}
|
||||
|
||||
@Roles('viewer', 'editor', 'admin')
|
||||
@Get(':id/attachment/download')
|
||||
async downloadAttachment(@Param('id') id: string): Promise<StreamableFile> {
|
||||
const { stream, contentType, fileName, contentLength } =
|
||||
await this.equipmentService.getAttachmentDownloadStream(id);
|
||||
const encoded = encodeURIComponent(fileName);
|
||||
return new StreamableFile(stream, {
|
||||
type: contentType,
|
||||
disposition: `attachment; filename*=UTF-8''${encoded}`,
|
||||
length: contentLength,
|
||||
});
|
||||
}
|
||||
|
||||
@Roles('viewer', 'editor', 'admin')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { setListHeaders } from '../../common/http';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { StoredAttachmentMeta } from '../../storage/storage.service';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||
import { isEquipmentAttachmentStored } from './attachment.types';
|
||||
@@ -153,6 +154,40 @@ export class EquipmentService {
|
||||
return this.toRecord(updated);
|
||||
}
|
||||
|
||||
async getAttachmentDownloadStream(id: string): Promise<{
|
||||
stream: Readable;
|
||||
contentType: string;
|
||||
fileName: string;
|
||||
contentLength?: number;
|
||||
}> {
|
||||
if (!this.storage.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Object storage is not configured (set S3_* environment variables).',
|
||||
);
|
||||
}
|
||||
|
||||
const item = await this.prisma.equipment.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Equipment ${id} not found`);
|
||||
}
|
||||
if (!isEquipmentAttachmentStored(item.attachment)) {
|
||||
throw new NotFoundException(`Вложение для оборудования ${id} не найдено`);
|
||||
}
|
||||
|
||||
const raw = item.attachment;
|
||||
const fileName = decodeUtf8FilenameFromMultipart(raw.originalFileName) || 'file';
|
||||
const { stream, contentType, contentLength } = await this.storage.getObjectStream(raw.objectKey);
|
||||
const len =
|
||||
typeof contentLength === 'bigint' ? Number(contentLength) : contentLength;
|
||||
|
||||
return {
|
||||
stream,
|
||||
contentType: raw.contentType || contentType,
|
||||
fileName,
|
||||
contentLength: len,
|
||||
};
|
||||
}
|
||||
|
||||
async removeAttachment(id: string) {
|
||||
if (!this.storage.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } fro
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { HttpException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
export type StoredAttachmentMeta = {
|
||||
objectKey: string;
|
||||
@@ -77,6 +78,30 @@ export class StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
async getObjectStream(objectKey: string): Promise<{
|
||||
stream: Readable;
|
||||
contentType: string;
|
||||
contentLength?: number;
|
||||
}> {
|
||||
this.assertConfigured();
|
||||
try {
|
||||
const out = await this.client!.send(
|
||||
new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }),
|
||||
);
|
||||
if (!out.Body) {
|
||||
throw new ServiceUnavailableException('S3 GetObject: пустое тело ответа');
|
||||
}
|
||||
const stream = out.Body as Readable;
|
||||
return {
|
||||
stream,
|
||||
contentType: out.ContentType || 'application/octet-stream',
|
||||
contentLength: out.ContentLength,
|
||||
};
|
||||
} catch (err) {
|
||||
throw interpretS3Failure('GetObject', err);
|
||||
}
|
||||
}
|
||||
|
||||
async getDownloadUrl(objectKey: string): Promise<string> {
|
||||
this.assertConfigured();
|
||||
if (this.publicBaseUrl) {
|
||||
|
||||
Reference in New Issue
Block a user