1173 lines
44 KiB
TypeScript
1173 lines
44 KiB
TypeScript
"use client";
|
||
|
||
/* eslint-disable @next/next/no-img-element */
|
||
import dynamic from "next/dynamic";
|
||
import {
|
||
Box,
|
||
CheckCircle2,
|
||
Download,
|
||
File,
|
||
FileSpreadsheet,
|
||
ImageIcon,
|
||
Loader2,
|
||
Maximize2,
|
||
Minimize2,
|
||
Package,
|
||
RotateCcw,
|
||
ShieldAlert,
|
||
Undo2,
|
||
X,
|
||
} from "lucide-react";
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useId,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import {
|
||
buildOpenClawArtifactProxyUrl,
|
||
getOpenClaw3DArtifactVariant,
|
||
getOpenClaw3DArtifactBundle,
|
||
type OpenClawArtifact,
|
||
} from "@/lib/openclaw-chat/artifacts";
|
||
import {
|
||
getOpenClawPackagingInteractionParts,
|
||
normalizeOpenClawModelInteractionManifest,
|
||
type OpenClawModelInteractionManifest,
|
||
type OpenClawModelInteractionPart,
|
||
} from "@/lib/openclaw-chat/model-interaction";
|
||
import {
|
||
isOpenClawProductionDrawingReleased,
|
||
normalizeOpenClawProductionDrawingManifest,
|
||
type OpenClawProductionDrawingManifest,
|
||
} from "@/lib/openclaw-chat/production-drawing";
|
||
import {
|
||
clearOpenClaw3DViewerSession,
|
||
readOpenClaw3DViewerSession,
|
||
writeOpenClaw3DViewerSession,
|
||
} from "@/lib/openclaw-chat/viewer-session";
|
||
import type { OpenClawModelCameraState } from "@/components/canvas/chat-panel/OpenClawInteractiveModelViewer";
|
||
|
||
const Model3DViewer = dynamic(
|
||
() =>
|
||
import("@/components/canvas/model3d/Model3DViewer").then(
|
||
(module) => module.Model3DViewer
|
||
),
|
||
{
|
||
ssr: false,
|
||
loading: () => <ViewerLoading />,
|
||
}
|
||
);
|
||
|
||
const OpenClawInteractiveModelViewer = dynamic(
|
||
() =>
|
||
import(
|
||
"@/components/canvas/chat-panel/OpenClawInteractiveModelViewer"
|
||
).then((module) => module.OpenClawInteractiveModelViewer),
|
||
{
|
||
ssr: false,
|
||
loading: () => <ViewerLoading />,
|
||
}
|
||
);
|
||
|
||
const ACTIVE_VIEWER_EVENT = "openclaw-active-3d-viewer";
|
||
type OpenClaw3DViewMode = "product" | "packaging";
|
||
|
||
function ViewerLoading() {
|
||
return (
|
||
<div className="flex h-full w-full items-center justify-center bg-zinc-950 text-zinc-400">
|
||
<Loader2 className="h-6 w-6 animate-spin" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function supportsWebGl() {
|
||
try {
|
||
const canvas = document.createElement("canvas");
|
||
return Boolean(
|
||
canvas.getContext("webgl2") ||
|
||
canvas.getContext("webgl") ||
|
||
canvas.getContext("experimental-webgl")
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function getViewerSessionStorage() {
|
||
try {
|
||
return window.sessionStorage;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function formatDimensions(part: OpenClawModelInteractionPart) {
|
||
const dimensions = part.dimensionsMm;
|
||
if (!dimensions) return null;
|
||
return `${dimensions.x} x ${dimensions.y} x ${dimensions.z} mm`;
|
||
}
|
||
|
||
function formatArtifactDimensions(artifact: OpenClawArtifact) {
|
||
const dimensions = artifact.dimensionsMm;
|
||
if (!dimensions) return null;
|
||
return `${dimensions.x} x ${dimensions.y} x ${dimensions.z} mm`;
|
||
}
|
||
|
||
function OpenClawProductionDrawingPanel({
|
||
artifacts,
|
||
chatId,
|
||
messageId,
|
||
}: {
|
||
artifacts: Array<{ artifact: OpenClawArtifact; index: number }>;
|
||
chatId: string;
|
||
messageId: string;
|
||
}) {
|
||
const manifestArtifact = artifacts.find(
|
||
({ artifact }) =>
|
||
artifact.artifactRole.toLowerCase() === "production_drawing_manifest"
|
||
);
|
||
const pdfs = artifacts.filter(
|
||
({ artifact }) =>
|
||
artifact.artifactRole.toLowerCase() === "production_drawing_pdf"
|
||
);
|
||
const dxfBundles = artifacts.filter(
|
||
({ artifact }) =>
|
||
artifact.artifactRole.toLowerCase() === "production_drawing_dxf_bundle"
|
||
);
|
||
const [manifest, setManifest] =
|
||
useState<OpenClawProductionDrawingManifest | null>();
|
||
const [manifestError, setManifestError] = useState(false);
|
||
|
||
const proxyUrl = useCallback(
|
||
(index: number, download = false) =>
|
||
buildOpenClawArtifactProxyUrl({
|
||
chatId,
|
||
messageId,
|
||
artifactIndex: index,
|
||
download,
|
||
}),
|
||
[chatId, messageId]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!manifestArtifact) {
|
||
setManifest(null);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
setManifestError(false);
|
||
fetch(proxyUrl(manifestArtifact.index), {
|
||
cache: "no-store",
|
||
signal: controller.signal,
|
||
})
|
||
.then(async (response) => {
|
||
if (!response.ok) throw new Error("production manifest unavailable");
|
||
const normalized = normalizeOpenClawProductionDrawingManifest(
|
||
await response.json()
|
||
);
|
||
if (!normalized) throw new Error("invalid production manifest");
|
||
setManifest(normalized);
|
||
})
|
||
.catch(() => {
|
||
if (controller.signal.aborted) return;
|
||
setManifest(null);
|
||
setManifestError(true);
|
||
});
|
||
return () => controller.abort();
|
||
}, [manifestArtifact, proxyUrl]);
|
||
|
||
if (!manifestArtifact && pdfs.length === 0 && dxfBundles.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const isReleased = isOpenClawProductionDrawingReleased(manifest || null);
|
||
const awaitingEngineeringRelease = Boolean(
|
||
manifest?.automatedValidationStatus === "production_ready" &&
|
||
manifest.approvalStatus === "awaiting_engineering_release"
|
||
);
|
||
const statusLabel = isReleased
|
||
? "生产图已签批发布"
|
||
: awaitingEngineeringRelease
|
||
? "自动检查通过,待结构工程师签批"
|
||
: "未批准不得下料";
|
||
|
||
return (
|
||
<section className="mt-3 overflow-hidden rounded-md border border-black/10 dark:border-white/10">
|
||
<div
|
||
className={
|
||
isReleased
|
||
? "flex items-start gap-2 bg-emerald-50 px-3 py-2.5 text-emerald-800 dark:bg-emerald-950/35 dark:text-emerald-300"
|
||
: awaitingEngineeringRelease
|
||
? "flex items-start gap-2 bg-amber-50 px-3 py-2.5 text-amber-800 dark:bg-amber-950/35 dark:text-amber-300"
|
||
: "flex items-start gap-2 bg-red-50 px-3 py-2.5 text-red-700 dark:bg-red-950/35 dark:text-red-300"
|
||
}
|
||
>
|
||
{isReleased ? (
|
||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||
) : (
|
||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||
)}
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-semibold">{statusLabel}</p>
|
||
{manifestError ? (
|
||
<p className="mt-1 text-[11px] opacity-80">
|
||
发布清单读取失败,已按未发布状态保护。
|
||
</p>
|
||
) : null}
|
||
{!manifestArtifact ? (
|
||
<p className="mt-1 text-[11px] opacity-80">
|
||
缺少生产图发布清单,不能确认签批状态。
|
||
</p>
|
||
) : null}
|
||
{manifest?.releaseBlockers.length ? (
|
||
<ul className="mt-1 space-y-0.5 text-[11px] opacity-90">
|
||
{manifest.releaseBlockers.map((blocker, index) => (
|
||
<li key={`${blocker}:${index}`}>· {blocker}</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
{isReleased ? (
|
||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] opacity-85">
|
||
{manifest?.version ? <span>版本:{manifest.version}</span> : null}
|
||
{manifest?.approvedBy ? (
|
||
<span>签批人:{manifest.approvedBy}</span>
|
||
) : null}
|
||
{manifest?.fileSha256 ? (
|
||
<span className="break-all">SHA-256:{manifest.fileSha256}</span>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{pdfs.length > 0 || dxfBundles.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2 px-3 py-2.5">
|
||
{pdfs.map(({ artifact, index }) => (
|
||
<a
|
||
key={`${artifact.downloadUrl}:${index}`}
|
||
className="inline-flex h-9 items-center gap-2 rounded-md border border-black/10 px-3 text-xs font-medium text-[#535d6d] hover:bg-black/5 dark:border-white/15 dark:text-zinc-300 dark:hover:bg-white/8"
|
||
href={proxyUrl(index)}
|
||
rel="noreferrer"
|
||
target="_blank"
|
||
>
|
||
<File className="h-4 w-4" />
|
||
预览/下载生产工艺图
|
||
</a>
|
||
))}
|
||
{dxfBundles.map(({ artifact, index }) => (
|
||
<a
|
||
key={`${artifact.downloadUrl}:${index}`}
|
||
className="inline-flex h-9 items-center gap-2 rounded-md border border-black/10 px-3 text-xs font-medium text-[#535d6d] hover:bg-black/5 dark:border-white/15 dark:text-zinc-300 dark:hover:bg-white/8"
|
||
href={proxyUrl(index, true)}
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
下载DXF图包
|
||
</a>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function OpenClawSupportingArtifacts({
|
||
artifacts,
|
||
chatId,
|
||
messageId,
|
||
}: {
|
||
artifacts: OpenClawArtifact[];
|
||
chatId: string;
|
||
messageId: string;
|
||
}) {
|
||
const withIndex = artifacts.map((artifact, index) => ({ artifact, index }));
|
||
const overview = withIndex.find(
|
||
({ artifact }) =>
|
||
artifact.artifactRole.toLowerCase() === "bom_component_overview"
|
||
);
|
||
const thumbnails = withIndex.filter(
|
||
({ artifact }) =>
|
||
artifact.artifactRole.toLowerCase() === "bom_component_thumbnail"
|
||
);
|
||
const workbooks = withIndex.filter(
|
||
({ artifact }) => artifact.artifactRole.toLowerCase() === "bom_workbook"
|
||
);
|
||
const advanced = withIndex.filter(({ artifact }) => {
|
||
const role = artifact.artifactRole.toLowerCase();
|
||
return (
|
||
artifact.displayMode.toLowerCase() === "advanced" &&
|
||
role !== "model_interaction_manifest" &&
|
||
!role.startsWith("production_drawing_")
|
||
);
|
||
});
|
||
const downloads = withIndex.filter(({ artifact }) => {
|
||
const role = artifact.artifactRole.toLowerCase();
|
||
return (
|
||
artifact.displayMode.toLowerCase() === "download" &&
|
||
role !== "bom_workbook" &&
|
||
role !== "model_interaction_manifest" &&
|
||
role !== "production_drawing_manifest" &&
|
||
role !== "production_drawing_pdf" &&
|
||
role !== "production_drawing_dxf_bundle"
|
||
);
|
||
});
|
||
if (
|
||
!overview &&
|
||
thumbnails.length === 0 &&
|
||
workbooks.length === 0 &&
|
||
downloads.length === 0 &&
|
||
advanced.length === 0 &&
|
||
!withIndex.some(({ artifact }) =>
|
||
artifact.artifactRole.toLowerCase().startsWith("production_drawing_")
|
||
)
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
const proxyUrl = (index: number, download = false) =>
|
||
buildOpenClawArtifactProxyUrl({
|
||
chatId,
|
||
messageId,
|
||
artifactIndex: index,
|
||
download,
|
||
});
|
||
|
||
return (
|
||
<section className="mt-3 w-full max-w-[760px] border-t border-black/8 pt-3 dark:border-white/10">
|
||
{overview ? (
|
||
<figure className="overflow-hidden rounded-lg border border-black/10 bg-white dark:border-white/10 dark:bg-zinc-950">
|
||
<img
|
||
alt={overview.artifact.displayName || "配件图总览"}
|
||
className="max-h-[460px] w-full object-contain"
|
||
loading="lazy"
|
||
src={proxyUrl(overview.index)}
|
||
/>
|
||
<figcaption className="border-t border-black/8 px-3 py-2 text-xs font-medium text-[#535d6d] dark:border-white/10 dark:text-zinc-300">
|
||
{overview.artifact.displayName || "配件图总览"}
|
||
</figcaption>
|
||
</figure>
|
||
) : null}
|
||
|
||
{thumbnails.length > 0 ? (
|
||
<div className="mt-3">
|
||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-[#535d6d] dark:text-zinc-300">
|
||
<ImageIcon className="h-4 w-4" />
|
||
<span>配件图对照</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||
{thumbnails.map(({ artifact, index }) => {
|
||
const dimensions = formatArtifactDimensions(artifact);
|
||
return (
|
||
<figure
|
||
key={`${artifact.partId || artifact.filename}:${index}`}
|
||
className="min-w-0 overflow-hidden rounded-md border border-black/10 bg-white dark:border-white/10 dark:bg-zinc-950"
|
||
>
|
||
<img
|
||
alt={artifact.displayName || artifact.partName || "配件图"}
|
||
className="aspect-square w-full object-contain"
|
||
loading="lazy"
|
||
src={proxyUrl(index)}
|
||
/>
|
||
<figcaption className="border-t border-black/8 px-2.5 py-2 dark:border-white/10">
|
||
<p className="truncate text-xs font-medium text-[#303744] dark:text-zinc-200">
|
||
{[artifact.partId, artifact.partName]
|
||
.filter(Boolean)
|
||
.join(" · ") || artifact.displayName}
|
||
</p>
|
||
{dimensions ? (
|
||
<p className="mt-0.5 truncate text-[10px] text-[#929baa] dark:text-zinc-500">
|
||
{dimensions}
|
||
</p>
|
||
) : null}
|
||
</figcaption>
|
||
</figure>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{workbooks.length > 0 || downloads.length > 0 ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{workbooks.map(({ artifact, index }) => (
|
||
<a
|
||
key={`${artifact.downloadUrl}:${index}`}
|
||
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#202631] px-3 text-xs font-medium text-white hover:bg-[#11151c] dark:bg-[#3ecf8e] dark:text-[#07120d] dark:hover:bg-[#55dda0]"
|
||
href={proxyUrl(index, true)}
|
||
>
|
||
<FileSpreadsheet className="h-4 w-4" />
|
||
{artifact.displayName || "下载带配件图BOM与成本报价"}
|
||
</a>
|
||
))}
|
||
{downloads.map(({ artifact, index }) => (
|
||
<a
|
||
key={`${artifact.downloadUrl}:${index}`}
|
||
className="inline-flex h-9 items-center gap-2 rounded-md border border-black/10 px-3 text-xs font-medium text-[#535d6d] hover:bg-black/5 dark:border-white/15 dark:text-zinc-300 dark:hover:bg-white/8"
|
||
href={proxyUrl(index, true)}
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
{artifact.displayName || artifact.filename}
|
||
</a>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<OpenClawProductionDrawingPanel
|
||
artifacts={withIndex}
|
||
chatId={chatId}
|
||
messageId={messageId}
|
||
/>
|
||
|
||
{advanced.length > 0 ? (
|
||
<details className="mt-3 border-t border-black/8 pt-2 dark:border-white/10">
|
||
<summary className="cursor-pointer text-xs font-medium text-[#687386] dark:text-zinc-400">
|
||
高级工程文件({advanced.length})
|
||
</summary>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{advanced.map(({ artifact, index }) => (
|
||
<a
|
||
key={`${artifact.downloadUrl}:${index}`}
|
||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-black/10 px-2.5 text-[11px] text-[#687386] hover:bg-black/5 dark:border-white/15 dark:text-zinc-400 dark:hover:bg-white/8"
|
||
href={proxyUrl(index, true)}
|
||
>
|
||
<File className="h-3.5 w-3.5" />
|
||
{artifact.displayName || artifact.filename}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</details>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function OpenClawModel3DPreview({
|
||
artifacts,
|
||
chatId,
|
||
messageId,
|
||
}: {
|
||
artifacts: OpenClawArtifact[];
|
||
chatId: string;
|
||
messageId: string;
|
||
}) {
|
||
const viewerId = useId();
|
||
const bundle = useMemo(
|
||
() => getOpenClaw3DArtifactBundle(artifacts),
|
||
[artifacts]
|
||
);
|
||
const primaryModel = bundle?.model || null;
|
||
const packagingModel =
|
||
bundle && bundle.packagingModelIndex >= 0
|
||
? artifacts[bundle.packagingModelIndex] || null
|
||
: null;
|
||
const primaryIsPackaging =
|
||
primaryModel && getOpenClaw3DArtifactVariant(primaryModel) === "packaging";
|
||
const productModel = primaryIsPackaging ? null : primaryModel;
|
||
const [viewMode, setViewMode] = useState<OpenClaw3DViewMode>(
|
||
productModel ? "product" : "packaging"
|
||
);
|
||
const model =
|
||
viewMode === "packaging" && packagingModel
|
||
? packagingModel
|
||
: productModel || packagingModel;
|
||
const [isActive, setIsActive] = useState(false);
|
||
const [webGlUnavailable, setWebGlUnavailable] = useState(false);
|
||
const [resetViewToken, setResetViewToken] = useState(0);
|
||
const [resetSelectedToken, setResetSelectedToken] = useState(0);
|
||
const [resetAllToken, setResetAllToken] = useState(0);
|
||
const [manifest, setManifest] =
|
||
useState<OpenClawModelInteractionManifest | null>();
|
||
const [manifestError, setManifestError] = useState<string | null>(null);
|
||
const [selectedPart, setSelectedPart] =
|
||
useState<OpenClawModelInteractionPart | null>(null);
|
||
const [selectedPackagingPart, setSelectedPackagingPart] =
|
||
useState<OpenClawModelInteractionPart | null>(null);
|
||
const [matchState, setMatchState] = useState({ matched: 0, total: 0 });
|
||
const [explodeAmount, setExplodeAmount] = useState(0);
|
||
const [packagingExplodeAmount, setPackagingExplodeAmount] = useState(1);
|
||
const [transformRequest, setTransformRequest] = useState({
|
||
actionId: null as string | null,
|
||
amount: productModel ? 0 : 1,
|
||
animate: false,
|
||
durationMs: 700,
|
||
nonce: 0,
|
||
});
|
||
const hasRestoredSessionRef = useRef(false);
|
||
const restoredSelectedNodeNameRef = useRef<string | null>(null);
|
||
const restoredSelectedPackagingNodeNameRef = useRef<string | null>(null);
|
||
const cameraStatesRef = useRef<
|
||
Partial<Record<OpenClaw3DViewMode, OpenClawModelCameraState>>
|
||
>({});
|
||
|
||
const closeViewer = useCallback(() => {
|
||
setIsActive(false);
|
||
setSelectedPart(null);
|
||
setSelectedPackagingPart(null);
|
||
setMatchState({ matched: 0, total: 0 });
|
||
if (typeof window !== "undefined") {
|
||
const storage = getViewerSessionStorage();
|
||
if (!storage) return;
|
||
clearOpenClaw3DViewerSession({
|
||
storage,
|
||
chatId,
|
||
messageId,
|
||
});
|
||
}
|
||
}, [chatId, messageId]);
|
||
const handleMatchChange = useCallback((matched: number, total: number) => {
|
||
setMatchState((current) =>
|
||
current.matched === matched && current.total === total
|
||
? current
|
||
: { matched, total }
|
||
);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (hasRestoredSessionRef.current || !primaryModel) return;
|
||
hasRestoredSessionRef.current = true;
|
||
const storage = getViewerSessionStorage();
|
||
if (!storage) return;
|
||
const session = readOpenClaw3DViewerSession({
|
||
storage,
|
||
chatId,
|
||
});
|
||
if (session?.messageId !== messageId) return;
|
||
if (!supportsWebGl()) {
|
||
setWebGlUnavailable(true);
|
||
clearOpenClaw3DViewerSession({
|
||
storage,
|
||
chatId,
|
||
messageId,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const restoredViewMode =
|
||
session.viewMode === "packaging" && packagingModel
|
||
? "packaging"
|
||
: productModel
|
||
? "product"
|
||
: "packaging";
|
||
restoredSelectedNodeNameRef.current = session.selectedNodeName;
|
||
restoredSelectedPackagingNodeNameRef.current =
|
||
session.selectedPackagingNodeName;
|
||
setViewMode(restoredViewMode);
|
||
setExplodeAmount(session.explodeAmount);
|
||
setPackagingExplodeAmount(session.packagingExplodeAmount);
|
||
setTransformRequest((current) => ({
|
||
actionId: null,
|
||
amount:
|
||
restoredViewMode === "packaging"
|
||
? session.packagingExplodeAmount
|
||
: session.explodeAmount,
|
||
animate: false,
|
||
durationMs:
|
||
(restoredViewMode === "packaging"
|
||
? packagingModel?.viewer?.transitionDurationMs
|
||
: productModel?.viewer?.transitionDurationMs) || 700,
|
||
nonce: current.nonce + 1,
|
||
}));
|
||
window.dispatchEvent(
|
||
new CustomEvent(ACTIVE_VIEWER_EVENT, { detail: viewerId })
|
||
);
|
||
setIsActive(true);
|
||
}, [chatId, messageId, packagingModel, primaryModel, productModel, viewerId]);
|
||
|
||
useEffect(() => {
|
||
if (!isActive) return;
|
||
const storage = getViewerSessionStorage();
|
||
if (!storage) return;
|
||
writeOpenClaw3DViewerSession({
|
||
storage,
|
||
chatId,
|
||
messageId,
|
||
viewMode,
|
||
explodeAmount,
|
||
packagingExplodeAmount,
|
||
selectedNodeName: selectedPart?.nodeName || null,
|
||
selectedPackagingNodeName: selectedPackagingPart?.nodeName || null,
|
||
});
|
||
}, [
|
||
chatId,
|
||
explodeAmount,
|
||
isActive,
|
||
messageId,
|
||
packagingExplodeAmount,
|
||
selectedPackagingPart?.nodeName,
|
||
selectedPart?.nodeName,
|
||
viewMode,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
const nodeName = restoredSelectedNodeNameRef.current;
|
||
if (!manifest || !nodeName) return;
|
||
restoredSelectedNodeNameRef.current = null;
|
||
setSelectedPart(
|
||
manifest.parts.find((part) => part.nodeName === nodeName) || null
|
||
);
|
||
}, [manifest]);
|
||
|
||
useEffect(() => {
|
||
const nodeName = restoredSelectedPackagingNodeNameRef.current;
|
||
if (!manifest || !nodeName) return;
|
||
restoredSelectedPackagingNodeNameRef.current = null;
|
||
setSelectedPackagingPart(
|
||
getOpenClawPackagingInteractionParts(manifest).find(
|
||
(part) => part.nodeName === nodeName
|
||
) || null
|
||
);
|
||
}, [manifest]);
|
||
|
||
useEffect(() => {
|
||
const handleOtherViewerActivated = (event: Event) => {
|
||
const activeId = (event as CustomEvent<string>).detail;
|
||
if (activeId !== viewerId) closeViewer();
|
||
};
|
||
window.addEventListener(ACTIVE_VIEWER_EVENT, handleOtherViewerActivated);
|
||
return () => {
|
||
window.removeEventListener(ACTIVE_VIEWER_EVENT, handleOtherViewerActivated);
|
||
};
|
||
}, [closeViewer, viewerId]);
|
||
|
||
useEffect(() => {
|
||
if (!isActive) return;
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key !== "Escape") return;
|
||
event.preventDefault();
|
||
closeViewer();
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [closeViewer, isActive]);
|
||
|
||
useEffect(() => {
|
||
if (!isActive || !bundle || manifest !== undefined) return;
|
||
if (bundle.manifestIndex < 0) {
|
||
setManifest(null);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
const manifestUrl = buildOpenClawArtifactProxyUrl({
|
||
chatId,
|
||
messageId,
|
||
artifactIndex: bundle.manifestIndex,
|
||
});
|
||
setManifestError(null);
|
||
fetch(manifestUrl, { cache: "no-store", signal: controller.signal })
|
||
.then(async (response) => {
|
||
if (!response.ok) {
|
||
throw new Error(`交互清单读取失败(HTTP ${response.status})`);
|
||
}
|
||
const normalized = normalizeOpenClawModelInteractionManifest(
|
||
await response.json()
|
||
);
|
||
if (!normalized) throw new Error("交互清单格式不正确");
|
||
setManifest(normalized);
|
||
})
|
||
.catch((error) => {
|
||
if (controller.signal.aborted) return;
|
||
setManifest(null);
|
||
setManifestError(
|
||
error instanceof Error ? error.message : "交互清单读取失败"
|
||
);
|
||
});
|
||
return () => controller.abort();
|
||
}, [bundle, chatId, isActive, manifest, messageId]);
|
||
|
||
useEffect(() => {
|
||
if (viewMode === "product" && !productModel && packagingModel) {
|
||
setViewMode("packaging");
|
||
} else if (viewMode === "packaging" && !packagingModel && productModel) {
|
||
setViewMode("product");
|
||
}
|
||
}, [packagingModel, productModel, viewMode]);
|
||
|
||
const packagingInteractionParts = useMemo(
|
||
() => (manifest ? getOpenClawPackagingInteractionParts(manifest) : []),
|
||
[manifest]
|
||
);
|
||
|
||
if (!bundle || !model) {
|
||
return (
|
||
<OpenClawSupportingArtifacts
|
||
artifacts={artifacts}
|
||
chatId={chatId}
|
||
messageId={messageId}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const isPackagingView = viewMode === "packaging" && Boolean(packagingModel);
|
||
const activeModelIndex = isPackagingView
|
||
? bundle.packagingModelIndex
|
||
: bundle.modelIndex;
|
||
const activePreviewIndex = isPackagingView
|
||
? bundle.packagingPreviewIndex
|
||
: bundle.previewIndex;
|
||
const interactionParts = isPackagingView
|
||
? packagingInteractionParts
|
||
: manifest?.parts || [];
|
||
const selectedInteractionPart = isPackagingView
|
||
? selectedPackagingPart
|
||
: selectedPart;
|
||
const activeExplodeAmount = isPackagingView
|
||
? packagingExplodeAmount
|
||
: explodeAmount;
|
||
|
||
const modelUrl = buildOpenClawArtifactProxyUrl({
|
||
chatId,
|
||
messageId,
|
||
artifactIndex: activeModelIndex,
|
||
});
|
||
const downloadUrl = buildOpenClawArtifactProxyUrl({
|
||
chatId,
|
||
messageId,
|
||
artifactIndex: activeModelIndex,
|
||
download: true,
|
||
});
|
||
const previewUrl =
|
||
activePreviewIndex >= 0
|
||
? buildOpenClawArtifactProxyUrl({
|
||
chatId,
|
||
messageId,
|
||
artifactIndex: activePreviewIndex,
|
||
})
|
||
: null;
|
||
const isAssembledModel =
|
||
model.artifactRole.toLowerCase() === "assembled_3d" ||
|
||
model.viewer?.variant === "assembled";
|
||
const artifactVariant = getOpenClaw3DArtifactVariant(model);
|
||
const sourceVariant = artifactVariant === "packaging" ? "exploded" : artifactVariant;
|
||
const supportsNodeInteraction = Boolean(
|
||
manifest &&
|
||
interactionParts.length > 0 &&
|
||
(isPackagingView || isAssembledModel || sourceVariant === "exploded")
|
||
);
|
||
const displayedModelName =
|
||
isPackagingView
|
||
? model.displayName || "3D包装预览"
|
||
: supportsNodeInteraction && sourceVariant === "exploded"
|
||
? "可旋转3D整装成品模型"
|
||
: model.displayName || model.filename || "3D 模型";
|
||
const canDragParts = Boolean(
|
||
!isPackagingView &&
|
||
supportsNodeInteraction &&
|
||
(model.viewer?.partDragging || manifest?.parts.some((part) => part.draggable))
|
||
);
|
||
const canExplode = Boolean(
|
||
supportsNodeInteraction &&
|
||
(isPackagingView
|
||
? manifest?.capabilities.oneClickPackagingExplode
|
||
: model.viewer?.oneClickExplode || manifest?.capabilities.oneClickExplode)
|
||
);
|
||
const canAssemble = Boolean(
|
||
supportsNodeInteraction &&
|
||
(isPackagingView
|
||
? manifest?.capabilities.oneClickPackagingPack
|
||
: model.viewer?.oneClickAssemble || manifest?.capabilities.oneClickAssemble)
|
||
);
|
||
const showExplodeSlider = Boolean(
|
||
!isPackagingView &&
|
||
supportsNodeInteraction &&
|
||
(model.viewer?.explodeSlider || manifest?.capabilities.explodeSlider)
|
||
);
|
||
const canResetParts = Boolean(
|
||
!isPackagingView &&
|
||
supportsNodeInteraction &&
|
||
(model.viewer?.resetParts || manifest?.parts.some((part) => part.draggable))
|
||
);
|
||
|
||
const activateViewer = () => {
|
||
if (!supportsWebGl()) {
|
||
setWebGlUnavailable(true);
|
||
return;
|
||
}
|
||
setWebGlUnavailable(false);
|
||
window.dispatchEvent(new CustomEvent(ACTIVE_VIEWER_EVENT, { detail: viewerId }));
|
||
setIsActive(true);
|
||
};
|
||
const updateExplodeAmount = (
|
||
amount: number,
|
||
animate: boolean,
|
||
actionId: string | null = null
|
||
) => {
|
||
const normalized = Math.max(0, Math.min(1, amount));
|
||
if (isPackagingView) {
|
||
setPackagingExplodeAmount(normalized);
|
||
} else {
|
||
setExplodeAmount(normalized);
|
||
}
|
||
const action = manifest?.actions.find((item) => item.id === actionId);
|
||
setTransformRequest((current) => ({
|
||
actionId,
|
||
amount: normalized,
|
||
animate,
|
||
durationMs:
|
||
action?.durationMs || model.viewer?.transitionDurationMs || 700,
|
||
nonce: current.nonce + 1,
|
||
}));
|
||
};
|
||
const switchViewMode = (nextMode: OpenClaw3DViewMode) => {
|
||
if (nextMode === viewMode) return;
|
||
const nextAmount =
|
||
nextMode === "packaging" ? packagingExplodeAmount : explodeAmount;
|
||
setViewMode(nextMode);
|
||
setMatchState({ matched: 0, total: 0 });
|
||
setTransformRequest((current) => ({
|
||
actionId: null,
|
||
amount: nextAmount,
|
||
animate: false,
|
||
durationMs: 700,
|
||
nonce: current.nonce + 1,
|
||
}));
|
||
};
|
||
const selectedDimensions = selectedInteractionPart
|
||
? formatDimensions(selectedInteractionPart)
|
||
: null;
|
||
const packagingInfo = isPackagingView ? manifest?.packaging : null;
|
||
const packagingDimensions = packagingInfo?.packageDimensions.length
|
||
? packagingInfo.packageDimensions
|
||
.map(
|
||
(item) =>
|
||
`第${item.packageIndex}箱 ${item.dimensionsMm.x} x ${item.dimensionsMm.y} x ${item.dimensionsMm.z} mm`
|
||
)
|
||
.join(";")
|
||
: packagingInfo?.packageDimensionsMm
|
||
? `${packagingInfo.packageDimensionsMm.x} x ${packagingInfo.packageDimensionsMm.y} x ${packagingInfo.packageDimensionsMm.z} mm`
|
||
: null;
|
||
|
||
return (
|
||
<>
|
||
<section className="mt-3 w-full max-w-[760px] overflow-hidden rounded-lg border border-black/10 bg-white dark:border-white/10 dark:bg-zinc-950">
|
||
<header className="flex min-h-11 items-center justify-between gap-2 border-b border-black/8 px-3 py-1.5 dark:border-white/10">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<Box className="h-4 w-4 shrink-0 text-[#687386] dark:text-zinc-400" />
|
||
<span className="truncate text-[13px] font-medium text-[#2f3640] dark:text-zinc-200">
|
||
{displayedModelName}
|
||
</span>
|
||
<span className="shrink-0 text-[11px] uppercase text-[#9aa2af] dark:text-zinc-500">
|
||
{model.viewer?.format || "glb"}
|
||
</span>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
{isActive ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-zinc-100"
|
||
aria-label="复位3D视角"
|
||
title="复位3D视角"
|
||
onClick={() => {
|
||
delete cameraStatesRef.current[viewMode];
|
||
setResetViewToken((value) => value + 1);
|
||
}}
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-8 items-center justify-center gap-1 rounded-md px-2 text-[12px] font-medium text-[#535d6d] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-white"
|
||
aria-label="退出3D预览"
|
||
title="退出3D预览,恢复页面滚动"
|
||
onClick={closeViewer}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
<span className="hidden sm:inline">退出预览</span>
|
||
</button>
|
||
</>
|
||
) : null}
|
||
<a
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-zinc-100"
|
||
aria-label="下载3D模型"
|
||
title="下载3D模型"
|
||
href={downloadUrl}
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
</a>
|
||
</div>
|
||
</header>
|
||
|
||
{productModel && packagingModel ? (
|
||
<div className="flex items-center gap-1 border-b border-black/8 bg-black/[0.015] px-3 py-2 dark:border-white/10 dark:bg-white/[0.025]">
|
||
<button
|
||
type="button"
|
||
aria-pressed={viewMode === "product"}
|
||
className={
|
||
viewMode === "product"
|
||
? "inline-flex h-8 items-center gap-1.5 rounded-md bg-[#202631] px-3 text-xs font-medium text-white dark:bg-[#3ecf8e] dark:text-[#07120d]"
|
||
: "inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium text-[#687386] hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/8 dark:hover:text-white"
|
||
}
|
||
onClick={() => switchViewMode("product")}
|
||
>
|
||
<Box className="h-3.5 w-3.5" />
|
||
产品
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-pressed={viewMode === "packaging"}
|
||
className={
|
||
viewMode === "packaging"
|
||
? "inline-flex h-8 items-center gap-1.5 rounded-md bg-[#202631] px-3 text-xs font-medium text-white dark:bg-[#3ecf8e] dark:text-[#07120d]"
|
||
: "inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium text-[#687386] hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/8 dark:hover:text-white"
|
||
}
|
||
onClick={() => switchViewMode("packaging")}
|
||
>
|
||
<Package className="h-3.5 w-3.5" />
|
||
包装预览
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
<div
|
||
className="relative h-[clamp(300px,44vw,460px)] w-full overflow-hidden"
|
||
style={{ backgroundColor: model.viewer?.background || "#f7f9fc" }}
|
||
>
|
||
{isActive ? (
|
||
manifest === undefined && bundle.manifestIndex >= 0 ? (
|
||
<ViewerLoading />
|
||
) : supportsNodeInteraction && manifest ? (
|
||
<>
|
||
<OpenClawInteractiveModelViewer
|
||
allowDragging={canDragParts}
|
||
background={model.viewer?.background || "#f7f9fc"}
|
||
fitExplodedSource={isPackagingView}
|
||
initialCameraState={cameraStatesRef.current[viewMode] || null}
|
||
interactionParts={interactionParts}
|
||
manifest={manifest}
|
||
modelUrl={modelUrl}
|
||
onCameraStateChange={(state) => {
|
||
cameraStatesRef.current[viewMode] = state;
|
||
}}
|
||
onMatchChange={handleMatchChange}
|
||
onSelectedPartChange={(part) => {
|
||
if (isPackagingView) {
|
||
setSelectedPackagingPart(part);
|
||
} else {
|
||
setSelectedPart(part);
|
||
}
|
||
}}
|
||
previewImageUrl={previewUrl}
|
||
resetAllToken={resetAllToken}
|
||
resetSelectedToken={resetSelectedToken}
|
||
resetViewToken={resetViewToken}
|
||
selectedNodeName={selectedInteractionPart?.nodeName || null}
|
||
sourceVariant={sourceVariant}
|
||
transformRequest={transformRequest}
|
||
/>
|
||
|
||
{selectedInteractionPart ? (
|
||
<div className="pointer-events-none absolute left-3 top-3 max-w-[calc(100%-24px)] rounded-md bg-black/72 px-3 py-2 text-white shadow-sm backdrop-blur-sm">
|
||
<p className="truncate text-xs font-medium">
|
||
{selectedInteractionPart.partId} · {selectedInteractionPart.partName}
|
||
</p>
|
||
{selectedDimensions ? (
|
||
<p className="mt-0.5 text-[11px] text-white/65">
|
||
{selectedDimensions}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="absolute inset-x-0 bottom-0 border-t border-black/10 bg-white/94 px-3 py-2.5 backdrop-blur-md dark:border-white/10 dark:bg-zinc-950/94">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{canExplode || canAssemble ? (
|
||
<div
|
||
aria-label={isPackagingView ? "3D包装状态" : "3D模型状态"}
|
||
className="inline-flex h-8 items-center rounded-md border border-black/10 bg-black/[0.025] p-0.5 dark:border-white/15 dark:bg-white/[0.06]"
|
||
role="group"
|
||
>
|
||
{canAssemble ? (
|
||
<button
|
||
type="button"
|
||
aria-pressed={activeExplodeAmount < 0.5}
|
||
className={
|
||
activeExplodeAmount < 0.5
|
||
? "inline-flex h-7 items-center gap-1.5 rounded-[4px] bg-white px-2.5 text-xs font-medium text-[#202631] shadow-sm dark:bg-[#3ecf8e] dark:text-[#07120d]"
|
||
: "inline-flex h-7 items-center gap-1.5 rounded-[4px] px-2.5 text-xs font-medium text-[#687386] hover:text-[#202631] dark:text-zinc-400 dark:hover:text-zinc-100"
|
||
}
|
||
onClick={() =>
|
||
updateExplodeAmount(
|
||
0,
|
||
true,
|
||
isPackagingView
|
||
? "packaging_pack"
|
||
: model.viewer?.assembleActionId || "assemble_all"
|
||
)
|
||
}
|
||
>
|
||
<Minimize2 className="h-3.5 w-3.5" />
|
||
{isPackagingView ? "合上包装" : "组合状态"}
|
||
</button>
|
||
) : null}
|
||
{canExplode ? (
|
||
<button
|
||
type="button"
|
||
aria-pressed={activeExplodeAmount >= 0.5}
|
||
className={
|
||
activeExplodeAmount >= 0.5
|
||
? "inline-flex h-7 items-center gap-1.5 rounded-[4px] bg-white px-2.5 text-xs font-medium text-[#202631] shadow-sm dark:bg-[#3ecf8e] dark:text-[#07120d]"
|
||
: "inline-flex h-7 items-center gap-1.5 rounded-[4px] px-2.5 text-xs font-medium text-[#687386] hover:text-[#202631] dark:text-zinc-400 dark:hover:text-zinc-100"
|
||
}
|
||
onClick={() =>
|
||
updateExplodeAmount(
|
||
1,
|
||
true,
|
||
isPackagingView
|
||
? "packaging_explode"
|
||
: model.viewer?.explodeActionId || "explode_all"
|
||
)
|
||
}
|
||
>
|
||
<Maximize2 className="h-3.5 w-3.5" />
|
||
{isPackagingView ? "展开包装" : "爆炸状态"}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{selectedInteractionPart && canResetParts ? (
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white"
|
||
aria-label="归位当前配件"
|
||
title="归位当前配件"
|
||
onClick={() => setResetSelectedToken((value) => value + 1)}
|
||
>
|
||
<Undo2 className="h-4 w-4" />
|
||
</button>
|
||
) : null}
|
||
{canResetParts ? (
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white"
|
||
aria-label="全部配件归位"
|
||
title="全部配件归位"
|
||
onClick={() => setResetAllToken((value) => value + 1)}
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
</button>
|
||
) : null}
|
||
{matchState.total > 0 && matchState.matched < matchState.total ? (
|
||
<span className="text-[11px] text-amber-700 dark:text-amber-300">
|
||
已识别 {matchState.matched}/{matchState.total} 个
|
||
{isPackagingView ? "包装层" : "配件"}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{showExplodeSlider ? (
|
||
<label className="mt-2 flex items-center gap-3 text-[11px] text-[#697386] dark:text-zinc-400">
|
||
<span className="shrink-0">爆炸程度</span>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="100"
|
||
value={Math.round(activeExplodeAmount * 100)}
|
||
className="h-1.5 min-w-0 flex-1 accent-[#202631] dark:accent-[#3ecf8e]"
|
||
onChange={(event) =>
|
||
updateExplodeAmount(Number(event.target.value) / 100, false)
|
||
}
|
||
/>
|
||
<span className="w-8 text-right tabular-nums">
|
||
{Math.round(activeExplodeAmount * 100)}%
|
||
</span>
|
||
</label>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<Model3DViewer
|
||
autoFitModel
|
||
modelFormat={model.viewer?.format || "glb"}
|
||
modelUrl={modelUrl}
|
||
previewImageUrl={previewUrl}
|
||
resetViewToken={resetViewToken}
|
||
settings={{
|
||
autoRotate: model.viewer?.autoRotate || false,
|
||
cameraPreset: "iso",
|
||
envPreset: "studio",
|
||
lightIntensity: 1,
|
||
showGrid: false,
|
||
wireframe: false,
|
||
}}
|
||
/>
|
||
)
|
||
) : (
|
||
<div className="relative flex h-full w-full items-center justify-center bg-[#f7f9fc] dark:bg-zinc-950">
|
||
{previewUrl ? (
|
||
<img
|
||
alt={`${model.displayName || "3D 模型"}静态预览`}
|
||
className="absolute inset-0 h-full w-full object-contain"
|
||
loading="lazy"
|
||
src={previewUrl}
|
||
/>
|
||
) : (
|
||
<Box className="h-16 w-16 text-[#c7cdd6] dark:text-zinc-700" />
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="relative inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#202631] px-4 text-[13px] font-medium text-white shadow-sm transition-colors hover:bg-[#11151c] dark:bg-[#3ecf8e] dark:text-[#07120d] dark:hover:bg-[#55dda0]"
|
||
onClick={activateViewer}
|
||
>
|
||
<Box className="h-4 w-4" />
|
||
查看 3D
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{packagingInfo ? (
|
||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 border-t border-black/8 bg-black/[0.015] px-3 py-3 text-xs dark:border-white/10 dark:bg-white/[0.025] sm:grid-cols-4">
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] text-[#929baa] dark:text-zinc-500">箱型</p>
|
||
<p className="mt-0.5 truncate font-medium text-[#303744] dark:text-zinc-200">
|
||
{packagingInfo.boxStyleLabel || "未标注"}
|
||
</p>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] text-[#929baa] dark:text-zinc-500">包装箱数</p>
|
||
<p className="mt-0.5 font-medium text-[#303744] dark:text-zinc-200">
|
||
{packagingInfo.packageCount ?? "-"}
|
||
</p>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] text-[#929baa] dark:text-zinc-500">总包装体积</p>
|
||
<p className="mt-0.5 font-medium text-[#303744] dark:text-zinc-200">
|
||
{packagingInfo.totalPackagingVolumeM3 != null
|
||
? `${packagingInfo.totalPackagingVolumeM3.toFixed(3)} m³`
|
||
: "-"}
|
||
</p>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] text-[#929baa] dark:text-zinc-500">体积重</p>
|
||
<p className="mt-0.5 font-medium text-[#303744] dark:text-zinc-200">
|
||
{packagingInfo.volumeWeightKg != null
|
||
? `${packagingInfo.volumeWeightKg.toFixed(1)} kg`
|
||
: "-"}
|
||
</p>
|
||
</div>
|
||
{packagingDimensions ? (
|
||
<div className="col-span-2 min-w-0 sm:col-span-4">
|
||
<p className="text-[11px] text-[#929baa] dark:text-zinc-500">每箱尺寸</p>
|
||
<p className="mt-0.5 break-words font-medium text-[#303744] dark:text-zinc-200">
|
||
{packagingDimensions}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{webGlUnavailable || manifestError ? (
|
||
<p className="border-t border-black/8 px-3 py-2 text-xs text-amber-700 dark:border-white/10 dark:text-amber-300">
|
||
{webGlUnavailable
|
||
? "当前浏览器无法启动3D预览,已保留静态预览和模型下载。"
|
||
: `${manifestError},已切换为基础3D预览。`}
|
||
</p>
|
||
) : null}
|
||
</section>
|
||
<OpenClawSupportingArtifacts
|
||
artifacts={artifacts}
|
||
chatId={chatId}
|
||
messageId={messageId}
|
||
/>
|
||
</>
|
||
);
|
||
}
|