From 14caa50088fbd58bb85965cf39ed5ee69be1d61c Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 9 Jun 2026 11:33:47 +0300 Subject: [PATCH] chore: checkpoint before cross assistant work --- .../api/plane/app/views/issue/attachment.py | 5 +- .../attachment/attachment-item-list.tsx | 6 +- .../attachment/attachment-list-item.tsx | 459 +++++++++++++++--- .../components/issues/peek-overview/view.tsx | 5 +- .../workspace/settings/storage-settings.tsx | 94 ++-- .../issue/issue_attachment.service.ts | 123 ++++- .../issue/issue-details/attachment.store.ts | 6 +- plane-src/apps/web/helpers/beam-viewer.ts | 250 ++++++++-- .../types/src/issues/issue_attachment.ts | 51 +- 9 files changed, 849 insertions(+), 150 deletions(-) diff --git a/plane-src/apps/api/plane/app/views/issue/attachment.py b/plane-src/apps/api/plane/app/views/issue/attachment.py index 546eb9a..00b9c9b 100644 --- a/plane-src/apps/api/plane/app/views/issue/attachment.py +++ b/plane-src/apps/api/plane/app/views/issue/attachment.py @@ -263,7 +263,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView): existing_beam_viewer = attributes.get("beamViewer") if not isinstance(existing_beam_viewer, dict): return Response( - {"error": "The attachment is not a Beam Viewer reference.", "status": False}, + {"error": "The attachment is not a BIM Viewer reference.", "status": False}, status=status.HTTP_400_BAD_REQUEST, ) @@ -271,6 +271,9 @@ class IssueAttachmentV2Endpoint(BaseAPIView): **existing_beam_viewer, **beam_viewer, } + for attribute_key in ("name", "size", "type", "version"): + if attribute_key in request.data: + attributes[attribute_key] = request.data.get(attribute_key) issue_attachment.attributes = attributes issue_attachment.is_uploaded = True issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"]) diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx index b4bdbea..efd9c42 100644 --- a/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx @@ -211,8 +211,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList {isListView ? (
-
-
+
+
Файл
Дата
Версия
@@ -222,7 +222,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList {uploadStatus?.map((status) => (
{status.name}
diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx index ed2b37b..fb51785 100644 --- a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx @@ -5,12 +5,13 @@ */ import { observer } from "mobx-react"; -import { useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ChangeEvent, ReactNode } from "react"; import { useTranslation } from "@plane/i18n"; import { getIconButtonStyling } from "@plane/propel/icon-button"; import { TrashIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; import type { TIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types"; @@ -18,7 +19,7 @@ import { EIssueServiceType } from "@plane/types"; import type { TContextMenuItem } from "@plane/ui"; import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui"; import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils"; -import { AlertTriangle, Box, Download, Eye, ImageIcon, Play, X } from "lucide-react"; +import { AlertTriangle, Box, Download, Eye, History, ImageIcon, Play, UploadCloud, X } from "lucide-react"; // components // import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; @@ -27,8 +28,12 @@ import { buildBeamViewerUrl, dispatchBeamViewerOpenEvent, fetchBeamConversionStatus, + getBeamModelVersionRecords, + getBeamVersionViewerUrl, getBeamViewerAttachment, isBeamModelFile, + syncCurrentBeamVersionRecord, + type TBeamModelVersionRecord, type TBeamViewerAttachment, type TBeamConversionStatus, } from "@/helpers/beam-viewer"; @@ -37,6 +42,7 @@ import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attach // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useMember } from "@/hooks/store/use-member"; +import { useUser } from "@/hooks/store/user"; import { usePlatformOS } from "@/hooks/use-platform-os"; import { IssueAttachmentService } from "@/services/issue"; @@ -61,7 +67,7 @@ const appendSearchParam = (url: string | undefined, key: string, value: string): return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`; }; -const getUsableBeamViewerUrl = (url: string | undefined): string | undefined => { +const getUsableBeamViewerUrl = (url: string | null | undefined): string | undefined => { if (!url) return undefined; try { const parsedUrl = new URL(url, "http://localhost"); @@ -75,7 +81,10 @@ const getUsableBeamViewerUrl = (url: string | undefined): string | undefined => } }; -const withBeamViewerSettingsSrc = (url: string | undefined, settingsSrc: string | undefined): string | undefined => { +const withBeamViewerSettingsSrc = ( + url: string | null | undefined, + settingsSrc: string | undefined +): string | undefined => { if (!url || !settingsSrc) return url; try { const parsedUrl = new URL(url, "http://localhost"); @@ -99,9 +108,20 @@ const getBeamConversionStatusLabel = (status: string | undefined): string | null if (status === "ready") return "Модель готова"; if (status === "failed") return "Ошибка дерева"; if (status === "processing") return "Собираем дерево"; + if (status === "conversion_required") return "Ожидает очереди"; return "Ожидает дерево"; }; +const sanitizeBeamStatusMessage = (message: string | undefined): string | undefined => + message + ?.replace(/XKT-просмотр/g, "просмотр") + .replace(/XKT и дерева/g, "модели и дерева") + .replace(/GLB\/XKT/g, "модели") + .replace(/XKT/g, "просмотра"); + +const getBeamVersionRecordKey = (version: TBeamModelVersionRecord): string => + version.versionId || `version-${version.version}`; + const buildSyncedBeamViewerAttachment = ( beamViewer: TBeamViewerAttachment, status: TBeamConversionStatus, @@ -112,9 +132,13 @@ const buildSyncedBeamViewerAttachment = ( const artifactType = status.artifactType || status.targetFormat || "gltf"; const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb"); - return { + return syncCurrentBeamVersionRecord({ ...beamViewer, + assetId: beamViewer.assetId || status.assetId, previewAvailable: true, + sha256: beamViewer.sha256 || status.sha256, + version: beamViewer.version || status.version, + versionId: beamViewer.versionId || status.versionId, viewerUrl: buildBeamViewerUrl({ name: fullFileName, settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src, @@ -122,19 +146,20 @@ const buildSyncedBeamViewerAttachment = ( type: artifactType, }), conversion: { - ...(beamViewer.conversion ?? {}), + ...beamViewer.conversion, artifactSrc: status.artifactSrc, artifactType, componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true, message: status.message || beamViewer.conversion?.message, metadataSrc: status.metadataSrc, + size: status.size ?? beamViewer.conversion?.size, sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type, sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc, status: "ready", targetFormat, updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt, }, - }; + }); }; export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) { @@ -151,6 +176,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt } = props; // store hooks const { getUserDetails } = useMember(); + const { data: currentUser } = useUser(); const { attachment: { getAttachmentById }, fetchAttachments, @@ -170,7 +196,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt const [beamConversionError, setBeamConversionError] = useState(null); const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]); const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined; - const storedModelViewerUrl = getUsableBeamViewerUrl(beamViewer?.viewerUrl); + const beamEffectiveStatus = beamConversionStatus?.status ?? beamViewer?.conversion?.status; + const storedModelViewerUrl = + beamEffectiveStatus === "ready" ? getUsableBeamViewerUrl(beamViewer?.viewerUrl) : undefined; const beamViewerSettingsSrc = beamConversionStatus?.sourceSrc || beamViewer?.conversion?.sourceSrc || beamViewer?.src; const storedModelViewerUrlWithSettings = withBeamViewerSettingsSrc(storedModelViewerUrl, beamViewerSettingsSrc); const modelViewerUrl = @@ -185,24 +213,37 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src; const previewDownloadUrl = modelDownloadUrl || fileURL; const isBeamModel = isBeamModelFile(fullFileName); - const beamEffectiveStatus = beamConversionStatus?.status ?? beamViewer?.conversion?.status; const beamConversionStatusLabel = getBeamConversionStatusLabel(beamEffectiveStatus); const beamStatusErrorMessage = beamConversionError || beamConversionStatus?.error || (beamConversionStatus?.status === "ready" && !modelViewerUrl - ? "Beam вернул статус готовности, но не вернул viewer-артефакт." + ? "BIM Viewer вернул статус готовности, но не вернул viewer-артефакт." : null); const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage; const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed; - const beamStatusTooltipContent = isBeamConversionFailed + const rawBeamStatusTooltipContent = isBeamConversionFailed ? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов." : beamConversionStatus?.message || beamViewer?.conversion?.message || - "Оригинальная модель загружена в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов."; + "Оригинальная модель загружена в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов."; + const beamStatusTooltipContent = sanitizeBeamStatusMessage(rawBeamStatusTooltipContent) || ""; + const beamStatusTooltipMessage = + beamStatusTooltipContent && + !["ready", "processing", "failed", "conversion_required"].includes(beamStatusTooltipContent) + ? beamStatusTooltipContent + : ""; + const beamStatusIconTooltipContent = beamConversionStatusLabel + ? `${beamConversionStatusLabel}${beamStatusTooltipMessage ? `. ${beamStatusTooltipMessage}` : ""}` + : beamStatusTooltipContent; const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false); + const [deletingVersionKey, setDeletingVersionKey] = useState(null); + const [isVersionUploading, setIsVersionUploading] = useState(false); const [isThumbnailError, setIsThumbnailError] = useState(false); - const rawVersion = attachment?.attributes.version; + const versionUploadInputRef = useRef(null); + const beamVersions = useMemo(() => getBeamModelVersionRecords(beamViewer, attachment), [attachment, beamViewer]); + const rawVersion = beamViewer?.version ?? attachment?.attributes.version; const versionLabel = typeof rawVersion === "number" ? `v${rawVersion}` @@ -211,8 +252,14 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt ? rawVersion : `v${rawVersion}` : "v1"; + const currentVersionId = beamViewer?.versionId; const uploadedAt = renderFormattedDate(attachment?.created_at ?? attachment?.updated_at ?? ""); const uploadedBy = attachment?.created_by ? (getUserDetails(attachment.created_by)?.display_name ?? "—") : "—"; + const getVersionAuthorName = (version: TBeamModelVersionRecord): string => { + const userId = version.uploadedBy || attachment?.created_by; + if (!userId) return "—"; + return getUserDetails(userId)?.display_name ?? "—"; + }; const openModelViewer = () => { if (!modelViewerUrl) return; dispatchBeamViewerOpenEvent({ @@ -224,6 +271,88 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt viewerUrl: modelViewerUrl, }); }; + const openBeamVersionViewer = (version: TBeamModelVersionRecord) => { + const viewerUrl = getBeamVersionViewerUrl(version); + if (!viewerUrl) return; + + setIsVersionHistoryOpen(false); + dispatchBeamViewerOpenEvent({ + downloadUrl: version.downloadUrl, + fileExtension: getFileExtension(version.originalFilename), + fileName: version.originalFilename, + fileSize: version.size || version.conversion?.size || 0, + issueId, + viewerUrl, + }); + }; + const startVersionUpload = () => { + versionUploadInputRef.current?.click(); + }; + const handleVersionFileChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || !attachment || !beamViewer) return; + + if (!isBeamModelFile(file.name)) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Формат не поддержан", + message: "Для новой версии нужно выбрать файл модели, который поддерживает BIM Viewer.", + }); + return; + } + + setIsVersionUploading(true); + try { + await attachmentService.uploadBeamIssueAttachmentVersion( + workspaceSlug, + projectId, + issueId, + attachment, + file, + undefined, + currentUser?.id + ); + await fetchAttachments(workspaceSlug, projectId, issueId); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Версия добавлена", + message: `${file.name} загружен как новая версия модели.`, + }); + } catch (error) { + console.error("Error in uploading Beam model version:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Версия не загружена", + message: error instanceof Error ? error.message : "Не удалось загрузить новую версию модели.", + }); + } finally { + setIsVersionUploading(false); + } + }; + const handleDeleteVersion = async (version: TBeamModelVersionRecord) => { + if (!attachment || !beamViewer) return; + + const versionKey = getBeamVersionRecordKey(version); + setDeletingVersionKey(versionKey); + try { + await attachmentService.deleteBeamIssueAttachmentVersion(workspaceSlug, projectId, issueId, attachment, version); + await fetchAttachments(workspaceSlug, projectId, issueId); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Версия удалена", + message: `v${version.version} удалена из истории модели.`, + }); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Версия не удалена", + message: error instanceof Error ? error.message : "Не удалось удалить версию модели.", + }); + } finally { + setDeletingVersionKey(null); + } + }; const menuItems: TContextMenuItem[] = [ ...(modelViewerUrl ? [ @@ -235,6 +364,23 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt }, ] : []), + ...(beamViewer + ? [ + { + key: "upload-model-version", + action: startVersionUpload, + title: isVersionUploading ? "Загружаем версию..." : "Добавить версию", + icon: UploadCloud, + disabled: isVersionUploading, + }, + { + key: "model-version-history", + action: () => setIsVersionHistoryOpen(true), + title: "История версий", + icon: History, + }, + ] + : []), ...(modelDownloadUrl ? [ { @@ -258,9 +404,19 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt ]; // hooks const { isMobile } = usePlatformOS(); + const versionUploadInput = beamViewer ? ( + + ) : null; useEffect(() => { - if (!beamViewer || storedModelViewerUrl || !beamViewer.src) return; + if (!beamViewer || !beamViewer.src) return; + if (beamViewer.conversion?.status === "ready" && storedModelViewerUrl) return; let isMounted = true; let timeoutId: number | undefined; @@ -283,7 +439,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt } } catch (error) { if (!isMounted) return; - setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус Beam."); + setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус BIM-модели."); timeoutId = window.setTimeout(pollStatus, 8000); } }; @@ -311,7 +467,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt if (viewMode === "list") { return ( <> -
+ {versionUploadInput} +
@@ -374,6 +521,40 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
{versionLabel}
{uploadedBy}
+ {beamViewer && ( + + + + )} + {beamViewer && ( + + + + )} {(modelViewerUrl || previewURL) && ( + setIsVersionHistoryOpen(false)} + onDeleteVersion={handleDeleteVersion} + onOpenVersion={openBeamVersionViewer} + versions={beamVersions} + /> ); } return ( <> + {versionUploadInput}
{modelDownloadUrl && ( @@ -702,3 +906,150 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
); }; + +type TBeamVersionHistoryModal = { + currentVersionId: string | undefined; + deletingVersionKey: string | null; + fileName: string; + getVersionAuthorName: (version: TBeamModelVersionRecord) => string; + isOpen: boolean; + onClose: () => void; + onDeleteVersion: (version: TBeamModelVersionRecord) => void; + onOpenVersion: (version: TBeamModelVersionRecord) => void; + versions: TBeamModelVersionRecord[]; +}; + +const BeamVersionHistoryModal = (props: TBeamVersionHistoryModal) => { + const { + currentVersionId, + deletingVersionKey, + fileName, + getVersionAuthorName, + isOpen, + onClose, + onDeleteVersion, + onOpenVersion, + versions, + } = props; + const latestVersion = versions.reduce((latest, version) => Math.max(latest, version.version), 0); + + return ( + +
+
+
+
История версий
+
{fileName}
+
+ +
+ +
+ {versions.length === 0 ? ( +
+ История версий пока не создана. +
+ ) : ( +
+
+
+
Файл
+
Версия
+
Размер
+
Автор
+
Дата
+
Статус
+
Действия
+
+ {versions + .toSorted((first, second) => second.version - first.version) + .map((version) => { + const viewerUrl = getBeamVersionViewerUrl(version); + const isCurrent = currentVersionId + ? version.versionId === currentVersionId + : version.version === latestVersion; + const statusLabel = getBeamConversionStatusLabel(version.conversion?.status) ?? "Модель готова"; + const versionKey = getBeamVersionRecordKey(version); + const isDeletingVersion = deletingVersionKey === versionKey; + const authorName = getVersionAuthorName(version); + const versionSize = version.size || version.conversion?.size || 0; + return ( +
+
+
{version.originalFilename}
+ {version.sha256 && ( +
sha256 {version.sha256}
+ )} +
+
+ v{version.version} + {isCurrent && ( + + текущая + + )} +
+
{convertBytesToSize(versionSize)}
+
{authorName}
+
{renderFormattedDate(version.uploadedAt)}
+
{statusLabel}
+
+ {viewerUrl && ( + + )} + {version.downloadUrl && ( + + + + )} + + + +
+
+ ); + })} +
+
+ )} +
+
+
+ ); +}; diff --git a/plane-src/apps/web/core/components/issues/peek-overview/view.tsx b/plane-src/apps/web/core/components/issues/peek-overview/view.tsx index 104dca8..a9f6b75 100644 --- a/plane-src/apps/web/core/components/issues/peek-overview/view.tsx +++ b/plane-src/apps/web/core/components/issues/peek-overview/view.tsx @@ -574,7 +574,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) { } as CSSProperties } > -
+
+ {/* eslint-disable-next-line react/iframe-missing-sandbox -- BIM Viewer needs its scripted same-origin context for controls and settings. */}