diff --git a/src/webparts/requestPortal/shared/attachments/downloadAll.test.ts b/src/webparts/requestPortal/shared/attachments/downloadAll.test.ts new file mode 100644 index 0000000..2842834 --- /dev/null +++ b/src/webparts/requestPortal/shared/attachments/downloadAll.test.ts @@ -0,0 +1,24 @@ +import { forcedDownloadUrl } from './downloadAll'; + +const SITE = 'https://tnsukz.sharepoint.com/sites/requests'; + +describe('forcedDownloadUrl', () => { + it('серверный относительный путь — через download.aspx с абсолютным SourceUrl', () => { + expect(forcedDownloadUrl('/sites/requests/Lists/List4/Attachments/5/скан.pdf', SITE)).toBe( + `${SITE}/_layouts/15/download.aspx?SourceUrl=${encodeURIComponent( + 'https://tnsukz.sharepoint.com/sites/requests/Lists/List4/Attachments/5/скан.pdf' + )}` + ); + }); + + it('абсолютный url остаётся как есть внутри SourceUrl', () => { + const file = 'https://tnsukz.sharepoint.com/sites/requests/Shared Documents/a.docx'; + expect(forcedDownloadUrl(file, SITE)).toBe( + `${SITE}/_layouts/15/download.aspx?SourceUrl=${encodeURIComponent(file)}` + ); + }); + + it('слеш на конце адреса сайта не удваивается', () => { + expect(forcedDownloadUrl('/x.pdf', `${SITE}/`)).toContain(`${SITE}/_layouts/15/download.aspx`); + }); +}); diff --git a/src/webparts/requestPortal/shared/attachments/downloadAll.ts b/src/webparts/requestPortal/shared/attachments/downloadAll.ts new file mode 100644 index 0000000..942e7e0 --- /dev/null +++ b/src/webparts/requestPortal/shared/attachments/downloadAll.ts @@ -0,0 +1,57 @@ +import { DATA_SITE_URL } from '../config'; + +/** Вложение в том виде, в каком его показывает AttachmentList. */ +export interface IDownloadableFile { + fileName: string; + /** Серверный относительный (как у вложений элементов) или абсолютный url. */ + url: string; +} + +// Пауза между загрузками. Если запустить все сразу, браузер часть загрузок +// молча отбрасывает; полсекунды хватает, чтобы каждая успела стартовать. +const DOWNLOAD_INTERVAL_MS = 500; + +function absoluteUrl(url: string, siteUrl: string): string { + if (/^https?:\/\//i.test(url)) return url; + return `${new URL(siteUrl).origin}${url.startsWith('/') ? '' : '/'}${url}`; +} + +/** + * Ссылка, по которой SharePoint ОТДАЁТ файл загрузкой, а не открывает его: + * прямая ссылка на PDF или картинку открылась бы в просмотре браузера. + * download.aspx — стандартная страница SharePoint, отдаёт файл с + * Content-Disposition: attachment. + */ +export function forcedDownloadUrl(url: string, siteUrl: string = DATA_SITE_URL): string { + const site = siteUrl.replace(/\/+$/, ''); + return `${site}/_layouts/15/download.aspx?SourceUrl=${encodeURIComponent(absoluteUrl(url, siteUrl))}`; +} + +function startDownload(file: IDownloadableFile): void { + const link = document.createElement('a'); + link.href = forcedDownloadUrl(file.url); + link.download = file.fileName; + link.rel = 'noopener'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +/** + * Скачать все файлы по очереди — отдельными загрузками, без архива. При первом + * использовании браузер спросит, разрешить ли сайту скачивать несколько файлов: + * это его защита, обойти её нельзя, достаточно один раз разрешить. + * Промис завершается, когда запущена последняя загрузка. + */ +export function downloadAllFiles(files: IDownloadableFile[]): Promise { + const withUrl = files.filter((f) => !!f.url); + return new Promise((resolve) => { + withUrl.forEach((file, i) => { + window.setTimeout(() => { + startDownload(file); + if (i === withUrl.length - 1) resolve(); + }, i * DOWNLOAD_INTERVAL_MS); + }); + if (withUrl.length === 0) resolve(); + }); +} diff --git a/src/webparts/requestPortal/shared/chat/ui/ChatMessage.tsx b/src/webparts/requestPortal/shared/chat/ui/ChatMessage.tsx index e70a169..57deb64 100644 --- a/src/webparts/requestPortal/shared/chat/ui/ChatMessage.tsx +++ b/src/webparts/requestPortal/shared/chat/ui/ChatMessage.tsx @@ -34,7 +34,8 @@ export const ChatMessage: React.FC = ({ message }) => ( {message.text &&
{message.text}
} {message.attachments.length > 0 && (
- + {/* В сообщении чата «Скачать все» ни к чему — там обычно один-два файла. */} +
)} diff --git a/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.module.scss b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.module.scss new file mode 100644 index 0000000..8169d51 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.module.scss @@ -0,0 +1,14 @@ +@import '../../../../../styles/design-tokens'; + +.wrap { + display: flex; + flex-direction: column; + gap: $sp-2; + width: 100%; +} + +// Кнопка над списком, прижата вправо — не спорит с именами файлов. +.toolbar { + display: flex; + justify-content: flex-end; +} diff --git a/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.tsx b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.tsx new file mode 100644 index 0000000..aded4b5 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { AttachmentList as KitAttachmentList, Button, Icon } from '@tnsu/ui-kit'; +import { downloadAllFiles } from '../../attachments/downloadAll'; +import styles from './AttachmentList.module.scss'; + +interface Props { + items: Array<{ url: string; fileName: string }>; + /** Если задан — рядом с файлом кнопка удаления (режим правки), как в ui-kit. */ + onRemove?: (fileName: string) => void; + /** Кнопка «Скачать все». По умолчанию есть; false — не показывать (напр. в чате). */ + downloadAll?: boolean; +} + +// Меньше двух файлов — «скачать все» ничем не отличается от клика по ссылке. +const MIN_FILES_FOR_DOWNLOAD_ALL = 2; + +const TEXT = { + downloadAll: (n: number): string => `Скачать все (${n})`, + downloading: 'Скачиваю…', + hint: 'Файлы скачаются по одному. Если браузер спросит про несколько файлов — разрешите.', +}; + +/** + * Список вложений из ui-kit + кнопка «Скачать все». Та же обёртка для всех + * форм и просмотров (экспорт из shared/ui подменён на неё), поэтому кнопка + * появляется везде без правок в самих формах. + */ +export const AttachmentList: React.FC = ({ items, onRemove, downloadAll = true }) => { + const [downloading, setDownloading] = React.useState(false); + const showDownloadAll = downloadAll && items.length >= MIN_FILES_FOR_DOWNLOAD_ALL; + + const handleDownloadAll = (): void => { + setDownloading(true); + downloadAllFiles(items) + .then(() => setDownloading(false)) + .catch(() => setDownloading(false)); + }; + + return ( +
+ {showDownloadAll && ( +
+ +
+ )} + +
+ ); +}; diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index 84b3f9e..35fa453 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -19,7 +19,6 @@ export { CollapseSection, FormActions, FileDropzone, - AttachmentList, FormModeProvider, useFormReadOnly, Icon, @@ -34,6 +33,8 @@ export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit'; // FormField — обёртка над ui-kit с подсветкой правок согласующего (журнал в // FieldChangesProvider); без журнала ведёт себя как FormField из ui-kit. export { FormField } from './FormField/FormField'; +// AttachmentList — обёртка над ui-kit с кнопкой «Скачать все» (без архива, файлы по очереди). +export { AttachmentList } from './AttachmentList/AttachmentList'; export { FieldChangesProvider } from './FieldChanges/FieldChangesContext'; export { FieldChangesBanner } from './FieldChanges/FieldChangesBanner'; export { MultiChoiceField } from './MultiChoiceField/MultiChoiceField';