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")
|
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"])
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -322,25 +479,33 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
else setIsPreviewOpen(true);
|
else setIsPreviewOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
|
<Tooltip
|
||||||
{isBeamModel ? (
|
tooltipContent={isBeamModel ? beamStatusIconTooltipContent : fullFileName}
|
||||||
isBeamConversionFailed ? (
|
isMobile={isMobile}
|
||||||
<AlertTriangle className="text-red-400 size-5" />
|
position={isBeamModel ? "left" : "top"}
|
||||||
) : isBeamAwaitingPreview ? (
|
sideOffset={isBeamModel ? 6 : 10}
|
||||||
<Spinner
|
className={isBeamModel ? "z-[220]" : ""}
|
||||||
height="20px"
|
>
|
||||||
width="20px"
|
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
|
||||||
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
|
{isBeamModel ? (
|
||||||
/>
|
isBeamConversionFailed ? (
|
||||||
|
<AlertTriangle className="text-red-400 size-5" />
|
||||||
|
) : isBeamAwaitingPreview ? (
|
||||||
|
<Spinner
|
||||||
|
height="20px"
|
||||||
|
width="20px"
|
||||||
|
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||||
|
)
|
||||||
|
) : previewType === "file" ? (
|
||||||
|
getFileIcon(fileExtension, 18)
|
||||||
) : (
|
) : (
|
||||||
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
|
<ImageIcon className="size-5" />
|
||||||
)
|
)}
|
||||||
) : previewType === "file" ? (
|
</div>
|
||||||
getFileIcon(fileExtension, 18)
|
</Tooltip>
|
||||||
) : (
|
|
||||||
<ImageIcon className="size-5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,34 +202,40 @@ 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">
|
||||||
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
|
{isEnabled ? (
|
||||||
<input
|
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
|
||||||
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
|
<input
|
||||||
disabled={isSaving}
|
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
|
||||||
inputMode="numeric"
|
disabled={isSaving}
|
||||||
min={1}
|
inputMode="numeric"
|
||||||
onChange={(event) => setQuotaMb(event.target.value)}
|
min={1}
|
||||||
onKeyDown={(event) => {
|
onChange={(event) => setQuotaMb(event.target.value)}
|
||||||
if (event.key === "Enter") {
|
onKeyDown={(event) => {
|
||||||
saveQuota();
|
if (event.key === "Enter") {
|
||||||
}
|
saveQuota();
|
||||||
}}
|
}
|
||||||
type="number"
|
}}
|
||||||
value={quotaMb}
|
type="number"
|
||||||
/>
|
value={quotaMb}
|
||||||
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
|
/>
|
||||||
</div>
|
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
|
||||||
<div
|
</div>
|
||||||
className={cn(
|
) : (
|
||||||
"mt-1 truncate text-[11px] text-tertiary",
|
<div className="rounded-full bg-white/6 px-3 py-1.5 text-center text-12 font-medium text-primary">
|
||||||
project.quota_warning && "text-yellow-200",
|
Без лимита
|
||||||
project.quota_exceeded && "text-red-200"
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
{project.quota_enabled && (
|
||||||
{project.quota_enabled
|
<div
|
||||||
? `${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`
|
className={cn(
|
||||||
: "без лимита"}
|
"mt-1 truncate text-[11px] text-tertiary",
|
||||||
</div>
|
project.quota_warning && "text-yellow-200",
|
||||||
|
project.quota_exceeded && "text-red-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{`${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`}
|
||||||
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -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}/`
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
artifactSrc?: string;
|
||||||
conversion?: {
|
artifactType?: string;
|
||||||
artifactSrc?: string;
|
componentTreeRequired: boolean;
|
||||||
artifactType?: string;
|
message?: string;
|
||||||
componentTreeRequired: boolean;
|
metadataSrc?: string;
|
||||||
message?: string;
|
size?: number;
|
||||||
metadataSrc?: string;
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
sourceFormat: string;
|
metadataSrc?: string;
|
||||||
status: "conversion_required" | "processing" | "ready" | "failed";
|
size?: number;
|
||||||
targetFormat: "glb" | "xkt";
|
sourceFormat: string;
|
||||||
};
|
sourceSrc?: string;
|
||||||
|
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||||
|
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 = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue