chore: checkpoint before cross assistant work
This commit is contained in:
parent
2ad9e7f85c
commit
14caa50088
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -211,8 +211,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
|||
</div>
|
||||
{isListView ? (
|
||||
<div className="nodedc-attachments-list-scroll overflow-auto rounded-2xl bg-surface-2/40">
|
||||
<div className="min-w-[760px]">
|
||||
<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="min-w-[840px]">
|
||||
<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>
|
||||
|
|
@ -222,7 +222,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
|||
{uploadStatus?.map((status) => (
|
||||
<div
|
||||
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="truncate font-semibold text-secondary">{status.name}</div>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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<string | null>(null);
|
||||
const [isVersionUploading, setIsVersionUploading] = 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 =
|
||||
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<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[] = [
|
||||
...(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 ? (
|
||||
<input
|
||||
ref={versionUploadInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
disabled={isVersionUploading}
|
||||
onChange={handleVersionFileChange}
|
||||
/>
|
||||
) : 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 (
|
||||
<>
|
||||
<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
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-3 text-left"
|
||||
|
|
@ -321,6 +478,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
if (modelViewerUrl) openModelViewer();
|
||||
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">
|
||||
{isBeamModel ? (
|
||||
|
|
@ -341,6 +505,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
<ImageIcon className="size-5" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="min-w-0">
|
||||
<Tooltip tooltipContent={fullFileName} isMobile={isMobile}>
|
||||
<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 className="size-1 rounded-full bg-layer-1" />
|
||||
<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>
|
||||
</button>
|
||||
|
|
@ -374,6 +521,40 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
<div className="text-secondary">{versionLabel}</div>
|
||||
<div className="truncate text-tertiary">{uploadedBy}</div>
|
||||
<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) && (
|
||||
<Tooltip
|
||||
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
|
||||
|
|
@ -446,12 +627,24 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
size={attachment.attributes.size}
|
||||
/>
|
||||
</ModalCore>
|
||||
<BeamVersionHistoryModal
|
||||
currentVersionId={currentVersionId}
|
||||
deletingVersionKey={deletingVersionKey}
|
||||
fileName={fullFileName}
|
||||
getVersionAuthorName={getVersionAuthorName}
|
||||
isOpen={isVersionHistoryOpen}
|
||||
onClose={() => setIsVersionHistoryOpen(false)}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onOpenVersion={openBeamVersionViewer}
|
||||
versions={beamVersions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -571,6 +764,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
size={attachment.attributes.size}
|
||||
/>
|
||||
</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">
|
||||
{isBeamConversionFailed
|
||||
? beamStatusTooltipContent
|
||||
: "Оригинальный STEP загружен в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов."}
|
||||
: "Оригинальный STEP загружен в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов."}
|
||||
</div>
|
||||
</div>
|
||||
{modelDownloadUrl && (
|
||||
|
|
@ -702,3 +906,150 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -574,7 +574,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
|||
} 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 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)]">
|
||||
|
|
@ -617,10 +617,11 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* eslint-disable-next-line react/iframe-missing-sandbox -- BIM Viewer needs its scripted same-origin context for controls and settings. */}
|
||||
<iframe
|
||||
key={beamPeekViewer.viewerUrl}
|
||||
src={beamPeekViewer.viewerUrl}
|
||||
title={`Beam Viewer: ${beamPeekViewer.fileName}`}
|
||||
title={`BIM Viewer: ${beamPeekViewer.fileName}`}
|
||||
tabIndex={-1}
|
||||
className="h-full w-full border-0 ring-0 outline-none focus:ring-0 focus:outline-none"
|
||||
allow="fullscreen"
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const StatCard = (props: {
|
|||
return (
|
||||
<div className="nodedc-settings-card p-5">
|
||||
<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
|
||||
className={cn(
|
||||
"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" />
|
||||
</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>
|
||||
);
|
||||
|
|
@ -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="flex min-w-0 flex-col">
|
||||
<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>
|
||||
<StorageValue>{formatCount(project.file_count)}</StorageValue>
|
||||
<StorageValue>{formatCount(project.blob_count)}</StorageValue>
|
||||
|
|
@ -106,7 +106,9 @@ const ProjectStorageRow = (props: {
|
|||
<StorageValue accent>{formatBytes(project.dedup_savings)}</StorageValue>
|
||||
<ProjectQuotaControl project={project} workspaceSlug={workspaceSlug} onRefresh={onRefresh} />
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -125,7 +127,7 @@ const StorageValue = (props: { accent?: boolean; children: string; warning?: boo
|
|||
);
|
||||
|
||||
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>Blob</span>
|
||||
|
|
@ -173,7 +175,7 @@ const ProjectQuotaControl = (props: {
|
|||
title: "Квота обновлена",
|
||||
message: isEnabled ? "Лимит проекта сохранен." : "Лимит проекта отключен.",
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Квота не сохранена",
|
||||
|
|
@ -200,6 +202,7 @@ const ProjectQuotaControl = (props: {
|
|||
<Database className="size-3.5" />
|
||||
</button>
|
||||
<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">
|
||||
<input
|
||||
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>
|
||||
</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
|
||||
className={cn(
|
||||
"mt-1 truncate text-[11px] text-tertiary",
|
||||
|
|
@ -224,10 +233,9 @@ const ProjectQuotaControl = (props: {
|
|||
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>
|
||||
<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="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
|
||||
<div className="mt-3 text-2xl font-semibold tracking-normal text-primary">{value}</div>
|
||||
<div className="text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</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>
|
||||
<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)
|
||||
);
|
||||
|
||||
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 runMaintenance = async (action: TWorkspaceStorageMaintenanceAction) => {
|
||||
|
|
@ -313,7 +321,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
|||
title: "Хранилище обновлено",
|
||||
message: getMaintenanceResultMessage(result.action, result),
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Действие не выполнено",
|
||||
|
|
@ -333,14 +341,14 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
|||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="nodedc-settings-card h-36 animate-pulse" />
|
||||
{["logical", "physical", "dedup", "problems"].map((skeletonKey) => (
|
||||
<div key={skeletonKey} className="nodedc-settings-card h-36 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
|
@ -415,7 +423,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
|||
<section>
|
||||
<div className="mb-4 flex items-end justify-between gap-4">
|
||||
<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>
|
||||
|
|
@ -470,7 +478,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
|||
<section className="nodedc-settings-card p-5">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<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>
|
||||
</div>
|
||||
<div className="nodedc-settings-chip flex min-h-0 items-center px-4 py-2 text-12 font-medium text-secondary">
|
||||
|
|
|
|||
|
|
@ -17,8 +17,15 @@ import type {
|
|||
import { EIssueServiceType } from "@plane/types";
|
||||
// services
|
||||
import {
|
||||
deleteBeamModelAsset,
|
||||
deleteBeamModelVersion,
|
||||
getBeamModelVersionRecords,
|
||||
getBeamModelMimeType,
|
||||
getBeamViewerAttachment,
|
||||
getBeamViewerVersionNumber,
|
||||
isBeamModelFile,
|
||||
mergeBeamModelVersionRecords,
|
||||
type TBeamModelVersionRecord,
|
||||
type TBeamViewerAttachment,
|
||||
uploadBeamModelFile,
|
||||
} from "@/helpers/beam-viewer";
|
||||
|
|
@ -81,11 +88,12 @@ export class IssueAttachmentService extends APIService {
|
|||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
beamViewer: TBeamViewerAttachment
|
||||
beamViewer: TBeamViewerAttachment,
|
||||
attributes: Record<string, unknown> = {}
|
||||
): Promise<TIssueAttachment> {
|
||||
return this.patch(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
|
||||
{ beamViewer }
|
||||
{ ...attributes, beamViewer }
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.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(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
|
|
@ -167,8 +276,16 @@ export class IssueAttachmentService extends APIService {
|
|||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
assetId: string
|
||||
assetId: string,
|
||||
attachment?: 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(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${assetId}/`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const getAttachmentTimestamp = (attachment: TIssueAttachment) =>
|
|||
new Date(attachment.created_at ?? attachment.updated_at ?? 0).getTime();
|
||||
|
||||
const sortAttachmentIdsByNewest = (attachmentIds: string[], attachmentMap: TIssueAttachmentMap): string[] =>
|
||||
[...attachmentIds].sort((firstId, secondId) => {
|
||||
attachmentIds.toSorted((firstId, secondId) => {
|
||||
const firstAttachment = attachmentMap[firstId];
|
||||
const secondAttachment = attachmentMap[secondId];
|
||||
if (!firstAttachment || !secondAttachment) return 0;
|
||||
|
|
@ -232,11 +232,13 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
|||
|
||||
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {
|
||||
const issueAttachmentService = await this.getIssueAttachmentService();
|
||||
const attachment = this.attachmentMap[attachmentId];
|
||||
const response = await issueAttachmentService.deleteIssueAttachment(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachmentId
|
||||
attachmentId,
|
||||
attachment
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,57 @@
|
|||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { TIssueAttachment } from "@plane/types";
|
||||
|
||||
export type TBeamViewerAttachment = {
|
||||
backend: "beam-viewer-local" | "beam-viewer";
|
||||
conversion?: {
|
||||
export type TBeamViewerConversion = {
|
||||
artifactSrc?: string;
|
||||
artifactType?: string;
|
||||
componentTreeRequired: boolean;
|
||||
message?: string;
|
||||
metadataSrc?: string;
|
||||
size?: number;
|
||||
sourceFormat: string;
|
||||
sourceSrc?: string;
|
||||
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||
targetFormat: "glb" | "xkt";
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type TBeamModelVersionRecord = {
|
||||
assetId?: string;
|
||||
conversion?: TBeamViewerConversion;
|
||||
downloadUrl: string;
|
||||
originalFilename: string;
|
||||
previewAvailable: boolean;
|
||||
sha256?: string;
|
||||
size: number;
|
||||
src: string;
|
||||
type: string;
|
||||
uploadedBy?: 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 = {
|
||||
assetId?: string;
|
||||
artifactSrc?: string;
|
||||
artifactType?: string;
|
||||
artifactUrl?: string;
|
||||
|
|
@ -34,11 +61,15 @@ export type TBeamConversionStatus = {
|
|||
message?: string;
|
||||
metadataSrc?: string;
|
||||
metadataUrl?: string;
|
||||
sha256?: string;
|
||||
sourceFormat?: string;
|
||||
sourceSrc?: string;
|
||||
size?: number;
|
||||
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||
targetFormat?: "glb" | "xkt";
|
||||
updatedAt?: string;
|
||||
version?: number;
|
||||
versionId?: string;
|
||||
};
|
||||
|
||||
export const BEAM_VIEWER_OPEN_EVENT = "nodedc:beam-viewer-open";
|
||||
|
|
@ -160,13 +191,120 @@ export const getBeamViewerAttachment = (attachment: TIssueAttachment | undefined
|
|||
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> => {
|
||||
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
|
||||
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
|
||||
|
||||
const response = await fetch(statusUrl.toString());
|
||||
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;
|
||||
|
|
@ -180,16 +318,19 @@ export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachmen
|
|||
export const uploadBeamModelFile = (
|
||||
file: File,
|
||||
options: {
|
||||
assetId?: string;
|
||||
issueId?: string;
|
||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
|
||||
projectId?: string;
|
||||
uploadedBy?: string;
|
||||
version?: number;
|
||||
workspaceSlug?: string;
|
||||
} = {}
|
||||
): Promise<TBeamViewerAttachment> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const type = getBeamModelTypeFromName(file.name);
|
||||
if (!type) {
|
||||
reject(new Error("Формат модели не поддерживается Beam Viewer"));
|
||||
reject(new Error("Формат модели не поддерживается BIM Viewer"));
|
||||
return;
|
||||
}
|
||||
const directViewerType = getBeamDirectModelTypeFromName(file.name);
|
||||
|
|
@ -201,64 +342,81 @@ export const uploadBeamModelFile = (
|
|||
"projectId",
|
||||
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();
|
||||
xhr.open("POST", uploadUrl.toString());
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.upload.onprogress = (event) => {
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable || !options.onUploadProgress) return;
|
||||
options.onUploadProgress({
|
||||
loaded: event.loaded,
|
||||
progress: event.loaded / event.total,
|
||||
total: event.total,
|
||||
} as AxiosProgressEvent);
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("Не удалось загрузить модель в Beam Viewer"));
|
||||
xhr.onload = () => {
|
||||
});
|
||||
xhr.addEventListener("error", () => reject(new Error("Не удалось загрузить модель в BIM Viewer")));
|
||||
xhr.addEventListener("load", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText) as {
|
||||
assetId?: string;
|
||||
conversion?: TBeamViewerAttachment["conversion"];
|
||||
originalFilename?: string;
|
||||
sha256?: string;
|
||||
src?: string;
|
||||
uploadedAt?: string;
|
||||
version?: number;
|
||||
versionId?: string;
|
||||
};
|
||||
if (!response.src) {
|
||||
reject(new Error("Beam Viewer не вернул путь к загруженной модели"));
|
||||
reject(new Error("BIM Viewer не вернул путь к загруженной модели"));
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
|
||||
const baseAttachment: TBeamViewerAttachment = {
|
||||
assetId: response.assetId ?? options.assetId,
|
||||
backend: "beam-viewer-local",
|
||||
downloadUrl: downloadUrl.toString(),
|
||||
originalFilename: file.name,
|
||||
originalFilename: response.originalFilename || file.name,
|
||||
previewAvailable: !!directViewerType,
|
||||
sha256: response.sha256,
|
||||
src: downloadUrl.toString(),
|
||||
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) {
|
||||
resolve({
|
||||
const nextAttachment: TBeamViewerAttachment = {
|
||||
...baseAttachment,
|
||||
conversion: {
|
||||
...(response.conversion ?? {}),
|
||||
...response.conversion,
|
||||
componentTreeRequired: true,
|
||||
message:
|
||||
response.conversion?.message ??
|
||||
"Оригинальный STEP загружен. Preview появится после подготовки GLB и дерева компонентов.",
|
||||
"Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
|
||||
sourceFormat: type,
|
||||
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;
|
||||
}
|
||||
|
||||
resolve({
|
||||
const nextAttachment: TBeamViewerAttachment = {
|
||||
...baseAttachment,
|
||||
viewerUrl: buildBeamViewerUrl({
|
||||
name: file.name,
|
||||
|
|
@ -266,10 +424,38 @@ export const uploadBeamModelFile = (
|
|||
src: downloadUrl.toString(),
|
||||
type: directViewerType,
|
||||
}),
|
||||
};
|
||||
|
||||
resolve({
|
||||
...nextAttachment,
|
||||
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
|
||||
});
|
||||
} catch (_error) {
|
||||
reject(new Error("Beam Viewer вернул некорректный ответ"));
|
||||
reject(new Error("BIM Viewer вернул некорректный ответ"));
|
||||
}
|
||||
};
|
||||
});
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -6,22 +6,53 @@
|
|||
|
||||
import type { TFileSignedURLResponse } from "../file";
|
||||
|
||||
export type TBeamViewerAttachment = {
|
||||
backend: "beam-viewer-local" | "beam-viewer";
|
||||
conversion?: {
|
||||
export type TBeamViewerConversion = {
|
||||
artifactSrc?: string;
|
||||
artifactType?: string;
|
||||
componentTreeRequired: boolean;
|
||||
message?: string;
|
||||
metadataSrc?: string;
|
||||
size?: number;
|
||||
sourceFormat: string;
|
||||
sourceSrc?: string;
|
||||
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||
targetFormat: "glb" | "xkt";
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type TBeamModelVersionRecord = {
|
||||
assetId?: string;
|
||||
conversion?: TBeamViewerConversion;
|
||||
downloadUrl: string;
|
||||
originalFilename: string;
|
||||
previewAvailable: boolean;
|
||||
sha256?: string;
|
||||
size: number;
|
||||
src: string;
|
||||
type: string;
|
||||
uploadedBy?: 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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue