From e8daf0813da4c3480ff27a39483081048747d334 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 24 Aug 2026 14:05:04 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2?= =?UTF-8?q?=20=D1=87=D0=B0=D1=82=D0=B5=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=E2=80=94=20=D0=B1=D0=B8=D0=B1=D0=BB=D0=B8=D0=BE=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D0=B0=20=C2=AB=D0=A4=D0=B0=D0=B9=D0=BB=D1=8B=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У комментария SharePoint нет вложений, только текст. Поэтому файлы уходят в отдельную библиотеку на сайте данных (папка на заявку), а в тексте комментария остаётся пометка с их именами — по ней сообщение и находит свои файлы. Адрес библиотеки резолвится по заголовку на лету, поэтому ссылки в переписке не ломаются при её переносе, а чат без файлов к библиотеке вообще не обращается. --- .../shared/chat/chatFileNames.test.ts | 48 ++++++ .../shared/chat/chatFileNames.ts | 77 ++++++++++ .../shared/chat/chatFiles.service.ts | 139 ++++++++++++++++++ .../shared/chat/chatMessages.test.ts | 83 ++++++++++- .../requestPortal/shared/chat/chatMessages.ts | 84 +++++++++-- .../requestPortal/shared/chat/index.ts | 12 +- .../shared/chat/models/IChatMessage.ts | 11 ++ .../shared/chat/requestChat.service.ts | 22 ++- .../shared/chat/ui/ChatComposer.tsx | 82 ++++++++--- .../shared/chat/ui/ChatMessage.tsx | 11 +- .../shared/chat/ui/RequestChat.module.scss | 41 +++++- .../shared/chat/useRequestChat.ts | 25 +++- .../requestPortal/shared/listTitles.ts | 11 ++ 13 files changed, 597 insertions(+), 49 deletions(-) create mode 100644 src/webparts/requestPortal/shared/chat/chatFileNames.test.ts create mode 100644 src/webparts/requestPortal/shared/chat/chatFileNames.ts create mode 100644 src/webparts/requestPortal/shared/chat/chatFiles.service.ts diff --git a/src/webparts/requestPortal/shared/chat/chatFileNames.test.ts b/src/webparts/requestPortal/shared/chat/chatFileNames.test.ts new file mode 100644 index 0000000..fdcae55 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatFileNames.test.ts @@ -0,0 +1,48 @@ +import { displayFileName, fileStamp, sanitizeFileName, storedFileName } from './chatFileNames'; + +describe('sanitizeFileName', () => { + it('заменяет запрещённые SharePoint символы', () => { + expect(sanitizeFileName('от 24/08 №1: акт*.pdf')).toBe('от 24_08 №1_ акт_.pdf'); + }); + + it('убирает точки в конце — такое имя SharePoint не принимает', () => { + expect(sanitizeFileName('акт...')).toBe('акт'); + }); + + it('пустое имя заменяет заглушкой, а не пустой строкой', () => { + expect(sanitizeFileName(' ')).toBe('файл'); + }); + + it('длинное имя обрезает, сохраняя расширение', () => { + const long = `${'я'.repeat(200)}.pdf`; + const result = sanitizeFileName(long); + + expect(result.length).toBeLessThanOrEqual(120); + expect(result.endsWith('.pdf')).toBe(true); + }); +}); + +describe('storedFileName / displayFileName', () => { + it('складское имя разбирается обратно в исходное', () => { + const stored = storedFileName('20260824-153012', 1, 'акт приёмки.pdf'); + + expect(stored).toBe('20260824-153012-1__акт приёмки.pdf'); + expect(displayFileName(stored)).toBe('акт приёмки.pdf'); + }); + + it('двойное подчёркивание в исходном имени не ломает разбор', () => { + const stored = storedFileName('20260824-153012', 2, 'акт__итог.pdf'); + expect(displayFileName(stored)).toBe('акт__итог.pdf'); + }); + + it('файл, положенный в библиотеку не порталом, показывается как есть', () => { + expect(displayFileName('справка.pdf')).toBe('справка.pdf'); + }); + + it('файлы одного сообщения получают разные имена', () => { + const stamp = fileStamp(new Date(2026, 7, 24, 15, 30, 12)); + + expect(stamp).toBe('20260824-153012'); + expect(storedFileName(stamp, 1, 'скан.jpg')).not.toBe(storedFileName(stamp, 2, 'скан.jpg')); + }); +}); diff --git a/src/webparts/requestPortal/shared/chat/chatFileNames.ts b/src/webparts/requestPortal/shared/chat/chatFileNames.ts new file mode 100644 index 0000000..83eb650 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatFileNames.ts @@ -0,0 +1,77 @@ +/** + * Имена файлов чата. Модуль чистый (без SharePoint) и покрыт тестами: от + * формата имени зависит и то, что файл вообще запишется, и то, каким он + * покажется в переписке. + * + * Имя в библиотеке: «<метка времени>-<номер>__<исходное имя>», например + * «20260824-153012-1__акт приёмки.pdf». Префикс нужен, чтобы два человека + * (или один дважды) могли прикрепить к одной заявке файлы с одинаковым + * названием — без него второй файл перезаписал бы первый. + */ + +/** Разделитель служебного префикса и исходного имени. */ +const SEPARATOR = '__'; + +/** + * Запрещённые SharePoint символы в имени файла (" * : < > ? / \ |), плюс # % ~: + * формально они допустимы в SPO, но ломают прямые ссылки на файл. + */ +const FORBIDDEN_CHARS = /["*:<>?/\\|#%~]/g; + +/** + * Предел длины исходной части имени. Общий предел пути в SharePoint — 400 + * символов, и в него входят адрес сайта, имя библиотеки и папка заявки; + * 120 символов на само имя оставляют запас и при этом не режут нормальные + * названия документов. + */ +const MAX_NAME_LENGTH = 120; + +function splitExtension(name: string): { base: string; extension: string } { + const dot = name.lastIndexOf('.'); + // Точка в начале — это не расширение, а имя вида «.gitignore». + if (dot <= 0) return { base: name, extension: '' }; + return { base: name.slice(0, dot), extension: name.slice(dot) }; +} + +/** + * Привести имя файла к тому, что SharePoint примет: убрать запрещённые символы + * и обрезать слишком длинное имя, СОХРАНИВ расширение (без него файл не + * откроется нужным приложением). + */ +export function sanitizeFileName(name: string): string { + const cleaned = name.replace(FORBIDDEN_CHARS, '_').replace(/\s+/g, ' ').trim(); + // Имя, оканчивающееся точкой, SharePoint тоже не принимает. + const withoutTrailingDots = cleaned.replace(/\.+$/, ''); + if (!withoutTrailingDots) return 'файл'; + + if (withoutTrailingDots.length <= MAX_NAME_LENGTH) return withoutTrailingDots; + + const { base, extension } = splitExtension(withoutTrailingDots); + const room = MAX_NAME_LENGTH - extension.length; + // Расширение длиннее всего лимита — вырожденный случай, режем как есть. + if (room <= 0) return withoutTrailingDots.slice(0, MAX_NAME_LENGTH); + return `${base.slice(0, room)}${extension}`; +} + +function pad2(value: number): string { + return value < 10 ? `0${value}` : `${value}`; +} + +/** Метка времени для префикса: «20260824-153012». */ +export function fileStamp(date: Date): string { + const day = `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`; + const time = `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`; + return `${day}-${time}`; +} + +/** Имя, под которым файл ляжет в библиотеку. index — порядок файла в сообщении. */ +export function storedFileName(stamp: string, index: number, originalName: string): string { + return `${stamp}-${index}${SEPARATOR}${sanitizeFileName(originalName)}`; +} + +/** Исходное имя из складского: то, что видит пользователь в переписке. */ +export function displayFileName(storedName: string): string { + const at = storedName.indexOf(SEPARATOR); + // Разделителя нет — файл положили не порталом; показываем имя как есть. + return at < 0 ? storedName : storedName.slice(at + SEPARATOR.length); +} diff --git a/src/webparts/requestPortal/shared/chat/chatFiles.service.ts b/src/webparts/requestPortal/shared/chat/chatFiles.service.ts new file mode 100644 index 0000000..e7d403a --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatFiles.service.ts @@ -0,0 +1,139 @@ +import '@pnp/sp/webs'; +import '@pnp/sp/lists'; +import '@pnp/sp/folders'; +import '@pnp/sp/files'; +import type { IFolder } from '@pnp/sp/folders'; +import { getSP } from '../pnp/pnpjs'; +import { CHAT_FILES_LIBRARY_TITLE } from '../listTitles'; +import { RequestTypeId } from '../../models/types'; +import { fileStamp, storedFileName } from './chatFileNames'; + +/** + * Файлы, прикреплённые к сообщениям чата, — библиотека «Файлы чата» на сайте + * данных, папка на каждую заявку: «Файлы чата/<тип заявки>-». + * + * Почему не вложения самой заявки (shared/attachments): вложения — это + * документы заявки, они попадают в её карточку и печать. Файл из переписки — + * не документ заявки, а часть разговора; смешивать их значит засорять заявку. + * + * Связь «файл → сообщение» хранится в тексте комментария (см. FILE_MARKER в + * chatMessages.ts), поэтому чтение переписки НЕ обращается к библиотеке вовсе, + * пока в ней действительно нет файлов. + */ + +/** Больше 10 МБ через обычную загрузку слать не стоит — это переписка, а не файлообмен. */ +export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; + +/** Сколько файлов можно приложить к одному сообщению. */ +export const MAX_FILES_PER_MESSAGE = 5; + +// Адрес библиотеки в пределах сессии не меняется, а нужен при каждой загрузке +// переписки с файлами. Ошибку НЕ кешируем: не отвечавший на холодном старте +// запрос должен повториться, а не залипнуть до конца сессии (тот же приём, что +// в shared/approval/siteUser.ts). +let libraryRootPromise: Promise | undefined; + +/** + * Серверный относительный адрес корня библиотеки, например + * «/sites/requests/Файлы чата». + * + * Резолвим по ЗАГОЛОВКУ списка, а не собираем строкой из адреса сайта: url + * библиотеки может не совпадать с её названием (и меняться при переносе), а + * заголовок — то, что реально настраивал администратор. + */ +function chatLibraryRoot(): Promise { + if (!libraryRootPromise) { + libraryRootPromise = getSP() + .web.lists.getByTitle(CHAT_FILES_LIBRARY_TITLE) + .rootFolder.select('ServerRelativeUrl')() + .then((folder) => folder.ServerRelativeUrl as string) + .catch((e) => { + libraryRootPromise = undefined; + throw new Error( + `Библиотека «${CHAT_FILES_LIBRARY_TITLE}» недоступна на сайте данных: ${(e as Error).message}` + ); + }); + } + return libraryRootPromise; +} + +/** Имя папки заявки в библиотеке. Тип заявки в имени — чтобы id разных типов не смешивались. */ +function requestFolderName(formKey: RequestTypeId, itemId: number): string { + return `${formKey}-${itemId}`; +} + +/** Адрес папки заявки. Папки может ещё не существовать — для чтения ссылок это неважно. */ +export async function chatFilesFolderUrl( + formKey: RequestTypeId, + itemId: number +): Promise { + const root = await chatLibraryRoot(); + return `${root}/${requestFolderName(formKey, itemId)}`; +} + +/** + * Папка заявки, гарантированно существующая. + * + * Проверяем существование отдельным запросом, а не создаём «поверх»: + * addSubFolderUsingPath на существующей папке возвращает ошибку, а + * addUsingPath с overwrite=true СНОСИТ содержимое — то есть все ранее + * приложенные к заявке файлы. + */ +async function ensureRequestFolder(formKey: RequestTypeId, itemId: number): Promise { + const root = await chatLibraryRoot(); + const name = requestFolderName(formKey, itemId); + const folder = getSP().web.getFolderByServerRelativePath(`${root}/${name}`); + + const exists = await folder + .select('Exists')() + .then((info) => !!info.Exists) + .catch(() => false); + + if (!exists) { + await getSP().web.getFolderByServerRelativePath(root).addSubFolderUsingPath(name); + } + return folder; +} + +/** + * Загрузить файлы сообщения в папку заявки. Возвращает СКЛАДСКИЕ имена — их + * сервис чата кладёт в текст комментария (см. buildCommentText). + * + * Вызывается ДО создания комментария: имена файлов нужны, чтобы его собрать. + * Если создать комментарий потом не удастся, файлы останутся в библиотеке + * ничьими — это лучше, чем сообщение со ссылками на незагруженные файлы. + */ +export async function uploadChatFiles( + formKey: RequestTypeId, + itemId: number, + files: File[] +): Promise { + if (files.length === 0) return []; + + const folder = await ensureRequestFolder(formKey, itemId); + const stamp = fileStamp(new Date()); + const storedNames: string[] = []; + + // Последовательно, а не Promise.all: параллельная запись в одну папку чаще + // упирается в throttling SharePoint, а файлов здесь единицы. + for (let i = 0; i < files.length; i++) { + const name = storedFileName(stamp, i + 1, files[i].name); + // File — это Blob, поэтому читать его в ArrayBuffer заранее не нужно. + await folder.files.addUsingPath(name, files[i]); + storedNames.push(name); + } + return storedNames; +} + +/** Проверка файлов до отправки. Возвращает текст ошибки или undefined. */ +export function validateChatFiles(files: File[]): string | undefined { + if (files.length > MAX_FILES_PER_MESSAGE) { + return `К одному сообщению можно приложить не больше ${MAX_FILES_PER_MESSAGE} файлов.`; + } + const tooBig = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES)[0]; + if (tooBig) { + const limitMb = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024)); + return `Файл «${tooBig.name}» больше ${limitMb} МБ — приложите его к самой заявке или дайте ссылку.`; + } + return undefined; +} diff --git a/src/webparts/requestPortal/shared/chat/chatMessages.test.ts b/src/webparts/requestPortal/shared/chat/chatMessages.test.ts index 1b954e2..066a07c 100644 --- a/src/webparts/requestPortal/shared/chat/chatMessages.test.ts +++ b/src/webparts/requestPortal/shared/chat/chatMessages.test.ts @@ -1,4 +1,9 @@ -import { ICommentSource, toChatMessages } from './chatMessages'; +import { + ICommentSource, + buildCommentText, + hasAttachments, + toChatMessages +} from './chatMessages'; const ME = 'ivan@tnsu.kz'; @@ -73,3 +78,79 @@ describe('toChatMessages', () => { expect(messages[1].authorName).toBe('Неизвестный автор'); }); }); + +describe('файлы в сообщении', () => { + const FOLDER = '/sites/requests/Файлы чата/hr-vacation-12'; + const FILE = '20260824-153012-1__акт.pdf'; + const SECOND_FILE = '20260824-153012-2__скан.jpg'; + + it('пометка о файлах не показывается в тексте сообщения', () => { + const messages = toChatMessages( + [comment({ text: `Посмотрите акт\n[rp-files]${FILE}` })], + ME, + FOLDER + ); + + expect(messages[0].text).toBe('Посмотрите акт'); + }); + + it('файл получает исходное имя и ссылку в папке заявки', () => { + const messages = toChatMessages( + [comment({ text: `[rp-files]${FILE}|${SECOND_FILE}` })], + ME, + FOLDER + ); + + expect(messages[0].text).toBe(''); + expect(messages[0].attachments).toEqual([ + { fileName: 'акт.pdf', url: `${FOLDER}/${FILE}` }, + { fileName: 'скан.jpg', url: `${FOLDER}/${SECOND_FILE}` } + ]); + }); + + it('сообщение из одних файлов не отбрасывается как пустое', () => { + const messages = toChatMessages([comment({ text: `[rp-files]${FILE}` })], ME, FOLDER); + expect(messages).toHaveLength(1); + }); + + it('пометка переживает перевод строки в формате Windows', () => { + const messages = toChatMessages([comment({ text: `Текст\r\n[rp-files]${FILE}` })], ME, FOLDER); + + expect(messages[0].text).toBe('Текст'); + expect(messages[0].attachments).toHaveLength(1); + }); + + it('обычное сообщение остаётся без вложений', () => { + const messages = toChatMessages([comment({ text: 'Просто текст' })], ME); + expect(messages[0].attachments).toEqual([]); + }); + + it('hasAttachments отличает переписку с файлами от переписки без них', () => { + expect(hasAttachments([comment({ text: 'Просто текст' })])).toBe(false); + expect(hasAttachments([comment({ text: `[rp-files]${FILE}` })])).toBe(true); + }); +}); + +describe('buildCommentText', () => { + it('без файлов отдаёт очищенный текст', () => { + expect(buildCommentText(' Привет ', [])).toBe('Привет'); + }); + + it('с файлами дописывает пометку отдельной строкой', () => { + expect(buildCommentText('Смотрите', ['a__акт.pdf', 'b__скан.jpg'])).toBe( + 'Смотрите\n[rp-files]a__акт.pdf|b__скан.jpg' + ); + }); + + it('без текста остаётся одна пометка — отправка «только файл»', () => { + expect(buildCommentText('', ['a__акт.pdf'])).toBe('[rp-files]a__акт.pdf'); + }); + + it('собранный текст читается обратно без потерь', () => { + const raw = buildCommentText('Смотрите акт', ['20260824-153012-1__акт.pdf']); + const messages = toChatMessages([comment({ text: raw })], ME, '/f'); + + expect(messages[0].text).toBe('Смотрите акт'); + expect(messages[0].attachments[0].fileName).toBe('акт.pdf'); + }); +}); diff --git a/src/webparts/requestPortal/shared/chat/chatMessages.ts b/src/webparts/requestPortal/shared/chat/chatMessages.ts index 91bcc57..d541ebb 100644 --- a/src/webparts/requestPortal/shared/chat/chatMessages.ts +++ b/src/webparts/requestPortal/shared/chat/chatMessages.ts @@ -1,4 +1,5 @@ -import { IChatMessage } from './models/IChatMessage'; +import { IChatAttachment, IChatMessage } from './models/IChatMessage'; +import { displayFileName } from './chatFileNames'; /** * Часть комментария SharePoint, из которой строится сообщение чата. @@ -15,6 +16,53 @@ export interface ICommentSource { author?: { name?: string; email?: string }; } +/** + * Служебная пометка о прикреплённых файлах в КОНЦЕ текста комментария: + * Проверьте, пожалуйста, акт + * [rp-files]20260824-153012-1__акт.pdf|20260824-153012-2__скан.jpg + * + * Почему в тексте, а не отдельным хранилищем: у комментария SharePoint нет + * своих полей — только текст. Класть связь «сообщение → файлы» больше некуда, + * не заводя отдельный список и не превращая каждую загрузку чата в лишний + * запрос к библиотеке. Имена файлов складские (с префиксом), но не полные + * адреса — адрес библиотеки резолвится на лету, поэтому её можно переместить, + * и ссылки в старой переписке не сломаются. + * + * Разделитель имён — «|»: этот символ SharePoint в именах файлов запрещает + * (см. FORBIDDEN_CHARS), значит в имени он встретиться не может. + */ +const FILE_MARKER = '[rp-files]'; + +/** Пометка всегда последняя и всегда в одну строку — отсюда привязка к концу. */ +const FILE_MARKER_PATTERN = /\s*\[rp-files\]([^\r\n]*)$/; + +/** + * Собрать текст комментария из сообщения и складских имён файлов. + * Сообщение может быть пустым — тогда комментарий состоит из одной пометки + * (так выглядит отправка «только файл»). + */ +export function buildCommentText(text: string, storedFileNames: string[]): string { + const trimmed = text.trim(); + if (storedFileNames.length === 0) return trimmed; + + const marker = `${FILE_MARKER}${storedFileNames.join('|')}`; + return trimmed ? `${trimmed}\n${marker}` : marker; +} + +function parseStoredNames(raw: string): string[] { + const found = FILE_MARKER_PATTERN.exec(raw); + if (!found) return []; + return found[1] + .split('|') + .map((name) => name.trim()) + .filter((name) => !!name); +} + +/** Есть ли в переписке файлы — по этому признаку сервис решает, нужен ли адрес библиотеки. */ +export function hasAttachments(comments: ICommentSource[]): boolean { + return comments.some((c) => !!c.text && FILE_MARKER_PATTERN.test(c.text)); +} + /** * Дата комментария приходит строкой ISO. Если часового пояса в ней нет, * new Date() трактует строку как МЕСТНОЕ время, а SharePoint отдаёт UTC — @@ -26,19 +74,32 @@ function parseCommentDate(raw: string): Date { return new Date(hasTimeZone ? raw : `${raw}Z`); } -function toMessage(comment: ICommentSource, currentUserEmail: string): IChatMessage { +function toAttachments(storedNames: string[], folderUrl: string): IChatAttachment[] { + return storedNames.map((storedName) => ({ + fileName: displayFileName(storedName), + url: folderUrl ? `${folderUrl}/${storedName}` : '' + })); +} + +function toMessage( + comment: ICommentSource, + currentUserEmail: string, + folderUrl: string +): IChatMessage { const authorEmail = comment.author?.email || ''; const authorName = comment.author?.name || authorEmail || 'Неизвестный автор'; + const storedNames = parseStoredNames(comment.text); return { id: comment.id, - text: comment.text, + text: comment.text.replace(FILE_MARKER_PATTERN, '').trim(), authorName, authorEmail, createdAt: parseCommentDate(comment.createdDate), isOwn: !!authorEmail && !!currentUserEmail && - authorEmail.toLowerCase() === currentUserEmail.toLowerCase() + authorEmail.toLowerCase() === currentUserEmail.toLowerCase(), + attachments: toAttachments(storedNames, folderUrl) }; } @@ -47,16 +108,21 @@ function toMessage(comment: ICommentSource, currentUserEmail: string): IChatMess * переписке: свежее внизу). * * Порядок ответа API не документирован и мы на него не опираемся — сортируем - * по дате сами. Пустые комментарии отбрасываем: показывать пустой «пузырь» - * нечего, а в списке они появляются, если кто-то отправил комментарий не из - * портала (например, из стандартной формы SharePoint). + * по дате сами. Комментарии без текста и без файлов отбрасываем: показывать + * пустой «пузырь» нечего, а в списке они появляются, если кто-то отправил + * комментарий не из портала (например, из стандартной формы SharePoint). + * + * folderUrl — папка заявки в библиотеке «Файлы чата»; пустая строка допустима, + * когда в переписке файлов нет (тогда адрес библиотеки и не запрашивался). */ export function toChatMessages( comments: ICommentSource[], - currentUserEmail: string + currentUserEmail: string, + folderUrl: string = '' ): IChatMessage[] { return comments .filter((c) => !!c.text && !!c.text.trim()) - .map((c) => toMessage(c, currentUserEmail)) + .map((c) => toMessage(c, currentUserEmail, folderUrl)) + .filter((m) => !!m.text || m.attachments.length > 0) .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); } diff --git a/src/webparts/requestPortal/shared/chat/index.ts b/src/webparts/requestPortal/shared/chat/index.ts index 0a080d5..7e0cef3 100644 --- a/src/webparts/requestPortal/shared/chat/index.ts +++ b/src/webparts/requestPortal/shared/chat/index.ts @@ -3,6 +3,14 @@ export { RequestChat } from './ui/RequestChat'; export { useRequestChat } from './useRequestChat'; export type { IRequestChat } from './useRequestChat'; export { getChatMessages, addChatMessage } from './requestChat.service'; -export { toChatMessages } from './chatMessages'; +export { + uploadChatFiles, + chatFilesFolderUrl, + validateChatFiles, + MAX_FILES_PER_MESSAGE, + MAX_FILE_SIZE_BYTES +} from './chatFiles.service'; +export { toChatMessages, buildCommentText, hasAttachments } from './chatMessages'; export type { ICommentSource } from './chatMessages'; -export type { IChatMessage } from './models/IChatMessage'; +export { sanitizeFileName, storedFileName, displayFileName, fileStamp } from './chatFileNames'; +export type { IChatMessage, IChatAttachment } from './models/IChatMessage'; diff --git a/src/webparts/requestPortal/shared/chat/models/IChatMessage.ts b/src/webparts/requestPortal/shared/chat/models/IChatMessage.ts index b82cc26..778167d 100644 --- a/src/webparts/requestPortal/shared/chat/models/IChatMessage.ts +++ b/src/webparts/requestPortal/shared/chat/models/IChatMessage.ts @@ -1,3 +1,11 @@ +/** Файл, прикреплённый к сообщению чата (лежит в библиотеке «Файлы чата»). */ +export interface IChatAttachment { + /** Имя для показа — без служебного префикса (см. chatFileNames.ts). */ + fileName: string; + /** Ссылка на файл в библиотеке. */ + url: string; +} + /** * Сообщение чата заявки — комментарий к элементу списка данных, приведённый * к виду, удобному интерфейсу (SharePoint отдаёт сырой ICommentInfo). @@ -5,6 +13,7 @@ export interface IChatMessage { /** Id комментария в SharePoint. Строка, а не число — так его отдаёт API. */ id: string; + /** Текст сообщения БЕЗ служебной пометки о файлах. */ text: string; /** ФИО автора; если SharePoint его не отдал — email или заглушка. */ authorName: string; @@ -15,4 +24,6 @@ export interface IChatMessage { * Считается по email автора, см. toChatMessages. */ isOwn: boolean; + /** Прикреплённые файлы; пустой массив — файлов нет. */ + attachments: IChatAttachment[]; } diff --git a/src/webparts/requestPortal/shared/chat/requestChat.service.ts b/src/webparts/requestPortal/shared/chat/requestChat.service.ts index 7a1c199..5a884a7 100644 --- a/src/webparts/requestPortal/shared/chat/requestChat.service.ts +++ b/src/webparts/requestPortal/shared/chat/requestChat.service.ts @@ -4,7 +4,8 @@ import type { ICommentInfo, IComments } from '@pnp/sp/comments'; import { resolveDataList } from '../dataList'; import { RequestTypeId } from '../../models/types'; import { IChatMessage } from './models/IChatMessage'; -import { toChatMessages } from './chatMessages'; +import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages'; +import { chatFilesFolderUrl, uploadChatFiles } from './chatFiles.service'; /** * Чат по заявке ПОВЕРХ штатных комментариев к строке списка. @@ -15,6 +16,10 @@ import { toChatMessages } from './chatMessages'; * колонки в девяти списках, ни провижининга, и переписка не мешает * согласованию (комментарий решения — отдельное поле строки маршрута). * + * Единственное, чего у комментария нет, — вложений: у него есть только текст. + * Поэтому файлы лежат в отдельной библиотеке (chatFiles.service.ts), а в тексте + * комментария остаётся пометка с их именами (chatMessages.ts). + * * Заявка живёт в КОНКРЕТНОЙ партиции (списки нумерованные, см. * shared/approval/listPartitions.ts), поэтому список резолвим общим * resolveDataList(formKey, listId) — тем же, которым формы пишут саму заявку. @@ -47,18 +52,27 @@ export async function getChatMessages( currentUserEmail: string ): Promise { const comments: ICommentInfo[] = await commentsOf(formKey, itemId, listId)(); - return toChatMessages(comments, currentUserEmail); + // Адрес библиотеки спрашиваем, только если в переписке ЕСТЬ файлы: у заявки + // без вложений чат тогда вообще не зависит от библиотеки — ни лишнего + // запроса, ни падения, если её переименуют. + const folderUrl = hasAttachments(comments) ? await chatFilesFolderUrl(formKey, itemId) : ''; + return toChatMessages(comments, currentUserEmail, folderUrl); } /** * Отправить сообщение — добавить комментарий к строке заявки. Автора и время * ставит сам SharePoint, поэтому передаём только текст. + * + * Файлы сначала уходят в библиотеку, и только потом создаётся комментарий с + * пометкой об их именах — см. uploadChatFiles о том, почему такой порядок. */ export async function addChatMessage( formKey: RequestTypeId, itemId: number, listId: string | undefined, - text: string + text: string, + files: File[] = [] ): Promise { - await commentsOf(formKey, itemId, listId).add(text); + const storedNames = await uploadChatFiles(formKey, itemId, files); + await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames)); } diff --git a/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx b/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx index 69b4da0..80415a4 100644 --- a/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx +++ b/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx @@ -1,27 +1,38 @@ import * as React from 'react'; import styles from './RequestChat.module.scss'; -import { Button, Icon } from '../../ui'; +import { Button, FileDropzone, Icon } from '../../ui'; +import { MAX_FILES_PER_MESSAGE, MAX_FILE_SIZE_BYTES } from '../chatFiles.service'; interface IChatComposerProps { sending: boolean; /** Возвращает true, если сообщение записано — только тогда очищаем поле. */ - onSend: (text: string) => Promise; + onSend: (text: string, files: File[]) => Promise; } +const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024)); + /** - * Поле ввода сообщения. Здесь НЕ ui-kit TextArea, а собственный textarea: - * чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш не - * пробрасывает. + * Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный + * textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш + * не пробрасывает. */ export const ChatComposer: React.FC = ({ sending, onSend }) => { const [text, setText] = React.useState(''); - const canSend = text.trim().length > 0 && !sending; + const [files, setFiles] = React.useState([]); + const [filesOpen, setFilesOpen] = React.useState(false); + + // Сообщение из одних файлов — нормальный случай («вот скан»), поэтому пустой + // текст отправку не блокирует. + const canSend = (text.trim().length > 0 || files.length > 0) && !sending; const send = (): void => { if (!canSend) return; - onSend(text) + onSend(text, files) .then((sent) => { - if (sent) setText(''); + if (!sent) return; + setText(''); + setFiles([]); + setFilesOpen(false); }) .catch(() => undefined); }; @@ -35,21 +46,46 @@ export const ChatComposer: React.FC = ({ sending, onSend }) }; return ( -
-