feat(web-platform): release V0.9.4 前端设计优化
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
This commit is contained in:
@@ -4,6 +4,14 @@
|
||||
|
||||
## [未发布]
|
||||
|
||||
## [0.9.4] - 2026-09-09
|
||||
|
||||
- 前端设计优化:升级蓝黑、电光青与少量紫色的 Cyber HUD 视觉,保留亮色主题;统一工作台、欢迎界面、共享控件、浮层和源码/调参窗口材质。
|
||||
- 恢复自调参平台左侧会话与 Trial、中间指标与排行、右侧决策与审批的三栏布局;窄屏纵向排列,保留审批与 Monaco Diff 操作。
|
||||
- 增加运行状态呼吸与交互微光,支持减少动态效果、强制色彩和无模糊能力回退;侧栏采用静态材质,避免大面积实时模糊影响仿真性能。
|
||||
- 优化欢迎页与源代码入口、执行器控件密度和重复参数提示,将训练地图操作文案统一为“同步场景地图”。
|
||||
- 补充双主题材质、状态动效、回退及三栏布局回归测试和设计交付说明;性能数据仅为无头 Chromium 短时特效开关对照,不代表真实 GPU 或重型模型基准。
|
||||
|
||||
## [0.9.3] - 2026-09-08
|
||||
|
||||
- 重构绿色主工作台:紧凑全局入口、可收起资源/上下文侧栏,统一状态、仿真/地图工具、草稿、摄像头与风险槽位;窄屏临时布局不覆盖桌面偏好。
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.4",
|
||||
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Cyber HUD 界面样式
|
||||
|
||||
本次直接升级现有 React 界面的 JSX/CSS,不增加静态演示入口、外部字体或依赖,保留亮色主题和所有仿真业务行为。
|
||||
|
||||
## 源码
|
||||
|
||||
- `src/styles.css`:蓝黑/电光青语义色、紫色装饰、玻璃材质、交互微光、状态呼吸和可访问性降级。
|
||||
- `src/components/ui/{Button,IconButton,Tabs,CollapsibleSection,Dialog,Popover}.tsx`:共享视觉挂点,保留原有键盘交互与 Portal。
|
||||
- `src/app/App.tsx` 及 `src/app/components/{WorkbenchHeader,SidebarPanel,ViewportHUD,WorkspaceOverlays,SourceEditorDialog}.tsx`:工作台外壳、欢迎卡片、HUD 和编辑窗口。
|
||||
- `src/tuning/{TuningApp,TuningConsole,TuningSessionRail,MetricsComparisonBoard}.tsx`:调参页、导航、指标卡片;Monaco 原生亮/暗代码主题不变。
|
||||
- `e2e/ui-cyber-hud.spec.ts`:新增 5 个材质、主题持久化、动效及回退测试。
|
||||
|
||||
## v0.9.4 调参布局
|
||||
|
||||
自调参会话页恢复三栏:左侧会话与 Trial、中间指标与排行、右侧决策与审批。宽度达到 1280px 时三栏同时显示并分别滚动,更窄窗口按上述顺序纵向排列。原有主题、审批与 Diff 编辑行为保持不变。
|
||||
|
||||
## 材质与性能约束
|
||||
|
||||
- 暗色基底 `#060914` / `#0B1224`,强调 `#36E4F2`,装饰 `#A78BFA`;亮色强调 `#087887`。
|
||||
- 毛玻璃仅用于顶栏、欢迎卡片、小型 HUD 和浮层;侧栏使用静态渐变质感,避免长列表滚动时大面积背景模糊。
|
||||
- 3.6 秒呼吸动效仅绑定真实仿真运行状态;装饰层不拦截指针。
|
||||
- 减少动态效果偏好禁用动画;无背景模糊支持时使用实色表面;强制色彩模式保留清晰边框。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- `npm run typecheck`、`npm run lint`、`npm run build` 通过。
|
||||
- `npm run test`:97 个测试文件、408 项测试通过。
|
||||
- 工作台布局、共享浮层、领域面板、调参系统及新增 HUD 测试:59 项端到端测试通过。
|
||||
- 双主题覆盖 1920×1080、1440×900、1366×768、1024×768、768×800;检查了源码/Diff、弹层、主题同步和视口避让。
|
||||
- 变更源码的 Prettier 检查与 `git diff --check` 通过。
|
||||
- 构建仍提示部分包超过 500kB;本次未改变加载架构。
|
||||
|
||||
性能取证:Headless Chromium、1440×900、24 刚体场景下按“关闭特效→开启→开启→关闭”采样,每段 2.5 秒并滚动侧栏。初版全侧栏模糊约 54 FPS,取消侧栏实时模糊后开/关特效均约 60 FPS,P95 帧间隔约 16.7ms。
|
||||
|
||||
**限制**:这是短时特效开关对照,不是完整重构前后的严格基准,也不代表真实桌面 GPU 或重型模型表现;未执行 Firefox/Safari 实机验证。无模糊回退测试通过修改 CSS 能力分支来模拟,不等同于旧浏览器实测。
|
||||
|
||||
## 本地证据
|
||||
|
||||
- `../test-results/cyber-hud-delivery/workbench-dark.png`
|
||||
- `../test-results/cyber-hud-delivery/workbench-light.png`
|
||||
- `../test-results/cyber-hud-delivery/tuning-dark.png`
|
||||
- `../test-results/cyber-hud-delivery/tuning-light.png`
|
||||
- `../test-results/cyber-hud-delivery/before-dark.png`
|
||||
- `../test-results/cyber-hud-delivery/performance-final.json`
|
||||
|
||||
截图与性能证据为本地生成产物,不纳入源码提交;再次运行 Playwright 可能清理 `test-results/`。详细命令日志保存在 `/tmp/cyber-hud-evidence/`。
|
||||
|
||||
预览:`npm run dev`,主入口 `/`,独立调参入口 `/tuning.html`。
|
||||
@@ -264,7 +264,6 @@ test('转换后的 MJCF 可编辑并重新载入', async ({ page }) => {
|
||||
.first()
|
||||
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: '工程', exact: true }).click();
|
||||
await page.getByRole('button', { name: '源代码' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
|
||||
await expect(dialog).toBeVisible();
|
||||
@@ -291,7 +290,6 @@ test('关闭已修改的 MJCF 前要求确认', async ({ page }) => {
|
||||
.first()
|
||||
.setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: '工程', exact: true }).click();
|
||||
await page.getByRole('button', { name: '源代码' }).click();
|
||||
const editorDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
|
||||
await editorDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
|
||||
@@ -354,7 +352,6 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
|
||||
await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
|
||||
await page.getByRole('button', { name: '工程', exact: true }).click();
|
||||
await page.getByRole('button', { name: '源代码' }).click();
|
||||
const sourceDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
|
||||
await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
|
||||
@@ -434,7 +431,6 @@ test('转换后的 MJCF 保存时保留 DAE 转换缓存资源', async ({ page }
|
||||
.getByRole('button', { name: '转换并加载' })
|
||||
.click();
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: '工程', exact: true }).click();
|
||||
await page.getByRole('button', { name: '源代码' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
|
||||
await dialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const MODEL = `<mujoco model="hud"><worldbody><light pos="0 0 3"/>
|
||||
<geom type="plane" size="3 3 .1"/><body pos="0 0 1"><freejoint/>
|
||||
<geom type="box" size=".2 .2 .2" mass="1"/></body></worldbody></mujoco>`;
|
||||
|
||||
for (const theme of ['dark', 'light']) {
|
||||
test(`Cyber HUD 材质与可读性 ${theme}`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.addInitScript((value) => {
|
||||
if (!localStorage.getItem('mujoco-platform-theme')) {
|
||||
localStorage.setItem('mujoco-platform-theme', value);
|
||||
}
|
||||
}, theme);
|
||||
await page.goto('/');
|
||||
const welcome = page.getByRole('region', { name: '导入模型工程' });
|
||||
await expect(welcome).toBeVisible();
|
||||
await expect(page.locator('.cyber-workspace')).toHaveClass(new RegExp(`theme-${theme}`));
|
||||
await expect(welcome).toHaveCSS('backdrop-filter', 'blur(12px) saturate(1.2)');
|
||||
const primary = page.getByRole('button', { name: '选择文件', exact: true });
|
||||
await primary.focus();
|
||||
await expect(primary).toBeFocused();
|
||||
await expect(primary).toHaveCSS('outline-style', 'solid');
|
||||
// 在真实浏览器中验证主按钮与正文的计算色对比度,而非仅比较 token。
|
||||
const contrast = await primary.evaluate((button) => {
|
||||
function luminance(color: string) {
|
||||
const rgb = color
|
||||
.match(/[\d.]+/g)!
|
||||
.slice(0, 3)
|
||||
.map(Number);
|
||||
const linear = rgb.map((value) => {
|
||||
const v = value / 255;
|
||||
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
|
||||
}
|
||||
const style = getComputedStyle(button);
|
||||
const a = luminance(style.color),
|
||||
b = luminance(style.backgroundColor);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
});
|
||||
expect(contrast).toBeGreaterThanOrEqual(4.5);
|
||||
const disabled = page.getByRole('button', { name: '源代码', exact: true });
|
||||
await expect(disabled).toBeDisabled();
|
||||
await expect(disabled).toHaveCSS('box-shadow', 'none');
|
||||
await page.screenshot({
|
||||
path: info.outputPath(`workbench-${theme}.png`),
|
||||
animations: 'disabled',
|
||||
});
|
||||
await page.goto('/tuning.html');
|
||||
await expect(page.locator('.cyber-workspace')).toHaveClass(new RegExp(`theme-${theme}`));
|
||||
await page
|
||||
.getByRole('button', { name: `切换到${theme === 'dark' ? '白天' : '黑夜'}主题` })
|
||||
.click();
|
||||
await page.reload();
|
||||
await expect(page.locator('.cyber-workspace')).toHaveClass(
|
||||
new RegExp(`theme-${theme === 'dark' ? 'light' : 'dark'}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('运行状态呼吸遵循减少动态效果,装饰不拦截交互', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('#mujoco-project-files').setInputFiles({
|
||||
name: 'hud.xml',
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(MODEL),
|
||||
});
|
||||
const play = page.getByRole('button', { name: '▶ 播放' });
|
||||
await expect(play).toBeEnabled({ timeout: 30_000 });
|
||||
await play.click();
|
||||
const signal = page.locator('.hud-signal');
|
||||
await expect(signal).toHaveAttribute('data-running', 'true');
|
||||
const animation = () => signal.evaluate((el) => getComputedStyle(el, '::after').animationName);
|
||||
expect(await animation()).toBe('hud-breathe');
|
||||
expect(await signal.evaluate((el) => getComputedStyle(el, '::after').pointerEvents)).toBe('none');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await expect.poll(animation).toBe('none');
|
||||
await page.getByRole('button', { name: '⏸ 暂停' }).click();
|
||||
await expect(signal).toHaveAttribute('data-running', 'false');
|
||||
await page.emulateMedia({ forcedColors: 'active' });
|
||||
await expect(page.locator('.engineering-glass').first()).toHaveCSS('backdrop-filter', 'none');
|
||||
});
|
||||
|
||||
for (const theme of ['dark', 'light']) {
|
||||
test(`无模糊能力时使用实色回退 ${theme}`, async ({ page }) => {
|
||||
// Chromium 支持模糊:使构建 CSS 的能力分支为 false,验证回退规则本身。
|
||||
let replaced = false;
|
||||
await page.route('**/*.css', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const css = await response.text();
|
||||
const fallback = css.replace(/backdrop-filter:\s*blur\(1px\)/g, 'cyber-unsupported: none');
|
||||
replaced ||= css !== fallback;
|
||||
await route.fulfill({ response, body: fallback });
|
||||
});
|
||||
await page.addInitScript(
|
||||
(value) => localStorage.setItem('mujoco-platform-theme', value),
|
||||
theme,
|
||||
);
|
||||
await page.goto('/');
|
||||
const welcome = page.getByRole('region', { name: '导入模型工程' });
|
||||
await expect(welcome).toBeVisible();
|
||||
expect(replaced).toBe(true);
|
||||
await expect(welcome).toHaveCSS('backdrop-filter', 'none');
|
||||
await expect(welcome).toHaveCSS(
|
||||
'background-color',
|
||||
theme === 'dark' ? 'rgb(11, 18, 36)' : 'rgb(251, 252, 254)',
|
||||
);
|
||||
await page.getByRole('button', { name: '打开命令面板' }).click();
|
||||
await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog', { name: '命令面板' })).toBeHidden();
|
||||
});
|
||||
}
|
||||
@@ -72,7 +72,24 @@ for (const theme of ['dark', 'light'] as const)
|
||||
await page.getByRole('button', { name: '连接/刷新' }).click();
|
||||
await expect(page.getByRole('img', { name: /收敛曲线/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '停止 Session' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /决策与审批 · 待审批 1/ })).toBeVisible();
|
||||
const sessions = page.getByRole('region', { name: '会话与 Trial' });
|
||||
const metrics = page.getByRole('main', { name: '指标与排行' });
|
||||
const decisions = page.getByRole('complementary', { name: '决策与审批' });
|
||||
await expect(sessions).toBeVisible();
|
||||
await expect(metrics).toBeVisible();
|
||||
await expect(decisions).toBeVisible();
|
||||
const left = (await sessions.boundingBox())!;
|
||||
const center = (await metrics.boundingBox())!;
|
||||
const right = (await decisions.boundingBox())!;
|
||||
if (width >= 1280) {
|
||||
expect(Math.abs(left.y - center.y)).toBeLessThan(2);
|
||||
expect(Math.abs(center.y - right.y)).toBeLessThan(2);
|
||||
expect(left.x + left.width).toBeLessThanOrEqual(center.x + 1);
|
||||
expect(center.x + center.width).toBeLessThanOrEqual(right.x + 1);
|
||||
} else {
|
||||
expect(left.y + left.height).toBeLessThanOrEqual(center.y + 1);
|
||||
expect(center.y + center.height).toBeLessThanOrEqual(right.y + 1);
|
||||
}
|
||||
await page.getByRole('button', { name: '收敛曲线放大', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: `切换到${theme === 'dark' ? '白天' : '黑夜'}主题` })
|
||||
@@ -88,12 +105,11 @@ for (const theme of ['dark', 'light'] as const)
|
||||
await page.getByRole('heading', { name: 'Trial Leaderboard' }).scrollIntoViewIfNeeded();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('tuning-leaderboard.png') });
|
||||
await page.getByRole('button', { name: '会话与 Trial' }).click();
|
||||
await page.getByRole('button', { name: '导入', exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole('button', { name: '导入', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('导入会替换主工作台策略;无主窗口时下载文件。')).toBeVisible();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('tuning-sessions.png') });
|
||||
await page.getByRole('button', { name: /决策与审批/ }).click();
|
||||
await page.getByRole('button', { name: 'Monaco Diff 审查' }).scrollIntoViewIfNeeded();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('tuning-decisions.png') });
|
||||
@@ -204,7 +220,6 @@ for (const theme of ['dark', 'light'] as const)
|
||||
await expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('diagnostics.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: '工程', exact: true }).click();
|
||||
await page.getByRole('button', { name: '源代码', exact: true }).click();
|
||||
const source = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
|
||||
await expect(source.locator('.monaco-editor')).toBeVisible();
|
||||
|
||||
@@ -2492,7 +2492,7 @@ export function App() {
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
className={`${theme === 'light' ? 'theme-light' : 'theme-dark'} flex h-screen min-w-0 flex-col overflow-hidden bg-app text-text-primary`}
|
||||
className={`${theme === 'light' ? 'theme-light' : 'theme-dark'} cyber-workspace flex h-screen min-w-0 flex-col overflow-hidden bg-app text-text-primary`}
|
||||
onDragEnter={dragEnter}
|
||||
onDragLeave={dragLeave}
|
||||
onDragOver={dragOver}
|
||||
|
||||
@@ -27,7 +27,12 @@ describe('第二批工作台组件', () => {
|
||||
it('空工作区解释导入到仿真的三步流程', () => {
|
||||
render(<EmptyWorkspace />);
|
||||
expect(screen.getByRole('region', { name: '导入模型工程' })).toBeVisible();
|
||||
fireEvent.click(screen.getByText('导入与仿真流程'));
|
||||
expect(screen.getByText('Local Simulation Workspace')).toBeVisible();
|
||||
expect(screen.getByText('本地解析、编译并运行机器人模型,无需上传资源')).toBeVisible();
|
||||
expect(screen.getByLabelText('支持格式')).toHaveTextContent('OBJ / STL / DAE');
|
||||
expect(screen.getByRole('button', { name: '选择文件' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '选择文件夹' })).toBeVisible();
|
||||
expect(screen.queryByText('导入与仿真流程')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('导入');
|
||||
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('检查与配置');
|
||||
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试');
|
||||
|
||||
@@ -18,7 +18,7 @@ export function SidebarPanel({
|
||||
return (
|
||||
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
|
||||
<aside
|
||||
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border-subtle `}
|
||||
className={`cyber-sidebar flex h-full w-full min-w-0 flex-col overflow-hidden ${side === 'left' ? 'border-r' : 'border-l'} border-border-subtle `}
|
||||
>
|
||||
<h2 className="flex h-9 shrink-0 items-center gap-2 border-b border-border-subtle px-3 text-xs font-semibold text-text-primary">
|
||||
<span className="text-accent [&>svg]:h-4 [&>svg]:w-4">
|
||||
|
||||
@@ -146,7 +146,7 @@ export function SourceEditorDialog({
|
||||
height: 'min(650px, calc(100vh - 32px))',
|
||||
}
|
||||
}
|
||||
className={`source-editor-window pointer-events-auto fixed flex min-h-64 min-w-0 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized ? 'inset-0 h-full w-full' : 'resize'}`}
|
||||
className={`source-editor-window pointer-events-auto fixed flex min-h-64 min-w-0 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex-col overflow-hidden border border-border-strong ${maximized ? 'inset-0 h-full w-full' : 'resize'}`}
|
||||
>
|
||||
<header
|
||||
className="flex min-h-11 flex-wrap shrink-0 py-2 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
|
||||
|
||||
@@ -9,9 +9,10 @@ export function ViewportHUD() {
|
||||
return (
|
||||
<div
|
||||
aria-label="视口状态"
|
||||
className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-panel/90 px-2 py-1 text-xs shadow-sm"
|
||||
className="engineering-glass flex flex-wrap items-center gap-2 rounded-lg border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
<Badge tone={!ready || paused ? 'neutral' : 'success'}>
|
||||
<span aria-hidden="true" className="hud-signal" data-running={ready && !paused} />
|
||||
{!ready || paused ? (
|
||||
<CirclePause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { WorkbenchHeader } from './WorkbenchHeader';
|
||||
const fn = () => {};
|
||||
describe('WorkbenchHeader', () => {
|
||||
it('保留导入、工程菜单、命令与侧栏入口,不再重复仿真控制', () => {
|
||||
it('保留导入、源代码直达、命令与侧栏入口,不再重复仿真控制', () => {
|
||||
const source = vi.fn(),
|
||||
files = vi.fn(),
|
||||
left = vi.fn();
|
||||
@@ -26,8 +26,8 @@ describe('WorkbenchHeader', () => {
|
||||
});
|
||||
expect(files).toHaveBeenCalledOnce();
|
||||
expect(screen.getByLabelText('打开文件夹')).toHaveAttribute('webkitdirectory');
|
||||
expect(screen.queryByRole('button', { name: '源代码' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '工程' }));
|
||||
expect(screen.getByRole('button', { name: '源代码' })).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: '工程' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '源代码' }));
|
||||
expect(source).toHaveBeenCalledOnce();
|
||||
fireEvent.click(screen.getByRole('button', { name: '隐藏工程面板' }));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import { Boxes, Code2, FolderOpen, PanelLeft, PanelRight, Search, Upload } from 'lucide-react';
|
||||
import { IconButton, Popover } from '../../components/ui';
|
||||
import { IconButton } from '../../components/ui';
|
||||
const fileActionClass =
|
||||
'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
|
||||
'cyber-action inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
|
||||
const fileActionLabelClass = 'whitespace-nowrap';
|
||||
export function WorkbenchHeader({
|
||||
loading,
|
||||
@@ -32,7 +32,7 @@ export function WorkbenchHeader({
|
||||
onCommands: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="workbench-header relative z-40 flex h-11 shrink-0 justify-between items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
|
||||
<header className="workbench-header relative z-40 flex h-11 shrink-0 justify-between items-center gap-2 border-b border-border px-2.5 ">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
|
||||
@@ -76,32 +76,15 @@ export function WorkbenchHeader({
|
||||
onChange={onFolder}
|
||||
/>
|
||||
</label>
|
||||
<Popover
|
||||
label="工程菜单"
|
||||
placement="bottom-left"
|
||||
trigger={({ toggle }) => (
|
||||
<button type="button" className={fileActionClass} onClick={toggle}>
|
||||
工程
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${fileActionClass} disabled:pointer-events-none disabled:opacity-40`}
|
||||
disabled={!hasProject || loading}
|
||||
onClick={onOpenSource}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="w-52 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center gap-2 rounded px-2 text-xs hover:bg-element-hover disabled:opacity-40"
|
||||
disabled={!hasProject || loading}
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenSource?.();
|
||||
}}
|
||||
>
|
||||
<Code2 className="h-4 w-4" />
|
||||
源代码
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
<Code2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
<span className={fileActionLabelClass}>源代码</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-0.5">
|
||||
<IconButton
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
LoaderCircle,
|
||||
PlayCircle,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Upload,
|
||||
UploadCloud,
|
||||
} from 'lucide-react';
|
||||
@@ -39,36 +41,78 @@ export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<section
|
||||
aria-label="导入模型工程"
|
||||
className="mx-auto w-full max-w-md rounded-lg border border-border bg-panel p-6 text-center"
|
||||
className="workspace-welcome relative w-full max-w-[600px] overflow-hidden rounded-[22px] border border-border-strong px-6 py-6 text-center sm:px-7"
|
||||
>
|
||||
<h2 className="text-base font-semibold">拖放模型工程到此处</h2>
|
||||
<p className="mt-2 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Upload className="h-4 w-4" />}
|
||||
onClick={() => document.getElementById('mujoco-project-files')?.click()}
|
||||
<div aria-hidden="true" className="welcome-glow" />
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex items-center justify-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-accent">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Local Simulation Workspace
|
||||
</div>
|
||||
<span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-accent/25 bg-accent-soft text-accent tool-active-glow">
|
||||
<UploadCloud className="h-6 w-6" />
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold tracking-tight text-text-primary">
|
||||
拖放模型工程到此处
|
||||
</h2>
|
||||
<p className="mt-1.5 text-xs leading-5 text-text-tertiary">
|
||||
本地解析、编译并运行机器人模型,无需上传资源
|
||||
</p>
|
||||
<div
|
||||
className="mt-4 flex flex-wrap items-center justify-center gap-1.5"
|
||||
aria-label="支持格式"
|
||||
>
|
||||
选择文件
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
onClick={() => document.getElementById('mujoco-project-folder')?.click()}
|
||||
{['MJCF / XML', 'URDF', 'ZIP', 'OBJ / STL / DAE'].map((format) => (
|
||||
<span
|
||||
key={format}
|
||||
className="rounded-full border border-border bg-surface/80 px-2.5 py-1 text-[10px] font-medium text-text-secondary"
|
||||
>
|
||||
{format}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Upload className="h-4 w-4" />}
|
||||
onClick={() => document.getElementById('mujoco-project-files')?.click()}
|
||||
>
|
||||
选择文件
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
onClick={() => document.getElementById('mujoco-project-folder')?.click()}
|
||||
>
|
||||
选择文件夹
|
||||
</Button>
|
||||
</div>
|
||||
<ol
|
||||
aria-label="仿真工作流程"
|
||||
className="mt-6 hidden grid-cols-3 gap-2 border-t border-border-subtle pt-4 sm:grid"
|
||||
>
|
||||
选择文件夹
|
||||
</Button>
|
||||
</div>
|
||||
<details className="domain-details mt-4 text-left">
|
||||
<summary>导入与仿真流程</summary>
|
||||
<ol aria-label="仿真工作流程" className="space-y-2 text-xs text-text-secondary">
|
||||
{workflow.map((item) => (
|
||||
<li key={item.label}>
|
||||
{item.label} · {item.detail}
|
||||
{workflow.map((item, index) => (
|
||||
<li
|
||||
key={item.label}
|
||||
className="engineering-card rounded-xl border border-border/70 px-3 py-3 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="technical-value text-[10px] font-semibold text-accent">
|
||||
0{index + 1}
|
||||
</span>
|
||||
<item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary" />
|
||||
<span className="text-xs font-semibold text-text-primary">{item.label}</span>
|
||||
</div>
|
||||
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
|
||||
{item.detail}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
<p className="mt-3 text-xs text-text-tertiary">模型与资源仅在当前浏览器会话中处理</p>
|
||||
<p className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
|
||||
<ShieldCheck aria-hidden="true" className="h-3.5 w-3.5 text-success" />
|
||||
模型与资源仅在当前浏览器会话中处理
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,10 +135,14 @@ export function WorkspaceToolsPanel({
|
||||
keepMounted
|
||||
badge={<ConsoleSectionBadges count={snapshot.actuators.length} />}
|
||||
>
|
||||
{snapshot.actuators.some((actuator) => ['motor', 'position'].includes(actuator.kind)) && (
|
||||
<p className="mb-2 text-xs text-warning">参数立即作用于模型,可随 MJCF 导出。</p>
|
||||
)}
|
||||
{snapshot.actuators.length ? (
|
||||
snapshot.actuators.map((actuator) => (
|
||||
<ActuatorControl
|
||||
key={actuator.id}
|
||||
showParameterNotice={false}
|
||||
actuator={actuator}
|
||||
onControl={(value) => onActuator(actuator.id, value)}
|
||||
onParameters={(parameters) => onActuatorParameters(actuator.id, parameters)}
|
||||
|
||||
@@ -20,7 +20,8 @@ export function Button({
|
||||
secondary: 'border-border bg-surface text-text-primary hover:bg-element-hover',
|
||||
ghost:
|
||||
'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary',
|
||||
danger: 'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white',
|
||||
danger:
|
||||
'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-[var(--ui-danger-contrast)]',
|
||||
};
|
||||
const sizes = {
|
||||
sm: 'h-control gap-1.5 rounded-md px-2 text-xs',
|
||||
@@ -30,7 +31,7 @@ export function Button({
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:hover:-translate-y-px enabled:active:translate-y-0 enabled:active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
|
||||
className={`cyber-action cyber-button-${variant} inline-flex shrink-0 select-none items-center justify-center border font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:hover:-translate-y-px enabled:active:translate-y-0 enabled:active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
|
||||
@@ -25,7 +25,7 @@ export function CollapsibleSection({
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
|
||||
className="cyber-section-heading flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -118,7 +118,7 @@ export function Dialog({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className={`relative flex w-full flex-col overflow-hidden border border-border bg-panel shadow-2xl focus:outline-none ${
|
||||
className={`cyber-dialog relative flex w-full flex-col overflow-hidden border border-border focus:outline-none ${
|
||||
placement === 'bottom'
|
||||
? 'max-h-[42vh] max-w-none rounded-t-xl border-x-0 border-b-0'
|
||||
: 'max-h-[80vh] max-w-lg rounded-xl'
|
||||
|
||||
@@ -17,7 +17,7 @@ export function IconButton({
|
||||
type={type}
|
||||
aria-label={props['aria-label'] ?? tooltip}
|
||||
aria-pressed={active || undefined}
|
||||
className={`inline-flex h-hit-compact w-hit-compact shrink-0 items-center justify-center rounded-md border transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'tool-active-glow border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
|
||||
className={`cyber-action inline-flex h-hit-compact w-hit-compact shrink-0 items-center justify-center rounded-md border transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'tool-active-glow border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ export function Popover({
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
style={{ ...style, zIndex: layer.zIndex }}
|
||||
className="ui-popover overflow-auto rounded-lg text-xs text-text-primary focus:outline-none"
|
||||
className="ui-popover engineering-glass overflow-auto rounded-lg text-xs text-text-primary focus:outline-none"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Tab' || !layer.isTop()) return;
|
||||
const items = Array.from(
|
||||
|
||||
@@ -46,7 +46,7 @@ export function Tabs<T extends string>({
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className="flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2"
|
||||
className="cyber-tabs flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
@@ -60,7 +60,7 @@ export function Tabs<T extends string>({
|
||||
disabled={item.disabled}
|
||||
onClick={() => onValueChange(item.value)}
|
||||
onKeyDown={navigate}
|
||||
className={`relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${item.value === active.value ? 'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent' : 'text-text-tertiary hover:text-text-primary'}`}
|
||||
className={`cyber-tab relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${item.value === active.value ? 'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent' : 'text-text-tertiary hover:text-text-primary'}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
|
||||
@@ -28,6 +28,23 @@ const actuator: ActuatorInfo = {
|
||||
};
|
||||
|
||||
describe('ActuatorControl', () => {
|
||||
it('列表模式不重复参数提示,不限幅输入保持紧凑且可编辑', () => {
|
||||
const onControl = vi.fn();
|
||||
render(
|
||||
<ActuatorControl
|
||||
actuator={{ ...actuator, ctrlLimited: false }}
|
||||
showParameterNotice={false}
|
||||
onControl={onControl}
|
||||
onParameters={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('参数立即作用于模型,可随 MJCF 导出。')).not.toBeInTheDocument();
|
||||
const input = screen.getByLabelText('控制输入(不限幅)');
|
||||
expect(input.parentElement).toHaveClass('grid-cols-[minmax(0,1fr)_6rem]');
|
||||
fireEvent.change(input, { target: { value: '3' } });
|
||||
fireEvent.blur(input);
|
||||
expect(onControl).toHaveBeenCalledWith(3);
|
||||
});
|
||||
it('显示对应关节和常用力矩单位', () => {
|
||||
render(<ActuatorControl actuator={actuator} onControl={() => {}} onParameters={() => {}} />);
|
||||
expect(screen.getByText('shoulder_motor')).toBeVisible();
|
||||
|
||||
@@ -5,8 +5,10 @@ export function ActuatorControl({
|
||||
actuator,
|
||||
onControl,
|
||||
onParameters,
|
||||
showParameterNotice = true,
|
||||
}: {
|
||||
actuator: ActuatorInfo;
|
||||
showParameterNotice?: boolean;
|
||||
onControl: (value: number) => void;
|
||||
onParameters: (parameters: ActuatorParameters) => void;
|
||||
}) {
|
||||
@@ -53,8 +55,8 @@ export function ActuatorControl({
|
||||
controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
|
||||
gearSquared = actuator.gear * actuator.gear;
|
||||
return (
|
||||
<div className="mb-3 border-b border-border pb-3">
|
||||
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="mb-2 border-b border-border pb-2">
|
||||
<div className="mb-1 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}
|
||||
@@ -67,6 +69,7 @@ export function ActuatorControl({
|
||||
</div>
|
||||
{actuator.controlCount === 1 ? (
|
||||
<ControlSlider
|
||||
compact
|
||||
label={outputLabel}
|
||||
value={outputValue}
|
||||
min={outputMin}
|
||||
@@ -84,19 +87,20 @@ export function ActuatorControl({
|
||||
</p>
|
||||
)}
|
||||
{actuator.controlCount === 1 && !actuator.ctrlLimited && (
|
||||
<div className="mb-2">
|
||||
<div className="mb-1">
|
||||
<ParameterInput
|
||||
inline
|
||||
label={`${controlLabel}输入(不限幅)`}
|
||||
value={actuator.value * targetScale}
|
||||
onCommit={(value) => onControl(value / targetScale)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
{editable && showParameterNotice && (
|
||||
<p className="mb-2 text-xs text-warning">参数立即作用于模型,可随 MJCF 导出。</p>
|
||||
)}
|
||||
{editable ? (
|
||||
<details className="group border-t border-border pt-2">
|
||||
<details className="group pt-1">
|
||||
<summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">
|
||||
增益与输出限幅
|
||||
</summary>
|
||||
@@ -191,7 +195,9 @@ function ParameterInput({
|
||||
value,
|
||||
onCommit,
|
||||
disabled = false,
|
||||
inline = false,
|
||||
}: {
|
||||
inline?: boolean;
|
||||
label: string;
|
||||
value: number;
|
||||
onCommit: (value: number) => void;
|
||||
@@ -200,6 +206,8 @@ function ParameterInput({
|
||||
return (
|
||||
<ScrubbableNumberInput
|
||||
label={label}
|
||||
containerClassName={inline ? 'grid grid-cols-[minmax(0,1fr)_6rem] items-center gap-2' : ''}
|
||||
labelClassName={inline ? '!mb-0' : ''}
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
step={Math.max(0.001, Math.abs(value) * 0.01)}
|
||||
disabled={disabled}
|
||||
@@ -264,7 +272,9 @@ export function ControlSlider({
|
||||
limitsIgnored = false,
|
||||
limitMin = 0,
|
||||
limitMax = 0,
|
||||
compact = false,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
@@ -281,7 +291,7 @@ export function ControlSlider({
|
||||
const sane = Number.isFinite(value) ? value : 0,
|
||||
format = (number: number) => `${number.toFixed(3)}${unit}`;
|
||||
return (
|
||||
<label className="mb-3 block text-xs">
|
||||
<label className={`${compact ? 'mb-1' : '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>
|
||||
|
||||
+203
-41
@@ -29,10 +29,13 @@
|
||||
--ui-text-primary: #102033;
|
||||
--ui-text-secondary: #3a4d62;
|
||||
--ui-text-tertiary: #57687b;
|
||||
--ui-accent: #138462;
|
||||
--ui-accent-hover: #0d6d50;
|
||||
--ui-accent-soft: #ddf5ec;
|
||||
--ui-accent: #087887;
|
||||
--ui-accent-hover: #06616e;
|
||||
--ui-accent-soft: #def3f6;
|
||||
--ui-violet: #7450b5;
|
||||
--ui-glow: rgb(8 120 135 / 12%);
|
||||
--ui-danger: #c53b45;
|
||||
--ui-danger-contrast: #fff;
|
||||
--ui-danger-soft: #fff0f1;
|
||||
--ui-danger-border: #f1b9bd;
|
||||
--ui-warning: #a76612;
|
||||
@@ -49,25 +52,28 @@
|
||||
color-scheme: light;
|
||||
}
|
||||
.theme-dark {
|
||||
--ui-accent-contrast: #071c14;
|
||||
--ui-bg: #070b12;
|
||||
--ui-panel: #0d141f;
|
||||
--ui-panel-muted: #101a28;
|
||||
--ui-surface: #121d2a;
|
||||
--ui-surface-elevated: #182536;
|
||||
--ui-input: #090f18;
|
||||
--ui-hover: #172638;
|
||||
--ui-active: #1d3147;
|
||||
--ui-border: #1e2d3e;
|
||||
--ui-border-subtle: #162333;
|
||||
--ui-border-strong: #668099;
|
||||
--ui-text-primary: #eaf2fb;
|
||||
--ui-text-secondary: #b7c5d5;
|
||||
--ui-text-tertiary: #94a6ba;
|
||||
--ui-accent: #38d39f;
|
||||
--ui-accent-hover: #2db789;
|
||||
--ui-accent-soft: #123b31;
|
||||
--ui-accent-contrast: #04171e;
|
||||
--ui-bg: #060914;
|
||||
--ui-panel: #0b1224;
|
||||
--ui-panel-muted: #101a2d;
|
||||
--ui-surface: #121e33;
|
||||
--ui-surface-elevated: #19283e;
|
||||
--ui-input: #080f1e;
|
||||
--ui-hover: #1a3047;
|
||||
--ui-active: #203d54;
|
||||
--ui-border: #2a4058;
|
||||
--ui-border-subtle: #1b2b42;
|
||||
--ui-border-strong: #70869e;
|
||||
--ui-text-primary: #edf6ff;
|
||||
--ui-text-secondary: #becfe2;
|
||||
--ui-text-tertiary: #9baec6;
|
||||
--ui-accent: #36e4f2;
|
||||
--ui-accent-hover: #7eeef7;
|
||||
--ui-accent-soft: #10343f;
|
||||
--ui-violet: #a78bfa;
|
||||
--ui-glow: rgb(54 228 242 / 16%);
|
||||
--ui-danger: #ff7282;
|
||||
--ui-danger-contrast: #260b12;
|
||||
--ui-danger-soft: #391b24;
|
||||
--ui-danger-border: #68303b;
|
||||
--ui-warning: #ffad42;
|
||||
@@ -78,7 +84,7 @@
|
||||
--ui-success-border: #275d50;
|
||||
--ui-scrollbar: #34475c;
|
||||
--ui-scrollbar-hover: #536b84;
|
||||
--ui-glass: rgb(8 15 24 / 82%);
|
||||
--ui-glass: rgb(11 18 36 / 88%);
|
||||
--ui-glass-highlight: rgb(255 255 255 / 7%);
|
||||
--ui-shadow: rgb(0 0 0 / 48%);
|
||||
color-scheme: dark;
|
||||
@@ -140,13 +146,30 @@
|
||||
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cyber-workspace {
|
||||
background:
|
||||
radial-gradient(ellipse at 8% 0%, var(--ui-glow), transparent 45%),
|
||||
radial-gradient(
|
||||
ellipse at 95% 100%,
|
||||
color-mix(in srgb, var(--ui-violet) 7%, transparent),
|
||||
transparent 45%
|
||||
),
|
||||
var(--ui-bg);
|
||||
}
|
||||
.workbench-header {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--ui-panel) 94%, var(--ui-surface)),
|
||||
var(--ui-panel) 42%,
|
||||
color-mix(in srgb, var(--ui-panel) 88%, var(--ui-accent) 12%)
|
||||
);
|
||||
background:
|
||||
linear-gradient(100deg, var(--ui-glass-highlight), transparent 35%), var(--ui-panel);
|
||||
border-bottom-color: color-mix(in srgb, var(--ui-accent) 24%, var(--ui-border));
|
||||
box-shadow: 0 1px 18px var(--ui-glow);
|
||||
}
|
||||
/* 长列表侧栏采用静态玻璃质感,避免滚动时持续采样大面积背景。 */
|
||||
.cyber-sidebar {
|
||||
background:
|
||||
linear-gradient(180deg, var(--ui-glass-highlight), transparent 180px), var(--ui-panel);
|
||||
}
|
||||
.cyber-sidebar > h2 {
|
||||
background: linear-gradient(90deg, var(--ui-glow), transparent 85%);
|
||||
box-shadow: inset 2px 0 var(--ui-accent);
|
||||
}
|
||||
.viewport-shell {
|
||||
isolation: isolate;
|
||||
@@ -166,15 +189,33 @@
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 2%), transparent 22%, rgb(0 0 0 / 7%));
|
||||
}
|
||||
.engineering-glass {
|
||||
border-color: color-mix(in srgb, var(--ui-border-strong) 72%, transparent);
|
||||
.engineering-glass,
|
||||
.workspace-welcome,
|
||||
.cyber-dialog,
|
||||
.source-editor-window {
|
||||
border-color: color-mix(in srgb, var(--ui-accent) 22%, var(--ui-border));
|
||||
background:
|
||||
linear-gradient(135deg, var(--ui-glass-highlight), transparent 42%), var(--ui-glass);
|
||||
linear-gradient(135deg, var(--ui-glass-highlight), transparent 42%), var(--ui-panel);
|
||||
box-shadow:
|
||||
0 16px 44px var(--ui-shadow),
|
||||
inset 0 1px 0 color-mix(in srgb, white 10%, transparent),
|
||||
inset 0 -1px 0 color-mix(in srgb, black 10%, transparent);
|
||||
backdrop-filter: blur(16px) saturate(135%);
|
||||
0 0 24px color-mix(in srgb, var(--ui-glow) 45%, transparent),
|
||||
inset 0 1px 0 var(--ui-glass-highlight);
|
||||
}
|
||||
@supports (backdrop-filter: blur(1px)) {
|
||||
.engineering-glass,
|
||||
.workspace-welcome,
|
||||
.cyber-dialog,
|
||||
.source-editor-window,
|
||||
.workbench-header {
|
||||
background-color: var(--ui-glass);
|
||||
background-image: linear-gradient(135deg, var(--ui-glass-highlight), transparent 42%);
|
||||
backdrop-filter: blur(12px) saturate(120%);
|
||||
}
|
||||
/* 内层表面不重复采样背景,代码和图表区域维持实底。 */
|
||||
.cyber-sidebar .engineering-glass,
|
||||
.cyber-dialog .engineering-glass {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
.engineering-card {
|
||||
border-color: color-mix(in srgb, var(--ui-border) 88%, transparent);
|
||||
@@ -183,10 +224,14 @@
|
||||
color-mix(in srgb, var(--ui-surface) 88%, transparent),
|
||||
color-mix(in srgb, var(--ui-panel) 72%, transparent)
|
||||
);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--ui-glass-highlight),
|
||||
0 4px 16px color-mix(in srgb, var(--ui-shadow) 25%, transparent);
|
||||
}
|
||||
.tool-active-glow {
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--ui-accent) 14%, transparent);
|
||||
box-shadow:
|
||||
inset 0 0 12px var(--ui-glow),
|
||||
0 0 12px var(--ui-glow);
|
||||
}
|
||||
.engineering-badge {
|
||||
background-image: linear-gradient(180deg, rgb(255 255 255 / 5%), transparent);
|
||||
@@ -201,6 +246,74 @@
|
||||
.draft-dirty-dot {
|
||||
animation: draft-dirty-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.cyber-action {
|
||||
transition-property: color, background-color, border-color, transform, box-shadow;
|
||||
}
|
||||
.cyber-button-primary {
|
||||
background-image: linear-gradient(125deg, rgb(0 0 0 / 6%), transparent 65%);
|
||||
box-shadow:
|
||||
0 2px 12px var(--ui-glow),
|
||||
inset 0 1px 0 rgb(255 255 255 / 20%);
|
||||
}
|
||||
.cyber-action:focus-visible,
|
||||
.cyber-action:focus-within {
|
||||
box-shadow: 0 0 0 3px var(--ui-glow);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.cyber-action:not(:disabled, [aria-disabled='true']):hover {
|
||||
border-color: var(--ui-accent);
|
||||
box-shadow:
|
||||
0 0 16px var(--ui-glow),
|
||||
inset 0 1px 0 var(--ui-glass-highlight);
|
||||
}
|
||||
.cyber-button-danger:not(:disabled):hover {
|
||||
border-color: var(--ui-danger);
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--ui-danger) 16%, transparent);
|
||||
}
|
||||
.engineering-card:has(> button):hover {
|
||||
border-color: color-mix(in srgb, var(--ui-accent) 45%, var(--ui-border));
|
||||
}
|
||||
}
|
||||
.cyber-action:is(:disabled, [aria-disabled='true']) {
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
.cyber-tabs {
|
||||
background-image: linear-gradient(90deg, var(--ui-glow), transparent 60%);
|
||||
}
|
||||
.cyber-tab {
|
||||
border-radius: 6px 6px 0 0;
|
||||
transition:
|
||||
background-color var(--ui-duration),
|
||||
color var(--ui-duration);
|
||||
}
|
||||
.cyber-tab[aria-selected='true'] {
|
||||
background: linear-gradient(0deg, var(--ui-glow), transparent);
|
||||
}
|
||||
.cyber-tab[aria-selected='true']::after {
|
||||
box-shadow: 0 0 8px var(--ui-glow);
|
||||
}
|
||||
.cyber-section-heading[aria-expanded='true'] {
|
||||
background-image: linear-gradient(90deg, var(--ui-glow), transparent 85%);
|
||||
color: var(--ui-text-primary);
|
||||
}
|
||||
.hud-signal {
|
||||
position: relative;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.hud-signal[data-running='true']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
animation: hud-breathe 3.6s ease-in-out infinite;
|
||||
}
|
||||
.domain-details {
|
||||
@apply min-w-0 border-t border-border-subtle py-2 text-xs text-text-secondary;
|
||||
}
|
||||
@@ -250,9 +363,20 @@
|
||||
}
|
||||
.workspace-welcome {
|
||||
animation: workspace-enter 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
box-shadow:
|
||||
0 28px 80px rgb(2 8 23 / 28%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 5%);
|
||||
}
|
||||
.workspace-welcome::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 15% auto;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--ui-accent),
|
||||
var(--ui-violet),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.welcome-glow {
|
||||
position: absolute;
|
||||
@@ -267,6 +391,17 @@
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@keyframes hud-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.65;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
@keyframes map-tool-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -321,12 +456,36 @@
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
animation: none !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.engineering-glass,
|
||||
.engineering-card,
|
||||
.workspace-welcome,
|
||||
.cyber-dialog,
|
||||
.cyber-action,
|
||||
.source-editor-window {
|
||||
border: 1px solid CanvasText;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.cyber-tab[aria-selected='true'] {
|
||||
outline: 1px solid Highlight;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.hud-signal {
|
||||
background: CanvasText;
|
||||
}
|
||||
.welcome-glow,
|
||||
.workspace-welcome::after,
|
||||
.hud-signal::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 视口尺寸而非窗口尺寸决定浮层避让,双侧栏打开时仍保留完整操作。 */
|
||||
.viewport-shell {
|
||||
container-type: inline-size;
|
||||
@@ -390,6 +549,9 @@
|
||||
border: 1px solid var(--ui-border-strong);
|
||||
border-radius: 12px;
|
||||
background: var(--ui-panel);
|
||||
box-shadow:
|
||||
0 6px 24px var(--ui-shadow),
|
||||
inset 0 1px 0 var(--ui-glass-highlight);
|
||||
}
|
||||
.sensor-camera-frame {
|
||||
position: relative;
|
||||
|
||||
@@ -270,7 +270,7 @@ describe('LocalTrainingPanel 自定义任务', () => {
|
||||
const fetchMock = customServer();
|
||||
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
|
||||
await connectCustom();
|
||||
fireEvent.click(screen.getByRole('button', { name: '检查场景地图' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '同步场景地图' }));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('没有已应用');
|
||||
fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'plane' } });
|
||||
fireEvent.change(screen.getByLabelText('地图尺寸 m'), { target: { value: '100' } });
|
||||
@@ -347,7 +347,7 @@ describe('LocalTrainingPanel 配套策略交接', () => {
|
||||
expect(screen.getByLabelText('训练地形')).toHaveValue('custom_boxes');
|
||||
expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('目标 Y')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '检查场景地图' })).toHaveAttribute(
|
||||
expect(screen.getByRole('button', { name: '同步场景地图' })).toHaveAttribute(
|
||||
'title',
|
||||
expect.stringContaining('旋转障碍会膨胀'),
|
||||
);
|
||||
@@ -420,13 +420,13 @@ it('custom_boxes拒绝未应用草稿,且训练时自动重新编译最新地
|
||||
/>,
|
||||
);
|
||||
await connectCustom();
|
||||
fireEvent.click(screen.getByRole('button', { name: '检查场景地图' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '同步场景地图' }));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('草稿');
|
||||
expect(compileScene).not.toHaveBeenCalled();
|
||||
rerender(
|
||||
<LocalTrainingPanel onPolicyReady={vi.fn()} sceneMaps={assets} compileScene={compileScene} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '检查场景地图' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '同步场景地图' }));
|
||||
expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('目标 X')).not.toBeInTheDocument();
|
||||
current = { ...current, boxes: [current.boxes[0], { ...current.boxes[1], pos: [1, 3, 0.5] }] };
|
||||
|
||||
@@ -607,7 +607,7 @@ export function LocalTrainingPanel({
|
||||
title="从已应用实例编译并校验世界 AABB;旋转障碍会膨胀为轴对齐包围盒"
|
||||
onClick={syncMap}
|
||||
>
|
||||
检查场景地图
|
||||
同步场景地图
|
||||
</Button>
|
||||
{terrainPreset === 'custom_boxes' && customTerrainBoxes && !sceneDirty && (
|
||||
<p role="status">
|
||||
|
||||
@@ -245,7 +245,7 @@ function MultiTrialPlot({
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-border bg-app/80 shadow-[inset_0_1px_0_rgb(255_255_255/0.025)]">
|
||||
<section className="engineering-card overflow-hidden rounded-xl border border-border">
|
||||
<header className="flex min-h-10 flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-1.5 break-all text-xs font-semibold">
|
||||
|
||||
@@ -77,9 +77,9 @@ export function TuningApp() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`theme-${theme} flex h-full min-h-0 flex-col overflow-x-hidden overflow-y-auto bg-app text-text-primary`}
|
||||
className={`theme-${theme} cyber-workspace flex h-full min-h-0 flex-col overflow-x-hidden overflow-y-auto bg-app text-text-primary`}
|
||||
>
|
||||
<header className="flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border bg-surface px-5 py-2.5">
|
||||
<header className="workbench-header flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-2.5">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-base font-semibold">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg border border-success-border bg-accent-soft">
|
||||
@@ -120,7 +120,7 @@ export function TuningApp() {
|
||||
</header>
|
||||
|
||||
{!sessionId && (
|
||||
<section className="grid shrink-0 gap-3 border-b border-border bg-panel/70 p-3 lg:grid-cols-[1fr_1fr_auto]">
|
||||
<section className="engineering-card grid shrink-0 gap-3 border-b border-border p-3 lg:grid-cols-[1fr_1fr_auto]">
|
||||
<Field label="训练服务地址">
|
||||
<input
|
||||
aria-label="训练服务地址"
|
||||
@@ -277,7 +277,7 @@ function NewSessionView({
|
||||
return (
|
||||
<main className="flex-1 p-4">
|
||||
<div className="mx-auto grid max-w-6xl items-start gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<section className="border-b border-border bg-surface p-4">
|
||||
<section className="engineering-card rounded-xl border border-border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">新建 {taskId} 调参 Session</h2>
|
||||
@@ -456,7 +456,7 @@ function NewSessionView({
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-border bg-surface p-4">
|
||||
<section className="engineering-card rounded-xl border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold">历史 Sessions</h2>
|
||||
<Badge>{sessions.length}</Badge>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../components/ui';
|
||||
import { PretrainedIdentity } from '../training/PretrainedSourceSelect';
|
||||
import { useTuningStore } from './tuningStore';
|
||||
import { AgentDecisionTimeline } from './AgentDecisionTimeline';
|
||||
@@ -12,59 +10,34 @@ import { useTuningPolling } from './useTuningPolling';
|
||||
export function TuningConsole() {
|
||||
useTuningPolling();
|
||||
const pretrained = useTuningStore((state) => state.sessionConfig?.pretrained);
|
||||
const pending = useTuningStore(
|
||||
(state) =>
|
||||
state.proposalIds.filter((id) => state.proposalsById[id]?.state === 'pending').length,
|
||||
);
|
||||
const [side, setSide] = useState<'sessions' | 'decisions' | null>(null);
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<TuningControlToolbar />
|
||||
<div className="px-3">
|
||||
<PretrainedIdentity source={pretrained} />
|
||||
</div>
|
||||
<nav
|
||||
aria-label="调参详情"
|
||||
className="flex flex-wrap items-center gap-2 border-b border-border p-3"
|
||||
>
|
||||
<Button
|
||||
aria-expanded={side === 'sessions'}
|
||||
aria-controls="tuning-sessions"
|
||||
onClick={() => setSide(side === 'sessions' ? null : 'sessions')}
|
||||
<div className="grid min-w-0 grid-cols-1 xl:min-h-0 xl:flex-1 xl:grid-cols-[286px_minmax(520px,1fr)_390px]">
|
||||
<div
|
||||
id="tuning-sessions"
|
||||
role="region"
|
||||
aria-label="会话与 Trial"
|
||||
className="cyber-sidebar min-h-0 min-w-0 border-b border-border xl:border-b-0 xl:border-r"
|
||||
>
|
||||
会话与 Trial
|
||||
</Button>
|
||||
<Button
|
||||
variant={pending ? 'primary' : 'secondary'}
|
||||
aria-expanded={side === 'decisions'}
|
||||
aria-controls="tuning-decisions"
|
||||
onClick={() => setSide(side === 'decisions' ? null : 'decisions')}
|
||||
>
|
||||
决策与审批{pending ? ` · 待审批 ${pending}` : ''}
|
||||
</Button>
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{pending ? '审批后将调度候选训练;请先审查参数变化。' : '指标与排行随当前会话更新'}
|
||||
</span>
|
||||
</nav>
|
||||
<div
|
||||
className={`grid min-w-0 ${side ? 'lg:grid-cols-[minmax(0,1fr)_minmax(300px,380px)]' : ''}`}
|
||||
>
|
||||
<main className="min-w-0 bg-app p-3">
|
||||
<TuningSessionRail />
|
||||
</div>
|
||||
<main aria-label="指标与排行" className="min-h-0 min-w-0 p-3 panel-scroll xl:overflow-auto">
|
||||
<div className="mx-auto max-w-[1500px] space-y-4">
|
||||
<MetricsComparisonBoard />
|
||||
<TuningLeaderboard />
|
||||
</div>
|
||||
</main>
|
||||
<div
|
||||
className={`min-w-0 border-l border-border bg-panel ${side ? 'max-lg:row-start-1' : 'hidden'}`}
|
||||
<aside
|
||||
id="tuning-decisions"
|
||||
aria-label="决策与审批"
|
||||
className="cyber-sidebar min-h-0 min-w-0 border-t border-border p-3 panel-scroll xl:overflow-auto xl:border-l xl:border-t-0"
|
||||
>
|
||||
<div id="tuning-sessions" hidden={side !== 'sessions'}>
|
||||
<TuningSessionRail />
|
||||
</div>
|
||||
<div id="tuning-decisions" hidden={side !== 'decisions'} className="p-3">
|
||||
<AgentDecisionTimeline />
|
||||
</div>
|
||||
</div>
|
||||
<AgentDecisionTimeline />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ function AshaPromotionTree() {
|
||||
});
|
||||
void entitiesRevision;
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-app/70 p-2.5">
|
||||
<section className="engineering-card rounded-lg border border-border p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-text-secondary">
|
||||
<GitBranch className="h-3.5 w-3.5 text-accent" /> ASHA 晋级树
|
||||
@@ -164,7 +164,7 @@ export function TuningSessionRail() {
|
||||
void entitiesRevision;
|
||||
|
||||
return (
|
||||
<aside className="flex min-w-0 flex-col bg-panel">
|
||||
<aside className="flex min-h-0 min-w-0 flex-col xl:h-full">
|
||||
<div className="shrink-0 border-b border-border p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
|
||||
Reference in New Issue
Block a user