feat(web-platform): release V0.9.3 全模块界面重构
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-08 17:57:57 +08:00
parent 831d0b95bb
commit 13e35be98b
124 changed files with 5182 additions and 2141 deletions
@@ -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({
>
<span className="flex h-5 w-5 items-center justify-center">{command.icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{command.label}</span>
<span className="block text-[10px] text-text-tertiary">{command.group}</span>
<span className="block break-words font-medium">{command.label}</span>
<span className="block text-xs text-text-tertiary">{command.group}</span>
</span>
{command.shortcut && <Kbd>{command.shortcut}</Kbd>}
</button>
@@ -22,7 +22,7 @@ export function DiagnosticNotice({
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-text-primary">{value.summary}</h2>
{value.path && (
<p className="mt-0.5 truncate text-xs text-text-tertiary" title={value.path}>
<p className="mt-0.5 break-all text-xs text-text-tertiary" title={value.path}>
路径:{value.path}
</p>
)}
@@ -41,7 +41,7 @@ export function DiagnosticNotice({
</IconButton>
</div>
{expanded && (
<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">
<pre className="max-h-36 overflow-auto whitespace-pre-wrap break-words border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">
{value.detail}
</pre>
)}
@@ -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<Filter>('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 (
<div className="space-y-2">
<div className="diagnostics-terminal panel-scroll min-h-32 max-h-[45vh] overflow-auto rounded-lg border border-border-subtle bg-input/75">
{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 (
<article key={item.id} className="rounded-lg border border-border bg-surface p-3">
<div className="flex items-start gap-2">
<Icon
className={`mt-0.5 h-4 w-4 ${item.tone === 'danger' ? 'text-danger' : item.tone === 'warning' ? 'text-warning' : 'text-success'}`}
/>
<div className="min-w-0 flex-1">
<h3 className="text-xs font-semibold">{item.title}</h3>
<time className="text-[10px] text-text-tertiary">
{new Date(item.at).toLocaleString('zh-CN')}
</time>
{item.detail && (
<pre className="mt-2 whitespace-pre-wrap text-[10px] leading-4 text-text-secondary">
{item.detail}
</pre>
<article
key={item.id}
className="grid grid-cols-[auto_minmax(0,1fr)] sm:grid-cols-[auto_minmax(0,1fr)_auto] gap-2 border-b border-border-subtle px-3 py-2.5 last:border-0 hover:bg-element-hover/45"
>
<Icon className={`mt-0.5 h-3.5 w-3.5 ${meta.color}`} aria-hidden="true" />
<div className="min-w-0">
<div className="flex min-w-0 items-baseline gap-2">
<span className={`technical-value text-xs font-bold ${meta.color}`}>
{meta.label}
</span>
<h3 className="break-words text-xs font-semibold text-text-primary">
{item.title}
</h3>
{item.category && (
<span className="technical-value rounded border border-border-subtle px-1 text-xs uppercase text-text-tertiary">
{item.category}
</span>
)}
</div>
{item.detail && (
<pre className="mt-1 min-w-0 whitespace-pre-wrap break-words font-mono text-xs leading-4 text-text-tertiary">
{item.detail}
</pre>
)}
</div>
<div className="flex items-start gap-1.5">
<time
dateTime={validDate ? date.toISOString() : undefined}
className="technical-value whitespace-nowrap text-xs text-text-tertiary"
>
{validDate ? date.toLocaleTimeString('zh-CN', { hour12: false }) : '—'}
</time>
{item.detail && (
<CopyButton value={`${item.title}\n${item.detail}`} label="复制事件详情" />
)}
@@ -54,19 +92,28 @@ export function DiagnosticsDrawer({
);
})
) : (
<p className="p-8 text-center text-xs text-text-tertiary">没有符合条件的事件</p>
<div className="grid min-h-32 place-items-center text-xs text-text-tertiary">
没有符合条件的事件
</div>
)}
</div>
);
};
return (
<Dialog
open={open}
onClose={onClose}
title="诊断与事件日志"
className="max-w-2xl"
placement="bottom"
className="bg-panel"
contentClassName="pt-3"
footer={
<div className="flex justify-end">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs text-text-tertiary">
<TerminalSquare className="h-3.5 w-3.5 text-accent" />
日志仅保存在当前浏览器会话;清空后不可恢复
</span>
<Button variant="danger" disabled={!items.length} onClick={onClear}>
清空事件
</Button>
@@ -79,15 +126,21 @@ export function DiagnosticsDrawer({
onValueChange={setFilter}
keepMounted={false}
items={[
{ value: 'all', label: `全部 ${items.length}`, content: content('all') },
{ value: 'all', label: `全部 ${groups.all.length}`, content: content('all') },
{
value: 'import',
label: `导入 ${groups.import.length}`,
icon: <FileInput className="h-3 w-3" />,
content: content('import'),
},
{
value: 'warning',
label: `警告 ${items.filter((item) => item.tone === 'warning').length}`,
label: `警告 ${groups.warning.length}`,
content: content('warning'),
},
{
value: 'danger',
label: `错误 ${items.filter((item) => item.tone === 'danger').length}`,
label: `错误 ${groups.danger.length}`,
content: content('danger'),
},
]}
@@ -9,7 +9,7 @@ describe('EntrySelectionDialog', () => {
],
select = vi.fn();
const { rerender } = render(<EntrySelectionDialog entries={entries} onSelect={select} />);
const entry = screen.getByRole('button', { name: '模型 A' });
const entry = screen.getByRole('button', { name: '模型 A a.xml' });
entry.focus();
rerender(<EntrySelectionDialog entries={[...entries]} onSelect={select} />);
expect(entry).toHaveFocus();
@@ -15,11 +15,17 @@ export function EntrySelectionDialog({
{entries.map((entry) => (
<Button
key={entry.path}
className="w-full justify-start overflow-hidden"
aria-label={`${entry.label} ${entry.path}`}
className="!h-auto min-h-8 w-full justify-start py-2 text-left"
onClick={() => onSelect(entry.path)}
icon={<FileCode2 className="h-4 w-4" />}
>
<span className="truncate">{entry.label}</span>
<span className="min-w-0">
<span className="block break-words">{entry.label}</span>
<span className="block break-all font-mono text-xs text-text-tertiary">
{entry.path}
</span>
</span>
</Button>
))}
</div>
@@ -17,7 +17,7 @@ export function ErrorRecoveryPanel({
return (
<section
role="alert"
className="absolute bottom-4 left-1/2 z-30 w-[min(42rem,calc(100%-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-danger-border bg-panel shadow-2xl"
className="w-full shrink-0 overflow-hidden rounded-xl border border-danger-border bg-panel shadow-2xl"
>
<div className="flex items-start gap-3 p-3">
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-danger-soft text-danger">
@@ -52,6 +52,7 @@ describe('工作台反馈组件', () => {
it('展示格式化状态数据', () => {
render(<StatusBar time={1.25} fps={60} stepMs={0.5} memoryMb={10} loaded overBudget={false} />);
expect(screen.getByText(/时间 1.250 s/)).toBeVisible();
expect(screen.getByText(/WASM 已加载/)).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: /FPS 60/ }));
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('WASM已加载');
});
});
@@ -1,5 +1,5 @@
import { Columns3, Focus, PanelLeft, PanelRight, RotateCcw } from 'lucide-react';
import { Button, Dialog } from '../../components/ui';
import { Button, Dialog, Tooltip } from '../../components/ui';
export type LayoutPreset = 'default' | 'viewport' | 'project' | 'control';
const presets = [
{ value: 'default' as const, label: '默认布局', detail: '左右面板均衡显示', icon: Columns3 },
@@ -49,19 +49,20 @@ export function LayoutSettingsDialog({
<h3 className="mb-2 mt-4 text-xs font-semibold">布局预设</h3>
<div className="grid grid-cols-2 gap-2">
{presets.map((item) => (
<button
key={item.value}
onClick={() => onPreset(item.value)}
className="flex gap-2 rounded-lg border border-border bg-surface p-3 text-left hover:border-accent hover:bg-accent-soft focus-visible:ring-2 focus-visible:ring-accent/30"
>
<item.icon className="h-4 w-4 shrink-0 text-accent" />
<span>
<span className="block text-xs font-medium">{item.label}</span>
<span className="mt-0.5 block text-[10px] text-text-tertiary">{item.detail}</span>
</span>
</button>
<Tooltip key={item.value} content={item.detail}>
<button
onClick={() => onPreset(item.value)}
className="flex gap-2 rounded-lg border border-border bg-surface p-3 text-left hover:border-accent hover:bg-accent-soft focus-visible:ring-2 focus-visible:ring-accent/30"
>
<item.icon className="h-4 w-4 shrink-0 text-accent" />
<span>
<span className="block text-xs font-medium">{item.label}</span>
</span>
</button>
</Tooltip>
))}
</div>
<p className="mt-3 text-xs text-text-tertiary">窄屏一次显示一侧面板,不覆盖桌面宽度偏好。</p>
<Button
className="mt-4 w-full"
onClick={onReset}
@@ -64,7 +64,18 @@ describe('MapViewportToolbar', () => {
});
describe('MapDraftStatusOverlay', () => {
it('常驻显示未保存数量并提供提交与丢弃入口', () => {
it('提交中与失败摘要不能隐藏,失败保留重试和放弃入口', () => {
const props = { visible: true, changeCount: 2, onCommit: noop, onDiscard: noop };
const { rerender } = render(<MapDraftStatusOverlay {...props} loading />);
expect(screen.getByRole('status')).toHaveTextContent('正在应用草稿');
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled();
rerender(<MapDraftStatusOverlay {...props} loading={false} error="编译失败,草稿已保留" />);
expect(screen.getByRole('status')).toHaveTextContent('应用失败');
expect(screen.getByRole('status')).toHaveTextContent('编译失败,草稿已保留');
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeEnabled();
expect(screen.getByRole('button', { name: '丢弃地图草稿' })).toBeEnabled();
});
it('显示未保存数量并提供提交与丢弃入口', () => {
const onCommit = vi.fn();
const onDiscard = vi.fn();
render(
@@ -83,7 +94,7 @@ describe('MapDraftStatusOverlay', () => {
expect(onDiscard).toHaveBeenCalledOnce();
});
it('无草稿时显示已同步并禁用动作', () => {
it('无草稿时不占位', () => {
render(
<MapDraftStatusOverlay
visible
@@ -93,7 +104,6 @@ describe('MapDraftStatusOverlay', () => {
onDiscard={noop}
/>,
);
expect(screen.getByText('地图草稿已同步')).toBeVisible();
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled();
expect(screen.queryByLabelText('地图草稿状态')).not.toBeInTheDocument();
});
});
@@ -61,7 +61,7 @@ export function MapViewportToolbar({
return (
<div
aria-label="地图视口工具"
className="engineering-glass map-tool-enter absolute left-1/2 top-3 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-1.5 rounded-xl border p-1.5"
className="flex max-w-full flex-wrap items-center justify-center gap-1 p-1"
>
<button
type="button"
@@ -69,7 +69,7 @@ export function MapViewportToolbar({
aria-pressed={interactionActive}
disabled={loading}
onClick={onActivate}
className={`hidden h-8 items-center gap-1.5 rounded-lg px-2 text-[10px] font-semibold transition-colors sm:flex ${interactionActive ? 'bg-accent-soft text-accent' : 'bg-surface/80 text-text-secondary hover:bg-element-hover'}`}
className={`flex h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-semibold transition-colors ${interactionActive ? 'bg-accent-soft text-accent' : 'bg-surface/80 text-text-secondary hover:bg-element-hover'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
地图编辑
@@ -91,8 +91,8 @@ export function MapViewportToolbar({
<Grid3X3 className="h-3.5 w-3.5" />
</IconButton>
<span aria-hidden="true" className="mx-0.5 h-5 w-px bg-border" />
<label className="flex items-center gap-1 text-[10px] text-text-tertiary">
<span className="hidden lg:inline">贴地</span>
<label className="flex items-center gap-1 text-xs text-text-tertiary">
<span>贴地</span>
<Select
aria-label="贴地检测模式"
className="w-[112px]"
@@ -129,27 +129,34 @@ export function MapDraftStatusOverlay({
loading,
onCommit,
onDiscard,
error,
}: {
visible: boolean;
changeCount: number;
loading: boolean;
error?: string;
onCommit: () => void;
onDiscard: () => void;
}) {
if (!visible) return null;
const dirty = changeCount > 0;
if (!visible || (!dirty && !loading && !error)) return null;
return (
<div
aria-label="地图草稿状态"
role="status"
className="engineering-glass absolute bottom-3 left-1/2 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-2 rounded-xl border px-2 py-1.5"
className="engineering-glass flex max-w-full flex-wrap items-center justify-center gap-2 rounded-xl border px-2 py-1.5"
>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${dirty ? 'draft-dirty-dot bg-warning' : 'bg-success'}`}
/>
<span className="min-w-0 whitespace-nowrap text-[10px] font-medium text-text-secondary sm:text-[11px]">
{dirty ? `${changeCount} 项未保存改动` : '地图草稿已同步'}
<span className="min-w-0 text-xs font-medium text-text-secondary">
{loading
? '正在应用草稿…'
: error
? `应用失败 · ${changeCount} 项未保存改动`
: `${changeCount} 项未保存改动`}
{error && <span className="block text-danger">{error}</span>}
</span>
<Button
variant="ghost"
@@ -158,7 +165,7 @@ export function MapDraftStatusOverlay({
icon={<Trash2 className="h-3 w-3" />}
onClick={onDiscard}
>
<span className="hidden sm:inline">丢弃</span>
放弃
</Button>
<Button
variant="primary"
@@ -167,7 +174,7 @@ export function MapDraftStatusOverlay({
icon={<Check className="h-3 w-3" />}
onClick={onCommit}
>
<span className="hidden sm:inline">提交</span>
应用
</Button>
</div>
);
@@ -191,7 +198,7 @@ export function MapAssetDropIndicator({ target }: { target?: MapAssetDropTarget
<span className="grid h-8 w-8 place-items-center rounded-full border border-current bg-panel/85 backdrop-blur">
<MapPinned className="h-4 w-4" />
</span>
<span className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-strong bg-panel/90 px-2 py-1 text-[10px] font-medium shadow-xl backdrop-blur">
<span className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-strong bg-panel/90 px-2 py-1 text-xs font-medium shadow-xl backdrop-blur">
{valid
? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}`
: '请拖到 3D 地面'}
@@ -83,24 +83,30 @@ function props(
}
describe('ModelControlsSidebar', () => {
it('没有选择时显示场景摘要,并保留基础地图快速入口', () => {
it('没有选择时仅显示收敛的场景摘要', () => {
render(<ModelControlsSidebar {...props()} />);
expect(screen.getByText('未选择对象')).toBeVisible();
expect(screen.getByText('模型摘要')).toBeVisible();
expect(screen.getByText('未选择地图实例')).toBeVisible();
expect(screen.getByRole('button', { name: '模型摘要' })).toHaveAttribute(
'aria-expanded',
'false',
);
fireEvent.click(screen.getByRole('button', { name: '模型摘要' }));
expect(screen.getByText('qpos / qvel')).toBeVisible();
expect(screen.queryByText('未选择地图实例')).not.toBeInTheDocument();
});
it('按照统一选择自动路由 Body 与 Joint 检查器', () => {
const view = render(
<ModelControlsSidebar {...props({ selection: { kind: 'body', bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Body')).toBeVisible();
expect(screen.getByText('arm', { selector: '[title="arm"]' })).toBeVisible();
expect(screen.getByText('Body #2')).toBeVisible();
expect(screen.getByText('arm')).toBeVisible();
view.rerender(
<ModelControlsSidebar {...props({ selection: { kind: 'joint', jointId: 7, bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Joint')).toBeVisible();
expect(screen.getByRole('slider', { name: 'arm_joint' })).toBeVisible();
expect(screen.getByText('Hinge · arm')).toBeVisible();
expect(screen.getByText('关联 Actuator')).toBeVisible();
});
@@ -153,8 +159,8 @@ describe('ModelControlsSidebar', () => {
})}
/>,
);
expect(screen.getByText('Map / Instance')).toBeVisible();
expect(screen.getByText('随机粗糙地形', { selector: '[title="随机粗糙地形"]' })).toBeVisible();
expect(screen.getByText('参数化地形')).toBeVisible();
expect(screen.getByText('随机粗糙地形', { selector: 'header span' })).toBeVisible();
fireEvent.click(screen.getByRole('tab', { name: '控制台' }));
fireEvent.click(screen.getByRole('tab', { name: '数据录制' }));
expect(onWorkspaceToolChange.mock.calls).toEqual([['controls'], ['data']]);
@@ -105,7 +105,10 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
{props.workspaceTool ? (
props.workspaceTools
) : (
<div className="p-4 text-sm text-text-tertiary">导入模型后显示检查器</div>
<div className="m-2.5 flex h-14 items-center justify-center gap-2 border border-dashed border-border-subtle text-xs text-text-tertiary">
<PanelRight className="h-4 w-4" aria-hidden="true" />
检查器待命
</div>
)}
</SidebarPanel>
);
@@ -231,7 +234,6 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
onBaseMode={props.onBaseMode}
onShowCollision={props.onShowCollision}
/>
{props.mapSelection.kind === 'none' && mapPanel}
</>
);
}
@@ -247,7 +249,9 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
>
{inspector}
</div>
{props.workspaceTool && props.workspaceTools}
<div hidden={!props.workspaceTool} className="min-h-0 flex-1 overflow-auto panel-scroll">
{props.workspaceTools}
</div>
</SidebarPanel>
);
}
@@ -4,7 +4,11 @@ import { Badge, IconButton, Popover } from '../../components/ui';
export interface WorkbenchNotification {
id: number;
title: string;
/** 通知中心/诊断抽屉中的完整技术详情。 */
detail?: string;
/** Toast 使用的一行摘要,避免把日志常驻在视口上。 */
message?: string;
category?: 'import' | 'compile' | 'runtime' | 'system';
tone: 'success' | 'warning' | 'danger' | 'info';
at: number;
}
@@ -33,13 +37,13 @@ export function NotificationCenter({
)}
>
{({ close }) => (
<div className="w-80 overflow-hidden rounded-lg border border-border bg-surface-elevated shadow-xl">
<div className="w-80 max-w-full overflow-hidden rounded-lg border border-border bg-surface-elevated shadow-xl">
<header className="flex h-9 items-center justify-between border-b border-border px-3">
<h2 className="text-xs font-semibold">通知</h2>
<div className="flex gap-2">
{onOpenLog && (
<button
className="text-[10px] text-accent"
className="min-h-7 text-xs text-accent"
onClick={() => {
close();
onOpenLog();
@@ -50,7 +54,7 @@ export function NotificationCenter({
)}
{items.length > 0 && (
<button
className="flex items-center gap-1 text-[10px] text-text-tertiary hover:text-danger"
className="flex min-h-7 items-center gap-1 text-xs text-text-tertiary hover:text-danger"
onClick={onClear}
>
<Trash2 className="h-3 w-3" />
@@ -73,7 +77,7 @@ export function NotificationCenter({
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-xs font-medium">{item.title}</h3>
<h3 className="break-words text-xs font-medium">{item.title}</h3>
<Badge>
{new Date(item.at).toLocaleTimeString('zh-CN', {
hour: '2-digit',
@@ -82,9 +86,12 @@ export function NotificationCenter({
</Badge>
</div>
{item.detail && (
<p className="mt-1 line-clamp-3 text-[10px] leading-4 text-text-tertiary">
{item.detail}
</p>
<details className="domain-details mt-1">
<summary>事件详情</summary>
<p className="whitespace-pre-wrap break-words text-xs text-text-secondary">
{item.detail}
</p>
</details>
)}
</div>
<IconButton
@@ -127,13 +134,15 @@ export function ToastViewport({
return (
<div
role="status"
className="pointer-events-auto absolute right-4 top-4 z-30 flex w-80 gap-2 rounded-lg border border-border bg-surface-elevated p-3 shadow-xl"
className="pointer-events-auto flex w-full gap-2 rounded-lg border border-border bg-surface-elevated p-3 shadow-xl"
>
<Icon className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">{item.title}</p>
{item.detail && (
<p className="mt-1 line-clamp-2 text-[10px] text-text-tertiary">{item.detail}</p>
{(item.message ?? item.detail) && (
<p className="mt-1 line-clamp-3 text-xs text-text-tertiary">
{item.message ?? item.detail}
</p>
)}
</div>
</div>
@@ -1,36 +1,34 @@
import { Activity, ChevronUp, Cpu, MemoryStick, TriangleAlert } from 'lucide-react';
import { Activity, ChevronUp, MemoryStick, TriangleAlert } from 'lucide-react';
import { Badge, Popover, PropertyRow, Separator } from '../../components/ui';
export function PerformancePopover({
fps,
stepMs,
memoryMb,
overBudget,
loaded,
}: {
fps: number;
stepMs: number;
memoryMb?: number;
overBudget: boolean;
loaded?: boolean;
}) {
return (
<Popover
label="性能详情"
placement="top-left"
placement="bottom-left"
trigger={({ open, toggle }) => (
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={toggle}
className="flex h-6 items-center gap-3 rounded px-1.5 hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
className="flex h-7 items-center gap-3 rounded px-1.5 hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
>
<span className="flex items-center gap-1.5">
<Activity className="h-3 w-3" />
FPS {fps.toFixed(0)}
</span>
<span className="flex items-center gap-1.5">
<Cpu className="h-3 w-3" />
物理 {stepMs.toFixed(2)} ms
</span>
<ChevronUp className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
)}
@@ -43,6 +41,9 @@ export function PerformancePopover({
{overBudget ? '预算超限' : '运行正常'}
</Badge>
</div>
{loaded !== undefined && (
<PropertyRow label="WASM" value={loaded ? '已加载' : '未加载'} />
)}
<PropertyRow label="渲染帧率" value={`${fps.toFixed(0)} FPS`} />
<PropertyRow label="物理步进" value={`${stepMs.toFixed(2)} ms`} />
<PropertyRow
@@ -1,6 +1,6 @@
import { ChevronRight, FolderRoot } from 'lucide-react';
import type { ModelEntry } from '../../project/types';
import { SearchableCombobox } from '../../components/ui';
import { SearchableCombobox, Tooltip } from '../../components/ui';
export function ProjectBreadcrumb({
projectName,
entries,
@@ -17,21 +17,24 @@ export function ProjectBreadcrumb({
const parts = selectedEntry?.split('/').filter(Boolean) ?? [];
return (
<div className="border-b border-border bg-surface px-3 py-2">
<div
aria-label="当前工程路径"
className="flex min-w-0 items-center gap-1 text-[10px] text-text-tertiary"
>
<FolderRoot className="h-3 w-3 shrink-0 text-accent" />
<span className="truncate">{projectName}</span>
{parts.map((part, index) => (
<span key={`${part}-${index}`} className="contents">
<ChevronRight className="h-3 w-3 shrink-0" />
<span className={`truncate ${index === parts.length - 1 ? 'text-text-primary' : ''}`}>
{part}
<Tooltip content={[projectName, selectedEntry].filter(Boolean).join('/')}>
<div
tabIndex={0}
aria-label="当前工程路径"
className="flex min-w-0 items-center gap-1 text-xs text-text-tertiary"
>
<FolderRoot className="h-3 w-3 shrink-0 text-accent" />
<span className="truncate">{projectName}</span>
{parts.map((part, index) => (
<span key={`${part}-${index}`} className="contents">
<ChevronRight className="h-3 w-3 shrink-0" />
<span className={`truncate ${index === parts.length - 1 ? 'text-text-primary' : ''}`}>
{part}
</span>
</span>
</span>
))}
</div>
))}
</div>
</Tooltip>
{entries.length > 1 && (
<div className="mt-2">
<SearchableCombobox
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { FolderTree, Library, Search } from 'lucide-react';
import { FolderTree, Library } from 'lucide-react';
import type { MapEntry, ModelEntry } from '../../project/types';
import {
countProjectSearchResults,
@@ -106,7 +106,7 @@ export function ProjectSidebar({
const sceneMatches = countSceneSearchResults(snapshot, placedMaps, editorDocuments, sceneQuery);
return (
<SidebarPanel title="场景大纲与资产中心" side="left" visible={visible} icon={<Library />}>
<SidebarPanel title="场景与资源" side="left" visible={visible} icon={<Library />}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
@@ -114,12 +114,6 @@ export function ProjectSidebar({
<div className="truncate text-sm font-medium text-accent" title={projectName}>
{projectName}
</div>
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
{snapshot
? `${snapshot.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')).length} Body`
: '模型未加载'}{' '}
· {placedMaps.length} 个地图实例 · {files.length} 个文件
</div>
</div>
<Button variant="danger" onClick={onRemove} disabled={loading}>
移除
@@ -136,10 +130,6 @@ export function ProjectSidebar({
secondClassName="flex flex-col"
first={
<>
<div className="flex h-8 shrink-0 items-center gap-2 px-3 text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">
<Search className="h-3.5 w-3.5" aria-hidden="true" />
场景大纲
</div>
<TreeSearchField
value={sceneQuery}
onChange={setSceneQuery}
@@ -23,7 +23,7 @@ export function RightSidebarTabs({
<div
role="tablist"
aria-label="右侧工作区视图"
className="grid shrink-0 grid-cols-3 gap-1 border-b border-border bg-panel-muted/40 p-2"
className="grid shrink-0 grid-cols-3 gap-1 border-b border-border-subtle bg-panel-muted/35 p-1"
>
{VIEWS.map((view, index) => {
const Icon = view.icon;
@@ -35,9 +35,9 @@ export function RightSidebarTabs({
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={`flex min-w-0 items-center justify-center gap-1.5 rounded-md px-1 py-2 text-[11px] font-medium transition-colors ${
className={`flex h-8 min-w-0 items-center justify-center gap-1 rounded px-1 text-xs font-medium transition-colors ${
selected
? 'bg-panel text-accent shadow-sm'
? 'border border-border-subtle bg-panel text-accent shadow-sm tool-active-glow'
: 'text-text-tertiary hover:bg-element-hover hover:text-text-primary'
}`}
onClick={() => onChange(view.value)}
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Box, Bot, Layers3, MapPinned, Trash2, Zap } from 'lucide-react';
import { Button, EmptySearchState, SearchHighlight } from '../../components/ui';
import { Button, EmptySearchState, SearchHighlight, Tooltip } from '../../components/ui';
import type { EditableMapDocument } from '../../map/editor/types';
import type { PlacedMapAsset } from '../../map/types';
import {
@@ -136,7 +136,7 @@ export function SceneOutliner({
{pendingSceneChangeCount > 0 && (
<div
role="status"
className="mb-2 rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-[10px] text-text-secondary"
className="mb-2 rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-xs text-text-secondary"
>
<div className="flex items-center gap-1.5 font-medium text-accent">
<Zap className="h-3.5 w-3.5" aria-hidden="true" />
@@ -158,7 +158,7 @@ export function SceneOutliner({
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Bot className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">机器人</span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
<span className="technical-value text-xs font-normal text-text-tertiary">
{robotBodies.length} Body
</span>
</summary>
@@ -182,7 +182,7 @@ export function SceneOutliner({
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">地图与环境</span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
<span className="technical-value text-xs font-normal text-text-tertiary">
{maps.length} 实例
</span>
</summary>
@@ -213,20 +213,22 @@ export function SceneOutliner({
<SearchHighlight text={asset.name} query={query} />
</span>
{pending.has(asset.id) && (
<span className="rounded bg-warning/10 px-1 text-[9px] text-warning">
<span className="rounded bg-warning/10 px-1 text-xs text-warning">
待应用
</span>
)}
</button>
<button
type="button"
aria-label={`删除地图实例 ${asset.name}`}
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
disabled={loading}
onClick={() => onRemoveMap(asset.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
<Tooltip content={`从场景移除 ${asset.name};应用前可放弃更改。`}>
<button
type="button"
aria-label={`删除地图实例 ${asset.name}`}
className="mr-1 grid h-7 w-7 shrink-0 place-items-center rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
disabled={loading}
onClick={() => onRemoveMap(asset.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</Tooltip>
</div>
{objects.length > 0 && (
<ul role="group" className="ml-3 border-l border-border pl-1">
@@ -242,7 +244,7 @@ export function SceneOutliner({
role="treeitem"
aria-selected={selected}
disabled={loading}
className={`flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-[11px] ${
className={`flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-xs ${
selected
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
@@ -253,7 +255,7 @@ export function SceneOutliner({
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={object.name} query={query} />
</span>
<span className="text-[9px] text-text-tertiary">{object.type}</span>
<span className="text-xs text-text-tertiary">{object.type}</span>
</button>
</li>
);
@@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ShortcutHelpDialog } from './ShortcutHelpDialog';
import { TreeSearchField } from './TreeSearchField';
import { ViewportHUD } from './ViewportHUD';
import { EmptyWorkspace } from './WorkspaceOverlays';
import { ViewerDisplayPopover } from './ViewerDisplayPopover';
import { DEFAULT_VIEWER_DISPLAY_OPTIONS } from '../../viewer/displayOptions';
@@ -25,24 +24,10 @@ describe('第二批工作台组件', () => {
fireEvent.keyDown(document, { key: 'Escape' });
expect(close).toHaveBeenCalledTimes(1);
});
it('视口 HUD 复用状态并给出当前模式的鼠标提示', () => {
render(
<ViewportHUD
ready
paused={false}
mode="joint"
selection={{ bodyId: 2, bodyName: 'arm', geomId: 3, geomType: 1, position: [0, 0, 0] }}
/>,
);
expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');
expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');
expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');
expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('左键拖动关节');
expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('右键平移');
});
it('空工作区解释导入到仿真的三步流程', () => {
render(<EmptyWorkspace />);
expect(screen.getByRole('region', { name: '导入模型工程' })).toBeVisible();
fireEvent.click(screen.getByText('导入与仿真流程'));
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('导入');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('检查与配置');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试');
@@ -47,8 +47,12 @@ export function SettingsDialog({
</section>
<section>
<h3 className="mb-2 text-xs font-semibold">模型与控制</h3>
<p className="mb-2 text-xs text-warning">
外力强度立即作用于施力工具,可能改变模型运动。
</p>
<PropertyRow
label="角度单位"
description="只切换显示单位,不改变模型关节限位"
value={
<Select
aria-label="设置角度单位"
@@ -1,5 +1,6 @@
import { Dialog, Kbd, Separator } from '../../components/ui';
const shortcuts = [
['Ctrl / Cmd + K', '打开命令面板'],
['Space', '播放 / 暂停'],
['R', '重置仿真(非地图编辑)'],
['1', '选择模式'],
@@ -17,11 +18,14 @@ const shortcuts = [
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
<Dialog open={open} onClose={onClose} title="快捷键与视口操作">
<p className="mb-4 text-xs text-text-tertiary">
输入框与 Monaco 编辑器聚焦时,地图和仿真快捷键不抢占输入;源码 Ctrl/Cmd+S 保存并重新载入。
</p>
<section>
<h3 className="mb-2 text-xs font-semibold text-text-primary">键盘快捷键</h3>
<dl className="space-y-2">
{shortcuts.map(([key, label]) => (
<div key={key} className="flex items-center justify-between text-xs">
<div key={key} className="flex flex-wrap items-center justify-between gap-2 text-xs">
<dt className="text-text-secondary">{label}</dt>
<dd>
<Kbd>{key}</Kbd>
@@ -18,10 +18,10 @@ export function SidebarPanel({
return (
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
<aside
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border-subtle `}
>
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-gradient-to-r from-panel via-surface/70 to-panel px-3 text-sm font-semibold tracking-tight text-text-primary shadow-[inset_0_-1px_0_rgb(255_255_255/0.025)]">
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent [&>svg]:h-3.5 [&>svg]:w-3.5">
<h2 className="flex h-9 shrink-0 items-center gap-2 border-b border-border-subtle px-3 text-xs font-semibold text-text-primary">
<span className="text-accent [&>svg]:h-4 [&>svg]:w-4">
{icon ?? <Settings2 aria-hidden="true" className="h-3.5 w-3.5" />}
</span>
{title}
@@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { SimulationControls } from './SimulationControls';
describe('SimulationControls', () => {
it('迁移后保留播放、单步、重置、速度及就绪限制', () => {
const pause = vi.fn(),
step = vi.fn(),
reset = vi.fn(),
speed = vi.fn();
const props = {
ready: true,
paused: true,
loading: false,
speed: 1,
onTogglePause: pause,
onStep: step,
onReset: reset,
onSpeed: speed,
};
const { rerender } = render(<SimulationControls {...props} />);
fireEvent.click(screen.getByRole('button', { name: '▶ 播放' }));
fireEvent.click(screen.getByRole('button', { name: '单步' }));
fireEvent.click(screen.getByRole('button', { name: '重置' }));
fireEvent.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } });
expect(pause).toHaveBeenCalledOnce();
expect(step).toHaveBeenCalledOnce();
expect(reset).toHaveBeenCalledOnce();
expect(speed).toHaveBeenCalledWith(2);
rerender(<SimulationControls {...props} paused={false} />);
expect(screen.getByRole('button', { name: '⏸ 暂停' })).toBeEnabled();
expect(screen.getByRole('button', { name: '单步' })).toBeDisabled();
rerender(<SimulationControls {...props} ready={false} />);
expect(screen.getByRole('button', { name: '▶ 播放' })).toBeDisabled();
expect(screen.getByRole('button', { name: '重置' })).toBeDisabled();
});
});
@@ -0,0 +1,64 @@
import { Pause, Play, RotateCcw, StepForward } from 'lucide-react';
import { Button, IconButton, Select } from '../../components/ui';
export function SimulationControls({
paused,
ready,
speed,
loading,
onTogglePause,
onStep,
onReset,
onSpeed,
}: {
paused: boolean;
ready: boolean;
speed: number;
loading: boolean;
onTogglePause: () => void;
onStep: () => void;
onReset: () => void;
onSpeed: (speed: number) => void;
}) {
return (
<div aria-label="仿真控制" className="flex flex-wrap items-center justify-center gap-1">
<Button
variant="ghost"
onClick={onTogglePause}
disabled={!ready}
aria-label={paused ? '▶ 播放' : '⏸ 暂停'}
icon={paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
>
{paused ? '播放' : '暂停'}
</Button>
<IconButton
tooltip="单步(暂停时可用)"
aria-label="单步"
onClick={onStep}
disabled={!ready || !paused}
>
<StepForward className="h-3.5 w-3.5" />
</IconButton>
<IconButton
tooltip="重置仿真(R;地图编辑时 R 为缩放)"
aria-label="重置"
onClick={onReset}
disabled={!ready}
>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
<Select
aria-label="仿真速度"
value={speed}
disabled={loading}
onChange={(event) => onSpeed(Number(event.target.value))}
className="w-[70px]"
>
<option value={0.25}>0.25×</option>
<option value={0.5}>0.5×</option>
<option value={1}>1×</option>
<option value={2}>2×</option>
<option value={4}>4×</option>
</Select>
</div>
);
}
@@ -0,0 +1,49 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { SourceEditorDialog } from './SourceEditorDialog';
vi.mock('./monacoSetup', () => ({}));
vi.mock('@monaco-editor/react', () => ({
default: ({
value,
onChange,
theme,
}: {
value: string;
onChange(value: string): void;
theme: string;
}) => (
<textarea
aria-label="源码"
data-theme={theme}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
it('保存失败保留草稿与风险,嵌套确认 Escape 只关闭确认', async () => {
const close = vi.fn(),
save = vi.fn().mockRejectedValue(new Error('编译失败,上一模型保留'));
render(
<SourceEditorDialog
open
code="<mujoco/>"
filePath="model.xml"
theme="light"
onClose={close}
onSave={save}
/>,
);
const editor = screen.getByLabelText('源码');
expect(editor).toHaveAttribute('data-theme', 'light');
expect(screen.getByText(/保存将重新编译/)).toBeVisible();
fireEvent.change(editor, { target: { value: '<mujoco model="new"/>' } });
editor.focus();
fireEvent.keyDown(editor, { key: 's', ctrlKey: true });
expect(await screen.findByRole('alert')).toHaveTextContent('编译失败,上一模型保留');
expect(editor).toHaveValue('<mujoco model="new"/>');
expect(save).toHaveBeenCalledWith('model.xml', '<mujoco model="new"/>');
fireEvent.click(screen.getByRole('button', { name: '关闭源代码编辑器' }));
expect(screen.getByRole('dialog', { name: '放弃未保存的修改?' })).toBeVisible();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '放弃未保存的修改?' })).not.toBeInTheDocument();
expect(close).not.toHaveBeenCalled();
});
@@ -1,3 +1,4 @@
import { FloatingLayerContext, useFloatingLayer } from '../../components/ui/floating';
import './monacoSetup';
import Editor from '@monaco-editor/react';
import {
@@ -42,6 +43,7 @@ export function SourceEditorDialog({
const [code, setCode] = useState(sourceCode),
[savedCode, setSavedCode] = useState(sourceCode),
[saving, setSaving] = useState(false),
[saveError, setSaveError] = useState<string>(),
[copied, setCopied] = useState(false),
[maximized, setMaximized] = useState(false),
[discardOpen, setDiscardOpen] = useState(false),
@@ -61,13 +63,17 @@ export function SourceEditorDialog({
const save = useCallback(async () => {
if (!dirty || problem) return;
setSaving(true);
setSaveError(undefined);
try {
await onSave(filePath, code);
setSavedCode(code);
} catch (value) {
setSaveError(value instanceof Error ? value.message : String(value));
} finally {
setSaving(false);
}
}, [code, dirty, filePath, onSave, problem]);
const layer = useFloatingLayer({ open, roots: () => [dialog.current], dismiss: requestClose });
useEffect(() => {
if (!open) return;
previousFocus.current =
@@ -80,7 +86,7 @@ export function SourceEditorDialog({
}, [open]);
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (discardOpen) return;
if (!open || discardOpen || !dialog.current?.contains(document.activeElement)) return;
if (
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === 's' &&
@@ -89,14 +95,11 @@ export function SourceEditorDialog({
) {
event.preventDefault();
void save();
} else if (event.key === 'Escape') {
event.preventDefault();
requestClose();
}
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [dirty, discardOpen, problem, requestClose, save]);
}, [open, dirty, discardOpen, problem, requestClose, save]);
const copy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
@@ -124,7 +127,7 @@ export function SourceEditorDialog({
};
if (!open) return null;
return (
<>
<FloatingLayerContext.Provider value={layer.context}>
<div className="fixed inset-0 z-[390] pointer-events-none" role="presentation">
<section
ref={dialog}
@@ -133,12 +136,20 @@ export function SourceEditorDialog({
aria-modal="false"
aria-label="转换后的 MJCF 编辑器"
style={
maximized ? undefined : { left: position.x, top: position.y, width: 900, height: 650 }
maximized
? { zIndex: layer.zIndex }
: {
zIndex: layer.zIndex,
left: `clamp(16px, ${position.x}px, max(16px, calc(100vw - 916px)))`,
top: `clamp(16px, ${position.y}px, max(16px, calc(100vh - 666px)))`,
width: 'min(900px, calc(100vw - 32px))',
height: 'min(650px, calc(100vh - 32px))',
}
}
className={`source-editor-window pointer-events-auto fixed flex min-h-[360px] min-w-[520px] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized ? 'inset-0 h-full w-full' : 'resize'}`}
className={`source-editor-window pointer-events-auto fixed flex min-h-64 min-w-0 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized ? 'inset-0 h-full w-full' : 'resize'}`}
>
<header
className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
className="flex min-h-11 flex-wrap shrink-0 py-2 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
onPointerDown={pointerDown}
onPointerMove={pointerMove}
onPointerUp={() => {
@@ -151,16 +162,16 @@ export function SourceEditorDialog({
<div className="truncate font-mono text-xs font-semibold text-text-primary">
转换后的 MJCF
</div>
<div className="truncate font-mono text-[9px] text-text-tertiary" title={filePath}>
<div className="break-all font-mono text-xs text-text-tertiary" title={filePath}>
{filePath}
</div>
</div>
<span className="text-[10px] text-text-tertiary">{contentSize(code)}</span>
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold text-accent">
<span className="text-xs text-text-tertiary">{contentSize(code)}</span>
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-xs font-semibold text-accent">
缓存文件 · 可编辑
</span>
{dirty && (
<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">
<span className="rounded bg-warning-soft px-1.5 py-0.5 text-xs font-semibold text-warning">
已修改
</span>
)}
@@ -193,6 +204,14 @@ export function SourceEditorDialog({
<X className="h-4 w-4" />
</IconButton>
</header>
<p className="border-b border-border px-3 py-2 text-xs text-warning">
保存将重新编译并载入模型;未保存的源码关闭后无法恢复。
</p>
{saveError && (
<p role="alert" className="px-3 py-2 text-xs text-danger">
{saveError}
</p>
)}
<div className="min-h-0 flex-1 bg-input">
<Editor
height="100%"
@@ -218,8 +237,8 @@ export function SourceEditorDialog({
}}
/>
</div>
<footer className="flex h-7 shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-[10px]">
<div className={problem ? 'truncate text-warning' : 'text-success'}>
<footer className="flex min-h-7 flex-wrap shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-xs">
<div className={problem ? 'break-words text-warning' : 'text-success'}>
{problem ? `XML 错误:${problem}` : '✓ XML 结构正常'}
</div>
<div className="flex items-center gap-2 font-mono text-text-tertiary">
@@ -243,6 +262,6 @@ export function SourceEditorDialog({
当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。
</p>
</ConfirmDialog>
</>
</FloatingLayerContext.Provider>
);
}
+18 -37
View File
@@ -1,7 +1,5 @@
import type { ReactNode } from 'react';
import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react';
import { Clock3, TriangleAlert } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Kbd } from '../../components/ui';
import { useAppStore } from '../../stores/useAppStore';
import { PerformancePopover } from './PerformancePopover';
export interface StatusBarProps {
@@ -12,47 +10,30 @@ export interface StatusBarProps {
loaded: boolean;
overBudget: boolean;
}
function Item({
icon: Icon,
children,
className = '',
}: {
icon: typeof Clock3;
children: ReactNode;
className?: string;
}) {
return (
<span className={`items-center gap-1.5 ${className || 'flex'}`}>
<Icon aria-hidden="true" className="h-3 w-3 text-text-tertiary" />
{children}
</span>
);
}
export function StatusBar({ time, fps, stepMs, memoryMb, loaded, overBudget }: StatusBarProps) {
return (
<footer className="technical-value relative z-30 flex h-7 shrink-0 items-center gap-3 overflow-hidden border-t border-border bg-panel px-3 text-[11px] text-text-tertiary lg:gap-5">
<Item icon={Clock3}>时间 {time?.toFixed(3) ?? '—'} s</Item>
<PerformancePopover fps={fps} stepMs={stepMs} memoryMb={memoryMb} overBudget={overBudget} />
<Item icon={MemoryStick} className="hidden items-center gap-1.5 md:flex">
内存 {memoryMb === undefined ? '—' : `${memoryMb.toFixed(1)} MiB`}
</Item>
<Item icon={Box} className="hidden items-center gap-1.5 sm:flex">
WASM {loaded ? '已加载' : '未加载'}
</Item>
<div className="technical-value flex flex-wrap items-center gap-2 text-xs text-text-secondary">
<span className="flex items-center gap-1 whitespace-nowrap">
<Clock3 aria-hidden="true" className="h-3.5 w-3.5" />
时间 {time?.toFixed(3) ?? '—'} s
</span>
<PerformancePopover
fps={fps}
stepMs={stepMs}
memoryMb={memoryMb}
loaded={loaded}
overBudget={overBudget}
/>
{overBudget && (
<span className="hidden min-w-0 items-center gap-1 truncate text-warning lg:flex">
<TriangleAlert className="h-3 w-3 shrink-0" />
主线程超出步进预算,已限制追帧
<span className="flex items-center gap-1 text-warning">
<TriangleAlert aria-hidden="true" className="h-3.5 w-3.5" />
步进预算超限
</span>
)}
<span className="ml-auto hidden items-center gap-1.5 xl:flex">
<Kbd>Space</Kbd> 播放/暂停 · <Kbd>R</Kbd> 重置 · <Kbd>1/2/3</Kbd> 模式
</span>
</footer>
</div>
);
}
/** 仅让状态栏订阅高频性能数据,避免带动整个工作台重渲染。 */
/** 仅浮层订阅高频性能数据,不把 FPS/耗时提升到 App。 */
export function StoreStatusBar() {
const metrics = useAppStore(
useShallow((state) => ({
@@ -28,7 +28,6 @@ export function ToolbarOverflowMenu({
return (
<DropdownMenu
label="更多工作台操作"
className="xl:hidden"
items={[
{
id: 'commands',
@@ -1,7 +1,7 @@
import { useState, type ReactNode } from 'react';
import { Camera, Settings2 } from 'lucide-react';
import type { CameraDirection, UrdfEnhancementOptions } from '../../project/urdfToMjcf';
import { Button, Dialog, Select } from '../../components/ui';
import { Button, Dialog, Select, Tooltip } from '../../components/ui';
function OptionCard({
checked,
@@ -84,9 +84,8 @@ export function UrdfImportOptionsDialog({
</div>
}
>
<p className="text-sm text-text-secondary">
导入 <strong className="text-text-primary">{path}</strong>{' '}
后,是否自动补充以下仿真组件?稍后重新选择该 URDF 时仍会再次询问。
<p className="break-words text-sm text-text-secondary">
导入 <strong className="text-text-primary">{path}</strong> 的仿真组件
</p>
<div className="mt-4 space-y-3">
<OptionCard
@@ -94,7 +93,7 @@ export function UrdfImportOptionsDialog({
onChange={(addActuators) => setOptions((value) => ({ ...value, addActuators }))}
icon={<Settings2 className="h-4 w-4" />}
title="为关节添加驱动器"
description="为每个 hinge/slide 关节生成控制输入不限幅的 motor 驱动器;hinge 使用 N·m、slide 使用 N。kp/kv 用于调整对应 MJCF 关节的刚度和阻尼,已有驱动器不会重复添加。"
description="生成不限幅 motor 控制输入:hinge 为 N·m,slide 为 N。已有驱动器不重复添加。"
/>
<OptionCard
checked={options.addSensors}
@@ -104,9 +103,9 @@ export function UrdfImportOptionsDialog({
description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"
/>
{options.addSensors && (
<div className="rounded-lg border border-border bg-surface p-3">
<div className="border-t border-border pt-3">
<div className="mb-2 text-xs font-medium text-text-primary">摄像头安装参数</div>
<label className="block text-[11px] text-text-secondary">
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">固连 Body</span>
<Select
aria-label="摄像头固连 Body"
@@ -132,11 +131,14 @@ export function UrdfImportOptionsDialog({
</label>
<div className="mt-3 grid grid-cols-3 gap-2">
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
<label key={axis} className="text-[11px] text-text-secondary">
<span className="mb-1 block">位置 {axis}(m)</span>
<label key={axis} className="text-xs text-text-secondary">
<span className="mb-0.5 flex items-center gap-1">
<span className={`axis-badge axis-${axis.toLowerCase()}`}>{axis}</span>
位置(m)
</span>
<input
aria-label={`摄像头位置 ${axis}`}
className="field h-8 w-full px-2 text-xs"
className="field technical-value h-8 w-full px-1.5 text-xs"
type="number"
step="0.01"
value={(options.cameraPosition ?? [0.1, 0, 0.05])[index]}
@@ -145,7 +147,7 @@ export function UrdfImportOptionsDialog({
</label>
))}
</div>
<label className="mt-3 block text-[11px] text-text-secondary">
<label className="mt-3 block text-xs text-text-secondary">
<span className="mb-1 block">镜头朝向(Body 局部轴)</span>
<Select
aria-label="摄像头朝向"
@@ -163,9 +165,11 @@ export function UrdfImportOptionsDialog({
))}
</Select>
</label>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
位置和朝向均相对于所选 Body;常见 ROS 头部摄像头使用 +X 朝前、+Z 朝上。
</p>
<Tooltip content="位置和朝向相对于所选 Body;ROS 摄像头通常 +X 朝前、+Z 朝上">
<span tabIndex={0} className="mt-2 inline-block text-xs text-text-tertiary">
安装坐标说明
</span>
</Tooltip>
</div>
)}
</div>
@@ -1,8 +1,6 @@
import { Crosshair, Hand, MapPinned, MousePointer2, RotateCcw } from 'lucide-react';
import { Crosshair, Hand, MousePointer2 } from 'lucide-react';
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
import { ViewerDisplayPopover } from './ViewerDisplayPopover';
import { ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
const tools: ToolbarItem<InteractionMode>[] = [
{ value: 'select', label: '选择', icon: MousePointer2 },
{ value: 'joint', label: '关节拖动', icon: Hand },
@@ -10,42 +8,12 @@ const tools: ToolbarItem<InteractionMode>[] = [
];
export function ViewerToolDock({
mode,
display,
mapEditContext,
onModeChange,
onDisplayChange,
onResetCamera,
}: {
mode: InteractionMode;
display: ViewerDisplayOptions;
mapEditContext?: {
active: boolean;
label: string;
onActivate: () => void;
};
onModeChange: (mode: InteractionMode) => void;
onDisplayChange: (next: ViewerDisplayOptions) => void;
onResetCamera: () => void;
}) {
return (
<div className="flex items-center gap-1">
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
{mapEditContext && (
<button
type="button"
aria-label={`地图编辑联动:${mapEditContext.label}`}
aria-pressed={mapEditContext.active}
onClick={mapEditContext.onActivate}
className={`hidden h-7 items-center gap-1.5 rounded-lg border px-2 text-[10px] font-semibold transition-colors md:flex ${mapEditContext.active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-border bg-surface/80 text-text-tertiary hover:bg-element-hover hover:text-text-primary'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
{mapEditContext.label}
</button>
)}
<ViewerDisplayPopover value={display} onChange={onDisplayChange} />
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
</div>
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
);
}
@@ -0,0 +1,26 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { ViewportHUD } from './ViewportHUD';
import { useAppStore } from '../../stores/useAppStore';
describe('ViewportHUD', () => {
beforeEach(() => useAppStore.getState().clearProject());
it('独立更新性能,不带动父布局;细节按需展示,无手势长串', () => {
const parent = vi.fn();
function Layout() {
parent();
return <ViewportHUD />;
}
render(<Layout />);
expect(screen.getByLabelText('视口状态')).toHaveTextContent('待导入');
act(() => useAppStore.getState().setMetrics(60, 1.25, 32, false));
expect(screen.getByRole('button', { name: /FPS 60/ })).toBeVisible();
expect(screen.queryByText(/物理 1.25/)).not.toBeInTheDocument();
expect(screen.queryByLabelText('视口操作提示')).not.toBeInTheDocument();
expect(parent).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: /FPS/ }));
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('1.25 ms');
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('WASM未加载');
act(() => useAppStore.getState().setMetrics(30, 40, 32, true));
expect(screen.getByLabelText('视口状态')).toHaveTextContent('步进预算超限');
expect(parent).toHaveBeenCalledOnce();
});
});
+20 -83
View File
@@ -1,88 +1,25 @@
import { CirclePause, CirclePlay, Mouse, MousePointer2 } from 'lucide-react';
import type { InteractionMode, ViewerSelection } from '../../viewer/MuJoCoViewer';
import { Badge, Kbd } from '../../components/ui';
const labels: Record<InteractionMode, string> = {
select: '选择',
joint: '关节拖动',
force: '外力施加',
};
const primaryGestures: Record<InteractionMode, string> = {
select: '左键旋转',
joint: '左键拖动关节',
force: '左键拖动施力',
};
export function ViewportHUD({
paused,
mode,
selection,
ready,
mapEditing = false,
}: {
paused: boolean;
mode: InteractionMode;
selection: ViewerSelection | null;
ready: boolean;
mapEditing?: boolean;
}) {
if (!ready) return null;
import { CirclePause, CirclePlay } from 'lucide-react';
import { useAppStore } from '../../stores/useAppStore';
import { Badge } from '../../components/ui';
import { StoreStatusBar } from './StatusBar';
export function ViewportHUD() {
const paused = useAppStore((state) => state.paused);
const ready = useAppStore((state) => Boolean(state.snapshot));
return (
<>
<div
aria-label="视口状态"
className="pointer-events-none absolute left-3 top-3 z-10 flex max-w-[70%] flex-wrap items-center gap-1.5"
>
<Badge tone={paused ? 'neutral' : 'success'}>
{paused ? <CirclePause className="h-3 w-3" /> : <CirclePlay className="h-3 w-3" />}
{paused ? '已暂停' : '仿真中'}
</Badge>
<Badge tone="accent">
<MousePointer2 className="h-3 w-3" />
{labels[mode]}
</Badge>
{selection && (
<Badge title={`body ${selection.bodyId} · geom ${selection.geomId}`}>
{selection.bodyName}
</Badge>
)}
</div>
<div
aria-label="视口操作提示"
className="pointer-events-none absolute bottom-14 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel/85 px-3 py-1.5 text-[10px] text-text-secondary shadow-xl backdrop-blur lg:flex"
>
<Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary" />
<span>{primaryGestures[mode]}</span>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<span>右键平移</span>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<span>滚轮缩放</span>
{mapEditing && mode === 'select' ? (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>W</Kbd>
<Kbd>E</Kbd>
<Kbd>R</Kbd>
<span>变换</span>
<Kbd>F</Kbd>
<span>聚焦</span>
</>
<div
aria-label="视口状态"
className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-panel/90 px-2 py-1 text-xs shadow-sm"
>
<Badge tone={!ready || paused ? 'neutral' : 'success'}>
{!ready || paused ? (
<CirclePause className="h-3.5 w-3.5" />
) : (
mode !== 'select' && (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>1</Kbd>
<span>旋转视角</span>
</>
)
<CirclePlay className="h-3.5 w-3.5" />
)}
</div>
</>
{!ready ? '待导入' : paused ? '已暂停' : '仿真中'}
</Badge>
<StoreStatusBar />
</div>
);
}
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
/** 仅分配空间;槽位内容自行订阅状态,禁止在此接入仿真业务。 */
export function ViewportOverlayLayout({
status,
view,
notices,
context,
camera,
orientation,
controls,
draft,
}: {
status: ReactNode;
view: ReactNode;
notices?: ReactNode;
context?: ReactNode;
camera?: ReactNode;
orientation?: ReactNode;
controls: ReactNode;
draft?: ReactNode;
}) {
return (
<div className="viewport-overlays pointer-events-none absolute inset-0 z-20 p-3">
<div className="viewport-overlay-top">
<div data-overlay-slot="status">{status}</div>
<div data-overlay-slot="view">{view}</div>
</div>
<div className="viewport-overlay-notices">
<div data-overlay-slot="context">{context}</div>
<div data-overlay-slot="notices">{notices}</div>
</div>
<div className="min-h-0 flex-1" />
<div className="viewport-overlay-bottom">
<div data-overlay-slot="camera">{camera}</div>
<div data-overlay-slot="orientation">{orientation}</div>
<div className="viewport-overlay-actions">
<div data-overlay-slot="controls">{controls}</div>
<div data-overlay-slot="draft">{draft}</div>
</div>
</div>
</div>
);
}
@@ -2,57 +2,41 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { WorkbenchHeader } from './WorkbenchHeader';
const fn = () => {};
describe('WorkbenchHeader', () => {
it('透传仿真动作且保留可访问名称', () => {
const pause = vi.fn(),
step = vi.fn(),
reset = vi.fn(),
speed = vi.fn(),
openSource = vi.fn();
it('保留导入、工程菜单、命令与侧栏入口,不再重复仿真控制', () => {
const source = vi.fn(),
files = vi.fn(),
left = vi.fn();
render(
<WorkbenchHeader
paused
ready
speed={1}
theme="dark"
loading={false}
hasProject
leftOpen
rightOpen
fullscreen={false}
center={<span>工具</span>}
onFiles={fn}
onFiles={files}
onFolder={fn}
onOpenSource={openSource}
onTogglePause={pause}
onStep={step}
onReset={reset}
onSpeed={speed}
onToggleLeft={fn}
onOpenSource={source}
onToggleLeft={left}
onToggleRight={fn}
onToggleTheme={fn}
onHelp={fn}
onCommands={fn}
onToggleFullscreen={fn}
/>,
);
const sourceButton = screen.getByRole('button', { name: '源代码' });
expect(sourceButton).toHaveTextContent('源代码');
fireEvent.click(sourceButton);
fireEvent.click(screen.getByRole('button', { name: '▶ 播放' }));
fireEvent.click(screen.getByRole('button', { name: '单步' }));
fireEvent.click(screen.getByRole('button', { name: '重置' }));
fireEvent.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } });
expect(openSource).toHaveBeenCalledTimes(1);
expect(pause).toHaveBeenCalledTimes(1);
expect(step).toHaveBeenCalledTimes(1);
expect(reset).toHaveBeenCalledTimes(1);
expect(speed).toHaveBeenCalledWith(2);
expect(screen.getByRole('button', { name: '切换到白天主题' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'MuJoCo' })).toBeVisible();
fireEvent.change(screen.getByLabelText('打开文件'), {
target: { files: [new File(['x'], 'model.xml')] },
});
expect(files).toHaveBeenCalledOnce();
expect(screen.getByLabelText('打开文件夹')).toHaveAttribute('webkitdirectory');
expect(screen.queryByRole('button', { name: '源代码' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '工程' }));
fireEvent.click(screen.getByRole('button', { name: '源代码' }));
expect(source).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: '隐藏工程面板' }));
expect(left).toHaveBeenCalledOnce();
expect(screen.getByRole('button', { name: '隐藏工程面板' })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(screen.getByRole('button', { name: '打开命令面板' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '进入全屏' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '打开命令面板' })).toBeVisible();
expect(screen.queryByRole('button', { name: '▶ 播放' })).not.toBeInTheDocument();
});
});
@@ -1,88 +1,45 @@
import type { ChangeEvent, ReactNode } from 'react';
import {
Boxes,
CircleHelp,
Code2,
Expand,
FolderOpen,
Minimize,
PanelLeft,
PanelRight,
Pause,
Play,
RotateCcw,
Search,
StepForward,
Sun,
Moon,
Upload,
} from 'lucide-react';
import { Button, IconButton, Select } from '../../components/ui';
import { Boxes, Code2, FolderOpen, PanelLeft, PanelRight, Search, Upload } from 'lucide-react';
import { IconButton, Popover } from '../../components/ui';
const fileActionClass =
'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass = 'hidden sm:inline';
'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass = 'whitespace-nowrap';
export function WorkbenchHeader({
paused,
ready,
speed,
theme,
loading,
leftOpen,
rightOpen,
fullscreen,
hasProject,
center,
endActions,
compactMenu,
onFiles,
onFolder,
onOpenSource,
onTogglePause,
onStep,
onReset,
onSpeed,
onToggleLeft,
onToggleRight,
onToggleTheme,
onHelp,
onCommands,
onToggleFullscreen,
}: {
paused: boolean;
ready: boolean;
speed: number;
theme: 'light' | 'dark';
loading: boolean;
leftOpen: boolean;
rightOpen: boolean;
fullscreen: boolean;
hasProject?: boolean;
center: ReactNode;
endActions?: ReactNode;
compactMenu?: ReactNode;
onFiles: (event: ChangeEvent<HTMLInputElement>) => void;
onFolder: (event: ChangeEvent<HTMLInputElement>) => void;
onOpenSource?: () => void;
onTogglePause: () => void;
onStep: () => void;
onReset: () => void;
onSpeed: (value: number) => void;
onToggleLeft: () => void;
onToggleRight: () => void;
onToggleTheme: () => void;
onHelp: () => void;
onCommands: () => void;
onToggleFullscreen: () => void;
}) {
return (
<header className="workbench-header relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<header className="workbench-header relative z-40 flex h-11 shrink-0 justify-between items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<div className="flex min-w-0 items-center gap-1">
<div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
<div className="mr-2 flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
<Boxes aria-hidden="true" className="h-3.5 w-3.5" />
</span>
<h1 className="truncate text-sm font-semibold tracking-tight text-text-primary">
MuJoCo Web 仿真平台
MuJoCo
</h1>
</div>
<label
@@ -119,48 +76,34 @@ export function WorkbenchHeader({
onChange={onFolder}
/>
</label>
<button
type="button"
className={`${fileActionClass} disabled:cursor-not-allowed disabled:opacity-40`}
title="查看和修改缓存源代码"
disabled={!hasProject || loading}
onClick={onOpenSource}
<Popover
label="工程菜单"
placement="bottom-left"
trigger={({ toggle }) => (
<button type="button" className={fileActionClass} onClick={toggle}>
工程
</button>
)}
>
<Code2 className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}>源代码</span>
</button>
{({ close }) => (
<div className="w-52 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl">
<button
type="button"
className="flex h-8 w-full items-center gap-2 rounded px-2 text-xs hover:bg-element-hover disabled:opacity-40"
disabled={!hasProject || loading}
onClick={() => {
close();
onOpenSource?.();
}}
>
<Code2 className="h-4 w-4" />
源代码
</button>
</div>
)}
</Popover>
</div>
<div className="flex items-center justify-center">{center}</div>
<div className="flex min-w-0 items-center justify-end gap-0.5">
<Button
variant="ghost"
onClick={onTogglePause}
disabled={!ready}
aria-label={paused ? '▶ 播放' : '⏸ 暂停'}
icon={paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
>
{paused ? '播放' : '暂停'}
</Button>
<IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready || !paused}>
<StepForward className="h-3.5 w-3.5" />
</IconButton>
<IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
<Select
aria-label="仿真速度"
value={speed}
disabled={loading}
onChange={(event) => onSpeed(Number(event.target.value))}
className="w-[70px]"
>
<option value={0.25}>0.25×</option>
<option value={0.5}>0.5×</option>
<option value={1}>1×</option>
<option value={2}>2×</option>
<option value={4}>4×</option>
</Select>
<span className="mx-1 h-5 border-l border-border" />
<IconButton
tooltip={leftOpen ? '隐藏工程面板' : '显示工程面板'}
aria-label={leftOpen ? '隐藏工程面板' : '显示工程面板'}
@@ -179,38 +122,9 @@ export function WorkbenchHeader({
</IconButton>
{endActions}
{compactMenu}
<IconButton
className="hidden xl:inline-flex"
tooltip="命令面板(Ctrl+K)"
aria-label="打开命令面板"
onClick={onCommands}
>
<IconButton tooltip="命令面板(Ctrl+K)" aria-label="打开命令面板" onClick={onCommands}>
<Search className="h-4 w-4" />
</IconButton>
<IconButton
className="hidden xl:inline-flex"
tooltip={fullscreen ? '退出全屏' : '进入全屏'}
aria-label={fullscreen ? '退出全屏' : '进入全屏'}
onClick={onToggleFullscreen}
>
{fullscreen ? <Minimize className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
</IconButton>
<IconButton
className="hidden xl:inline-flex"
tooltip="快捷键帮助"
aria-label="快捷键帮助"
onClick={onHelp}
>
<CircleHelp className="h-4 w-4" />
</IconButton>
<IconButton
className="hidden xl:inline-flex"
tooltip={theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题'}
aria-label={theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题'}
onClick={onToggleTheme}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</IconButton>
</div>
</header>
);
@@ -6,12 +6,10 @@ import {
LoaderCircle,
PlayCircle,
Settings2,
ShieldCheck,
Sparkles,
Upload,
UploadCloud,
} from 'lucide-react';
import { ProgressBar, Skeleton } from '../../components/ui';
import { Button, ProgressBar, Skeleton, Tooltip } from '../../components/ui';
export interface ImportProgress {
title?: string;
@@ -41,79 +39,36 @@ export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
return (
<section
aria-label="导入模型工程"
className="workspace-welcome relative w-[min(600px,calc(100vw-32px))] overflow-hidden rounded-[22px] border border-border-strong bg-panel/90 px-6 py-6 text-center shadow-2xl backdrop-blur-md sm:px-7"
className="mx-auto w-full max-w-md rounded-lg border border-border bg-panel p-6 text-center"
>
<div aria-hidden="true" className="welcome-glow" />
<div className="relative">
<div className="mb-4 flex items-center justify-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-accent">
<Sparkles className="h-3.5 w-3.5" />
Local Simulation Workspace
</div>
<span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-accent/25 bg-accent-soft text-accent shadow-[0_12px_32px_rgba(53,199,146,0.16)]">
<UploadCloud className="h-6 w-6" />
</span>
<h2 className="text-lg font-semibold tracking-tight text-text-primary">
拖放模型工程到此处
</h2>
<p className="mt-1.5 text-xs leading-5 text-text-tertiary">
本地解析、编译并运行机器人模型,无需上传资源
</p>
<div
className="mt-4 flex flex-wrap items-center justify-center gap-1.5"
aria-label="支持格式"
<h2 className="text-base font-semibold">拖放模型工程到此处</h2>
<p className="mt-2 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
<div className="mt-4 flex flex-wrap justify-center gap-2">
<Button
variant="primary"
icon={<Upload className="h-4 w-4" />}
onClick={() => document.getElementById('mujoco-project-files')?.click()}
>
{['MJCF / XML', 'URDF', 'ZIP', 'OBJ / STL / DAE'].map((format) => (
<span
key={format}
className="rounded-full border border-border bg-surface/80 px-2.5 py-1 text-[10px] font-medium text-text-secondary"
>
{format}
</span>
))}
</div>
<div className="mt-5 flex items-center justify-center gap-2">
<label
htmlFor="mujoco-project-files"
className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3.5 text-xs font-semibold text-white shadow-[0_8px_22px_rgba(22,131,95,0.22)] transition-[background-color,transform,box-shadow] hover:-translate-y-0.5 hover:bg-accent-hover hover:shadow-[0_10px_26px_rgba(22,131,95,0.28)] focus-within:ring-2 focus-within:ring-accent/40"
>
<Upload className="h-3.5 w-3.5" />
选择文件
</label>
<label
htmlFor="mujoco-project-folder"
className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-surface px-3.5 text-xs font-semibold text-text-primary shadow-sm transition-[background-color,transform] hover:-translate-y-0.5 hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/40"
>
<FolderOpen className="h-3.5 w-3.5" />
选择文件夹
</label>
</div>
<ol
aria-label="仿真工作流程"
className="mt-6 hidden grid-cols-3 gap-2 border-t border-border/80 pt-4 sm:grid"
选择文件
</Button>
<Button
icon={<FolderOpen className="h-4 w-4" />}
onClick={() => document.getElementById('mujoco-project-folder')?.click()}
>
{workflow.map((item, index) => (
<li
key={item.label}
className="rounded-xl border border-border/70 bg-surface/70 px-3 py-3 text-left"
>
<div className="flex items-center gap-2">
<span className="technical-value text-[10px] font-semibold text-accent">
0{index + 1}
</span>
<item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary" />
<span className="text-xs font-semibold text-text-primary">{item.label}</span>
</div>
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
{item.detail}
</span>
选择文件夹
</Button>
</div>
<details className="domain-details mt-4 text-left">
<summary>导入与仿真流程</summary>
<ol aria-label="仿真工作流程" className="space-y-2 text-xs text-text-secondary">
{workflow.map((item) => (
<li key={item.label}>
{item.label} · {item.detail}
</li>
))}
</ol>
<p className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
<ShieldCheck aria-hidden="true" className="h-3.5 w-3.5 text-success" />
模型与资源仅在当前浏览器会话中处理
</p>
</div>
</details>
<p className="mt-3 text-xs text-text-tertiary">模型与资源仅在当前浏览器会话中处理</p>
</section>
);
}
@@ -122,20 +77,20 @@ function LoadingCard({ progress, compact }: { progress?: ImportProgress; compact
const title = progress?.title ?? '正在加载 MuJoCo 与模型';
return (
<div
className={`${compact ? 'w-[min(360px,calc(100vw-32px))]' : 'w-[min(400px,calc(100vw-32px))]'} overflow-hidden rounded-2xl border border-border-strong bg-panel/95 shadow-2xl backdrop-blur-md`}
className={`${compact ? 'w-[min(360px,calc(100vw-32px))]' : 'w-[min(400px,calc(100vw-32px))]'} overflow-hidden rounded-lg border border-border bg-panel shadow-xl`}
>
<div className="h-0.5 bg-gradient-to-r from-transparent via-accent to-transparent opacity-90" />
<div className={compact ? 'px-4 py-3.5' : 'px-5 py-5'}>
<div className="flex items-start gap-3">
<span className="relative mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent-soft text-accent">
<LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin" />
<span className="absolute inset-0 animate-ping rounded-xl border border-accent/20" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-text-primary">{title}</div>
<div className="mt-0.5 truncate text-[11px] text-text-tertiary">
{progress?.detail ?? '首次运行会下载并编译本地 WebAssembly 运行时'}
</div>
<Tooltip content={progress?.detail ?? '首次运行会下载并编译本地 WebAssembly 运行时'}>
<span tabIndex={0} className="mt-1 inline-block text-xs text-text-tertiary">
加载详情
</span>
</Tooltip>
</div>
</div>
{progress ? (
@@ -168,7 +123,7 @@ export function WorkspaceOverlays({
<>
{!hasSnapshot && !loading && (
<div className="pointer-events-none absolute inset-0 grid place-items-center p-4">
<div className="pointer-events-auto">
<div className="pointer-events-auto w-full max-w-[600px]">
<EmptyWorkspace />
</div>
</div>
@@ -180,7 +135,7 @@ export function WorkspaceOverlays({
aria-label={progress?.label ?? '正在加载 MuJoCo 与模型'}
className={
hasSnapshot
? 'pointer-events-none absolute right-4 top-4 z-30'
? 'pointer-events-none absolute inset-0 z-30 grid place-items-center p-4'
: 'absolute inset-0 z-30 grid place-items-center bg-app/70 p-4 backdrop-blur-[3px]'
}
>
@@ -1,5 +1,5 @@
import type { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { WorkspaceToolsPanel } from './WorkspaceToolsPanel';
@@ -56,6 +56,20 @@ function props(): ComponentProps<typeof WorkspaceToolsPanel> {
}
describe('WorkspaceToolsPanel', () => {
it('首次展开后折叠训练组不丢连接凭据与状态', () => {
render(<WorkspaceToolsPanel {...props()} />);
expect(screen.queryByLabelText('训练服务访问令牌')).not.toBeInTheDocument();
const section = screen.getByRole('button', { name: '强化学习任务' });
fireEvent.click(section);
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
target: { value: 'session-only' },
});
fireEvent.click(section);
expect(screen.getByLabelText('训练服务访问令牌')).not.toBeVisible();
expect(section).toHaveTextContent('离线');
fireEvent.click(section);
expect(screen.getByLabelText('训练服务访问令牌')).toHaveValue('session-only');
});
it('按实时控制到自动化流程排序,并默认折叠所有控制台组件', () => {
render(<WorkspaceToolsPanel {...props()} />);
@@ -63,12 +77,12 @@ describe('WorkspaceToolsPanel', () => {
.getAllByRole('button')
.filter((button) => button.hasAttribute('aria-expanded'));
const expected = [
['执行器实时控制', '控制'],
['关节姿态调试', '调试'],
['外力交互参数', '交互'],
['Python 脚本控制', '控制', '运行'],
['ONNX 策略运行', '推理', '停止'],
['强化学习任务', '训练'],
['执行器实时控制'],
['关节姿态调试'],
['外力交互参数'],
['Python 脚本控制', '运行'],
['ONNX 策略运行', '停止'],
['强化学习任务'],
];
expect(sections).toHaveLength(expected.length);
@@ -1,3 +1,4 @@
import { useState } from 'react';
import type { TrainingSceneCompiler } from '../../map/trainingMap';
import type { PolicyDeployment } from '../../rl/deployment';
import type { PlacedMapAsset } from '../../map/types';
@@ -10,25 +11,22 @@ import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/Si
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
import { Badge, Button, CollapsibleSection } from '../../components/ui';
import { Badge, Button, CollapsibleSection, Tooltip } from '../../components/ui';
import type { RightSidebarView } from './RightSidebarTabs';
export type WorkspaceTool = Exclude<RightSidebarView, 'inspector'>;
function ConsoleSectionBadges({
category,
count,
state,
running = false,
}: {
category: string;
count?: number;
state?: string;
running?: boolean;
}) {
return (
<span className="flex shrink-0 items-center gap-1">
<Badge tone="accent">{category}</Badge>
{count !== undefined && <Badge>{count}</Badge>}
{state && <Badge tone={running ? 'success' : 'neutral'}>{state}</Badge>}
</span>
@@ -126,6 +124,7 @@ export function WorkspaceToolsPanel({
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}) {
const [trainingSummary, setTrainingSummary] = useState('');
const resolvedControllerStatus = controllerStatus ?? snapshot?.controller;
const resolvedPolicyStatus = policyStatus ?? snapshot?.rlPolicy;
const controls = snapshot ? (
@@ -133,7 +132,8 @@ export function WorkspaceToolsPanel({
<CollapsibleSection
title="执行器实时控制"
defaultOpen={false}
badge={<ConsoleSectionBadges category="控制" count={snapshot.actuators.length} />}
keepMounted
badge={<ConsoleSectionBadges count={snapshot.actuators.length} />}
>
{snapshot.actuators.length ? (
snapshot.actuators.map((actuator) => (
@@ -151,7 +151,8 @@ export function WorkspaceToolsPanel({
<CollapsibleSection
title="关节姿态调试"
defaultOpen={false}
badge={<ConsoleSectionBadges category="调试" count={snapshot.joints.length} />}
keepMounted
badge={<ConsoleSectionBadges count={snapshot.joints.length} />}
>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={onResetJoints}>重置关节</Button>
@@ -203,25 +204,33 @@ export function WorkspaceToolsPanel({
<CollapsibleSection
title="外力交互参数"
defaultOpen={false}
badge={<ConsoleSectionBadges category="交互" />}
keepMounted
badge={<ConsoleSectionBadges />}
>
<ControlSlider
label={`${forceScale.toFixed(0)} N/屏幕单位`}
label="外力强度"
unit=" N/屏幕单位"
value={forceScale}
min={5}
max={200}
onChange={onForceScale}
/>
<p className="text-xs text-text-tertiary">
选择“外力施加”,在动态物体上按住拖动,松开即清零。
</p>
<Tooltip content="选择视口底部的外力施加工具,在动态物体上拖动,松开即清零。">
<button
type="button"
className="min-h-7 text-xs text-text-secondary underline decoration-dotted"
>
外力操作说明
</button>
</Tooltip>
</CollapsibleSection>
<CollapsibleSection
title="Python 脚本控制"
forceOpen={Boolean(resolvedControllerStatus?.error)}
defaultOpen={false}
keepMounted
badge={
<ConsoleSectionBadges
category="控制"
state={
resolvedControllerStatus
? resolvedControllerStatus.enabled
@@ -248,10 +257,11 @@ export function WorkspaceToolsPanel({
</CollapsibleSection>
<CollapsibleSection
title="ONNX 策略运行"
forceOpen={Boolean(resolvedPolicyStatus?.error)}
defaultOpen={false}
keepMounted
badge={
<ConsoleSectionBadges
category="推理"
state={
resolvedPolicyStatus ? (resolvedPolicyStatus.enabled ? '运行' : '停止') : undefined
}
@@ -280,9 +290,16 @@ export function WorkspaceToolsPanel({
<CollapsibleSection
title="强化学习任务"
defaultOpen={false}
badge={<ConsoleSectionBadges category="训练" />}
keepMounted
badge={
<ConsoleSectionBadges
state={trainingSummary || undefined}
running={trainingSummary === '训练中'}
/>
}
>
<LocalTrainingPanel
onStatusChange={setTrainingSummary}
onPolicyReady={onImportPolicy}
compileScene={compileTrainingScene}
sceneMaps={trainingSceneMaps}
@@ -300,21 +317,23 @@ export function WorkspaceToolsPanel({
aria-label={active === 'controls' ? '控制台' : '数据录制'}
className="min-h-0 flex-1 overflow-y-auto panel-scroll"
>
{active === 'controls' ? (
controls
) : snapshot ? (
<DataRecordingPanel
status={snapshot.telemetry}
bodies={snapshot.bodies}
onConfigure={onDataRecorderConfigure}
onStart={onDataRecordingStart}
onStop={onDataRecordingStop}
onClear={onDataRecordingClear}
onExport={onDataRecordingExport}
/>
) : (
<p className="p-4 text-sm text-text-tertiary">导入模型后显示数据录制工具</p>
)}
<div hidden={active !== 'controls'}>{controls}</div>
<div hidden={active !== 'data'}>
{active === 'data' &&
(snapshot ? (
<DataRecordingPanel
status={snapshot.telemetry}
bodies={snapshot.bodies}
onConfigure={onDataRecorderConfigure}
onStart={onDataRecordingStart}
onStop={onDataRecordingStop}
onClear={onDataRecordingClear}
onExport={onDataRecordingExport}
/>
) : (
<p className="p-4 text-sm text-text-tertiary">导入模型后显示数据录制工具</p>
))}
</div>
</div>
);
}