feat: «Скачать все» для вложений заявки
Над списком вложений (от двух файлов) — кнопка «Скачать все»: файлы скачиваются по очереди, без архива, через download.aspx SharePoint, чтобы PDF и картинки не открывались в просмотре. Обёртка над AttachmentList из ui-kit подменена в shared/ui, поэтому кнопка появилась во всех формах и карточках без правок в них; в чате выключена. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -34,7 +34,8 @@ export const ChatMessage: React.FC<IChatMessageProps> = ({ message }) => (
|
|||||||
{message.text && <div className={styles.text}>{message.text}</div>}
|
{message.text && <div className={styles.text}>{message.text}</div>}
|
||||||
{message.attachments.length > 0 && (
|
{message.attachments.length > 0 && (
|
||||||
<div className={styles.attachments}>
|
<div className={styles.attachments}>
|
||||||
<AttachmentList items={message.attachments} />
|
{/* В сообщении чата «Скачать все» ни к чему — там обычно один-два файла. */}
|
||||||
|
<AttachmentList items={message.attachments} downloadAll={false} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Props> = ({ items, onRemove, downloadAll = true }) => {
|
||||||
|
const [downloading, setDownloading] = React.useState<boolean>(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 (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
{showDownloadAll && (
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleDownloadAll} disabled={downloading} title={TEXT.hint}>
|
||||||
|
<Icon name="download" size="sm" />
|
||||||
|
{downloading ? TEXT.downloading : TEXT.downloadAll(items.length)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<KitAttachmentList items={items} onRemove={onRemove} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,7 +19,6 @@ export {
|
|||||||
CollapseSection,
|
CollapseSection,
|
||||||
FormActions,
|
FormActions,
|
||||||
FileDropzone,
|
FileDropzone,
|
||||||
AttachmentList,
|
|
||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
useFormReadOnly,
|
useFormReadOnly,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -34,6 +33,8 @@ export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit';
|
|||||||
// FormField — обёртка над ui-kit с подсветкой правок согласующего (журнал в
|
// FormField — обёртка над ui-kit с подсветкой правок согласующего (журнал в
|
||||||
// FieldChangesProvider); без журнала ведёт себя как FormField из ui-kit.
|
// FieldChangesProvider); без журнала ведёт себя как FormField из ui-kit.
|
||||||
export { FormField } from './FormField/FormField';
|
export { FormField } from './FormField/FormField';
|
||||||
|
// AttachmentList — обёртка над ui-kit с кнопкой «Скачать все» (без архива, файлы по очереди).
|
||||||
|
export { AttachmentList } from './AttachmentList/AttachmentList';
|
||||||
export { FieldChangesProvider } from './FieldChanges/FieldChangesContext';
|
export { FieldChangesProvider } from './FieldChanges/FieldChangesContext';
|
||||||
export { FieldChangesBanner } from './FieldChanges/FieldChangesBanner';
|
export { FieldChangesBanner } from './FieldChanges/FieldChangesBanner';
|
||||||
export { MultiChoiceField } from './MultiChoiceField/MultiChoiceField';
|
export { MultiChoiceField } from './MultiChoiceField/MultiChoiceField';
|
||||||
|
|||||||
Reference in New Issue
Block a user