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
|
"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 { useCallback, useState, type ChangeEvent } from 'react';
|
||||||
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
|
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
|
||||||
import { resolveApiBaseUrl } from '../../lib/apiBase';
|
import { resolveApiBaseUrl } from '../../lib/apiBase';
|
||||||
|
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
||||||
|
|
||||||
export type FileAttachmentValue = {
|
export type FileAttachmentValue = {
|
||||||
objectKey?: string;
|
objectKey?: string;
|
||||||
@@ -83,11 +84,12 @@ export function EquipmentAttachmentInput() {
|
|||||||
<Typography component="label" variant="body2">
|
<Typography component="label" variant="body2">
|
||||||
Вложение (файл в MinIO)
|
Вложение (файл в MinIO)
|
||||||
</Typography>
|
</Typography>
|
||||||
{att?.downloadUrl ? (
|
{att?.objectKey ? (
|
||||||
<Typography variant="body2">
|
<Typography variant="body2" component="div">
|
||||||
<a href={att.downloadUrl} target="_blank" rel="noreferrer">
|
<EquipmentAttachmentLink
|
||||||
{att.originalFileName || 'Скачать файл'}
|
equipmentId={String(record.id)}
|
||||||
</a>
|
fileName={att.originalFileName || 'Скачать файл'}
|
||||||
|
/>
|
||||||
{att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null}
|
{att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null}
|
||||||
</Typography>
|
</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,
|
TextInput,
|
||||||
TopToolbar,
|
TopToolbar,
|
||||||
} from 'react-admin';
|
} from 'react-admin';
|
||||||
|
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
||||||
import { equipmentStatusChoices } from './shared';
|
import { equipmentStatusChoices } from './shared';
|
||||||
|
|
||||||
const equipmentFilters = [
|
const equipmentFilters = [
|
||||||
@@ -37,7 +38,19 @@ export function EquipmentList() {
|
|||||||
<SelectField source="status" choices={equipmentStatusChoices} />
|
<SelectField source="status" choices={equipmentStatusChoices} />
|
||||||
<FunctionField
|
<FunctionField
|
||||||
label="Файл"
|
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>
|
</Datagrid>
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
|
import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
|
||||||
|
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
||||||
import { equipmentStatusChoices } from './shared';
|
import { equipmentStatusChoices } from './shared';
|
||||||
|
|
||||||
export function EquipmentShow() {
|
export function EquipmentShow() {
|
||||||
@@ -13,11 +14,15 @@ export function EquipmentShow() {
|
|||||||
<SelectField source="status" choices={equipmentStatusChoices} />
|
<SelectField source="status" choices={equipmentStatusChoices} />
|
||||||
<FunctionField
|
<FunctionField
|
||||||
label="Вложение"
|
label="Вложение"
|
||||||
render={(record: { attachment?: { downloadUrl?: string | null; originalFileName?: string | null } | null }) =>
|
render={(record: {
|
||||||
record?.attachment?.downloadUrl ? (
|
id: string;
|
||||||
<a href={record.attachment.downloadUrl} target="_blank" rel="noreferrer">
|
attachment?: { objectKey?: string | null; originalFileName?: string | null } | null;
|
||||||
{record.attachment.originalFileName || 'Скачать'}
|
}) =>
|
||||||
</a>
|
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;
|
type uuid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpoint downloadEquipmentAttachment {
|
||||||
|
label "GET /equipment/{id}/attachment/download";
|
||||||
|
description "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)";
|
||||||
|
attribute id {
|
||||||
|
type uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
api API.EquipmentStatusChange {
|
api API.EquipmentStatusChange {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
|
StreamableFile,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@@ -51,6 +52,19 @@ export class EquipmentController {
|
|||||||
return this.equipmentService.removeAttachment(id);
|
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')
|
@Roles('viewer', 'editor', 'admin')
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { setListHeaders } from '../../common/http';
|
|||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import type { StoredAttachmentMeta } from '../../storage/storage.service';
|
import type { StoredAttachmentMeta } from '../../storage/storage.service';
|
||||||
import { StorageService } from '../../storage/storage.service';
|
import { StorageService } from '../../storage/storage.service';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||||
import { isEquipmentAttachmentStored } from './attachment.types';
|
import { isEquipmentAttachmentStored } from './attachment.types';
|
||||||
@@ -153,6 +154,40 @@ export class EquipmentService {
|
|||||||
return this.toRecord(updated);
|
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) {
|
async removeAttachment(id: string) {
|
||||||
if (!this.storage.isConfigured()) {
|
if (!this.storage.isConfigured()) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } fro
|
|||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
import { HttpException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { HttpException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
|
|
||||||
export type StoredAttachmentMeta = {
|
export type StoredAttachmentMeta = {
|
||||||
objectKey: string;
|
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> {
|
async getDownloadUrl(objectKey: string): Promise<string> {
|
||||||
this.assertConfigured();
|
this.assertConfigured();
|
||||||
if (this.publicBaseUrl) {
|
if (this.publicBaseUrl) {
|
||||||
|
|||||||
Reference in New Issue
Block a user