chore(web-platform): release V0.6.1 工程质量优化
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-08-28 15:38:10 +08:00
parent f4b415c54f
commit 60d3a6d68c
135 changed files with 13552 additions and 2882 deletions
@@ -1,39 +1,96 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {ActuatorControl} from './SidebarPanel';
import type {ActuatorInfo} from '../../simulation/SimulationSession';
import { fireEvent, render, screen } from '@testing-library/react';
import { ActuatorControl } from './SidebarPanel';
import type { ActuatorInfo } from '../../simulation/SimulationSession';
const actuator:ActuatorInfo={id:0,name:'shoulder_motor',value:.5,min:-1,max:1,limited:true,jointId:0,jointName:'shoulder',jointType:3,unit:'N·m',kind:'motor',controlCount:1,gear:2,gain:1,kp:0,kv:0,ctrlLimited:true,ctrlMin:-1,ctrlMax:1,forceLimited:true,forceMin:-20,forceMax:20};
const actuator: ActuatorInfo = {
id: 0,
name: 'shoulder_motor',
value: 0.5,
min: -1,
max: 1,
limited: true,
jointId: 0,
jointName: 'shoulder',
jointType: 3,
unit: 'N·m',
kind: 'motor',
controlCount: 1,
gear: 2,
gain: 1,
kp: 0,
kv: 0,
ctrlLimited: true,
ctrlMin: -1,
ctrlMax: 1,
forceLimited: true,
forceMin: -20,
forceMax: 20,
};
describe('ActuatorControl',()=>{
it('显示对应关节和常用力矩单位',()=>{
render(<ActuatorControl actuator={actuator} onControl={()=>{}} onParameters={()=>{}}/>);
describe('ActuatorControl', () => {
it('显示对应关节和常用力矩单位', () => {
render(<ActuatorControl actuator={actuator} onControl={() => {}} onParameters={() => {}} />);
expect(screen.getByText('shoulder_motor')).toBeVisible();
expect(screen.getByText('关节:shoulder')).toBeVisible();
expect(screen.getByText('1.000 N·m')).toBeVisible();
});
it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数',()=>{
const onControl=vi.fn(),onParameters=vi.fn();
render(<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters}/>);
fireEvent.change(screen.getByRole('slider'),{target:{value:'2'}});
it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数', () => {
const onControl = vi.fn(),
onParameters = vi.fn();
render(
<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters} />,
);
fireEvent.change(screen.getByRole('slider'), { target: { value: '2' } });
expect(onControl).toHaveBeenCalledWith(1);
fireEvent.click(screen.getByText('常用参数'));
expect(screen.queryByLabelText('传动比 gear')).not.toBeInTheDocument();
expect(screen.queryByLabelText('固定增益 gain')).not.toBeInTheDocument();
const kp=screen.getByLabelText(/kp(MJCF stiffness/);fireEvent.change(kp,{target:{value:'3'}});fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:3,ctrlLimited:true,forceLimited:true}));
const kp = screen.getByLabelText(/kp(MJCF stiffness/);
fireEvent.change(kp, { target: { value: '3' } });
fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(
expect.objectContaining({ kp: 3, ctrlLimited: true, forceLimited: true }),
);
});
it('position 伺服使用角度目标并开放 kp、kv',()=>{
const onParameters=vi.fn();
render(<ActuatorControl actuator={{...actuator,name:'shoulder_servo',kind:'position',unit:'°',value:Math.PI/2,min:-Math.PI,max:Math.PI,gear:1,kp:100,kv:10,gain:100}} onControl={()=>{}} onParameters={onParameters}/>);
expect(screen.getByText('90.000 °')).toBeVisible();fireEvent.click(screen.getByText('常用参数'));
const kp=screen.getByLabelText(/位置增益 kp/);fireEvent.change(kp,{target:{value:'150'}});fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:150,kv:10}));
it('position 伺服使用角度目标并开放 kp、kv', () => {
const onParameters = vi.fn();
render(
<ActuatorControl
actuator={{
...actuator,
name: 'shoulder_servo',
kind: 'position',
unit: '°',
value: Math.PI / 2,
min: -Math.PI,
max: Math.PI,
gear: 1,
kp: 100,
kv: 10,
gain: 100,
}}
onControl={() => {}}
onParameters={onParameters}
/>,
);
expect(screen.getByText('90.000 °')).toBeVisible();
fireEvent.click(screen.getByText('常用参数'));
const kp = screen.getByLabelText(/位置增益 kp/);
fireEvent.change(kp, { target: { value: '150' } });
fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({ kp: 150, kv: 10 }));
});
it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑',()=>{
render(<ActuatorControl actuator={{...actuator,name:'custom',kind:'other',unit:'',value:.25}} onControl={()=>{}} onParameters={()=>{}}/>);
it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑', () => {
render(
<ActuatorControl
actuator={{ ...actuator, name: 'custom', kind: 'other', unit: '', value: 0.25 }}
onControl={() => {}}
onParameters={() => {}}
/>,
);
expect(screen.getByText('0.250')).toBeVisible();
expect(screen.queryByText('常用参数')).not.toBeInTheDocument();
expect(screen.getByText(/不是可直接编辑的 motor\/position/)).toBeVisible();
@@ -1,12 +1,120 @@
import {useEffect,useId,useMemo,useRef,useState,type ReactNode} from 'react';
import {Search} from 'lucide-react';
import {Dialog,EmptySearchState,Kbd} from '../../components/ui';
export interface WorkbenchCommand{id:string;label:string;group:string;icon?:ReactNode;shortcut?:string;disabled?:boolean;run:()=>void;}
export function CommandPalette({open,onClose,commands}:{open:boolean;onClose:()=>void;commands:WorkbenchCommand[]}){
const [query,setQuery]=useState(''),[active,setActive]=useState(0),input=useRef<HTMLInputElement>(null),listId=useId();
const filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return commands.filter(command=>!needle||`${command.label} ${command.group}`.toLocaleLowerCase().includes(needle));},[commands,query]);
const enabled=filtered.flatMap((command,index)=>command.disabled?[]:[index]),highlighted=filtered[active]&&!filtered[active].disabled?active:(enabled[0]??-1);
useEffect(()=>{if(open)requestAnimationFrame(()=>input.current?.focus());},[open]);
const close=()=>{setQuery('');setActive(0);onClose();},execute=(command?:WorkbenchCommand)=>{if(!command||command.disabled)return;command.run();close();};
return <Dialog open={open} onClose={close} title="命令面板" className="max-w-xl"><div className="relative -m-4 mb-2 border-b border-border"><Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary"/><input ref={input} role="combobox" aria-label="搜索命令" aria-autocomplete="list" aria-expanded="true" aria-controls={listId} aria-activedescendant={highlighted>=0?`${listId}-${filtered[highlighted].id}`:undefined} value={query} onChange={event=>{setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!enabled.length)return;const current=Math.max(0,enabled.indexOf(highlighted));if(event.key==='ArrowDown'){event.preventDefault();setActive(enabled[(current+1)%enabled.length]);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(enabled[(current-1+enabled.length)%enabled.length]);}else if(event.key==='Enter'){event.preventDefault();execute(filtered[highlighted]);}}} placeholder="输入命令名称…" className="h-11 w-full bg-input pl-11 pr-4 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"/></div><div id={listId} role="listbox" aria-label="可用命令" className="max-h-80 space-y-1 overflow-auto pt-1">{filtered.length?filtered.map((command,index)=><button key={command.id} id={`${listId}-${command.id}`} type="button" role="option" aria-selected={index===highlighted} disabled={command.disabled} onMouseEnter={()=>{if(!command.disabled)setActive(index);}} onClick={()=>execute(command)} className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs outline-none ${index===highlighted?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'} disabled:opacity-40`}><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>{command.shortcut&&<Kbd>{command.shortcut}</Kbd>}</button>):<EmptySearchState label="没有匹配的命令"/>}</div></Dialog>;
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
import { Search } from 'lucide-react';
import { Dialog, EmptySearchState, Kbd } from '../../components/ui';
export interface WorkbenchCommand {
id: string;
label: string;
group: string;
icon?: ReactNode;
shortcut?: string;
disabled?: boolean;
run: () => void;
}
export function CommandPalette({
open,
onClose,
commands,
}: {
open: boolean;
onClose: () => void;
commands: WorkbenchCommand[];
}) {
const [query, setQuery] = useState(''),
[active, setActive] = useState(0),
input = useRef<HTMLInputElement>(null),
listId = useId();
const filtered = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return commands.filter(
(command) =>
!needle || `${command.label} ${command.group}`.toLocaleLowerCase().includes(needle),
);
}, [commands, query]);
const enabled = filtered.flatMap((command, index) => (command.disabled ? [] : [index])),
highlighted = filtered[active] && !filtered[active].disabled ? active : (enabled[0] ?? -1);
useEffect(() => {
if (open) requestAnimationFrame(() => input.current?.focus());
}, [open]);
const close = () => {
setQuery('');
setActive(0);
onClose();
},
execute = (command?: WorkbenchCommand) => {
if (!command || command.disabled) return;
command.run();
close();
};
return (
<Dialog open={open} onClose={close} title="命令面板" className="max-w-xl">
<div className="relative -m-4 mb-2 border-b border-border">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary" />
<input
ref={input}
role="combobox"
aria-label="搜索命令"
aria-autocomplete="list"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={
highlighted >= 0 ? `${listId}-${filtered[highlighted].id}` : undefined
}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={(event) => {
if (!enabled.length) return;
const current = Math.max(0, enabled.indexOf(highlighted));
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive(enabled[(current + 1) % enabled.length]);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActive(enabled[(current - 1 + enabled.length) % enabled.length]);
} else if (event.key === 'Enter') {
event.preventDefault();
execute(filtered[highlighted]);
}
}}
placeholder="输入命令名称…"
className="h-11 w-full bg-input pl-11 pr-4 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
/>
</div>
<div
id={listId}
role="listbox"
aria-label="可用命令"
className="max-h-80 space-y-1 overflow-auto pt-1"
>
{filtered.length ? (
filtered.map((command, index) => (
<button
key={command.id}
id={`${listId}-${command.id}`}
type="button"
role="option"
aria-selected={index === highlighted}
disabled={command.disabled}
onMouseEnter={() => {
if (!command.disabled) setActive(index);
}}
onClick={() => execute(command)}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs outline-none ${index === highlighted ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'} disabled:opacity-40`}
>
<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>
{command.shortcut && <Kbd>{command.shortcut}</Kbd>}
</button>
))
) : (
<EmptySearchState label="没有匹配的命令" />
)}
</div>
</Dialog>
);
}
@@ -1,5 +1,50 @@
import {useState} from 'react';
import {ChevronDown,TriangleAlert,X} from 'lucide-react';
import type {AppDiagnostic} from '../../stores/useAppStore';
import {IconButton} from '../../components/ui';
export function DiagnosticNotice({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){const [expanded,setExpanded]=useState(false);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"><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"><TriangleAlert className="h-4 w-4"/></span><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}>路径:{value.path}</p>}<button type="button" aria-expanded={expanded} className="mt-1 flex items-center gap-1 text-xs text-danger hover:underline" onClick={()=>setExpanded(v=>!v)}>技术详情<ChevronDown className={`h-3 w-3 ${expanded?'rotate-180':''}`}/></button></div><IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}><X className="h-4 w-4"/></IconButton></div>{expanded&&<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">{value.detail}</pre>}</section>;}
import { useState } from 'react';
import { ChevronDown, TriangleAlert, X } from 'lucide-react';
import type { AppDiagnostic } from '../../stores/useAppStore';
import { IconButton } from '../../components/ui';
export function DiagnosticNotice({
value,
onClose,
}: {
value: AppDiagnostic;
onClose: () => void;
}) {
const [expanded, setExpanded] = useState(false);
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"
>
<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">
<TriangleAlert className="h-4 w-4" />
</span>
<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}>
路径:{value.path}
</p>
)}
<button
type="button"
aria-expanded={expanded}
className="mt-1 flex items-center gap-1 text-xs text-danger hover:underline"
onClick={() => setExpanded((v) => !v)}
>
技术详情
<ChevronDown className={`h-3 w-3 ${expanded ? 'rotate-180' : ''}`} />
</button>
</div>
<IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}>
<X className="h-4 w-4" />
</IconButton>
</div>
{expanded && (
<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">
{value.detail}
</pre>
)}
</section>
);
}
@@ -1,6 +1,97 @@
import {useState} from 'react';
import {CheckCircle2,Info,TriangleAlert,XCircle} from 'lucide-react';
import {Button,CopyButton,Dialog,Tabs} from '../../components/ui';
import type {WorkbenchNotification} from './NotificationCenter';
type Filter='all'|'warning'|'danger';
export function DiagnosticsDrawer({open,items,onClose,onClear}:{open:boolean;items:WorkbenchNotification[];onClose:()=>void;onClear:()=>void}){const [filter,setFilter]=useState<Filter>('all');const content=(value:Filter)=>{const filtered=items.filter(item=>value==='all'||item.tone===value);return <div className="space-y-2">{filtered.length?filtered.map(item=>{const Icon=item.tone==='danger'?XCircle:item.tone==='warning'?TriangleAlert:item.tone==='success'?CheckCircle2:Info;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>}</div>{item.detail&&<CopyButton value={`${item.title}\n${item.detail}`} label="复制事件详情"/>}</div></article>}):<p className="p-8 text-center text-xs text-text-tertiary">没有符合条件的事件</p>}</div>;};return <Dialog open={open} onClose={onClose} title="诊断与事件日志" className="max-w-2xl" footer={<div className="flex justify-end"><Button variant="danger" disabled={!items.length} onClick={onClear}>清空事件</Button></div>}><Tabs label="事件筛选" value={filter} onValueChange={setFilter} keepMounted={false} items={[{value:'all',label:`全部 ${items.length}`,content:content('all')},{value:'warning',label:`警告 ${items.filter(item=>item.tone==='warning').length}`,content:content('warning')},{value:'danger',label:`错误 ${items.filter(item=>item.tone==='danger').length}`,content:content('danger')}]}/></Dialog>;}
import { useState } from 'react';
import { CheckCircle2, Info, TriangleAlert, XCircle } from 'lucide-react';
import { Button, CopyButton, Dialog, Tabs } from '../../components/ui';
import type { WorkbenchNotification } from './NotificationCenter';
type Filter = 'all' | 'warning' | 'danger';
export function DiagnosticsDrawer({
open,
items,
onClose,
onClear,
}: {
open: boolean;
items: WorkbenchNotification[];
onClose: () => void;
onClear: () => void;
}) {
const [filter, setFilter] = useState<Filter>('all');
const content = (value: Filter) => {
const filtered = items.filter((item) => value === 'all' || item.tone === value);
return (
<div className="space-y-2">
{filtered.length ? (
filtered.map((item) => {
const Icon =
item.tone === 'danger'
? XCircle
: item.tone === 'warning'
? TriangleAlert
: item.tone === 'success'
? CheckCircle2
: Info;
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>
)}
</div>
{item.detail && (
<CopyButton value={`${item.title}\n${item.detail}`} label="复制事件详情" />
)}
</div>
</article>
);
})
) : (
<p className="p-8 text-center text-xs text-text-tertiary">没有符合条件的事件</p>
)}
</div>
);
};
return (
<Dialog
open={open}
onClose={onClose}
title="诊断与事件日志"
className="max-w-2xl"
footer={
<div className="flex justify-end">
<Button variant="danger" disabled={!items.length} onClick={onClear}>
清空事件
</Button>
</div>
}
>
<Tabs
label="事件筛选"
value={filter}
onValueChange={setFilter}
keepMounted={false}
items={[
{ value: 'all', label: `全部 ${items.length}`, content: content('all') },
{
value: 'warning',
label: `警告 ${items.filter((item) => item.tone === 'warning').length}`,
content: content('warning'),
},
{
value: 'danger',
label: `错误 ${items.filter((item) => item.tone === 'danger').length}`,
content: content('danger'),
},
]}
/>
</Dialog>
);
}
@@ -1,4 +1,18 @@
import {render,screen} from '@testing-library/react';
import {EntrySelectionDialog} from './EntrySelectionDialog';
import { render, screen } from '@testing-library/react';
import { EntrySelectionDialog } from './EntrySelectionDialog';
describe('EntrySelectionDialog',()=>{it('父组件重渲染时不抢走入口按钮焦点,且不暴露无效关闭动作',()=>{const entries=[{path:'a.xml',label:'模型 A'},{path:'b.xml',label:'模型 B'}],select=vi.fn();const {rerender}=render(<EntrySelectionDialog entries={entries} onSelect={select}/>);const entry=screen.getByRole('button',{name:'模型 A'});entry.focus();rerender(<EntrySelectionDialog entries={[...entries]} onSelect={select}/>);expect(entry).toHaveFocus();expect(screen.queryByRole('button',{name:'关闭'})).not.toBeInTheDocument();});});
describe('EntrySelectionDialog', () => {
it('父组件重渲染时不抢走入口按钮焦点,且不暴露无效关闭动作', () => {
const entries = [
{ path: 'a.xml', label: '模型 A' },
{ path: 'b.xml', label: '模型 B' },
],
select = vi.fn();
const { rerender } = render(<EntrySelectionDialog entries={entries} onSelect={select} />);
const entry = screen.getByRole('button', { name: '模型 A' });
entry.focus();
rerender(<EntrySelectionDialog entries={[...entries]} onSelect={select} />);
expect(entry).toHaveFocus();
expect(screen.queryByRole('button', { name: '关闭' })).not.toBeInTheDocument();
});
});
@@ -1,4 +1,28 @@
import {FileCode2} from 'lucide-react';
import {Button,Dialog} from '../../components/ui';
const noop=()=>{};
export function EntrySelectionDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return <Dialog open={entries.length>0} onClose={noop} closable={false} title="选择模型入口"><p className="mb-4 text-sm text-text-secondary">工程包含多个可加载模型,请选择一个。</p><div className="space-y-2">{entries.map(entry=><Button key={entry.path} className="w-full justify-start overflow-hidden" onClick={()=>onSelect(entry.path)} icon={<FileCode2 className="h-4 w-4"/>}><span className="truncate">{entry.label}</span></Button>)}</div></Dialog>;}
import { FileCode2 } from 'lucide-react';
import { Button, Dialog } from '../../components/ui';
const noop = () => {};
export function EntrySelectionDialog({
entries,
onSelect,
}: {
entries: { path: string; label: string }[];
onSelect: (path: string) => void;
}) {
return (
<Dialog open={entries.length > 0} onClose={noop} closable={false} title="选择模型入口">
<p className="mb-4 text-sm text-text-secondary">工程包含多个可加载模型,请选择一个。</p>
<div className="space-y-2">
{entries.map((entry) => (
<Button
key={entry.path}
className="w-full justify-start overflow-hidden"
onClick={() => onSelect(entry.path)}
icon={<FileCode2 className="h-4 w-4" />}
>
<span className="truncate">{entry.label}</span>
</Button>
))}
</div>
</Dialog>
);
}
@@ -1,5 +1,68 @@
import {useState} from 'react';
import {ChevronDown,FolderTree,RefreshCw,TriangleAlert,X} from 'lucide-react';
import type {AppDiagnostic} from '../../stores/useAppStore';
import {Button,CopyButton,IconButton} from '../../components/ui';
export function ErrorRecoveryPanel({value,onClose,onRetry,onOpenProject}:{value:AppDiagnostic;onClose:()=>void;onRetry?:()=>void;onOpenProject:()=>void}){const [expanded,setExpanded]=useState(false);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"><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"><TriangleAlert className="h-4 w-4"/></span><div className="min-w-0 flex-1"><h2 className="text-sm font-semibold">{value.summary}</h2>{value.path&&<p className="truncate text-xs text-text-tertiary">路径:{value.path}</p>}<div className="mt-2 flex flex-wrap gap-2">{onRetry&&<Button variant="danger" onClick={onRetry} icon={<RefreshCw className="h-3.5 w-3.5"/>}>重试当前入口</Button>}<Button onClick={onOpenProject} icon={<FolderTree className="h-3.5 w-3.5"/>}>返回工程树</Button><CopyButton value={`${value.summary}\n${value.path??''}\n${value.detail}`} label="复制错误详情"/></div><button type="button" aria-expanded={expanded} className="mt-2 flex items-center gap-1 text-xs text-danger" onClick={()=>setExpanded(v=>!v)}>技术详情<ChevronDown className={`h-3 w-3 ${expanded?'rotate-180':''}`}/></button></div><IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}><X className="h-4 w-4"/></IconButton></div>{expanded&&<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">{value.detail}</pre>}</section>;}
import { useState } from 'react';
import { ChevronDown, FolderTree, RefreshCw, TriangleAlert, X } from 'lucide-react';
import type { AppDiagnostic } from '../../stores/useAppStore';
import { Button, CopyButton, IconButton } from '../../components/ui';
export function ErrorRecoveryPanel({
value,
onClose,
onRetry,
onOpenProject,
}: {
value: AppDiagnostic;
onClose: () => void;
onRetry?: () => void;
onOpenProject: () => void;
}) {
const [expanded, setExpanded] = useState(false);
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"
>
<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">
<TriangleAlert className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">{value.summary}</h2>
{value.path && <p className="truncate text-xs text-text-tertiary">路径:{value.path}</p>}
<div className="mt-2 flex flex-wrap gap-2">
{onRetry && (
<Button
variant="danger"
onClick={onRetry}
icon={<RefreshCw className="h-3.5 w-3.5" />}
>
重试当前入口
</Button>
)}
<Button onClick={onOpenProject} icon={<FolderTree className="h-3.5 w-3.5" />}>
返回工程树
</Button>
<CopyButton
value={`${value.summary}\n${value.path ?? ''}\n${value.detail}`}
label="复制错误详情"
/>
</div>
<button
type="button"
aria-expanded={expanded}
className="mt-2 flex items-center gap-1 text-xs text-danger"
onClick={() => setExpanded((v) => !v)}
>
技术详情
<ChevronDown className={`h-3 w-3 ${expanded ? 'rotate-180' : ''}`} />
</button>
</div>
<IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}>
<X className="h-4 w-4" />
</IconButton>
</div>
{expanded && (
<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">
{value.detail}
</pre>
)}
</section>
);
}
@@ -1,5 +1,38 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {DiagnosticNotice} from './DiagnosticNotice';
import {WorkspaceOverlays} from './WorkspaceOverlays';
import {StatusBar} from './StatusBar';
describe('工作台反馈组件',()=>{it('诊断详情可展开并关闭',()=>{const close=vi.fn();render(<DiagnosticNotice value={{category:'模型编译',summary:'模型编译失败',detail:'bad xml',path:'robot.xml',at:1}} onClose={close}/>);expect(screen.queryByText('bad xml')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'技术详情'}));expect(screen.getByText('bad xml')).toBeVisible();fireEvent.click(screen.getByRole('button',{name:'关闭错误'}));expect(close).toHaveBeenCalledTimes(1);});it('加载态与空态互斥',()=>{const {rerender}=render(<WorkspaceOverlays loading={false} hasSnapshot={false}/>);expect(screen.getByText('拖放模型工程到此处')).toBeVisible();rerender(<WorkspaceOverlays loading hasSnapshot={false}/>);expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();expect(screen.getByRole('status')).toBeVisible();});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();});});
import { fireEvent, render, screen } from '@testing-library/react';
import { DiagnosticNotice } from './DiagnosticNotice';
import { WorkspaceOverlays } from './WorkspaceOverlays';
import { StatusBar } from './StatusBar';
describe('工作台反馈组件', () => {
it('诊断详情可展开并关闭', () => {
const close = vi.fn();
render(
<DiagnosticNotice
value={{
category: '模型编译',
summary: '模型编译失败',
detail: 'bad xml',
path: 'robot.xml',
at: 1,
}}
onClose={close}
/>,
);
expect(screen.queryByText('bad xml')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '技术详情' }));
expect(screen.getByText('bad xml')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '关闭错误' }));
expect(close).toHaveBeenCalledTimes(1);
});
it('加载态与空态互斥', () => {
const { rerender } = render(<WorkspaceOverlays loading={false} hasSnapshot={false} />);
expect(screen.getByText('拖放模型工程到此处')).toBeVisible();
rerender(<WorkspaceOverlays loading hasSnapshot={false} />);
expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toBeVisible();
});
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();
});
});
@@ -1,12 +1,66 @@
import {fireEvent,render,screen,within} from '@testing-library/react';
import {DiagnosticsDrawer} from './DiagnosticsDrawer';
import {ErrorRecoveryPanel} from './ErrorRecoveryPanel';
import {ToolbarOverflowMenu} from './ToolbarOverflowMenu';
import {WorkspaceOverlays} from './WorkspaceOverlays';
const event={id:1,title:'编译失败',detail:'bad xml',tone:'danger' as const,at:0};
describe('第五批工作台组件',()=>{
it('事件日志支持分类和清空',()=>{const clear=vi.fn();render(<DiagnosticsDrawer open items={[event]} onClose={()=>{}} onClear={clear}/>);expect(within(screen.getByRole('tabpanel',{name:/全部/})).getByText('bad xml')).toBeVisible();expect(screen.getAllByText('bad xml')).toHaveLength(1);fireEvent.click(screen.getByRole('button',{name:'清空事件'}));expect(clear).toHaveBeenCalled();});
it('错误恢复面板透传重试与工程树动作',()=>{const retry=vi.fn(),project=vi.fn();render(<ErrorRecoveryPanel value={{category:'模型编译',summary:'失败',detail:'bad',path:'a.xml',at:1}} onClose={()=>{}} onRetry={retry} onOpenProject={project}/>);fireEvent.click(screen.getByRole('button',{name:'重试当前入口'}));fireEvent.click(screen.getByRole('button',{name:'返回工程树'}));expect(retry).toHaveBeenCalled();expect(project).toHaveBeenCalled();});
it('导入叠层显示阶段进度',()=>{render(<div className="relative"><WorkspaceOverlays loading hasSnapshot={false} progress={{label:'处理模型资源',value:.4}}/></div>);expect(screen.getByRole('progressbar',{name:'处理模型资源'})).toHaveAttribute('aria-valuenow','40');});
it('工具栏更多菜单提供窄桌面动作',()=>{const settings=vi.fn();render(<ToolbarOverflowMenu fullscreen={false} onCommands={()=>{}} onLayout={()=>{}} onSettings={settings} onFullscreen={()=>{}} onHelp={()=>{}} onTheme={()=>{}}/>);fireEvent.click(screen.getByRole('button',{name:'更多工作台操作'}));fireEvent.click(screen.getByRole('menuitem',{name:'工作台设置'}));expect(settings).toHaveBeenCalled();});
import { fireEvent, render, screen, within } from '@testing-library/react';
import { DiagnosticsDrawer } from './DiagnosticsDrawer';
import { ErrorRecoveryPanel } from './ErrorRecoveryPanel';
import { ToolbarOverflowMenu } from './ToolbarOverflowMenu';
import { WorkspaceOverlays } from './WorkspaceOverlays';
const event = { id: 1, title: '编译失败', detail: 'bad xml', tone: 'danger' as const, at: 0 };
describe('第五批工作台组件', () => {
it('事件日志支持分类和清空', () => {
const clear = vi.fn();
render(<DiagnosticsDrawer open items={[event]} onClose={() => {}} onClear={clear} />);
expect(
within(screen.getByRole('tabpanel', { name: /全部/ })).getByText('bad xml'),
).toBeVisible();
expect(screen.getAllByText('bad xml')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '清空事件' }));
expect(clear).toHaveBeenCalled();
});
it('错误恢复面板透传重试与工程树动作', () => {
const retry = vi.fn(),
project = vi.fn();
render(
<ErrorRecoveryPanel
value={{ category: '模型编译', summary: '失败', detail: 'bad', path: 'a.xml', at: 1 }}
onClose={() => {}}
onRetry={retry}
onOpenProject={project}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '重试当前入口' }));
fireEvent.click(screen.getByRole('button', { name: '返回工程树' }));
expect(retry).toHaveBeenCalled();
expect(project).toHaveBeenCalled();
});
it('导入叠层显示阶段进度', () => {
render(
<div className="relative">
<WorkspaceOverlays
loading
hasSnapshot={false}
progress={{ label: '处理模型资源', value: 0.4 }}
/>
</div>,
);
expect(screen.getByRole('progressbar', { name: '处理模型资源' })).toHaveAttribute(
'aria-valuenow',
'40',
);
});
it('工具栏更多菜单提供窄桌面动作', () => {
const settings = vi.fn();
render(
<ToolbarOverflowMenu
fullscreen={false}
onCommands={() => {}}
onLayout={() => {}}
onSettings={settings}
onFullscreen={() => {}}
onHelp={() => {}}
onTheme={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '更多工作台操作' }));
fireEvent.click(screen.getByRole('menuitem', { name: '工作台设置' }));
expect(settings).toHaveBeenCalled();
});
});
@@ -1,13 +1,103 @@
import {act,fireEvent,render,screen} from '@testing-library/react';
import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './NotificationCenter';
import {ProjectBreadcrumb} from './ProjectBreadcrumb';
import {SettingsDialog} from './SettingsDialog';
import {LayoutSettingsDialog} from './LayoutSettingsDialog';
const item:WorkbenchNotification={id:1,title:'模型加载完成',detail:'完成',tone:'success',at:0};
describe('第四批工作台组件',()=>{
it('通知中心展示、移除并清空消息',()=>{const dismiss=vi.fn(),clear=vi.fn();render(<NotificationCenter items={[item]} onDismiss={dismiss} onClear={clear}/>);fireEvent.click(screen.getByRole('button',{name:'通知中心'}));expect(screen.getByRole('dialog',{name:'通知中心'})).toHaveTextContent('模型加载完成');fireEvent.click(screen.getByRole('button',{name:'移除通知:模型加载完成'}));expect(dismiss).toHaveBeenCalledWith(1);fireEvent.click(screen.getByText('清空'));expect(clear).toHaveBeenCalled();});
it('Toast 自动关闭',()=>{vi.useFakeTimers();const close=vi.fn();render(<ToastViewport item={item} onDismiss={close}/>);act(()=>vi.advanceTimersByTime(4000));expect(close).toHaveBeenCalledWith(1);vi.useRealTimers();});
it('工程面包屑可切换多入口',()=>{const select=vi.fn();render(<ProjectBreadcrumb projectName="robot" selectedEntry="models/a.xml" entries={[{path:'models/a.xml',label:'A',format:'mjcf'},{path:'models/b.xml',label:'B',format:'mjcf'}]} onSelect={select}/>);fireEvent.click(screen.getByRole('button',{name:'切换模型入口'}));fireEvent.click(screen.getByRole('option',{name:/B/}));expect(select).toHaveBeenCalledWith('models/b.xml');});
it('模型加载期间禁用入口切换',()=>{render(<ProjectBreadcrumb projectName="robot" loading entries={[{path:'a.xml',label:'A',format:'mjcf'},{path:'b.xml',label:'B',format:'mjcf'}]} selectedEntry="a.xml" onSelect={()=>{}}/>);expect(screen.getByRole('button',{name:'切换模型入口'})).toBeDisabled();});
it('设置和布局弹窗透传现有设置动作',()=>{const theme=vi.fn(),preset=vi.fn();render(<><SettingsDialog open onClose={()=>{}} theme="dark" angleUnit="rad" showCollision={false} jointAdvanced={false} forceScale={50} onTheme={theme} onAngleUnit={()=>{}} onShowCollision={()=>{}} onJointAdvanced={()=>{}} onForceScale={()=>{}}/><LayoutSettingsDialog open={false} onClose={()=>{}} leftOpen rightOpen onLeftOpen={()=>{}} onRightOpen={()=>{}} onPreset={preset} onReset={()=>{}}/></>);fireEvent.change(screen.getByLabelText('设置主题'),{target:{value:'light'}});expect(theme).toHaveBeenCalledWith('light');});
import { act, fireEvent, render, screen } from '@testing-library/react';
import {
NotificationCenter,
ToastViewport,
type WorkbenchNotification,
} from './NotificationCenter';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { SettingsDialog } from './SettingsDialog';
import { LayoutSettingsDialog } from './LayoutSettingsDialog';
const item: WorkbenchNotification = {
id: 1,
title: '模型加载完成',
detail: '完成',
tone: 'success',
at: 0,
};
describe('第四批工作台组件', () => {
it('通知中心展示、移除并清空消息', () => {
const dismiss = vi.fn(),
clear = vi.fn();
render(<NotificationCenter items={[item]} onDismiss={dismiss} onClear={clear} />);
fireEvent.click(screen.getByRole('button', { name: '通知中心' }));
expect(screen.getByRole('dialog', { name: '通知中心' })).toHaveTextContent('模型加载完成');
fireEvent.click(screen.getByRole('button', { name: '移除通知:模型加载完成' }));
expect(dismiss).toHaveBeenCalledWith(1);
fireEvent.click(screen.getByText('清空'));
expect(clear).toHaveBeenCalled();
});
it('Toast 自动关闭', () => {
vi.useFakeTimers();
const close = vi.fn();
render(<ToastViewport item={item} onDismiss={close} />);
act(() => vi.advanceTimersByTime(4000));
expect(close).toHaveBeenCalledWith(1);
vi.useRealTimers();
});
it('工程面包屑可切换多入口', () => {
const select = vi.fn();
render(
<ProjectBreadcrumb
projectName="robot"
selectedEntry="models/a.xml"
entries={[
{ path: 'models/a.xml', label: 'A', format: 'mjcf' },
{ path: 'models/b.xml', label: 'B', format: 'mjcf' },
]}
onSelect={select}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '切换模型入口' }));
fireEvent.click(screen.getByRole('option', { name: /B/ }));
expect(select).toHaveBeenCalledWith('models/b.xml');
});
it('模型加载期间禁用入口切换', () => {
render(
<ProjectBreadcrumb
projectName="robot"
loading
entries={[
{ path: 'a.xml', label: 'A', format: 'mjcf' },
{ path: 'b.xml', label: 'B', format: 'mjcf' },
]}
selectedEntry="a.xml"
onSelect={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '切换模型入口' })).toBeDisabled();
});
it('设置和布局弹窗透传现有设置动作', () => {
const theme = vi.fn(),
preset = vi.fn();
render(
<>
<SettingsDialog
open
onClose={() => {}}
theme="dark"
angleUnit="rad"
showCollision={false}
jointAdvanced={false}
forceScale={50}
onTheme={theme}
onAngleUnit={() => {}}
onShowCollision={() => {}}
onJointAdvanced={() => {}}
onForceScale={() => {}}
/>
<LayoutSettingsDialog
open={false}
onClose={() => {}}
leftOpen
rightOpen
onLeftOpen={() => {}}
onRightOpen={() => {}}
onPreset={preset}
onReset={() => {}}
/>
</>,
);
fireEvent.change(screen.getByLabelText('设置主题'), { target: { value: 'light' } });
expect(theme).toHaveBeenCalledWith('light');
});
});
@@ -1,7 +1,78 @@
import {Columns3,Focus,PanelLeft,PanelRight,RotateCcw} from 'lucide-react';
import {Button,Dialog} from '../../components/ui';
export type LayoutPreset='default'|'viewport'|'project'|'control';
const presets=[{value:'default' as const,label:'默认布局',detail:'左右面板均衡显示',icon:Columns3},{value:'viewport' as const,label:'宽视口',detail:'隐藏两侧面板',icon:Focus},{value:'project' as const,label:'工程浏览',detail:'加宽工程面板',icon:PanelLeft},{value:'control' as const,label:'控制调试',detail:'加宽控制面板',icon:PanelRight}];
export function LayoutSettingsDialog({open,onClose,leftOpen,rightOpen,onLeftOpen,onRightOpen,onPreset,onReset}:{open:boolean;onClose:()=>void;leftOpen:boolean;rightOpen:boolean;onLeftOpen:(value:boolean)=>void;onRightOpen:(value:boolean)=>void;onPreset:(preset:LayoutPreset)=>void;onReset:()=>void}){return <Dialog open={open} onClose={onClose} title="布局设置"><div className="flex gap-2"><Button variant={leftOpen?'primary':'secondary'} aria-pressed={leftOpen} onClick={()=>onLeftOpen(!leftOpen)} icon={<PanelLeft className="h-3.5 w-3.5"/>}>工程面板</Button><Button variant={rightOpen?'primary':'secondary'} aria-pressed={rightOpen} onClick={()=>onRightOpen(!rightOpen)} icon={<PanelRight className="h-3.5 w-3.5"/>}>属性面板</Button></div><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>)}</div><Button className="mt-4 w-full" onClick={onReset} icon={<RotateCcw className="h-3.5 w-3.5"/>}>恢复默认布局与宽度</Button></Dialog>;}
import { Columns3, Focus, PanelLeft, PanelRight, RotateCcw } from 'lucide-react';
import { Button, Dialog } from '../../components/ui';
export type LayoutPreset = 'default' | 'viewport' | 'project' | 'control';
const presets = [
{ value: 'default' as const, label: '默认布局', detail: '左右面板均衡显示', icon: Columns3 },
{ value: 'viewport' as const, label: '宽视口', detail: '隐藏两侧面板', icon: Focus },
{ value: 'project' as const, label: '工程浏览', detail: '加宽工程面板', icon: PanelLeft },
{ value: 'control' as const, label: '控制调试', detail: '加宽控制面板', icon: PanelRight },
];
export function LayoutSettingsDialog({
open,
onClose,
leftOpen,
rightOpen,
onLeftOpen,
onRightOpen,
onPreset,
onReset,
}: {
open: boolean;
onClose: () => void;
leftOpen: boolean;
rightOpen: boolean;
onLeftOpen: (value: boolean) => void;
onRightOpen: (value: boolean) => void;
onPreset: (preset: LayoutPreset) => void;
onReset: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} title="布局设置">
<div className="flex gap-2">
<Button
variant={leftOpen ? 'primary' : 'secondary'}
aria-pressed={leftOpen}
onClick={() => onLeftOpen(!leftOpen)}
icon={<PanelLeft className="h-3.5 w-3.5" />}
>
工程面板
</Button>
<Button
variant={rightOpen ? 'primary' : 'secondary'}
aria-pressed={rightOpen}
onClick={() => onRightOpen(!rightOpen)}
icon={<PanelRight className="h-3.5 w-3.5" />}
>
属性面板
</Button>
</div>
<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>
))}
</div>
<Button
className="mt-4 w-full"
onClick={onReset}
icon={<RotateCcw className="h-3.5 w-3.5" />}
>
恢复默认布局与宽度
</Button>
</Dialog>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function dispatchLayoutWidths(left:number,right:number){window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left,right}}));}
export function dispatchLayoutWidths(left: number, right: number) {
window.dispatchEvent(new CustomEvent('mujoco-layout-widths', { detail: { left, right } }));
}
@@ -1,25 +1,87 @@
import {fireEvent,render,screen,waitFor} from '@testing-library/react';
import {beforeEach,describe,expect,it,vi} from 'vitest';
import {LocalTrainingPanel} from './LocalTrainingPanel';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalTrainingPanel } from './LocalTrainingPanel';
beforeEach(()=>{localStorage.clear();vi.unstubAllGlobals();});
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.unstubAllGlobals();
});
describe('LocalTrainingPanel',()=>{
it('连接本地服务并从图形界面发起训练请求',async()=>{
const health={version:'0.1.0',ready:true,trainerRoot:'/opt/unitree_rl_mjlab',python:'/env/bin/python',tasks:['Unitree-Go2-Flat']};
const job={id:'a'.repeat(32),state:'queued',taskId:'Unitree-Go2-Flat',createdAt:'2025-01-01T00:00:00Z',iteration:0,maxIterations:2000,progress:0,message:'等待启动',logs:[],artifactReady:false};
const fetchMock=vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(health),{status:200,headers:{'Content-Type':'application/json'}}))
.mockResolvedValueOnce(new Response(JSON.stringify(job),{status:202,headers:{'Content-Type':'application/json'}}));
vi.stubGlobal('fetch',fetchMock);
render(<LocalTrainingPanel onPolicyReady={vi.fn()}/>);
fireEvent.click(screen.getByRole('button',{name:'连接'}));
describe('LocalTrainingPanel', () => {
it('连接本地服务并从图形界面发起训练请求', async () => {
const health = {
version: '0.1.0',
ready: true,
trainerRoot: '/opt/unitree_rl_mjlab',
python: '/env/bin/python',
tasks: ['Unitree-Go2-Flat'],
};
const job = {
id: 'a'.repeat(32),
state: 'queued',
taskId: 'Unitree-Go2-Flat',
createdAt: '2025-01-01T00:00:00Z',
iteration: 0,
maxIterations: 2000,
progress: 0,
message: '等待启动',
logs: [],
artifactReady: false,
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify(health), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(job), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(health), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: '训练任务不存在或服务已重启' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
target: { value: 'secret-token' },
});
fireEvent.click(screen.getByRole('button', { name: '连接' }));
expect(await screen.findByText('/opt/unitree_rl_mjlab')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('并行环境'),{target:{value:'32'}});
fireEvent.click(screen.getByRole('button',{name:'发起本地训练'}));
await waitFor(()=>expect(fetchMock).toHaveBeenCalledTimes(2));
const request=fetchMock.mock.calls[1][1] as RequestInit;
expect(JSON.parse(String(request.body))).toMatchObject({taskId:'Unitree-Go2-Flat',numEnvs:32,device:'gpu',gpuIds:[0],wandbMode:'offline'});
fireEvent.change(screen.getByLabelText('并行环境'), { target: { value: '32' } });
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
const request = fetchMock.mock.calls[1][1] as RequestInit;
expect(JSON.parse(String(request.body))).toMatchObject({
taskId: 'Unitree-Go2-Flat',
numEnvs: 32,
device: 'gpu',
gpuIds: [0],
wandbMode: 'offline',
});
expect(new Headers(request.headers).get('Authorization')).toBe('Bearer secret-token');
expect(await screen.findByText('排队中')).toBeInTheDocument();
const tokenInput = screen.getByLabelText('训练服务访问令牌');
expect(tokenInput).toBeEnabled();
fireEvent.change(tokenInput, { target: { value: 'new-secret-token' } });
fireEvent.click(screen.getByRole('button', { name: '连接' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4));
expect(await screen.findByRole('button', { name: '发起本地训练' })).toBeInTheDocument();
expect(sessionStorage.getItem('mujoco-local-training-token')).toBe('new-secret-token');
});
});
@@ -1,77 +1,435 @@
import {useEffect,useState,type ReactNode} from 'react';
import {Download,Link,Play,Server,Square} from 'lucide-react';
import {Badge,Button,ProgressBar,PropertyRow,Select} from '../../components/ui';
import {LocalTrainingClient} from '../../training/LocalTrainingClient';
import type {TrainingDevice,TrainingJob,TrainingServerInfo,WandbMode} from '../../training/types';
import { useEffect, useState, type ReactNode } from 'react';
import { Download, Link, Play, Server, Square } from 'lucide-react';
import { Badge, Button, ProgressBar, PropertyRow, Select } from '../../components/ui';
import { LocalTrainingClient } from '../../training/LocalTrainingClient';
import type {
TrainingDevice,
TrainingJob,
TrainingServerInfo,
WandbMode,
} from '../../training/types';
const ENDPOINT_KEY='mujoco-local-training-endpoint',JOB_KEY='mujoco-local-training-job';
const DEFAULT_ENDPOINT='http://127.0.0.1:8765';
const ACTIVE_STATES=new Set(['queued','running']);
function stored(key:string,fallback=''):string{try{return localStorage.getItem(key)??fallback;}catch{return fallback;}}
function errorText(error:unknown):string{return error instanceof Error?error.message:String(error);}
function stateLabel(state:TrainingJob['state']):string{return {queued:'排队中',running:'训练中',succeeded:'已完成',failed:'失败',cancelled:'已取消'}[state];}
export function LocalTrainingPanel({onPolicyReady}:{onPolicyReady(file:File):void}){
const [endpoint,setEndpoint]=useState(()=>stored(ENDPOINT_KEY,DEFAULT_ENDPOINT));
const [server,setServer]=useState<TrainingServerInfo>();
const [job,setJob]=useState<TrainingJob>();
const [busy,setBusy]=useState(false),[error,setError]=useState<string>();
const [taskId,setTaskId]=useState('Unitree-Go2-Flat'),[numEnvs,setNumEnvs]=useState(4096),[maxIterations,setMaxIterations]=useState(2000),[seed,setSeed]=useState(42),[runName,setRunName]=useState('web'),[device,setDevice]=useState<TrainingDevice>('gpu'),[gpuIds,setGpuIds]=useState('0'),[wandbMode,setWandbMode]=useState<WandbMode>('offline');
const connect=async()=>{
setBusy(true);setError(undefined);
try{
const client=new LocalTrainingClient(endpoint),info=await client.health();
setServer(info);try{localStorage.setItem(ENDPOINT_KEY,client.endpoint);}catch{/* 当前会话仍可连接 */}
if(info.tasks.length&&!info.tasks.includes(taskId))setTaskId(info.tasks[0]);
const remembered=info.activeJobId??stored(JOB_KEY);
if(remembered){try{setJob(await client.job(remembered));}catch{try{localStorage.removeItem(JOB_KEY);}catch{/* ignore */}}}
if(!info.ready)setError(info.error??'训练服务尚未就绪');
}catch(value){setServer(undefined);setError(errorText(value));}
finally{setBusy(false);}
};
const jobId=job?.id,jobState=job?.state;
useEffect(()=>{
if(!jobId||!jobState||!ACTIVE_STATES.has(jobState))return;
let disposed=false;
const refresh=async()=>{try{const next=await new LocalTrainingClient(endpoint).job(jobId);if(!disposed)setJob(next);}catch(value){if(!disposed)setError(errorText(value));}};
const timer=window.setInterval(()=>void refresh(),1500);return()=>{disposed=true;window.clearInterval(timer);};
},[endpoint,jobId,jobState]);
const start=async()=>{
setBusy(true);setError(undefined);
try{
const ids=device==='gpu'?gpuIds.split(/[\s,]+/).filter(Boolean).map(Number):[];
if(ids.some(id=>!Number.isInteger(id)||id<0))throw new Error('GPU 编号必须是非负整数');
const next=await new LocalTrainingClient(endpoint).start({taskId,numEnvs,maxIterations,seed,runName,device,gpuIds:ids,wandbMode});
setJob(next);try{localStorage.setItem(JOB_KEY,next.id);}catch{/* ignore */}
}catch(value){setError(errorText(value));}finally{setBusy(false);}
};
const cancel=async()=>{if(!job)return;setBusy(true);setError(undefined);try{setJob(await new LocalTrainingClient(endpoint).cancel(job.id));}catch(value){setError(errorText(value));}finally{setBusy(false);}};
const importResult=async()=>{if(!job)return;setBusy(true);setError(undefined);try{onPolicyReady(await new LocalTrainingClient(endpoint).downloadPolicy(job.id));}catch(value){setError(errorText(value));}finally{setBusy(false);}};
const active=Boolean(job&&ACTIVE_STATES.has(job.state));
return <div>
<label className="block text-xs text-text-secondary"><span className="mb-1 block">本地训练服务</span><div className="flex gap-2"><input aria-label="本地训练服务地址" className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary" value={endpoint} disabled={active} onChange={event=>setEndpoint(event.target.value)}/><Button icon={<Link className="h-3.5 w-3.5"/>} disabled={busy||active} onClick={()=>void connect()}>连接</Button></div></label>
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary"><span className="flex min-w-0 items-center gap-1.5 truncate"><Server className="h-3.5 w-3.5"/>{server?.trainerRoot??'请先启动本地训练服务'}</span><Badge tone={server?.ready?'success':'warning'}>{server?.ready?'可用':'离线'}</Badge></div>
{server?.ready&&!job&&<div className="mt-3 space-y-2">
<Field label="训练任务"><Select aria-label="训练任务" className="w-full" value={taskId} onChange={event=>setTaskId(event.target.value)}>{server.tasks.map(task=><option key={task} value={task}>{task}</option>)}</Select></Field>
<div className="grid grid-cols-2 gap-2"><NumberField label="并行环境" value={numEnvs} min={1} max={16384} onChange={setNumEnvs}/><NumberField label="训练迭代" value={maxIterations} min={1} max={1000000} onChange={setMaxIterations}/><NumberField label="随机种子" value={seed} min={0} max={2147483647} onChange={setSeed}/><Field label="运行名称"><input aria-label="运行名称" className="field h-7 w-full px-2 text-xs text-text-primary" value={runName} onChange={event=>setRunName(event.target.value)}/></Field></div>
<div className="grid grid-cols-2 gap-2"><Field label="计算设备"><Select aria-label="计算设备" className="w-full" value={device} onChange={event=>setDevice(event.target.value as TrainingDevice)}><option value="gpu">GPU</option><option value="cpu">CPU</option></Select></Field><Field label="GPU 编号"><input aria-label="GPU 编号" className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40" value={gpuIds} disabled={device==='cpu'} onChange={event=>setGpuIds(event.target.value)}/></Field></div>
<Field label="实验记录"><Select aria-label="W&B 模式" className="w-full" value={wandbMode} onChange={event=>setWandbMode(event.target.value as WandbMode)}><option value="offline">本地离线(默认,无需登录)</option><option value="disabled">完全禁用 W&amp;B</option><option value="online">在线 W&amp;B(需要 API Key)</option></Select></Field>
<Button variant="primary" className="w-full" icon={<Play className="h-3.5 w-3.5"/>} disabled={busy} onClick={()=>void start()}>发起本地训练</Button>
<p className="text-[10px] leading-4 text-text-tertiary">训练使用本地 mjlab 任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。</p>
</div>}
{job&&<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2"><span className="truncate text-xs font-medium text-text-primary" title={job.id}>{job.taskId}</span><Badge tone={job.state==='succeeded'?'success':job.state==='failed'||job.state==='cancelled'?'warning':'accent'}>{stateLabel(job.state)}</Badge></div>
<ProgressBar value={job.progress} label="训练进度"/><div className="mt-2"><PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`}/><PropertyRow label="状态" value={job.message}/></div>
{job.logs.length>0&&<details className="mt-2"><summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary><pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-[9px] leading-4 text-text-tertiary">{job.logs.slice(-40).join('\n')}</pre></details>}
<div className="mt-3 grid grid-cols-2 gap-2">{active?<Button variant="danger" className="col-span-2" icon={<Square className="h-3.5 w-3.5"/>} disabled={busy} onClick={()=>void cancel()}>停止训练</Button>:<><Button disabled={busy||!job.artifactReady} icon={<Download className="h-3.5 w-3.5"/>} onClick={()=>void importResult()}>导入策略</Button><Button onClick={()=>{setJob(undefined);try{localStorage.removeItem(JOB_KEY);}catch{/* ignore */}}}>新建任务</Button></>}</div>
</div>}
{error&&<p role="alert" className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger">{error}</p>}
</div>;
const ENDPOINT_KEY = 'mujoco-local-training-endpoint',
JOB_KEY = 'mujoco-local-training-job',
TOKEN_KEY = 'mujoco-local-training-token';
const DEFAULT_ENDPOINT = 'http://127.0.0.1:8765';
const ACTIVE_STATES = new Set(['queued', 'running']);
function stored(key: string, fallback = ''): string {
try {
return localStorage.getItem(key) ?? fallback;
} catch {
return fallback;
}
}
function sessionStored(key: string): string {
try {
return sessionStorage.getItem(key) ?? '';
} catch {
return '';
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function stateLabel(state: TrainingJob['state']): string {
return {
queued: '排队中',
running: '训练中',
succeeded: '已完成',
failed: '失败',
cancelled: '已取消',
}[state];
}
function Field({label,children}:{label:string;children:ReactNode}){return <label className="block text-[10px] text-text-tertiary"><span className="mb-1 block">{label}</span>{children}</label>;}
function NumberField({label,value,min,max,onChange}:{label:string;value:number;min:number;max:number;onChange(value:number):void}){return <Field label={label}><input aria-label={label} type="number" className="field h-7 w-full px-2 text-xs text-text-primary" value={value} min={min} max={max} onChange={event=>onChange(Number(event.target.value))}/></Field>;}
export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File): void }) {
const [endpoint, setEndpoint] = useState(() => stored(ENDPOINT_KEY, DEFAULT_ENDPOINT));
const [token, setToken] = useState(() => sessionStored(TOKEN_KEY));
const [server, setServer] = useState<TrainingServerInfo>();
const [job, setJob] = useState<TrainingJob>();
const [busy, setBusy] = useState(false),
[error, setError] = useState<string>();
const [taskId, setTaskId] = useState('Unitree-Go2-Flat'),
[numEnvs, setNumEnvs] = useState(4096),
[maxIterations, setMaxIterations] = useState(2000),
[seed, setSeed] = useState(42),
[runName, setRunName] = useState('web'),
[device, setDevice] = useState<TrainingDevice>('gpu'),
[gpuIds, setGpuIds] = useState('0'),
[wandbMode, setWandbMode] = useState<WandbMode>('offline');
const connect = async () => {
setBusy(true);
setError(undefined);
try {
const client = new LocalTrainingClient(endpoint, token),
info = await client.health();
setServer(info);
try {
localStorage.setItem(ENDPOINT_KEY, client.endpoint);
sessionStorage.setItem(TOKEN_KEY, client.token);
} catch {
/* 当前会话仍可连接 */
}
if (info.tasks.length && !info.tasks.includes(taskId)) setTaskId(info.tasks[0]);
const remembered = info.activeJobId ?? stored(JOB_KEY);
if (remembered) {
try {
const recovered = await client.job(remembered);
setJob(recovered);
try {
localStorage.setItem(JOB_KEY, recovered.id);
} catch {
/* ignore */
}
} catch {
setJob(undefined);
try {
localStorage.removeItem(JOB_KEY);
} catch {
/* ignore */
}
}
} else {
setJob(undefined);
}
if (!info.ready) setError(info.error ?? '训练服务尚未就绪');
} catch (value) {
setServer(undefined);
setError(errorText(value));
} finally {
setBusy(false);
}
};
const jobId = job?.id,
jobState = job?.state;
useEffect(() => {
if (!jobId || !jobState || !ACTIVE_STATES.has(jobState)) return;
let disposed = false;
const refresh = async () => {
try {
const next = await new LocalTrainingClient(endpoint, token).job(jobId);
if (!disposed) setJob(next);
} catch (value) {
if (!disposed) setError(errorText(value));
}
};
const timer = window.setInterval(() => void refresh(), 1500);
return () => {
disposed = true;
window.clearInterval(timer);
};
}, [endpoint, jobId, jobState, token]);
const start = async () => {
setBusy(true);
setError(undefined);
try {
const ids =
device === 'gpu'
? gpuIds
.split(/[\s,]+/)
.filter(Boolean)
.map(Number)
: [];
if (ids.some((id) => !Number.isInteger(id) || id < 0))
throw new Error('GPU 编号必须是非负整数');
const next = await new LocalTrainingClient(endpoint, token).start({
taskId,
numEnvs,
maxIterations,
seed,
runName,
device,
gpuIds: ids,
wandbMode,
});
setJob(next);
try {
localStorage.setItem(JOB_KEY, next.id);
} catch {
/* ignore */
}
} catch (value) {
setError(errorText(value));
} finally {
setBusy(false);
}
};
const cancel = async () => {
if (!job) return;
setBusy(true);
setError(undefined);
try {
setJob(await new LocalTrainingClient(endpoint, token).cancel(job.id));
} catch (value) {
setError(errorText(value));
} finally {
setBusy(false);
}
};
const importResult = async () => {
if (!job) return;
setBusy(true);
setError(undefined);
try {
onPolicyReady(await new LocalTrainingClient(endpoint, token).downloadPolicy(job.id));
} catch (value) {
setError(errorText(value));
} finally {
setBusy(false);
}
};
const active = Boolean(job && ACTIVE_STATES.has(job.state));
return (
<div>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">本地训练服务</span>
<div className="flex gap-2">
<input
aria-label="本地训练服务地址"
className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary"
value={endpoint}
onChange={(event) => setEndpoint(event.target.value)}
/>
<Button
icon={<Link className="h-3.5 w-3.5" />}
disabled={busy || !token.trim()}
onClick={() => void connect()}
>
连接
</Button>
</div>
</label>
<label className="mt-2 block text-[10px] text-text-tertiary">
<span className="mb-1 block">访问令牌(服务启动时显示)</span>
<input
aria-label="训练服务访问令牌"
type="password"
autoComplete="off"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={token}
onChange={(event) => setToken(event.target.value)}
/>
</label>
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary">
<span className="flex min-w-0 items-center gap-1.5 truncate">
<Server className="h-3.5 w-3.5" />
{server?.trainerRoot ?? '请先启动本地训练服务'}
</span>
<Badge tone={server?.ready ? 'success' : 'warning'}>
{server?.ready ? '可用' : '离线'}
</Badge>
</div>
{server?.ready && !job && (
<div className="mt-3 space-y-2">
<Field label="训练任务">
<Select
aria-label="训练任务"
className="w-full"
value={taskId}
onChange={(event) => setTaskId(event.target.value)}
>
{server.tasks.map((task) => (
<option key={task} value={task}>
{task}
</option>
))}
</Select>
</Field>
<div className="grid grid-cols-2 gap-2">
<NumberField
label="并行环境"
value={numEnvs}
min={1}
max={16384}
onChange={setNumEnvs}
/>
<NumberField
label="训练迭代"
value={maxIterations}
min={1}
max={1000000}
onChange={setMaxIterations}
/>
<NumberField
label="随机种子"
value={seed}
min={0}
max={2147483647}
onChange={setSeed}
/>
<Field label="运行名称">
<input
aria-label="运行名称"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={runName}
onChange={(event) => setRunName(event.target.value)}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="计算设备">
<Select
aria-label="计算设备"
className="w-full"
value={device}
onChange={(event) => setDevice(event.target.value as TrainingDevice)}
>
<option value="gpu">GPU</option>
<option value="cpu">CPU</option>
</Select>
</Field>
<Field label="GPU 编号">
<input
aria-label="GPU 编号"
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
value={gpuIds}
disabled={device === 'cpu'}
onChange={(event) => setGpuIds(event.target.value)}
/>
</Field>
</div>
<Field label="实验记录">
<Select
aria-label="W&B 模式"
className="w-full"
value={wandbMode}
onChange={(event) => setWandbMode(event.target.value as WandbMode)}
>
<option value="offline">本地离线(默认,无需登录)</option>
<option value="disabled">完全禁用 W&amp;B</option>
<option value="online">在线 W&amp;B(需要 API Key)</option>
</Select>
</Field>
<Button
variant="primary"
className="w-full"
icon={<Play className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void start()}
>
发起本地训练
</Button>
<p className="text-[10px] leading-4 text-text-tertiary">
训练使用本地 mjlab
任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。
</p>
</div>
)}
{job && (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-text-primary" title={job.id}>
{job.taskId}
</span>
<Badge
tone={
job.state === 'succeeded'
? 'success'
: job.state === 'failed' || job.state === 'cancelled'
? 'warning'
: 'accent'
}
>
{stateLabel(job.state)}
</Badge>
</div>
<ProgressBar value={job.progress} label="训练进度" />
<div className="mt-2">
<PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`} />
<PropertyRow label="状态" value={job.message} />
</div>
{job.logs.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary>
<pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-[9px] leading-4 text-text-tertiary">
{job.logs.slice(-40).join('\n')}
</pre>
</details>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
{active ? (
<Button
variant="danger"
className="col-span-2"
icon={<Square className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void cancel()}
>
停止训练
</Button>
) : (
<>
<Button
disabled={busy || !job.artifactReady}
icon={<Download className="h-3.5 w-3.5" />}
onClick={() => void importResult()}
>
导入策略
</Button>
<Button
onClick={() => {
setJob(undefined);
try {
localStorage.removeItem(JOB_KEY);
} catch {
/* ignore */
}
}}
>
新建任务
</Button>
</>
)}
</div>
</div>
)}
{error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
>
{error}
</p>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block text-[10px] text-text-tertiary">
<span className="mb-1 block">{label}</span>
{children}
</label>
);
}
function NumberField({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange(value: number): void;
}) {
return (
<Field label={label}>
<input
aria-label={label}
type="number"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={value}
min={min}
max={max}
onChange={(event) => onChange(Number(event.target.value))}
/>
</Field>
);
}
@@ -1,7 +1,141 @@
import {useEffect,useRef} from 'react';
import {Bell,CheckCircle2,Info,Trash2,TriangleAlert,XCircle} from 'lucide-react';
import {Badge,IconButton,Popover} from '../../components/ui';
export interface WorkbenchNotification{id:number;title:string;detail?:string;tone:'success'|'warning'|'danger'|'info';at:number;}
const icons={success:CheckCircle2,warning:TriangleAlert,danger:XCircle,info:Info};
export function NotificationCenter({items,onDismiss,onClear,onOpenLog}:{items:WorkbenchNotification[];onDismiss:(id:number)=>void;onClear:()=>void;onOpenLog?:()=>void}){return <Popover label="通知中心" trigger={({open,toggle})=><IconButton tooltip="通知中心" aria-label="通知中心" aria-expanded={open} onClick={toggle}><Bell className="h-4 w-4"/>{items.length>0&&<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-warning"/>}</IconButton>}>{({close})=><div className="w-80 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" onClick={()=>{close();onOpenLog();}}>事件日志</button>}{items.length>0&&<button className="flex items-center gap-1 text-[10px] text-text-tertiary hover:text-danger" onClick={onClear}><Trash2 className="h-3 w-3"/>清空</button>}</div></header><div className="panel-scroll max-h-80 overflow-auto">{items.length?items.map(item=>{const Icon=icons[item.tone];return <article key={item.id} className="flex gap-2 border-b border-border px-3 py-2.5 last:border-0"><Icon className={`mt-0.5 h-4 w-4 shrink-0 ${item.tone==='success'?'text-success':item.tone==='warning'?'text-warning':item.tone==='danger'?'text-danger':'text-accent'}`}/><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h3 className="truncate text-xs font-medium">{item.title}</h3><Badge>{new Date(item.at).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</Badge></div>{item.detail&&<p className="mt-1 line-clamp-3 text-[10px] leading-4 text-text-tertiary">{item.detail}</p>}</div><IconButton aria-label={`移除通知:${item.title}`} tooltip="移除" onClick={()=>onDismiss(item.id)}><XCircle className="h-3.5 w-3.5"/></IconButton></article>}):<p className="p-6 text-center text-xs text-text-tertiary">当前没有通知</p>}</div></div>}</Popover>;}
export function ToastViewport({item,onDismiss}:{item?:WorkbenchNotification;onDismiss:(id:number)=>void}){const dismissRef=useRef(onDismiss);useEffect(()=>{dismissRef.current=onDismiss;},[onDismiss]);useEffect(()=>{if(!item)return;const timer=window.setTimeout(()=>dismissRef.current(item.id),4000);return()=>window.clearTimeout(timer);},[item]);if(!item)return null;const Icon=icons[item.tone];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"><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>}</div></div>;}
import { useEffect, useRef } from 'react';
import { Bell, CheckCircle2, Info, Trash2, TriangleAlert, XCircle } from 'lucide-react';
import { Badge, IconButton, Popover } from '../../components/ui';
export interface WorkbenchNotification {
id: number;
title: string;
detail?: string;
tone: 'success' | 'warning' | 'danger' | 'info';
at: number;
}
const icons = { success: CheckCircle2, warning: TriangleAlert, danger: XCircle, info: Info };
export function NotificationCenter({
items,
onDismiss,
onClear,
onOpenLog,
}: {
items: WorkbenchNotification[];
onDismiss: (id: number) => void;
onClear: () => void;
onOpenLog?: () => void;
}) {
return (
<Popover
label="通知中心"
trigger={({ open, toggle }) => (
<IconButton tooltip="通知中心" aria-label="通知中心" aria-expanded={open} onClick={toggle}>
<Bell className="h-4 w-4" />
{items.length > 0 && (
<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-warning" />
)}
</IconButton>
)}
>
{({ close }) => (
<div className="w-80 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"
onClick={() => {
close();
onOpenLog();
}}
>
事件日志
</button>
)}
{items.length > 0 && (
<button
className="flex items-center gap-1 text-[10px] text-text-tertiary hover:text-danger"
onClick={onClear}
>
<Trash2 className="h-3 w-3" />
清空
</button>
)}
</div>
</header>
<div className="panel-scroll max-h-80 overflow-auto">
{items.length ? (
items.map((item) => {
const Icon = icons[item.tone];
return (
<article
key={item.id}
className="flex gap-2 border-b border-border px-3 py-2.5 last:border-0"
>
<Icon
className={`mt-0.5 h-4 w-4 shrink-0 ${item.tone === 'success' ? 'text-success' : item.tone === 'warning' ? 'text-warning' : item.tone === 'danger' ? 'text-danger' : 'text-accent'}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-xs font-medium">{item.title}</h3>
<Badge>
{new Date(item.at).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</Badge>
</div>
{item.detail && (
<p className="mt-1 line-clamp-3 text-[10px] leading-4 text-text-tertiary">
{item.detail}
</p>
)}
</div>
<IconButton
aria-label={`移除通知:${item.title}`}
tooltip="移除"
onClick={() => onDismiss(item.id)}
>
<XCircle className="h-3.5 w-3.5" />
</IconButton>
</article>
);
})
) : (
<p className="p-6 text-center text-xs text-text-tertiary">当前没有通知</p>
)}
</div>
</div>
)}
</Popover>
);
}
export function ToastViewport({
item,
onDismiss,
}: {
item?: WorkbenchNotification;
onDismiss: (id: number) => void;
}) {
const dismissRef = useRef(onDismiss);
useEffect(() => {
dismissRef.current = onDismiss;
}, [onDismiss]);
useEffect(() => {
if (!item) return;
const timer = window.setTimeout(() => dismissRef.current(item.id), 4000);
return () => window.clearTimeout(timer);
}, [item]);
if (!item) return null;
const Icon = icons[item.tone];
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"
>
<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>
)}
</div>
</div>
);
}
@@ -1,3 +1,68 @@
import {Activity,ChevronUp,Cpu,MemoryStick,TriangleAlert} from 'lucide-react';
import {Badge,Popover,PropertyRow,Separator} from '../../components/ui';
export function PerformancePopover({fps,stepMs,memoryMb,overBudget}:{fps:number;stepMs:number;memoryMb?:number;overBudget:boolean}){return <Popover label="性能详情" placement="top-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"><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>}>{()=> <div className="w-72 rounded-lg border border-border bg-surface-elevated p-3 text-xs text-text-secondary shadow-xl"><div className="mb-2 flex items-center justify-between"><h2 className="font-semibold text-text-primary">运行性能</h2><Badge tone={overBudget?'warning':'success'}>{overBudget?'预算超限':'运行正常'}</Badge></div><PropertyRow label="渲染帧率" value={`${fps.toFixed(0)} FPS`}/><PropertyRow label="物理步进" value={`${stepMs.toFixed(2)} ms`}/><PropertyRow label="浏览器内存" value={memoryMb===undefined?'不可用':`${memoryMb.toFixed(1)} MiB`}/><Separator className="my-2"/>{overBudget?<p className="flex gap-2 text-warning"><TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0"/>主线程超出步进预算,平台已限制追帧以保持交互响应。</p>:<p className="flex gap-2 text-text-tertiary"><MemoryStick className="mt-0.5 h-3.5 w-3.5 shrink-0"/>指标来自浏览器运行时,仅用于当前会话诊断。</p>}</div>}</Popover>;}
import { Activity, ChevronUp, Cpu, MemoryStick, TriangleAlert } from 'lucide-react';
import { Badge, Popover, PropertyRow, Separator } from '../../components/ui';
export function PerformancePopover({
fps,
stepMs,
memoryMb,
overBudget,
}: {
fps: number;
stepMs: number;
memoryMb?: number;
overBudget: boolean;
}) {
return (
<Popover
label="性能详情"
placement="top-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"
>
<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>
)}
>
{() => (
<div className="w-72 rounded-lg border border-border bg-surface-elevated p-3 text-xs text-text-secondary shadow-xl">
<div className="mb-2 flex items-center justify-between">
<h2 className="font-semibold text-text-primary">运行性能</h2>
<Badge tone={overBudget ? 'warning' : 'success'}>
{overBudget ? '预算超限' : '运行正常'}
</Badge>
</div>
<PropertyRow label="渲染帧率" value={`${fps.toFixed(0)} FPS`} />
<PropertyRow label="物理步进" value={`${stepMs.toFixed(2)} ms`} />
<PropertyRow
label="浏览器内存"
value={memoryMb === undefined ? '不可用' : `${memoryMb.toFixed(1)} MiB`}
/>
<Separator className="my-2" />
{overBudget ? (
<p className="flex gap-2 text-warning">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
主线程超出步进预算,平台已限制追帧以保持交互响应。
</p>
) : (
<p className="flex gap-2 text-text-tertiary">
<MemoryStick className="mt-0.5 h-3.5 w-3.5 shrink-0" />
指标来自浏览器运行时,仅用于当前会话诊断。
</p>
)}
</div>
)}
</Popover>
);
}
@@ -1,4 +1,52 @@
import {ChevronRight,FolderRoot} from 'lucide-react';
import type {ModelEntry} from '../../project/types';
import {SearchableCombobox} from '../../components/ui';
export function ProjectBreadcrumb({projectName,entries,selectedEntry,loading=false,onSelect}:{projectName:string;entries:ModelEntry[];selectedEntry?:string;loading?:boolean;onSelect:(path:string)=>void}){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}</span></span>)}</div>{entries.length>1&&<div className="mt-2"><SearchableCombobox label="切换模型入口" disabled={loading} value={selectedEntry} onChange={onSelect} options={entries.map(entry=>({value:entry.path,label:entry.label,description:entry.path}))}/></div>}</div>;}
import { ChevronRight, FolderRoot } from 'lucide-react';
import type { ModelEntry } from '../../project/types';
import { SearchableCombobox } from '../../components/ui';
export function ProjectBreadcrumb({
projectName,
entries,
selectedEntry,
loading = false,
onSelect,
}: {
projectName: string;
entries: ModelEntry[];
selectedEntry?: string;
loading?: boolean;
onSelect: (path: string) => void;
}) {
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}
</span>
</span>
))}
</div>
{entries.length > 1 && (
<div className="mt-2">
<SearchableCombobox
label="切换模型入口"
disabled={loading}
value={selectedEntry}
onChange={onSelect}
options={entries.map((entry) => ({
value: entry.path,
label: entry.label,
description: entry.path,
}))}
/>
</div>
)}
</div>
);
}
@@ -1,23 +1,69 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {describe,expect,it,vi} from 'vitest';
import {PythonControllerPanel} from './PythonControllerPanel';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { PythonControllerPanel } from './PythonControllerPanel';
const noop=()=>{};
const noop = () => {};
describe('PythonControllerPanel',()=>{
it('向支持 command 的已启用控制器发送基本移动指令',()=>{
const onCommand=vi.fn();
render(<PythonControllerPanel paths={[]} loading={false} status={{language:'python',path:'go2.py',name:'Go2',controlHz:200,loaded:true,enabled:true,acceptsCommands:true,activeCommand:'stop',lastStepMs:.1}} onSelectPath={noop} onLoadPath={noop} onImport={noop} onToggle={noop} onCommand={onCommand} onRemove={noop}/>);
fireEvent.click(screen.getByRole('button',{name:'前进'}));
fireEvent.click(screen.getByRole('button',{name:'左转'}));
fireEvent.click(screen.getByRole('button',{name:'起跳'}));
expect(onCommand.mock.calls).toEqual([['forward'],['turn_left'],['jump']]);
expect(screen.getByRole('button',{name:'移动停止'})).toHaveAttribute('aria-pressed','true');
describe('PythonControllerPanel', () => {
it('向支持 command 的已启用控制器发送基本移动指令', () => {
const onCommand = vi.fn();
render(
<PythonControllerPanel
paths={[]}
loading={false}
status={{
language: 'python',
path: 'go2.py',
name: 'Go2',
controlHz: 200,
loaded: true,
enabled: true,
acceptsCommands: true,
activeCommand: 'stop',
lastStepMs: 0.1,
}}
onSelectPath={noop}
onLoadPath={noop}
onImport={noop}
onToggle={noop}
onCommand={onCommand}
onRemove={noop}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '前进' }));
fireEvent.click(screen.getByRole('button', { name: '左转' }));
fireEvent.click(screen.getByRole('button', { name: '起跳' }));
expect(onCommand.mock.calls).toEqual([['forward'], ['turn_left'], ['jump']]);
expect(screen.getByRole('button', { name: '移动停止' })).toHaveAttribute(
'aria-pressed',
'true',
);
});
it('控制器未启用时禁用基本移动按钮',()=>{
render(<PythonControllerPanel paths={[]} loading={false} status={{language:'python',path:'go2.py',name:'Go2',controlHz:200,loaded:true,enabled:false,acceptsCommands:true,lastStepMs:0}} onSelectPath={noop} onLoadPath={noop} onImport={noop} onToggle={noop} onCommand={noop} onRemove={noop}/>);
expect(screen.getByRole('button',{name:'前进'})).toBeDisabled();
expect(screen.getByRole('button',{name:'起跳'})).toBeDisabled();
it('控制器未启用时禁用基本移动按钮', () => {
render(
<PythonControllerPanel
paths={[]}
loading={false}
status={{
language: 'python',
path: 'go2.py',
name: 'Go2',
controlHz: 200,
loaded: true,
enabled: false,
acceptsCommands: true,
lastStepMs: 0,
}}
onSelectPath={noop}
onLoadPath={noop}
onImport={noop}
onToggle={noop}
onCommand={noop}
onRemove={noop}
/>,
);
expect(screen.getByRole('button', { name: '前进' })).toBeDisabled();
expect(screen.getByRole('button', { name: '起跳' })).toBeDisabled();
});
});
@@ -1,37 +1,184 @@
import {useRef,type ChangeEvent} from 'react';
import {ArrowDown,ArrowLeft,ArrowRight,ArrowUp,FileUp,Octagon,Power,RotateCw,Trash2} from 'lucide-react';
import type {ControllerCommand,ControllerStatus} from '../../controller/types';
import {Badge,Button,PropertyRow,Select} from '../../components/ui';
import { useRef, type ChangeEvent } from 'react';
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
FileUp,
Octagon,
Power,
RotateCw,
Trash2,
} from 'lucide-react';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
export interface PythonControllerPanelProps {
paths:string[];
selectedPath?:string;
status?:ControllerStatus;
loading:boolean;
onSelectPath(path:string):void;
onLoadPath(path:string):void;
onImport(file:File):void;
onToggle(enabled:boolean):void;
onCommand(command:ControllerCommand):void;
onRemove():void;
paths: string[];
selectedPath?: string;
status?: ControllerStatus;
loading: boolean;
onSelectPath(path: string): void;
onLoadPath(path: string): void;
onImport(file: File): void;
onToggle(enabled: boolean): void;
onCommand(command: ControllerCommand): void;
onRemove(): void;
}
export function PythonControllerPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:PythonControllerPanelProps){
const input=useRef<HTMLInputElement>(null);
const importFile=(event:ChangeEvent<HTMLInputElement>)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';};
return <div>
<input ref={input} className="hidden" type="file" accept=".py,text/x-python" onChange={importFile}/>
{paths.length>0&&<label className="mb-3 block text-xs text-text-secondary"><span className="mb-1 block">工程内脚本</span><Select aria-label="Python 控制脚本" className="w-full" value={selectedPath??''} disabled={loading} onChange={event=>onSelectPath(event.target.value)}><option value="">选择 .py 文件</option>{paths.map(path=><option key={path} value={path}>{path}</option>)}</Select></label>}
<div className="grid grid-cols-2 gap-2">
<Button icon={<FileUp className="h-3.5 w-3.5"/>} disabled={loading} onClick={()=>input.current?.click()}>导入 .py</Button>
<Button icon={<RotateCw className="h-3.5 w-3.5"/>} disabled={loading||!selectedPath} onClick={()=>selectedPath&&onLoadPath(selectedPath)}>加载脚本</Button>
export function PythonControllerPanel({
paths,
selectedPath,
status,
loading,
onSelectPath,
onLoadPath,
onImport,
onToggle,
onCommand,
onRemove,
}: PythonControllerPanelProps) {
const input = useRef<HTMLInputElement>(null);
const importFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) onImport(file);
event.target.value = '';
};
return (
<div>
<input
ref={input}
className="hidden"
type="file"
accept=".py,text/x-python"
onChange={importFile}
/>
{paths.length > 0 && (
<label className="mb-3 block text-xs text-text-secondary">
<span className="mb-1 block">工程内脚本</span>
<Select
aria-label="Python 控制脚本"
className="w-full"
value={selectedPath ?? ''}
disabled={loading}
onChange={(event) => onSelectPath(event.target.value)}
>
<option value="">选择 .py 文件</option>
{paths.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</Select>
</label>
)}
<div className="grid grid-cols-2 gap-2">
<Button
icon={<FileUp className="h-3.5 w-3.5" />}
disabled={loading}
onClick={() => input.current?.click()}
>
导入 .py
</Button>
<Button
icon={<RotateCw className="h-3.5 w-3.5" />}
disabled={loading || !selectedPath}
onClick={() => selectedPath && onLoadPath(selectedPath)}
>
加载脚本
</Button>
</div>
{status ? (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-text-primary" title={status.path}>
{status.name}
</span>
<Badge>{status.enabled ? '运行中' : '已停止'}</Badge>
</div>
<PropertyRow label="语言" value="Python / Pyodide" />
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`} />
<PropertyRow label="上次耗时" value={`${status.lastStepMs.toFixed(3)} ms`} />
{status.error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
>
{status.error}
</p>
)}
{status.acceptsCommands && (
<div className="mt-3 border-t border-border pt-3">
<p className="mb-2 text-[10px] text-text-tertiary">基本移动指令</p>
<div className="grid grid-cols-3 gap-1.5">
<span />
<Button
aria-pressed={status.activeCommand === 'forward'}
disabled={!status.enabled}
icon={<ArrowUp className="h-3.5 w-3.5" />}
onClick={() => onCommand('forward')}
>
前进
</Button>
<span />
<Button
aria-pressed={status.activeCommand === 'turn_left'}
disabled={!status.enabled}
icon={<ArrowLeft className="h-3.5 w-3.5" />}
onClick={() => onCommand('turn_left')}
>
左转
</Button>
<Button
aria-label="移动停止"
aria-pressed={status.activeCommand === 'stop'}
disabled={!status.enabled}
icon={<Octagon className="h-3.5 w-3.5" />}
onClick={() => onCommand('stop')}
>
停止
</Button>
<Button
aria-pressed={status.activeCommand === 'turn_right'}
disabled={!status.enabled}
icon={<ArrowRight className="h-3.5 w-3.5" />}
onClick={() => onCommand('turn_right')}
>
右转
</Button>
<span />
<Button
aria-pressed={status.activeCommand === 'backward'}
disabled={!status.enabled}
icon={<ArrowDown className="h-3.5 w-3.5" />}
onClick={() => onCommand('backward')}
>
后退
</Button>
<Button disabled={!status.enabled} onClick={() => onCommand('jump')}>
起跳
</Button>
</div>
</div>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
variant={status.enabled ? 'secondary' : 'primary'}
icon={<Power className="h-3.5 w-3.5" />}
onClick={() => onToggle(!status.enabled)}
>
{status.enabled ? '停止' : '启用'}
</Button>
<Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5" />} onClick={onRemove}>
卸载
</Button>
</div>
</div>
) : (
<p className="mt-3 text-xs leading-5 text-text-tertiary">
加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。
</p>
)}
</div>
{status?<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2"><span className="truncate text-xs font-medium text-text-primary" title={status.path}>{status.name}</span><Badge>{status.enabled?'运行中':'已停止'}</Badge></div>
<PropertyRow label="语言" value="Python / Pyodide"/><PropertyRow label="控制频率" value={`${status.controlHz} Hz`}/><PropertyRow label="上次耗时" value={`${status.lastStepMs.toFixed(3)} ms`}/>
{status.error&&<p role="alert" className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger">{status.error}</p>}
{status.acceptsCommands&&<div className="mt-3 border-t border-border pt-3"><p className="mb-2 text-[10px] text-text-tertiary">基本移动指令</p><div className="grid grid-cols-3 gap-1.5"><span/><Button aria-pressed={status.activeCommand==='forward'} disabled={!status.enabled} icon={<ArrowUp className="h-3.5 w-3.5"/>} onClick={()=>onCommand('forward')}>前进</Button><span/><Button aria-pressed={status.activeCommand==='turn_left'} disabled={!status.enabled} icon={<ArrowLeft className="h-3.5 w-3.5"/>} onClick={()=>onCommand('turn_left')}>左转</Button><Button aria-label="移动停止" aria-pressed={status.activeCommand==='stop'} disabled={!status.enabled} icon={<Octagon className="h-3.5 w-3.5"/>} onClick={()=>onCommand('stop')}>停止</Button><Button aria-pressed={status.activeCommand==='turn_right'} disabled={!status.enabled} icon={<ArrowRight className="h-3.5 w-3.5"/>} onClick={()=>onCommand('turn_right')}>右转</Button><span/><Button aria-pressed={status.activeCommand==='backward'} disabled={!status.enabled} icon={<ArrowDown className="h-3.5 w-3.5"/>} onClick={()=>onCommand('backward')}>后退</Button><Button disabled={!status.enabled} onClick={()=>onCommand('jump')}>起跳</Button></div></div>}
<div className="mt-3 grid grid-cols-2 gap-2"><Button variant={status.enabled?'secondary':'primary'} icon={<Power className="h-3.5 w-3.5"/>} onClick={()=>onToggle(!status.enabled)}>{status.enabled?'停止':'启用'}</Button><Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5"/>} onClick={onRemove}>卸载</Button></div>
</div>:<p className="mt-3 text-xs leading-5 text-text-tertiary">加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。</p>}
</div>;
);
}
+184 -24
View File
@@ -1,30 +1,190 @@
import {useRef,type ChangeEvent} from 'react';
import {BrainCircuit,FileUp,Power,RotateCw,Trash2} from 'lucide-react';
import type {RLCommand,RLPolicyStatus} from '../../rl/types';
import {Badge,Button,PropertyRow,Select} from '../../components/ui';
import { useRef, type ChangeEvent } from 'react';
import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
export interface RLPolicyPanelProps {
paths:string[];selectedPath?:string;status?:RLPolicyStatus;loading:boolean;
onSelectPath(path:string):void;onLoadPath(path:string):void;onImport(file:File):void;
onToggle(enabled:boolean):void;onCommand(command:RLCommand):void;onRemove():void;
paths: string[];
selectedPath?: string;
status?: RLPolicyStatus;
loading: boolean;
onSelectPath(path: string): void;
onLoadPath(path: string): void;
onImport(file: File): void;
onToggle(enabled: boolean): void;
onCommand(command: RLCommand): void;
onRemove(): void;
}
export function RLPolicyPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:RLPolicyPanelProps){
const input=useRef<HTMLInputElement>(null);
const importFile=(event:ChangeEvent<HTMLInputElement>)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';};
const command=status?.command??{linearX:0,linearY:0,angularZ:0};
return <div>
<input ref={input} className="hidden" type="file" accept=".onnx,application/octet-stream" onChange={importFile}/>
{paths.length>0&&<label className="mb-3 block text-xs text-text-secondary"><span className="mb-1 block">工程内策略</span><Select aria-label="ONNX 策略" className="w-full" value={selectedPath??''} disabled={loading} onChange={event=>onSelectPath(event.target.value)}><option value="">选择 .onnx 文件</option>{paths.map(path=><option key={path} value={path}>{path}</option>)}</Select></label>}
<div className="grid grid-cols-2 gap-2"><Button icon={<FileUp className="h-3.5 w-3.5"/>} disabled={loading} onClick={()=>input.current?.click()}>导入 ONNX</Button><Button icon={<RotateCw className="h-3.5 w-3.5"/>} disabled={loading||!selectedPath} onClick={()=>selectedPath&&onLoadPath(selectedPath)}>加载策略</Button></div>
{status?<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-1.5 truncate text-xs font-medium text-text-primary" title={status.path}><BrainCircuit className="h-3.5 w-3.5 shrink-0 text-accent"/>{status.taskName}</span><Badge>{status.enabled?'推理中':'已停止'}</Badge></div>
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`}/><PropertyRow label="观测 / 动作" value={`${status.observationSize} / ${status.actionSize}`}/><PropertyRow label="推理次数" value={status.inferenceCount}/><PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`}/>
<div className="mt-3 border-t border-border pt-3"><p className="mb-2 text-[10px] text-text-tertiary">速度指令(机身坐标系)</p><CommandInput label="前向 m/s" value={command.linearX} min={-0.5} max={1} onChange={linearX=>onCommand({...command,linearX})}/><CommandInput label="侧向 m/s" value={command.linearY} min={-0.5} max={0.5} onChange={linearY=>onCommand({...command,linearY})}/><CommandInput label="偏航 rad/s" value={command.angularZ} min={-1} max={1} onChange={angularZ=>onCommand({...command,angularZ})}/><Button className="mt-1 w-full" onClick={()=>onCommand({linearX:0,linearY:0,angularZ:0})}>停止移动</Button></div>
{status.error&&<p role="alert" className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger">{status.error}</p>}
<div className="mt-3 grid grid-cols-2 gap-2"><Button variant={status.enabled?'secondary':'primary'} icon={<Power className="h-3.5 w-3.5"/>} disabled={Boolean(status.error)} onClick={()=>onToggle(!status.enabled)}>{status.enabled?'停止':'启用'}</Button><Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5"/>} onClick={onRemove}>卸载</Button></div>
</div>:<p className="mt-3 text-xs leading-5 text-text-tertiary">加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和 12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。</p>}
</div>;
export function RLPolicyPanel({
paths,
selectedPath,
status,
loading,
onSelectPath,
onLoadPath,
onImport,
onToggle,
onCommand,
onRemove,
}: RLPolicyPanelProps) {
const input = useRef<HTMLInputElement>(null);
const importFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) onImport(file);
event.target.value = '';
};
const command = status?.command ?? { linearX: 0, linearY: 0, angularZ: 0 };
return (
<div>
<input
ref={input}
className="hidden"
type="file"
accept=".onnx,application/octet-stream"
onChange={importFile}
/>
{paths.length > 0 && (
<label className="mb-3 block text-xs text-text-secondary">
<span className="mb-1 block">工程内策略</span>
<Select
aria-label="ONNX 策略"
className="w-full"
value={selectedPath ?? ''}
disabled={loading}
onChange={(event) => onSelectPath(event.target.value)}
>
<option value="">选择 .onnx 文件</option>
{paths.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</Select>
</label>
)}
<div className="grid grid-cols-2 gap-2">
<Button
icon={<FileUp className="h-3.5 w-3.5" />}
disabled={loading}
onClick={() => input.current?.click()}
>
导入 ONNX
</Button>
<Button
icon={<RotateCw className="h-3.5 w-3.5" />}
disabled={loading || !selectedPath}
onClick={() => selectedPath && onLoadPath(selectedPath)}
>
加载策略
</Button>
</div>
{status ? (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<span
className="flex min-w-0 items-center gap-1.5 truncate text-xs font-medium text-text-primary"
title={status.path}
>
<BrainCircuit className="h-3.5 w-3.5 shrink-0 text-accent" />
{status.taskName}
</span>
<Badge>{status.enabled ? '推理中' : '已停止'}</Badge>
</div>
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`} />
<PropertyRow
label="观测 / 动作"
value={`${status.observationSize} / ${status.actionSize}`}
/>
<PropertyRow label="推理次数" value={status.inferenceCount} />
<PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`} />
<div className="mt-3 border-t border-border pt-3">
<p className="mb-2 text-[10px] text-text-tertiary">速度指令(机身坐标系)</p>
<CommandInput
label="前向 m/s"
value={command.linearX}
min={-0.5}
max={1}
onChange={(linearX) => onCommand({ ...command, linearX })}
/>
<CommandInput
label="侧向 m/s"
value={command.linearY}
min={-0.5}
max={0.5}
onChange={(linearY) => onCommand({ ...command, linearY })}
/>
<CommandInput
label="偏航 rad/s"
value={command.angularZ}
min={-1}
max={1}
onChange={(angularZ) => onCommand({ ...command, angularZ })}
/>
<Button
className="mt-1 w-full"
onClick={() => onCommand({ linearX: 0, linearY: 0, angularZ: 0 })}
>
停止移动
</Button>
</div>
{status.error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
>
{status.error}
</p>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
variant={status.enabled ? 'secondary' : 'primary'}
icon={<Power className="h-3.5 w-3.5" />}
disabled={Boolean(status.error)}
onClick={() => onToggle(!status.enabled)}
>
{status.enabled ? '停止' : '启用'}
</Button>
<Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5" />} onClick={onRemove}>
卸载
</Button>
</div>
</div>
) : (
<p className="mt-3 text-xs leading-5 text-text-tertiary">
加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和
12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。
</p>
)}
</div>
);
}
function CommandInput({label,value,min,max,onChange}:{label:string;value:number;min:number;max:number;onChange(value:number):void}){return <label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-[10px] text-text-tertiary"><span>{label}</span><input className="field h-7 w-full px-2 text-right text-xs text-text-primary" type="number" step="0.05" min={min} max={max} value={value} onChange={event=>onChange(Number(event.target.value))}/></label>;}
function CommandInput({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange(value: number): void;
}) {
return (
<label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-[10px] text-text-tertiary">
<span>{label}</span>
<input
className="field h-7 w-full px-2 text-right text-xs text-text-primary"
type="number"
step="0.05"
min={min}
max={max}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
@@ -1,15 +1,62 @@
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';
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';
describe('第二批工作台组件',()=>{
it('搜索框发送内容并可清除',()=>{const change=vi.fn();const {rerender}=render(<TreeSearchField value="" onChange={change}/>);fireEvent.change(screen.getByRole('searchbox'),{target:{value:'arm'}});expect(change).toHaveBeenCalledWith('arm');rerender(<TreeSearchField value="arm" resultCount={2} onChange={change}/>);expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');fireEvent.click(screen.getByRole('button',{name:'清除搜索'}));expect(change).toHaveBeenLastCalledWith('');});
it('快捷键帮助展示说明并支持 Escape',()=>{const close=vi.fn();render(<ShortcutHelpDialog open onClose={close}/>);expect(screen.getByRole('dialog',{name:'快捷键与视口操作'})).toBeVisible();expect(screen.getByText('播放 / 暂停')).toBeVisible();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();expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('导入');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('检查与配置');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('运行与调试');expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible();});
it('显示浮窗切换碰撞体和结构辅助标记',()=>{const change=vi.fn();render(<ViewerDisplayPopover value={{...DEFAULT_VIEWER_DISPLAY_OPTIONS}} onChange={change}/>);fireEvent.click(screen.getByRole('button',{name:'显示设置'}));expect(screen.getByRole('dialog',{name:'视图显示设置'})).toBeVisible();expect(screen.getAllByRole('switch')).toHaveLength(7);fireEvent.click(screen.getByRole('switch',{name:/碰撞体/}));expect(change).toHaveBeenCalledWith({...DEFAULT_VIEWER_DISPLAY_OPTIONS,showCollision:true});});
describe('第二批工作台组件', () => {
it('搜索框发送内容并可清除', () => {
const change = vi.fn();
const { rerender } = render(<TreeSearchField value="" onChange={change} />);
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'arm' } });
expect(change).toHaveBeenCalledWith('arm');
rerender(<TreeSearchField value="arm" resultCount={2} onChange={change} />);
expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }));
expect(change).toHaveBeenLastCalledWith('');
});
it('快捷键帮助展示说明并支持 Escape', () => {
const close = vi.fn();
render(<ShortcutHelpDialog open onClose={close} />);
expect(screen.getByRole('dialog', { name: '快捷键与视口操作' })).toBeVisible();
expect(screen.getByText('播放 / 暂停')).toBeVisible();
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();
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('导入');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('检查与配置');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试');
expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible();
});
it('显示浮窗切换碰撞体和结构辅助标记', () => {
const change = vi.fn();
render(
<ViewerDisplayPopover value={{ ...DEFAULT_VIEWER_DISPLAY_OPTIONS }} onChange={change} />,
);
fireEvent.click(screen.getByRole('button', { name: '显示设置' }));
expect(screen.getByRole('dialog', { name: '视图显示设置' })).toBeVisible();
expect(screen.getAllByRole('switch')).toHaveLength(7);
fireEvent.click(screen.getByRole('switch', { name: /碰撞体/ }));
expect(change).toHaveBeenCalledWith({ ...DEFAULT_VIEWER_DISPLAY_OPTIONS, showCollision: true });
});
});
@@ -1,3 +1,106 @@
import {Dialog,PropertyRow,Select} from '../../components/ui';
export function SettingsDialog({open,onClose,theme,angleUnit,showCollision,jointAdvanced,forceScale,onTheme,onAngleUnit,onShowCollision,onJointAdvanced,onForceScale}:{open:boolean;onClose:()=>void;theme:'light'|'dark';angleUnit:'rad'|'deg';showCollision:boolean;jointAdvanced:boolean;forceScale:number;onTheme:(value:'light'|'dark')=>void;onAngleUnit:(value:'rad'|'deg')=>void;onShowCollision:(value:boolean)=>void;onJointAdvanced:(value:boolean)=>void;onForceScale:(value:number)=>void}){return <Dialog open={open} onClose={onClose} title="工作台设置"><div className="space-y-4"><section><h3 className="mb-2 text-xs font-semibold">外观</h3><PropertyRow label="主题" value={<Select aria-label="设置主题" value={theme} onChange={event=>onTheme(event.target.value as 'light'|'dark')}><option value="dark">黑夜</option><option value="light">白天</option></Select>}/></section><section><h3 className="mb-2 text-xs font-semibold">模型与控制</h3><PropertyRow label="角度单位" value={<Select aria-label="设置角度单位" value={angleUnit} onChange={event=>onAngleUnit(event.target.value as 'rad'|'deg')}><option value="rad">弧度</option><option value="deg">角度</option></Select>}/><Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision}/><Check label="关节高级信息" checked={jointAdvanced} onChange={onJointAdvanced}/><label className="mt-3 block text-xs text-text-tertiary"><span className="mb-1 flex justify-between"><span>外力强度</span><output>{forceScale.toFixed(0)} N</output></span><input aria-label="设置外力强度" type="range" min={5} max={200} value={forceScale} onChange={event=>onForceScale(Number(event.target.value))} className="control-slider"/></label></section></div></Dialog>;}
function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-2 flex items-center justify-between text-xs text-text-tertiary"><span>{label}</span><input type="checkbox" aria-label={label} checked={checked} onChange={event=>onChange(event.target.checked)} className="accent-accent focus-visible:ring-2 focus-visible:ring-accent/30"/></label>;}
import { Dialog, PropertyRow, Select } from '../../components/ui';
export function SettingsDialog({
open,
onClose,
theme,
angleUnit,
showCollision,
jointAdvanced,
forceScale,
onTheme,
onAngleUnit,
onShowCollision,
onJointAdvanced,
onForceScale,
}: {
open: boolean;
onClose: () => void;
theme: 'light' | 'dark';
angleUnit: 'rad' | 'deg';
showCollision: boolean;
jointAdvanced: boolean;
forceScale: number;
onTheme: (value: 'light' | 'dark') => void;
onAngleUnit: (value: 'rad' | 'deg') => void;
onShowCollision: (value: boolean) => void;
onJointAdvanced: (value: boolean) => void;
onForceScale: (value: number) => void;
}) {
return (
<Dialog open={open} onClose={onClose} title="工作台设置">
<div className="space-y-4">
<section>
<h3 className="mb-2 text-xs font-semibold">外观</h3>
<PropertyRow
label="主题"
value={
<Select
aria-label="设置主题"
value={theme}
onChange={(event) => onTheme(event.target.value as 'light' | 'dark')}
>
<option value="dark">黑夜</option>
<option value="light">白天</option>
</Select>
}
/>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold">模型与控制</h3>
<PropertyRow
label="角度单位"
value={
<Select
aria-label="设置角度单位"
value={angleUnit}
onChange={(event) => onAngleUnit(event.target.value as 'rad' | 'deg')}
>
<option value="rad">弧度</option>
<option value="deg">角度</option>
</Select>
}
/>
<Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision} />
<Check label="关节高级信息" checked={jointAdvanced} onChange={onJointAdvanced} />
<label className="mt-3 block text-xs text-text-tertiary">
<span className="mb-1 flex justify-between">
<span>外力强度</span>
<output>{forceScale.toFixed(0)} N</output>
</span>
<input
aria-label="设置外力强度"
type="range"
min={5}
max={200}
value={forceScale}
onChange={(event) => onForceScale(Number(event.target.value))}
className="control-slider"
/>
</label>
</section>
</div>
</Dialog>
);
}
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-2 flex items-center justify-between text-xs text-text-tertiary">
<span>{label}</span>
<input
type="checkbox"
aria-label={label}
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="accent-accent focus-visible:ring-2 focus-visible:ring-accent/30"
/>
</label>
);
}
@@ -1,3 +1,37 @@
import {Dialog,Kbd,Separator} from '../../components/ui';
const shortcuts=[['Space','播放 / 暂停'],['R','重置仿真'],['1','选择模式'],['2','关节拖动'],['3','外力施加']];
export function ShortcutHelpDialog({open,onClose}:{open:boolean;onClose:()=>void}){return <Dialog open={open} onClose={onClose} title="快捷键与视口操作"><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"><dt className="text-text-secondary">{label}</dt><dd><Kbd>{key}</Kbd></dd></div>)}</dl></section><Separator className="my-4"/><section><h3 className="mb-2 text-xs font-semibold text-text-primary">鼠标操作</h3><ul className="space-y-1.5 text-xs text-text-secondary"><li>左键拖动:旋转相机或执行当前交互工具</li><li>右键拖动:平移相机</li><li>滚轮:缩放视口</li><li>选择物体后可在右侧“属性”中查看信息</li></ul></section></Dialog>;}
import { Dialog, Kbd, Separator } from '../../components/ui';
const shortcuts = [
['Space', '播放 / 暂停'],
['R', '重置仿真'],
['1', '选择模式'],
['2', '关节拖动'],
['3', '外力施加'],
];
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
<Dialog open={open} onClose={onClose} title="快捷键与视口操作">
<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">
<dt className="text-text-secondary">{label}</dt>
<dd>
<Kbd>{key}</Kbd>
</dd>
</div>
))}
</dl>
</section>
<Separator className="my-4" />
<section>
<h3 className="mb-2 text-xs font-semibold text-text-primary">鼠标操作</h3>
<ul className="space-y-1.5 text-xs text-text-secondary">
<li>左键拖动:旋转相机或执行当前交互工具</li>
<li>右键拖动:平移相机</li>
<li>滚轮:缩放视口</li>
<li>选择物体后可在右侧“属性”中查看信息</li>
</ul>
</section>
</Dialog>
);
}
+765 -63
View File
@@ -1,67 +1,769 @@
import {useState,type ReactNode} from 'react';
import {Box,FolderTree,Info,Settings2,SlidersHorizontal} from 'lucide-react';
import type {ModelEntry} from '../../project/types';
import {countProjectSearchResults,ProjectTree,type ProjectTreeFile} from '../../project/ProjectTree';
import {countModelStructureSearchResults,ModelStructureTree} from '../../project/ModelStructureTree';
import type {ActuatorInfo,ActuatorParameters,SimulationSnapshot} from '../../simulation/SimulationSession';
import type {UrdfBaseMode,UrdfLoadMode} from '../../simulation/PhysicsAdapter';
import type {ViewerSelection} from '../../viewer/MuJoCoViewer';
import type {ControllerCommand,ControllerStatus} from '../../controller/types';
import type {RLCommand,RLPolicyStatus} from '../../rl/types';
import {Badge,Button,CollapsibleSection,CopyButton,PropertyRow,ResizablePanel,Select,Tabs} from '../../components/ui';
import {TreeSearchField} from './TreeSearchField';
import {ProjectBreadcrumb} from './ProjectBreadcrumb';
import {PythonControllerPanel} from './PythonControllerPanel';
import {RLPolicyPanel} from './RLPolicyPanel';
import {LocalTrainingPanel} from './LocalTrainingPanel';
import { useState, type ReactNode } from 'react';
import { Box, FolderTree, Info, Settings2, SlidersHorizontal } from 'lucide-react';
import type { ModelEntry } from '../../project/types';
import {
countProjectSearchResults,
ProjectTree,
type ProjectTreeFile,
} from '../../project/ProjectTree';
import {
countModelStructureSearchResults,
ModelStructureTree,
} from '../../project/ModelStructureTree';
import type {
ActuatorInfo,
ActuatorParameters,
SimulationSnapshot,
} from '../../simulation/SimulationSession';
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import {
Badge,
Button,
CollapsibleSection,
CopyButton,
PropertyRow,
ResizablePanel,
Select,
Tabs,
} from '../../components/ui';
import { TreeSearchField } from './TreeSearchField';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { PythonControllerPanel } from './PythonControllerPanel';
import { RLPolicyPanel } from './RLPolicyPanel';
import { LocalTrainingPanel } from './LocalTrainingPanel';
export function SidebarPanel({title,side,children,visible=true}:{title:string;side:'left'|'right';children:ReactNode;visible?:boolean}){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`}><h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel px-3 text-sm font-semibold text-text-primary"><Settings2 aria-hidden="true" className="h-4 w-4 text-accent"/>{title}</h2>{children}</aside></ResizablePanel>;}
export function ProjectSidebar({projectName,files,entries,selectedEntry,snapshot,loading,visible=true,onRemove,onSelectEntry,onJointHover}:{projectName?:string;files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;snapshot?:SimulationSnapshot;loading:boolean;visible?:boolean;onRemove:()=>void;onSelectEntry:(path:string)=>void;onJointHover:(jointId:number|null)=>void}){const [tab,setTab]=useState<'project'|'structure'>('project'),[fileQuery,setFileQuery]=useState(''),[structureQuery,setStructureQuery]=useState('');const fileMatches=countProjectSearchResults(files,fileQuery),structureMatches=snapshot?countModelStructureSearchResults(snapshot.bodies,snapshot.joints,structureQuery):0;return <SidebarPanel title="工程资源" side="left" visible={visible}>{projectName?<><div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5"><div className="min-w-0 flex-1"><div className="truncate text-sm font-medium text-accent" title={projectName}>{projectName}</div><div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div></div><Button variant="danger" onClick={onRemove} disabled={loading}>移除</Button></div><ProjectBreadcrumb projectName={projectName} entries={entries} selectedEntry={selectedEntry} loading={loading} onSelect={onSelectEntry}/><Tabs label="工程侧栏" value={tab} onValueChange={setTab} items={[{value:'project',label:'工程',icon:<FolderTree className="h-3.5 w-3.5"/>,content:<><TreeSearchField value={fileQuery} onChange={setFileQuery} resultCount={fileMatches} label="搜索工程文件" placeholder="搜索文件或目录…"/><div className="px-2 pb-3"><ProjectTree key={projectName} files={files} entries={entries} selectedEntry={selectedEntry} query={fileQuery}/></div></>},{value:'structure',label:'模型结构',icon:<Box className="h-3.5 w-3.5"/>,disabled:!snapshot,content:snapshot?<><TreeSearchField value={structureQuery} onChange={setStructureQuery} resultCount={structureMatches} label="搜索模型结构" placeholder="搜索 Body 或关节…"/><div className="px-2 pb-3"><ModelStructureTree bodies={snapshot.bodies} joints={snapshot.joints} onJointHover={onJointHover} query={structureQuery}/></div></>:<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>}]}/></>:<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>}</SidebarPanel>;}
interface ModelControlsProps{
snapshot?:SimulationSnapshot;selection:ViewerSelection|null;selectedFormat?:ModelEntry['format'];loading:boolean;visible?:boolean;
urdfMode:UrdfLoadMode;baseMode:UrdfBaseMode;showCollision:boolean;ignoreJointLimits:boolean;jointAdvanced:boolean;angleUnit:'rad'|'deg';forceScale:number;
controllerPaths:string[];selectedControllerPath?:string;controllerStatus?:ControllerStatus;
policyPaths:string[];selectedPolicyPath?:string;policyStatus?:RLPolicyStatus;
onUrdfMode:(value:UrdfLoadMode)=>void;onBaseMode:(value:UrdfBaseMode)=>void;onShowCollision:(value:boolean)=>void;
onResetJoints:()=>void;onToggleJointLimits:()=>void;onToggleAdvanced:()=>void;onToggleAngleUnit:()=>void;
onActuator:(id:number,value:number)=>void;onActuatorParameters:(id:number,parameters:ActuatorParameters)=>void;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>void;
onSelectControllerPath:(path:string)=>void;onLoadControllerPath:(path:string)=>void;onImportController:(file:File)=>void;onToggleController:(enabled:boolean)=>void;onControllerCommand:(command:ControllerCommand)=>void;onRemoveController:()=>void;
onSelectPolicyPath:(path:string)=>void;onLoadPolicyPath:(path:string)=>void;onImportPolicy:(file:File)=>void;onTogglePolicy:(enabled:boolean)=>void;onPolicyCommand:(command:RLCommand)=>void;onRemovePolicy:()=>void;
}
export function ModelControlsSidebar(props:ModelControlsProps){const [tab,setTab]=useState<'properties'|'controls'>('properties'),s=props.snapshot;if(!s)return <SidebarPanel title="模型与控制" side="right" visible={props.visible}><div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div></SidebarPanel>;
const properties=<><CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}><div><PropertyRow label="Body" value={s.model.nbody}/><PropertyRow label="Joint" value={s.model.njnt}/><PropertyRow label="Geom" value={s.model.ngeom}/><PropertyRow label="Actuator" value={s.model.nactuator}/><PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`}/></div></CollapsibleSection>
{props.selectedFormat==='urdf'&&<CollapsibleSection title="URDF 处理方式" defaultOpen={false}><Select aria-label="URDF 处理方式" className="w-full" value={props.urdfMode} disabled={props.loading} onChange={event=>props.onUrdfMode(event.target.value as UrdfLoadMode)}><option value="mjcf">转换为 MJCF(推荐)</option><option value="native">MuJoCo 原生 URDF</option></Select><label className="mt-3 block text-xs text-text-secondary"><span className="mb-1 block">基座类型</span><Select aria-label="URDF 基座类型" className="w-full" value={props.baseMode} disabled={props.loading||props.urdfMode==='native'} onChange={event=>props.onBaseMode(event.target.value as UrdfBaseMode)}><option value="floating">浮动基座(Free Joint)</option><option value="fixed">固定基座(连接世界)</option></Select></label><p className="mt-2 text-xs text-text-tertiary">MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。</p><Check label="显示碰撞几何" checked={props.showCollision} onChange={props.onShowCollision}/></CollapsibleSection>}
<CollapsibleSection title="当前选择" defaultOpen>{props.selection?<div className="text-xs"><PropertyRow label="Body" value={props.selection.bodyName} action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称"/>}/><PropertyRow label="标识" value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`} action={<CopyButton value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`} label="复制标识"/>}/><PropertyRow label="位置" value={props.selection.position.map(value=>value.toFixed(3)).join(', ')} action={<CopyButton value={props.selection.position.join(', ')} label="复制位置"/>}/></div>:<p className="flex items-center gap-2 text-xs text-text-tertiary"><Info className="h-3.5 w-3.5"/>在视口中单击物体</p>}</CollapsibleSection></>;
const controls=<><CollapsibleSection title="ONNX 强化学习策略" defaultOpen badge={s.rlPolicy?<Badge>{s.rlPolicy.enabled?'推理':'停止'}</Badge>:undefined}><RLPolicyPanel paths={props.policyPaths} selectedPath={props.selectedPolicyPath} status={props.policyStatus??s.rlPolicy} loading={props.loading} onSelectPath={props.onSelectPolicyPath} onLoadPath={props.onLoadPolicyPath} onImport={props.onImportPolicy} onToggle={props.onTogglePolicy} onCommand={props.onPolicyCommand} onRemove={props.onRemovePolicy}/></CollapsibleSection><CollapsibleSection title="本地强化学习训练" defaultOpen={false}><LocalTrainingPanel onPolicyReady={props.onImportPolicy}/></CollapsibleSection><CollapsibleSection title="Python 控制器" defaultOpen badge={s.controller?<Badge>{s.controller.enabled?'运行':'停止'}</Badge>:undefined}><PythonControllerPanel paths={props.controllerPaths} selectedPath={props.selectedControllerPath} status={props.controllerStatus??s.controller} loading={props.loading} onSelectPath={props.onSelectControllerPath} onLoadPath={props.onLoadControllerPath} onImport={props.onImportController} onToggle={props.onToggleController} onCommand={props.onControllerCommand} onRemove={props.onRemoveController}/></CollapsibleSection><CollapsibleSection title="Actuator" defaultOpen={false} badge={<Badge>{s.actuators.length}</Badge>}>{s.actuators.length?s.actuators.map(actuator=><ActuatorControl key={actuator.id} actuator={actuator} onControl={value=>props.onActuator(actuator.id,value)} onParameters={parameters=>props.onActuatorParameters(actuator.id,parameters)}/>):<p className="text-xs text-text-tertiary">模型没有驱动器</p>}</CollapsibleSection>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}><div className="mb-4 grid grid-cols-2 gap-2"><Button onClick={props.onResetJoints}>重置关节</Button><Button variant={props.ignoreJointLimits?'primary':'secondary'} aria-pressed={props.ignoreJointLimits} onClick={props.onToggleJointLimits}>忽略关节限位</Button><Button variant={props.jointAdvanced?'primary':'secondary'} aria-pressed={props.jointAdvanced} onClick={props.onToggleAdvanced}>高级</Button><Button variant={props.angleUnit==='deg'?'primary':'secondary'} aria-pressed={props.angleUnit==='deg'} onClick={props.onToggleAngleUnit}>{props.angleUnit==='rad'?'rad 弧度制':'° 角度制'}</Button></div>{s.joints.map(joint=>{const scale=joint.type===3&&props.angleUnit==='deg'?180/Math.PI:1,unit=joint.type===3?(props.angleUnit==='deg'?'°':' rad'):joint.type===2?' m':'';return <ControlSlider key={joint.id} label={`${joint.name}${joint.editable?'':'(只读)'}`} value={joint.value*scale} min={joint.min*scale} max={joint.max*scale} unit={unit} advanced={props.jointAdvanced} limited={joint.limited} limitsIgnored={joint.limitsIgnored} limitMin={joint.limitMin*scale} limitMax={joint.limitMax*scale} disabled={!joint.editable} onChange={value=>props.onJoint(joint.id,value/scale)}/>;})}</CollapsibleSection>
<CollapsibleSection title="外力强度" defaultOpen={false}><ControlSlider label={`${props.forceScale.toFixed(0)} N/屏幕单位`} value={props.forceScale} min={5} max={200} onChange={props.onForceScale}/><p className="text-xs text-text-tertiary">选择“外力施加”,在动态物体上按住拖动,松开即清零。</p></CollapsibleSection></>;
return <SidebarPanel title="模型与控制" side="right" visible={props.visible}><Tabs label="模型控制侧栏" value={tab} onValueChange={setTab} items={[{value:'properties',label:'属性',icon:<Info className="h-3.5 w-3.5"/>,content:properties},{value:'controls',label:'控制',icon:<SlidersHorizontal className="h-3.5 w-3.5"/>,content:controls}]}/></SidebarPanel>;
export function SidebarPanel({
title,
side,
children,
visible = true,
}: {
title: string;
side: 'left' | 'right';
children: ReactNode;
visible?: boolean;
}) {
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`}
>
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel px-3 text-sm font-semibold text-text-primary">
<Settings2 aria-hidden="true" className="h-4 w-4 text-accent" />
{title}
</h2>
{children}
</aside>
</ResizablePanel>
);
}
export function ActuatorControl({actuator,onControl,onParameters}:{actuator:ActuatorInfo;onControl:(value:number)=>void;onParameters:(parameters:ActuatorParameters)=>void}){
const isMotor=actuator.kind==='motor',isPosition=actuator.kind==='position',editable=isMotor||isPosition,baseTargetScale=isPosition&&actuator.jointType===3?180/Math.PI:1,targetScale=isPosition&&Math.abs(actuator.gear)>1e-9?baseTargetScale/actuator.gear:baseTargetScale;
const clampForce=(value:number)=>actuator.forceLimited?Math.min(actuator.forceMax,Math.max(actuator.forceMin,value)):value,physicalScale=actuator.gear*actuator.gain;
const forceA=clampForce(actuator.min*actuator.gain)*actuator.gear,forceB=clampForce(actuator.max*actuator.gain)*actuator.gear;
const targetA=actuator.min*targetScale,targetB=actuator.max*targetScale,outputMin=isMotor?Math.min(forceA,forceB):Math.min(targetA,targetB),outputMax=isMotor?Math.max(forceA,forceB):Math.max(targetA,targetB);
const outputValue=isMotor?clampForce(actuator.value*actuator.gain)*actuator.gear:actuator.value*targetScale,outputDisabled=(isMotor&&Math.abs(physicalScale)<=1e-9)||(isPosition&&Math.abs(actuator.gear)<=1e-9);
const outputLabel=isMotor?(actuator.jointType===3?'输出力矩':'输出力'):isPosition?(actuator.jointType===3?'目标角度':'目标位置'):'控制输入';
const forceUnit=actuator.jointType===3?'N·m':actuator.jointType===2?'N':'',jointForceA=actuator.forceMin*actuator.gear,jointForceB=actuator.forceMax*actuator.gear,jointForceMin=Math.min(jointForceA,jointForceB),jointForceMax=Math.max(jointForceA,jointForceB);
const update=(patch:Partial<ActuatorParameters>)=>onParameters({...actuator,...patch}),controlLabel=isPosition?(actuator.jointType===3?'角度':'位置'):'控制',gearSquared=actuator.gear*actuator.gear;
return <div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex min-w-0 items-start justify-between gap-2"><div className="min-w-0"><div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>{actuator.name}</div><div className="mt-0.5 truncate text-[10px] text-text-tertiary">{actuator.jointName?`关节:${actuator.jointName}`:'未关联标量关节'}</div></div><Badge>{actuator.unit||'u'}</Badge></div>
{actuator.controlCount===1?<ControlSlider label={outputLabel} value={outputValue} min={outputMin} max={outputMax} disabled={outputDisabled} unit={actuator.unit?` ${actuator.unit}`:''} onChange={value=>{if(outputDisabled)return;onControl(isMotor?value/physicalScale:value/targetScale);}}/>:<p className="mb-2 text-[10px] leading-4 text-text-tertiary">该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。</p>}
{actuator.controlCount===1&&!actuator.ctrlLimited&&<div className="mb-2"><ParameterInput label={`${controlLabel}输入(不限幅)`} value={actuator.value*targetScale} onCommit={value=>onControl(value/targetScale)}/></div>}
{editable?<details className="group border-t border-border pt-2"><summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">常用参数</summary>
<div className="mt-2 grid grid-cols-2 gap-2">{isPosition?<><ParameterInput label={`位置增益 kp(${forceUnit}/${actuator.jointType===3?'rad':'m'})`} value={actuator.kp*gearSquared} disabled={gearSquared<=1e-18} onCommit={kp=>update({kp:kp/gearSquared})}/><ParameterInput label={`速度增益 kv(${forceUnit}·s/${actuator.jointType===3?'rad':'m'})`} value={actuator.kv*gearSquared} disabled={gearSquared<=1e-18} onCommit={kv=>update({kv:kv/gearSquared})}/></>:<><ParameterInput label={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType===3?'rad':'m'})`} value={actuator.kp} onCommit={kp=>update({kp})}/><ParameterInput label={`kv(MJCF damping,${forceUnit}·s/${actuator.jointType===3?'rad':'m'})`} value={actuator.kv} onCommit={kv=>update({kv})}/></>}</div>
<ParameterToggle label={`限制输出${actuator.jointType===3?'力矩':'力'}${forceUnit?`(${forceUnit})`:''}`} checked={actuator.forceLimited} onChange={forceLimited=>update({forceLimited})}/>
<div className="mt-2 grid grid-cols-2 gap-2"><ParameterInput label="输出下限" value={jointForceMin} disabled={!actuator.forceLimited||Math.abs(actuator.gear)<=1e-9} onCommit={value=>update(actuator.gear>=0?{forceMin:value/actuator.gear}:{forceMax:value/actuator.gear})}/><ParameterInput label="输出上限" value={jointForceMax} disabled={!actuator.forceLimited||Math.abs(actuator.gear)<=1e-9} onCommit={value=>update(actuator.gear>=0?{forceMax:value/actuator.gear}:{forceMin:value/actuator.gear})}/></div>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">{isPosition?'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。':'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'} 参数修改会立即作用于当前模型,并可随 MJCF 导出。</p>
</details>:<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF 源码中修改专用参数。</p>}
</div>;
export function ProjectSidebar({
projectName,
files,
entries,
selectedEntry,
snapshot,
loading,
visible = true,
onRemove,
onSelectEntry,
onJointHover,
}: {
projectName?: string;
files: ProjectTreeFile[];
entries: ModelEntry[];
selectedEntry?: string;
snapshot?: SimulationSnapshot;
loading: boolean;
visible?: boolean;
onRemove: () => void;
onSelectEntry: (path: string) => void;
onJointHover: (jointId: number | null) => void;
}) {
const [tab, setTab] = useState<'project' | 'structure'>('project'),
[fileQuery, setFileQuery] = useState(''),
[structureQuery, setStructureQuery] = useState('');
const fileMatches = countProjectSearchResults(files, fileQuery),
structureMatches = snapshot
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
: 0;
return (
<SidebarPanel title="工程资源" side="left" visible={visible}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-accent" title={projectName}>
{projectName}
</div>
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div>
</div>
<Button variant="danger" onClick={onRemove} disabled={loading}>
移除
</Button>
</div>
<ProjectBreadcrumb
projectName={projectName}
entries={entries}
selectedEntry={selectedEntry}
loading={loading}
onSelect={onSelectEntry}
/>
<Tabs
label="工程侧栏"
value={tab}
onValueChange={setTab}
items={[
{
value: 'project',
label: '工程',
icon: <FolderTree className="h-3.5 w-3.5" />,
content: (
<>
<TreeSearchField
value={fileQuery}
onChange={setFileQuery}
resultCount={fileMatches}
label="搜索工程文件"
placeholder="搜索文件或目录…"
/>
<div className="px-2 pb-3">
<ProjectTree
key={projectName}
files={files}
entries={entries}
selectedEntry={selectedEntry}
query={fileQuery}
/>
</div>
</>
),
},
{
value: 'structure',
label: '模型结构',
icon: <Box className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: snapshot ? (
<>
<TreeSearchField
value={structureQuery}
onChange={setStructureQuery}
resultCount={structureMatches}
label="搜索模型结构"
placeholder="搜索 Body 或关节…"
/>
<div className="px-2 pb-3">
<ModelStructureTree
bodies={snapshot.bodies}
joints={snapshot.joints}
onJointHover={onJointHover}
query={structureQuery}
/>
</div>
</>
) : (
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
),
},
]}
/>
</>
) : (
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>
)}
</SidebarPanel>
);
}
interface ModelControlsProps {
snapshot?: SimulationSnapshot;
selection: ViewerSelection | null;
selectedFormat?: ModelEntry['format'];
loading: boolean;
visible?: boolean;
urdfMode: UrdfLoadMode;
baseMode: UrdfBaseMode;
showCollision: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
onToggleAngleUnit: () => void;
onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
onJoint: (id: number, value: number) => void;
onForceScale: (value: number) => void;
onSelectControllerPath: (path: string) => void;
onLoadControllerPath: (path: string) => void;
onImportController: (file: File) => void;
onToggleController: (enabled: boolean) => void;
onControllerCommand: (command: ControllerCommand) => void;
onRemoveController: () => void;
onSelectPolicyPath: (path: string) => void;
onLoadPolicyPath: (path: string) => void;
onImportPolicy: (file: File) => void;
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
}
export function ModelControlsSidebar(props: ModelControlsProps) {
const [tab, setTab] = useState<'properties' | 'controls'>('properties'),
s = props.snapshot;
if (!s)
return (
<SidebarPanel title="模型与控制" side="right" visible={props.visible}>
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
</SidebarPanel>
);
const properties = (
<>
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
<div>
<PropertyRow label="Body" value={s.model.nbody} />
<PropertyRow label="Joint" value={s.model.njnt} />
<PropertyRow label="Geom" value={s.model.ngeom} />
<PropertyRow label="Actuator" value={s.model.nactuator} />
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
</div>
</CollapsibleSection>
{props.selectedFormat === 'urdf' && (
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
<Select
aria-label="URDF 处理方式"
className="w-full"
value={props.urdfMode}
disabled={props.loading}
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)}
>
<option value="mjcf">转换为 MJCF(推荐)</option>
<option value="native">MuJoCo 原生 URDF</option>
</Select>
<label className="mt-3 block text-xs text-text-secondary">
<span className="mb-1 block">基座类型</span>
<Select
aria-label="URDF 基座类型"
className="w-full"
value={props.baseMode}
disabled={props.loading || props.urdfMode === 'native'}
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
>
<option value="floating">浮动基座(Free Joint)</option>
<option value="fixed">固定基座(连接世界)</option>
</Select>
</label>
<p className="mt-2 text-xs text-text-tertiary">
MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
</p>
<Check
label="显示碰撞几何"
checked={props.showCollision}
onChange={props.onShowCollision}
/>
</CollapsibleSection>
)}
<CollapsibleSection title="当前选择" defaultOpen>
{props.selection ? (
<div className="text-xs">
<PropertyRow
label="Body"
value={props.selection.bodyName}
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
/>
<PropertyRow
label="标识"
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
action={
<CopyButton
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
label="复制标识"
/>
}
/>
<PropertyRow
label="位置"
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
/>
</div>
) : (
<p className="flex items-center gap-2 text-xs text-text-tertiary">
<Info className="h-3.5 w-3.5" />
在视口中单击物体
</p>
)}
</CollapsibleSection>
</>
);
const controls = (
<>
<CollapsibleSection
title="ONNX 强化学习策略"
defaultOpen
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
>
<RLPolicyPanel
paths={props.policyPaths}
selectedPath={props.selectedPolicyPath}
status={props.policyStatus ?? s.rlPolicy}
loading={props.loading}
onSelectPath={props.onSelectPolicyPath}
onLoadPath={props.onLoadPolicyPath}
onImport={props.onImportPolicy}
onToggle={props.onTogglePolicy}
onCommand={props.onPolicyCommand}
onRemove={props.onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
</CollapsibleSection>
<CollapsibleSection
title="Python 控制器"
defaultOpen
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
>
<PythonControllerPanel
paths={props.controllerPaths}
selectedPath={props.selectedControllerPath}
status={props.controllerStatus ?? s.controller}
loading={props.loading}
onSelectPath={props.onSelectControllerPath}
onLoadPath={props.onLoadControllerPath}
onImport={props.onImportController}
onToggle={props.onToggleController}
onCommand={props.onControllerCommand}
onRemove={props.onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="Actuator"
defaultOpen={false}
badge={<Badge>{s.actuators.length}</Badge>}
>
{s.actuators.length ? (
s.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => props.onActuator(actuator.id, value)}
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
)}
</CollapsibleSection>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={props.onResetJoints}>重置关节</Button>
<Button
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={props.ignoreJointLimits}
onClick={props.onToggleJointLimits}
>
忽略关节限位
</Button>
<Button
variant={props.jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={props.jointAdvanced}
onClick={props.onToggleAdvanced}
>
高级
</Button>
<Button
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={props.angleUnit === 'deg'}
onClick={props.onToggleAngleUnit}
>
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{s.joints.map((joint) => {
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
unit =
joint.type === 3
? props.angleUnit === 'deg'
? '°'
: ' rad'
: joint.type === 2
? ' m'
: '';
return (
<ControlSlider
key={joint.id}
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={props.jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => props.onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection title="外力强度" defaultOpen={false}>
<ControlSlider
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
value={props.forceScale}
min={5}
max={200}
onChange={props.onForceScale}
/>
<p className="text-xs text-text-tertiary">
选择“外力施加”,在动态物体上按住拖动,松开即清零。
</p>
</CollapsibleSection>
</>
);
return (
<SidebarPanel title="模型与控制" side="right" visible={props.visible}>
<Tabs
label="模型控制侧栏"
value={tab}
onValueChange={setTab}
items={[
{
value: 'properties',
label: '属性',
icon: <Info className="h-3.5 w-3.5" />,
content: properties,
},
{
value: 'controls',
label: '控制',
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
content: controls,
},
]}
/>
</SidebarPanel>
);
}
export function ActuatorControl({
actuator,
onControl,
onParameters,
}: {
actuator: ActuatorInfo;
onControl: (value: number) => void;
onParameters: (parameters: ActuatorParameters) => void;
}) {
const isMotor = actuator.kind === 'motor',
isPosition = actuator.kind === 'position',
editable = isMotor || isPosition,
baseTargetScale = isPosition && actuator.jointType === 3 ? 180 / Math.PI : 1,
targetScale =
isPosition && Math.abs(actuator.gear) > 1e-9
? baseTargetScale / actuator.gear
: baseTargetScale;
const clampForce = (value: number) =>
actuator.forceLimited
? Math.min(actuator.forceMax, Math.max(actuator.forceMin, value))
: value,
physicalScale = actuator.gear * actuator.gain;
const forceA = clampForce(actuator.min * actuator.gain) * actuator.gear,
forceB = clampForce(actuator.max * actuator.gain) * actuator.gear;
const targetA = actuator.min * targetScale,
targetB = actuator.max * targetScale,
outputMin = isMotor ? Math.min(forceA, forceB) : Math.min(targetA, targetB),
outputMax = isMotor ? Math.max(forceA, forceB) : Math.max(targetA, targetB);
const outputValue = isMotor
? clampForce(actuator.value * actuator.gain) * actuator.gear
: actuator.value * targetScale,
outputDisabled =
(isMotor && Math.abs(physicalScale) <= 1e-9) ||
(isPosition && Math.abs(actuator.gear) <= 1e-9);
const outputLabel = isMotor
? actuator.jointType === 3
? '输出力矩'
: '输出力'
: isPosition
? actuator.jointType === 3
? '目标角度'
: '目标位置'
: '控制输入';
const forceUnit = actuator.jointType === 3 ? 'N·m' : actuator.jointType === 2 ? 'N' : '',
jointForceA = actuator.forceMin * actuator.gear,
jointForceB = actuator.forceMax * actuator.gear,
jointForceMin = Math.min(jointForceA, jointForceB),
jointForceMax = Math.max(jointForceA, jointForceB);
const update = (patch: Partial<ActuatorParameters>) => onParameters({ ...actuator, ...patch }),
controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
gearSquared = actuator.gear * actuator.gear;
return (
<div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>
{actuator.name}
</div>
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
{actuator.jointName ? `关节:${actuator.jointName}` : '未关联标量关节'}
</div>
</div>
<Badge>{actuator.unit || 'u'}</Badge>
</div>
{actuator.controlCount === 1 ? (
<ControlSlider
label={outputLabel}
value={outputValue}
min={outputMin}
max={outputMax}
disabled={outputDisabled}
unit={actuator.unit ? ` ${actuator.unit}` : ''}
onChange={(value) => {
if (outputDisabled) return;
onControl(isMotor ? value / physicalScale : value / targetScale);
}}
/>
) : (
<p className="mb-2 text-[10px] leading-4 text-text-tertiary">
该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。
</p>
)}
{actuator.controlCount === 1 && !actuator.ctrlLimited && (
<div className="mb-2">
<ParameterInput
label={`${controlLabel}输入(不限幅)`}
value={actuator.value * targetScale}
onCommit={(value) => onControl(value / targetScale)}
/>
</div>
)}
{editable ? (
<details className="group border-t border-border pt-2">
<summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">
常用参数
</summary>
<div className="mt-2 grid grid-cols-2 gap-2">
{isPosition ? (
<>
<ParameterInput
label={`位置增益 kp(${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kp * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kp) => update({ kp: kp / gearSquared })}
/>
<ParameterInput
label={`速度增益 kv(${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kv * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kv) => update({ kv: kv / gearSquared })}
/>
</>
) : (
<>
<ParameterInput
label={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kp}
onCommit={(kp) => update({ kp })}
/>
<ParameterInput
label={`kv(MJCF damping,${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kv}
onCommit={(kv) => update({ kv })}
/>
</>
)}
</div>
<ParameterToggle
label={`限制输出${actuator.jointType === 3 ? '力矩' : '力'}${forceUnit ? `(${forceUnit})` : ''}`}
checked={actuator.forceLimited}
onChange={(forceLimited) => update({ forceLimited })}
/>
<div className="mt-2 grid grid-cols-2 gap-2">
<ParameterInput
label="输出下限"
value={jointForceMin}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMin: value / actuator.gear }
: { forceMax: value / actuator.gear },
)
}
/>
<ParameterInput
label="输出上限"
value={jointForceMax}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMax: value / actuator.gear }
: { forceMin: value / actuator.gear },
)
}
/>
</div>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
{isPosition
? 'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。'
: 'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'}{' '}
参数修改会立即作用于当前模型,并可随 MJCF 导出。
</p>
</details>
) : (
<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">
该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF
源码中修改专用参数。
</p>
)}
</div>
);
}
function ParameterInput({
label,
value,
onCommit,
disabled = false,
}: {
label: string;
value: number;
onCommit: (value: number) => void;
disabled?: boolean;
}) {
return (
<label className="block text-[10px] text-text-tertiary">
<span className="mb-1 block truncate">{label}</span>
<input
key={value}
type="number"
step="any"
defaultValue={Number.isFinite(value) ? value : 0}
disabled={disabled}
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
onBlur={(event) => {
const next = Number(event.currentTarget.value);
if (Number.isFinite(next) && next !== value) onCommit(next);
else event.currentTarget.value = String(value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
/>
</label>
);
}
function ParameterToggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary">
<input
type="checkbox"
className="accent-accent"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-3 flex items-center gap-2 text-xs text-text-secondary">
<input
type="checkbox"
className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
function ControlSlider({
label,
value,
min,
max,
onChange,
disabled = false,
unit = '',
advanced = false,
limited = false,
limitsIgnored = false,
limitMin = 0,
limitMax = 0,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
disabled?: boolean;
unit?: string;
advanced?: boolean;
limited?: boolean;
limitsIgnored?: boolean;
limitMin?: number;
limitMax?: number;
}) {
const sane = Number.isFinite(value) ? value : 0,
format = (number: number) => `${number.toFixed(3)}${unit}`;
return (
<label className="mb-3 block text-xs">
<span className="mb-1 flex justify-between gap-2">
<span className="truncate text-text-secondary">{label}</span>
<output className="technical-value text-text-primary">{format(sane)}</output>
</span>
<input
className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel"
type="range"
disabled={disabled}
value={Math.min(max, Math.max(min, sane))}
min={min}
max={max}
step={(max - min) / 500 || 0.001}
onChange={(event) => onChange(Number(event.target.value))}
/>
{advanced && (
<span className="mt-1 flex justify-between text-[10px] text-text-tertiary">
<span>下限 {limited ? format(limitMin) : '无限制'}</span>
{limitsIgnored && limited && <span className="text-warning">已忽略</span>}
<span>上限 {limited ? format(limitMax) : '无限制'}</span>
</span>
)}
</label>
);
}
function ParameterInput({label,value,onCommit,disabled=false}:{label:string;value:number;onCommit:(value:number)=>void;disabled?:boolean}){return <label className="block text-[10px] text-text-tertiary"><span className="mb-1 block truncate">{label}</span><input key={value} type="number" step="any" defaultValue={Number.isFinite(value)?value:0} disabled={disabled} className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40" onBlur={event=>{const next=Number(event.currentTarget.value);if(Number.isFinite(next)&&next!==value)onCommit(next);else event.currentTarget.value=String(value);}} onKeyDown={event=>{if(event.key==='Enter')event.currentTarget.blur();}}/></label>;}
function ParameterToggle({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary"><input type="checkbox" className="accent-accent" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</label>;}
function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-3 flex items-center gap-2 text-xs text-text-secondary"><input type="checkbox" className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</label>;}
function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(value:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between gap-2"><span className="truncate text-text-secondary">{label}</span><output className="technical-value text-text-primary">{format(sane)}</output></span><input className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={event=>onChange(Number(event.target.value))}/>{advanced&&<span className="mt-1 flex justify-between text-[10px] text-text-tertiary"><span>下限 {limited?format(limitMin):'无限制'}</span>{limitsIgnored&&limited&&<span className="text-warning">已忽略</span>}<span>上限 {limited?format(limitMax):'无限制'}</span></span>}</label>;}
@@ -1,29 +1,248 @@
import './monacoSetup';
import Editor from '@monaco-editor/react';
import {useCallback,useEffect,useRef,useState,type PointerEvent as ReactPointerEvent} from 'react';
import {Check,Code2,Copy,Download,Maximize2,Minimize2,Save,X} from 'lucide-react';
import {downloadBytes} from '../../project/cachedFiles';
import {Button,ConfirmDialog,IconButton} from '../../components/ui';
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { Check, Code2, Copy, Download, Maximize2, Minimize2, Save, X } from 'lucide-react';
import { downloadBytes } from '../../project/cachedFiles';
import { Button, ConfirmDialog, IconButton } from '../../components/ui';
function basename(path:string):string{return path.split('/').at(-1)??path;}
function contentSize(content:string):string{const bytes=new Blob([content]).size;return bytes<1024?`${bytes} B`:`${(bytes/1024).toFixed(1)} KB`;}
function xmlProblem(code:string):string|undefined{const document=new DOMParser().parseFromString(code,'application/xml'),error=document.querySelector('parsererror');return error?.textContent?.split('\n')[0]||undefined;}
export function SourceEditorDialog({open,code:sourceCode,filePath,theme,onClose,onSave}:{open:boolean;code:string;filePath:string;theme:'light'|'dark';onClose:()=>void;onSave:(path:string,text:string)=>void|Promise<void>}){
const [code,setCode]=useState(sourceCode),[savedCode,setSavedCode]=useState(sourceCode),[saving,setSaving]=useState(false),[copied,setCopied]=useState(false),[maximized,setMaximized]=useState(false),[discardOpen,setDiscardOpen]=useState(false),[position,setPosition]=useState(()=>({x:Math.max(24,(window.innerWidth-900)/2),y:Math.max(52,(window.innerHeight-650)/2)}));
const drag=useRef<{x:number;y:number;left:number;top:number}|null>(null),dialog=useRef<HTMLElement>(null),previousFocus=useRef<HTMLElement|null>(null),dirty=code!==savedCode,problem=xmlProblem(code);
const requestClose=useCallback(()=>{if(dirty)setDiscardOpen(true);else onClose();},[dirty,onClose]);
const save=useCallback(async()=>{if(!dirty||problem)return;setSaving(true);try{await onSave(filePath,code);setSavedCode(code);}finally{setSaving(false);}},[code,dirty,filePath,onSave,problem]);
useEffect(()=>{if(!open)return;previousFocus.current=document.activeElement instanceof HTMLElement?document.activeElement:null;requestAnimationFrame(()=>dialog.current?.focus());return()=>{if(previousFocus.current&&document.contains(previousFocus.current))previousFocus.current.focus();};},[open]);
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(discardOpen)return;if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'&&dirty&&!problem){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]);
const copy=async()=>{await navigator.clipboard.writeText(code);setCopied(true);window.setTimeout(()=>setCopied(false),1500);};
const download=()=>downloadBytes(new TextEncoder().encode(code),basename(filePath),'application/xml');
const pointerDown=(event:ReactPointerEvent)=>{if(maximized||event.button!==0||(event.target as HTMLElement).closest('button'))return;drag.current={x:event.clientX,y:event.clientY,left:position.x,top:position.y};event.currentTarget.setPointerCapture(event.pointerId);};
const pointerMove=(event:ReactPointerEvent)=>{if(!drag.current)return;setPosition({x:Math.min(window.innerWidth-120,Math.max(-780,drag.current.left+event.clientX-drag.current.x)),y:Math.min(window.innerHeight-48,Math.max(0,drag.current.top+event.clientY-drag.current.y))});};
if(!open)return null;
return <><div className="fixed inset-0 z-[390] pointer-events-none" role="presentation"><section ref={dialog} tabIndex={-1} role="dialog" aria-modal="false" aria-label="转换后的 MJCF 编辑器" style={maximized?undefined:{left:position.x,top:position.y,width:900,height:650}} 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'}`}>
<header className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3" onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={()=>{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}><Code2 className="h-4 w-4 shrink-0 text-accent"/><div className="min-w-0 flex-1"><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}>{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>{dirty&&<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">已修改</span>}<Button variant="primary" icon={<Save className="h-3 w-3"/>} disabled={!dirty||saving||Boolean(problem)} onClick={()=>void save()}>{saving?'重新载入中…':'保存并重新载入'}</Button><Button variant="ghost" icon={<Download className="h-3.5 w-3.5"/>} onClick={download}>下载</Button><Button variant="ghost" icon={copied?<Check className="h-3.5 w-3.5"/>:<Copy className="h-3.5 w-3.5"/>} onClick={()=>void copy()}>{copied?'已复制':'复制'}</Button><IconButton tooltip={maximized?'还原':'最大化'} aria-label={maximized?'还原':'最大化'} onClick={()=>setMaximized(value=>!value)}>{maximized?<Minimize2 className="h-4 w-4"/>:<Maximize2 className="h-4 w-4"/>}</IconButton><IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={requestClose}><X className="h-4 w-4"/></IconButton></header>
<div className="min-h-0 flex-1 bg-input"><Editor height="100%" language="xml" theme={theme==='light'?'light':'vs-dark'} value={code} onChange={value=>setCode(value??'')} options={{automaticLayout:true,minimap:{enabled:false},fontFamily:"'JetBrains Mono','Fira Code',ui-monospace,monospace",fontSize:13,fontLigatures:true,scrollBeyondLastLine:false,wordWrap:'off',stickyScroll:{enabled:false},tabSize:2,formatOnPaste:true,formatOnType:true,lineNumbersMinChars:4,padding:{top:12,bottom:14},renderLineHighlight:'all'}}/></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'}>{problem?`XML 错误:${problem}`:'✓ XML 结构正常'}</div><div className="flex items-center gap-2 font-mono text-text-tertiary"><span>Ctrl+S 保存并重新载入</span><span>•</span><span>MJCF / XML</span></div></footer>
</section></div><ConfirmDialog open={discardOpen} title="放弃未保存的修改?" confirmLabel="放弃修改" cancelLabel="继续编辑" danger onConfirm={onClose} onClose={()=>setDiscardOpen(false)}><p className="text-sm text-text-secondary">当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。</p></ConfirmDialog></>;
function basename(path: string): string {
return path.split('/').at(-1) ?? path;
}
function contentSize(content: string): string {
const bytes = new Blob([content]).size;
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
}
function xmlProblem(code: string): string | undefined {
const document = new DOMParser().parseFromString(code, 'application/xml'),
error = document.querySelector('parsererror');
return error?.textContent?.split('\n')[0] || undefined;
}
export function SourceEditorDialog({
open,
code: sourceCode,
filePath,
theme,
onClose,
onSave,
}: {
open: boolean;
code: string;
filePath: string;
theme: 'light' | 'dark';
onClose: () => void;
onSave: (path: string, text: string) => void | Promise<void>;
}) {
const [code, setCode] = useState(sourceCode),
[savedCode, setSavedCode] = useState(sourceCode),
[saving, setSaving] = useState(false),
[copied, setCopied] = useState(false),
[maximized, setMaximized] = useState(false),
[discardOpen, setDiscardOpen] = useState(false),
[position, setPosition] = useState(() => ({
x: Math.max(24, (window.innerWidth - 900) / 2),
y: Math.max(52, (window.innerHeight - 650) / 2),
}));
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null),
dialog = useRef<HTMLElement>(null),
previousFocus = useRef<HTMLElement | null>(null),
dirty = code !== savedCode,
problem = xmlProblem(code);
const requestClose = useCallback(() => {
if (dirty) setDiscardOpen(true);
else onClose();
}, [dirty, onClose]);
const save = useCallback(async () => {
if (!dirty || problem) return;
setSaving(true);
try {
await onSave(filePath, code);
setSavedCode(code);
} finally {
setSaving(false);
}
}, [code, dirty, filePath, onSave, problem]);
useEffect(() => {
if (!open) return;
previousFocus.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
requestAnimationFrame(() => dialog.current?.focus());
return () => {
if (previousFocus.current && document.contains(previousFocus.current))
previousFocus.current.focus();
};
}, [open]);
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (discardOpen) return;
if (
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === 's' &&
dirty &&
!problem
) {
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]);
const copy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
const download = () =>
downloadBytes(new TextEncoder().encode(code), basename(filePath), 'application/xml');
const pointerDown = (event: ReactPointerEvent) => {
if (maximized || event.button !== 0 || (event.target as HTMLElement).closest('button')) return;
drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y };
event.currentTarget.setPointerCapture(event.pointerId);
};
const pointerMove = (event: ReactPointerEvent) => {
if (!drag.current) return;
setPosition({
x: Math.min(
window.innerWidth - 120,
Math.max(-780, drag.current.left + event.clientX - drag.current.x),
),
y: Math.min(
window.innerHeight - 48,
Math.max(0, drag.current.top + event.clientY - drag.current.y),
),
});
};
if (!open) return null;
return (
<>
<div className="fixed inset-0 z-[390] pointer-events-none" role="presentation">
<section
ref={dialog}
tabIndex={-1}
role="dialog"
aria-modal="false"
aria-label="转换后的 MJCF 编辑器"
style={
maximized ? undefined : { left: position.x, top: position.y, width: 900, height: 650 }
}
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'}`}
>
<header
className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
onPointerDown={pointerDown}
onPointerMove={pointerMove}
onPointerUp={() => {
drag.current = null;
}}
onDoubleClick={() => setMaximized((value) => !value)}
>
<Code2 className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<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}>
{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>
{dirty && (
<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">
已修改
</span>
)}
<Button
variant="primary"
icon={<Save className="h-3 w-3" />}
disabled={!dirty || saving || Boolean(problem)}
onClick={() => void save()}
>
{saving ? '重新载入中…' : '保存并重新载入'}
</Button>
<Button variant="ghost" icon={<Download className="h-3.5 w-3.5" />} onClick={download}>
下载
</Button>
<Button
variant="ghost"
icon={copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
onClick={() => void copy()}
>
{copied ? '已复制' : '复制'}
</Button>
<IconButton
tooltip={maximized ? '还原' : '最大化'}
aria-label={maximized ? '还原' : '最大化'}
onClick={() => setMaximized((value) => !value)}
>
{maximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</IconButton>
<IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={requestClose}>
<X className="h-4 w-4" />
</IconButton>
</header>
<div className="min-h-0 flex-1 bg-input">
<Editor
height="100%"
language="xml"
theme={theme === 'light' ? 'light' : 'vs-dark'}
value={code}
onChange={(value) => setCode(value ?? '')}
options={{
automaticLayout: true,
minimap: { enabled: false },
fontFamily: "'JetBrains Mono','Fira Code',ui-monospace,monospace",
fontSize: 13,
fontLigatures: true,
scrollBeyondLastLine: false,
wordWrap: 'off',
stickyScroll: { enabled: false },
tabSize: 2,
formatOnPaste: true,
formatOnType: true,
lineNumbersMinChars: 4,
padding: { top: 12, bottom: 14 },
renderLineHighlight: 'all',
}}
/>
</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'}>
{problem ? `XML 错误:${problem}` : '✓ XML 结构正常'}
</div>
<div className="flex items-center gap-2 font-mono text-text-tertiary">
<span>Ctrl+S 保存并重新载入</span>
<span>•</span>
<span>MJCF / XML</span>
</div>
</footer>
</section>
</div>
<ConfirmDialog
open={discardOpen}
title="放弃未保存的修改?"
confirmLabel="放弃修改"
cancelLabel="继续编辑"
danger
onConfirm={onClose}
onClose={() => setDiscardOpen(false)}
>
<p className="text-sm text-text-secondary">
当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。
</p>
</ConfirmDialog>
</>
);
}
+51 -7
View File
@@ -1,7 +1,51 @@
import type {ReactNode} from 'react';
import {Box,Clock3,MemoryStick,TriangleAlert} from 'lucide-react';
import {Kbd} from '../../components/ui';
import {PerformancePopover} from './PerformancePopover';
export interface StatusBarProps{time?:number;fps:number;stepMs:number;memoryMb?:number;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>{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>}<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>;}
import type { ReactNode } from 'react';
import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react';
import { Kbd } from '../../components/ui';
import { PerformancePopover } from './PerformancePopover';
export interface StatusBarProps {
time?: number;
fps: number;
stepMs: number;
memoryMb?: number;
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>
{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>
)}
<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>
);
}
@@ -1,8 +1,31 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {CommandPalette,type WorkbenchCommand} from './CommandPalette';
import {PerformancePopover} from './PerformancePopover';
import { fireEvent, render, screen } from '@testing-library/react';
import { CommandPalette, type WorkbenchCommand } from './CommandPalette';
import { PerformancePopover } from './PerformancePopover';
describe('第三批工作台组件',()=>{
it('命令面板可搜索并执行现有命令',()=>{const run=vi.fn(),close=vi.fn(),commands:WorkbenchCommand[]=[{id:'reset',label:'重置仿真',group:'仿真',run},{id:'theme',label:'切换主题',group:'外观',run:vi.fn()}];render(<CommandPalette open onClose={close} commands={commands}/>);const input=screen.getByLabelText('搜索命令');fireEvent.change(input,{target:{value:'重置'}});expect(screen.queryByText('切换主题')).not.toBeInTheDocument();fireEvent.keyDown(input,{key:'Enter'});expect(run).toHaveBeenCalledTimes(1);expect(close).toHaveBeenCalledTimes(1);expect(input).toHaveAttribute('aria-controls');expect(input).toHaveAttribute('aria-activedescendant');});
it('状态栏性能入口展示已有指标',()=>{render(<PerformancePopover fps={59.6} stepMs={1.25} memoryMb={42.5} overBudget={false}/>);fireEvent.click(screen.getByRole('button'));expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('60 FPS');expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('42.5 MiB');fireEvent.keyDown(document,{key:'k',ctrlKey:true});expect(screen.queryByRole('dialog',{name:'性能详情'})).not.toBeInTheDocument();});
describe('第三批工作台组件', () => {
it('命令面板可搜索并执行现有命令', () => {
const run = vi.fn(),
close = vi.fn(),
commands: WorkbenchCommand[] = [
{ id: 'reset', label: '重置仿真', group: '仿真', run },
{ id: 'theme', label: '切换主题', group: '外观', run: vi.fn() },
];
render(<CommandPalette open onClose={close} commands={commands} />);
const input = screen.getByLabelText('搜索命令');
fireEvent.change(input, { target: { value: '重置' } });
expect(screen.queryByText('切换主题')).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: 'Enter' });
expect(run).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledTimes(1);
expect(input).toHaveAttribute('aria-controls');
expect(input).toHaveAttribute('aria-activedescendant');
});
it('状态栏性能入口展示已有指标', () => {
render(<PerformancePopover fps={59.6} stepMs={1.25} memoryMb={42.5} overBudget={false} />);
fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('60 FPS');
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('42.5 MiB');
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
expect(screen.queryByRole('dialog', { name: '性能详情' })).not.toBeInTheDocument();
});
});
@@ -1,3 +1,72 @@
import {CircleHelp,Expand,LayoutDashboard,Maximize,Search,Settings,SunMoon} from 'lucide-react';
import {DropdownMenu} from '../../components/ui';
export function ToolbarOverflowMenu({fullscreen,onCommands,onLayout,onSettings,onFullscreen,onHelp,onTheme}:{fullscreen:boolean;onCommands:()=>void;onLayout:()=>void;onSettings:()=>void;onFullscreen:()=>void;onHelp:()=>void;onTheme:()=>void}){return <DropdownMenu label="更多工作台操作" className="xl:hidden" items={[{id:'commands',label:'命令面板',icon:<Search className="h-4 w-4"/>,onSelect:onCommands},{id:'layout',label:'布局设置',icon:<LayoutDashboard className="h-4 w-4"/>,onSelect:onLayout},{id:'settings',label:'工作台设置',icon:<Settings className="h-4 w-4"/>,onSelect:onSettings},{id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',icon:fullscreen?<Expand className="h-4 w-4"/>:<Maximize className="h-4 w-4"/>,onSelect:onFullscreen},{id:'help',label:'快捷键帮助',icon:<CircleHelp className="h-4 w-4"/>,onSelect:onHelp},{id:'theme',label:'切换主题',icon:<SunMoon className="h-4 w-4"/>,onSelect:onTheme}]}/>;}
import {
CircleHelp,
Expand,
LayoutDashboard,
Maximize,
Search,
Settings,
SunMoon,
} from 'lucide-react';
import { DropdownMenu } from '../../components/ui';
export function ToolbarOverflowMenu({
fullscreen,
onCommands,
onLayout,
onSettings,
onFullscreen,
onHelp,
onTheme,
}: {
fullscreen: boolean;
onCommands: () => void;
onLayout: () => void;
onSettings: () => void;
onFullscreen: () => void;
onHelp: () => void;
onTheme: () => void;
}) {
return (
<DropdownMenu
label="更多工作台操作"
className="xl:hidden"
items={[
{
id: 'commands',
label: '命令面板',
icon: <Search className="h-4 w-4" />,
onSelect: onCommands,
},
{
id: 'layout',
label: '布局设置',
icon: <LayoutDashboard className="h-4 w-4" />,
onSelect: onLayout,
},
{
id: 'settings',
label: '工作台设置',
icon: <Settings className="h-4 w-4" />,
onSelect: onSettings,
},
{
id: 'fullscreen',
label: fullscreen ? '退出全屏' : '进入全屏',
icon: fullscreen ? <Expand className="h-4 w-4" /> : <Maximize className="h-4 w-4" />,
onSelect: onFullscreen,
},
{
id: 'help',
label: '快捷键帮助',
icon: <CircleHelp className="h-4 w-4" />,
onSelect: onHelp,
},
{
id: 'theme',
label: '切换主题',
icon: <SunMoon className="h-4 w-4" />,
onSelect: onTheme,
},
]}
/>
);
}
@@ -1,3 +1,46 @@
import {Search,X} from 'lucide-react';
import {IconButton} from '../../components/ui';
export function TreeSearchField({value,onChange,resultCount,placeholder='搜索…',label='搜索树'}:{value:string;onChange:(value:string)=>void;resultCount?:number;placeholder?:string;label?:string}){return <div className="m-2"><div className="relative"><Search aria-hidden="true" className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-tertiary"/><input type="search" aria-label={label} value={value} onChange={event=>onChange(event.target.value)} placeholder={placeholder} className="h-8 w-full rounded-md border border-border bg-input pl-7 pr-8 text-xs text-text-primary placeholder:text-text-tertiary focus-visible:ring-2 focus-visible:ring-accent/35"/>{value&&<span className="absolute right-0.5 top-0.5"><IconButton aria-label="清除搜索" tooltip="清除搜索" onClick={()=>onChange('')}><X className="h-3.5 w-3.5"/></IconButton></span>}</div>{value&&resultCount!==undefined&&<p role="status" className="px-1 pt-1.5 text-[10px] text-text-tertiary">找到 {resultCount} 个匹配项</p>}</div>;}
import { Search, X } from 'lucide-react';
import { IconButton } from '../../components/ui';
export function TreeSearchField({
value,
onChange,
resultCount,
placeholder = '搜索…',
label = '搜索树',
}: {
value: string;
onChange: (value: string) => void;
resultCount?: number;
placeholder?: string;
label?: string;
}) {
return (
<div className="m-2">
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-tertiary"
/>
<input
type="search"
aria-label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="h-8 w-full rounded-md border border-border bg-input pl-7 pr-8 text-xs text-text-primary placeholder:text-text-tertiary focus-visible:ring-2 focus-visible:ring-accent/35"
/>
{value && (
<span className="absolute right-0.5 top-0.5">
<IconButton aria-label="清除搜索" tooltip="清除搜索" onClick={() => onChange('')}>
<X className="h-3.5 w-3.5" />
</IconButton>
</span>
)}
</div>
{value && resultCount !== undefined && (
<p role="status" className="px-1 pt-1.5 text-[10px] text-text-tertiary">
找到 {resultCount} 个匹配项
</p>
)}
</div>
);
}
@@ -1,31 +1,52 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {UrdfImportOptionsDialog} from './UrdfImportOptionsDialog';
import { fireEvent, render, screen } from '@testing-library/react';
import { UrdfImportOptionsDialog } from './UrdfImportOptionsDialog';
describe('UrdfImportOptionsDialog',()=>{
it('默认选择关节驱动器和摄像头传感器',()=>{
const onConfirm=vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" mountBodies={['base','head_link']} onConfirm={onConfirm} onSkip={()=>{}}/>);
expect(screen.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked();
expect(screen.getByRole('checkbox',{name:/添加传感器/})).toBeChecked();
describe('UrdfImportOptionsDialog', () => {
it('默认选择关节驱动器和摄像头传感器', () => {
const onConfirm = vi.fn();
render(
<UrdfImportOptionsDialog
open
path="robot.urdf"
mountBodies={['base', 'head_link']}
onConfirm={onConfirm}
onSkip={() => {}}
/>,
);
expect(screen.getByRole('checkbox', { name: /为关节添加驱动器/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked();
expect(screen.getByLabelText('摄像头固连 Body')).toHaveValue('head_link');
fireEvent.change(screen.getByLabelText('摄像头位置 X'),{target:{value:'0.2'}});
fireEvent.click(screen.getByRole('button',{name:'转换并加载'}));
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:'head_link',cameraPosition:[.2,0,.05],cameraDirection:'+X'}));
fireEvent.change(screen.getByLabelText('摄像头位置 X'), { target: { value: '0.2' } });
fireEvent.click(screen.getByRole('button', { name: '转换并加载' }));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
addActuators: true,
addSensors: true,
sensorType: 'camera',
cameraMountBody: 'head_link',
cameraPosition: [0.2, 0, 0.05],
cameraDirection: '+X',
}),
);
});
it('允许分别关闭自动生成项',()=>{
const onConfirm=vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={onConfirm} onSkip={()=>{}}/>);
fireEvent.click(screen.getByRole('checkbox',{name:/为关节添加驱动器/}));
fireEvent.click(screen.getByRole('checkbox',{name:/添加传感器/}));
fireEvent.click(screen.getByRole('button',{name:'转换并加载'}));
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:false,addSensors:false,sensorType:'camera'}));
it('允许分别关闭自动生成项', () => {
const onConfirm = vi.fn();
render(
<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={onConfirm} onSkip={() => {}} />,
);
fireEvent.click(screen.getByRole('checkbox', { name: /为关节添加驱动器/ }));
fireEvent.click(screen.getByRole('checkbox', { name: /添加传感器/ }));
fireEvent.click(screen.getByRole('button', { name: '转换并加载' }));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ addActuators: false, addSensors: false, sensorType: 'camera' }),
);
});
it('可以不添加组件并继续加载',()=>{
const onSkip=vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={()=>{}} onSkip={onSkip}/>);
fireEvent.click(screen.getByRole('button',{name:'不添加,直接加载'}));
it('可以不添加组件并继续加载', () => {
const onSkip = vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={() => {}} onSkip={onSkip} />);
fireEvent.click(screen.getByRole('button', { name: '不添加,直接加载' }));
expect(onSkip).toHaveBeenCalledOnce();
});
});
@@ -1,26 +1,178 @@
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 { 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';
function OptionCard({checked,onChange,icon,title,description,children}:{checked:boolean;onChange:(checked:boolean)=>void;icon:ReactNode;title:string;description:string;children?:ReactNode}){
return <label className={`flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors ${checked?'border-accent/60 bg-accent/10':'border-border bg-surface hover:bg-element-hover'}`}>
<input className="mt-0.5 h-4 w-4 accent-accent" type="checkbox" checked={checked} onChange={event=>onChange(event.target.checked)}/>
<span className="mt-0.5 text-text-secondary" aria-hidden="true">{icon}</span>
<span className="min-w-0 flex-1"><span className="block text-sm font-medium text-text-primary">{title}</span><span className="mt-1 block text-xs leading-5 text-text-secondary">{description}</span>{children}</span>
</label>;
function OptionCard({
checked,
onChange,
icon,
title,
description,
children,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
icon: ReactNode;
title: string;
description: string;
children?: ReactNode;
}) {
return (
<label
className={`flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors ${checked ? 'border-accent/60 bg-accent/10' : 'border-border bg-surface hover:bg-element-hover'}`}
>
<input
className="mt-0.5 h-4 w-4 accent-accent"
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
<span className="mt-0.5 text-text-secondary" aria-hidden="true">
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-text-primary">{title}</span>
<span className="mt-1 block text-xs leading-5 text-text-secondary">{description}</span>
{children}
</span>
</label>
);
}
export function UrdfImportOptionsDialog({open,path,mountBodies=[],onConfirm,onSkip}:{open:boolean;path?:string;mountBodies?:string[];onConfirm:(options:UrdfEnhancementOptions)=>void;onSkip:()=>void}){
const [options,setOptions]=useState<UrdfEnhancementOptions>(()=>({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:mountBodies.find(name=>/(head|camera|sensor|neck|头)/i.test(name))??mountBodies.at(-1),cameraPosition:[.1,0,.05],cameraDirection:'+X'}));
const setPosition=(axis:number,value:number)=>setOptions(current=>{const position:[number,number,number]=[...(current.cameraPosition??[.1,0,.05])];position[axis]=Number.isFinite(value)?value:0;return {...current,cameraPosition:position};});
return <Dialog open={open} onClose={onSkip} closable={false} title="配置 URDF 仿真组件" className="max-w-xl" footer={<div className="flex justify-end gap-2"><Button onClick={onSkip}>不添加,直接加载</Button><Button variant="primary" onClick={()=>onConfirm(options)}>转换并加载</Button></div>}>
<p className="text-sm text-text-secondary">导入 <strong className="text-text-primary">{path}</strong> 后,是否自动补充以下仿真组件?稍后重新选择该 URDF 时仍会再次询问。</p>
<div className="mt-4 space-y-3">
<OptionCard checked={options.addActuators} 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 关节的刚度和阻尼,已有驱动器不会重复添加。"/>
<OptionCard checked={options.addSensors} onChange={addSensors=>setOptions(value=>({...value,addSensors}))} icon={<Camera className="h-4 w-4"/>} title="添加传感器" description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"/>
{options.addSensors&&<div className="rounded-lg border border-border bg-surface p-3"><div className="mb-2 text-xs font-medium text-text-primary">摄像头安装参数</div><label className="block text-[11px] text-text-secondary"><span className="mb-1 block">固连 Body</span><Select aria-label="摄像头固连 Body" className="w-full" value={options.cameraMountBody??''} onChange={event=>setOptions(value=>({...value,cameraMountBody:event.target.value||undefined}))}>{mountBodies.length?mountBodies.map(name=><option key={name} value={name}>{name}</option>):<option value="">自动选择头部/末端 Body</option>}</Select></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><input aria-label={`摄像头位置 ${axis}`} className="field h-8 w-full px-2 text-xs" type="number" step="0.01" value={(options.cameraPosition??[.1,0,.05])[index]} onChange={event=>setPosition(index,Number(event.target.value))}/></label>)}</div><label className="mt-3 block text-[11px] text-text-secondary"><span className="mb-1 block">镜头朝向(Body 局部轴)</span><Select aria-label="摄像头朝向" className="w-full" value={options.cameraDirection??'+X'} onChange={event=>setOptions(value=>({...value,cameraDirection:event.target.value as CameraDirection}))}>{(['+X','-X','+Y','-Y','+Z','-Z'] as CameraDirection[]).map(direction=><option key={direction}>{direction}</option>)}</Select></label><p className="mt-2 text-[10px] leading-4 text-text-tertiary">位置和朝向均相对于所选 Body;常见 ROS 头部摄像头使用 +X 朝前、+Z 朝上。</p></div>}
</div>
<p className="mt-4 text-xs text-text-tertiary">自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生 URDF”加载模式时不会注入这些组件。</p>
</Dialog>;
export function UrdfImportOptionsDialog({
open,
path,
mountBodies = [],
onConfirm,
onSkip,
}: {
open: boolean;
path?: string;
mountBodies?: string[];
onConfirm: (options: UrdfEnhancementOptions) => void;
onSkip: () => void;
}) {
const [options, setOptions] = useState<UrdfEnhancementOptions>(() => ({
addActuators: true,
addSensors: true,
sensorType: 'camera',
cameraMountBody:
mountBodies.find((name) => /(head|camera|sensor|neck|头)/i.test(name)) ?? mountBodies.at(-1),
cameraPosition: [0.1, 0, 0.05],
cameraDirection: '+X',
}));
const setPosition = (axis: number, value: number) =>
setOptions((current) => {
const position: [number, number, number] = [...(current.cameraPosition ?? [0.1, 0, 0.05])];
position[axis] = Number.isFinite(value) ? value : 0;
return { ...current, cameraPosition: position };
});
return (
<Dialog
open={open}
onClose={onSkip}
closable={false}
title="配置 URDF 仿真组件"
className="max-w-xl"
footer={
<div className="flex justify-end gap-2">
<Button onClick={onSkip}>不添加,直接加载</Button>
<Button variant="primary" onClick={() => onConfirm(options)}>
转换并加载
</Button>
</div>
}
>
<p className="text-sm text-text-secondary">
导入 <strong className="text-text-primary">{path}</strong>{' '}
后,是否自动补充以下仿真组件?稍后重新选择该 URDF 时仍会再次询问。
</p>
<div className="mt-4 space-y-3">
<OptionCard
checked={options.addActuators}
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 关节的刚度和阻尼,已有驱动器不会重复添加。"
/>
<OptionCard
checked={options.addSensors}
onChange={(addSensors) => setOptions((value) => ({ ...value, addSensors }))}
icon={<Camera className="h-4 w-4" />}
title="添加传感器"
description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"
/>
{options.addSensors && (
<div className="rounded-lg border border-border bg-surface p-3">
<div className="mb-2 text-xs font-medium text-text-primary">摄像头安装参数</div>
<label className="block text-[11px] text-text-secondary">
<span className="mb-1 block">固连 Body</span>
<Select
aria-label="摄像头固连 Body"
className="w-full"
value={options.cameraMountBody ?? ''}
onChange={(event) =>
setOptions((value) => ({
...value,
cameraMountBody: event.target.value || undefined,
}))
}
>
{mountBodies.length ? (
mountBodies.map((name) => (
<option key={name} value={name}>
{name}
</option>
))
) : (
<option value="">自动选择头部/末端 Body</option>
)}
</Select>
</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>
<input
aria-label={`摄像头位置 ${axis}`}
className="field h-8 w-full px-2 text-xs"
type="number"
step="0.01"
value={(options.cameraPosition ?? [0.1, 0, 0.05])[index]}
onChange={(event) => setPosition(index, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="mt-3 block text-[11px] text-text-secondary">
<span className="mb-1 block">镜头朝向(Body 局部轴)</span>
<Select
aria-label="摄像头朝向"
className="w-full"
value={options.cameraDirection ?? '+X'}
onChange={(event) =>
setOptions((value) => ({
...value,
cameraDirection: event.target.value as CameraDirection,
}))
}
>
{(['+X', '-X', '+Y', '-Y', '+Z', '-Z'] as CameraDirection[]).map((direction) => (
<option key={direction}>{direction}</option>
))}
</Select>
</label>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
位置和朝向均相对于所选 Body;常见 ROS 头部摄像头使用 +X 朝前、+Z 朝上。
</p>
</div>
)}
</div>
<p className="mt-4 text-xs text-text-tertiary">
自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生
URDF”加载模式时不会注入这些组件。
</p>
</Dialog>
);
}
@@ -1,30 +1,166 @@
import {Check,Eye,RotateCcw} from 'lucide-react';
import {IconButton,Popover} from '../../components/ui';
import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from '../../viewer/displayOptions';
import { Check, Eye, RotateCcw } from 'lucide-react';
import { IconButton, Popover } from '../../components/ui';
import {
DEFAULT_VIEWER_DISPLAY_OPTIONS,
type ViewerDisplayOptions,
} from '../../viewer/displayOptions';
type DisplayKey=keyof ViewerDisplayOptions;
interface DisplayItem {key:DisplayKey;label:string;description:string;color:string;}
const geometryItems:DisplayItem[]=[
{key:'showVisual',label:'视觉模型',description:'显示模型的外观几何与材质',color:'bg-slate-400'},
{key:'showCollision',label:'碰撞体',description:'以青色半透明方式叠加碰撞几何',color:'bg-cyan-400'},
type DisplayKey = keyof ViewerDisplayOptions;
interface DisplayItem {
key: DisplayKey;
label: string;
description: string;
color: string;
}
const geometryItems: DisplayItem[] = [
{
key: 'showVisual',
label: '视觉模型',
description: '显示模型的外观几何与材质',
color: 'bg-slate-400',
},
{
key: 'showCollision',
label: '碰撞体',
description: '以青色半透明方式叠加碰撞几何',
color: 'bg-cyan-400',
},
];
const helperItems:DisplayItem[]=[
{key:'showFrames',label:'坐标系',description:'显示每个刚体的 RGB 坐标轴',color:'bg-red-400'},
{key:'showJointAxes',label:'关节轴',description:'显示转动与滑动关节的正轴方向',color:'bg-red-500'},
{key:'showCenterOfMass',label:'质心',description:'显示各刚体的质量中心',color:'bg-yellow-400'},
{key:'showInertia',label:'惯量',description:'显示由主惯量计算的等效惯量盒',color:'bg-cyan-300'},
const helperItems: DisplayItem[] = [
{
key: 'showFrames',
label: '坐标系',
description: '显示每个刚体的 RGB 坐标轴',
color: 'bg-red-400',
},
{
key: 'showJointAxes',
label: '关节轴',
description: '显示转动与滑动关节的正轴方向',
color: 'bg-red-500',
},
{
key: 'showCenterOfMass',
label: '质心',
description: '显示各刚体的质量中心',
color: 'bg-yellow-400',
},
{
key: 'showInertia',
label: '惯量',
description: '显示由主惯量计算的等效惯量盒',
color: 'bg-cyan-300',
},
];
const sceneItems:DisplayItem[]=[
{key:'showGrid',label:'地面网格',description:'显示世界坐标系的参考网格',color:'bg-blue-400'},
const sceneItems: DisplayItem[] = [
{
key: 'showGrid',
label: '地面网格',
description: '显示世界坐标系的参考网格',
color: 'bg-blue-400',
},
];
function DisplayRows({items,value,onChange}:{items:DisplayItem[];value:ViewerDisplayOptions;onChange:(next:ViewerDisplayOptions)=>void}){
return <div className="space-y-0.5">{items.map(item=>{const checked=value[item.key];return <button key={item.key} type="button" role="switch" aria-checked={checked} onClick={()=>onChange({...value,[item.key]:!checked})} className="group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"><span className={`h-2.5 w-2.5 shrink-0 rounded-sm ${item.color}`}/><span className="min-w-0 flex-1"><span className="block text-xs font-medium text-text-primary">{item.label}</span><span className="block truncate text-[10px] text-text-tertiary">{item.description}</span></span><span aria-hidden="true" className={`grid h-4 w-4 shrink-0 place-items-center rounded border ${checked?'border-accent bg-accent text-white':'border-border-strong bg-input'}`}>{checked&&<Check className="h-3 w-3"/>}</span></button>;})}</div>;
function DisplayRows({
items,
value,
onChange,
}: {
items: DisplayItem[];
value: ViewerDisplayOptions;
onChange: (next: ViewerDisplayOptions) => void;
}) {
return (
<div className="space-y-0.5">
{items.map((item) => {
const checked = value[item.key];
return (
<button
key={item.key}
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange({ ...value, [item.key]: !checked })}
className="group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-sm ${item.color}`} />
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium text-text-primary">{item.label}</span>
<span className="block truncate text-[10px] text-text-tertiary">
{item.description}
</span>
</span>
<span
aria-hidden="true"
className={`grid h-4 w-4 shrink-0 place-items-center rounded border ${checked ? 'border-accent bg-accent text-white' : 'border-border-strong bg-input'}`}
>
{checked && <Check className="h-3 w-3" />}
</span>
</button>
);
})}
</div>
);
}
export function ViewerDisplayPopover({value,onChange}:{value:ViewerDisplayOptions;onChange:(next:ViewerDisplayOptions)=>void}){
const customized=Object.keys(DEFAULT_VIEWER_DISPLAY_OPTIONS).some(key=>value[key as DisplayKey]!==DEFAULT_VIEWER_DISPLAY_OPTIONS[key as DisplayKey]);
return <Popover placement="bottom-right" label="视图显示设置" trigger={({open,toggle})=><IconButton active={open||customized} tooltip="显示设置" aria-label="显示设置" aria-expanded={open} onClick={toggle}><Eye className="h-3.5 w-3.5"/></IconButton>}>
{()=> <div className="w-72 rounded-xl border border-border bg-surface-elevated p-2 shadow-2xl"><div className="mb-1 flex items-center justify-between px-2 py-1"><div><h2 className="text-xs font-semibold text-text-primary">视图显示</h2><p className="text-[10px] text-text-tertiary">控制模型和结构辅助标记</p></div><IconButton tooltip="恢复默认显示" aria-label="恢复默认显示" disabled={!customized} onClick={()=>onChange({...DEFAULT_VIEWER_DISPLAY_OPTIONS})}><RotateCcw className="h-3.5 w-3.5"/></IconButton></div><div className="border-t border-border pt-1"><p className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">几何</p><DisplayRows items={geometryItems} value={value} onChange={onChange}/><p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">辅助标记</p><DisplayRows items={helperItems} value={value} onChange={onChange}/><p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">场景</p><DisplayRows items={sceneItems} value={value} onChange={onChange}/></div></div>}
</Popover>;
export function ViewerDisplayPopover({
value,
onChange,
}: {
value: ViewerDisplayOptions;
onChange: (next: ViewerDisplayOptions) => void;
}) {
const customized = Object.keys(DEFAULT_VIEWER_DISPLAY_OPTIONS).some(
(key) => value[key as DisplayKey] !== DEFAULT_VIEWER_DISPLAY_OPTIONS[key as DisplayKey],
);
return (
<Popover
placement="bottom-right"
label="视图显示设置"
trigger={({ open, toggle }) => (
<IconButton
active={open || customized}
tooltip="显示设置"
aria-label="显示设置"
aria-expanded={open}
onClick={toggle}
>
<Eye className="h-3.5 w-3.5" />
</IconButton>
)}
>
{() => (
<div className="w-72 rounded-xl border border-border bg-surface-elevated p-2 shadow-2xl">
<div className="mb-1 flex items-center justify-between px-2 py-1">
<div>
<h2 className="text-xs font-semibold text-text-primary">视图显示</h2>
<p className="text-[10px] text-text-tertiary">控制模型和结构辅助标记</p>
</div>
<IconButton
tooltip="恢复默认显示"
aria-label="恢复默认显示"
disabled={!customized}
onClick={() => onChange({ ...DEFAULT_VIEWER_DISPLAY_OPTIONS })}
>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
</div>
<div className="border-t border-border pt-1">
<p className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">
几何
</p>
<DisplayRows items={geometryItems} value={value} onChange={onChange} />
<p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">
辅助标记
</p>
<DisplayRows items={helperItems} value={value} onChange={onChange} />
<p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">
场景
</p>
<DisplayRows items={sceneItems} value={value} onChange={onChange} />
</div>
</div>
)}
</Popover>
);
}
@@ -1,7 +1,33 @@
import {Crosshair,Hand,MousePointer2,RotateCcw} 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';
const tools:ToolbarItem<InteractionMode>[]=[{value:'select',label:'选择',icon:MousePointer2},{value:'joint',label:'关节拖动',icon:Hand},{value:'force',label:'外力施加',icon:Crosshair}];
export function ViewerToolDock({mode,display,onModeChange,onDisplayChange,onResetCamera}:{mode:InteractionMode;display:ViewerDisplayOptions;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="视口交互模式"/><ViewerDisplayPopover value={display} onChange={onDisplayChange}/><IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}><RotateCcw className="h-3.5 w-3.5"/></IconButton></div>;}
import { Crosshair, Hand, MousePointer2, RotateCcw } 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';
const tools: ToolbarItem<InteractionMode>[] = [
{ value: 'select', label: '选择', icon: MousePointer2 },
{ value: 'joint', label: '关节拖动', icon: Hand },
{ value: 'force', label: '外力施加', icon: Crosshair },
];
export function ViewerToolDock({
mode,
display,
onModeChange,
onDisplayChange,
onResetCamera,
}: {
mode: InteractionMode;
display: ViewerDisplayOptions;
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="视口交互模式" />
<ViewerDisplayPopover value={display} onChange={onDisplayChange} />
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
</div>
);
}
@@ -1,6 +1,72 @@
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}:{paused:boolean;mode:InteractionMode;selection:ViewerSelection|null;ready:boolean}){if(!ready)return null;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-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl 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>{mode!=='select'&&<><span aria-hidden="true" className="text-border-strong">·</span><Kbd>1</Kbd><span>旋转视角</span></>}</div></>;}
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,
}: {
paused: boolean;
mode: InteractionMode;
selection: ViewerSelection | null;
ready: boolean;
}) {
if (!ready) return null;
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-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl 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>
{mode !== 'select' && (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>1</Kbd>
<span>旋转视角</span>
</>
)}
</div>
</>
);
}
@@ -1,4 +1,51 @@
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();render(<WorkbenchHeader paused ready speed={1} theme="dark" loading={false} leftOpen rightOpen fullscreen={false} center={<span>工具</span>} onFiles={fn} onFolder={fn} onTogglePause={pause} onStep={step} onReset={reset} onSpeed={speed} onToggleLeft={fn} onToggleRight={fn} onToggleTheme={fn} onHelp={fn} onCommands={fn} onToggleFullscreen={fn}/>);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).toHaveBeenCalledTimes(1);expect(step).toHaveBeenCalledTimes(1);expect(reset).toHaveBeenCalledTimes(1);expect(speed).toHaveBeenCalledWith(2);expect(screen.getByRole('button',{name:'切换到白天主题'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'隐藏工程面板'})).toHaveAttribute('aria-expanded','true');expect(screen.getByRole('button',{name:'打开命令面板'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'进入全屏'})).toBeInTheDocument();});});
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();
render(
<WorkbenchHeader
paused
ready
speed={1}
theme="dark"
loading={false}
leftOpen
rightOpen
fullscreen={false}
center={<span>工具</span>}
onFiles={fn}
onFolder={fn}
onTogglePause={pause}
onStep={step}
onReset={reset}
onSpeed={speed}
onToggleLeft={fn}
onToggleRight={fn}
onToggleTheme={fn}
onHelp={fn}
onCommands={fn}
onToggleFullscreen={fn}
/>,
);
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).toHaveBeenCalledTimes(1);
expect(step).toHaveBeenCalledTimes(1);
expect(reset).toHaveBeenCalledTimes(1);
expect(speed).toHaveBeenCalledWith(2);
expect(screen.getByRole('button', { name: '切换到白天主题' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '隐藏工程面板' })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(screen.getByRole('button', { name: '打开命令面板' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '进入全屏' })).toBeInTheDocument();
});
});
@@ -1,6 +1,209 @@
import type {ChangeEvent,ReactNode} from 'react';
import {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';
const fileActionClass='inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-2 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass='hidden sm:inline';
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="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 bg-panel px-2.5"><div className="flex min-w-0 items-center gap-1"><h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">MuJoCo Web 仿真平台</h1><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><Upload className="h-3.5 w-3.5"/><span className={fileActionLabelClass}>打开文件</span><input id="mujoco-project-files" aria-label="打开文件" className="sr-only" type="file" disabled={loading} multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={onFiles}/></label><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><FolderOpen className="h-3.5 w-3.5"/><span className={fileActionLabelClass}>文件夹</span><input id="mujoco-project-folder" aria-label="打开文件夹" className="sr-only" type="file" disabled={loading} multiple {...({webkitdirectory:'',directory:''} as object)} onChange={onFolder}/></label><IconButton tooltip="查看和修改缓存源代码" aria-label="源代码" disabled={!hasProject||loading} onClick={onOpenSource}><Code2 className="h-4 w-4"/></IconButton></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={.25}>0.25×</option><option value={.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?'隐藏工程面板':'显示工程面板'} aria-expanded={leftOpen} onClick={onToggleLeft}><PanelLeft className="h-4 w-4"/></IconButton><IconButton tooltip={rightOpen?'隐藏属性面板':'显示属性面板'} aria-label={rightOpen?'隐藏属性面板':'显示属性面板'} aria-expanded={rightOpen} onClick={onToggleRight}><PanelRight className="h-4 w-4"/></IconButton>{endActions}{compactMenu}<IconButton className="hidden xl:inline-flex" 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>;}
import type { ChangeEvent, ReactNode } from 'react';
import {
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';
const fileActionClass =
'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-2 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass = 'hidden sm:inline';
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="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 bg-panel px-2.5">
<div className="flex min-w-0 items-center gap-1">
<h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">
MuJoCo Web 仿真平台
</h1>
<label
aria-disabled={loading}
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
>
<Upload className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}>打开文件</span>
<input
id="mujoco-project-files"
aria-label="打开文件"
className="sr-only"
type="file"
disabled={loading}
multiple
accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr"
onChange={onFiles}
/>
</label>
<label
aria-disabled={loading}
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
>
<FolderOpen className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}>文件夹</span>
<input
id="mujoco-project-folder"
aria-label="打开文件夹"
className="sr-only"
type="file"
disabled={loading}
multiple
{...({ webkitdirectory: '', directory: '' } as object)}
onChange={onFolder}
/>
</label>
<IconButton
tooltip="查看和修改缓存源代码"
aria-label="源代码"
disabled={!hasProject || loading}
onClick={onOpenSource}
>
<Code2 className="h-4 w-4" />
</IconButton>
</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 ? '隐藏工程面板' : '显示工程面板'}
aria-expanded={leftOpen}
onClick={onToggleLeft}
>
<PanelLeft className="h-4 w-4" />
</IconButton>
<IconButton
tooltip={rightOpen ? '隐藏属性面板' : '显示属性面板'}
aria-label={rightOpen ? '隐藏属性面板' : '显示属性面板'}
aria-expanded={rightOpen}
onClick={onToggleRight}
>
<PanelRight className="h-4 w-4" />
</IconButton>
{endActions}
{compactMenu}
<IconButton
className="hidden xl:inline-flex"
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>
);
}
@@ -1,6 +1,129 @@
import {Box,FolderOpen,LoaderCircle,PlayCircle,Settings2,ShieldCheck,Upload,UploadCloud} from 'lucide-react';
import {ProgressBar,Skeleton} from '../../components/ui';
export interface ImportProgress{label:string;value:number;}
const workflow=[{label:'导入',detail:'URDF、MJCF 或工程包',icon:UploadCloud},{label:'检查与配置',detail:'结构、驱动器与传感器',icon:Settings2},{label:'运行与调试',detail:'控制、策略与物理状态',icon:PlayCircle}];
export function EmptyWorkspace({compact=false}:{compact?:boolean}){if(compact)return <div className="m-3 rounded-xl border border-dashed border-border-strong bg-panel/90 p-4 text-center shadow-sm"><span className="mx-auto mb-3 grid h-10 w-10 place-items-center rounded-xl bg-accent-soft text-accent"><Box className="h-5 w-5"/></span><p className="text-sm font-semibold text-text-primary">拖放模型工程到此处</p><p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p></div>;return <section aria-label="导入模型工程" className="w-[min(560px,calc(100vw-32px))] rounded-2xl border border-border-strong bg-panel/90 p-6 text-center shadow-2xl backdrop-blur-md"><span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent"><UploadCloud className="h-5 w-5"/></span><h2 className="text-base font-semibold text-text-primary">拖放模型工程到此处</h2><p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p><div className="mt-4 flex items-center justify-center gap-2"><label htmlFor="mujoco-project-files" className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 text-xs font-medium text-white transition-colors hover:bg-accent-hover 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-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-xs font-medium text-text-primary transition-colors 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-5 hidden grid-cols-3 gap-2 border-t border-border pt-4 sm:grid">{workflow.map((item,index)=><li key={item.label} className="rounded-lg bg-surface px-3 py-2.5 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-medium text-text-primary">{item.label}</span></div><span className="mt-1 block text-[10px] leading-4 text-text-tertiary">{item.detail}</span></li>)}</ol><p className="mt-3 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary"><ShieldCheck aria-hidden="true" className="h-3 w-3 text-success"/>模型与资源仅在当前浏览器会话中处理</p></section>;}
export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&<div className="pointer-events-none absolute inset-0 grid place-items-center p-4"><div className="pointer-events-auto"><EmptyWorkspace/></div></div>}{loading&&<div role="status" aria-live="polite" aria-label={progress?.label??'正在加载 MuJoCo 与模型'} className="absolute inset-0 z-20 grid place-items-center bg-app/75 backdrop-blur-sm"><div className="w-80 rounded-xl border border-border bg-panel px-5 py-4 text-sm font-medium text-text-primary shadow-xl"><div className="flex items-center gap-3"><LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent"/>正在加载 MuJoCo 与模型…</div>{progress?<div className="mt-4"><ProgressBar value={progress.value} label={progress.label}/></div>:<div className="mt-4 space-y-2"><Skeleton className="h-2.5 w-full"/><Skeleton className="h-2.5 w-4/5"/></div>}</div></div>}</>;}
import {
Box,
FolderOpen,
LoaderCircle,
PlayCircle,
Settings2,
ShieldCheck,
Upload,
UploadCloud,
} from 'lucide-react';
import { ProgressBar, Skeleton } from '../../components/ui';
export interface ImportProgress {
label: string;
value: number;
}
const workflow = [
{ label: '导入', detail: 'URDF、MJCF 或工程包', icon: UploadCloud },
{ label: '检查与配置', detail: '结构、驱动器与传感器', icon: Settings2 },
{ label: '运行与调试', detail: '控制、策略与物理状态', icon: PlayCircle },
];
export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
if (compact)
return (
<div className="m-3 rounded-xl border border-dashed border-border-strong bg-panel/90 p-4 text-center shadow-sm">
<span className="mx-auto mb-3 grid h-10 w-10 place-items-center rounded-xl bg-accent-soft text-accent">
<Box className="h-5 w-5" />
</span>
<p className="text-sm font-semibold text-text-primary">拖放模型工程到此处</p>
<p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
</div>
);
return (
<section
aria-label="导入模型工程"
className="w-[min(560px,calc(100vw-32px))] rounded-2xl border border-border-strong bg-panel/90 p-6 text-center shadow-2xl backdrop-blur-md"
>
<span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent">
<UploadCloud className="h-5 w-5" />
</span>
<h2 className="text-base font-semibold text-text-primary">拖放模型工程到此处</h2>
<p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
<div className="mt-4 flex items-center justify-center gap-2">
<label
htmlFor="mujoco-project-files"
className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 text-xs font-medium text-white transition-colors hover:bg-accent-hover 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-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-xs font-medium text-text-primary transition-colors 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-5 hidden grid-cols-3 gap-2 border-t border-border pt-4 sm:grid"
>
{workflow.map((item, index) => (
<li key={item.label} className="rounded-lg bg-surface px-3 py-2.5 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-medium text-text-primary">{item.label}</span>
</div>
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
{item.detail}
</span>
</li>
))}
</ol>
<p className="mt-3 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
<ShieldCheck aria-hidden="true" className="h-3 w-3 text-success" />
模型与资源仅在当前浏览器会话中处理
</p>
</section>
);
}
export function WorkspaceOverlays({
loading,
hasSnapshot,
progress,
}: {
loading: boolean;
hasSnapshot: boolean;
progress?: ImportProgress;
}) {
return (
<>
{!hasSnapshot && !loading && (
<div className="pointer-events-none absolute inset-0 grid place-items-center p-4">
<div className="pointer-events-auto">
<EmptyWorkspace />
</div>
</div>
)}
{loading && (
<div
role="status"
aria-live="polite"
aria-label={progress?.label ?? '正在加载 MuJoCo 与模型'}
className="absolute inset-0 z-20 grid place-items-center bg-app/75 backdrop-blur-sm"
>
<div className="w-80 rounded-xl border border-border bg-panel px-5 py-4 text-sm font-medium text-text-primary shadow-xl">
<div className="flex items-center gap-3">
<LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent" />
正在加载 MuJoCo 与模型…
</div>
{progress ? (
<div className="mt-4">
<ProgressBar value={progress.value} label={progress.label} />
</div>
) : (
<div className="mt-4 space-y-2">
<Skeleton className="h-2.5 w-full" />
<Skeleton className="h-2.5 w-4/5" />
</div>
)}
</div>
</div>
)}
</>
);
}
@@ -1,8 +1,8 @@
import {loader} from '@monaco-editor/react';
import { loader } from '@monaco-editor/react';
import * as monaco from 'monaco-editor/editor/editor.api';
import 'monaco-editor/languages/definitions/xml/register';
import EditorWorker from 'monaco-editor/editor/editor.worker?worker';
type MonacoGlobal=typeof globalThis&{MonacoEnvironment?:{getWorker?:()=>Worker}};
(globalThis as MonacoGlobal).MonacoEnvironment={getWorker:()=>new EditorWorker()};
loader.config({monaco});
type MonacoGlobal = typeof globalThis & { MonacoEnvironment?: { getWorker?: () => Worker } };
(globalThis as MonacoGlobal).MonacoEnvironment = { getWorker: () => new EditorWorker() };
loader.config({ monaco });