();
@@ -2246,7 +2262,7 @@ export function App() {
)
return;
const target = event.target instanceof HTMLElement ? event.target : null;
- if (target?.closest('input,select,textarea,[contenteditable="true"]')) return;
+ if (isTextEditingTarget(target)) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') {
event.preventDefault();
setCommandOpen(true);
@@ -2392,84 +2408,87 @@ export function App() {
run: () => setHelpOpen(true),
},
];
- const workspaceTools = workspaceTool ? (
-
- 正在加载工作区工具…
-
- }
- >
- /\.py$/i.test(file.path))
- .map((file) => file.path)}
- selectedControllerPath={selectedControllerPath}
- controllerStatus={controllerStatus}
- policyPaths={state.files
- .filter((file) => /\.onnx$/i.test(file.path))
- .map((file) => file.path)}
- selectedPolicyPath={selectedPolicyPath}
- policyStatus={policyStatus}
- onResetJoints={resetJoints}
- onToggleJointLimits={toggleJointLimits}
- onToggleAdvanced={() => setJointAdvanced((value) => !value)}
- onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))}
- onActuator={setActuator}
- onActuatorParameters={setActuatorParameters}
- onJoint={setJoint}
- onForceScale={setForceScale}
- onSelectControllerPath={setSelectedControllerPath}
- onLoadControllerPath={loadControllerPath}
- onImportController={importController}
- onToggleController={toggleController}
- onControllerCommand={sendControllerCommand}
- onRemoveController={removeController}
- onSelectPolicyPath={setSelectedPolicyPath}
- onLoadPolicyPath={loadPolicyPath}
- onImportPolicy={importPolicy}
- compileTrainingScene={(coordinates) => {
- if (
- mapSceneDirty ||
- trainingDeployment ||
- useAppStore.getState().loading ||
- loadInFlight.current
- )
- throw new Error('请先应用地图草稿;训练部署/加载中的场景不能同步');
- return adapter.current.exportTrainingTerrain(appliedMapAssets, coordinates);
- }}
- trainingSceneMaps={appliedMapAssets}
- trainingSceneDirty={mapSceneDirty || Boolean(trainingDeployment)}
- onTogglePolicy={togglePolicy}
- onPolicyCommand={setPolicyCommand}
- navigationTargetMode={navigationTargetMode}
- onNavigationTargetMode={(active) => viewer.current?.setNavigationTargetMode(active)}
- onResetNavigationTarget={() => {
- viewer.current?.setNavigationTargetMode(false);
- adapter.current.resetNavigationTarget();
- const snapshot = adapter.current.snapshot();
- state.setSnapshot(snapshot ?? undefined);
- setPolicyStatus(snapshot?.rlPolicy);
- }}
- onRemovePolicy={removePolicy}
- onDataRecorderConfigure={configureDataRecorder}
- onDataRecordingStart={startDataRecording}
- onDataRecordingStop={stopDataRecording}
- onDataRecordingClear={clearDataRecording}
- onDataRecordingExport={exportDataRecording}
- />
-
- ) : undefined;
+ const [workspaceToolsVisited, setWorkspaceToolsVisited] = useState(false);
+ if (workspaceTool && !workspaceToolsVisited) setWorkspaceToolsVisited(true);
+ const workspaceTools =
+ workspaceToolsVisited || workspaceTool ? (
+
+ 正在加载工作区工具…
+
+ }
+ >
+ /\.py$/i.test(file.path))
+ .map((file) => file.path)}
+ selectedControllerPath={selectedControllerPath}
+ controllerStatus={controllerStatus}
+ policyPaths={state.files
+ .filter((file) => /\.onnx$/i.test(file.path))
+ .map((file) => file.path)}
+ selectedPolicyPath={selectedPolicyPath}
+ policyStatus={policyStatus}
+ onResetJoints={resetJoints}
+ onToggleJointLimits={toggleJointLimits}
+ onToggleAdvanced={() => setJointAdvanced((value) => !value)}
+ onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))}
+ onActuator={setActuator}
+ onActuatorParameters={setActuatorParameters}
+ onJoint={setJoint}
+ onForceScale={setForceScale}
+ onSelectControllerPath={setSelectedControllerPath}
+ onLoadControllerPath={loadControllerPath}
+ onImportController={importController}
+ onToggleController={toggleController}
+ onControllerCommand={sendControllerCommand}
+ onRemoveController={removeController}
+ onSelectPolicyPath={setSelectedPolicyPath}
+ onLoadPolicyPath={loadPolicyPath}
+ onImportPolicy={importPolicy}
+ compileTrainingScene={(coordinates) => {
+ if (
+ mapSceneDirty ||
+ trainingDeployment ||
+ useAppStore.getState().loading ||
+ loadInFlight.current
+ )
+ throw new Error('请先应用地图草稿;训练部署/加载中的场景不能同步');
+ return adapter.current.exportTrainingTerrain(appliedMapAssets, coordinates);
+ }}
+ trainingSceneMaps={appliedMapAssets}
+ trainingSceneDirty={mapSceneDirty || Boolean(trainingDeployment)}
+ onTogglePolicy={togglePolicy}
+ onPolicyCommand={setPolicyCommand}
+ navigationTargetMode={navigationTargetMode}
+ onNavigationTargetMode={(active) => viewer.current?.setNavigationTargetMode(active)}
+ onResetNavigationTarget={() => {
+ viewer.current?.setNavigationTargetMode(false);
+ adapter.current.resetNavigationTarget();
+ const snapshot = adapter.current.snapshot();
+ state.setSnapshot(snapshot ?? undefined);
+ setPolicyStatus(snapshot?.rlPolicy);
+ }}
+ onRemovePolicy={removePolicy}
+ onDataRecorderConfigure={configureDataRecorder}
+ onDataRecordingStart={startDataRecording}
+ onDataRecordingStop={stopDataRecording}
+ onDataRecordingClear={clearDataRecording}
+ onDataRecordingExport={exportDataRecording}
+ />
+
+ ) : undefined;
return (
setSourceOpen(true)}
- onTogglePause={togglePause}
- onStep={singleStep}
- onReset={reset}
- onSpeed={changeSpeed}
onToggleLeft={() => setLeftOpen((value) => !value)}
onToggleRight={() => setRightOpen((value) => !value)}
- onToggleTheme={() => setTheme((value) => (value === 'dark' ? 'light' : 'dark'))}
- onHelp={() => setHelpOpen(true)}
endActions={
<>
setNotifications([])}
onOpenLog={() => setDiagnosticsOpen(true)}
/>
-
- setLayoutOpen(true)}
- >
-
-
- setSettingsOpen(true)}
- >
-
-
-
+ setLayoutOpen(true)}
+ >
+
+
>
}
compactMenu={
@@ -2541,250 +2540,291 @@ export function App() {
/>
}
onCommands={() => setCommandOpen(true)}
- onToggleFullscreen={toggleFullscreen}
- center={
-
+ = 1024 ? (workspaceWidth - 480) / 2 : undefined
+ }
+ >
+
+
{
+ state.setSelection(null);
+ setEditorSelection({ kind: 'body', bodyId });
+ viewer.current?.selectMapEditorObject(null);
+ viewer.current?.selectParametricMapAsset(null);
+ viewer.current?.highlightJoint(null);
+ setRightOpen(true);
+ }}
+ onSelectJoint={(jointId, bodyId) => {
+ state.setSelection(null);
+ setEditorSelection({ kind: 'joint', jointId, bodyId });
+ viewer.current?.selectMapEditorObject(null);
+ viewer.current?.selectParametricMapAsset(null);
+ viewer.current?.highlightJoint(jointId);
+ setRightOpen(true);
+ }}
+ onJointHover={(jointId) =>
+ viewer.current?.highlightJoint(
+ jointId ?? (editorSelection?.kind === 'joint' ? editorSelection.jointId : null),
+ )
+ }
+ onAddMapAsset={(type, placementMode) =>
+ addCertifiedMapAsset(type, undefined, placementMode)
+ }
+ onAddProjectMap={addProjectMapAsset}
+ onSelectTerrain={selectTerrainAsset}
+ onAssetPlacementModeChange={setAssetPlacementMode}
+ onApplyScene={() => void commitMapScene()}
+ onDiscardScene={discardMapSceneDraft}
+ onSelectMap={activateMapAsset}
+ onRemoveMap={removePlacedMapAsset}
+ onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)}
+ />
+
+
+ }
+ orientation={}
+ view={
+
+
+ viewer.current?.resetCamera()}
+ >
+
+
+
+ }
+ controls={
+
+
+
+
+
+ }
+ draft={
+ void commitMapScene()}
+ onDiscard={discardMapSceneDraft}
+ />
+ }
+ notices={
+
+ {state.diagnostic && (
+ state.setDiagnostic(undefined)}
+ onRetry={
+ state.diagnostic.category === '模型编译' && state.diagnostic.path
+ ? () => void loadEntry(state.diagnostic!.path!)
+ : undefined
+ }
+ onOpenProject={() => {
+ setLeftOpen(true);
+ state.setDiagnostic(undefined);
+ }}
+ />
+ )}
+ {!state.diagnostic && (
+ setToast(undefined)} />
+ )}
+
+ }
+ context={
+ trainingDeployment && (
+
+ 训练配套物理地图(编辑器地图未更改,重载模型恢复)。
+ {trainingDeployment.terrain?.approximation && '训练专用离散近似。'}
+ {(policyStatus?.observationSize === 81 ||
+ policyStatus?.observationSize === 97) && (
+
+ )}
+
+ )
+ }
+ camera={
+ Boolean(state.snapshot?.model.ncam) &&
+ (showSensorCamera ? (
+ {
+ sensorCameraFrame.current = node;
+ viewer.current?.setSensorCameraViewportElement(node);
+ }}
+ className="sensor-camera-frame overflow-hidden rounded-lg border border-border-strong shadow-xl"
+ >
+
+
+
+ 摄像头
+
+
+
+
+ ) : (
+ }
+ onClick={() => setShowSensorCamera(true)}
+ >
+ 显示摄像头画面
+
+ ))
+ }
+ />
+
+
+ {state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && (
+
+ )}{' '}
+
+ asset.id === activeMapAssetId)?.name
+ }
+ mapSceneDirty={mapSceneDirty}
+ maps={projectMaps}
+ showVisualMap={showVisualMap}
+ showMapCollision={showMapCollision}
+ editorDocument={editorDocument}
+ editorDraftDocument={
+ mapSelection.kind === 'project'
+ ? editorDrafts.get(mapSelection.descriptorPath)
: undefined
}
- onModeChange={mode}
- onDisplayChange={setDisplayOptions}
- onResetCamera={() => viewer.current?.resetCamera()}
+ workspaceTool={workspaceTool}
+ workspaceTools={workspaceTools}
+ onWorkspaceToolChange={(tool) => {
+ setWorkspaceTool(tool);
+ if (tool) setRightOpen(true);
+ }}
+ onSelectJoint={(jointId, bodyId) => {
+ setEditorSelection({ kind: 'joint', jointId, bodyId });
+ viewer.current?.highlightJoint(jointId);
+ }}
+ onUrdfMode={changeUrdfMode}
+ onBaseMode={changeBaseMode}
+ onShowCollision={setShowCollision}
+ onResetJoints={resetJoints}
+ onToggleJointLimits={toggleJointLimits}
+ onToggleAdvanced={() => setJointAdvanced((value) => !value)}
+ onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))}
+ onActuator={setActuator}
+ onActuatorParameters={setActuatorParameters}
+ onJoint={setJoint}
+ onApplyMap={applyMapSelection}
+ onMapDraft={stageMapSelectionDraft}
+ onEditorPreview={previewEditorDocument}
+ onEditorDraftChange={updateEditorDraft}
+ onEditorApply={applyEditorDocument}
+ onEditorExport={exportSelectedMap}
+ onEditorConvert={convertSelectedMap}
+ onEditorBindInteraction={bindEditorInteraction}
+ onEditorSelect={selectEditorObject}
+ onEditorSessionStateChange={updateEditorSessionState}
+ onEditorSurfaceHeight={editorSurfaceHeight}
+ onMapDisplay={(visual, collision) => {
+ setShowVisualMap(visual);
+ setShowMapCollision(collision);
+ }}
/>
- }
- />
-
-
{
- state.setSelection(null);
- setEditorSelection({ kind: 'body', bodyId });
- viewer.current?.selectMapEditorObject(null);
- viewer.current?.selectParametricMapAsset(null);
- viewer.current?.highlightJoint(null);
- setRightOpen(true);
- }}
- onSelectJoint={(jointId, bodyId) => {
- state.setSelection(null);
- setEditorSelection({ kind: 'joint', jointId, bodyId });
- viewer.current?.selectMapEditorObject(null);
- viewer.current?.selectParametricMapAsset(null);
- viewer.current?.highlightJoint(jointId);
- setRightOpen(true);
- }}
- onJointHover={(jointId) =>
- viewer.current?.highlightJoint(
- jointId ?? (editorSelection?.kind === 'joint' ? editorSelection.jointId : null),
- )
- }
- onAddMapAsset={(type, placementMode) =>
- addCertifiedMapAsset(type, undefined, placementMode)
- }
- onAddProjectMap={addProjectMapAsset}
- onSelectTerrain={selectTerrainAsset}
- onAssetPlacementModeChange={setAssetPlacementMode}
- onApplyScene={() => void commitMapScene()}
- onDiscardScene={discardMapSceneDraft}
- onSelectMap={activateMapAsset}
- onRemoveMap={removePlacedMapAsset}
- onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)}
- />
-
-
-
-
- void commitMapScene()}
- onDiscard={discardMapSceneDraft}
- />
-
-
- setToast(undefined)} />
- {trainingDeployment && (
-
- 训练配套物理地图(编辑器地图未更改,重载模型恢复)。
- {trainingDeployment.terrain?.approximation && '训练专用离散近似。'}
- {(policyStatus?.observationSize === 81 || policyStatus?.observationSize === 97) && (
-
- )}
-
- )}
- {Boolean(state.snapshot?.model.ncam) &&
- (showSensorCamera ? (
-
-
-
-
- 摄像头
-
-
-
-
- ) : (
- }
- onClick={() => setShowSensorCamera(true)}
- >
- 显示摄像头画面
-
- ))}
- {state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && (
-
- )}{' '}
- {state.diagnostic && (
- state.setDiagnostic(undefined)}
- onRetry={
- state.diagnostic.category === '模型编译' && state.diagnostic.path
- ? () => void loadEntry(state.diagnostic!.path!)
- : undefined
- }
- onOpenProject={() => {
- setLeftOpen(true);
- state.setDiagnostic(undefined);
- }}
- />
- )}
-
- asset.id === activeMapAssetId)?.name}
- mapSceneDirty={mapSceneDirty}
- maps={projectMaps}
- showVisualMap={showVisualMap}
- showMapCollision={showMapCollision}
- editorDocument={editorDocument}
- editorDraftDocument={
- mapSelection.kind === 'project'
- ? editorDrafts.get(mapSelection.descriptorPath)
- : undefined
- }
- workspaceTool={workspaceTool}
- workspaceTools={workspaceTools}
- onWorkspaceToolChange={(tool) => {
- setWorkspaceTool(tool);
- if (tool) setRightOpen(true);
- }}
- onSelectJoint={(jointId, bodyId) => {
- setEditorSelection({ kind: 'joint', jointId, bodyId });
- viewer.current?.highlightJoint(jointId);
- }}
- onUrdfMode={changeUrdfMode}
- onBaseMode={changeBaseMode}
- onShowCollision={setShowCollision}
- onResetJoints={resetJoints}
- onToggleJointLimits={toggleJointLimits}
- onToggleAdvanced={() => setJointAdvanced((value) => !value)}
- onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))}
- onActuator={setActuator}
- onActuatorParameters={setActuatorParameters}
- onJoint={setJoint}
- onApplyMap={applyMapSelection}
- onMapDraft={stageMapSelectionDraft}
- onEditorPreview={previewEditorDocument}
- onEditorDraftChange={updateEditorDraft}
- onEditorApply={applyEditorDocument}
- onEditorExport={exportSelectedMap}
- onEditorConvert={convertSelectedMap}
- onEditorBindInteraction={bindEditorInteraction}
- onEditorSelect={selectEditorObject}
- onEditorSessionStateChange={updateEditorSessionState}
- onEditorSurfaceHeight={editorSurfaceHeight}
- onMapDisplay={(visual, collision) => {
- setShowVisualMap(visual);
- setShowMapCollision(collision);
- }}
- />
-
+
+
{pendingUrdfPath && (
该操作不会删除本地文件。
-
);
}
diff --git a/web_platform/src/app/ErrorBoundary.test.tsx b/web_platform/src/app/ErrorBoundary.test.tsx
new file mode 100644
index 00000000..c4ebef24
--- /dev/null
+++ b/web_platform/src/app/ErrorBoundary.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react';
+import { ErrorBoundary } from './ErrorBoundary';
+it('致命错误保留主题、完整错误与重新加载风险', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ localStorage.setItem('mujoco-platform-theme', 'light');
+ function Broken(): never {
+ throw new Error('渲染失败');
+ }
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole('main')).toHaveClass('theme-light');
+ expect(screen.getByText('渲染失败')).toBeVisible();
+ expect(screen.getByText(/重新加载会丢失/)).toBeVisible();
+ expect(screen.getByRole('button', { name: '重新加载' })).toBeEnabled();
+});
diff --git a/web_platform/src/app/ErrorBoundary.tsx b/web_platform/src/app/ErrorBoundary.tsx
index 5f8646d2..d0d0d8fa 100644
--- a/web_platform/src/app/ErrorBoundary.tsx
+++ b/web_platform/src/app/ErrorBoundary.tsx
@@ -1,3 +1,4 @@
+import { useThemePreference } from './hooks/useThemePreference';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from '../components/ui';
export class ErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
@@ -9,20 +10,26 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { error?:
console.error('React fatal error', error, info);
}
render() {
- return this.state.error ? (
-
-
- 界面发生致命错误
-
- {this.state.error.message}
-
-
-
-
- ) : (
- this.props.children
- );
+ return this.state.error ? : this.props.children;
}
}
+
+function FatalError({ error }: { error: Error }) {
+ const [theme] = useThemePreference();
+ return (
+
+
+ 界面发生致命错误
+
+ {error.message}
+
+ 重新加载会丢失当前浏览器会话中未保存的修改。
+
+
+
+ );
+}
diff --git a/web_platform/src/app/components/CommandPalette.tsx b/web_platform/src/app/components/CommandPalette.tsx
index e79ada07..9eee2be7 100644
--- a/web_platform/src/app/components/CommandPalette.tsx
+++ b/web_platform/src/app/components/CommandPalette.tsx
@@ -35,6 +35,12 @@ export function CommandPalette({
useEffect(() => {
if (open) requestAnimationFrame(() => input.current?.focus());
}, [open]);
+ useEffect(() => {
+ if (open && highlighted >= 0)
+ document
+ .getElementById(`${listId}-${filtered[highlighted].id}`)
+ ?.scrollIntoView?.({ block: 'nearest' });
+ }, [open, highlighted, filtered, listId]);
const close = () => {
setQuery('');
setActive(0);
@@ -105,8 +111,8 @@ export function CommandPalette({
>
{command.icon}
- {command.label}
- {command.group}
+ {command.label}
+ {command.group}
{command.shortcut && {command.shortcut}}
diff --git a/web_platform/src/app/components/DiagnosticNotice.tsx b/web_platform/src/app/components/DiagnosticNotice.tsx
index 957224a0..4e0fee11 100644
--- a/web_platform/src/app/components/DiagnosticNotice.tsx
+++ b/web_platform/src/app/components/DiagnosticNotice.tsx
@@ -22,7 +22,7 @@ export function DiagnosticNotice({
{value.summary}
{value.path && (
-
+
路径:{value.path}
)}
@@ -41,7 +41,7 @@ export function DiagnosticNotice({
{expanded && (
-
+
{value.detail}
)}
diff --git a/web_platform/src/app/components/DiagnosticsDrawer.tsx b/web_platform/src/app/components/DiagnosticsDrawer.tsx
index 1137db8b..08bcc76c 100644
--- a/web_platform/src/app/components/DiagnosticsDrawer.tsx
+++ b/web_platform/src/app/components/DiagnosticsDrawer.tsx
@@ -1,8 +1,24 @@
-import { useState } from 'react';
-import { CheckCircle2, Info, TriangleAlert, XCircle } from 'lucide-react';
+import { useMemo, useState } from 'react';
+import {
+ CheckCircle2,
+ FileInput,
+ Info,
+ TerminalSquare,
+ TriangleAlert,
+ XCircle,
+} from 'lucide-react';
import { Button, CopyButton, Dialog, Tabs } from '../../components/ui';
import type { WorkbenchNotification } from './NotificationCenter';
-type Filter = 'all' | 'warning' | 'danger';
+
+type Filter = 'all' | 'import' | 'warning' | 'danger';
+
+const toneMeta = {
+ danger: { icon: XCircle, color: 'text-danger', label: 'ERROR' },
+ warning: { icon: TriangleAlert, color: 'text-warning', label: 'WARN' },
+ success: { icon: CheckCircle2, color: 'text-success', label: 'OK' },
+ info: { icon: Info, color: 'text-accent', label: 'INFO' },
+} as const;
+
export function DiagnosticsDrawer({
open,
items,
@@ -15,37 +31,59 @@ export function DiagnosticsDrawer({
onClear: () => void;
}) {
const [filter, setFilter] = useState('all');
+ const groups = useMemo(
+ () => ({
+ all: items,
+ import: items.filter((item) => item.category === 'import'),
+ warning: items.filter((item) => item.tone === 'warning'),
+ danger: items.filter((item) => item.tone === 'danger'),
+ }),
+ [items],
+ );
+
const content = (value: Filter) => {
- const filtered = items.filter((item) => value === 'all' || item.tone === value);
+ const filtered = groups[value];
return (
-
+
{filtered.length ? (
filtered.map((item) => {
- const Icon =
- item.tone === 'danger'
- ? XCircle
- : item.tone === 'warning'
- ? TriangleAlert
- : item.tone === 'success'
- ? CheckCircle2
- : Info;
+ const meta = toneMeta[item.tone];
+ const Icon = meta.icon;
+ const date = new Date(item.at);
+ const validDate = Number.isFinite(date.getTime());
return (
-
-
-
-
-
{item.title}
-
- {item.detail && (
-
- {item.detail}
-
+
+
+
+
+
+ {meta.label}
+
+
+ {item.title}
+
+ {item.category && (
+
+ {item.category}
+
)}
+ {item.detail && (
+
+ {item.detail}
+
+ )}
+
+
+
{item.detail && (
)}
@@ -54,19 +92,28 @@ export function DiagnosticsDrawer({
);
})
) : (
-
没有符合条件的事件
+
+ 没有符合条件的事件
+
)}
);
};
+
return (