chore: checkpoint before cross assistant work

This commit is contained in:
DCCONSTRUCTIONS 2026-06-09 11:33:47 +03:00
parent 2ad9e7f85c
commit 14caa50088
9 changed files with 849 additions and 150 deletions

View File

@ -263,7 +263,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
existing_beam_viewer = attributes.get("beamViewer") existing_beam_viewer = attributes.get("beamViewer")
if not isinstance(existing_beam_viewer, dict): if not isinstance(existing_beam_viewer, dict):
return Response( 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, status=status.HTTP_400_BAD_REQUEST,
) )
@ -271,6 +271,9 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
**existing_beam_viewer, **existing_beam_viewer,
**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.attributes = attributes
issue_attachment.is_uploaded = True issue_attachment.is_uploaded = True
issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"]) issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"])

View File

@ -211,8 +211,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
</div> </div>
{isListView ? ( {isListView ? (
<div className="nodedc-attachments-list-scroll overflow-auto rounded-2xl bg-surface-2/40"> <div className="nodedc-attachments-list-scroll overflow-auto rounded-2xl bg-surface-2/40">
<div className="min-w-[760px]"> <div className="min-w-[840px]">
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] gap-3 border-b border-subtle px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase"> <div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] gap-3 border-b border-subtle px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
<div>Файл</div> <div>Файл</div>
<div>Дата</div> <div>Дата</div>
<div>Версия</div> <div>Версия</div>
@ -222,7 +222,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
{uploadStatus?.map((status) => ( {uploadStatus?.map((status) => (
<div <div
key={status.id} key={status.id}
className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12" className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12"
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-semibold text-secondary">{status.name}</div> <div className="truncate font-semibold text-secondary">{status.name}</div>

View File

@ -5,12 +5,13 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ChangeEvent, ReactNode } from "react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button"; import { getIconButtonStyling } from "@plane/propel/icon-button";
import { TrashIcon } from "@plane/propel/icons"; import { TrashIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import type { TIssueServiceType } from "@plane/types"; import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types";
@ -18,7 +19,7 @@ import { EIssueServiceType } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui"; import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui"; import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils"; 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 // components
// //
import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
@ -27,8 +28,12 @@ import {
buildBeamViewerUrl, buildBeamViewerUrl,
dispatchBeamViewerOpenEvent, dispatchBeamViewerOpenEvent,
fetchBeamConversionStatus, fetchBeamConversionStatus,
getBeamModelVersionRecords,
getBeamVersionViewerUrl,
getBeamViewerAttachment, getBeamViewerAttachment,
isBeamModelFile, isBeamModelFile,
syncCurrentBeamVersionRecord,
type TBeamModelVersionRecord,
type TBeamViewerAttachment, type TBeamViewerAttachment,
type TBeamConversionStatus, type TBeamConversionStatus,
} from "@/helpers/beam-viewer"; } from "@/helpers/beam-viewer";
@ -37,6 +42,7 @@ import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attach
// hooks // hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member"; import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
import { IssueAttachmentService } from "@/services/issue"; 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}` : ""}`; 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; if (!url) return undefined;
try { try {
const parsedUrl = new URL(url, "http://localhost"); 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; if (!url || !settingsSrc) return url;
try { try {
const parsedUrl = new URL(url, "http://localhost"); const parsedUrl = new URL(url, "http://localhost");
@ -99,9 +108,20 @@ const getBeamConversionStatusLabel = (status: string | undefined): string | null
if (status === "ready") return "Модель готова"; if (status === "ready") return "Модель готова";
if (status === "failed") return "Ошибка дерева"; if (status === "failed") return "Ошибка дерева";
if (status === "processing") return "Собираем дерево"; if (status === "processing") return "Собираем дерево";
if (status === "conversion_required") return "Ожидает очереди";
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 = ( const buildSyncedBeamViewerAttachment = (
beamViewer: TBeamViewerAttachment, beamViewer: TBeamViewerAttachment,
status: TBeamConversionStatus, status: TBeamConversionStatus,
@ -112,9 +132,13 @@ const buildSyncedBeamViewerAttachment = (
const artifactType = status.artifactType || status.targetFormat || "gltf"; const artifactType = status.artifactType || status.targetFormat || "gltf";
const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb"); const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb");
return { return syncCurrentBeamVersionRecord({
...beamViewer, ...beamViewer,
assetId: beamViewer.assetId || status.assetId,
previewAvailable: true, previewAvailable: true,
sha256: beamViewer.sha256 || status.sha256,
version: beamViewer.version || status.version,
versionId: beamViewer.versionId || status.versionId,
viewerUrl: buildBeamViewerUrl({ viewerUrl: buildBeamViewerUrl({
name: fullFileName, name: fullFileName,
settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src, settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src,
@ -122,19 +146,20 @@ const buildSyncedBeamViewerAttachment = (
type: artifactType, type: artifactType,
}), }),
conversion: { conversion: {
...(beamViewer.conversion ?? {}), ...beamViewer.conversion,
artifactSrc: status.artifactSrc, artifactSrc: status.artifactSrc,
artifactType, artifactType,
componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true, componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true,
message: status.message || beamViewer.conversion?.message, message: status.message || beamViewer.conversion?.message,
metadataSrc: status.metadataSrc, metadataSrc: status.metadataSrc,
size: status.size ?? beamViewer.conversion?.size,
sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type, sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type,
sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc, sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc,
status: "ready", status: "ready",
targetFormat, targetFormat,
updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt, updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt,
}, },
}; });
}; };
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) { export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
@ -151,6 +176,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
} = props; } = props;
// store hooks // store hooks
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
const { data: currentUser } = useUser();
const { const {
attachment: { getAttachmentById }, attachment: { getAttachmentById },
fetchAttachments, fetchAttachments,
@ -170,7 +196,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const [beamConversionError, setBeamConversionError] = useState<string | null>(null); const [beamConversionError, setBeamConversionError] = useState<string | null>(null);
const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]); const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]);
const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined; 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 beamViewerSettingsSrc = beamConversionStatus?.sourceSrc || beamViewer?.conversion?.sourceSrc || beamViewer?.src;
const storedModelViewerUrlWithSettings = withBeamViewerSettingsSrc(storedModelViewerUrl, beamViewerSettingsSrc); const storedModelViewerUrlWithSettings = withBeamViewerSettingsSrc(storedModelViewerUrl, beamViewerSettingsSrc);
const modelViewerUrl = const modelViewerUrl =
@ -185,24 +213,37 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src; const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
const previewDownloadUrl = modelDownloadUrl || fileURL; const previewDownloadUrl = modelDownloadUrl || fileURL;
const isBeamModel = isBeamModelFile(fullFileName); const isBeamModel = isBeamModelFile(fullFileName);
const beamEffectiveStatus = beamConversionStatus?.status ?? beamViewer?.conversion?.status;
const beamConversionStatusLabel = getBeamConversionStatusLabel(beamEffectiveStatus); const beamConversionStatusLabel = getBeamConversionStatusLabel(beamEffectiveStatus);
const beamStatusErrorMessage = const beamStatusErrorMessage =
beamConversionError || beamConversionError ||
beamConversionStatus?.error || beamConversionStatus?.error ||
(beamConversionStatus?.status === "ready" && !modelViewerUrl (beamConversionStatus?.status === "ready" && !modelViewerUrl
? "Beam вернул статус готовности, но не вернул viewer-артефакт." ? "BIM Viewer вернул статус готовности, но не вернул viewer-артефакт."
: null); : null);
const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage; const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage;
const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed; const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed;
const beamStatusTooltipContent = isBeamConversionFailed const rawBeamStatusTooltipContent = isBeamConversionFailed
? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов." ? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов."
: beamConversionStatus?.message || : beamConversionStatus?.message ||
beamViewer?.conversion?.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 [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
const [deletingVersionKey, setDeletingVersionKey] = useState<string | null>(null);
const [isVersionUploading, setIsVersionUploading] = useState(false);
const [isThumbnailError, setIsThumbnailError] = useState(false); const [isThumbnailError, setIsThumbnailError] = useState(false);
const rawVersion = attachment?.attributes.version; const versionUploadInputRef = useRef<HTMLInputElement>(null);
const beamVersions = useMemo(() => getBeamModelVersionRecords(beamViewer, attachment), [attachment, beamViewer]);
const rawVersion = beamViewer?.version ?? attachment?.attributes.version;
const versionLabel = const versionLabel =
typeof rawVersion === "number" typeof rawVersion === "number"
? `v${rawVersion}` ? `v${rawVersion}`
@ -211,8 +252,14 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
? rawVersion ? rawVersion
: `v${rawVersion}` : `v${rawVersion}`
: "v1"; : "v1";
const currentVersionId = beamViewer?.versionId;
const uploadedAt = renderFormattedDate(attachment?.created_at ?? attachment?.updated_at ?? ""); const uploadedAt = renderFormattedDate(attachment?.created_at ?? attachment?.updated_at ?? "");
const uploadedBy = attachment?.created_by ? (getUserDetails(attachment.created_by)?.display_name ?? "—") : "—"; 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 = () => { const openModelViewer = () => {
if (!modelViewerUrl) return; if (!modelViewerUrl) return;
dispatchBeamViewerOpenEvent({ dispatchBeamViewerOpenEvent({
@ -224,6 +271,88 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
viewerUrl: modelViewerUrl, 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<HTMLInputElement>) => {
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[] = [ const menuItems: TContextMenuItem[] = [
...(modelViewerUrl ...(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 ...(modelDownloadUrl
? [ ? [
{ {
@ -258,9 +404,19 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
]; ];
// hooks // hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const versionUploadInput = beamViewer ? (
<input
ref={versionUploadInputRef}
type="file"
className="hidden"
disabled={isVersionUploading}
onChange={handleVersionFileChange}
/>
) : null;
useEffect(() => { useEffect(() => {
if (!beamViewer || storedModelViewerUrl || !beamViewer.src) return; if (!beamViewer || !beamViewer.src) return;
if (beamViewer.conversion?.status === "ready" && storedModelViewerUrl) return;
let isMounted = true; let isMounted = true;
let timeoutId: number | undefined; let timeoutId: number | undefined;
@ -283,7 +439,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
} }
} catch (error) { } catch (error) {
if (!isMounted) return; if (!isMounted) return;
setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус Beam."); setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус BIM-модели.");
timeoutId = window.setTimeout(pollStatus, 8000); timeoutId = window.setTimeout(pollStatus, 8000);
} }
}; };
@ -311,7 +467,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
if (viewMode === "list") { if (viewMode === "list") {
return ( return (
<> <>
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition hover:bg-white/[0.035]"> {versionUploadInput}
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition hover:bg-white/[0.035]">
<button <button
type="button" type="button"
className="flex min-w-0 items-center gap-3 text-left" className="flex min-w-0 items-center gap-3 text-left"
@ -321,6 +478,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
if (modelViewerUrl) openModelViewer(); if (modelViewerUrl) openModelViewer();
else setIsPreviewOpen(true); else setIsPreviewOpen(true);
}} }}
>
<Tooltip
tooltipContent={isBeamModel ? beamStatusIconTooltipContent : fullFileName}
isMobile={isMobile}
position={isBeamModel ? "left" : "top"}
sideOffset={isBeamModel ? 6 : 10}
className={isBeamModel ? "z-[220]" : ""}
> >
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary"> <div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
{isBeamModel ? ( {isBeamModel ? (
@ -341,6 +505,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
<ImageIcon className="size-5" /> <ImageIcon className="size-5" />
)} )}
</div> </div>
</Tooltip>
<div className="min-w-0"> <div className="min-w-0">
<Tooltip tooltipContent={fullFileName} isMobile={isMobile}> <Tooltip tooltipContent={fullFileName} isMobile={isMobile}>
<div className="truncate font-semibold text-secondary">{fullFileName}</div> <div className="truncate font-semibold text-secondary">{fullFileName}</div>
@ -349,24 +514,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
<span>{fileExtension ? fileExtension.toUpperCase() : "FILE"}</span> <span>{fileExtension ? fileExtension.toUpperCase() : "FILE"}</span>
<span className="size-1 rounded-full bg-layer-1" /> <span className="size-1 rounded-full bg-layer-1" />
<span>{convertBytesToSize(attachment.attributes.size)}</span> <span>{convertBytesToSize(attachment.attributes.size)}</span>
{beamConversionStatusLabel && (
<>
<span className="size-1 rounded-full bg-layer-1" />
<Tooltip tooltipContent={beamStatusTooltipContent} isMobile={isMobile}>
<span
className={
isBeamConversionFailed
? "text-red-400"
: isBeamAwaitingPreview
? "text-[rgb(var(--nodedc-accent-rgb))]"
: "text-tertiary"
}
>
{beamConversionStatusLabel}
</span>
</Tooltip>
</>
)}
</div> </div>
</div> </div>
</button> </button>
@ -374,6 +521,40 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
<div className="text-secondary">{versionLabel}</div> <div className="text-secondary">{versionLabel}</div>
<div className="truncate text-tertiary">{uploadedBy}</div> <div className="truncate text-tertiary">{uploadedBy}</div>
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{beamViewer && (
<Tooltip tooltipContent="История версий" isMobile={isMobile}>
<button
type="button"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsVersionHistoryOpen(true);
}}
>
<History className="size-4" />
</button>
</Tooltip>
)}
{beamViewer && (
<Tooltip
tooltipContent={isVersionUploading ? "Загружаем версию..." : "Добавить версию"}
isMobile={isMobile}
>
<button
type="button"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary disabled:pointer-events-none disabled:opacity-40"
disabled={isVersionUploading}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startVersionUpload();
}}
>
<UploadCloud className="size-4" />
</button>
</Tooltip>
)}
{(modelViewerUrl || previewURL) && ( {(modelViewerUrl || previewURL) && (
<Tooltip <Tooltip
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"} tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
@ -446,12 +627,24 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
size={attachment.attributes.size} size={attachment.attributes.size}
/> />
</ModalCore> </ModalCore>
<BeamVersionHistoryModal
currentVersionId={currentVersionId}
deletingVersionKey={deletingVersionKey}
fileName={fullFileName}
getVersionAuthorName={getVersionAuthorName}
isOpen={isVersionHistoryOpen}
onClose={() => setIsVersionHistoryOpen(false)}
onDeleteVersion={handleDeleteVersion}
onOpenVersion={openBeamVersionViewer}
versions={beamVersions}
/>
</> </>
); );
} }
return ( return (
<> <>
{versionUploadInput}
<div className="group flex h-32 w-72 flex-shrink-0 overflow-hidden rounded-2xl border border-subtle bg-surface-2/80 transition hover:border-[rgba(var(--nodedc-accent-rgb),0.45)] hover:bg-surface-2"> <div className="group flex h-32 w-72 flex-shrink-0 overflow-hidden rounded-2xl border border-subtle bg-surface-2/80 transition hover:border-[rgba(var(--nodedc-accent-rgb),0.45)] hover:bg-surface-2">
<button <button
type="button" type="button"
@ -571,6 +764,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
size={attachment.attributes.size} size={attachment.attributes.size}
/> />
</ModalCore> </ModalCore>
<BeamVersionHistoryModal
currentVersionId={currentVersionId}
deletingVersionKey={deletingVersionKey}
fileName={fullFileName}
getVersionAuthorName={getVersionAuthorName}
isOpen={isVersionHistoryOpen}
onClose={() => setIsVersionHistoryOpen(false)}
onDeleteVersion={handleDeleteVersion}
onOpenVersion={openBeamVersionViewer}
versions={beamVersions}
/>
</> </>
); );
}); });
@ -674,7 +878,7 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
<div className="mt-2 text-14 text-secondary"> <div className="mt-2 text-14 text-secondary">
{isBeamConversionFailed {isBeamConversionFailed
? beamStatusTooltipContent ? beamStatusTooltipContent
: "Оригинальный STEP загружен в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов."} : "Оригинальный STEP загружен в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов."}
</div> </div>
</div> </div>
{modelDownloadUrl && ( {modelDownloadUrl && (
@ -702,3 +906,150 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
</div> </div>
); );
}; };
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 (
<ModalCore
isOpen={isOpen}
handleClose={onClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="max-h-[76vh] w-[min(96vw,1280px)] overflow-hidden bg-surface-1"
>
<div className="flex max-h-[76vh] min-h-0 flex-col bg-surface-1">
<div className="flex flex-shrink-0 items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<div className="text-15 truncate font-semibold text-primary">История версий</div>
<div className="mt-1 truncate text-12 text-tertiary">{fileName}</div>
</div>
<button
type="button"
className="hover:bg-surface-3 grid size-9 place-items-center rounded-full bg-surface-2 text-tertiary transition hover:text-primary"
onClick={onClose}
>
<X className="size-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{versions.length === 0 ? (
<div className="rounded-2xl bg-surface-2/60 px-4 py-8 text-center text-13 text-tertiary">
История версий пока не создана.
</div>
) : (
<div className="overflow-hidden rounded-2xl bg-surface-2/40">
<div className="min-w-0">
<div className="grid grid-cols-[minmax(260px,1.6fr)_80px_96px_140px_140px_150px_120px] gap-3 px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
<div>Файл</div>
<div>Версия</div>
<div>Размер</div>
<div>Автор</div>
<div>Дата</div>
<div>Статус</div>
<div className="text-right">Действия</div>
</div>
{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 (
<div
key={versionKey}
className="grid grid-cols-[minmax(260px,1.6fr)_80px_96px_140px_140px_150px_120px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition last:border-b-0 hover:bg-surface-2"
>
<div className="min-w-0">
<div className="truncate font-semibold text-secondary">{version.originalFilename}</div>
{version.sha256 && (
<div className="mt-1 truncate text-10 text-tertiary">sha256 {version.sha256}</div>
)}
</div>
<div className="font-semibold text-secondary">
v{version.version}
{isCurrent && (
<span className="mt-1 block text-10 font-medium text-[rgb(var(--nodedc-accent-rgb))]">
текущая
</span>
)}
</div>
<div className="text-tertiary">{convertBytesToSize(versionSize)}</div>
<div className="truncate text-tertiary">{authorName}</div>
<div className="truncate text-tertiary">{renderFormattedDate(version.uploadedAt)}</div>
<div className="truncate text-tertiary">{statusLabel}</div>
<div className="flex items-center justify-end gap-1">
{viewerUrl && (
<button
type="button"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
onClick={() => onOpenVersion(version)}
>
<Eye className="size-4" />
</button>
)}
{version.downloadUrl && (
<a
href={version.downloadUrl}
download={version.originalFilename}
target="_blank"
rel="noopener noreferrer"
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
>
<Download className="size-4" />
</a>
)}
<Tooltip
tooltipContent={isCurrent ? "Текущую версию нельзя удалить" : "Удалить версию из истории"}
>
<button
type="button"
className="hover:bg-red-500/10 hover:text-red-400 grid size-8 place-items-center rounded-full text-tertiary transition disabled:pointer-events-none disabled:opacity-40"
disabled={isCurrent || isDeletingVersion}
onClick={() => onDeleteVersion(version)}
>
{isDeletingVersion ? <Spinner className="size-3.5" /> : <TrashIcon className="size-4" />}
</button>
</Tooltip>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</ModalCore>
);
};

View File

@ -574,7 +574,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
} as CSSProperties } as CSSProperties
} }
> >
<section className="nodedc-beam-peek-viewer-card" aria-label="Beam Viewer"> <section className="nodedc-beam-peek-viewer-card" aria-label="BIM Viewer">
<div className="pointer-events-none absolute top-4 right-4 left-4 z-10 grid grid-cols-[1fr_auto_1fr] items-start gap-3"> <div className="pointer-events-none absolute top-4 right-4 left-4 z-10 grid grid-cols-[1fr_auto_1fr] items-start gap-3">
<div aria-hidden="true" /> <div aria-hidden="true" />
<div className="max-w-[min(44vw,34rem)] min-w-0 text-center text-white drop-shadow-[0_2px_10px_rgba(0,0,0,0.75)]"> <div className="max-w-[min(44vw,34rem)] min-w-0 text-center text-white drop-shadow-[0_2px_10px_rgba(0,0,0,0.75)]">
@ -617,10 +617,11 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
</button> </button>
</div> </div>
</div> </div>
{/* eslint-disable-next-line react/iframe-missing-sandbox -- BIM Viewer needs its scripted same-origin context for controls and settings. */}
<iframe <iframe
key={beamPeekViewer.viewerUrl} key={beamPeekViewer.viewerUrl}
src={beamPeekViewer.viewerUrl} src={beamPeekViewer.viewerUrl}
title={`Beam Viewer: ${beamPeekViewer.fileName}`} title={`BIM Viewer: ${beamPeekViewer.fileName}`}
tabIndex={-1} tabIndex={-1}
className="h-full w-full border-0 ring-0 outline-none focus:ring-0 focus:outline-none" className="h-full w-full border-0 ring-0 outline-none focus:ring-0 focus:outline-none"
allow="fullscreen" allow="fullscreen"

View File

@ -62,7 +62,7 @@ const StatCard = (props: {
return ( return (
<div className="nodedc-settings-card p-5"> <div className="nodedc-settings-card p-5">
<div className="mb-5 flex items-start justify-between gap-4"> <div className="mb-5 flex items-start justify-between gap-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div> <div className="text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</div>
<div <div
className={cn( className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary", "flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary",
@ -73,7 +73,7 @@ const StatCard = (props: {
<Icon className="size-4" /> <Icon className="size-4" />
</div> </div>
</div> </div>
<div className="text-3xl font-semibold tracking-normal text-primary">{value}</div> <div className="text-3xl tracking-normal font-semibold text-primary">{value}</div>
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div> <div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
</div> </div>
); );
@ -92,7 +92,7 @@ const ProjectStorageRow = (props: {
<div className="nodedc-settings-field grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] items-center gap-4 px-4 py-3.5"> <div className="nodedc-settings-field grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] items-center gap-4 px-4 py-3.5">
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
<span className="truncate text-14 font-semibold text-primary">{project.name}</span> <span className="truncate text-14 font-semibold text-primary">{project.name}</span>
<span className="mt-1 text-11 uppercase tracking-[0.16em] text-tertiary">{project.identifier}</span> <span className="mt-1 text-11 tracking-[0.16em] text-tertiary uppercase">{project.identifier}</span>
</div> </div>
<StorageValue>{formatCount(project.file_count)}</StorageValue> <StorageValue>{formatCount(project.file_count)}</StorageValue>
<StorageValue>{formatCount(project.blob_count)}</StorageValue> <StorageValue>{formatCount(project.blob_count)}</StorageValue>
@ -106,7 +106,9 @@ const ProjectStorageRow = (props: {
<StorageValue accent>{formatBytes(project.dedup_savings)}</StorageValue> <StorageValue accent>{formatBytes(project.dedup_savings)}</StorageValue>
<ProjectQuotaControl project={project} workspaceSlug={workspaceSlug} onRefresh={onRefresh} /> <ProjectQuotaControl project={project} workspaceSlug={workspaceSlug} onRefresh={onRefresh} />
<StorageValue warning={project.failed_upload_count > 0}>{formatCount(project.failed_upload_count)}</StorageValue> <StorageValue warning={project.failed_upload_count > 0}>{formatCount(project.failed_upload_count)}</StorageValue>
<StorageValue warning={project.stale_unuploaded_count > 0}>{formatCount(project.stale_unuploaded_count)}</StorageValue> <StorageValue warning={project.stale_unuploaded_count > 0}>
{formatCount(project.stale_unuploaded_count)}
</StorageValue>
<StorageValue warning={project.soft_deleted_count > 0}>{formatCount(project.soft_deleted_count)}</StorageValue> <StorageValue warning={project.soft_deleted_count > 0}>{formatCount(project.soft_deleted_count)}</StorageValue>
</div> </div>
); );
@ -125,7 +127,7 @@ const StorageValue = (props: { accent?: boolean; children: string; warning?: boo
); );
const ProjectStorageHeader = () => ( const ProjectStorageHeader = () => (
<div className="grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] gap-4 px-4 text-[11px] font-semibold uppercase tracking-[0.16em] text-tertiary"> <div className="grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] gap-4 px-4 text-[11px] font-semibold tracking-[0.16em] text-tertiary uppercase">
<span>Проект</span> <span>Проект</span>
<span>Файлы</span> <span>Файлы</span>
<span>Blob</span> <span>Blob</span>
@ -173,7 +175,7 @@ const ProjectQuotaControl = (props: {
title: "Квота обновлена", title: "Квота обновлена",
message: isEnabled ? "Лимит проекта сохранен." : "Лимит проекта отключен.", message: isEnabled ? "Лимит проекта сохранен." : "Лимит проекта отключен.",
}); });
} catch (err) { } catch (_err) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Квота не сохранена", title: "Квота не сохранена",
@ -200,6 +202,7 @@ const ProjectQuotaControl = (props: {
<Database className="size-3.5" /> <Database className="size-3.5" />
</button> </button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{isEnabled ? (
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5"> <div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
<input <input
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary" className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
@ -217,6 +220,12 @@ const ProjectQuotaControl = (props: {
/> />
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span> <span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
</div> </div>
) : (
<div className="rounded-full bg-white/6 px-3 py-1.5 text-center text-12 font-medium text-primary">
Без лимита
</div>
)}
{project.quota_enabled && (
<div <div
className={cn( className={cn(
"mt-1 truncate text-[11px] text-tertiary", "mt-1 truncate text-[11px] text-tertiary",
@ -224,10 +233,9 @@ const ProjectQuotaControl = (props: {
project.quota_exceeded && "text-red-200" project.quota_exceeded && "text-red-200"
)} )}
> >
{project.quota_enabled {`${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`}
? `${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`
: "без лимита"}
</div> </div>
)}
</div> </div>
<button <button
type="button" type="button"
@ -269,8 +277,8 @@ const MaintenanceCard = ({
<div className="nodedc-settings-card flex min-h-40 flex-col justify-between gap-5 p-5"> <div className="nodedc-settings-card flex min-h-40 flex-col justify-between gap-5 p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div> <div className="text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</div>
<div className="mt-3 text-2xl font-semibold tracking-normal text-primary">{value}</div> <div className="text-2xl tracking-normal mt-3 font-semibold text-primary">{value}</div>
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div> <div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
</div> </div>
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary"> <div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary">
@ -300,7 +308,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug) ([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug)
); );
const projects = [...(data?.projects ?? [])].sort((a, b) => b.logical_size - a.logical_size); const projects = [...(data?.projects ?? [])].toSorted((a, b) => b.logical_size - a.logical_size);
const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0); const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0);
const runMaintenance = async (action: TWorkspaceStorageMaintenanceAction) => { const runMaintenance = async (action: TWorkspaceStorageMaintenanceAction) => {
@ -313,7 +321,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
title: "Хранилище обновлено", title: "Хранилище обновлено",
message: getMaintenanceResultMessage(result.action, result), message: getMaintenanceResultMessage(result.action, result),
}); });
} catch (err) { } catch (_err) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Действие не выполнено", title: "Действие не выполнено",
@ -333,14 +341,14 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
{isLoading && ( {isLoading && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => ( {["logical", "physical", "dedup", "problems"].map((skeletonKey) => (
<div key={index} className="nodedc-settings-card h-36 animate-pulse" /> <div key={skeletonKey} className="nodedc-settings-card h-36 animate-pulse" />
))} ))}
</div> </div>
)} )}
{error && ( {error && (
<div className="nodedc-settings-card bg-red-500/10 px-5 py-4 text-14 text-red-200"> <div className="nodedc-settings-card bg-red-500/10 text-red-200 px-5 py-4 text-14">
Не удалось загрузить данные хранилища. Не удалось загрузить данные хранилища.
</div> </div>
)} )}
@ -415,7 +423,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
<section> <section>
<div className="mb-4 flex items-end justify-between gap-4"> <div className="mb-4 flex items-end justify-between gap-4">
<div> <div>
<h2 className="text-lg font-semibold tracking-normal text-primary">Обслуживание</h2> <h2 className="text-lg tracking-normal font-semibold text-primary">Обслуживание</h2>
<p className="mt-1 text-13 text-secondary"> <p className="mt-1 text-13 text-secondary">
Ручные действия для диагностики и очистки файловой помойки без фонового удаления при открытии. Ручные действия для диагностики и очистки файловой помойки без фонового удаления при открытии.
</p> </p>
@ -470,7 +478,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
<section className="nodedc-settings-card p-5"> <section className="nodedc-settings-card p-5">
<div className="mb-5 flex items-center justify-between gap-4"> <div className="mb-5 flex items-center justify-between gap-4">
<div> <div>
<h2 className="text-lg font-semibold tracking-normal text-primary">Проекты</h2> <h2 className="text-lg tracking-normal font-semibold text-primary">Проекты</h2>
<p className="mt-1 text-13 text-secondary">Сортировка по логическому объему файлов.</p> <p className="mt-1 text-13 text-secondary">Сортировка по логическому объему файлов.</p>
</div> </div>
<div className="nodedc-settings-chip flex min-h-0 items-center px-4 py-2 text-12 font-medium text-secondary"> <div className="nodedc-settings-chip flex min-h-0 items-center px-4 py-2 text-12 font-medium text-secondary">

View File

@ -17,8 +17,15 @@ import type {
import { EIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types";
// services // services
import { import {
deleteBeamModelAsset,
deleteBeamModelVersion,
getBeamModelVersionRecords,
getBeamModelMimeType, getBeamModelMimeType,
getBeamViewerAttachment,
getBeamViewerVersionNumber,
isBeamModelFile, isBeamModelFile,
mergeBeamModelVersionRecords,
type TBeamModelVersionRecord,
type TBeamViewerAttachment, type TBeamViewerAttachment,
uploadBeamModelFile, uploadBeamModelFile,
} from "@/helpers/beam-viewer"; } from "@/helpers/beam-viewer";
@ -81,11 +88,12 @@ export class IssueAttachmentService extends APIService {
projectId: string, projectId: string,
issueId: string, issueId: string,
attachmentId: string, attachmentId: string,
beamViewer: TBeamViewerAttachment beamViewer: TBeamViewerAttachment,
attributes: Record<string, unknown> = {}
): Promise<TIssueAttachment> { ): Promise<TIssueAttachment> {
return this.patch( return this.patch(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`, `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
{ beamViewer } { ...attributes, beamViewer }
) )
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
@ -93,6 +101,107 @@ export class IssueAttachmentService extends APIService {
}); });
} }
async uploadBeamIssueAttachmentVersion(
workspaceSlug: string,
projectId: string,
issueId: string,
attachment: TIssueAttachment,
file: File,
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"],
uploadedBy?: string
): Promise<TIssueAttachment> {
const previousBeamViewer = getBeamViewerAttachment(attachment);
if (!previousBeamViewer) {
throw new Error("Для этого вложения нет BIM-метаданных.");
}
if (!isBeamModelFile(file.name)) {
throw new Error("Формат модели не поддерживается BIM Viewer.");
}
const previousVersions = getBeamModelVersionRecords(previousBeamViewer, attachment);
const currentVersion = Math.max(
getBeamViewerVersionNumber(previousBeamViewer, attachment),
...previousVersions.map((version) => version.version)
);
const nextVersion = currentVersion + 1;
const assetId = previousBeamViewer.assetId || attachment.id;
const nextBeamViewer = await uploadBeamModelFile(file, {
assetId,
issueId,
onUploadProgress: uploadProgressHandler,
projectId,
uploadedBy,
version: nextVersion,
workspaceSlug,
});
const nextVersions = mergeBeamModelVersionRecords(
{ ...previousBeamViewer, assetId, versions: previousVersions },
nextBeamViewer,
attachment
);
return this.updateBeamIssueAttachmentReference(
workspaceSlug,
projectId,
issueId,
attachment.id,
{
...nextBeamViewer,
assetId,
previewAvailable: nextBeamViewer.previewAvailable,
uploadedBy,
version: nextVersion,
versions: nextVersions,
viewerUrl: nextBeamViewer.viewerUrl ?? null,
},
{
name: file.name,
size: file.size,
type: getBeamModelMimeType(file),
version: nextVersion,
}
);
}
async deleteBeamIssueAttachmentVersion(
workspaceSlug: string,
projectId: string,
issueId: string,
attachment: TIssueAttachment,
versionToDelete: TBeamModelVersionRecord
): Promise<TIssueAttachment> {
const beamViewer = getBeamViewerAttachment(attachment);
if (!beamViewer) {
throw new Error("Для этого вложения нет BIM-метаданных.");
}
const currentVersion = getBeamViewerVersionNumber(beamViewer, attachment);
const isCurrentVersion = versionToDelete.versionId
? beamViewer.versionId === versionToDelete.versionId
: currentVersion === versionToDelete.version;
if (isCurrentVersion) {
throw new Error("Текущую версию нельзя удалить. Сначала переключите модель на другую версию.");
}
const versions = getBeamModelVersionRecords(beamViewer, attachment);
const nextVersions = versions.filter((version) =>
versionToDelete.versionId
? version.versionId !== versionToDelete.versionId
: version.version !== versionToDelete.version
);
if (nextVersions.length === versions.length) {
throw new Error("Версия не найдена.");
}
await deleteBeamModelVersion(versionToDelete);
return this.updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachment.id, {
...beamViewer,
versions: nextVersions,
});
}
async uploadIssueAttachment( async uploadIssueAttachment(
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -167,8 +276,16 @@ export class IssueAttachmentService extends APIService {
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
issueId: string, issueId: string,
assetId: string assetId: string,
attachment?: TIssueAttachment
): Promise<TIssueAttachment> { ): Promise<TIssueAttachment> {
const beamViewer = getBeamViewerAttachment(attachment);
if (beamViewer) {
await deleteBeamModelAsset(beamViewer).catch((error) => {
console.warn("BIM storage cleanup failed; deleting OPS attachment reference anyway.", error);
});
}
return this.delete( return this.delete(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${assetId}/` `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${assetId}/`
) )

View File

@ -27,7 +27,7 @@ const getAttachmentTimestamp = (attachment: TIssueAttachment) =>
new Date(attachment.created_at ?? attachment.updated_at ?? 0).getTime(); new Date(attachment.created_at ?? attachment.updated_at ?? 0).getTime();
const sortAttachmentIdsByNewest = (attachmentIds: string[], attachmentMap: TIssueAttachmentMap): string[] => const sortAttachmentIdsByNewest = (attachmentIds: string[], attachmentMap: TIssueAttachmentMap): string[] =>
[...attachmentIds].sort((firstId, secondId) => { attachmentIds.toSorted((firstId, secondId) => {
const firstAttachment = attachmentMap[firstId]; const firstAttachment = attachmentMap[firstId];
const secondAttachment = attachmentMap[secondId]; const secondAttachment = attachmentMap[secondId];
if (!firstAttachment || !secondAttachment) return 0; if (!firstAttachment || !secondAttachment) return 0;
@ -232,11 +232,13 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => { removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {
const issueAttachmentService = await this.getIssueAttachmentService(); const issueAttachmentService = await this.getIssueAttachmentService();
const attachment = this.attachmentMap[attachmentId];
const response = await issueAttachmentService.deleteIssueAttachment( const response = await issueAttachmentService.deleteIssueAttachment(
workspaceSlug, workspaceSlug,
projectId, projectId,
issueId, issueId,
attachmentId attachmentId,
attachment
); );
runInAction(() => { runInAction(() => {

View File

@ -1,30 +1,57 @@
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import type { TIssueAttachment } from "@plane/types"; import type { TIssueAttachment } from "@plane/types";
export type TBeamViewerAttachment = { export type TBeamViewerConversion = {
backend: "beam-viewer-local" | "beam-viewer";
conversion?: {
artifactSrc?: string; artifactSrc?: string;
artifactType?: string; artifactType?: string;
componentTreeRequired: boolean; componentTreeRequired: boolean;
message?: string; message?: string;
metadataSrc?: string; metadataSrc?: string;
size?: number;
sourceFormat: string; sourceFormat: string;
sourceSrc?: string; sourceSrc?: string;
status: "conversion_required" | "processing" | "ready" | "failed"; status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat: "glb" | "xkt"; targetFormat: "glb" | "xkt";
updatedAt?: string; updatedAt?: string;
}; };
export type TBeamModelVersionRecord = {
assetId?: string;
conversion?: TBeamViewerConversion;
downloadUrl: string; downloadUrl: string;
originalFilename: string; originalFilename: string;
previewAvailable: boolean; previewAvailable: boolean;
sha256?: string;
size: number;
src: string; src: string;
type: string; type: string;
uploadedBy?: string;
uploadedAt: string; uploadedAt: string;
viewerUrl?: string; version: number;
versionId?: string;
viewerUrl?: string | null;
};
export type TBeamViewerAttachment = {
assetId?: string;
backend: "beam-viewer-local" | "beam-viewer";
conversion?: TBeamViewerConversion;
downloadUrl: string;
originalFilename: string;
previewAvailable: boolean;
sha256?: string;
src: string;
type: string;
uploadedBy?: string;
uploadedAt: string;
version?: number;
versionId?: string;
versions?: TBeamModelVersionRecord[];
viewerUrl?: string | null;
}; };
export type TBeamConversionStatus = { export type TBeamConversionStatus = {
assetId?: string;
artifactSrc?: string; artifactSrc?: string;
artifactType?: string; artifactType?: string;
artifactUrl?: string; artifactUrl?: string;
@ -34,11 +61,15 @@ export type TBeamConversionStatus = {
message?: string; message?: string;
metadataSrc?: string; metadataSrc?: string;
metadataUrl?: string; metadataUrl?: string;
sha256?: string;
sourceFormat?: string; sourceFormat?: string;
sourceSrc?: string; sourceSrc?: string;
size?: number;
status: "conversion_required" | "processing" | "ready" | "failed"; status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat?: "glb" | "xkt"; targetFormat?: "glb" | "xkt";
updatedAt?: string; updatedAt?: string;
version?: number;
versionId?: string;
}; };
export const BEAM_VIEWER_OPEN_EVENT = "nodedc:beam-viewer-open"; export const BEAM_VIEWER_OPEN_EVENT = "nodedc:beam-viewer-open";
@ -160,13 +191,120 @@ export const getBeamViewerAttachment = (attachment: TIssueAttachment | undefined
return beamViewer; return beamViewer;
}; };
const getAttachmentVersionNumber = (attachment: TIssueAttachment | undefined): number => {
const rawVersion = attachment?.attributes.version;
if (typeof rawVersion === "number" && Number.isFinite(rawVersion)) return rawVersion;
if (typeof rawVersion === "string") {
const parsed = Number.parseInt(rawVersion.replace(/^v/i, ""), 10);
if (Number.isFinite(parsed)) return parsed;
}
return 1;
};
export const getBeamViewerVersionNumber = (
beamViewer: TBeamViewerAttachment | null | undefined,
attachment?: TIssueAttachment
): number => {
if (typeof beamViewer?.version === "number" && Number.isFinite(beamViewer.version)) return beamViewer.version;
return getAttachmentVersionNumber(attachment);
};
export const createBeamModelVersionRecord = (
beamViewer: TBeamViewerAttachment,
options: {
attachment?: TIssueAttachment;
size?: number;
uploadedBy?: string;
} = {}
): TBeamModelVersionRecord => ({
assetId: beamViewer.assetId,
conversion: beamViewer.conversion,
downloadUrl: beamViewer.downloadUrl,
originalFilename: beamViewer.originalFilename,
previewAvailable: beamViewer.previewAvailable,
sha256: beamViewer.sha256,
size: options.size ?? beamViewer.conversion?.size ?? Number(options.attachment?.attributes.size ?? 0),
src: beamViewer.src,
type: beamViewer.type,
uploadedBy: beamViewer.uploadedBy || options.uploadedBy || options.attachment?.created_by,
uploadedAt: beamViewer.uploadedAt,
version: getBeamViewerVersionNumber(beamViewer, options.attachment),
versionId: beamViewer.versionId,
viewerUrl: beamViewer.viewerUrl,
});
const isSameBeamVersionRecord = (first: TBeamModelVersionRecord, second: TBeamModelVersionRecord): boolean => {
if (first.versionId && second.versionId) return first.versionId === second.versionId;
return first.version === second.version;
};
export const getBeamModelVersionRecords = (
beamViewer: TBeamViewerAttachment | null | undefined,
attachment?: TIssueAttachment
): TBeamModelVersionRecord[] => {
if (!beamViewer) return [];
const currentRecord = createBeamModelVersionRecord(beamViewer, { attachment });
const storedVersions = Array.isArray(beamViewer.versions) ? beamViewer.versions : [];
const versions = storedVersions.some((version) => isSameBeamVersionRecord(version, currentRecord))
? storedVersions
: [...storedVersions, currentRecord];
return versions
.filter((version) => typeof version?.version === "number" && Number.isFinite(version.version))
.toSorted((first, second) => first.version - second.version);
};
export const mergeBeamModelVersionRecords = (
previousBeamViewer: TBeamViewerAttachment,
nextBeamViewer: TBeamViewerAttachment,
attachment?: TIssueAttachment
): TBeamModelVersionRecord[] => {
const previousVersions = getBeamModelVersionRecords(previousBeamViewer, attachment);
const nextRecord =
nextBeamViewer.versions?.find((version) =>
nextBeamViewer.versionId
? version.versionId === nextBeamViewer.versionId
: version.version === nextBeamViewer.version
) ||
nextBeamViewer.versions?.[0] ||
createBeamModelVersionRecord(nextBeamViewer);
return [...previousVersions.filter((version) => !isSameBeamVersionRecord(version, nextRecord)), nextRecord].toSorted(
(first, second) => first.version - second.version
);
};
export const syncCurrentBeamVersionRecord = (
beamViewer: TBeamViewerAttachment,
attachment?: TIssueAttachment
): TBeamViewerAttachment => {
const currentRecord = createBeamModelVersionRecord(beamViewer, { attachment });
const versions = getBeamModelVersionRecords(beamViewer, attachment).map((version) => {
if (!isSameBeamVersionRecord(version, currentRecord)) return version;
return Object.assign({}, version, currentRecord);
});
return { ...beamViewer, versions };
};
export const getBeamVersionViewerUrl = (version: TBeamModelVersionRecord): string | undefined => {
if (version.viewerUrl) return version.viewerUrl;
if (version.conversion?.status !== "ready" || !version.conversion.artifactSrc) return undefined;
const artifactType = version.conversion.artifactType || version.conversion.targetFormat || "gltf";
return buildBeamViewerUrl({
name: version.originalFilename,
settingsSrc: version.conversion.sourceSrc || version.src,
src:
toBeamAbsoluteUrl(version.conversion.artifactSrc, version.conversion.updatedAt) || version.conversion.artifactSrc,
type: artifactType,
});
};
export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachment): Promise<TBeamConversionStatus> => { export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachment): Promise<TBeamConversionStatus> => {
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`); const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src)); statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
const response = await fetch(statusUrl.toString()); const response = await fetch(statusUrl.toString());
if (!response.ok) { if (!response.ok) {
throw new Error(`Beam conversion status failed: HTTP ${response.status}`); throw new Error(`BIM conversion status failed: HTTP ${response.status}`);
} }
const payload = (await response.json()) as TBeamConversionStatus; const payload = (await response.json()) as TBeamConversionStatus;
@ -180,16 +318,19 @@ export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachmen
export const uploadBeamModelFile = ( export const uploadBeamModelFile = (
file: File, file: File,
options: { options: {
assetId?: string;
issueId?: string; issueId?: string;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void; onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
projectId?: string; projectId?: string;
uploadedBy?: string;
version?: number;
workspaceSlug?: string; workspaceSlug?: string;
} = {} } = {}
): Promise<TBeamViewerAttachment> => ): Promise<TBeamViewerAttachment> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const type = getBeamModelTypeFromName(file.name); const type = getBeamModelTypeFromName(file.name);
if (!type) { if (!type) {
reject(new Error("Формат модели не поддерживается Beam Viewer")); reject(new Error("Формат модели не поддерживается BIM Viewer"));
return; return;
} }
const directViewerType = getBeamDirectModelTypeFromName(file.name); const directViewerType = getBeamDirectModelTypeFromName(file.name);
@ -201,64 +342,81 @@ export const uploadBeamModelFile = (
"projectId", "projectId",
getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId]) getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId])
); );
if (options.assetId) uploadUrl.searchParams.set("assetId", options.assetId);
if (options.version) uploadUrl.searchParams.set("version", String(options.version));
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl.toString()); xhr.open("POST", uploadUrl.toString());
xhr.setRequestHeader("Content-Type", "application/octet-stream"); xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (event) => { xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable || !options.onUploadProgress) return; if (!event.lengthComputable || !options.onUploadProgress) return;
options.onUploadProgress({ options.onUploadProgress({
loaded: event.loaded, loaded: event.loaded,
progress: event.loaded / event.total, progress: event.loaded / event.total,
total: event.total, total: event.total,
} as AxiosProgressEvent); } as AxiosProgressEvent);
}; });
xhr.onerror = () => reject(new Error("Не удалось загрузить модель в Beam Viewer")); xhr.addEventListener("error", () => reject(new Error("Не удалось загрузить модель в BIM Viewer")));
xhr.onload = () => { xhr.addEventListener("load", () => {
if (xhr.status < 200 || xhr.status >= 300) { if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(xhr.responseText || `Beam Viewer upload failed: HTTP ${xhr.status}`)); reject(new Error(xhr.responseText || `BIM Viewer upload failed: HTTP ${xhr.status}`));
return; return;
} }
try { try {
const response = JSON.parse(xhr.responseText) as { const response = JSON.parse(xhr.responseText) as {
assetId?: string;
conversion?: TBeamViewerAttachment["conversion"]; conversion?: TBeamViewerAttachment["conversion"];
originalFilename?: string;
sha256?: string;
src?: string; src?: string;
uploadedAt?: string;
version?: number;
versionId?: string;
}; };
if (!response.src) { if (!response.src) {
reject(new Error("Beam Viewer не вернул путь к загруженной модели")); reject(new Error("BIM Viewer не вернул путь к загруженной модели"));
return; return;
} }
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`); const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
const baseAttachment: TBeamViewerAttachment = { const baseAttachment: TBeamViewerAttachment = {
assetId: response.assetId ?? options.assetId,
backend: "beam-viewer-local", backend: "beam-viewer-local",
downloadUrl: downloadUrl.toString(), downloadUrl: downloadUrl.toString(),
originalFilename: file.name, originalFilename: response.originalFilename || file.name,
previewAvailable: !!directViewerType, previewAvailable: !!directViewerType,
sha256: response.sha256,
src: downloadUrl.toString(), src: downloadUrl.toString(),
type, type,
uploadedAt: new Date().toISOString(), uploadedBy: options.uploadedBy,
uploadedAt: response.uploadedAt || new Date().toISOString(),
version: response.version ?? options.version ?? 1,
versionId: response.versionId,
}; };
if (!directViewerType) { if (!directViewerType) {
resolve({ const nextAttachment: TBeamViewerAttachment = {
...baseAttachment, ...baseAttachment,
conversion: { conversion: {
...(response.conversion ?? {}), ...response.conversion,
componentTreeRequired: true, componentTreeRequired: true,
message: message:
response.conversion?.message ?? response.conversion?.message ??
"Оригинальный STEP загружен. Preview появится после подготовки GLB и дерева компонентов.", "Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
sourceFormat: type, sourceFormat: type,
status: response.conversion?.status ?? "conversion_required", status: response.conversion?.status ?? "conversion_required",
targetFormat: response.conversion?.targetFormat ?? "glb", targetFormat: response.conversion?.targetFormat ?? "xkt",
}, },
};
resolve({
...nextAttachment,
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
}); });
return; return;
} }
resolve({ const nextAttachment: TBeamViewerAttachment = {
...baseAttachment, ...baseAttachment,
viewerUrl: buildBeamViewerUrl({ viewerUrl: buildBeamViewerUrl({
name: file.name, name: file.name,
@ -266,10 +424,38 @@ export const uploadBeamModelFile = (
src: downloadUrl.toString(), src: downloadUrl.toString(),
type: directViewerType, type: directViewerType,
}), }),
};
resolve({
...nextAttachment,
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
}); });
} catch (_error) { } catch (_error) {
reject(new Error("Beam Viewer вернул некорректный ответ")); reject(new Error("BIM Viewer вернул некорректный ответ"));
} }
}; });
xhr.send(file); xhr.send(file);
}); });
const deleteBeamStorageResource = async (
endpoint: "/api/uploads/asset" | "/api/uploads/version",
payload: unknown
): Promise<void> => {
const response = await fetch(new URL(endpoint, `${getBeamApiBaseUrl()}/`).toString(), {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error((await response.text()) || `BIM storage delete failed: HTTP ${response.status}`);
}
};
export const deleteBeamModelAsset = async (beamViewer: TBeamViewerAttachment): Promise<void> =>
deleteBeamStorageResource("/api/uploads/asset", beamViewer);
export const deleteBeamModelVersion = async (version: TBeamModelVersionRecord): Promise<void> =>
deleteBeamStorageResource("/api/uploads/version", version);

View File

@ -6,22 +6,53 @@
import type { TFileSignedURLResponse } from "../file"; import type { TFileSignedURLResponse } from "../file";
export type TBeamViewerAttachment = { export type TBeamViewerConversion = {
backend: "beam-viewer-local" | "beam-viewer"; artifactSrc?: string;
conversion?: { artifactType?: string;
componentTreeRequired: boolean; componentTreeRequired: boolean;
message?: string; message?: string;
metadataSrc?: string;
size?: number;
sourceFormat: string; sourceFormat: string;
sourceSrc?: string;
status: "conversion_required" | "processing" | "ready" | "failed"; status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat: "glb" | "xkt"; targetFormat: "glb" | "xkt";
}; updatedAt?: string;
};
export type TBeamModelVersionRecord = {
assetId?: string;
conversion?: TBeamViewerConversion;
downloadUrl: string; downloadUrl: string;
originalFilename: string; originalFilename: string;
previewAvailable: boolean; previewAvailable: boolean;
sha256?: string;
size: number;
src: string; src: string;
type: string; type: string;
uploadedBy?: string;
uploadedAt: string; uploadedAt: string;
viewerUrl?: string; version: number;
versionId?: string;
viewerUrl?: string | null;
};
export type TBeamViewerAttachment = {
assetId?: string;
backend: "beam-viewer-local" | "beam-viewer";
conversion?: TBeamViewerConversion;
downloadUrl: string;
originalFilename: string;
previewAvailable: boolean;
sha256?: string;
src: string;
type: string;
uploadedBy?: string;
uploadedAt: string;
version?: number;
versionId?: string;
versions?: TBeamModelVersionRecord[];
viewerUrl?: string | null;
}; };
export type TIssueAttachment = { export type TIssueAttachment = {